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

337 lines
20 KiB
TypeScript

import AxeBuilder from '@axe-core/playwright';
import {
expect, test as base, type APIRequestContext, type BrowserContext,
type Page, type Request, 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 Theme = 'light' | 'dark';
type Sample = { language: Language; theme: Theme; width: number; height: number };
type Owner = { email: string; id: string; headers: Record<string, string> };
type Session = {
id: string; deviceLabel: string | null; lastSeenAtUtc: string;
expiresAtUtc: string; isCurrentSession: boolean;
};
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const response = await api.get(`${apiUrl}/auth/me`);
expect(response.status()).toBe(200);
const me = await response.json();
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
expect(csrf).toBeDefined();
expect(me.sessionContext).toEqual(expect.any(String));
return { id: me.id as string, headers: {
'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext as string,
} };
}
async function localOnly(context: BrowserContext) {
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();
});
}
// Normal public login and administrator user creation in the runner's disposable local API.
// No alternative authentication endpoint, role elevation or provider request is introduced.
const test = base.extend<{ owner: Owner }>({
owner: async ({ page, context, playwright }, provide) => {
const admin = await playwright.request.newContext();
try {
const adminIdentity = await login(admin, 'e2e@example.test');
const email = `p7-account-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, {
headers: adminIdentity.headers, data: { email, password, roles: [] },
})).status()).toBe(200);
const identity = await login(page.request, email);
await localOnly(context);
await context.addInitScript(() => {
if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en');
if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light');
});
await provide({ ...identity, email });
} finally {
await admin.dispose();
}
},
});
async function read<T>(api: APIRequestContext, path: string): Promise<T> {
const response = await api.get(`${apiUrl}${path}`);
expect(response.status()).toBe(200);
return response.json() as Promise<T>;
}
async function enterProfile(page: Page, owner: Owner, sample: Sample) {
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, {
headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' },
})).status()).toBe(204);
await page.setViewportSize({ width: sample.width, height: sample.height });
await page.goto('/dashboard');
await expect(page.getByRole('main')).toBeVisible();
await page.evaluate(({ language, theme }) => {
localStorage.setItem('uiLanguage', language);
localStorage.setItem('jobtracker.themeMode', theme);
}, sample);
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/profile');
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
await expect(page.getByLabel(translations[sample.language].profileDisplayName, { exact: true })).toBeEnabled();
}
function observeWrites(page: Page) {
const writes: Array<{ method: string; path: string }> = [];
const listener = (request: Request) => {
const url = new URL(request.url());
if (url.pathname.startsWith('/api/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) {
writes.push({ method: request.method(), path: url.pathname });
}
};
page.on('request', listener);
return { writes, stop: () => page.off('request', listener) };
}
async function collect(page: Page, info: TestInfo, sample: Sample, state: string) {
await page.evaluate(async () => {
await document.fonts.ready;
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
});
const geometry = await page.evaluate(() => ({
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
width: innerWidth, height: innerHeight,
focus: document.activeElement?.getAttribute('aria-label') || document.activeElement?.tagName,
regions: Array.from(document.querySelectorAll('main section[aria-labelledby]')).map(element => {
const bounds = element.getBoundingClientRect();
const title = document.getElementById(element.getAttribute('aria-labelledby') || '');
return { name: title?.textContent, left: bounds.left, right: bounds.right, width: bounds.width };
}),
}));
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
const name = `p7-account-${sample.language}-${sample.theme}-${sample.width}-${state}`;
await info.attach(`${name}-audit`, { body: JSON.stringify({
state, sample, url: page.url(), geometry,
ariaSnapshot: await page.locator('body').ariaSnapshot(),
axe: { scope: 'All enabled rules, including explicit target-size; violations/incomplete require contextual triage',
violations: axe.violations, incomplete: axe.incomplete,
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
limits: ['No human screen reader', 'No physical touch/software keyboard', 'No real provider or production qualification'],
}), contentType: 'application/json' });
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
expect(geometry.rootOverflow, 'ordinary account chrome reflows without document-root horizontal scrolling').toBe(0);
}
const matrix: Sample[] = [
{ language: 'nb', theme: 'dark', width: 320, height: 844 },
{ language: 'en', theme: 'light', width: 390, height: 844 },
{ language: 'nb', theme: 'light', width: 768, height: 1024 },
{ language: 'en', theme: 'dark', width: 1440, height: 900 },
];
for (const sample of matrix) {
test(`[P7-01 PRES-E06 E08 E09 C01 C07] ${sample.language}/${sample.theme} ${sample.width}px account identity save reload preserves canonical and sibling CVs`, async ({ page, owner }, info) => {
test.setTimeout(120_000);
const t = translations[sample.language];
const canonical = {
version: '1', contact: { fullName: 'Åse Canonical', headline: 'Canonical career headline', location: 'Oslo' },
jobs: [{ title: 'Recorded engineer', company: 'Source-only employer', bullets: ['Recorded work, never an account field'] }],
skills: ['C#', 'Norsk'],
};
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: canonical } })).status()).toBe(200);
const cvIds: number[] = [];
for (const name of ['First protected CV', 'Sibling protected CV']) {
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
data: { name, settings: { themeId: 'minimal', language: 'en', headline: `${name} override` } } });
expect(created.status()).toBe(200);
cvIds.push((await created.json()).id);
}
const beforeProfile = await read<{ profile: unknown }>(page.request, '/career/profile');
const beforeVariants = await Promise.all(cvIds.map(id => read(page.request, `/cv/variants/${id}`)));
const beforeMe = await read<{ userName: string }>(page.request, '/auth/me');
await enterProfile(page, owner, sample);
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
await expect(page.getByText(t.profileCvTextMissing, { exact: true })).toBeVisible();
await expect(page.getByText(t.profileCvMissing, { exact: true })).toHaveCount(0);
await expect(page.getByText(/NEXT_PUBLIC_/)).toHaveCount(0);
for (const title of [t.authStatusTitle, t.twoFactorSectionTitle, t.sessionsSectionTitle]) {
await expect(page.getByRole('region', { name: title, exact: true })).toBeVisible();
}
const status = page.getByRole('region', { name: t.twoFactorSectionTitle, exact: true });
await expect(status.getByText(t.twoFactorStatusDisabled, { exact: true })).toBeVisible();
const changed = {
userName: beforeMe.userName, firstName: 'Åse', lastName: 'Account identity',
displayName: 'Åse — documented account identity with a deliberately long personal display name',
};
await page.getByLabel(t.profileDisplayName, { exact: true }).fill(changed.displayName);
await page.getByLabel(t.profileFirstName, { exact: true }).fill(changed.firstName);
await page.getByLabel(t.profileLastName, { exact: true }).fill(changed.lastName);
const writes = observeWrites(page);
const saved = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/profile' && response.request().method() === 'PUT');
const save = page.getByRole('button', { name: t.profileSaveChanges, exact: true });
await save.focus();
await save.press('Enter');
const response = await saved;
expect(response.status()).toBe(204);
expect(response.request().postDataJSON()).toEqual(changed);
await expect(page.getByLabel(t.profileDisplayName, { exact: true })).toHaveValue(changed.displayName);
await expect(save).toBeEnabled();
expect(await read(page.request, '/auth/me')).toMatchObject(changed);
await page.reload();
await expect(page.getByLabel(t.profileDisplayName, { exact: true })).toHaveValue(changed.displayName);
await expect(page.getByLabel(t.profileFirstName, { exact: true })).toHaveValue(changed.firstName);
await expect(page.getByLabel(t.profileLastName, { exact: true })).toHaveValue(changed.lastName);
expect((await read<{ profile: unknown }>(page.request, '/career/profile')).profile).toEqual(beforeProfile.profile);
expect(await Promise.all(cvIds.map(id => read(page.request, `/cv/variants/${id}`)))).toEqual(beforeVariants);
expect(writes.writes.filter(write => write.path.startsWith('/api/career/') || write.path.startsWith('/api/cv/'))).toEqual([]);
expect(writes.writes.filter(write => write.path === '/api/auth/profile')).toEqual([{ method: 'PUT', path: '/api/auth/profile' }]);
writes.stop();
await info.attach('identity-only-readback', { body: JSON.stringify({
submittedFieldNames: Object.keys(changed), identityReadback: true, reloadReadback: true,
canonicalUnchanged: true, bothCvVariantsUnchanged: true, browserCanonicalOrCvWrites: 0,
}), contentType: 'application/json' });
await collect(page, info, sample, 'identity-persisted');
});
}
test('[P7-01 PRES-E04 E08] failed 2FA status is read-only and deliberate retry restores the actual disabled state', async ({ page, owner }, info) => {
test.setTimeout(90_000);
const sample: Sample = { language: 'nb', theme: 'dark', width: 320, height: 844 };
const t = translations[sample.language];
let failedReads = 0;
const fail = async (route: import('@playwright/test').Route) => {
expect(route.request().method()).toBe('GET');
failedReads += 1;
return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
};
await page.route('**/api/auth/2fa/status', fail);
const writes = observeWrites(page);
await enterProfile(page, owner, sample);
const region = page.getByRole('region', { name: t.twoFactorSectionTitle, exact: true });
const alert = region.getByRole('alert');
await expect(alert).toBeVisible();
expect(failedReads).toBeGreaterThanOrEqual(1);
await expect(region.getByText(t.twoFactorStatusDisabled, { exact: true })).toHaveCount(0);
// Hidden or disabled setup controls are both honest unknown-state presentations.
expect(await region.getByRole('button', { name: t.twoFactorEnableButton, exact: true }).evaluateAll(buttons =>
buttons.every(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true'))).toBe(true);
expect(writes.writes.filter(write => write.path.startsWith('/api/auth/2fa/'))).toEqual([]);
await collect(page, info, sample, 'status-failed-read-only');
await page.unroute('**/api/auth/2fa/status', fail);
const loaded = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/2fa/status' && response.request().method() === 'GET');
await alert.getByRole('button', { name: t.retry, exact: true }).press('Enter');
expect((await loaded).status()).toBe(200);
await expect(region.getByText(t.twoFactorStatusDisabled, { exact: true })).toBeVisible();
await expect(region.getByRole('button', { name: t.twoFactorEnableButton, exact: true })).toBeEnabled();
expect(await read(page.request, '/auth/2fa/status')).toMatchObject({ enabled: false });
expect(writes.writes.filter(write => write.path.startsWith('/api/auth/2fa/'))).toEqual([]);
writes.stop();
await collect(page, info, sample, 'status-read-recovered');
});
test('[P7-01 PRES-E05 E08 E09] native sessions cancel no-write, failed confirmed revoke stays in the dialog, retry revokes others and current logout disposes account', async ({ page, owner, playwright }, info) => {
test.setTimeout(120_000);
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
const t = translations[sample.language];
const other = await playwright.request.newContext({ userAgent:
'P7 synthetic remote browser - a deliberately long workstation device description for responsive account metadata and named actions' });
try {
await login(other, owner.email);
const before = await read<Session[]>(page.request, '/auth/sessions');
expect(before).toHaveLength(2);
const current = before.find(session => session.isCurrentSession)!;
const remote = before.find(session => !session.isCurrentSession)!;
expect(current).toBeDefined();
expect(remote.deviceLabel).toContain('deliberately long workstation');
await enterProfile(page, owner, sample);
const region = page.getByRole('region', { name: t.sessionsSectionTitle, exact: true });
await expect(region.getByText(remote.deviceLabel!, { exact: true })).toBeVisible();
const trigger = region.getByRole('button', { name: t.sessionsRevokeOthers, exact: true });
await expect(trigger).toBeVisible();
const writes = observeWrites(page);
await trigger.focus();
await trigger.press('Enter');
const dialog = page.getByRole('dialog', { name: t.sessionsRevokeOthersConfirmTitle, exact: true });
await expect(dialog).toBeVisible();
// MUI initially focuses its trap container; the next Tab must enter the named dialog.
await expect.poll(() => dialog.evaluate(element =>
element.closest('.MuiDialog-container')?.contains(document.activeElement))).toBe(true);
await info.attach('initial-dialog-focus', { body: JSON.stringify(await dialog.evaluate(element => ({
focusedTag: document.activeElement?.tagName,
focusedRole: document.activeElement?.getAttribute('role'),
inTrapContainer: element.closest('.MuiDialog-container')?.contains(document.activeElement),
inNamedSurface: element.contains(document.activeElement),
}))), contentType: 'application/json' });
await page.keyboard.press('Tab');
await expect.poll(() => dialog.evaluate(element => element.contains(document.activeElement))).toBe(true);
await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused();
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true })).toBeFocused();
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true })).toBeFocused();
await dialog.getByRole('button', { name: t.cancel, exact: true }).press('Enter');
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
expect(writes.writes.filter(write => write.path.startsWith('/api/auth/sessions'))).toEqual([]);
expect((await read<Session[]>(page.request, '/auth/sessions')).map(session => session.id).sort()).toEqual(before.map(session => session.id).sort());
expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(200);
let failedAttempts = 0;
const fail = async (route: import('@playwright/test').Route) => {
expect(route.request().method()).toBe('POST');
failedAttempts += 1;
return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
};
await page.route('**/api/auth/sessions/revoke-others', fail);
await trigger.press('Enter');
const confirm = dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true });
await confirm.press('Enter');
await expect(dialog.getByRole('alert')).toBeVisible();
expect(await dialog.getByRole('alert').evaluate(element => Boolean(element.closest('[aria-hidden="true"]')))).toBe(false);
await expect(dialog).toBeVisible();
expect(failedAttempts).toBe(1);
await page.keyboard.press('Tab');
await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(confirm).toBeFocused();
expect((await read<Session[]>(page.request, '/auth/sessions')).map(session => session.id).sort()).toEqual(before.map(session => session.id).sort());
expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(200);
await collect(page, info, sample, 'revoke-others-failed-modal');
await page.unroute('**/api/auth/sessions/revoke-others', fail);
const revoked = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/sessions/revoke-others' && response.request().method() === 'POST');
await confirm.press('Enter');
expect((await revoked).status()).toBe(204);
await expect(dialog).toBeHidden();
await expect(trigger).toHaveCount(0);
expect(await read<Session[]>(page.request, '/auth/sessions')).toEqual([expect.objectContaining({ id: current.id, isCurrentSession: true })]);
expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(401);
await collect(page, info, sample, 'only-current-session');
const currentRevoke = region.getByRole('button', { name: t.sessionsRevoke, exact: true });
await expect(currentRevoke).toHaveCount(1);
const disposed = page.waitForResponse(response => new URL(response.url()).pathname === `/api/auth/sessions/${current.id}` && response.request().method() === 'DELETE');
await currentRevoke.press('Enter');
expect((await disposed).status()).toBe(204);
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await expect(page.getByRole('region', { name: t.sessionsSectionTitle, exact: true })).toHaveCount(0);
await expect(page.getByText(remote.deviceLabel!, { exact: true })).toHaveCount(0);
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
expect(writes.writes.filter(write => write.path === '/api/auth/sessions/revoke-others')).toHaveLength(2);
expect(writes.writes.filter(write => write.method === 'DELETE' && write.path.startsWith('/api/auth/sessions/')))
.toEqual([{ method: 'DELETE', path: `/api/auth/sessions/${current.id}` }]);
writes.stop();
await info.attach('native-session-confirmation-readback', { body: JSON.stringify({
initialSessions: 2, canceledRevokeWrites: 0, failedAttempt: 1, explicitRetry: 1,
remoteSessionRejectedAfterRetry: true, currentSessionKeptUntilExplicitRevoke: true,
currentSessionRevokeDisposedPrivateUi: true, noExternalProviderCalls: true,
}), contentType: 'application/json' });
} finally {
await other.dispose();
}
});