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

This commit is contained in:
Frank committed 2026-09-01 16:42:33 -07:00
1 parent e513007aa4
commit dfd9c8d1c0
18 files changed
+497 -6

No files matched your search

+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 });
});