diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index e0e1f3e..18d3794 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -35,8 +35,11 @@ export default defineConfig({ // 2026-10-19 - the newest guarded app, in neither GUARDED list nor any // spec until now: the 17-hermes "uncovered vhost broke silently" class). // 22- joins Prometheus's app-is-really-there proof (its own API), closing - // the "not a login wall but maybe a 404" hole 20- closed for Grafana. - testMatch: /(10|12|13|16|17|18|20|21|22)-.*\.spec\.ts/, + // the "not a login wall but maybe a 404" hole 20- closed for Grafana. 24- + // joins 2026-11-03 (Audiobookshelf's DEFAULT listener grant) and belongs to + // this project precisely because the standard identity is in no Audiobookshelf + // group — the admin project could not observe the default. + testMatch: /(10|12|13|16|17|18|20|21|22|24)-.*\.spec\.ts/, use: { storageState: ".auth/qa-e2e.json" }, dependencies: ["setup"], }, diff --git a/tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts b/tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts new file mode 100644 index 0000000..1db46d3 --- /dev/null +++ b/tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +// Audiobookshelf grants the LISTENER role to anyone the IdP lets sign in (2026-11-03). +// +// The role used to come from membership of `audiobookshelf_users` — a group nobody was ever +// added to. The household accounts exist in authentik, and every one of them was denied at ABS +// AFTER a successful authentication, which reads as a broken login rather than a policy decision. +// The scope mapping now answers `abs_groups: ["user"]` for every signer and reserves `admin` for +// `audiobookshelf_admins`, so the IdP is the only gate to the library and onboarding a listener +// is exactly "create the account". +// +// This is the counterpart to 23-: that spec proves the admin escalation, this one proves the +// DEFAULT grant, from an identity in neither group. Against the old mapping it is red — ABS +// refuses the sign-in and the SPA never leaves /login. +const HOST = "192.168.0.235:8000"; +const ABS = `http://${HOST}/audiobookshelf`; + +test("an SSO signer in no Audiobookshelf group lands as a listener", async ({ page }) => { + test.setTimeout(180_000); + + await page.goto(`${ABS}/login`, { waitUntil: "domcontentloaded" }); + const sso = page.getByText("Sign in with Authentik"); + await expect(sso).toBeVisible(); + await sso.click(); + + // The standard project's storageState carries a live authentik session, so the round trip is + // silent: authorize -> code -> callback -> token. Still being on /login means the app refused + // the identity (the denial this change removes) or the exchange failed. + await expect + .poll(() => !new URL(page.url()).pathname.endsWith("/login"), { timeout: 90_000 }) + .toBe(true); + + const grabToken = () => + page.evaluate(() => { + for (let i = 0; i < localStorage.length; i++) { + const v = localStorage.getItem(localStorage.key(i) as string) || ""; + if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(v)) return v; + } + const c = document.cookie.match(/(?:^|;\s*)abs_token=([^;]+)/); + return c ? decodeURIComponent(c[1]) : ""; + }); + await expect.poll(grabToken, { timeout: 60_000 }).not.toBe(""); + + const me = await page.request.get(`${ABS}/api/me`, { + headers: { Authorization: `Bearer ${await grabToken()}` }, + }); + expect(me.status()).toBe(200); + const user = await me.json(); + expect(user.username).toBe("qa-e2e"); + // The default, not a grant: the listener role for a signer in neither group. Being `user` + // rather than `admin` is also the proof that the default never escalates. + expect(user.type).toBe("user"); +});