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.
100 lines
3.9 KiB
TypeScript
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);
|
|
});
|
|
});
|