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.
This commit is contained in:
1 parent
4d697f5fcf
commit
f579696633
6 files changed
+173
No files matched your search
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user