import AxeBuilder from '@axe-core/playwright'; import { expect, test as base, type APIRequestContext, type BrowserContext, type Page, type Request, type TestInfo, } from '@playwright/test'; import { translations } from '../src/i18n/translations'; const apiUrl = 'http://localhost:5302/api'; const password = 'E2ePassword123!'; type Language = 'en' | 'nb'; type Theme = 'light' | 'dark'; type Sample = { language: Language; theme: Theme; width: number; height: number }; type Owner = { email: string; id: string; headers: Record }; type Session = { id: string; deviceLabel: string | null; lastSeenAtUtc: string; expiresAtUtc: string; isCurrentSession: boolean; }; async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const response = await api.get(`${apiUrl}/auth/me`); expect(response.status()).toBe(200); const me = await response.json(); const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN'); expect(csrf).toBeDefined(); expect(me.sessionContext).toEqual(expect.any(String)); return { id: me.id as string, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext as string, } }; } async function localOnly(context: BrowserContext) { await context.route('**/*', route => { const url = new URL(route.request().url()); return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort(); }); } // Normal public login and administrator user creation in the runner's disposable local API. // No alternative authentication endpoint, role elevation or provider request is introduced. const test = base.extend<{ owner: Owner }>({ owner: async ({ page, context, playwright }, provide) => { const admin = await playwright.request.newContext(); try { const adminIdentity = await login(admin, 'e2e@example.test'); const email = `p7-account-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminIdentity.headers, data: { email, password, roles: [] }, })).status()).toBe(200); const identity = await login(page.request, email); await localOnly(context); await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); }); await provide({ ...identity, email }); } finally { await admin.dispose(); } }, }); async function read(api: APIRequestContext, path: string): Promise { const response = await api.get(`${apiUrl}${path}`); expect(response.status()).toBe(200); return response.json() as Promise; } async function enterProfile(page: Page, owner: Owner, sample: Sample) { expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' }, })).status()).toBe(204); await page.setViewportSize({ width: sample.width, height: sample.height }); await page.goto('/dashboard'); await expect(page.getByRole('main')).toBeVisible(); await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, sample); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto('/profile'); await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en'); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme); await expect(page.getByLabel(translations[sample.language].profileDisplayName, { exact: true })).toBeEnabled(); } function observeWrites(page: Page) { const writes: Array<{ method: string; path: string }> = []; const listener = (request: Request) => { const url = new URL(request.url()); if (url.pathname.startsWith('/api/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) { writes.push({ method: request.method(), path: url.pathname }); } }; page.on('request', listener); return { writes, stop: () => page.off('request', listener) }; } async function collect(page: Page, info: TestInfo, sample: Sample, state: string) { await page.evaluate(async () => { await document.fonts.ready; await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); const geometry = await page.evaluate(() => ({ rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth), width: innerWidth, height: innerHeight, focus: document.activeElement?.getAttribute('aria-label') || document.activeElement?.tagName, regions: Array.from(document.querySelectorAll('main section[aria-labelledby]')).map(element => { const bounds = element.getBoundingClientRect(); const title = document.getElementById(element.getAttribute('aria-labelledby') || ''); return { name: title?.textContent, left: bounds.left, right: bounds.right, width: bounds.width }; }), })); const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze(); const name = `p7-account-${sample.language}-${sample.theme}-${sample.width}-${state}`; await info.attach(`${name}-audit`, { body: JSON.stringify({ state, sample, url: page.url(), geometry, ariaSnapshot: await page.locator('body').ariaSnapshot(), axe: { scope: 'All enabled rules, including explicit target-size; violations/incomplete require contextual triage', violations: axe.violations, incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) }, limits: ['No human screen reader', 'No physical touch/software keyboard', 'No real provider or production qualification'], }), contentType: 'application/json' }); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); expect(geometry.rootOverflow, 'ordinary account chrome reflows without document-root horizontal scrolling').toBe(0); } const matrix: Sample[] = [ { language: 'nb', theme: 'dark', width: 320, height: 844 }, { language: 'en', theme: 'light', width: 390, height: 844 }, { language: 'nb', theme: 'light', width: 768, height: 1024 }, { language: 'en', theme: 'dark', width: 1440, height: 900 }, ]; for (const sample of matrix) { test(`[P7-01 PRES-E06 E08 E09 C01 C07] ${sample.language}/${sample.theme} ${sample.width}px account identity save reload preserves canonical and sibling CVs`, async ({ page, owner }, info) => { test.setTimeout(120_000); const t = translations[sample.language]; const canonical = { version: '1', contact: { fullName: 'Åse Canonical', headline: 'Canonical career headline', location: 'Oslo' }, jobs: [{ title: 'Recorded engineer', company: 'Source-only employer', bullets: ['Recorded work, never an account field'] }], skills: ['C#', 'Norsk'], }; expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: canonical } })).status()).toBe(200); const cvIds: number[] = []; for (const name of ['First protected CV', 'Sibling protected CV']) { const created = await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name, settings: { themeId: 'minimal', language: 'en', headline: `${name} override` } } }); expect(created.status()).toBe(200); cvIds.push((await created.json()).id); } const beforeProfile = await read<{ profile: unknown }>(page.request, '/career/profile'); const beforeVariants = await Promise.all(cvIds.map(id => read(page.request, `/cv/variants/${id}`))); const beforeMe = await read<{ userName: string }>(page.request, '/auth/me'); await enterProfile(page, owner, sample); await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1); await expect(page.getByText(t.profileCvTextMissing, { exact: true })).toBeVisible(); await expect(page.getByText(t.profileCvMissing, { exact: true })).toHaveCount(0); await expect(page.getByText(/NEXT_PUBLIC_/)).toHaveCount(0); for (const title of [t.authStatusTitle, t.twoFactorSectionTitle, t.sessionsSectionTitle]) { await expect(page.getByRole('region', { name: title, exact: true })).toBeVisible(); } const status = page.getByRole('region', { name: t.twoFactorSectionTitle, exact: true }); await expect(status.getByText(t.twoFactorStatusDisabled, { exact: true })).toBeVisible(); const changed = { userName: beforeMe.userName, firstName: 'Åse', lastName: 'Account identity', displayName: 'Åse — documented account identity with a deliberately long personal display name', }; await page.getByLabel(t.profileDisplayName, { exact: true }).fill(changed.displayName); await page.getByLabel(t.profileFirstName, { exact: true }).fill(changed.firstName); await page.getByLabel(t.profileLastName, { exact: true }).fill(changed.lastName); const writes = observeWrites(page); const saved = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/profile' && response.request().method() === 'PUT'); const save = page.getByRole('button', { name: t.profileSaveChanges, exact: true }); await save.focus(); await save.press('Enter'); const response = await saved; expect(response.status()).toBe(204); expect(response.request().postDataJSON()).toEqual(changed); await expect(page.getByLabel(t.profileDisplayName, { exact: true })).toHaveValue(changed.displayName); await expect(save).toBeEnabled(); expect(await read(page.request, '/auth/me')).toMatchObject(changed); await page.reload(); await expect(page.getByLabel(t.profileDisplayName, { exact: true })).toHaveValue(changed.displayName); await expect(page.getByLabel(t.profileFirstName, { exact: true })).toHaveValue(changed.firstName); await expect(page.getByLabel(t.profileLastName, { exact: true })).toHaveValue(changed.lastName); expect((await read<{ profile: unknown }>(page.request, '/career/profile')).profile).toEqual(beforeProfile.profile); expect(await Promise.all(cvIds.map(id => read(page.request, `/cv/variants/${id}`)))).toEqual(beforeVariants); expect(writes.writes.filter(write => write.path.startsWith('/api/career/') || write.path.startsWith('/api/cv/'))).toEqual([]); expect(writes.writes.filter(write => write.path === '/api/auth/profile')).toEqual([{ method: 'PUT', path: '/api/auth/profile' }]); writes.stop(); await info.attach('identity-only-readback', { body: JSON.stringify({ submittedFieldNames: Object.keys(changed), identityReadback: true, reloadReadback: true, canonicalUnchanged: true, bothCvVariantsUnchanged: true, browserCanonicalOrCvWrites: 0, }), contentType: 'application/json' }); await collect(page, info, sample, 'identity-persisted'); }); } test('[P7-01 PRES-E04 E08] failed 2FA status is read-only and deliberate retry restores the actual disabled state', async ({ page, owner }, info) => { test.setTimeout(90_000); const sample: Sample = { language: 'nb', theme: 'dark', width: 320, height: 844 }; const t = translations[sample.language]; let failedReads = 0; const fail = async (route: import('@playwright/test').Route) => { expect(route.request().method()).toBe('GET'); failedReads += 1; return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); }; await page.route('**/api/auth/2fa/status', fail); const writes = observeWrites(page); await enterProfile(page, owner, sample); const region = page.getByRole('region', { name: t.twoFactorSectionTitle, exact: true }); const alert = region.getByRole('alert'); await expect(alert).toBeVisible(); expect(failedReads).toBeGreaterThanOrEqual(1); await expect(region.getByText(t.twoFactorStatusDisabled, { exact: true })).toHaveCount(0); // Hidden or disabled setup controls are both honest unknown-state presentations. expect(await region.getByRole('button', { name: t.twoFactorEnableButton, exact: true }).evaluateAll(buttons => buttons.every(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true'))).toBe(true); expect(writes.writes.filter(write => write.path.startsWith('/api/auth/2fa/'))).toEqual([]); await collect(page, info, sample, 'status-failed-read-only'); await page.unroute('**/api/auth/2fa/status', fail); const loaded = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/2fa/status' && response.request().method() === 'GET'); await alert.getByRole('button', { name: t.retry, exact: true }).press('Enter'); expect((await loaded).status()).toBe(200); await expect(region.getByText(t.twoFactorStatusDisabled, { exact: true })).toBeVisible(); await expect(region.getByRole('button', { name: t.twoFactorEnableButton, exact: true })).toBeEnabled(); expect(await read(page.request, '/auth/2fa/status')).toMatchObject({ enabled: false }); expect(writes.writes.filter(write => write.path.startsWith('/api/auth/2fa/'))).toEqual([]); writes.stop(); await collect(page, info, sample, 'status-read-recovered'); }); test('[P7-01 PRES-E05 E08 E09] native sessions cancel no-write, failed confirmed revoke stays in the dialog, retry revokes others and current logout disposes account', async ({ page, owner, playwright }, info) => { test.setTimeout(120_000); const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 }; const t = translations[sample.language]; const other = await playwright.request.newContext({ userAgent: 'P7 synthetic remote browser - a deliberately long workstation device description for responsive account metadata and named actions' }); try { await login(other, owner.email); const before = await read(page.request, '/auth/sessions'); expect(before).toHaveLength(2); const current = before.find(session => session.isCurrentSession)!; const remote = before.find(session => !session.isCurrentSession)!; expect(current).toBeDefined(); expect(remote.deviceLabel).toContain('deliberately long workstation'); await enterProfile(page, owner, sample); const region = page.getByRole('region', { name: t.sessionsSectionTitle, exact: true }); await expect(region.getByText(remote.deviceLabel!, { exact: true })).toBeVisible(); const trigger = region.getByRole('button', { name: t.sessionsRevokeOthers, exact: true }); await expect(trigger).toBeVisible(); const writes = observeWrites(page); await trigger.focus(); await trigger.press('Enter'); const dialog = page.getByRole('dialog', { name: t.sessionsRevokeOthersConfirmTitle, exact: true }); await expect(dialog).toBeVisible(); // MUI initially focuses its trap container; the next Tab must enter the named dialog. await expect.poll(() => dialog.evaluate(element => element.closest('.MuiDialog-container')?.contains(document.activeElement))).toBe(true); await info.attach('initial-dialog-focus', { body: JSON.stringify(await dialog.evaluate(element => ({ focusedTag: document.activeElement?.tagName, focusedRole: document.activeElement?.getAttribute('role'), inTrapContainer: element.closest('.MuiDialog-container')?.contains(document.activeElement), inNamedSurface: element.contains(document.activeElement), }))), contentType: 'application/json' }); await page.keyboard.press('Tab'); await expect.poll(() => dialog.evaluate(element => element.contains(document.activeElement))).toBe(true); await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused(); await page.keyboard.press('Tab'); await expect(dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true })).toBeFocused(); await page.keyboard.press('Tab'); await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true })).toBeFocused(); await dialog.getByRole('button', { name: t.cancel, exact: true }).press('Enter'); await expect(dialog).toBeHidden(); await expect(trigger).toBeFocused(); expect(writes.writes.filter(write => write.path.startsWith('/api/auth/sessions'))).toEqual([]); expect((await read(page.request, '/auth/sessions')).map(session => session.id).sort()).toEqual(before.map(session => session.id).sort()); expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(200); let failedAttempts = 0; const fail = async (route: import('@playwright/test').Route) => { expect(route.request().method()).toBe('POST'); failedAttempts += 1; return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' }); }; await page.route('**/api/auth/sessions/revoke-others', fail); await trigger.press('Enter'); const confirm = dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true }); await confirm.press('Enter'); await expect(dialog.getByRole('alert')).toBeVisible(); expect(await dialog.getByRole('alert').evaluate(element => Boolean(element.closest('[aria-hidden="true"]')))).toBe(false); await expect(dialog).toBeVisible(); expect(failedAttempts).toBe(1); await page.keyboard.press('Tab'); await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(confirm).toBeFocused(); expect((await read(page.request, '/auth/sessions')).map(session => session.id).sort()).toEqual(before.map(session => session.id).sort()); expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(200); await collect(page, info, sample, 'revoke-others-failed-modal'); await page.unroute('**/api/auth/sessions/revoke-others', fail); const revoked = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/sessions/revoke-others' && response.request().method() === 'POST'); await confirm.press('Enter'); expect((await revoked).status()).toBe(204); await expect(dialog).toBeHidden(); await expect(trigger).toHaveCount(0); expect(await read(page.request, '/auth/sessions')).toEqual([expect.objectContaining({ id: current.id, isCurrentSession: true })]); expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(401); await collect(page, info, sample, 'only-current-session'); const currentRevoke = region.getByRole('button', { name: t.sessionsRevoke, exact: true }); await expect(currentRevoke).toHaveCount(1); const disposed = page.waitForResponse(response => new URL(response.url()).pathname === `/api/auth/sessions/${current.id}` && response.request().method() === 'DELETE'); await currentRevoke.press('Enter'); expect((await disposed).status()).toBe(204); await expect(page).toHaveURL(/\/login\?sessionChanged=1$/); await expect(page.getByRole('region', { name: t.sessionsSectionTitle, exact: true })).toHaveCount(0); await expect(page.getByText(remote.deviceLabel!, { exact: true })).toHaveCount(0); expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401); expect(writes.writes.filter(write => write.path === '/api/auth/sessions/revoke-others')).toHaveLength(2); expect(writes.writes.filter(write => write.method === 'DELETE' && write.path.startsWith('/api/auth/sessions/'))) .toEqual([{ method: 'DELETE', path: `/api/auth/sessions/${current.id}` }]); writes.stop(); await info.attach('native-session-confirmation-readback', { body: JSON.stringify({ initialSessions: 2, canceledRevokeWrites: 0, failedAttempt: 1, explicitRetry: 1, remoteSessionRejectedAfterRetry: true, currentSessionKeptUntilExplicitRevoke: true, currentSessionRevokeDisposedPrivateUi: true, noExternalProviderCalls: true, }), contentType: 'application/json' }); } finally { await other.dispose(); } });