diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 16f925d..611bffa 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -29,8 +29,10 @@ export default defineConfig({ // green suite with a test that does not exist. 17- joined on 2026-09-16 // (the hermes vhost, whose upstream is a host address rather than a // container name, so it needs its own pass condition). 18- joined on - // 2026-09-24 (OpenWebUI SSO through authentik). - testMatch: /(10|12|13|16|17|18)-.*\.spec\.ts/, + // 2026-09-24 (OpenWebUI SSO through authentik). 20- joined on 2026-10-21 + // (Grafana's OAuth2 relying-party login, which the edge-only 13- never + // follows). + testMatch: /(10|12|13|16|17|18|20)-.*\.spec\.ts/, use: { storageState: ".auth/qa-e2e.json" }, dependencies: ["setup"], }, diff --git a/tests/e2e/specs/20-grafana-sso.spec.ts b/tests/e2e/specs/20-grafana-sso.spec.ts new file mode 100644 index 0000000..d8c850d --- /dev/null +++ b/tests/e2e/specs/20-grafana-sso.spec.ts @@ -0,0 +1,56 @@ +import { expect, test } from "@playwright/test"; + +// Grafana (grafana.franksdank.cloud) is an OAuth2 RELYING PARTY, not an +// outpost-fronted proxy: the guard chain (13-sso) only proves an authenticated +// session reaches Grafana's login page. The login itself happens through +// Grafana's own "Sign in with Authentik" button, which the edge test never +// follows — that is exactly how the RP login could be dead (500 on authorize, +// or an email-claim gap bouncing back to /login) while 13- still passed green. +// This spec drives the real RP round-trip and asserts a live Grafana session. + +const HOST = "grafana.franksdank.cloud"; +const CHALLENGE = "Making sure you're not a bot"; + +test("SSO: Grafana relying-party login lands a real session", async ({ page, request }) => { + test.setTimeout(180_000); + + // Warm the guard: the vhost runs its own Anubis proof-of-work on first visit. + await page.goto(`https://${HOST}/`); + await expect + .poll( + async () => !(await page.locator("body").innerText()).includes(CHALLENGE), + { timeout: 90_000 }, + ) + .toBe(true); + + // The "Sign in with Authentik" button targets this RP start URL exactly. + // Going straight there is the same code path without depending on the SPA's + // button markup. + await page.goto(`https://${HOST}/login/generic_oauth`); + + // The chain: grafana -> auth (implicit consent, the session is already + // there) -> back to grafana with the code -> grafana provisions the user and + // redirects to "/". A dead provider/claims config bounces back to /login. + await expect + .poll( + async () => { + const u = new URL(page.url()); + return u.hostname === HOST && u.pathname !== "/login"; + }, + { timeout: 90_000 }, + ) + .toBe(true); + + const body = await page.locator("body").innerText(); + expect(body).not.toContain("Login failed"); + expect(body.toLowerCase()).not.toContain("powered by authentik"); // IdP flow shell + expect(body).not.toContain(CHALLENGE); + + // A genuine logged-in session exists: /api/user answers 200 with an email, + // not 401. This is the assertion the edge-only spec could never make. + const resp = await page.request.get(`https://${HOST}/api/user`); + expect(resp.status()).toBe(200); + const user = await resp.json(); + expect(typeof user.email).toBe("string"); + expect(user.email.length).toBeGreaterThan(0); +});