diff --git a/packages/pds-core/src/__tests__/chooser-enrichment.test.ts b/packages/pds-core/src/__tests__/chooser-enrichment.test.ts index 53ae36bf..bf680cc7 100644 --- a/packages/pds-core/src/__tests__/chooser-enrichment.test.ts +++ b/packages/pds-core/src/__tests__/chooser-enrichment.test.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it, vi } from 'vitest' import { appendScriptHashToCsp, @@ -30,6 +31,279 @@ describe('buildChooserEnrichmentScript (HYPER-268)', () => { }) }) +class FakeTextNode { + readonly nodeType = 3 + + constructor(readonly data: string) {} +} + +class FakeClassList { + private readonly classes = new Set() + + add(className: string): void { + this.classes.add(className) + } + + contains(className: string): boolean { + return this.classes.has(className) + } + + set(value: string): void { + this.classes.clear() + for (const className of value.split(/\s+/)) { + if (className) this.classes.add(className) + } + } +} + +class FakeElement { + readonly nodeType = 1 + readonly childNodes: Array = [] + readonly dataset: Record = {} + readonly classList = new FakeClassList() + readonly style: Record = {} + parentElement: FakeElement | null = null + textContentOverride: string | null = null + + constructor( + readonly tagName: string, + private readonly attributes: Record = {}, + ) {} + + appendChild(child: FakeElement | FakeTextNode): void { + if (child instanceof FakeElement) { + child.parentElement = this + } + this.childNodes.push(child) + } + + set textContent(value: string) { + this.textContentOverride = value + } + + set className(value: string) { + this.classList.set(value) + } + + get textContent(): string { + if (this.textContentOverride !== null) return this.textContentOverride + return this.childNodes + .map((child) => + child instanceof FakeTextNode ? child.data : child.textContent, + ) + .join('') + } + + closest(selector: string): FakeElement | null { + if (selector !== '[role="button"][tabindex="0"]') return null + + if (this.attributes.role === 'button' && this.attributes.tabindex === '0') { + return this + } + + let current = this.parentElement + while (current) { + if ( + current.attributes.role === 'button' && + current.attributes.tabindex === '0' + ) { + return current + } + current = current.parentElement + } + return null + } + + querySelectorAll(selector: string): FakeElement[] { + const descendants = this.descendants() + if (selector === 'button, a') { + return descendants.filter( + (el) => el.tagName === 'button' || el.tagName === 'a', + ) + } + if (selector === '[role="button"]') { + return descendants.filter((el) => el.attributes.role === 'button') + } + return [] + } + + querySelector(selector: string): FakeElement | null { + if ( + selector === + '[role="button"][aria-label="Login to account that is not listed"]' + ) { + return ( + this.descendants().find( + (el) => + el.attributes.role === 'button' && + el.attributes['aria-label'] === + 'Login to account that is not listed', + ) ?? null + ) + } + return null + } + + descendants(): FakeElement[] { + const result: FakeElement[] = [] + const visit = (node: FakeElement): void => { + for (const child of node.childNodes) { + if (child instanceof FakeElement) { + result.push(child) + visit(child) + } + } + } + visit(this) + return result + } +} + +class FakeDocument { + readonly root = new FakeElement('div', { id: 'root' }) + readyState = 'loading' + private domContentLoadedListener: (() => void) | null = null + + get documentElement(): FakeElement { + return this.root + } + + getElementById(id: string): FakeElement | null { + return id === 'root' ? this.root : null + } + + createTreeWalker(root: FakeElement): { nextNode: () => FakeElement | null } { + const elements = root.descendants() + let index = 0 + return { + nextNode: () => elements[index++] ?? null, + } + } + + createElement(tagName: string): FakeElement { + return new FakeElement(tagName) + } + + querySelector(): null { + return null + } + + addEventListener(event: string, listener: () => void): void { + if (event === 'DOMContentLoaded') this.domContentLoadedListener = listener + } + + dispatchDOMContentLoaded(): void { + this.readyState = 'complete' + this.domContentLoadedListener?.() + } +} + +function appendText(parent: FakeElement, text: string): void { + parent.appendChild(new FakeTextNode(text)) +} + +function runChooserEnrichmentScript(document: FakeDocument): void { + const fakeWindow: Record = { + location: { search: '' }, + } + const sandbox = { + document, + MutationObserver: class { + observed = false + + observe(): void { + this.observed = true + } + }, + Node: { TEXT_NODE: 3 }, + NodeFilter: { SHOW_ELEMENT: 1 }, + URLSearchParams, + window: fakeWindow, + } + + runInNewContext(buildChooserEnrichmentScript(), sandbox) // NOSONAR — test executes only the deterministic script generated in this repository. + fakeWindow.__sessions = [ + { + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + preferred_username: 'alice.test', + }, + }, + { + account: { + sub: 'did:plc:bob', + email: 'bob@example.test', + preferred_username: 'bob.test', + }, + }, + ] + document.dispatchDOMContentLoaded() +} + +describe('buildChooserEnrichmentScript account row scoping', () => { + it('does not enrich consent copy outside a chooser account row', () => { + const document = new FakeDocument() + const paragraph = new FakeElement('p') + const consentHandle = new FakeElement('span') + appendText(consentHandle, 'alice.test') + paragraph.appendChild(consentHandle) + appendText(paragraph, ' grants access to did:plc:alice') + document.root.appendChild(paragraph) + + runChooserEnrichmentScript(document) + + expect(document.root.descendants()).not.toContainEqual( + expect.objectContaining({ textContentOverride: 'alice@example.test' }), + ) + expect(consentHandle.classList.contains('epds-handle-label')).toBe(false) + }) + + it('enriches a chooser-like account row', () => { + const document = new FakeDocument() + const row = new FakeElement('div', { role: 'button', tabindex: '0' }) + const wrap = new FakeElement('span') + const handle = new FakeElement('span') + appendText(handle, 'alice.test') + wrap.appendChild(handle) + row.appendChild(wrap) + document.root.appendChild(row) + + runChooserEnrichmentScript(document) + + const emailLabel = wrap.childNodes.find( + (child) => + child instanceof FakeElement && + child.classList.contains('epds-email-label'), + ) as FakeElement | undefined + + expect(emailLabel).toBeInstanceOf(FakeElement) + expect(emailLabel?.textContent).toBe('alice@example.test') + expect(handle.classList.contains('epds-handle-label')).toBe(true) + }) + + it('enriches multiple chooser-like account rows', () => { + const document = new FakeDocument() + const rows = ['alice.test', 'bob.test'].map((handleText) => { + const row = new FakeElement('div', { role: 'button', tabindex: '0' }) + const wrap = new FakeElement('span') + const handle = new FakeElement('span') + appendText(handle, handleText) + wrap.appendChild(handle) + row.appendChild(wrap) + document.root.appendChild(row) + return { wrap, handle } + }) + + runChooserEnrichmentScript(document) + + expect(rows[0].wrap.textContent).toContain('alice@example.test') + expect(rows[1].wrap.textContent).toContain('bob@example.test') + expect(rows[0].handle.classList.contains('epds-handle-label')).toBe(true) + expect(rows[1].handle.classList.contains('epds-handle-label')).toBe(true) + }) +}) + describe('sha256Base64', () => { it('produces a stable SHA256 base64 hash', () => { // Known value for the empty string. diff --git a/packages/pds-core/src/chooser-enrichment.ts b/packages/pds-core/src/chooser-enrichment.ts index 6a38778f..dbf76ecd 100644 --- a/packages/pds-core/src/chooser-enrichment.ts +++ b/packages/pds-core/src/chooser-enrichment.ts @@ -180,6 +180,9 @@ export function buildChooserEnrichmentScript(): string { if (email) matches.push({ el: node, email: email }); } matches.forEach(function(m) { + // oauth-provider-ui 0.4.3 renders chooser rows this way; revisit if the upstream PDS UI is upgraded. + var row = m.el.closest('[role="button"][tabindex="0"]'); + if (!row) return; // Upstream wraps the handle span in a flex-row container: // // HANDLE