133 lines
16 KiB
TypeScript
133 lines
16 KiB
TypeScript
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<string, string>;
|
|
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<string, string>)[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<T>(response: Pick<APIResponse, 'status' | 'url' | 'json'>, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
|
|
async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise<Owner> {
|
|
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<void>(resolve=>{release=resolve;}); let payload:Record<string,unknown>|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([]);
|
|
});
|