e2e: Playwright suite for homepage + guard chain; RBAC gate on /admin; fix stale service links

This commit is contained in:
Frank
2026-09-01 16:42:33 -07:00
parent e513007aa4
commit dfd9c8d1c0
18 changed files with 497 additions and 6 deletions
+7
View File
@@ -5,6 +5,13 @@
# LLM fleet tunables (paths and numbers, never credentials)
!scripts/*.env
# Playwright e2e: local creds, browser storage states, node artifacts
tests/e2e/.env.local
tests/e2e/.auth/
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
# pi subagent artifacts
.pi-subagents/
+12 -2
View File
@@ -243,9 +243,19 @@ http://:8017 {
@home host www.franksdank.cloud
handle @home {
root * /srv/homepage
# RBAC (2026-09-01): /admin requires membership in the homepage-admins
# authentik group. In proxy-provider mode the outpost injects
# X-Authentik-Groups on its upstream request to this listener and strips
# any client-supplied X-Authentik-* headers first, so browsers cannot
# spoof it. Regex is a substring match, insensitive to the group-list
# separator.
handle /admin* {
try_files /admin.html
file_server
@adminok header_regexp X-Authentik-Groups homepage-admins
handle @adminok {
try_files /admin.html
file_server
}
respond "Forbidden: homepage admins only" 403
}
@data path /services.json /status.json /app.js /style.css /deployed.js
handle @data {
+3
View File
@@ -88,6 +88,9 @@ for path in "${CHANGED[@]}"; do
add_test tests/caddyfile.bats
add_test tests/deployment_scoping.bats
;;
tests/e2e/*)
add_test tests/e2e_scaffold.bats
;;
dankserver/.env.example|dankclaw/.env.example)
add_test tests/env_consistency.bats
;;
+4 -4
View File
@@ -1,16 +1,16 @@
[
{"name":"SearXNG","category":"user","description":"Privacy-respecting metasearch engine.","url":"http://search.franksdank.cloud","docs":"","probe":"127.0.0.1:8081","method":"http","wip":false,"showLink":true},
{"name":"ComfyUI","category":"user","description":"Node-based image generation.","url":"http://comfyui.franksdank.cloud","docs":"","probe":"127.0.0.1:8188","method":"http","wip":false,"showLink":true},
{"name":"OpenWebUI","category":"user","description":"Chat interface for the LLM fleet.","url":"http://chat.franksdank.cloud","docs":"","probe":"127.0.0.1:3005","method":"http","wip":false,"showLink":true},
{"name":"ComfyUI","category":"user","description":"Node-based image generation.","url":"","docs":"","probe":"127.0.0.1:8188","method":"http","wip":false,"showLink":false},
{"name":"OpenWebUI","category":"user","description":"Chat interface for the LLM fleet.","url":"http://ai.franksdank.cloud","docs":"","probe":"127.0.0.1:3005","method":"http","wip":false,"showLink":true},
{"name":"Gitea","category":"user","description":"Git hosting and code review.","url":"http://gitea.franksdank.cloud","docs":"","probe":"127.0.0.1:3000","method":"http","wip":false,"showLink":true},
{"name":"Calibre-Web","category":"user","description":"E-book library.","url":"http://books.franksdank.cloud","docs":"","probe":"192.168.0.235:8083","method":"http","wip":false,"showLink":true},
{"name":"RomM","category":"user","description":"ROM and emulator library.","url":"http://romm.franksdank.cloud","docs":"","probe":"192.168.0.235:8085","method":"http","wip":false,"showLink":true},
{"name":"Copyparty","category":"user","description":"File sharing over the LAN.","url":"http://files.franksdank.cloud","docs":"","probe":"192.168.0.235:3923","method":"http","wip":false,"showLink":true},
{"name":"Plex","category":"user","description":"Media server.","url":"http://plex.franksdank.cloud","docs":"","probe":"192.168.0.235:32400","method":"http","wip":false,"showLink":true},
{"name":"Jellyfin","category":"user","description":"Media server.","url":"http://jellyfin.franksdank.cloud","docs":"","probe":"192.168.0.235:8096","method":"http","wip":false,"showLink":true},
{"name":"Jellyfin","category":"user","description":"Media server.","url":"http://jelly.franksdank.cloud","docs":"","probe":"192.168.0.235:8096","method":"http","wip":false,"showLink":true},
{"name":"Seerr","category":"user","description":"Requests for the *arr stack.","url":"http://seerr.franksdank.cloud","docs":"","probe":"127.0.0.1:5055","method":"http","wip":false,"showLink":true},
{"name":"Hermes","category":"admin","description":"The agent gateway.","url":"http://hermes.franksdank.cloud","docs":"","probe":"127.0.0.1:8090","method":"http","wip":false,"showLink":true},
{"name":"Hermes","category":"admin","description":"The agent gateway.","url":"","docs":"","probe":"127.0.0.1:8090","method":"http","wip":false,"showLink":false},
{"name":"Grafana","category":"admin","description":"Dashboards.","url":"http://grafana.franksdank.cloud","docs":"","probe":"127.0.0.1:3002","method":"http","wip":false,"showLink":true},
{"name":"Prometheus","category":"admin","description":"Metrics collection.","url":"http://prometheus.franksdank.cloud","docs":"","probe":"127.0.0.1:9090","method":"http","wip":false,"showLink":true},
{"name":"Loki","category":"admin","description":"Log storage.","url":"http://loki.franksdank.cloud","docs":"","probe":"127.0.0.1:3100","method":"http","wip":false,"showLink":true},
+8
View File
@@ -0,0 +1,8 @@
# Copy to tests/e2e/.env.local (git-ignored) and fill in real values.
# qa-e2e / qa-e2e-admin are fleet-managed IdP test users (standard user vs
# homepage-admins member). Values are secrets: never commit them.
E2E_BASE_URL=https://www.franksdank.cloud
E2E_STANDARD_USER=qa-e2e
E2E_STANDARD_PASS=REPLACE_ME
E2E_ADMIN_USER=qa-e2e-admin
E2E_ADMIN_PASS=REPLACE_ME
+39
View File
@@ -0,0 +1,39 @@
import { readFileSync } from "node:fs";
// Parses tests/e2e/.env.local (git-ignored) into a plain record. Returns {}
// when the file is absent so config load never crashes before a useful error.
export function loadEnvFile(): Record<string, string> {
try {
const raw = readFileSync(new URL("../.env.local", import.meta.url), "utf8");
const out: Record<string, string> = {};
for (const line of raw.split("\n")) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m) out[m[1]] = m[2];
}
return out;
} catch {
return {};
}
}
const env = loadEnvFile();
export const BASE_URL = process.env.E2E_BASE_URL || env.E2E_BASE_URL || "https://www.franksdank.cloud";
// Anubis challenges obvious headless UAs; present as a stock Chrome.
export const REAL_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36";
function credential(name: string): string {
const v = process.env[name] || env[name];
if (!v) throw new Error(`missing ${name} — set it in tests/e2e/.env.local or the environment`);
return v;
}
export function standardCredentials(): { user: string; pass: string } {
return { user: credential("E2E_STANDARD_USER"), pass: credential("E2E_STANDARD_PASS") };
}
export function adminCredentials(): { user: string; pass: string } {
return { user: credential("E2E_ADMIN_USER"), pass: credential("E2E_ADMIN_PASS") };
}
+35
View File
@@ -0,0 +1,35 @@
import { expect, type Page } from "@playwright/test";
import { REAL_UA } from "./env";
// Authentik's default login flow is two stages (identification -> password),
// both rendered inside open shadow roots. Playwright's CSS engine pierces
// open shadow roots, so plain input[name=...] locators work. Each login runs
// in a FRESH browser context: reusing a half-finished flow poisons authentik's
// pending_user stage and the second login hangs (see the
// authentik-flow-ui-automation skill). Anubis sits in front and solves its
// proof-of-work automatically in a real Chromium, which is why the
// uidField wait can legitimately take a while on a cold challenge.
export async function akLogin(page: Page, baseUrl: string, user: string, pass: string): Promise<void> {
await page.goto(baseUrl + "/");
const uid = page.locator('input[name="uidField"]');
await uid.waitFor({ state: "visible", timeout: 90_000 });
await uid.fill(user);
await page.locator('button[type="submit"]').first().click();
const pwd = page.locator('input[name="password"]');
await pwd.waitFor({ state: "visible", timeout: 30_000 });
await pwd.fill(pass);
await page.locator('button[type="submit"]').first().click();
// Back on the homepage with panels rendered.
await page.waitForURL(/:\/\/www\.franksdank\.cloud\//, { timeout: 60_000 });
await expect(page.locator("#grid .panel").first()).toBeVisible({ timeout: 30_000 });
}
export function launchOptions() {
return {
userAgent: REAL_UA,
ignoreHTTPSErrors: true,
viewport: { width: 1440, height: 900 },
};
}
+78
View File
@@ -0,0 +1,78 @@
{
"name": "serverappconfigs-e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "serverappconfigs-e2e",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.62.1"
}
},
"node_modules/@playwright/test": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "serverappconfigs-e2e",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "Playwright e2e for the homepage and the Anubis->Authentik guard chain. Drives the live fleet; not part of ./tests/run.sh.",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.62.1"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { defineConfig } from "@playwright/test";
import { BASE_URL, REAL_UA } from "./lib/env";
export default defineConfig({
testDir: "./specs",
timeout: 90_000,
expect: { timeout: 20_000 },
retries: 1,
workers: 2,
fullyParallel: false,
reporter: [["list"]],
outputDir: "./test-results",
use: {
baseURL: BASE_URL,
userAgent: REAL_UA,
ignoreHTTPSErrors: true,
viewport: { width: 1440, height: 900 },
trace: "retain-on-failure",
},
projects: [
{
name: "setup",
testMatch: /00-auth\.setup\.ts/,
},
{
name: "standard",
testMatch: /(10|12|13)-.*\.spec\.ts/,
use: { storageState: ".auth/qa-e2e.json" },
dependencies: ["setup"],
},
{
name: "admin",
testMatch: /11-.*\.spec\.ts/,
use: { storageState: ".auth/qa-e2e-admin.json" },
dependencies: ["setup"],
},
{
name: "guest",
testMatch: /14-.*\.spec\.ts/,
use: { storageState: { cookies: [], origins: [] } },
},
],
});
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Playwright e2e for the homepage and the Anubis -> Authentik guard chain.
# Drives the LIVE fleet, so it needs real credentials and is deliberately NOT
# part of ./tests/run.sh. Credentials live in tests/e2e/.env.local
# (git-ignored; see .env.local.example). Usage:
#
# tests/e2e/run.sh # full suite
# tests/e2e/run.sh --project guest # any playwright args pass through
set -euo pipefail
cd "$(dirname "$0")"
if [ ! -f .env.local ]; then
echo "tests/e2e/.env.local missing — copy .env.local.example and fill in the IdP credentials" >&2
exit 1
fi
if [ ! -d node_modules ]; then
if [ -f package-lock.json ]; then
npm ci --no-audit --no-fund
else
npm install --no-audit --no-fund
fi
fi
npx playwright install chromium
exec npx playwright test "$@"
+21
View File
@@ -0,0 +1,21 @@
import { test as setup } from "@playwright/test";
import { BASE_URL, adminCredentials, standardCredentials } from "../lib/env";
import { akLogin, launchOptions } from "../lib/login";
// One setup test per identity; each uses a FRESH context so authentik's
// pending_user stage is never shared between logins. Storage states land in
// .auth/ (git-ignored) and back the `standard` and `admin` projects.
const legs = [
{ key: "qa-e2e", creds: standardCredentials() },
{ key: "qa-e2e-admin", creds: adminCredentials() },
];
for (const leg of legs) {
setup(`login ${leg.key}`, async ({ browser }) => {
const context = await browser.newContext(launchOptions());
const page = await context.newPage();
await akLogin(page, BASE_URL, leg.creds.user, leg.creds.pass);
await page.context().storageState({ path: `.auth/${leg.key}.json` });
await context.close();
});
}
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
// The user-facing homepage as a standard (non-admin) authenticated user.
test("user homepage renders only user-category services", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveTitle(/frank — services/);
const names = await page.locator("#grid .panel .name").allTextContents();
expect(names.length).toBeGreaterThanOrEqual(9);
for (const wanted of ["SearXNG", "OpenWebUI", "Gitea", "RomM", "Jellyfin", "Seerr"]) {
expect(names, `user panel for ${wanted}`).toContain(wanted);
}
for (const adminOnly of ["Grafana", "Prometheus", "Loki", "Hermes"]) {
expect(names, `admin service ${adminOnly} must not leak into user grid`).not.toContain(adminOnly);
}
});
test("user homepage exposes exactly the registry's open links", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#grid a.open")).toHaveCount(9);
const hrefs = await page.locator("#grid a.open").evaluateAll((els) =>
els.map((el) => (el as HTMLAnchorElement).getAttribute("href")),
);
for (const href of hrefs) {
expect(href, "links use current vhost names").not.toMatch(/chat\.|jellyfin\.|comfyui\./);
}
});
test("standard user is denied the admin panel (RBAC)", async ({ page }) => {
const resp = await page.request.get("/admin/");
expect(resp.status()).toBe(403);
expect(await resp.text()).toContain("Forbidden: homepage admins only");
// The footer link a standard user can click also dead-ends at the gate.
await page.goto("/");
await page.locator('footer a[href="/admin/"]').click();
await expect(page.locator("body")).toContainText("Forbidden: homepage admins only");
});
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from "@playwright/test";
// The admin panel as a homepage-admins member. The 200 itself is the RBAC
// allow-leg: the same path returns 403 for the standard user (10-homepage).
test("admin panel renders admin-category services", async ({ page }) => {
await page.goto("/admin/");
await expect(page).toHaveTitle(/frank — admin/);
const names = await page.locator("#grid .panel .name").allTextContents();
for (const wanted of ["Grafana", "Prometheus", "Loki"]) {
expect(names, `admin panel for ${wanted}`).toContain(wanted);
}
expect(names, "user services stay out of the admin grid").not.toContain("SearXNG");
});
test("admin open links reach their apps through SSO", async ({ page }) => {
await page.goto("/admin/");
const hrefs = await page.locator("#grid a.open").evaluateAll((els) =>
els.map((el) => (el as HTMLAnchorElement).href),
);
expect(hrefs.length).toBe(3); // grafana, prometheus, loki
for (const href of hrefs) {
const target = new URL(href);
await page.goto(href);
await expect
.poll(() => new URL(page.url()).hostname, { timeout: 60_000 })
.toBe(target.hostname);
expect(await page.locator('input[name="uidField"]').count()).toBe(0);
const resp = await page.request.get(page.url());
expect(resp.status(), `${target.hostname} must answer, not error`).toBeLessThan(500);
}
});
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test";
// Clicks every open link on the user homepage and asserts the destination is
// the real app, reached through the guard chain without an authentik prompt.
// The link set is read live from the rendered homepage (it mirrors
// shared/homepage/services.json), so registry edits flow into the crawl
// automatically. Apps with their own auth (Gitea, OpenWebUI, Calibre-Web,
// Plex, Jellyfin) must show THEIR OWN landing page; guarded apps must arrive
// via silent SSO.
test("every user open link reaches its app", async ({ page }) => {
await page.goto("/");
const hrefs = await page.locator("#grid a.open").evaluateAll((els) =>
els.map((el) => (el as HTMLAnchorElement).href),
);
expect(hrefs.length).toBeGreaterThanOrEqual(8);
const failures: string[] = [];
for (const href of hrefs) {
const target = new URL(href);
try {
await page.locator(`#grid a.open[href="${href}"]`).click();
// Guarded hops pass through Anubis (auto-solves) and authentik (silent
// with a session); wait until we have landed off the IdP.
await expect
.poll(() => new URL(page.url()).hostname, { timeout: 60_000 })
.not.toBe("auth.franksdank.cloud");
expect(new URL(page.url()).hostname, `${target.hostname} should serve its own vhost`).toBe(
target.hostname,
);
expect(
await page.locator('input[name="uidField"]').count(),
`${target.hostname} must not re-prompt for authentik login`,
).toBe(0);
const body = await page.locator("body").innerText();
expect(body, `${target.hostname} must not show an Anubis interstitial`).not.toContain(
"Making sure you're not a bot",
);
const resp = await page.request.get(page.url());
expect(resp.status(), `${target.hostname} must answer, not error`).toBeLessThan(500);
} catch (err) {
failures.push(`${target.hostname}: ${err instanceof Error ? err.message : String(err)}`);
await page.goto("/");
await page.locator("#grid .panel").first().waitFor();
}
}
expect(failures, failures.join("\n")).toEqual([]);
});
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from "@playwright/test";
// Single-login enforcement: with ONLY the homepage session cookies, a direct
// visit to every outpost-guarded vhost must resolve through Anubis +
// authentik silently and land on the app — no login form, no challenge, no
// IdP error page. Exercises the provider the outpost actually carries
// (fixed 2026-09-01: loki/grafana/seerr were missing from the outpost M2M).
const GUARDED = ["search", "romm", "files", "seerr", "grafana", "prometheus", "loki"];
for (const host of GUARDED) {
test(`SSO passes silently for ${host}.franksdank.cloud`, async ({ page }) => {
await page.goto(`https://${host}.franksdank.cloud/`);
await expect
.poll(() => new URL(page.url()).hostname, { timeout: 60_000 })
.toBe(`${host}.franksdank.cloud`);
expect(await page.locator('input[name="uidField"]').count()).toBe(0);
const body = await page.locator("body").innerText();
expect(body).not.toContain("Making sure you're not a bot");
const resp = await page.request.get(page.url());
expect(resp.status()).toBeLessThan(500);
});
}
+14
View File
@@ -0,0 +1,14 @@
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.
test("unauthenticated homepage visit requires authentik login", async ({ page }) => {
await page.goto("/");
await expect(page.locator('input[name="uidField"]')).toBeVisible({ timeout: 90_000 });
});
test("unauthenticated /admin requires authentik login", async ({ page }) => {
await page.goto("/admin/");
await expect(page.locator('input[name="uidField"]')).toBeVisible({ timeout: 90_000 });
});
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bats
# Seam: tests/e2e — Playwright suite structure. The suite drives the LIVE
# fleet (homepage + Anubis/Authentik guard chain) and is deliberately not
# part of ./tests/run.sh; this seam only asserts the scaffold is present,
# well-formed, and that no credentials or browser auth state are tracked.
load helpers
@test "e2e: runner, config, lib, and specs exist" {
for f in run.sh package.json playwright.config.ts .env.local.example \
lib/env.ts lib/login.ts \
specs/00-auth.setup.ts specs/10-homepage.spec.ts specs/11-admin.spec.ts \
specs/12-crawl.spec.ts specs/13-sso.spec.ts specs/14-guard.spec.ts; do
[ -f "$REPO_ROOT/tests/e2e/$f" ] || {
echo "missing tests/e2e/$f"
return 1
}
done
}
@test "e2e: package.json is valid JSON with the playwright dependency" {
python3 - "$REPO_ROOT/tests/e2e/package.json" <<'PY'
import json, sys
pkg = json.load(open(sys.argv[1]))
assert "@playwright/test" in pkg.get("devDependencies", {}), "missing @playwright/test"
PY
}
@test "e2e: run.sh parses" {
bash -n "$REPO_ROOT/tests/e2e/run.sh"
}
@test "e2e: example env file carries only placeholders" {
if grep -E '^E2E_[A-Z_]*PASS=' "$REPO_ROOT/tests/e2e/.env.local.example" | grep -vq REPLACE_ME; then
echo ".env.local.example must hold REPLACE_ME placeholders, never real passwords"
return 1
fi
}
@test "e2e: no credentials or auth state are tracked" {
cd "$REPO_ROOT"
tracked="$(git ls-files tests/e2e)"
if echo "$tracked" | grep -Eq '(^|/)\.env\.local$|(^|/)\.auth(/|$)|node_modules'; then
echo "tracked files must not include creds or auth state:"
echo "$tracked" | grep -E '(^|/)\.env\.local$|(^|/)\.auth(/|$)|node_modules'
return 1
fi
}