Files
jobtrackingapp/job-tracker-ui/e2e/analytics-auth-repair.spec.ts

204 lines
13 KiB
TypeScript

import { expect, test as base, type APIRequestContext, type Page } from '@playwright/test';
import { translations } from '../src/i18n/translations';
const apiUrl = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(api: APIRequestContext, email: string) {
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
}
const test = base.extend<{ email: string }>({
email: async ({ playwright, context }, provide) => {
const admin = await playwright.request.newContext();
try {
const headers = await login(admin, 'e2e@example.test');
const email = `analytics-auth-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200);
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); });
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 provide(email);
} finally { await admin.dispose(); }
},
});
async function submitLogin(page: Page, email: string, language: 'en' | 'nb' = 'en', value = password) {
const t = translations[language];
if (new URL(page.url()).pathname !== '/login') await page.getByRole('button', { name: t.signInTitle, exact: true }).first().click();
await page.getByLabel(t.usernameOrEmail, { exact: true }).fill(email);
await page.getByLabel(t.profileCurrentPassword, { exact: true }).fill(value);
await page.getByRole('button', { name: t.signInTitle, exact: true }).click();
}
test('[P5-15-01R-03 AUTH-01] native sign-in restores the non-default Analytics period', async ({ page, email }, info) => {
await page.goto('/analytics?months=6');
await expect(page).toHaveURL(/\/$/);
await submitLogin(page, email);
await expect(page.getByRole('heading', { name: translations.en.analytics, exact: true, level: 1 })).toBeVisible();
await info.attach('analytics-native-auth-return', { body: JSON.stringify({ requested: '/analytics?months=6', returned: new URL(page.url()).pathname + new URL(page.url()).search }), contentType: 'application/json' });
await expect(page).toHaveURL(/\/analytics\?months=6$/);
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
});
function periodLabel(language: 'en' | 'nb', months: number) {
return translations[language].dashboardMonthsShort.replace('{count}', String(months));
}
function analyticsResponse(page: Page, months: number) {
return page.waitForResponse(response => {
const url = new URL(response.url());
return url.pathname === '/api/jobapplications/analytics' && url.searchParams.get('months') === String(months);
});
}
test('[P5-15-01R-03] Analytics encoded period, fragment and existing defaults survive native sign-in across locales/themes', async ({ page, context, email }, info) => {
test.setTimeout(120_000);
const results: { requested: string; returned: string; effectiveMonths: number; language: string; mode: string; width: number }[] = [];
for (const variant of [
{ language: 'en', mode: 'light', width: 1440, path: '/analytics?months=24#activity', months: 24 },
{ language: 'nb', mode: 'dark', width: 390, path: '/analytics?months=%36#rapport', months: 6 },
{ language: 'en', mode: 'dark', width: 768, path: '/analytics?months=12', months: 12 },
{ language: 'nb', mode: 'light', width: 320, path: '/analytics?months=3#rapport', months: 12 },
] as const) {
const headers = await login(page.request, email);
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: variant.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
await page.goto('about:blank');
await context.clearCookies();
await page.setViewportSize({ width: variant.width, height: 900 });
await page.goto('/');
await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, variant);
await page.goto(variant.path);
await expect(page).toHaveURL(/\/$/);
const report = analyticsResponse(page, variant.months);
await submitLogin(page, email, variant.language);
await expect(page).toHaveURL(`http://localhost:3300${variant.path}`);
expect((await report).status()).toBe(200);
await expect(page.getByRole('heading', { name: translations[variant.language].analytics, exact: true, level: 1 })).toBeVisible();
await expect(page.getByRole('button', { name: periodLabel(variant.language, variant.months), exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', variant.mode);
const returned = new URL(page.url());
results.push({ requested: variant.path, returned: returned.pathname + returned.search + returned.hash, effectiveMonths: variant.months, language: variant.language, mode: variant.mode, width: variant.width });
const path = info.outputPath(`analytics-auth-${variant.language}-${variant.mode}-${variant.width}.png`);
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
await info.attach(`analytics-auth-${variant.language}-${variant.mode}-${variant.width}`, { path, contentType: 'image/png' });
}
await info.attach('analytics-auth-period-matrix', { body: JSON.stringify(results), contentType: 'application/json' });
});
test('[P5-15-01R-03] failed local sign-in preserves the destination; default, history, refresh and authenticated links stay correct', async ({ page, email }, info) => {
await page.goto('/analytics');
await expect(page).toHaveURL(/\/$/);
const failedLogin = page.waitForResponse(response => response.url() === `${apiUrl}/auth/login` && response.request().method() === 'POST');
await submitLogin(page, email, 'en', 'IncorrectPassword123!');
expect((await failedLogin).status()).toBe(401);
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole('alert').filter({ hasText: 'Unauthorized' })).toBeVisible();
const report = analyticsResponse(page, 12);
await submitLogin(page, email);
await expect(page).toHaveURL(/\/analytics$/);
expect((await report).status()).toBe(200);
await expect(page.getByRole('button', { name: '12 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: '6 mo', exact: true }).click();
await expect(page).toHaveURL(/\/analytics\?months=6$/);
await page.getByRole('button', { name: '24 mo', exact: true }).click();
await expect(page).toHaveURL(/\/analytics\?months=24$/);
await page.goBack();
await expect(page).toHaveURL(/\/analytics\?months=6$/);
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.goBack();
await expect(page).toHaveURL(/\/analytics$/);
await expect(page.getByRole('button', { name: '12 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.goForward();
await expect(page).toHaveURL(/\/analytics\?months=6$/);
await page.reload();
await expect(page.getByRole('button', { name: '6 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.goto('/analytics?months=24#activity');
await expect(page).toHaveURL(/\/analytics\?months=24#activity$/);
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await info.attach('analytics-native-history-and-authentication', { body: JSON.stringify({ failedLogin: 401, retryDefault: 12, backPeriods: [6, 12], forwardAndRefresh: 6, authenticatedDirect: 24 }), contentType: 'application/json' });
});
test('[P5-15-01R-03] expired browser cookie retains the report return path; deliberate logout clears it', async ({ page, context, email }, info) => {
await login(page.request, email);
await page.goto('/analytics?months=24#activity');
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
const authCookie = (await context.cookies()).find(cookie => cookie.name === 'jobtracker_auth');
expect(authCookie).toBeDefined();
await context.addCookies([{ ...authCookie!, expires: 1 }]);
expect((await context.cookies()).some(cookie => cookie.name === 'jobtracker_auth')).toBe(false);
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
await page.reload();
await expect(page).toHaveURL(/\/$/);
await submitLogin(page, email);
await expect(page).toHaveURL(/\/analytics\?months=24#activity$/);
await expect(page.getByRole('button', { name: '24 mo', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
const loggedOut = page.waitForResponse(response => response.url() === `${apiUrl}/auth/logout`);
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
expect((await loggedOut).status()).toBe(204);
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await submitLogin(page, email);
await expect(page).toHaveURL(/\/dashboard$/);
await info.attach('native-expiry-versus-deliberate-logout', { body: JSON.stringify({ expiredCookieMe: 401, expiryReturn: '/analytics?months=24#activity', logout: 204, logoutSubsequentLogin: '/dashboard' }), contentType: 'application/json' });
});
test('[P5-15-01R-03 PRES-E09] another protected section keeps its destination without bypassing ownership or unsafe-target validation', async ({ page, playwright, email }, info) => {
test.setTimeout(90_000);
const owner = await playwright.request.newContext();
const admin = await playwright.request.newContext();
try {
const ownerHeaders = await login(owner, email);
const company = await owner.post(`${apiUrl}/companies`, { headers: ownerHeaders, data: { name: 'Private Analytics-auth employer' } });
expect(company.status()).toBe(201);
const job = await owner.post(`${apiUrl}/jobapplications`, { headers: ownerHeaders, data: { companyId: (await company.json()).id, jobTitle: 'Private Analytics-auth role', status: 'Saved' } });
expect(job.status()).toBe(201);
const jobId = (await job.json()).id;
const destination = `/jobs/${jobId}?section=documents#documents`;
await page.goto(destination);
await expect(page).toHaveURL(/\/$/);
await submitLogin(page, email);
await expect(page).toHaveURL(`http://localhost:3300${destination}`);
await expect(page.getByRole('region', { name: translations.en.workspaceCurrentDocuments, exact: true })).toBeVisible();
await page.reload();
await expect(page.getByRole('region', { name: translations.en.workspaceCurrentDocuments, exact: true })).toBeVisible();
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
const adminHeaders = await login(admin, 'e2e@example.test');
const emailB = `analytics-auth-other-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200);
await page.goto(destination);
await expect(page).toHaveURL(/\/$/);
const denied = page.waitForResponse(response => new URL(response.url()).pathname === `/api/jobapplications/${jobId}/workspace`);
await submitLogin(page, emailB);
await expect(page).toHaveURL(`http://localhost:3300${destination}`);
expect((await denied).status()).toBe(404);
await expect(page.getByText(translations.en.workspaceLoadFailed, { exact: true })).toBeVisible();
await expect(page.getByText('Private Analytics-auth role', { exact: true })).toHaveCount(0);
expect((await page.request.get(`${apiUrl}/jobapplications/${jobId}`)).status()).toBe(404);
expect((await (await owner.get(`${apiUrl}/jobapplications/${jobId}`)).json()).jobTitle).toBe('Private Analytics-auth role');
const rejected: string[] = [];
for (const target of ['//outside.example.test/analytics', '/%2f%2foutside.example.test/analytics']) {
await page.getByRole('button', { name: translations.en.user, exact: true }).click();
await page.getByRole('menuitem', { name: translations.en.signOut, exact: true }).click();
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
await page.goto('/login');
await page.evaluate(from => { history.replaceState({ ...history.state, usr: { from } }, '', location.href); }, target);
await page.reload();
await submitLogin(page, emailB);
await expect(page).toHaveURL(/\/dashboard$/);
rejected.push(target);
}
await info.attach('native-return-target-and-ownership', { body: JSON.stringify({ ownedDestination: destination, foreignResource: 404, foreignWorkspace: 404, ownerReadbackUnchanged: true, rejectedTargets: rejected, fallback: '/dashboard' }), contentType: 'application/json' });
} finally { await owner.dispose(); await admin.dispose(); }
});