import { expect, test } from "@playwright/test"; // Guard chain: Caddy -> Anubis (edge challenge) -> authentik outpost -> app. // The guard is only effective if the outpost is actually consulted on every // route of a guarded vhost — including deep links and API paths that a direct // navigation would skip. These tests drive the browser as an UNAUTHENTICATED // user (the guest project has no storage state) and navigate directly to each // guarded vhost's root path: the guard must present authentik's flow UI in // place and must never leak app content. plex and audiobooks are the documented // exemptions — PMS and Audiobookshelf enforce their own auth — so they must be // reachable unauthenticated with zero challenge pages, proving the exemption is // deliberate, not accidental. const BASE = "https://www.franksdank.cloud"; // Every vhost that Caddy routes through anubis:8923 (see dankclaw/Caddyfile). // Keep this list in lockstep with the Caddyfile: a new guarded vhost must be // added here or the guard-chain audit is incomplete. const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook"]; // Markers that prove app content leaked past the guard to an anonymous user. Each // marker must be a string the APP renders that the gate itself could never print: // authentik's gate never renders an app's UI text, because it IS the IdP. const LEAK_MARKERS: Record = { "search.franksdank.cloud": ["SearXNG preferences"], "romm.franksdank.cloud": ["RomM"], "files.franksdank.cloud": ["Copyparty"], "seerr.franksdank.cloud": ["Request Movie"], "grafana.franksdank.cloud": ["Dashboards"], "prometheus.franksdank.cloud": ["Expression Browser", "Graph"], "notebook.franksdank.cloud": ["Open Notebook"], }; test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => { await page.goto(`${BASE}/`); // The login flow renders in place (no redirect away from the vhost). const uid = page.locator('input[name="uidField"]'); await expect(uid).toBeVisible({ timeout: 90_000 }); await uid.fill("nobody"); await page.locator('button[type="submit"]').first().click(); // Wrong credentials must not leak panels: the flow re-renders with an error. const pwd = page.locator('input[name="password"]'); await expect(pwd).toBeVisible({ timeout: 30_000 }); await pwd.fill("not-a-real-user"); await pwd.press("Enter"); await expect(page.locator("#grid .panel").first()).toHaveCount(0); }); test("/admin/ is guarded for unauthenticated users", async ({ page }) => { await page.goto(`${BASE}/admin/`); const uid = page.locator('input[name="uidField"]'); await expect(uid).toBeVisible({ timeout: 90_000 }); await expect(page.locator("#grid .panel").first()).toHaveCount(0); }); for (const sub of GUARDED) { test(`unauthenticated direct navigation to ${sub}.franksdank.cloud is gated by authentik`, async ({ page }) => { test.setTimeout(180_000); const host = `${sub}.franksdank.cloud`; await page.goto(`https://${host}/`); // The guard resolves (Anubis PoW solves automatically in a real browser) // and presents authentik's flow UI — either in place or after a redirect // to the IdP host. Either way, the user never reaches the app content. const uid = page.locator('input[name="uidField"]'); await expect(uid).toBeVisible({ timeout: 90_000 }); // The challenge/login flow itself must not be leaking content. const body = await page.locator("body").innerText(); for (const marker of LEAK_MARKERS[host] ?? []) { expect(body, `${host} leaked app content to an unauthenticated user`).not.toContain(marker); } // The guard is present: either the flow UI renders in place on the guarded // vhost, or the browser was redirected to the IdP host (auth.) with the // guarded host preserved as redirect_uri. Both prove a gate; neither leaks. const landed = new URL(page.url()).hostname; expect([host, "auth.franksdank.cloud"], `${host} must gate, not pass through`).toContain(landed); if (landed === "auth.franksdank.cloud") { const params = new URL(page.url()).searchParams; const redirect = params.get("redirect_path") ?? params.get("redirect_uri") ?? ""; expect(redirect, `${host} must return to the guarded host after auth`).toContain(sub); } }); } test("plex is exempt from the guard chain and serves PMS content unauthenticated", async ({ page }) => { test.setTimeout(120_000); // Exempt-by-construction: Caddy reverse-proxies straight to PMS on the LAN // hop (PMS enforces its own auth). An unauthenticated visitor must reach the // app itself — never an authentik challenge or flow UI. await page.goto("https://plex.franksdank.cloud/"); expect(new URL(page.url()).hostname).toBe("plex.franksdank.cloud"); const body = await page.locator("body").innerText(); expect(body).not.toContain("Making sure you're not a bot"); // no challenge expect(await page.locator('input[name="uidField"]').count()).toBe(0); // no IdP form // PMS answers with its XML MediaContainer index when unauthenticated; the // exact payload (empty body vs XML) depends on PMS version, so assert only // that the answer comes from PMS itself: sub-500 and no IdP markers. const resp = await page.request.get("https://plex.franksdank.cloud/"); expect(resp.status(), "PMS must answer directly, not error").toBeLessThan(500); }); // Audiobookshelf is exempt by construction (2026-11-03). The phone app is a // non-browser client and solves no JS proof-of-work, so behind the guard chain it // was answered with challenge HTML wherever the socket `init` payload belongs — // every remote sign-in died as a TypeError on a non-object, and the container's // request log never saw a byte of it. ABS enforces its own auth (local accounts // plus authentik OIDC), exactly like PMS above. Asserted from the CLIENT side: the // endpoints an app must call before it holds any credential are answered by the // app, and the app's own 401 — not a 200 of HTML — is what an anonymous API call gets. test("audiobookshelf answers an unauthenticated app client with app bytes, not gate bytes", async ({ request, }) => { const ABS = "https://audiobooks.franksdank.cloud"; // The probe every Audiobookshelf client sends first. The app answers it at the // ROOT whatever ROUTER_BASE_PATH says, so this is the vhost's own behaviour. const ping = await request.get(`${ABS}/ping`, { timeout: 30_000 }); expect(ping.status(), "/ping must be answered by the app").toBe(200); expect(await ping.json(), "/ping must carry the app's own payload").toEqual({ success: true }); // The socket.io handshake. Engine.IO answers this in a machine format; the gate // answered it in HTML, which is the defect this exemption exists to kill. const sock = await request.get(`${ABS}/audiobookshelf/socket.io/?EIO=4&transport=polling`, { timeout: 30_000, }); expect( await sock.text(), "the socket handshake was answered by the gate, not Engine.IO", ).not.toContain("Making sure you're not a bot"); expect( sock.headers()["content-type"] ?? "", "the socket handshake must not arrive as HTML", ).not.toMatch(/text\/html/); // An API call with no credential: the app refuses it. A gate would answer 200 // with HTML, so the status code alone distinguishes the two owners. const api = await request.get(`${ABS}/audiobookshelf/api/libraries`, { timeout: 30_000 }); expect(api.status(), "the app's own auth gate must own unauthenticated API calls").toBe(401); }); test("audiobookshelf login renders the app's own sign-in to an anonymous browser", async ({ page }) => { test.setTimeout(120_000); await page.goto("https://audiobooks.franksdank.cloud/audiobookshelf/login"); expect(new URL(page.url()).hostname).toBe("audiobooks.franksdank.cloud"); const body = await page.locator("body").innerText(); expect(body, "the bot challenge still fronts this vhost").not.toContain("Making sure you're not a bot"); expect(await page.locator('input[name="uidField"]').count(), "the IdP gate form must not front the app").toBe(0); // The app answers, and its login screen carries SSO beside the local form. await expect(page.getByText("Sign in with Authentik")).toBeVisible({ timeout: 90_000 }); });