135 lines
16 KiB
JavaScript
135 lines
16 KiB
JavaScript
// Actual public routes; synthetic confirmation/MFA responses, no real identity or provider access.
|
|
import { chromium, expect } from '@playwright/test';
|
|
import AxeBuilder from '@axe-core/playwright';
|
|
import ts from 'typescript';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
const compiled = ts.transpileModule(fs.readFileSync(new URL('../src/i18n/translations.ts', import.meta.url), 'utf8'), { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText;
|
|
const { translations } = await import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`);
|
|
const landingAudit = process.env.A3_LANDING_AUDIT === '1';
|
|
const out = path.resolve(import.meta.dirname, landingAudit ? '../../docs/ui-modernisation/visual-correction/evidence/landing-baseline' : '../../docs/ui-modernisation/visual-correction/evidence/public-entry');
|
|
fs.mkdirSync(out, { recursive: true });
|
|
const report = { screens: [], journeys: [], confirmations: [], signIns: [], challenges: [], preferenceDenials: [], unexpectedWrites: [], unavailableReads: [], blockedExternal: [], errors: [] };
|
|
const browser = await chromium.launch();
|
|
try {
|
|
const widths = landingAudit ? [320, 768, 1440] : [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920];
|
|
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : widths.flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode])));
|
|
for (const [width, language, mode] of configurations) {
|
|
console.log(`Checking ${width}/${language}/${mode}`);
|
|
const other = language === 'en' ? 'nb' : 'en';
|
|
const text = (locale, key) => { assert.ok(translations[locale][key], key); return translations[locale][key]; };
|
|
const t = key => text(language, key);
|
|
const context = await browser.newContext({ viewport: { width, height: 1000 }, reducedMotion: 'reduce' });
|
|
const page = await context.newPage(); page.on('pageerror', error => report.errors.push(error.message));
|
|
await page.addInitScript(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
|
let configReads = 0, confirmations = 0, signIns = 0, challenges = 0;
|
|
await page.route('**/*', route => {
|
|
const req = route.request(), url = new URL(req.url()), p = url.pathname;
|
|
if (url.origin !== 'http://localhost:4338' && !(url.origin === 'http://localhost:5202' && p.startsWith('/api/'))) { report.blockedExternal.push(url.origin); return route.abort(); }
|
|
if (landingAudit && p === '/mockups/dashboard.svg') {
|
|
const mockup = fs.readFileSync(path.resolve(import.meta.dirname, '../out/mockups/dashboard.svg'), 'utf8');
|
|
const safeMockup = mockup.replace(/[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}/g, 'alex@example.test');
|
|
return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: safeMockup });
|
|
}
|
|
if (!p.startsWith('/api/')) return route.continue();
|
|
const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
|
if (req.method() === 'GET' && p === '/api/auth/config') { configReads++; return answer({ requireAuth: true, localEnabled: true, allowRegistration: true, requireEmailVerification: true, googleEnabled: false, microsoftEnabled: false, turnstileEnabled: false }); }
|
|
if (landingAudit && req.method() === 'GET' && p === '/api/auth/me') return route.fulfill({ status: 401, contentType: 'application/json', body: '{}' });
|
|
if (landingAudit && req.method() === 'PUT' && p === '/api/auth/preferences/language') {
|
|
const preference = req.postDataJSON().language;
|
|
assert.ok(['en', 'nb-NO'].includes(preference));
|
|
report.preferenceDenials.push({ width, language, mode, preference });
|
|
return route.fulfill({ status: 401, contentType: 'application/json', body: '{}' });
|
|
}
|
|
if (landingAudit && req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
|
|
if (req.method() === 'POST' && ['/api/auth/verify-email', '/api/auth/email-change/confirm'].includes(p)) {
|
|
const expected = { userId: 'synthetic-owner', ...(p.includes('email-change') ? { email: 'synthetic@example.test' } : {}), token: 'synthetic-confirmation' };
|
|
assert.deepEqual(req.postDataJSON(), expected); confirmations++; report.confirmations.push({ width, language, mode, path: p }); return answer({});
|
|
}
|
|
if (req.method() === 'POST' && p === '/api/auth/login') {
|
|
const body = req.postDataJSON(); assert.equal(body.email, 'synthetic@example.test'); assert.equal(body.password, 'Synthetic challenge example'); assert.equal(typeof body.rememberMe, 'boolean'); assert.deepEqual(Object.keys(body).sort(), ['email', 'password', 'rememberMe']);
|
|
signIns++; report.signIns.push({ width, language, mode }); return answer({ requiresTwoFactor: true, pendingToken: 'synthetic-pending' });
|
|
}
|
|
if (req.method() === 'POST' && p === '/api/auth/2fa/challenge') {
|
|
assert.deepEqual(req.postDataJSON(), { pendingToken: 'synthetic-pending', code: '000000', trustDevice: false }); challenges++; report.challenges.push({ width, language, mode });
|
|
return route.fulfill({ status: 429, contentType: 'application/problem+json', body: '{}' });
|
|
}
|
|
if (req.method() !== 'GET') { report.unexpectedWrites.push(p); return route.abort(); }
|
|
report.unavailableReads.push(p); return route.fulfill({ status: 503, contentType: 'application/json', body: '{}' });
|
|
});
|
|
const scan = async state => {
|
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false, `overflow:${state}`);
|
|
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa']).analyze();
|
|
report.screens.push({ width, language, mode, state, violations: result.violations }); assert.deepEqual(result.violations, [], state);
|
|
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); await page.screenshot({ path: path.join(out, `${state}-${language}-${mode}-${width}.png`), fullPage: true });
|
|
};
|
|
const selectLanguage = locale => page.getByRole('button', { name: locale === 'en' ? 'English' : 'Norsk bokmål', exact: true }).click();
|
|
const ready = async (url, title, config = false) => { await page.goto(`http://localhost:4338${url}`); await expect(page.getByRole('heading', { level: 1, name: t(title), exact: true })).toBeVisible(); if (config) await expect(page.getByText(t('loading'), { exact: true })).toHaveCount(0); };
|
|
if (landingAudit) {
|
|
const landing = async locale => {
|
|
const heading = `${text(locale, 'landingHeroBefore')} ${text(locale, 'landingHeroAccent')}.`;
|
|
await expect(page.getByRole('heading', { level: 1, name: heading, exact: true })).toBeVisible();
|
|
};
|
|
await page.goto('http://localhost:4338/');
|
|
await landing(language);
|
|
await expect.poll(() => page.locator('img[src^="/mockups/"]').evaluateAll(images => images.filter(img => img.complete).length)).toBe(3);
|
|
if (!report.rawHtml) {
|
|
const response = await page.request.get('http://localhost:4338/');
|
|
assert.equal(response.status(), 200);
|
|
report.rawHtml = await page.evaluate(html => {
|
|
const raw = new DOMParser().parseFromString(html, 'text/html');
|
|
return { language: raw.documentElement.lang, title: raw.title, description: raw.querySelector('meta[name=description]')?.content ?? null, canonical: raw.querySelector('link[rel=canonical]')?.getAttribute('href') ?? null, alternateLanguages: [...raw.querySelectorAll('link[hreflang]')].map(link => link.hreflang), headings: [...raw.querySelectorAll('body h1')].map(heading => heading.textContent) };
|
|
}, await response.text());
|
|
}
|
|
const layout = await page.evaluate(() => ({
|
|
pageOverflow: document.documentElement.scrollWidth > innerWidth + 1,
|
|
landmarks: [...document.querySelectorAll('header, main, footer, nav')].map(node => node.tagName.toLowerCase()),
|
|
mockups: [...document.querySelectorAll('img[src^="/mockups/"]')].map(img => ({ src: img.getAttribute('src'), loaded: img.complete && img.naturalWidth > 0 })),
|
|
}));
|
|
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa', 'best-practice']).analyze();
|
|
report.screens.push({ width, language, mode, state: 'landing-baseline', layout, violations: result.violations });
|
|
await page.screenshot({ path: path.join(out, `landing-${language}-${mode}-${width}.png`), fullPage: true });
|
|
await selectLanguage(other); await landing(other);
|
|
assert.equal(await page.evaluate(() => localStorage.getItem('uiLanguage')), other);
|
|
await selectLanguage(language); await landing(language);
|
|
await page.getByRole('link', { name: t('landingSeeFeatures'), exact: true }).press('Enter');
|
|
await expect.poll(() => new URL(page.url()).hash).toBe('#features');
|
|
await page.getByRole('button', { name: t('landingSignIn'), exact: true }).first().press('Enter');
|
|
await expect(page.getByRole('heading', { name: t('signInTitle'), level: 1, exact: true })).toBeVisible();
|
|
await page.goto('http://localhost:4338/'); await landing(language);
|
|
await page.getByRole('button', { name: t('landingPlanFreeCta'), exact: true }).press('Enter');
|
|
await expect(page.getByRole('heading', { name: t('createAccount'), level: 1, exact: true })).toBeVisible();
|
|
report.journeys.push({ width, language, mode, publicLanguageSwitch: 'PASS', featuresAnchorAndAuthNavigation: 'PASS', submissions: 'NOT RUN' });
|
|
await context.close(); continue;
|
|
}
|
|
await ready('/login', 'signInTitle', true); await scan('login');
|
|
await page.getByLabel(t('usernameOrEmail'), { exact: true }).fill('synthetic@example.test'); await page.getByLabel(t('profileCurrentPassword'), { exact: true }).fill('Synthetic unsubmitted example');
|
|
const reads = configReads; await selectLanguage(other);
|
|
await expect(page.getByLabel(text(other, 'usernameOrEmail'), { exact: true })).toHaveValue('synthetic@example.test'); await expect(page.getByLabel(text(other, 'profileCurrentPassword'), { exact: true })).toHaveValue('Synthetic unsubmitted example');
|
|
assert.equal(await page.evaluate(() => localStorage.getItem('uiLanguage')), other); assert.equal(configReads, reads); assert.equal(signIns, 0);
|
|
await selectLanguage(language); await page.getByLabel(t('usernameOrEmail'), { exact: true }).fill(''); await page.getByLabel(t('profileCurrentPassword'), { exact: true }).fill('');
|
|
await page.getByRole('button', { name: t('signInTitle'), exact: true }).click(); await expect(page.getByText(t('usernameOrEmailRequired'), { exact: true })).toBeVisible(); await scan('login-validation');
|
|
await ready('/register', 'createAccount', true); await scan('register'); await page.getByRole('button', { name: t('createAccount'), exact: true }).click(); await expect(page.getByText(t('emailRequired'), { exact: true })).toBeVisible(); await scan('register-validation');
|
|
await ready('/forgot-password', 'forgotPasswordTitle'); await scan('password-request'); await page.getByRole('button', { name: t('forgotPasswordSubmit'), exact: true }).click(); await expect(page.getByText(t('emailRequired'), { exact: true })).toBeVisible(); await scan('password-request-validation');
|
|
await ready('/reset-password?email=synthetic%40example.test&token=synthetic', 'resetPasswordTitle'); await expect(page.getByRole('button', { name: t('updatePassword'), exact: true })).toBeEnabled(); await scan('password-reset');
|
|
await page.getByRole('button', { name: t('updatePassword'), exact: true }).click(); await expect(page.getByLabel(t('profileNewPassword'), { exact: true })).toHaveAttribute('aria-invalid', 'true'); await scan('password-reset-validation');
|
|
for (const [url, title, errorKey, state] of [['/verify-email', 'verifyEmailTitle', 'missingVerifyLinkInfo', 'verify-missing'], ['/confirm-email-change', 'confirmEmailChangeTitle', 'missingEmailChangeLinkInfo', 'email-change-missing'], ['/microsoft-legacy-relink', 'microsoftLegacyRelinkTitle', 'microsoftLegacyRelinkMissing', 'microsoft-recovery-missing']]) {
|
|
await ready(url, title); await expect(page.getByRole('main').getByRole('alert')).toContainText(t(errorKey)); if (state === 'microsoft-recovery-missing') await expect(page.getByRole('button', { name: t('continueWithMicrosoft'), exact: true })).toBeDisabled(); await scan(state);
|
|
}
|
|
assert.equal(confirmations, 0); assert.equal(signIns, 0); assert.equal(challenges, 0);
|
|
for (const [url, title, successKey, state] of [['/verify-email?userId=synthetic-owner&token=synthetic-confirmation', 'verifyEmailTitle', 'verifyEmailSuccess', 'verify-success'], ['/confirm-email-change?userId=synthetic-owner&email=synthetic%40example.test&token=synthetic-confirmation', 'confirmEmailChangeTitle', 'confirmEmailChangeSuccess', 'email-change-success']]) {
|
|
const expected = confirmations + 1; await ready(url, title); await expect(page.getByRole('status')).toContainText(t(successKey)); await selectLanguage(other); await expect(page.getByRole('status')).toContainText(text(other, successKey)); await selectLanguage(language); assert.equal(confirmations, expected); await scan(state);
|
|
}
|
|
await ready('/login', 'signInTitle', true); await page.getByLabel(t('usernameOrEmail'), { exact: true }).fill('synthetic@example.test'); await page.getByLabel(t('profileCurrentPassword'), { exact: true }).fill('Synthetic challenge example'); await page.getByRole('button', { name: t('signInTitle'), exact: true }).click();
|
|
await page.getByLabel(t('twoFactorCodeLabel'), { exact: true }).fill('000000'); await page.getByRole('button', { name: t('twoFactorVerify'), exact: true }).click(); await expect(page.getByRole('main').getByRole('alert')).toContainText(t('twoFactorRateLimited'));
|
|
await selectLanguage(other); await expect(page.getByRole('main').getByRole('alert')).toContainText(text(other, 'twoFactorRateLimited')); await expect(page.getByLabel(text(other, 'twoFactorCodeLabel'), { exact: true })).toHaveValue('000000'); await selectLanguage(language); await scan('mfa-rate-limit');
|
|
assert.equal(confirmations, 2); assert.equal(signIns, 1); assert.equal(challenges, 1); report.journeys.push({ width, language, mode, publicLanguageAndDraft: 'PASS', clientValidationWithoutSubmission: 'PASS', confirmationLocaleWithoutReplay: 'PASS', mfaRateLimitAndCodeRetained: 'PASS' }); await context.close();
|
|
}
|
|
assert.equal(report.screens.length, configurations.length * (landingAudit ? 1 : 14)); assert.equal(report.journeys.length, configurations.length); assert.equal(report.confirmations.length, landingAudit ? 0 : configurations.length * 2); assert.equal(report.signIns.length, landingAudit ? 0 : configurations.length); assert.equal(report.challenges.length, landingAudit ? 0 : configurations.length);
|
|
assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.deepEqual(report.unavailableReads, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com'));
|
|
console.log(landingAudit ? `Audited ${report.screens.length} existing landing states. ${report.journeys.length} controlled navigation journeys passed. See recorded findings.` : `PASS:${report.journeys.length} public journeys,${report.screens.length} scans/captures; synthetic confirmations and MFA only.`);
|
|
} finally {
|
|
fs.writeFileSync(path.join(out, process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['Every API response is intercepted; no real account, password, email delivery, verification, MFA or provider activation.', 'Synthetic confirmation tokens and challenge values cannot qualify server-side token, rate-limit or authentication enforcement.', 'No native/human, integrated security, release or production qualification.'] }, null, 2)); await browser.close();
|
|
}
|