fix(i18n): preserve public auth commands and feedback across language changes
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
// 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 out = path.resolve(import.meta.dirname, '../../docs/ui-modernisation/visual-correction/evidence/public-entry');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const report = { screens: [], journeys: [], confirmations: [], signIns: [], challenges: [], unexpectedWrites: [], unavailableReads: [], blockedExternal: [], errors: [] };
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const configurations = process.env.A3_PILOT ? [[320, 'en', 'light']] : [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920].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 (!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 (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); };
|
||||
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 * 14); assert.equal(report.journeys.length, configurations.length); assert.equal(report.confirmations.length, configurations.length * 2); assert.equal(report.signIns.length, configurations.length); assert.equal(report.challenges.length, 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(`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();
|
||||
}
|
||||
Reference in New Issue
Block a user