Files
jobtrackingapp/job-tracker-ui/e2e/phase7-authentication.spec.ts

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(); }
});
}