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