e2e: Playwright suite for homepage + guard chain; RBAC gate on /admin; fix stale service links
This commit is contained in:
@@ -5,6 +5,13 @@
|
|||||||
# LLM fleet tunables (paths and numbers, never credentials)
|
# LLM fleet tunables (paths and numbers, never credentials)
|
||||||
!scripts/*.env
|
!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 subagent artifacts
|
||||||
.pi-subagents/
|
.pi-subagents/
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -243,9 +243,19 @@ http://:8017 {
|
|||||||
@home host www.franksdank.cloud
|
@home host www.franksdank.cloud
|
||||||
handle @home {
|
handle @home {
|
||||||
root * /srv/homepage
|
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* {
|
handle /admin* {
|
||||||
try_files /admin.html
|
@adminok header_regexp X-Authentik-Groups homepage-admins
|
||||||
file_server
|
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
|
@data path /services.json /status.json /app.js /style.css /deployed.js
|
||||||
handle @data {
|
handle @data {
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ for path in "${CHANGED[@]}"; do
|
|||||||
add_test tests/caddyfile.bats
|
add_test tests/caddyfile.bats
|
||||||
add_test tests/deployment_scoping.bats
|
add_test tests/deployment_scoping.bats
|
||||||
;;
|
;;
|
||||||
|
tests/e2e/*)
|
||||||
|
add_test tests/e2e_scaffold.bats
|
||||||
|
;;
|
||||||
dankserver/.env.example|dankclaw/.env.example)
|
dankserver/.env.example|dankclaw/.env.example)
|
||||||
add_test tests/env_consistency.bats
|
add_test tests/env_consistency.bats
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -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":"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":"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://chat.franksdank.cloud","docs":"","probe":"127.0.0.1:3005","method":"http","wip":false,"showLink":true},
|
{"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":"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":"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":"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":"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":"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":"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":"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":"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},
|
{"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},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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") };
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
Generated
+78
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [] } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
Executable
+27
@@ -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 "$@"
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
Executable
+48
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user