diff --git a/.gitignore b/.gitignore index 961cdad..7175fa5 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index 62e6596..4e2f4e2 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -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 { diff --git a/scripts/test-changed.sh b/scripts/test-changed.sh index 702e231..0186aa4 100755 --- a/scripts/test-changed.sh +++ b/scripts/test-changed.sh @@ -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 ;; diff --git a/shared/homepage/services.json b/shared/homepage/services.json index 24293e0..dfad6f6 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -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}, diff --git a/tests/e2e/.env.local.example b/tests/e2e/.env.local.example new file mode 100644 index 0000000..c83fd21 --- /dev/null +++ b/tests/e2e/.env.local.example @@ -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 diff --git a/tests/e2e/lib/env.ts b/tests/e2e/lib/env.ts new file mode 100644 index 0000000..00de5f4 --- /dev/null +++ b/tests/e2e/lib/env.ts @@ -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 { + try { + const raw = readFileSync(new URL("../.env.local", import.meta.url), "utf8"); + const out: Record = {}; + 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") }; +} diff --git a/tests/e2e/lib/login.ts b/tests/e2e/lib/login.ts new file mode 100644 index 0000000..d9ec69e --- /dev/null +++ b/tests/e2e/lib/login.ts @@ -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 { + 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 }, + }; +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..b3a7fbb --- /dev/null +++ b/tests/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..8c964a5 --- /dev/null +++ b/tests/e2e/package.json @@ -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" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..4e13600 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -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: [] } }, + }, + ], +}); diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh new file mode 100755 index 0000000..96586d8 --- /dev/null +++ b/tests/e2e/run.sh @@ -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 "$@" diff --git a/tests/e2e/specs/00-auth.setup.ts b/tests/e2e/specs/00-auth.setup.ts new file mode 100644 index 0000000..b6f6d49 --- /dev/null +++ b/tests/e2e/specs/00-auth.setup.ts @@ -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(); + }); +} diff --git a/tests/e2e/specs/10-homepage.spec.ts b/tests/e2e/specs/10-homepage.spec.ts new file mode 100644 index 0000000..46c7d66 --- /dev/null +++ b/tests/e2e/specs/10-homepage.spec.ts @@ -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"); +}); diff --git a/tests/e2e/specs/11-admin.spec.ts b/tests/e2e/specs/11-admin.spec.ts new file mode 100644 index 0000000..4e180c6 --- /dev/null +++ b/tests/e2e/specs/11-admin.spec.ts @@ -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); + } +}); diff --git a/tests/e2e/specs/12-crawl.spec.ts b/tests/e2e/specs/12-crawl.spec.ts new file mode 100644 index 0000000..4fc552c --- /dev/null +++ b/tests/e2e/specs/12-crawl.spec.ts @@ -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([]); +}); diff --git a/tests/e2e/specs/13-sso.spec.ts b/tests/e2e/specs/13-sso.spec.ts new file mode 100644 index 0000000..9f05b3f --- /dev/null +++ b/tests/e2e/specs/13-sso.spec.ts @@ -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); + }); +} diff --git a/tests/e2e/specs/14-guard.spec.ts b/tests/e2e/specs/14-guard.spec.ts new file mode 100644 index 0000000..274b770 --- /dev/null +++ b/tests/e2e/specs/14-guard.spec.ts @@ -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 }); +}); diff --git a/tests/e2e_scaffold.bats b/tests/e2e_scaffold.bats new file mode 100755 index 0000000..b105adb --- /dev/null +++ b/tests/e2e_scaffold.bats @@ -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 +}