225 lines
16 KiB
TypeScript
225 lines
16 KiB
TypeScript
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<Sample, 'language' | 'mode' | 'width' | 'height'>) {
|
|
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<HTMLElement>('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(); }
|
|
});
|
|
}
|