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,4 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "bookhoard-ui-sweep",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.0"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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