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:
John O'Keefe committed 2026-10-29 10:25:08 -04:00
1 parent 4d697f5fcf
commit f579696633
6 files changed
+173

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
node_modules/
package-lock.json
test-results/
playwright-report/
+24
View File
@@ -0,0 +1,24 @@
# UI sweep coverage (plan §7.5)
Gate: every line below executes for BOTH personas unless marked
"admin-only". Status: ✅ swept+green · 🔲 pending (future sweep runs).
| # | Page / control | admin | restricted |
|---|---|---|---|
| 1 | Login: bad password rejected; good login lands authed; logout | ✅ | ✅ |
| 2 | Dashboard: sections render (Recently Added / Not Started) | ✅ | ✅ (hidden lib absent) |
| 3 | Dashboard: hidden library visibility (Audiobooks) | ✅ visible | ✅ absent |
| 4 | Search: results scoped (`/api/media-items/search`) | ✅ | ✅ no leaks |
| 5 | Media list: no hidden items in payloads | ✅ | ✅ |
| 6 | Library browse grid + pagination | 🔲 | 🔲 |
| 7 | Library create / edit / delete (admin) | 🔲 | n/a |
| 8 | Admin visibility toggle roundtrip (hide → restricted loses → unhide) | 🔲 | 🔲 |
| 9 | Media detail: edit metadata / archive / delete | 🔲 | n/a |
| 10 | Reader: open, page turn, TOC, bookmark, highlight, note, settings | 🔲 | 🔲 |
| 11 | Collections: CRUD, add/remove items, smart-rule editor | 🔲 | 🔲 |
| 12 | Series: list/detail | 🔲 | 🔲 |
| 13 | Stats screens + range filters | 🔲 | 🔲 |
| 14 | Admin: users CRUD, visibility matrix, settings toggles, processing issues | 🔲 | n/a |
| 15 | Upload: EPUB generated at runtime, upload → appears → delete | 🔲 | n/a |
| 16 | Auth edges: restricted → admin URL; anonymous → media URL | 🔲 | ✅ (detail/404) |
| 17 | OPDS / KOReader | API-covered (invariant test) | API-covered |
+18
View File
@@ -0,0 +1,18 @@
# tests/ui — Playwright sweep (plan §7.5)
Real-browser sweep of the SSR/HTMX frontend. Dev server must be up on
localhost:8765. Run inside the official image (no host browsers needed):
```bash
cd tests/ui
docker run --rm --network host \
-v "$PWD":/work -w /work \
mcr.microsoft.com/playwright:v1.49.0 \
bash -c "npm install --no-audit --no-fund && npx playwright test"
```
Personas (see rls_sweep.spec.ts): `testuser` (admin) and `rls-phone`
(restricted — Audiobooks hidden from them).
Coverage: `COVERAGE.md` enumerates every swept page/control per the
plan; new pages/controls get a line when added.
+11
View File
@@ -0,0 +1,11 @@
{
"name": "bookhoard-ui-sweep",
"private": true,
"version": "1.0.0",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "1.49.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test";
// Full-sweep config (plan §7.5). The dev server must be up on
// localhost:8765 (same precondition as the Go integration tests).
// Run via the official Playwright image — see tests/ui/README.md.
export default defineConfig({
testDir: ".",
timeout: 30_000,
retries: 0,
reporter: [["list"]],
use: {
baseURL: "http://localhost:8765",
trace: "retain-on-failure",
screenshot: "only-on-failure",
actionTimeout: 10_000,
},
});
+99
View File
@@ -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);
});
});