tests/security.spec.js
// API-level security tests: no browser page needed, just Playwright's
// request fixture, which shares cookies with the signed-in context (§77).
import { test, expect } from "@playwright/test";
import { USERS } from "./fixtures.js";
const csrfOf = async (request) => (await (await request.get("/api/me")).json()).csrf;
test("every response carries the security headers", async ({ request }) => {
const res = await request.get("/");
const h = res.headers();
expect(h["content-security-policy"]).toContain("require-trusted-types-for 'script'");
expect(h["content-security-policy"]).toContain("frame-ancestors 'none'");
expect(h["x-content-type-options"]).toBe("nosniff");
expect(h["cross-origin-opener-policy"]).toBe("same-origin");
});
test("a state-changing request without the CSRF token is refused", async ({ request }) => {
const res = await request.post("/api/refunds", { data: { orderId: "A-1001" } });
expect(res.status()).toBe(403);
});
test("a cross-site request is refused even with the token", async ({ request }) => {
const res = await request.post("/api/refunds", {
data: { orderId: "A-1001" },
headers: { "x-csrf-token": await csrfOf(request), "sec-fetch-site": "cross-site" },
});
expect(res.status()).toBe(403);
});
test("Alice cannot refund Bob's order (no IDOR)", async ({ request }) => {
const res = await request.post("/api/refunds", {
data: { orderId: "B-2001" },
headers: { "x-csrf-token": await csrfOf(request) },
});
expect(res.status()).toBe(404); // not 403: don't confirm the order exists
});
test("errors never leak stack traces", async ({ request }) => {
const res = await request.post("/api/chat", {
headers: { "x-csrf-token": await csrfOf(request), "content-type": "application/json" },
data: Buffer.from("{not json"), // a string would be JSON-encoded into valid JSON
});
expect(res.status()).toBe(400);
const body = await res.json();
expect(body).toEqual({ error: "Malformed JSON", requestId: expect.stringMatching(/^[0-9a-f]{12}$/) });
});
test.describe("signed out", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("the API requires a session", async ({ request }) => {
expect((await request.get("/api/orders")).status()).toBe(401);
});
test("login is rate-limited after 5 failed attempts", async ({ request }, testInfo) => {
// A unique key per run: the limiter's memory outlives this test, and
// retries or --repeat-each would otherwise start already locked out.
const email = `ratelimit-${testInfo.workerIndex}-${testInfo.repeatEachIndex}-${testInfo.retry}@example.com`;
const attempt = () => request.post("/api/login", { data: { email, password: "x" } });
for (let i = 0; i < 5; i++) expect((await attempt()).status()).toBe(401);
const blocked = await attempt();
expect(blocked.status()).toBe(429);
expect(Number(blocked.headers()["retry-after"])).toBeGreaterThan(0);
});
test("two users in two contexts see only their own data", async ({ browser }) => {
// Two isolated browser contexts = two users at once, in one test.
const [alice, bob] = await Promise.all([browser.newContext(), browser.newContext()]);
for (const [ctx, user] of [[alice, USERS.alice], [bob, USERS.bob]]) {
expect((await ctx.request.post("/api/login", { data: user })).ok()).toBe(true);
}
const ids = async (ctx) => (await (await ctx.request.get("/api/orders")).json()).map((o) => o.id);
// Other tests seed extra orders for Alice, so assert ownership, not exact lists.
const [aliceIds, bobIds] = await Promise.all([ids(alice), ids(bob)]);
expect(aliceIds).toEqual(expect.arrayContaining(["A-1001", "A-1002"]));
expect(aliceIds).not.toContain("B-2001");
expect(bobIds).toEqual(["B-2001"]);
await Promise.all([alice.close(), bob.close()]);
});
});