e2e: Playwright suite for homepage + guard chain; RBAC gate on /admin; fix stale service links
This commit is contained in:
1 parent
e513007aa4
commit
dfd9c8d1c0
18 files changed
+497
-6
No files matched your search
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user