diff --git a/.changeset/email-first-account-presentation.md b/.changeset/email-first-account-presentation.md new file mode 100644 index 00000000..ce6e8a34 --- /dev/null +++ b/.changeset/email-first-account-presentation.md @@ -0,0 +1,14 @@ +--- +'ePDS': patch +--- + +Sign-in screens now show your email as the main way to recognise an account, with the public handle explained alongside it. + +**Affects:** End users + +**End users:** + +- When your handle was generated for you rather than chosen, the app approval, account chooser, and account-management screens now lead with your email address, so you can tell your accounts apart. +- The generated handle is still available next to it, behind an information icon that explains what a public handle is and which email it belongs to. +- The information icon works with hover, keyboard focus, and tap, stays open when you tap or click it, and closes again with Escape. +- The final approval step no longer briefly shows a generated handle before settling on your email. diff --git a/e2e/step-definitions/consent.steps.ts b/e2e/step-definitions/consent.steps.ts index 45eeb01a..89620bea 100644 --- a/e2e/step-definitions/consent.steps.ts +++ b/e2e/step-definitions/consent.steps.ts @@ -9,6 +9,7 @@ import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import type { EpdsWorld } from '../support/world.js' import { testEnv } from '../support/env.js' import { @@ -35,6 +36,66 @@ import { fillOtp } from '../support/otp.js' // Note: When('the user clicks {string}') lives in common.steps.ts — it is a // generic UI interaction step used here for "Authorize" and "Deny access" buttons. +function requireScenarioEmail(world: EpdsWorld): string { + if (!world.testEmail) { + throw new Error( + 'No test email set — "a returning user has a PDS account" step must run first', + ) + } + return world.testEmail +} + +function requireScenarioHandle(world: EpdsWorld): string { + if (!world.userHandle) { + throw new Error( + 'No user handle set — "a returning user has a PDS account" step must run first', + ) + } + return world.userHandle +} + +function formatPublicHandle(handle: string): string { + return handle.startsWith('@') ? handle : `@${handle}` +} + +function formatRawPublicHandle(handle: string): string { + return handle.startsWith('@') ? handle.slice(1) : handle +} + +function escapeRegex(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`) +} + +async function openIdentityTooltip(page: Page): Promise { + // .first(): enrichment walks every matching identity node, so a consent + // page rendering more than one approved phrasing ("Grant access to your + // X account" / "... wants to access your X account") gets an icon on + // each. Without this the locator resolves to several elements and + // Playwright throws a strict-mode error. Each icon carries its own + // aria-describedby, so asserting against the first is well-defined. + const tooltipControl = page + .getByRole('main') + .getByRole('button', { name: 'Identity information' }) + .first() + + await expect(tooltipControl).toHaveAttribute('type', 'button') + await expect(tooltipControl).toHaveAttribute('aria-expanded', 'false') + const describedBy = await tooltipControl.getAttribute('aria-describedby') + expect(describedBy?.trim()).toBeTruthy() + if (!describedBy?.trim()) { + throw new Error('Expected aria-describedby to reference a tooltip') + } + const [tooltipId] = describedBy.trim().split(/\s+/) + + await tooltipControl.click() + await expect(tooltipControl).toHaveAttribute('aria-expanded', 'true') + + const tooltip = page.locator(`#${tooltipId}`) + await expect(tooltip).toHaveAttribute('role', 'tooltip') + await expect(tooltip).toBeVisible() + return tooltip +} + Then('a consent screen is displayed', async function (this: EpdsWorld) { const page = getPage(this) @@ -103,6 +164,80 @@ When( }, ) +When( + 'the untrusted demo client starts a new OAuth flow with random handle mode', + async function (this: EpdsWorld) { + if (!testEnv.demoUntrustedUrl) return 'pending' + const page = getPage(this) + const base = testEnv.demoUntrustedUrl.replace(/\/$/, '') + await page.goto(`${base}/flow3`) + await page.click('button[type=submit]') + }, +) + +Then( + 'the consent page shows the email as the primary account identifier', + async function (this: EpdsWorld) { + const page = getPage(this) + const scenarioEmail = requireScenarioEmail(this) + const grantAccessText = /\bGrant\s+access\s+to\s+your\b/ + const accountCardText = /\bwants\s+to\s+access\s+your\b/ + const grantAccessParagraph = page.getByText(grantAccessText).first() + const accountCard = page + .getByRole('main') + .getByText(accountCardText) + .first() + + await expect(grantAccessParagraph).toBeVisible() + await expect(grantAccessParagraph).toContainText(scenarioEmail) + await expect(accountCard).toBeVisible() + await expect(accountCard).toContainText(scenarioEmail) + }, +) + +Then( + 'the consent identity tooltip exposes the public AT Protocol handle', + async function (this: EpdsWorld) { + const page = getPage(this) + const publicHandle = formatPublicHandle(requireScenarioHandle(this)) + const tooltip = await openIdentityTooltip(page) + await expect(tooltip).toContainText('Public AT Protocol handle:') + await expect(tooltip).toContainText(publicHandle) + }, +) + +Then( + 'the consent identity tooltip exposes the account email', + async function (this: EpdsWorld) { + const page = getPage(this) + const scenarioEmail = requireScenarioEmail(this) + const tooltip = await openIdentityTooltip(page) + await expect(tooltip).toContainText('This handle is associated with') + await expect(tooltip).toContainText(scenarioEmail) + }, +) + +Then( + 'the public handle is not shown as the primary consent identifier', + async function (this: EpdsWorld) { + const page = getPage(this) + const scenarioHandle = requireScenarioHandle(this) + const primaryHandlePatterns = [ + formatPublicHandle(scenarioHandle), + formatRawPublicHandle(scenarioHandle), + ].map( + (publicHandle) => + new RegExp( + String.raw`\byour\s+${escapeRegex(publicHandle)}\s+account\b`, + ), + ) + + for (const primaryHandlePattern of primaryHandlePatterns) { + await expect(page.getByText(primaryHandlePattern)).toHaveCount(0) + } + }, +) + Then( 'the browser is redirected back to the untrusted demo client with an auth error', async function (this: EpdsWorld) { diff --git a/e2e/step-definitions/session-reuse-bugs.steps.ts b/e2e/step-definitions/session-reuse-bugs.steps.ts index 23cc5bdf..d946a9ff 100644 --- a/e2e/step-definitions/session-reuse-bugs.steps.ts +++ b/e2e/step-definitions/session-reuse-bugs.steps.ts @@ -320,8 +320,8 @@ When( // pds-core's chooser middleware reads to inject // into the chooser's // . The enrichment script reads that meta and hides the handle - // span (display:none on .epds-handle-label, title= on - // .epds-email-label) without touching the DB or the account's actual + // span (display:none on .epds-handle-label) and describes it through + // aria-describedby without touching the DB or the account's actual // stored handle. const page = getPage(this) const base = testEnv.demoTrustedUrl.replace(/\/$/, '') @@ -417,25 +417,100 @@ Then( }, ) +type HiddenHandleDescriptionRow = { + describedBy: string | null + descriptions: { + id: string + isHiddenHandleDescription: boolean + text: string + }[] + emailTitle: string | null + hiddenHandleText: string + rowIndex: number +} + Then( - 'each row exposes the handle only via a title tooltip', + 'each row exposes the hidden handle through an accessible description', async function (this: EpdsWorld) { const page = getPage(this) - // The script copies the hidden handle span's text into a title= - // attribute on the adjacent .epds-email-label so power-users can - // still inspect which account maps to which DID without the - // gibberish random handle cluttering the visual hierarchy. - const emailLabels = page.locator('.epds-email-label') - const count = await emailLabels.count() - expect(count).toBeGreaterThan(0) - for (let i = 0; i < count; i++) { - const title = await emailLabels.nth(i).getAttribute('title') - const titleRepr = title === null ? 'null' : `"${title}"` + await expect(page.locator('.epds-email-label').first()).toBeVisible({ + timeout: 10_000, + }) + + const rows = await page + .locator('.epds-email-label') + .evaluateAll((emailLabels): HiddenHandleDescriptionRow[] => { + return emailLabels + .map((emailLabel, rowIndex) => { + const row = emailLabel.closest('[aria-label]') + const handleLabel = row?.querySelector('.epds-handle-label') + if (!row || !handleLabel) return null + + const handleIsHidden = + globalThis.getComputedStyle(handleLabel).display === 'none' + if (!handleIsHidden) return null + + const hiddenHandleText = handleLabel.textContent?.trim() ?? '' + const describedBy = row.getAttribute('aria-describedby') + const descriptionIds = + describedBy?.trim().split(/\s+/).filter(Boolean) ?? [] + const descriptions = descriptionIds.map((id) => { + const describedElement = document.getElementById(id) + return { + id, + isHiddenHandleDescription: + describedElement?.classList.contains( + 'epds-hidden-handle-description', + ) ?? false, + text: describedElement?.textContent?.trim() ?? '', + } + }) + + return { + describedBy, + descriptions, + emailTitle: emailLabel.getAttribute('title'), + hiddenHandleText, + rowIndex, + } + }) + .filter((row): row is HiddenHandleDescriptionRow => row !== null) + }) + + expect(rows.length).toBeGreaterThan(0) + for (const row of rows) { expect( - title, - `Row ${i}: expected .epds-email-label to carry the hidden handle as title=, got ${titleRepr}`, + row.describedBy, + `Row ${row.rowIndex}: expected chooser row to reference the hidden handle with aria-describedby`, ).toBeTruthy() - expect(title!.trim().length).toBeGreaterThan(0) + + const description = row.descriptions.find( + (candidate) => candidate.isHiddenHandleDescription, + ) + expect( + description, + `Row ${row.rowIndex}: expected aria-describedby to reference an .epds-hidden-handle-description element`, + ).toBeDefined() + + const descriptionText = description?.text ?? '' + const prefix = 'Underlying handle:' + const prefixIndex = descriptionText.indexOf(prefix) + expect( + prefixIndex, + `Row ${row.rowIndex}: expected hidden-handle description to contain "${prefix}", got "${descriptionText}"`, + ).toBeGreaterThanOrEqual(0) + const describedHiddenHandle = descriptionText + .slice(prefixIndex + prefix.length) + .trim() + expect( + describedHiddenHandle, + `Row ${row.rowIndex}: expected hidden-handle description suffix to match the hidden handle text`, + ).toBe(row.hiddenHandleText) + + expect( + row.emailTitle, + `Row ${row.rowIndex}: .epds-email-label should not expose the hidden handle through title=`, + ).toBeNull() } }, ) diff --git a/features/consent-screen.feature b/features/consent-screen.feature index 69871b57..e7dbe3c4 100644 --- a/features/consent-screen.feature +++ b/features/consent-screen.feature @@ -72,6 +72,29 @@ Feature: OAuth consent screen When the user later initiates an OAuth login via the untrusted demo client Then a consent screen is displayed + @untrusted-client @email + Scenario: Default picker consent tooltip shows email associated with the public handle + Given a returning user has a PDS account + When the untrusted demo client initiates an OAuth login + And the user enters the test email on the login page + And an OTP email arrives in the mail trap + And the user enters the OTP code + Then a consent screen is displayed + And it identifies the untrusted demo client by its URL host + And the consent identity tooltip exposes the account email + + @untrusted-client @email + Scenario: Random-handle consent shows email with public handle in identity tooltip + Given a returning user has a PDS account + When the untrusted demo client starts a new OAuth flow with random handle mode + And the user enters the test email on the login page + And an OTP email arrives in the mail trap + And the user enters the OTP code + Then a consent screen is displayed + And the consent page shows the email as the primary account identifier + And the consent identity tooltip exposes the public AT Protocol handle + And the public handle is not shown as the primary consent identifier + # TODO: automate once custom CSS injection is merged into the consent route # (renderConsent() needs to accept and apply clientBrandingCss from client metadata) @manual diff --git a/features/session-reuse-bugs.feature b/features/session-reuse-bugs.feature index c35d3a4a..1548de87 100644 --- a/features/session-reuse-bugs.feature +++ b/features/session-reuse-bugs.feature @@ -79,7 +79,7 @@ Feature: Welcome-page guard suppresses upstream's authentication UI When the demo client starts a new OAuth flow with random handle mode Then the browser lands on the ePDS enriched account picker And the enriched account picker renders without the handle visible - And each row exposes the handle only via a title tooltip + And each row exposes the hidden handle through an accessible description And the email remains visible as the primary identifier @pending diff --git a/packages/pds-core/src/__tests__/chooser-enrichment.test.ts b/packages/pds-core/src/__tests__/chooser-enrichment.test.ts index 53ae36bf..5a5a17cc 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,1396 @@ 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 = {} + id = '' + hidden = false + type = '' + parentElement: FakeElement | null = null + textContentOverride: string | null = null + private readonly eventListeners = new Map< + string, + Array<(event: FakeEvent) => void> + >() + + constructor( + readonly tagName: string, + private readonly attributes: Record = {}, + ) {} + + appendChild(child: FakeElement | FakeTextNode): void { + if (child instanceof FakeElement) { + child.parentElement = this + } + this.childNodes.push(child) + } + + insertAdjacentElement(position: string, element: FakeElement): void { + if (position !== 'afterend' || !this.parentElement) return + const siblings = this.parentElement.childNodes + const index = siblings.indexOf(this) + if (index === -1) return + element.parentElement = this.parentElement + siblings.splice(index + 1, 0, element) + } + + set textContent(value: string) { + this.textContentOverride = value + } + + set className(value: string) { + this.classList.set(value) + } + + setAttribute(name: string, value: string): void { + this.attributes[name] = value + if (name === 'id') this.id = value + } + + getAttribute(name: string): string | null { + return this.attributes[name] ?? null + } + + 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 === 'a') { + if (this.tagName === 'a') return this + + let current = this.parentElement + while (current) { + if (current.tagName === 'a') return current + current = current.parentElement + } + return 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 + } + + addEventListener(event: string, listener: (event: FakeEvent) => void): void { + const listeners = this.eventListeners.get(event) ?? [] + listeners.push(listener) + this.eventListeners.set(event, listeners) + } + + dispatchEvent(eventName: string, init: { key?: string } = {}): FakeEvent { + const event = new FakeEvent(init.key) + for (const listener of this.eventListeners.get(eventName) ?? []) { + listener(event) + } + return event + } + + 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') + } + if (selector === 'h2') { + return descendants.filter((el) => el.tagName === 'h2') + } + 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 + ) + } + if (selector === 'meta[name="epds-handle-mode"]') { + return ( + this.descendants().find( + (el) => + el.tagName === 'meta' && el.attributes.name === 'epds-handle-mode', + ) ?? null + ) + } + if (selector === 'meta[name="epds-auth-origin"]') { + return ( + this.descendants().find( + (el) => + el.tagName === 'meta' && el.attributes.name === 'epds-auth-origin', + ) ?? 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 FakeEvent { + defaultPrevented = false + propagationStopped = false + + constructor(readonly key?: string) {} + + preventDefault(): void { + this.defaultPrevented = true + } + + stopPropagation(): void { + this.propagationStopped = true + } +} + +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 + } + + /** + * Deliberate divergence from the browser: this snapshots the descendant + * list up front, whereas a real TreeWalker is live. The enrichment script + * inserts icons, tooltips and email labels *while* walking, so a browser + * walker visits those inserted nodes and this fake does not. + * + * Benign today because nothing the script inserts can match + * isConsentIdentityElement() (which requires a / in an approved + * consent phrasing) or matchAccountIdentifier(). If either ever loosens, + * this fake would hide the resulting re-entrancy, so make it live rather + * than trusting that the tests still cover the browser's behaviour. + */ + 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(selector: string): FakeElement | null { + return this.root.querySelector(selector) + } + + 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)) +} + +const DEFAULT_CHOOSER_LOCATION = { + pathname: '/oauth/authorize', + search: '', +} + +const ALICE_ASSOCIATED_TOOLTIP = + 'This handle is associated with alice@example.test.' +const ASSOCIATED_TOOLTIP_PREFIX = 'This handle is associated' +const ALICE_PUBLIC_HANDLE_TOOLTIP = + 'Public AT Protocol handle: @alice.test. Handles are public account names used by AT Protocol apps.' +const EMAIL_LABEL_CLASS = 'epds-email-label' +const HIDDEN_HANDLE_DESCRIPTION_CLASS = 'epds-hidden-handle-description' +const IDENTITY_INFO_ICON_CLASS = 'epds-identity-info-icon' +const IDENTITY_TOOLTIP_CLASS = 'epds-identity-tooltip' + +function findChildWithClass( + parent: FakeElement, + className: string, +): FakeElement | undefined { + return parent.childNodes.find( + (child) => + child instanceof FakeElement && child.classList.contains(className), + ) as FakeElement | undefined +} + +function findEmailLabel(parent: FakeElement): FakeElement | undefined { + return findChildWithClass(parent, EMAIL_LABEL_CLASS) +} + +function findHiddenHandleDescription( + parent: FakeElement, +): FakeElement | undefined { + return findChildWithClass(parent, HIDDEN_HANDLE_DESCRIPTION_CLASS) +} + +function findDescendantsWithClass( + parent: FakeElement, + className: string, +): FakeElement[] { + return parent.descendants().filter((el) => el.classList.contains(className)) +} + +function expectConsentTooltip( + container: FakeElement, + expectedText: string, +): void { + const icon = findChildWithClass(container, IDENTITY_INFO_ICON_CLASS) + const tooltip = findChildWithClass(container, IDENTITY_TOOLTIP_CLASS) + + expect(icon).toBeInstanceOf(FakeElement) + expect(icon?.tagName).toBe('button') + expect(icon?.getAttribute('aria-describedby')).toBe(tooltip?.id) + expect(tooltip?.textContent).toBe(expectedText) +} + +function expectConsentTooltipTexts( + document: FakeDocument, + expectedTexts: string[], +): void { + const icons = findDescendantsWithClass( + document.root, + IDENTITY_INFO_ICON_CLASS, + ) + const tooltips = findDescendantsWithClass( + document.root, + IDENTITY_TOOLTIP_CLASS, + ) + + expect(icons).toHaveLength(expectedTexts.length) + expect(tooltips.map((tooltip) => tooltip.textContent)).toEqual(expectedTexts) +} + +function runChooserEnrichmentScript( + document: FakeDocument, + globals: { + __sessions?: unknown[] + __deviceSessions?: unknown[] + // Opt-in hook receiving the script's MutationObserver callback, so a + // test can replay a re-render tick the way the real SPA would. + onObserve?: (tick: () => void) => void + } = {}, + location: { pathname: string; search?: string } = DEFAULT_CHOOSER_LOCATION, +): void { + const fakeWindow: Record = { + location, + } + const onObserve = globals.onObserve + const sandbox = { + document, + MutationObserver: class { + observed = false + + constructor(private readonly tick: () => void) {} + + observe(): void { + this.observed = true + onObserve?.(this.tick) + } + }, + 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 = globals.__sessions ?? [ + { + selected: true, + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + preferred_username: 'alice.test', + selected: true, + }, + }, + { + account: { + sub: 'did:plc:bob', + email: 'bob@example.test', + preferred_username: 'bob.test', + }, + }, + ] + if (globals.__deviceSessions) { + fakeWindow.__deviceSessions = globals.__deviceSessions + } + document.dispatchDOMContentLoaded() +} + +function createChooserRow( + document: FakeDocument, + identifierText: string, +): { row: FakeElement; wrap: FakeElement; identifier: FakeElement } { + const row = new FakeElement('div', { role: 'button', tabindex: '0' }) + const wrap = new FakeElement('span') + const identifier = new FakeElement('span') + appendText(identifier, identifierText) + wrap.appendChild(identifier) + row.appendChild(wrap) + document.root.appendChild(row) + return { row, wrap, identifier } +} + +function createAccountListRow( + document: FakeDocument, + identifierText: string, + { emptyTitle = false }: { emptyTitle?: boolean } = {}, +): { + anchor: FakeElement + title?: FakeElement + wrap: FakeElement + identifier: FakeElement +} { + const anchor = new FakeElement('a', { + href: '/account/did:plc:alice', + 'aria-label': 'View and manage account for alice.test', + }) + const wrap = new FakeElement('span') + const identifier = new FakeElement('span') + const title = emptyTitle ? new FakeElement('h2') : undefined + + if (title) anchor.appendChild(title) + appendText(identifier, identifierText) + wrap.appendChild(identifier) + anchor.appendChild(wrap) + document.root.appendChild(anchor) + return { anchor, title, wrap, identifier } +} + +function createAccountSelector( + document: FakeDocument, + identifierTexts: string[], +): { button: FakeElement; identifiers: FakeElement[]; wrap: FakeElement } { + const button = new FakeElement('button', { + 'aria-label': 'Select an account', + }) + const wrap = new FakeElement('span') + const identifiers = identifierTexts.map((identifierText) => { + const identifier = new FakeElement('p') + appendText(identifier, identifierText) + wrap.appendChild(identifier) + return identifier + }) + button.appendChild(wrap) + document.root.appendChild(button) + return { button, identifiers, wrap } +} + +function createConsentIdentity( + document: FakeDocument, + textBefore: string, + identifierText: string, + textAfter: string, + tagName = 'b', +): { container: FakeElement; identifier: FakeElement } { + const container = new FakeElement('p') + appendText(container, textBefore) + const identifier = new FakeElement(tagName) + appendText(identifier, identifierText) + container.appendChild(identifier) + appendText(container, textAfter) + document.root.appendChild(container) + return { container, identifier } +} + +function createPreviewChooserConsentIdentities( + document: FakeDocument, + mode: string, +): { + sidebar: { container: FakeElement; identifier: FakeElement } + mainCard: { container: FakeElement; identifier: FakeElement } +} { + appendHandleModeMeta(document, mode) + + return { + sidebar: createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ), + mainCard: createConsentIdentity( + document, + 'wants to access your ', + 'alice.test', + ' account', + ), + } +} + +function runPreviewChooserConsentEnrichment( + document: FakeDocument, + mode: string, +): void { + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { + pathname: '/preview/chooser', + search: `?epds_handle_mode=${mode}`, + }, + ) +} + +function expectArbitraryConsentProseUntouched({ + prefix, + identifierText, + suffix, + tagName, + expectedText, +}: { + prefix: string + identifierText: string + suffix: string + tagName?: string + expectedText: string +}): void { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const { container, identifier } = createConsentIdentity( + document, + prefix, + identifierText, + suffix, + tagName, + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + expect(identifier.textContent).toBe(identifierText) + expect(container.textContent).toBe(expectedText) + expect(container.textContent).not.toContain(ASSOCIATED_TOOLTIP_PREFIX) +} + +function findConsentIdentityTooltip(container: FakeElement): { + icon: FakeElement + tooltip: FakeElement +} { + const icon = findChildWithClass(container, IDENTITY_INFO_ICON_CLASS) + const tooltip = findChildWithClass(container, IDENTITY_TOOLTIP_CLASS) + + return { icon: icon as FakeElement, tooltip: tooltip as FakeElement } +} + +function selectedAliceSession(preferredUsername = 'alice.test'): unknown[] { + return [ + { + selected: true, + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + preferred_username: preferredUsername, + selected: true, + }, + }, + ] +} + +function aliceDeviceSession(): unknown[] { + return [ + { + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + preferred_username: 'alice.test', + }, + selected: true, + }, + ] +} + +/** An account with no handle yet, so only its DID can match the rendered text. */ +function handlelessDeviceSession(): unknown[] { + return [ + { + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + }, + selected: true, + }, + ] +} + +function appendHandleModeMeta(document: FakeDocument, mode: string): void { + document.root.appendChild( + new FakeElement('meta', { name: 'epds-handle-mode', content: mode }), + ) +} + +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, + wrap, + identifier: handle, + } = createChooserRow(document, 'alice.test') + + runChooserEnrichmentScript(document) + + const emailLabel = findEmailLabel(wrap) + + expect(emailLabel).toBeInstanceOf(FakeElement) + // Trimmed: the chooser label is rendered with a leading space for + // visual separation from the handle, which is presentation, not identity. + expect(emailLabel?.textContent.trim()).toBe('alice@example.test') + expect(handle.classList.contains('epds-handle-label')).toBe(true) + expect(handle.style.display).toBeUndefined() + expect(row.getAttribute('aria-label')).toBe('Sign in as alice@example.test') + }) + + it('enriches preview chooser rows in picker-with-random mode', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker-with-random') + const { + row, + wrap, + identifier: handle, + } = createChooserRow(document, 'alice.test') + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/preview/chooser', search: '' }, + ) + + expect(wrap.textContent).toContain('alice@example.test') + expect(handle.classList.contains('epds-handle-label')).toBe(true) + expect(handle.style.display).toBeUndefined() + expect(row.getAttribute('aria-label')).toBe('Sign in as alice@example.test') + }) + + it('uses email as the visible random-mode identifier and describes the hidden handle', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { + row, + wrap, + identifier: handle, + } = createChooserRow(document, 'alice.test') + + runChooserEnrichmentScript(document) + + const emailLabel = findEmailLabel(wrap) + const handleDescription = findHiddenHandleDescription(row) + + expect(emailLabel?.textContent.trim()).toBe('alice@example.test') + expect(handle.style.display).toBe('none') + expect(handleDescription?.textContent).toBe('Underlying handle: alice.test') + expect(row.getAttribute('aria-describedby')).toBe(handleDescription?.id) + expect(emailLabel?.getAttribute('title')).toBeNull() + expect(row.getAttribute('aria-label')).toBe('Sign in as alice@example.test') + }) + + it('uses email as the visible random-mode identifier on preview chooser rows', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { + row, + wrap, + identifier: handle, + } = createChooserRow(document, 'alice.test') + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/preview/chooser', search: '' }, + ) + + const emailLabel = findEmailLabel(wrap) + const handleDescription = findHiddenHandleDescription(row) + + expect(emailLabel?.textContent.trim()).toBe('alice@example.test') + expect(handle.style.display).toBe('none') + expect(handleDescription?.textContent).toBe('Underlying handle: alice.test') + expect(row.getAttribute('aria-describedby')).toBe(handleDescription?.id) + }) + + it('gives rows enriched on a later re-render tick a distinct hidden-handle id', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { row: aliceRow } = createChooserRow(document, 'alice.test') + + let replayTick: (() => void) | undefined + runChooserEnrichmentScript(document, { + onObserve: (tick) => { + replayTick = tick + }, + }) + + // Bob's row arrives in a later SPA render. The first row is already + // marked enriched and so is excluded from the rebuilt match list, + // which is exactly the situation where a per-tick index restarts + // at 0 and collides with Alice's existing description id. + const { row: bobRow } = createChooserRow(document, 'bob.test') + expect(replayTick).toBeDefined() + replayTick?.() + + const aliceId = findHiddenHandleDescription(aliceRow)?.id + const bobId = findHiddenHandleDescription(bobRow)?.id + + expect(aliceId).toBeTruthy() + expect(bobId).toBeTruthy() + // Duplicate ids would make aria-describedby resolve to the first + // node, so Bob's row would announce Alice's handle. + expect(bobId).not.toBe(aliceId) + expect(bobRow.getAttribute('aria-describedby')).toBe(bobId) + }) + + it('does not hide random-mode handles outside oauth authorize', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { wrap, identifier, anchor } = createAccountListRow( + document, + 'alice.test', + ) + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/account', search: '' }, + ) + + expect(wrap.textContent).toContain('alice@example.test') + expect(identifier.style.display).toBeUndefined() + expect(anchor.getAttribute('aria-label')).toBe( + 'View and manage account for alice@example.test (@alice.test)', + ) + }) + + it('uses an empty account-list title slot for the email on account pages', () => { + const document = new FakeDocument() + const { title, identifier, anchor } = createAccountListRow( + document, + 'alice.test', + { emptyTitle: true }, + ) + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/account', search: '' }, + ) + + expect(title?.textContent).toBe('alice@example.test') + expect(identifier.textContent).toBe('alice.test') + expect(identifier.style.display).toBeUndefined() + expect(anchor.getAttribute('aria-label')).toBe( + 'View and manage account for alice@example.test (@alice.test)', + ) + }) + + it('leaves non-account links on account pages untouched', () => { + const document = new FakeDocument() + const anotherAccount = new FakeElement('a', { + href: '/account/login', + 'aria-label': 'Sign in with another account', + }) + appendText(anotherAccount, 'Sign in with another account') + document.root.appendChild(anotherAccount) + const terms = new FakeElement('a', { href: '/terms' }) + appendText(terms, 'Terms') + document.root.appendChild(terms) + const prose = new FakeElement('p') + appendText(prose, 'Manage alice.test from this page.') + document.root.appendChild(prose) + + runChooserEnrichmentScript( + document, + {}, + { pathname: '/account', search: '' }, + ) + + expect(anotherAccount.textContent).toBe('Sign in with another account') + expect(terms.textContent).toBe('Terms') + expect(prose.textContent).toBe('Manage alice.test from this page.') + expect(document.root.textContent).not.toContain('alice@example.test') + }) + + it('adds email next to the current account selector handle on account detail pages', () => { + const document = new FakeDocument() + const { button, identifiers, wrap } = createAccountSelector(document, [ + 'alice.test', + ]) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: aliceDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + expect(identifiers[0].textContent).toBe('alice.test') + expect(identifiers[0].style.display).toBeUndefined() + expect(wrap.textContent).toContain('alice@example.test') + expect(button.getAttribute('aria-label')).toBe( + 'Select account alice@example.test (@alice.test)', + ) + }) + + it('does not present a DID as a handle in the account selector accessible name', () => { + const document = new FakeDocument() + const { button } = createAccountSelector(document, ['did:plc:alice']) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: handlelessDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + // A DID is not a handle, so it must not be decorated with '@'. + expect(button.getAttribute('aria-label')).toBe( + 'Select account alice@example.test (did:plc:alice)', + ) + }) + + it('collapses duplicate current account selector handle lines to email plus handle', () => { + const document = new FakeDocument() + const { button, identifiers } = createAccountSelector(document, [ + 'alice.test', + 'alice.test', + ]) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: aliceDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + expect(identifiers[0].textContent).toBe('alice@example.test') + expect(identifiers[1].textContent).toBe('alice.test') + expect(button.textContent).toBe('alice@example.testalice.test') + expect(button.getAttribute('aria-label')).toBe( + 'Select account alice@example.test (@alice.test)', + ) + }) + + it('keeps the current account selector handle visible when account pages use random mode', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { identifiers, wrap } = createAccountSelector(document, [ + 'alice.test', + ]) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: aliceDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + expect(identifiers[0].textContent).toBe('alice.test') + expect(identifiers[0].style.display).toBeUndefined() + expect(wrap.textContent).toContain('alice@example.test') + }) + + it('does not enrich non-selector controls on account detail pages', () => { + const document = new FakeDocument() + createAccountSelector(document, ['alice.test']) + const connectedApp = new FakeElement('button', { + 'aria-label': 'Open app settings', + }) + appendText(connectedApp, 'alice.test') + document.root.appendChild(connectedApp) + const signOut = new FakeElement('button', { 'aria-label': 'Sign out' }) + appendText(signOut, 'Sign out alice.test') + document.root.appendChild(signOut) + const breadcrumb = new FakeElement('a', { href: '/account' }) + appendText(breadcrumb, 'alice.test') + document.root.appendChild(breadcrumb) + + runChooserEnrichmentScript( + document, + { __sessions: [], __deviceSessions: aliceDeviceSession() }, + { pathname: '/account/did:plc:alice', search: '' }, + ) + + expect(connectedApp.textContent).toBe('alice.test') + expect(signOut.textContent).toBe('Sign out alice.test') + expect(breadcrumb.textContent).toBe('alice.test') + }) + + it('enriches exact at-prefixed handle matches', () => { + const document = new FakeDocument() + const { wrap, identifier } = createChooserRow(document, '@alice.test') + + runChooserEnrichmentScript(document) + + expect(wrap.textContent).toContain('alice@example.test') + expect(identifier.classList.contains('epds-handle-label')).toBe(true) + }) + + it('enriches exact DID matches', () => { + const document = new FakeDocument() + const { wrap, identifier } = createChooserRow(document, 'did:plc:alice') + + runChooserEnrichmentScript(document) + + expect(wrap.textContent).toContain('alice@example.test') + expect(identifier.classList.contains('epds-handle-label')).toBe(true) + }) + + it('enriches rows from captured device sessions', () => { + const document = new FakeDocument() + const { wrap, identifier } = createChooserRow(document, 'carol.test') + + runChooserEnrichmentScript(document, { + __sessions: [], + __deviceSessions: [ + { + account: { + sub: 'did:plc:carol', + email: 'carol@example.test', + preferred_username: 'carol.test', + }, + selected: true, + }, + ], + }) + + expect(wrap.textContent).toContain('carol@example.test') + expect(identifier.classList.contains('epds-handle-label')).toBe(true) + }) + + it('does not enrich substring-only chooser row prose', () => { + const document = new FakeDocument() + const { wrap, identifier } = createChooserRow( + document, + 'Signed in as alice.test', + ) + + runChooserEnrichmentScript(document) + + expect(wrap.textContent).not.toContain('alice@example.test') + expect(identifier.classList.contains('epds-handle-label')).toBe(false) + }) + + 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('buildChooserEnrichmentScript consent identity enrichment', () => { + it('enriches grant-access consent identity copy', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document) + + expect(identifier.textContent).toBe('alice.test') + expectConsentTooltip(container, ALICE_ASSOCIATED_TOOLTIP) + }) + + it('enriches client-wants-access consent identity copy', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const { container, identifier } = createConsentIdentity( + document, + 'Example App wants to access your ', + 'alice.test', + ' account', + 'strong', + ) + + runChooserEnrichmentScript(document, { + __sessions: selectedAliceSession(), + }) + + expect(identifier.textContent).toBe('alice.test') + expect(container.textContent).toContain(ALICE_ASSOCIATED_TOOLTIP) + }) + + it('enriches upstream main-card consent identity copy without same-paragraph client name', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const { container, identifier } = createConsentIdentity( + document, + 'wants to access your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { + __sessions: selectedAliceSession(), + }) + + const iconIndex = container.childNodes.findIndex( + (child) => + child instanceof FakeElement && + child.classList.contains(IDENTITY_INFO_ICON_CLASS), + ) + const identifierIndex = container.childNodes.indexOf(identifier) + + expect(identifier.textContent).toBe('alice.test') + expect(iconIndex).toBe(identifierIndex + 1) + expect(container.textContent).toContain(ALICE_ASSOCIATED_TOOLTIP) + }) + + it('enriches sidebar and main-card consent identities on the same page', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker-with-random') + const sidebar = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + const mainCard = createConsentIdentity( + document, + 'wants to access your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { pathname: '/preview/consent', search: '' }, + ) + + expect(sidebar.identifier.textContent).toBe('alice.test') + expect(mainCard.identifier.textContent).toBe('alice.test') + expectConsentTooltipTexts(document, [ + ALICE_ASSOCIATED_TOOLTIP, + ALICE_ASSOCIATED_TOOLTIP, + ]) + }) + + it('treats picker-with-random and default consent like picker consent', () => { + for (const mode of ['picker-with-random', null]) { + const document = new FakeDocument() + if (mode) appendHandleModeMeta(document, mode) + const { container, identifier } = createConsentIdentity( + document, + 'Example App wants to access your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { + __sessions: selectedAliceSession(), + }) + + expect(identifier.textContent).toBe('alice.test') + expect(container.textContent).toContain(ALICE_ASSOCIATED_TOOLTIP) + } + }) + + it('shows the email for random consent and exposes the public handle in the tooltip', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + '@alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { + __sessions: selectedAliceSession('@alice.test'), + }) + + expect(identifier.textContent).toBe('alice@example.test') + expect(container.textContent).toContain(ALICE_PUBLIC_HANDLE_TOOLTIP) + expect(container.textContent).not.toContain('@@alice.test') + }) + + it('describes a DID as an identifier, not a handle, in the random consent tooltip', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + 'did:plc:alice', + ' account', + ) + + runChooserEnrichmentScript(document, { + __sessions: [ + { + selected: true, + account: { + sub: 'did:plc:alice', + email: 'alice@example.test', + selected: true, + }, + }, + ], + }) + + expect(identifier.textContent).toBe('alice@example.test') + expect(container.textContent).toContain( + 'Public AT Protocol identifier: did:plc:alice. This account has no handle yet, so its DID is shown instead.', + ) + expect(container.textContent).not.toContain('@did:plc:alice') + }) + + it('enriches preview consent identity copy', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker-with-random') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { pathname: '/preview/consent', search: '' }, + ) + + expect(identifier.textContent).toBe('alice.test') + expect(container.textContent).toContain(ALICE_ASSOCIATED_TOOLTIP) + }) + + it('enriches preview chooser consent state in picker-with-random mode', () => { + const document = new FakeDocument() + const { sidebar, mainCard } = createPreviewChooserConsentIdentities( + document, + 'picker-with-random', + ) + + runPreviewChooserConsentEnrichment(document, 'picker-with-random') + + expect(sidebar.identifier.textContent).toBe('alice.test') + expect(mainCard.identifier.textContent).toBe('alice.test') + expectConsentTooltipTexts(document, [ + ALICE_ASSOCIATED_TOOLTIP, + ALICE_ASSOCIATED_TOOLTIP, + ]) + }) + + it('uses email as the visible preview chooser consent identity in random mode', () => { + const document = new FakeDocument() + const { sidebar, mainCard } = createPreviewChooserConsentIdentities( + document, + 'random', + ) + + runPreviewChooserConsentEnrichment(document, 'random') + + expect(sidebar.identifier.textContent).toBe('alice@example.test') + expect(mainCard.identifier.textContent).toBe('alice@example.test') + expectConsentTooltipTexts(document, [ + ALICE_PUBLIC_HANDLE_TOOLTIP, + ALICE_PUBLIC_HANDLE_TOOLTIP, + ]) + }) + + it('uses email as the visible preview consent identity in random mode', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'random') + const { container, identifier } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { pathname: '/preview/consent', search: '' }, + ) + + expect(identifier.textContent).toBe('alice@example.test') + expect(container.textContent).toContain(ALICE_PUBLIC_HANDLE_TOOLTIP) + }) + + it('leaves generic and legal consent paragraphs untouched', () => { + const document = new FakeDocument() + appendHandleModeMeta(document, 'picker') + const legal = new FakeElement('p') + appendText( + legal, + 'By clicking Authorize, you confirm that alice.test is your account.', + ) + document.root.appendChild(legal) + const unrelated = createConsentIdentity( + document, + 'Grant access to your ', + 'bob.test', + ' account', + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + expect(document.root.textContent).not.toContain(ASSOCIATED_TOOLTIP_PREFIX) + expect(unrelated.identifier.textContent).toBe('bob.test') + }) + + it('leaves arbitrary bold selected-account legal copy untouched', () => { + expectArbitraryConsentProseUntouched({ + prefix: 'By clicking Authorize, ', + identifierText: 'alice.test', + suffix: ' confirms access.', + expectedText: 'By clicking Authorize, alice.test confirms access.', + }) + }) + + it('leaves arbitrary strong selected-account technical prose untouched', () => { + expectArbitraryConsentProseUntouched({ + prefix: 'Technical details for ', + identifierText: 'alice.test', + suffix: ' may include OAuth scopes.', + tagName: 'strong', + expectedText: + 'Technical details for alice.test may include OAuth scopes.', + }) + }) + + it('opens the tooltip on hover/focus and toggles it on click/tap', () => { + const document = new FakeDocument() + const { container } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + const { icon, tooltip } = findConsentIdentityTooltip(container) + + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + icon.dispatchEvent('mouseenter') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + icon.dispatchEvent('mouseleave') + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + icon.dispatchEvent('focus') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + icon.dispatchEvent('blur') + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + const click = icon.dispatchEvent('click') + expect(click.defaultPrevented).toBe(true) + expect(click.propagationStopped).toBe(true) + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + icon.dispatchEvent('mouseleave') + expect(tooltip.hidden).toBe(false) + icon.dispatchEvent('blur') + expect(tooltip.hidden).toBe(false) + icon.dispatchEvent('click') + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + }) + + it('dismisses the consent tooltip on Escape, including when pinned', () => { + const document = new FakeDocument() + const { container } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + const { icon, tooltip } = findConsentIdentityTooltip(container) + + // Pinned is the case that matters: hide() bails out early while + // pinned, so before the Escape handler a keyboard user had no way + // to dismiss it without moving focus (WCAG 1.4.13 Dismissible). + icon.dispatchEvent('click') + expect(tooltip.hidden).toBe(false) + + const escape = icon.dispatchEvent('keyup', { key: 'Escape' }) + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + expect(escape.propagationStopped).toBe(true) + + // Unpinned hover/focus content is dismissible the same way. + icon.dispatchEvent('focus') + expect(tooltip.hidden).toBe(false) + icon.dispatchEvent('keyup', { key: 'Escape' }) + expect(tooltip.hidden).toBe(true) + + // Other keys leave it alone, and Escape on an already-hidden tooltip + // does not claim the event from the surrounding page. + icon.dispatchEvent('focus') + icon.dispatchEvent('keyup', { key: 'a' }) + expect(tooltip.hidden).toBe(false) + icon.dispatchEvent('keyup', { key: 'Escape' }) + const escapeWhenHidden = icon.dispatchEvent('keyup', { key: 'Escape' }) + expect(escapeWhenHidden.propagationStopped).toBe(false) + }) + + it('keeps the tooltip pinned open when touch focus fires before click', () => { + const document = new FakeDocument() + const { container } = createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript(document, { __sessions: selectedAliceSession() }) + + const { icon, tooltip } = findConsentIdentityTooltip(container) + + icon.dispatchEvent('focus') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + + icon.dispatchEvent('click') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + + icon.dispatchEvent('blur') + expect(tooltip.hidden).toBe(false) + expect(icon.getAttribute('aria-expanded')).toBe('true') + + icon.dispatchEvent('click') + expect(tooltip.hidden).toBe(true) + expect(icon.getAttribute('aria-expanded')).toBe('false') + }) + + it('does not apply consent tooltip behavior on account pages', () => { + const document = new FakeDocument() + createConsentIdentity( + document, + 'Grant access to your ', + 'alice.test', + ' account', + ) + + runChooserEnrichmentScript( + document, + { __sessions: selectedAliceSession() }, + { pathname: '/account', search: '' }, + ) + + expect(document.root.textContent).not.toContain(ASSOCIATED_TOOLTIP_PREFIX) + }) +}) + describe('sha256Base64', () => { it('produces a stable SHA256 base64 hash', () => { // Known value for the empty string. @@ -388,13 +1779,15 @@ describe('buildChooserEnrichmentScript handle-mode hiding (HYPER-268 Layer 4)', expect(script).toContain('querySelector(\'meta[name="epds-handle-mode"]\')') }) - it("hides the handle span and sets a title tooltip when mode is 'random'", () => { + it("hides the handle span and adds an accessible description when mode is 'random'", () => { const script = buildChooserEnrichmentScript() - // Hiding strategy: display:none on the handle element + title - // attribute on the email label carrying the original handle text. - expect(script).toContain("hideHandle = handleMode === 'random'") + // Hiding strategy: display:none on the handle element plus an + // aria-describedby target carrying the original handle text. + expect(script).toContain( + "hideHandle = handleMode === 'random' && isChooserLikePage()", + ) expect(script).toContain("m.el.style.display = 'none'") - expect(script).toContain('label.title = ownText') + expect(script).toContain("appendAriaReference(row, 'aria-describedby'") }) it('leaves the handle visible for picker / picker-with-random', () => { @@ -456,6 +1849,92 @@ describe('createChooserEnrichmentMiddleware handle-mode meta (HYPER-268 Layer 4) expect(written).toContain('') }) + it('resolves client metadata handle mode from request_uri when client_id is absent', async () => { + const resolveClientIdFromRequestUri = vi + .fn() + .mockResolvedValue('https://demo.example/client') + const resolveClientMetadata = vi + .fn() + .mockResolvedValue({ epds_handle_mode: 'random' as const }) + + const written = await captureWrittenHtml( + { + resolveClientMetadata, + resolveClientIdFromRequestUri, + }, + { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' }, + ) + + expect(resolveClientIdFromRequestUri).toHaveBeenCalledWith( + 'urn:ietf:params:oauth:request_uri:req-123', + ) + expect(resolveClientMetadata).toHaveBeenCalledWith( + 'https://demo.example/client', + ) + expect(written).toContain('') + }) + + it('keeps explicit query handle mode ahead of request_uri metadata', async () => { + const resolveClientIdFromRequestUri = vi + .fn() + .mockResolvedValue('https://demo.example/client') + const resolveClientMetadata = vi + .fn() + .mockResolvedValue({ epds_handle_mode: 'random' as const }) + + const written = await captureWrittenHtml( + { + resolveClientMetadata, + resolveClientIdFromRequestUri, + }, + { + epds_handle_mode: 'picker', + request_uri: 'urn:ietf:params:oauth:request_uri:req-123', + }, + ) + + expect(resolveClientIdFromRequestUri).not.toHaveBeenCalled() + expect(resolveClientMetadata).not.toHaveBeenCalled() + expect(written).toContain('') + }) + + it('degrades silently when request_uri client-id resolution rejects', async () => { + const written = await captureWrittenHtml( + { + resolveClientMetadata: vi.fn(), + resolveClientIdFromRequestUri: () => + Promise.reject(new Error('request expired')), + }, + { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' }, + ) + + expect(written).toContain( + '', + ) + }) + + it('degrades silently when request_uri metadata lookup rejects', async () => { + const resolveClientMetadata = vi + .fn() + .mockRejectedValue(new Error('metadata unavailable')) + + const written = await captureWrittenHtml( + { + resolveClientMetadata, + resolveClientIdFromRequestUri: () => + Promise.resolve('https://demo.example/client'), + }, + { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' }, + ) + + expect(resolveClientMetadata).toHaveBeenCalledWith( + 'https://demo.example/client', + ) + expect(written).toContain( + '', + ) + }) + it('ignores invalid handle modes from metadata (fall through to fallback)', async () => { const written = await captureWrittenHtml( { @@ -472,9 +1951,29 @@ describe('createChooserEnrichmentMiddleware handle-mode meta (HYPER-268 Layer 4) ) }) - it('degrades silently when the metadata resolver rejects', async () => { + it('ignores invalid request_uri metadata handle modes through the shared resolver', async () => { + const written = await captureWrittenHtml( + { + resolveClientMetadata: () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- deliberate bad value + Promise.resolve({ epds_handle_mode: 'garbage' as any }), + resolveClientIdFromRequestUri: () => + Promise.resolve('https://demo.example/client'), + }, + { request_uri: 'urn:ietf:params:oauth:request_uri:req-123' }, + ) + + expect(written).toContain( + '', + ) + }) + + it('logs and falls back when the metadata resolver rejects', async () => { + const debug = vi.fn() + const written = await captureWrittenHtml( { + logger: { debug }, resolveClientMetadata: () => Promise.reject(new Error('network error')), }, { client_id: 'https://demo.example/client' }, @@ -483,6 +1982,42 @@ describe('createChooserEnrichmentMiddleware handle-mode meta (HYPER-268 Layer 4) expect(written).toContain( '', ) + expect(debug).toHaveBeenCalledWith( + expect.objectContaining({ + err: expect.any(Error), + queryMode: undefined, + // This flow carried client_id, not request_uri. + hasRequestUri: false, + }), + 'chooser-enrichment: failed to resolve handle mode from OAuth request context', + ) + }) + + // request_uri is a short-lived bearer reference to the PAR entry, so a + // log line carrying its value is replayable by anyone reading the logs. + it('logs request_uri presence but never its value', async () => { + const requestUri = 'urn:ietf:params:oauth:request_uri:secret-par-handle' + const debug = vi.fn() + await captureWrittenHtml( + { + logger: { debug }, + resolveClientMetadata: () => Promise.resolve({}), + resolveClientIdFromRequestUri: () => + Promise.reject(new Error('network error')), + }, + { request_uri: requestUri }, + ) + expect(debug).toHaveBeenCalledWith( + expect.objectContaining({ hasRequestUri: true }), + 'chooser-enrichment: failed to resolve handle mode from OAuth request context', + ) + // JSON.stringify renders an Error as {} because message and stack are + // non-enumerable, which would hide a request_uri that leaked through an + // error message — the most likely way for it to reach the logs here. + const serialized = JSON.stringify(debug.mock.calls, (_key, value) => + value instanceof Error ? `${value.name}: ${value.message}` : value, + ) + expect(serialized).not.toContain(requestUri) }) }) diff --git a/packages/pds-core/src/__tests__/preview-chooser.test.ts b/packages/pds-core/src/__tests__/preview-chooser.test.ts index 07ad8b4f..bff33e70 100644 --- a/packages/pds-core/src/__tests__/preview-chooser.test.ts +++ b/packages/pds-core/src/__tests__/preview-chooser.test.ts @@ -122,6 +122,28 @@ describe('createPreviewChooserHandler', () => { expect(res.body).toContain(`function readHandleMode()`) }) + it('places the enrichment script before __sessions hydration and includes fixture identities', async () => { + const handler = createPreviewChooserHandler(makeDeps())! + const res = mockRes() + await handler({ query: { numAccounts: '2' } }, res) + const scriptIndex = res.body!.indexOf('function readHandleMode()') + const hydrationIndex = res.body!.indexOf('window["__sessions"]') + + expect(scriptIndex).toBeGreaterThan(-1) + expect(hydrationIndex).toBeGreaterThan(-1) + expect(scriptIndex).toBeLessThan(hydrationIndex) + expect(res.body).toContain( + String.raw`\"preferred_username\":\"alice.preview.example\"`, + ) + expect(res.body).toContain( + String.raw`\"email\":\"alice@preview.example\"`, + ) + expect(res.body).toContain( + String.raw`\"preferred_username\":\"bob.preview.example\"`, + ) + expect(res.body).toContain(String.raw`\"email\":\"bob@preview.example\"`) + }) + it('reads the override from ?epds_handle_mode (production param name)', async () => { const handler = createPreviewChooserHandler(makeDeps())! const res = mockRes() diff --git a/packages/pds-core/src/__tests__/preview-consent.test.ts b/packages/pds-core/src/__tests__/preview-consent.test.ts index 26a76c26..94b2785c 100644 --- a/packages/pds-core/src/__tests__/preview-consent.test.ts +++ b/packages/pds-core/src/__tests__/preview-consent.test.ts @@ -80,6 +80,62 @@ describe('createPreviewConsentHandler', () => { expect(res.body).toContain('/@atproto/oauth-provider/~assets/') }) + it('injects handle-mode meta and enrichment script before __sessions hydration', async () => { + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler({ query: {} }, res) + const handleModeIndex = res.body!.indexOf( + '', + ) + const enrichmentIndex = res.body!.indexOf('function readHandleMode()') + const hydrationIndex = res.body!.indexOf('window["__sessions"]') + + expect(handleModeIndex).toBeGreaterThan(-1) + expect(enrichmentIndex).toBeGreaterThan(-1) + expect(hydrationIndex).toBeGreaterThan(-1) + expect(handleModeIndex).toBeLessThan(enrichmentIndex) + expect(enrichmentIndex).toBeLessThan(hydrationIndex) + }) + + it('hydrates the selected preview session with handle and email', async () => { + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler({ query: {} }, res) + + expect(res.body).toContain(String.raw`\"selected\":true`) + expect(res.body).toContain( + String.raw`\"preferred_username\":\"alice.preview.example\"`, + ) + expect(res.body).toContain( + String.raw`\"email\":\"alice@preview.example\"`, + ) + }) + + it('supports ?epds_handle_mode=random for consent preview', async () => { + const handler = createPreviewConsentHandler({ + trustedClients: [], + resolveClientMetadata: () => Promise.resolve({}), + getClientCss: () => null, + logger: mockLogger(), + })! + const res = mockRes() + await handler({ query: { epds_handle_mode: 'random' } }, res) + + expect(res.body).toContain( + '', + ) + }) + it('resolves client metadata and injects CSS for custom client_id', async () => { const trusted = 'https://trusted.example/client-metadata.json' const resolveClientMetadata = vi.fn(() => diff --git a/packages/pds-core/src/chooser-enrichment.ts b/packages/pds-core/src/chooser-enrichment.ts index 871a72f6..4c9c8e68 100644 --- a/packages/pds-core/src/chooser-enrichment.ts +++ b/packages/pds-core/src/chooser-enrichment.ts @@ -25,6 +25,10 @@ import type { ResolveClientMetadataOptions, } from '@certified-app/shared' import { resolveHandleMode, VALID_HANDLE_MODES } from '@certified-app/shared' +import { + resolveOAuthClientIdFromQuery, + type ResolveClientIdFromRequestUri, +} from './lib/oauth-request-context.js' /** * Build the post-hydration enrichment script injected into `/account*` @@ -43,7 +47,7 @@ import { resolveHandleMode, VALID_HANDLE_MODES } from '@certified-app/shared' * this runs in a plain `` return ` @@ -109,11 +121,13 @@ async function renderConsentHtml(opts: { + ${handleModeMeta} Consent preview — ${escapeHtml(opts.fixture.clientId)} ${styleLinks} ${injectedStyle} + ${enrichmentScript}
@@ -142,10 +156,13 @@ export function createPreviewConsentHandler( 'Preview consent', ) - const fixture: PreviewAuthorizeFixture = { + const handleMode = resolveQueryHandleMode(req, metadata) + + const fixture: PreviewConsentFixture = { clientId, clientMetadata: metadata, isTrusted: deps.trustedClients.includes(clientId), + handleMode, } const html = await renderConsentHtml({ fixture, injectedCss }) diff --git a/packages/pds-core/src/lib/preview-shared.ts b/packages/pds-core/src/lib/preview-shared.ts index aba8a66f..df6101ef 100644 --- a/packages/pds-core/src/lib/preview-shared.ts +++ b/packages/pds-core/src/lib/preview-shared.ts @@ -11,7 +11,8 @@ * The CSP, hydration format, and asset-URL prefix are documented further * in {@link ./preview-consent.ts}. */ -import type { ClientMetadata } from '@certified-app/shared' +import type { ClientMetadata, HandleMode } from '@certified-app/shared' +import { resolveHandleMode, VALID_HANDLE_MODES } from '@certified-app/shared' import { readFile } from 'node:fs/promises' import { createRequire } from 'node:module' import serialize from 'serialize-javascript' @@ -193,6 +194,30 @@ export function readClientIdQuery(req: RequestLike): string { : PREVIEW_FIXTURE_DEFAULT_CLIENT_ID } +/** + * Resolve handle-mode the same way the real chooserEnrichment middleware + * does: query > client metadata > env default. Same override name + * (`epds_handle_mode`) so the preview dropdowns exercise the production + * resolver path verbatim. Shared by both preview routes so they cannot + * drift apart in how they interpret an unknown metadata value. + */ +export function resolveQueryHandleMode( + req: RequestLike, + metadata: ClientMetadata, +): HandleMode { + const queryMode = + typeof req.query.epds_handle_mode === 'string' + ? req.query.epds_handle_mode + : undefined + const rawMetaMode = metadata.epds_handle_mode + const metaMode = + typeof rawMetaMode === 'string' && + (VALID_HANDLE_MODES as readonly string[]).includes(rawMetaMode) + ? rawMetaMode + : undefined + return resolveHandleMode(queryMode, metaMode) +} + /** * Preview-route CSP: relaxed `script-src` to allow the inline hydration * block. Pinning its sha256 would fight every time the fixture changes,