Files
serverAppConfigs/tests/e2e/specs/14-guard.spec.ts
Frank 23ad0ca317 chore(abs): name the Audiobookshelf door audiobooks.franksdank.cloud
`abs.` was a three-letter abbreviation of the container; the library it serves is
/mnt/entropy/Audiobooks, and `audiobooks.` says what the door is. Renamed in one
commit on purpose: the pihole seam fails the suite if a Caddyfile vhost has no
addn-hosts record, so any split would leave a red commit, and the `abs` name was
never deployed at the IdP with this fix, so nothing has to keep answering it —
no alias, no redirect, no stale URI.

Moves together: the Caddyfile site block, its matcher (@abs -> @audiobooks), the
LAN record, ABS_EXTRA_BASES in authentik-audiobookshelf-sso.sh (that script
REPLACES the provider's redirect-URI list, so the old origin's callback leaves the
RP when it is next run), the homepage admin tile, the guard spec's GUARDED list and
leak marker, and the three vault pages that name the door. authentik's provider row
follows in the deploy window (external_host + name), which re-derives its
redirect_uris through scripts/authentik-proxy-provider-defaults.sh; the Application
row keeps its own name, "Audiobookshelf Guard" — it names the app, not the vhost.
2026-11-03 01:23:54 -08:00

105 lines
5.5 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", "audiobooks"];
// 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 —
// for audiobooks that is the Audiobookshelf login's own SSO button label (the app's
// door), which authentik's gate never renders because it IS the IdP.
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"],
"audiobooks.franksdank.cloud": ["Sign in with Authentik"],
};
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);
});