Frontend Labs
⋯

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

Generated from SOURCE.md · 8 Oct 2026 16:36