- Every app card carries hex-clipped art: 19 amber-tinted brand marks from the Simple Icons CDN plus 19 improvised monogram SVGs, wired via a per-service "art" field; homepage_config.bats pins art presence for all 32 apps. - Model cards render zealot -> dragoon -> archon (registry order pinned in the seam). - Dragoon and zealot share one common uncensored legal warning (adult content, harmful/hateful/illegal descriptions, sensitive offensive cybersecurity topics, severe legal risk, "don't ruin this server for everybody"), hover label on card and docs. - Archon gets a bordered red permission callout: ask Frank before use. - SearXNG obfuscation-not-anonymity note moved to a card-level hover warning (.svc-warn) and a docs hover note. - Gitea: self-service signup flow; no need to ask Frank. - Footer docs/admin/account-settings become glowing bordered buttons; the admin button is shown only to homepage-admins members via a new Caddy /whoami endpoint echoing the outpost-injected X-Authentik-* headers (spoof-proof; the /admin* 403 stays the real gate). e2e pins both sides. - Docs naming swept: Protoss names primary, bracketed api ids, explicit note that a future switchover renames the ids themselves.
98 lines
4.5 KiB
TypeScript
98 lines
4.5 KiB
TypeScript
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);
|
|
}
|
|
});
|