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)
|
||||
!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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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