204 lines
13 KiB
TypeScript
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(); }
|
|
});
|