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 }) => { // Identity gate (admin side): a homepage-admins member sees the admin // button on the user homepage (the standard user does not: 10-homepage). await page.goto("/"); await expect(page.locator("#admin-link")).toBeVisible({ timeout: 60_000 }); await page.goto("/admin/"); await expect(page).toHaveTitle(/frank — admin/, { timeout: 60_000 }); const registry: { name: string; category: string }[] = await (await page.request.get("/services.json")).json(); const adminNames = registry.filter((s) => s.category === "admin").map((s) => s.name); // Panels hydrate client-side; poll the count, then compare exact sets. await expect .poll(() => page.locator("#grid .panel .name").count(), { timeout: 60_000 }) .toBe(adminNames.length); const names = await page.locator("#grid .panel .name").allTextContents(); expect([...names].sort()).toEqual([...adminNames].sort()); // RBAC tripwire: a user-category panel in the admin grid means category leakage. expect(names).not.toContain("SearXNG"); }); test("admin open links reach their apps through SSO", async ({ page }) => { test.setTimeout(240_000); // Expected link count comes from the live registry (admin showLink=true // entries, now including the *arr vhosts that use their own auth). The // frankbook-hosted links (probe on 192.168.0.112) are NOT walked here: // they terminate on FrankBook-Pro, which sleeps when idle, so their walk // lives in 19-frankbook.spec.ts (frankbook project — excluded from the // default e2e run and from the deploy gate unless a frankbook trigger // path changed; see tests/e2e/run.sh and scripts/deploy.sh). const registry: { name: string; category: string; showLink: boolean; url: string; probe: string; }[] = await (await page.request.get("/services.json")).json(); const expectedAdminLinks = registry.filter((s) => s.category === "admin" && s.showLink).length; const frankbookUrls = registry .filter( (s) => s.category === "admin" && s.showLink && ((s.probe ?? "").startsWith("192.168.0.112") || s.url.includes("192.168.0.112")), ) .map((s) => new URL(s.url).origin); await page.goto("/admin/"); await expect(page.locator("#grid a.open")).toHaveCount(expectedAdminLinks); const hrefs = await page.locator("#grid a.open").evaluateAll((els) => els.map((el) => (el as HTMLAnchorElement).href), ); for (const href of hrefs) { if (frankbookUrls.includes(new URL(href).origin)) continue; // 19-frankbook walks these const target = new URL(href); await page.goto(href); // Every guarded vhost runs its own Anubis proof-of-work on first visit. await expect .poll( () => page.locator("body").innerText().then((t) => !t.includes("Making sure you're not a bot")), { timeout: 60_000 }, ) .toBe(true); 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); } }); // Admin-space reachability must not depend on the panel's link list: an admin // session must reach every guarded admin app by direct navigation, even where // no open-link exists. This is the RBAC allow-leg for the outpost gates: an // authenticated admin always lands in the app, never on a login wall or a 403. test("admin direct navigation reaches every admin app without an auth wall", async ({ page }) => { test.setTimeout(240_000); for (const sub of ["grafana", "prometheus", "loki"]) { const host = `${sub}.franksdank.cloud`; await page.goto(`https://${host}/`); // The outpost hands off silently for an authenticated session; the admin // must land in the app, not on authentik's flow UI. await expect .poll(() => page.locator('input[name="uidField"]').isVisible().catch(() => false), { timeout: 60_000 }) .toBe(false); expect(new URL(page.url()).hostname, `${host} must not bounce an admin off its host`).toBe(host); const resp = await page.request.get(`https://${host}/`); expect(resp.status(), `${host} must answer an authenticated admin, not error`).toBeLessThan(500); } });