import { expect, test as base, type APIRequestContext, type PlaywrightWorkerArgs, type Page } from "@playwright/test"; const apiUrl = "http://localhost:5302/api"; const password = "E2ePassword123!"; type Account = { api: APIRequestContext; email: string; id: string; sessionContext?: string; headers: Record }; async function signIn(playwright: PlaywrightWorkerArgs["playwright"], email: string): Promise { const api = await playwright.request.newContext(); 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 { api, email, id: me.id, sessionContext: me.sessionContext, headers: { "X-CSRF-TOKEN": csrf, ...(me.sessionContext ? { "X-Session-Context": me.sessionContext } : {}) } }; } const test = base.extend<{ accounts: { a: Account; b: Account } }>({ accounts: async ({ playwright }, provide) => { const admin = await signIn(playwright, "e2e@example.test"); const accounts: Account[] = []; try { for (const name of ["a", "b"]) { const email = `b02-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`; expect((await admin.api.post(`${apiUrl}/users`, { headers: admin.headers, data: { email, password, roles: [] } })).status()).toBe(200); const account = await signIn(playwright, email); accounts.push(account); expect((await account.api.put(`${apiUrl}/career/profile`, { headers: account.headers, data: profile(`Private ${name.toUpperCase()}`) })).status()).toBe(200); } await provide({ a: accounts[0], b: accounts[1] }); } finally { await Promise.all([admin, ...accounts].map(account => account.api.dispose())); } }, }); function profile(fullName: string) { return { profile: { version: "1", contact: { fullName }, jobs: [{ title: "Engineer", company: "Private employer" }], skills: ["C#"] } }; } async function storedName(account: Account) { return (await (await account.api.get(`${apiUrl}/career/profile`)).json()).profile.contact.fullName; } test("retained profile request from A cannot write using B cookies and fresh B CSRF", async ({ accounts, playwright }, info) => { const { a, b } = accounts; // The original browser reads a fresh shared CSRF cookie at dispatch. Simulate that explicitly. const browserSession = await signIn(playwright, a.email); try { const retained = (await (await browserSession.api.get(`${apiUrl}/career/profile`)).json()); retained.profile.contact.fullName = "A draft must never enter B"; const staleContext = browserSession.sessionContext ?? "pre-B02-no-context"; expect((await browserSession.api.post(`${apiUrl}/auth/logout`)).status()).toBe(204); expect((await browserSession.api.post(`${apiUrl}/auth/login`, { data: { email: b.email, password } })).status()).toBe(200); const csrfB = (await browserSession.api.storageState()).cookies.find(cookie => cookie.name === "XSRF-TOKEN")!.value; const response = await browserSession.api.put(`${apiUrl}/career/profile`, { headers: { "X-CSRF-TOKEN": csrfB, "X-Session-Context": staleContext }, data: retained, }); const persisted = { status: response.status(), a: await storedName(a), b: await storedName(b) }; await info.attach("retained-request-persistence", { body: JSON.stringify(persisted, null, 2), contentType: "application/json" }); expect(persisted).toEqual({ status: 409, a: "Private A", b: "Private B" }); expect(await response.json()).toMatchObject({ code: "session_context_mismatch" }); } finally { await browserSession.api.dispose(); } }); test("missing and forged session contexts fail closed while same-account profile saves work", async ({ accounts }) => { const { a, b } = accounts; for (const sessionContext of [undefined, a.sessionContext, a.id, "crafted-session"]) { const response = await b.api.put(`${apiUrl}/career/profile`, { headers: { "X-CSRF-TOKEN": b.headers["X-CSRF-TOKEN"], ...(sessionContext ? { "X-Session-Context": sessionContext } : {}) }, data: { ...profile("forged"), userId: a.id, ownerUserId: a.id, accountId: a.id }, }); expect(response.status()).toBe(sessionContext ? 409 : 428); } expect(await storedName(a)).toBe("Private A"); expect(await storedName(b)).toBe("Private B"); expect((await b.api.get(`${apiUrl}/career/profile`, { headers: { "X-Session-Context": a.sessionContext! } })).status()).toBe(409); const singletonRead = await b.api.get(`${apiUrl}/career/profile?ownerUserId=${a.id}`); expect((await singletonRead.json()).profile.contact.fullName).toBe("Private B"); const saved = await b.api.put(`${apiUrl}/career/profile`, { headers: b.headers, data: { ...profile("Legitimate B"), userId: a.id, ownerUserId: a.id, roles: ["Admin"], plan: "pro" }, }); expect(saved.status()).toBe(200); // Over-posted ownership/security fields are not DTO properties. expect(await storedName(a)).toBe("Private A"); expect(await storedName(b)).toBe("Legitimate B"); expect(await (await b.api.get(`${apiUrl}/auth/me`)).json()).toMatchObject({ id: b.id, roles: [], plan: "free" }); }); test("crafted CV identifiers cannot cross read update delete or job-association ownership", async ({ accounts }) => { const { a, b } = accounts; const company = await (await a.api.post(`${apiUrl}/companies`, { headers: a.headers, data: { name: "A company" } })).json(); const job = await (await a.api.post(`${apiUrl}/jobapplications`, { headers: a.headers, data: { companyId: company.id, jobTitle: "A job", status: "Saved" } })).json(); const created = await a.api.post(`${apiUrl}/cv/variants`, { headers: a.headers, data: { name: "A CV", jobApplicationId: job.id } }); expect(created.status()).toBe(200); const variant = await created.json(); for (const suffix of ["", "/preview"]) expect((await b.api.get(`${apiUrl}/cv/variants/${variant.id}${suffix}`)).status()).toBe(404); expect(await (await b.api.get(`${apiUrl}/cv/variants/${variant.id}/versions`)).json()).toEqual([]); expect((await b.api.put(`${apiUrl}/cv/variants/${variant.id}`, { headers: b.headers, data: { name: "B overwrite", settings: variant.settings, ownerUserId: b.id, id: variant.id } })).status()).toBe(404); expect((await b.api.delete(`${apiUrl}/cv/variants/${variant.id}`, { headers: b.headers })).status()).toBe(404); expect((await b.api.post(`${apiUrl}/cv/variants/${variant.id}/duplicate`, { headers: b.headers, data: {} })).status()).toBe(404); const attach = await b.api.post(`${apiUrl}/cv/variants`, { headers: b.headers, data: { name: "Cross-account association", jobApplicationId: job.id, ownerUserId: a.id } }); expect(attach.status()).toBe(400); expect(await attach.text()).toContain("The job application is unavailable."); expect(await (await b.api.get(`${apiUrl}/cv/variants`)).json()).toEqual([]); expect(await (await a.api.get(`${apiUrl}/cv/variants/${variant.id}`)).json()).toMatchObject({ name: "A CV", jobApplicationId: job.id }); expect((await a.api.put(`${apiUrl}/cv/variants/${variant.id}`, { headers: a.headers, data: { name: "A legitimate edit", settings: variant.settings, expectedVersion: variant.version } })).status()).toBe(200); expect((await a.api.delete(`${apiUrl}/cv/variants/${variant.id}`, { headers: a.headers })).status()).toBe(204); }); async function uiLogin(page: Page, email: string) { await page.goto("/login"); await page.getByLabel("Username or email", { exact: true }).fill(email); await page.getByLabel("Current password", { exact: true }).fill(password); await page.getByRole("button", { name: "Sign in", exact: true }).click(); await expect(page).toHaveURL(/\/dashboard$/); } test("[P5-00 PRES-E05 E07 E08] account switch disposes open command, account and notification surfaces", async ({ context, accounts }) => { test.setTimeout(90_000); const { a, b } = accounts; const company = await a.api.post(`${apiUrl}/companies`, { headers: a.headers, data: { name: "Private A shell company" } }); expect(company.status()).toBe(201); 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 context.addInitScript(() => localStorage.setItem("uiLanguage", "en")); const session = await context.newPage(); await uiLogin(session, a.email); const command = await context.newPage(); await command.goto("/dashboard"); await command.getByRole("button", { name: /Quick Search/ }).click(); await command.getByRole("textbox", { name: "Quick Search", exact: true }).fill("Private A shell"); await expect(command.getByRole("button", { name: /Private A shell company/ })).toBeVisible(); const account = await context.newPage(); await account.goto("/dashboard"); await account.getByRole("button", { name: "User", exact: true }).click(); await expect(account.getByRole("menu")).toBeVisible(); const notification = await context.newPage(); // Only notification content is a fixture; authentication/logout/switch and company search are native. await notification.route(`${apiUrl}/notifications?limit=10`, route => route.fulfill({ json: [{ id: "p500-private-a", kind: "info", title: "Private A shell notice", message: "Account A only", createdAtUtc: new Date().toISOString(), }] }), { times: 1 }); await notification.goto("/dashboard"); await notification.getByRole("button", { name: "Notifications", exact: true }).click(); await expect(notification.getByText("Private A shell notice", { exact: true })).toBeVisible(); await session.getByRole("button", { name: "User", exact: true }).click(); await session.getByRole("menuitem", { name: "Sign out", exact: true }).click(); await expect(session).toHaveURL(/\/login\?sessionChanged=1$/); await uiLogin(session, b.email); for (const tab of [command, account, notification]) { await expect(tab.getByRole("dialog")).toHaveCount(0); await expect(tab.getByRole("menu")).toHaveCount(0); await expect(tab.getByText(/Private A shell/)).toHaveCount(0); await tab.goto("/dashboard"); await expect(tab.getByText(a.email, { exact: true })).toHaveCount(0); await expect(tab.getByTestId("admin-version-badge")).toHaveCount(0); } await command.getByRole("button", { name: /Quick Search/ }).click(); await command.getByRole("textbox", { name: "Quick Search", exact: true }).fill("Private A shell"); await expect(command.getByText("No matching commands or records.", { exact: true })).toBeVisible(); await notification.getByRole("button", { name: "Notifications", exact: true }).click(); await expect(notification.getByText("Private A shell notice", { exact: true })).toHaveCount(0); expect(await storedName(a)).toBe("Private A"); expect(await storedName(b)).toBe("Private B"); }); test("two tabs discard A's private form before B can use it, including Back navigation", async ({ context, accounts }) => { test.setTimeout(90_000); const { a, b } = accounts; 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 context.addInitScript(() => localStorage.setItem("uiLanguage", "en")); const pageA = await context.newPage(); await uiLogin(pageA, a.email); await pageA.goto("/career?section=profile"); await expect(pageA.getByLabel("Full name", { exact: true })).toHaveValue("Private A"); await pageA.getByLabel("Full name", { exact: true }).fill("Unsaved A draft"); const pageB = await context.newPage(); await pageB.goto("/dashboard"); await pageB.getByRole("button", { name: "User", exact: true }).click(); await pageB.getByRole("menuitem", { name: "Sign out", exact: true }).click(); // Wait for B02's final document replacement, not the intermediate router navigation. await expect(pageB).toHaveURL(/\/login\?sessionChanged=1$/); await expect(pageB.getByLabel("Username or email", { exact: true })).toBeVisible(); await uiLogin(pageB, b.email); await expect(pageA.locator('input[value="Unsaved A draft"]')).toHaveCount(0); await pageA.goBack(); await expect(pageA.locator('input[value="Unsaved A draft"], input[value="Private A"]')).toHaveCount(0); await pageA.goto("/career?section=profile"); await expect(pageA.getByLabel("Full name", { exact: true })).toHaveValue("Private B"); await pageA.getByLabel("Full name", { exact: true }).fill("B browser edit"); await pageA.getByRole("button", { name: "Save changes", exact: true }).click(); await expect.poll(() => storedName(b)).toBe("B browser edit"); expect(await storedName(a)).toBe("Private A"); });