From 5a083eee6cead3679d30fc777ee394f749881480 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 3 Sep 2026 16:16:11 -0700 Subject: [PATCH] test(e2e): prove guard chain blocks direct navigation and admins keep admin reach 14-guard.spec.ts: every authentik-guarded vhost (www, search, romm, files, seerr, grafana, prometheus, loki) must challenge an unauthenticated direct navigation; plex is asserted exempt (PMS answers directly, no IdP form). 11-admin.spec.ts: admin direct-nav to grafana/prometheus/loki passes through with host preserved and no uidField challenge. --- tests/e2e/specs/11-admin.spec.ts | 20 +++++++ tests/e2e/specs/14-guard.spec.ts | 94 +++++++++++++++++++++++++++++--- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/tests/e2e/specs/11-admin.spec.ts b/tests/e2e/specs/11-admin.spec.ts index 3e525a5..ac3bb25 100644 --- a/tests/e2e/specs/11-admin.spec.ts +++ b/tests/e2e/specs/11-admin.spec.ts @@ -47,3 +47,23 @@ test("admin open links reach their apps through SSO", async ({ page }) => { expect(resp.status(), `${target.hostname} must answer, not error`).toBeLessThan(500); } }); + +// Admin-space reachability must not depend on the panel's link list: an admin +// session must reach every guarded admin app by direct navigation, even where +// no open-link exists. This is the RBAC allow-leg for the outpost gates: an +// authenticated admin always lands in the app, never on a login wall or a 403. +test("admin direct navigation reaches every admin app without an auth wall", async ({ page }) => { + test.setTimeout(240_000); + for (const sub of ["grafana", "prometheus", "loki"]) { + const host = `${sub}.franksdank.cloud`; + await page.goto(`https://${host}/`); + // The outpost hands off silently for an authenticated session; the admin + // must land in the app, not on authentik's flow UI. + await expect + .poll(() => page.locator('input[name="uidField"]').isVisible().catch(() => false), { timeout: 60_000 }) + .toBe(false); + expect(new URL(page.url()).hostname, `${host} must not bounce an admin off its host`).toBe(host); + const resp = await page.request.get(`https://${host}/`); + expect(resp.status(), `${host} must answer an authenticated admin, not error`).toBeLessThan(500); + } +}); diff --git a/tests/e2e/specs/14-guard.spec.ts b/tests/e2e/specs/14-guard.spec.ts index 274b770..a370415 100644 --- a/tests/e2e/specs/14-guard.spec.ts +++ b/tests/e2e/specs/14-guard.spec.ts @@ -1,14 +1,92 @@ import { expect, test } from "@playwright/test"; -// With no session at all, the guard chain must demand authentik login for -// both homepage faces. This is the outer wall the whole e2e suite assumes. +// 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. -test("unauthenticated homepage visit requires authentik login", async ({ page }) => { - await page.goto("/"); - await expect(page.locator('input[name="uidField"]')).toBeVisible({ timeout: 90_000 }); +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("unauthenticated /admin requires authentik login", async ({ page }) => { - await page.goto("/admin/"); - await expect(page.locator('input[name="uidField"]')).toBeVisible({ timeout: 90_000 }); +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); });