import { expect, test, type Page } 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 open entries) so a registry edit cannot leave // this test asserting a stale world. const CHALLENGE = "Making sure you're not a bot"; // Vendor hand-offs. An app may legitimately carry a session-less browser to its // OWN hosted surface: Plex Web (plex.franksdank.cloud, served by our stack) // bounces a signed-out visitor to app.plex.tv after it has already answered. // That is the app working, not a guard-chain failure — the fleet contract this // spec owns is still judged: the Anubis challenge cleared, no authentik // uidField prompt appeared, and the app answered below 500. Any host NOT listed // here still has to land on its own vhost. const HANDOFFS: Record = { "plex.franksdank.cloud": ["app.plex.tv", "accounts.plex.tv", "clients.plex.tv"], }; async function settleThroughGuard( page: Page, targetHost: string, foreignHosts: string[] = [], ): Promise { // Each guarded vhost runs its own Anubis proof-of-work on first visit; // wait for the challenge to clear before judging the destination. await expect .poll(async () => !(await page.locator("body").innerText()).includes(CHALLENGE), { timeout: 90_000 }) .toBe(true); const landing = [targetHost, ...foreignHosts]; await expect .poll(() => landing.includes(new URL(page.url()).hostname), { timeout: 60_000 }) .toBe(true); expect(await page.locator('input[name="uidField"]').count()).toBe(0); } async function backHome(page: Page): Promise { // The homepage is the loop's only anchor, and some apps keep issuing their // OWN navigation after load — Plex Web's "would like to sign in to your // Plex account" iframe redirects the top frame, which cancels our goto with // net::ERR_ABORTED. That abort is the app's behaviour, not a guard-chain // failure (the app itself was already judged above), so tolerate it and // re-issue rather than letting one host abort the whole crawl. for (let attempt = 0; attempt < 3; attempt++) { try { await page.goto("/", { waitUntil: "domcontentloaded" }); return; } catch (err) { if (!String(err).includes("ERR_ABORTED")) throw err; await page.waitForTimeout(1_000); } } await page.goto("/"); } test("every user open link reaches its app", async ({ page }) => { test.setTimeout(420_000); // Expected link count comes from the live registry, mirroring the // showLink=true user entries — a registry edit cannot stale this test. const registry: { name: string; category: string; showLink: boolean }[] = await (await page.request.get("/services.json")).json(); const expectedUserLinks = registry.filter((s) => s.category === "user" && s.showLink).length; await page.goto("/"); await expect(page.locator("#grid a.open")).toHaveCount(expectedUserLinks); const hrefs = await page .locator("#grid a.open") .evaluateAll((els) => els.map((el) => ({ href: (el as HTMLAnchorElement).href, label: el.textContent ?? "" }))); const failures: string[] = []; for (const link of hrefs) { const target = new URL(link.href); try { await page.goto(link.href); await settleThroughGuard(page, target.hostname, HANDOFFS[target.hostname] ?? []); const resp = await page.request.get(page.url()); if (resp.status() >= 500) { failures.push(`${link.label}: ${page.url()} answered ${resp.status()}`); } } catch (err) { failures.push(`${link.label}: ${err instanceof Error ? err.message : String(err)}`); } await backHome(page); await expect(page.locator("#grid a.open")).toHaveCount(expectedUserLinks); } expect(failures, failures.join("\n")).toEqual([]); });