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 is the documented exemption — // PMS enforces its own auth — so it 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. 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); });