Files
jobtrackingapp/job-tracker-ui/e2e/account-isolation.spec.ts
T
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

148 lines
9.5 KiB
TypeScript

import { expect, test as base, type APIRequestContext, type Playwright, 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<string, string> };
async function signIn(playwright: Playwright, email: string): Promise<Account> {
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 } })).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("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");
});