Files
serverAppConfigs/tests/e2e/specs/14-guard.spec.ts
T
Frank ab06e4c221 test(e2e): cover the two guard-surface gaps found in the full-suite audit
Audit of all 12 e2e specs for the 'green-but-vacuous' class that broke
Grafana's SSO. The suite is otherwise sound (incident-driven, leakage-based);
two real coverage gaps surfaced:

- notebook.franksdank.cloud (open-notebook, deployed 2026-10-19) was a guarded
  vhost in NEITHER the 13-/14- GUARDED lists NOR any dedicated spec. A broken
  upstream would stay invisible - the exact 17-hermes class. Added to the 14-
  guard audit (unauth -> authentik gate, 'Open Notebook' leak marker) plus a
  new 21- that proves authenticated reachability to open-notebook's own login
  through the guard chain, with a positive app marker.
- prometheus.franksdank.cloud was only ever asserted '<500 and not a login
  wall' (13-/11-), which a 404 also passes. New 22- adds the app-is-really-
  there proof 20- gives Grafana: SSO handoff + its own /api/v1/status/buildinfo
  answers {status:success}.

Both specs wired into the standard project (playwright testMatch), which the
e2e_scaffold bats gate enforces. Verified live against the fleet; full bats
suite green.
2026-10-20 21:16:23 -07:00

101 lines
5.1 KiB
TypeScript

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<string, string[]> = {
"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);
});