diff --git a/tests/ui/.gitignore b/tests/ui/.gitignore new file mode 100644 index 00000000..5a7316b4 --- /dev/null +++ b/tests/ui/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +test-results/ +playwright-report/ diff --git a/tests/ui/COVERAGE.md b/tests/ui/COVERAGE.md new file mode 100644 index 00000000..e53b8d8c --- /dev/null +++ b/tests/ui/COVERAGE.md @@ -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 | diff --git a/tests/ui/README.md b/tests/ui/README.md new file mode 100644 index 00000000..99e9ac2e --- /dev/null +++ b/tests/ui/README.md @@ -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. diff --git a/tests/ui/package.json b/tests/ui/package.json new file mode 100644 index 00000000..b1188607 --- /dev/null +++ b/tests/ui/package.json @@ -0,0 +1,11 @@ +{ + "name": "bookhoard-ui-sweep", + "private": true, + "version": "1.0.0", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.49.0" + } +} diff --git a/tests/ui/playwright.config.ts b/tests/ui/playwright.config.ts new file mode 100644 index 00000000..2598dc63 --- /dev/null +++ b/tests/ui/playwright.config.ts @@ -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, + }, +}); diff --git a/tests/ui/rls_sweep.spec.ts b/tests/ui/rls_sweep.spec.ts new file mode 100644 index 00000000..558ceae8 --- /dev/null +++ b/tests/ui/rls_sweep.spec.ts @@ -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> = {}; + +async function authedPage(browser: Browser, persona: Persona): Promise { + 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); + }); +});