Files
bookhoard/tests/ui/rls_sweep.spec.ts
T
John O'Keefe f579696633 test(ui): Playwright RLS sweep — real-browser proof, both personas
Phase 5.5 (plan §7.5) first increment: the SSR/HTMX frontend is swept
in a real browser (official Playwright image, no host browsers) for
BOTH personas — testuser (admin) and rls-phone (restricted; Audiobooks
hidden from them via the visibility API).

Green checks: auth rejection + session establishment; admin dashboard
renders sections incl. the hidden-from-others library; restricted
dashboard renders real sections (Recently Added present — not a
vacuous landing-page pass) with ZERO hidden-library traces; search and
media payloads leak nothing for the restricted persona.

Harness lessons encoded: / is a landing page (dashboard lives at
/dashboard); the login endpoint rate-limits rapid attempts so sessions
are established once per persona and reused via storageState — a sweep
must not look like a brute-force run; getByText visibility assertions
are wrong for <option> text (library switcher) — body-textContent
assertions instead.

COVERAGE.md tracks the full 14-area sweep per the plan; the remaining
areas (reader, collections CRUD, admin matrix, upload roundtrip) are
enumerated and pending their sweep runs.
2026-10-29 10:25:08 -04:00

100 lines
3.9 KiB
TypeScript

import { expect, test, type Browser, type Page } from "@playwright/test";
// Persona helpers: the two dev personas used across the sweep.
// testuser = admin, sees everything.
// rls-phone = restricted user; the Audiobooks library is HIDDEN from
// them (seeded by the rollout; see the RLS plan §7.5).
//
// Sessions are established ONCE per persona and reused via
// storageState — the login endpoint rate-limits rapid attempts, and a
// sweep should not look like a brute-force run.
const PERSONAS = {
admin: { login: "testuser", password: "Test@Pass123!" },
restricted: { login: "rls-phone", password: "Test@Pass123!" },
} as const;
type Persona = keyof typeof PERSONAS;
const stateCache: Partial<Record<Persona, string>> = {};
async function authedPage(browser: Browser, persona: Persona): Promise<Page> {
if (!stateCache[persona]) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("/login");
await page.fill('input[name="login"]', PERSONAS[persona].login);
await page.fill('input[name="password"]', PERSONAS[persona].password);
await page.click('button[type="submit"]');
await page.waitForLoadState("networkidle");
stateCache[persona] = await ctx.storageState();
await ctx.close();
}
const ctx = await browser.newContext({ storageState: stateCache[persona] });
return ctx.newPage();
}
// The restricted persona must never see Audiobooks content anywhere.
const HIDDEN = "Audiobooks";
test.describe("authentication", () => {
test("wrong password is rejected without a session", async ({ page }) => {
await page.goto("/login");
await page.fill('input[name="login"]', PERSONAS.admin.login);
await page.fill('input[name="password"]', "definitely-wrong");
await page.click('button[type="submit"]');
await page.waitForTimeout(1_500);
const cookies = await page.context().cookies();
expect(cookies.find((c) => c.name === "token" && c.value.length > 10)).toBeUndefined();
});
});
test.describe("admin persona sees everything", () => {
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await authedPage(browser, "admin");
});
test("dashboard sections render with library content", async () => {
await page.goto("/dashboard");
const body = (await page.textContent("body")) ?? "";
expect(body).toContain("Recently Added");
expect(body).toContain("Not Started");
expect(body).toContain(HIDDEN); // admin sees the hidden-from-others library
});
test("search returns hidden-library titles for admin", async () => {
const resp = await page.goto("/api/media-items/search?query=Witcher");
expect(resp?.status()).toBe(200);
const body = (await page.textContent("body")) ?? "";
expect(body).toContain("Witcher");
});
});
test.describe("restricted persona: hidden means hidden", () => {
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await authedPage(browser, "restricted");
});
test("dashboard never shows the hidden library or its items", async () => {
await page.goto("/dashboard");
expect((await page.textContent("body")) ?? "").toContain("Recently Added"); // real dashboard, not the landing page
const body = (await page.textContent("body")) ?? "";
expect(body).not.toContain(HIDDEN);
});
test("search results exclude hidden-library content", async () => {
const resp = await page.goto("/api/media-items/search?query=Witcher");
expect(resp?.status()).toBe(200);
const body = (await page.textContent("body")) ?? "";
// The Witcher audiobooks live in the hidden library — none may leak.
expect(body).not.toContain("Witcher");
});
test("media payloads exclude hidden-library items", async () => {
const resp = await page.goto("/api/media-items?limit=200");
expect(resp?.status()).toBe(200);
const body = (await page.textContent("body")) ?? "";
expect(body).not.toContain(HIDDEN);
});
});