import AxeBuilder from '@axe-core/playwright'; import { expect, test, type APIRequestContext, type Page, 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 Mode = 'light' | 'dark'; type Sample = { path: string; title: keyof typeof translations.en; language: Language; mode: Mode; width: number; height: number; screenshot?: boolean }; // Finite presentation samples, not a Cartesian route/viewport certification. // Public forms have no density control; compact/comfortable belongs to the account shell pack. const samples: Sample[] = [ { path: '/login', title: 'signInTitle', language: 'nb', mode: 'dark', width: 320, height: 740, screenshot: true }, { path: '/login', title: 'signInTitle', language: 'en', mode: 'light', width: 1280, height: 900 }, { path: '/register', title: 'createAccount', language: 'nb', mode: 'light', width: 390, height: 844, screenshot: true }, { path: '/register', title: 'createAccount', language: 'en', mode: 'dark', width: 1440, height: 900 }, { path: '/forgot-password', title: 'forgotPasswordTitle', language: 'en', mode: 'dark', width: 360, height: 740, screenshot: true }, { path: '/forgot-password', title: 'forgotPasswordTitle', language: 'nb', mode: 'light', width: 1024, height: 768 }, { path: '/reset-password', title: 'resetPasswordTitle', language: 'en', mode: 'light', width: 320, height: 740, screenshot: true }, { path: '/reset-password', title: 'resetPasswordTitle', language: 'nb', mode: 'dark', width: 768, height: 1024 }, { path: '/verify-email', title: 'verifyEmailTitle', language: 'en', mode: 'light', width: 1920, height: 1080 }, { path: '/verify-email', title: 'verifyEmailTitle', language: 'nb', mode: 'dark', width: 390, height: 844, screenshot: true }, { path: '/confirm-email-change', title: 'confirmEmailChangeTitle', language: 'en', mode: 'dark', width: 1024, height: 768, screenshot: true }, { path: '/confirm-email-change', title: 'confirmEmailChangeTitle', language: 'nb', mode: 'light', width: 768, height: 1024 }, { path: '/microsoft-legacy-relink', title: 'microsoftLegacyRelinkTitle', language: 'nb', mode: 'light', width: 1280, height: 900 }, { path: '/microsoft-legacy-relink', title: 'microsoftLegacyRelinkTitle', language: 'en', mode: 'dark', width: 1440, height: 900, screenshot: true }, { path: '/register', title: 'createAccount', language: 'nb', mode: 'dark', width: 1280, height: 320, screenshot: true }, ]; test.beforeEach(async ({ context, page }) => { 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(); }); await page.emulateMedia({ reducedMotion: 'reduce' }); }); async function configure(page: Page, sample: Pick) { await page.setViewportSize({ width: sample.width, height: sample.height }); await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, sample); } async function audit(page: Page, info: TestInfo, name: string, context: object, screenshot = true) { // Inspect natural document origin even when autofocus scrolled a short viewport. await page.evaluate(() => window.scrollTo(0, 0)); const geometry = await page.evaluate(() => { const main = document.querySelector('main')!; const heading = main.querySelector('h1')!; const controls = Array.from(main.querySelectorAll('input,button,select,textarea,[role="button"]')).flatMap(element => { const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' ? [{ tag: element.tagName, type: element.getAttribute('type'), text: element.textContent?.trim(), left: rect.left, right: rect.right, width: rect.width, height: rect.height }] : []; }); return { viewportWidth: innerWidth, viewportHeight: innerHeight, pageOverflow: document.documentElement.scrollWidth - innerWidth, mainOverflow: main.scrollWidth - main.clientWidth, headingTop: heading.getBoundingClientRect().top, mainName: main.getAttribute('aria-labelledby'), h1Count: document.querySelectorAll('h1').length, controls }; }); // No tags, exclusions or disabled rules: retain incomplete nodes for manual triage. const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-all-enabled-axe`, { body: JSON.stringify({ context, geometry, axe }, null, 2), contentType: 'application/json' }); if (screenshot) { const path = info.outputPath(`${name}.png`); await page.screenshot({ path, fullPage: true, animations: 'disabled' }); await info.attach(name, { path, contentType: 'image/png' }); } expect(geometry.h1Count).toBe(1); expect(geometry.pageOverflow).toBeLessThanOrEqual(1); expect(geometry.mainOverflow).toBeLessThanOrEqual(1); expect(geometry.headingTop).toBeGreaterThanOrEqual(0); for (const control of geometry.controls) { expect(control.left, `${control.tag} ${control.text ?? control.type}`).toBeGreaterThanOrEqual(-1); expect(control.right, `${control.tag} ${control.text ?? control.type}`).toBeLessThanOrEqual(geometry.viewportWidth + 1); } expect(axe.violations).toEqual([]); } for (const sample of samples) { test(`[P7-01 public/auth] ${sample.path} ${sample.language}/${sample.mode} ${sample.width}x${sample.height}: landmarks, labels and reflow`, async ({ page }, info) => { const t = translations[sample.language]; await configure(page, sample); await page.goto(sample.path); const main = page.getByRole('main', { name: t[sample.title], exact: true }); await expect(main).toBeVisible(); await expect(main.getByRole('heading', { name: t[sample.title], level: 1, exact: true })).toBeVisible(); await expect(page.getByRole('main')).toHaveCount(1); await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.mode); if (sample.path === '/login' || sample.path === '/register') { await expect(main.getByText(t.authRequired, { exact: true })).toBeVisible(); await expect(main.getByLabel(sample.path === '/login' ? t.usernameOrEmail : t.profileEmail, { exact: true })).toBeVisible(); await expect(main.getByLabel(sample.path === '/login' ? t.profileCurrentPassword : t.profileNewPassword, { exact: true })).toHaveAttribute('autocomplete', sample.path === '/login' ? 'current-password' : 'new-password'); if (sample.path === '/register') { await expect(main.getByRole('button', { name: t.createAccount, exact: true })).toBeDisabled(); await expect(main.getByRole('alert')).toContainText(t.registrationUnavailable); await expect(main.getByLabel(t.confirmPassword, { exact: true })).toBeVisible(); } } const missing = sample.path === '/reset-password' ? t.missingResetLinkInfo : sample.path === '/verify-email' ? t.missingVerifyLinkInfo : sample.path === '/confirm-email-change' ? t.missingEmailChangeLinkInfo : sample.path === '/microsoft-legacy-relink' ? t.microsoftLegacyRelinkMissing : undefined; if (missing) await expect(main.getByRole('alert')).toHaveText(missing); await audit(page, info, 'public-auth-presentation', { ...sample, state: missing ? 'missing-link' : sample.path === '/register' ? 'registration-disabled' : 'ready', density: 'not-applicable', browserZoom: 'not-measured' }, Boolean(sample.screenshot)); }); } for (const variant of [{ language: 'en', mode: 'light', width: 1280, height: 900 }, { language: 'nb', mode: 'dark', width: 390, height: 844 }] as const) { test(`[P7-01 public/auth] ${variant.language} keyboard recovery entry preserves the prefill, validates locally and returns focus to sign-in`, async ({ page }, info) => { const t = translations[variant.language]; await configure(page, variant); await page.goto('/login'); const username = page.getByLabel(t.usernameOrEmail, { exact: true }); await expect(username).toBeFocused(); await username.fill('prefill+proof@example.test'); await page.getByRole('button', { name: t.forgotPassword, exact: true }).press('Enter'); await expect(page).toHaveURL(/\/forgot-password\?email=prefill%2Bproof%40example\.test$/); const email = page.getByLabel(t.profileEmail, { exact: true }); await expect(email).toHaveValue('prefill+proof@example.test'); await expect(email).toBeFocused(); const commands: string[] = []; const record = (request: import('@playwright/test').Request) => { if (request.method() === 'POST' && request.url().endsWith('/auth/request-password-reset')) commands.push(request.url()); }; page.on('request', record); await email.fill(''); await page.getByRole('button', { name: t.forgotPasswordSubmit, exact: true }).press('Enter'); await expect(email).toBeFocused(); await expect(email).toHaveAttribute('aria-invalid', 'true'); await expect(email).toHaveAccessibleDescription(t.emailRequired); expect(commands).toEqual([]); page.off('request', record); await audit(page, info, 'public-auth-reset-request-validation', { ...variant, route: '/forgot-password', state: 'client-validation', requests: commands }); await page.getByRole('button', { name: t.backToLogin, exact: true }).press('Enter'); await expect(page).toHaveURL(/\/login$/); await expect(page.getByLabel(t.usernameOrEmail, { exact: true })).toBeFocused(); }); } // Three real token-error calls stay within the unchanged auth-email policy's five-call budget. for (const row of [ { language: 'en', mode: 'light', width: 1280, height: 900, path: '/verify-email?userId=p7-nonexistent-user&token=invalid-proof', endpoint: '/auth/verify-email', title: 'verifyEmailTitle' }, { language: 'nb', mode: 'dark', width: 390, height: 844, path: '/confirm-email-change?userId=p7-nonexistent-user&email=unused%40example.test&token=invalid-proof', endpoint: '/auth/email-change/confirm', title: 'confirmEmailChangeTitle' }, { language: 'nb', mode: 'light', width: 768, height: 1024, path: '/reset-password?email=p7-nonexistent-account%40example.test&token=invalid-proof', endpoint: '/auth/reset-password', title: 'resetPasswordTitle' }, ] as const) { test(`[P7-01 public/auth] real ${row.endpoint} invalid-token rejection retains a usable page`, async ({ page }, info) => { const t = translations[row.language]; await configure(page, row); const response = page.waitForResponse(reply => new URL(reply.url()).pathname === `/api${row.endpoint}` && reply.request().method() === 'POST'); await page.goto(row.path); const main = page.getByRole('main', { name: t[row.title], exact: true }); if (row.endpoint === '/auth/reset-password') { await main.getByLabel(t.profileNewPassword, { exact: true }).fill('NewPassword123!'); await main.getByLabel(t.confirmPassword, { exact: true }).fill('NewPassword123!'); await main.getByRole('button', { name: t.updatePassword, exact: true }).press('Enter'); } const reply = await response; expect(reply.status()).toBe(400); const error = main.getByRole('alert'); await expect(error).toBeVisible(); await expect(error).not.toBeEmpty(); await expect(page).toHaveURL(`http://localhost:3300${row.path}`); if (row.endpoint === '/auth/reset-password') { await expect(main.getByLabel(t.profileNewPassword, { exact: true })).toHaveValue('NewPassword123!'); await expect(main.getByLabel(t.confirmPassword, { exact: true })).toHaveValue('NewPassword123!'); await expect(main.getByRole('button', { name: t.updatePassword, exact: true })).toBeEnabled(); } await audit(page, info, 'public-auth-invalid-token', { ...row, state: 'actual-http-400', errorText: await error.innerText(), errorSource: 'existing server response; no frontend translation claim for server-authored text', persistedRecovery: 'covered by separate isolated SMTP pack' }); await main.getByRole('button', { name: t.backToLogin, exact: true }).press('Enter'); await expect(page.getByLabel(t.usernameOrEmail, { exact: true })).toBeFocused(); }); } async function login(api: APIRequestContext, email: string) { expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200); const me = await api.get(`${apiUrl}/auth/me`); expect(me.status()).toBe(200); return { 'X-CSRF-TOKEN': (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': (await me.json()).sessionContext as string }; } for (const variant of [{ language: 'en', mode: 'dark', width: 1280, height: 900 }, { language: 'nb', mode: 'light', width: 320, height: 740 }] as const) { test(`[P7-01 public/auth] ${variant.language} rejected sign-in retains credentials and the exact protected return route`, async ({ playwright, page }, info) => { const t = translations[variant.language]; const admin = await playwright.request.newContext(); const owner = await playwright.request.newContext(); try { const adminHeaders = await login(admin, 'e2e@example.test'); const email = `p7-public-auth-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200); const ownerHeaders = await login(owner, email); expect((await owner.put(`${apiUrl}/auth/preferences/language`, { headers: ownerHeaders, data: { language: variant.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204); await configure(page, variant); const requested = '/analytics?months=%36#return-proof'; await page.goto(requested); await expect(page).toHaveURL('http://localhost:3300/'); await page.getByRole('button', { name: t.signInTitle, exact: true }).first().press('Enter'); const username = page.getByLabel(t.usernameOrEmail, { exact: true }); const currentPassword = page.getByLabel(t.profileCurrentPassword, { exact: true }); await expect(username).toBeFocused(); await username.fill(email); await currentPassword.fill('IncorrectPassword123!'); const rejected = page.waitForResponse(response => response.url() === `${apiUrl}/auth/login` && response.request().method() === 'POST'); await page.getByRole('button', { name: t.signInTitle, exact: true }).press('Enter'); expect((await rejected).status()).toBe(401); await expect(page.getByRole('main').getByRole('alert')).toBeVisible(); await expect(username).toHaveValue(email); await expect(currentPassword).toHaveValue('IncorrectPassword123!'); await expect(page).toHaveURL('http://localhost:3300/login'); await audit(page, info, 'public-auth-sign-in-error', { ...variant, requested, state: 'actual-http-401', provider: 'local' }); await currentPassword.fill(password); await page.getByRole('button', { name: t.signInTitle, exact: true }).press('Enter'); await expect(page).toHaveURL(`http://localhost:3300${requested}`); await expect(page.getByRole('heading', { name: t.analytics, level: 1, exact: true })).toBeVisible(); const me = await page.request.get(`${apiUrl}/auth/me`); expect(me.status()).toBe(200); expect((await me.json()).email).toBe(email); await info.attach('public-auth-protected-return', { body: JSON.stringify({ ...variant, requested, returned: new URL(page.url()).pathname + new URL(page.url()).search + new URL(page.url()).hash, actualAuthenticatedEmail: email }), contentType: 'application/json' }); } finally { await owner.dispose(); await admin.dispose(); } }); }