The full-suite re-run after the reset fix came back "1 flaky": 12-crawl reached plex.franksdank.cloud, the guard cleared, and Plex Web — signed out in the qa-e2e profile — handed the browser to its own hosted sign-in surface, so the hostname never settled on the vhost (60 s poll, Expected plex.franksdank.cloud / Received app.plex.tv). settleThroughGuard now takes a HANDOFFS landing list per vhost; only plex.franksdank.cloud gets one, every other host still has to land on its own vhost, and the guard assertions (Anubis cleared, no authentik uidField, status < 500) are unchanged. Worklog and E2E gotcha 6 carry both symptoms: the ERR_ABORTED reset and the hand-off.
90 lines
3.9 KiB
TypeScript
90 lines
3.9 KiB
TypeScript
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<string, string[]> = {
|
|
"plex.franksdank.cloud": ["app.plex.tv", "accounts.plex.tv", "clients.plex.tv"],
|
|
};
|
|
|
|
async function settleThroughGuard(
|
|
page: Page,
|
|
targetHost: string,
|
|
foreignHosts: string[] = [],
|
|
): Promise<void> {
|
|
// 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<void> {
|
|
// 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([]);
|
|
});
|