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