import AxeBuilder from '@axe-core/playwright'; import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, 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 Headers = Record; type Owner = { id: string; email: string; headers: Headers }; type RequestRecord = { channel: 'browser' | 'api'; method: string; path: string; query: string; status?: number }; type Session = { owner: Owner; admin: APIRequestContext; adminHeaders: Headers; requests: RequestRecord[] }; const boundary = 'Actual isolated Identity/CSRF login and non-admin GET denials; administrator System GET diagnostics and SMTP PUT acknowledgements are controlled browser fixtures. No actual SMTP delivery/configuration, AI probe/model calls, maintenance dispatch or production writes. Six paired language/theme widths and automated axe do not establish human/device acceptance.'; function copy(key: string, language: Language = 'en') { return (translations[language] as Record)[key] ?? key; } function browserRequests(session: Session, path: string, method?: string) { return session.requests.filter(value => value.channel === 'browser' && value.path === `/api${path}` && (!method || value.method === method)); } async function call(records: RequestRecord[], api: APIRequestContext, method: 'GET' | 'POST' | 'PUT', path: string, headers?: Headers, data?: unknown) { const result = await api.fetch(`${apiUrl}${path}`, { method, headers, data }); const url = new URL(result.url()); records.push({ channel: 'api', method, path: url.pathname, query: url.search, status: result.status() }); return result; } async function json(response: Pick, status = 200): Promise { expect(response.status(), response.url()).toBe(status); return response.json(); } async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise { expect((await call(records, api, 'POST', '/auth/login', undefined, { email, password })).status()).toBe(200); const me = await json<{ id: string; sessionContext: string }>(await call(records, api, 'GET', '/auth/me')); const token = (await api.storageState()).cookies.find(value => value.name === 'XSRF-TOKEN'); expect(token).toBeDefined(); return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } }; } async function createOwner(records: RequestRecord[], admin: APIRequestContext, headers: Headers) { const email = `p710-system-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await call(records, admin, 'POST', '/users', headers, { email, password, roles: [] })).status()).toBe(200); return email; } const test = base.extend<{ session: Session }>({ session: async ({ page, context, playwright }, provide, info) => { expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String)); const records: RequestRecord[] = []; const forbidden: string[] = []; const fonts: string[] = []; const admin = await playwright.request.newContext(); context.on('request', request => { const url = new URL(request.url()); records.push({ channel: 'browser', method: request.method(), path: url.pathname, query: url.search }); }); context.on('response', result => { const url = new URL(result.url()); const record = [...records].reverse().find(value => value.channel === 'browser' && value.status === undefined && value.method === result.request().method() && value.path === url.pathname && value.query === url.search); if (record) record.status = result.status(); }); await context.route('**/*', route => { const request = route.request(); const url = new URL(request.url()); if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push(url.href); return route.abort(); } const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname); const statusRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname); const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname) || request.method() === 'PUT' && url.pathname === '/api/ai/settings'; if (mailbox && !statusRead || ai && !accountAiRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup\/restore)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); } if (url.pathname.startsWith('/api/admin/system')) { forbidden.push(`${request.method()} ${url.pathname}`); return route.abort(); } return route.continue(); }); await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); }); try { const administrator = await login(records, admin, 'e2e@example.test'); const email = await createOwner(records, admin, administrator.headers); const owner = await login(records, page.request, email); expect((await json<{ entitlements: { ai: boolean } }>(await call(records, page.request, 'GET', '/auth/me'))).entitlements.ai).toBe(false); await provide({ owner, admin, adminHeaders: administrator.headers, requests: records }); expect(forbidden).toEqual([]); } finally { await info.attach('settings-preferences-boundary-and-requests', { body: JSON.stringify({ boundary, requests: records, forbiddenRequests: forbidden, abortedFontStylesheets: fonts }), contentType: 'application/json' }); await admin.dispose(); } }, }); async function start(page: Page, path = '/settings', width = 1440) { await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path); await expect(page.getByRole('main').getByRole('heading', { level: 1 })).toBeVisible(); } async function keyboard(control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await control.press('Enter'); } async function capture(page: Page, info: TestInfo, name: string) { const geometry = await page.getByRole('main').evaluate(main => { const bounds = main.getBoundingClientRect(); const targets = Array.from(main.querySelectorAll('h1,h2,h3,p,button,input,[role="combobox"],[role="status"],.MuiFormControlLabel-root')); // MUI Switch uses a transparent 300%-wide native input; its painted label/control remains in the checked targets. // System diagnostics wrap within the page; no document canvas exception applies. return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => !node.closest('table') && node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden' && !(node instanceof HTMLInputElement && getComputedStyle(node).opacity === '0')).map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) }; }); await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]); await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' }); const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete, qualification: 'All enabled axe rules; raw incomplete contexts retained for root triage, not human screen-reader acceptance.' }), contentType: 'application/json' }); expect(axe.violations).toEqual([]); } function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method()===method && new URL(value.url()).pathname===`/api${path}`); } const smtp = { enabled: true, host: 'smtp.example.test', port: 587, user: 'mailer@example.test', from: 'noreply@example.test', fromName: 'Jobbjakt', enableSsl: true, timeoutMs: 15000, usesOverrides: false, hasPassword: true }; const status = { environment: 'Controlled fixture', contentRoot: '/app', version: '1.2.3', commitSha: 'abc1234', buildStamp: '2026-10-20 UTC', storage: { dataRoot: '/data', dbPath: '/data/jobtracker.db', dbExists: true, dbSizeBytes: 2048, companyCount: 3, jobCount: 7, deletedCount: 1 }, email: smtp, database: { provider: 'mariadb', looksConfigured: true, canConnect: true, target: 'server=db', usesFileStorage: false, warning: null }, runtime: { framework: '.NET 9', osDescription: 'Linux', processArchitecture: 'X64', machineName: 'fixture' }, auth: { required: true, hasJwtKey: true, googleConfigured: false, gmailConfigured: false }, ai: { healthy: true, model: 'fixture-only', device: 'cpu', gpuAvailable: false, ocrAvailable: true, ocrLanguages: 'eng', ollamaConfigured: false, requests: 18, cacheHits: 9, cacheMisses: 9, failures: 0, probeFailures: 0, averageLatencyMs: 42.2, ocrRequests: 5, ocrFailures: 0, averageOcrLatencyMs: 88.4 } }; async function fixtures(page: Page, failStatus: () => boolean = () => false) { await page.route('**/api/admin/system**', route => { const request=route.request(); const path=new URL(request.url()).pathname; expect(request.method()).toBe('GET'); if (path==='/api/admin/system' && failStatus()) return route.fulfill({ status:503, json: { title:'Unavailable' } }); const data=path.endsWith('/email-settings') ? smtp : path.endsWith('/maintenance-jobs') ? [] : path.endsWith('/cv-benchmark') ? { rootPath:'/data/fixture', indexJson:null, reportMarkdown:'Controlled benchmark report', lastUpdatedAtUtc:null } : status; return route.fulfill({ status:200, json:data }); }); } test('[P7-10 System A03 E09] non-admin read denial and controlled initial failure retain GET-only focused recovery', async ({ page, session }) => { for (const path of ['/admin/system','/admin/system/email-settings','/admin/system/cv-benchmark','/admin/system/maintenance-jobs']) expect((await call(session.requests,page.request,'GET',path)).status()).toBe(403); await login(session.requests,page.request,'e2e@example.test'); let failed=true; await fixtures(page,()=>failed); await start(page,'/admin/system',390); const alert=page.getByRole('alert').filter({ hasText:'Unavailable' }); await expect(alert).toBeVisible(); await expect(page.getByText(copy('adminSystemOffline'), { exact:true })).toHaveCount(0); failed=false; const count=browserRequests(session,'/admin/system').length; await keyboard(alert.getByRole('button',{name:copy('retry'),exact:true})); await expect(page.getByText('Controlled fixture',{exact:true})).toBeVisible(); expect(browserRequests(session,'/admin/system').length).toBe(count+1); expect(session.requests.filter(value=>value.channel==='browser' && value.method!=='GET')).toEqual([]); await capture(page,test.info(),'system-status-read-recovery-390-en-light'); }); test('[P7-10 System A03] SMTP submitted acknowledgement preserves newer edits and refresh draft without operational calls', async ({page,session},info) => { await login(session.requests,page.request,'e2e@example.test'); await fixtures(page); await start(page,'/admin/system'); await keyboard(page.getByRole('tab',{name:copy('adminSystemSettingsTab'),exact:true})); const panel=page.getByRole('tabpanel',{name:copy('adminSystemSettingsTab'),exact:true}); await expect(panel).toBeVisible(); await panel.getByLabel(copy('adminSystemHost'),{exact:true}).fill('submitted.example.test'); await panel.getByLabel(copy('adminSystemPassword'),{exact:true}).fill('synthetic-submitted'); let release!:()=>void; const held=new Promise(resolve=>{release=resolve;}); let payload:Record|undefined; await page.route('**/api/admin/system/email-settings',async route=>{ if(route.request().method()!=='PUT') return route.fallback(); payload=route.request().postDataJSON(); await held; return route.fulfill({status:200,json:{...smtp,host:'submitted.example.test',usesOverrides:true}}); }); await keyboard(panel.getByRole('button',{name:copy('adminSystemSaveSettings'),exact:true})); await expect.poll(()=>payload?.host).toBe('submitted.example.test'); expect(payload?.password).toBe('synthetic-submitted'); expect(payload?.clearPassword).toBe(false); await panel.getByLabel(copy('adminSystemHost'),{exact:true}).fill('newer.example.test'); await panel.getByLabel(copy('adminSystemPassword'),{exact:true}).fill('synthetic-newer'); await expect(panel.getByRole('button',{name:copy('adminSystemSendTestEmail'),exact:true})).toBeDisabled(); await expect(page.getByRole('button',{name:copy('adminSystemRefresh'),exact:true})).toBeDisabled(); release(); await expect(panel.getByRole('button',{name:copy('adminSystemSaveSettings'),exact:true})).toBeEnabled(); await expect(panel.getByLabel(copy('adminSystemHost'),{exact:true})).toHaveValue('newer.example.test'); await expect(panel.getByLabel(copy('adminSystemPassword'),{exact:true})).toHaveValue('synthetic-newer'); await expect(panel.getByText(copy('adminSystemUnsavedChanges'),{exact:true})).toBeVisible(); await keyboard(page.getByRole('button',{name:copy('adminSystemRefresh'),exact:true})); await expect(page.getByRole('button',{name:copy('adminSystemRefresh'),exact:true})).toBeEnabled(); await expect(panel.getByLabel(copy('adminSystemHost'),{exact:true})).toHaveValue('newer.example.test'); await expect(panel.getByLabel(copy('adminSystemPassword'),{exact:true})).toHaveValue('synthetic-newer'); expect(session.requests.filter(value=>value.channel==='browser' && value.method!=='GET').map(value=>[value.method,value.path])).toEqual([['PUT','/api/admin/system/email-settings']]); await capture(page,info,'system-newer-smtp-draft-1440-en-light'); }); test('[P7-10 System A03] bilingual diagnostic and settings panels reflow at six finite theme widths',async({page,session},info)=>{ const administrator=await login(session.requests,page.request,'e2e@example.test'); await fixtures(page); for(const sample of [{width:320,language:'nb',mode:'dark'},{width:390,language:'en',mode:'light'},{width:768,language:'nb',mode:'light'},{width:1024,language:'en',mode:'dark'},{width:1440,language:'en',mode:'light'},{width:1920,language:'nb',mode:'dark'}] as const){ expect((await call(session.requests,page.request,'PUT','/auth/preferences/language',administrator.headers,{language:sample.language==='nb'?'nb-NO':'en'})).status()).toBe(204); await page.addInitScript(value=>{localStorage.setItem('uiLanguage',value.language);localStorage.setItem('jobtracker.themeMode',value.mode);},sample); await start(page,'/admin/system',sample.width); await expect(page.getByText('Controlled fixture',{exact:true})).toBeVisible(); await capture(page,info,`system-status-${sample.width}-${sample.language}-${sample.mode}`); await keyboard(page.getByRole('tab',{name:copy('adminSystemSettingsTab',sample.language),exact:true})); await expect(page.getByRole('tabpanel',{name:copy('adminSystemSettingsTab',sample.language),exact:true})).toBeVisible(); await capture(page,info,`system-settings-${sample.width}-${sample.language}-${sample.mode}`); } expect(session.requests.filter(value=>value.channel==='browser' && value.method!=='GET')).toEqual([]); });