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"]; // 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"], }; 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 in place — the user never reaches the // app without a session. 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 response is the IdP's flow UI, not a passthrough: it must be served // by authentik (the flow shell renders "Sign in" / uidField), never the app. expect(new URL(page.url()).hostname).toBe(host); }); } 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); });