Files
kevinveenbirkenbachandClaude Opus 5 b582846ab3 fix(test-e2e-playwright): make the Keycloak redirect and login-control checks able to fail
performKeycloakLogin polled the page URL with toContain(canonicalDomain).
On the Keycloak auth page the app domain already sits in redirect_uri,
so the poll was satisfied before the login had completed. The denial
path and the biber/admin iframe checks used the same substring test.
All of them now compare hostnameOf(url) with the canonical domain, which
is a bare hostname (env.js reads it with /\.onion$/).

LOGIN_CONTROL_NAME never matched a login link that carries an
icon-font glyph: Playwright's accessible name includes the private-use
character, and the pattern only allowed whitespace before the text. The
post-login waitFor({state: "hidden"}) therefore passed at once, even
before oidc.js had swapped the link. The pattern now admits \p{Co}
before the text, so that wait really waits for the swap.

dom.test.js pins both: glyph-prefixed login controls match while Logout
and Accessories do not, an IdP URL carrying the app domain in
redirect_uri is rejected, a look-alike suffix is rejected, and
about:blank yields an empty host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:15:10 +02:00

182 lines
6.8 KiB
JavaScript

/**
* `biber` persona: single-app authenticated journey.
*
* appBaseUrl → (OIDC if applicable) → CSP injection check
* → role-specific interaction → in-app logout
* → unauthenticated landing assertion.
*
* Cross-service surface checks (prometheus deny, matomo deny,
* dashboard tile reachability) are owned by the dedicated provider
* specs and no longer run as part of every role's biber persona:
*
* - `roles/web-app-prometheus/files/playwright/playwright.spec.js` parameterises
* scrape-target presence + admin reach + biber denial.
* - `roles/web-app-matomo/files/playwright/playwright.spec.js` parameterises
* tracker-site presence + admin reach + biber denial.
* - `roles/web-app-dashboard/files/playwright/playwright.spec.js` parameterises
* dashboard-tile reachability per consumer role.
*
* Each role's biber scenario therefore visits its OWN canonical URL
* directly (no dashboard tile click) and exercises only that role.
*/
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("../timeouts");
const {
normalizeUrl,
readEnv,
safeIsEnabled,
gotoOnion,
performKeycloakLogin,
clickOidcLoginLink,
inAppLogout,
assertUnauthenticatedLanding,
assertCspInjections,
runRoleInteraction,
hostnameOf,
LOGIN_CONTROL_NAME,
} = require("./utils");
async function runBiberFlow(page, opts = {}) {
if ((process.env.PERSONA_BIBER_BLOCKED || "").toLowerCase() === "true") {
test.skip(
true,
`biber persona is explicitly blocked by the role contract (PERSONA_BIBER_BLOCKED=true). See the role's TODO.md for the rationale and the path back to a runnable journey.`,
);
return;
}
safeIsEnabled("sso");
safeIsEnabled("logout");
safeIsEnabled("matomo");
const canonicalDomain = readEnv("CANONICAL_DOMAIN");
const appBaseUrl = normalizeUrl(process.env.APP_BASE_URL);
const biberUsername = readEnv("BIBER_USERNAME");
const biberPassword = readEnv("BIBER_PASSWORD");
if (!appBaseUrl || !canonicalDomain) {
test.skip(
true,
"Auth-less role (no APP_BASE_URL / CANONICAL_DOMAIN) — persona scenario collapsed.",
);
return;
}
await page.context().clearCookies();
await gotoOnion(page,`${appBaseUrl}/`, { waitUntil: "domcontentloaded" }).catch(() => {});
const oidcEnabled = safeIsEnabled("sso");
let keycloakRoundTripCompleted = false;
let authPage = page;
if (biberUsername && biberPassword) {
if (oidcEnabled && !page.url().includes("openid-connect/auth")) {
const strictLogin = page
.getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i })
.or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso)\s*$/i }))
.first();
const looseLogin = page
.getByRole("link", { name: /log\s*in|sign\s*in|sso/i })
.or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso/i }))
.first();
authPage = (await clickOidcLoginLink(page, strictLogin, looseLogin)) || page;
}
if (authPage.url().includes("openid-connect/auth")) {
await performKeycloakLogin(authPage, biberUsername, biberPassword, canonicalDomain);
keycloakRoundTripCompleted = true;
}
}
await assertCspInjections(page, { isEnabled: safeIsEnabled });
const authMarker = (surface) =>
surface
.getByRole("button", { name: /log\s*out|sign\s*out|sign-out|abmelden/i })
.or(surface.getByRole("link", { name: /log\s*out|sign\s*out|sign-out|abmelden/i }))
.or(surface.getByRole("menuitem", { name: /log\s*out|sign\s*out|sign-out|abmelden/i }))
.or(surface.getByRole("button", { name: /(account|profile|user.?menu|^menu$|signed\s*in)/i }))
.or(surface.getByRole("link", { name: /(account|profile|user.?menu|^menu$|signed\s*in)/i }))
.or(
surface.locator(
"[data-region='user-menu-toggle'], .user-menu-toggle, .usermenu, [aria-label*='user menu' i], [aria-label*='account' i], [data-testid*='user' i]:not(input):not(textarea):not(select), a[href*='logout' i], a[href*='end_session' i], a[href*='end-session' i]",
),
);
let reachedAuthenticated = keycloakRoundTripCompleted
&& new URL(page.url()).hostname.endsWith(canonicalDomain);
if (!reachedAuthenticated) {
reachedAuthenticated = await authMarker(page)
.first()
.waitFor({ state: "visible", timeout: resolveTimeout(15_000) })
.then(() => true)
.catch(() => false);
}
if (!reachedAuthenticated) {
for (const frame of page.frames()) {
if (frame === page.mainFrame()) continue;
const fUrl = frame.url();
if (!fUrl || fUrl === "about:blank") continue;
if (await authMarker(frame).first().isVisible().catch(() => false)) {
reachedAuthenticated = true;
break;
}
}
}
if (!reachedAuthenticated) {
for (const frame of page.frames()) {
if (frame === page.mainFrame()) continue;
const fUrl = frame.url();
if (!fUrl || fUrl === "about:blank") continue;
if (/openid-connect\/auth|\/oauth2\/(?:start|sign_in|callback)/.test(fUrl)) continue;
if (canonicalDomain && hostnameOf(fUrl) === canonicalDomain) {
reachedAuthenticated = true;
break;
}
if (appBaseUrl && fUrl.startsWith(appBaseUrl)) {
reachedAuthenticated = true;
break;
}
}
}
if (reachedAuthenticated) {
const loginControl = page
.getByRole("link", { name: LOGIN_CONTROL_NAME })
.or(page.getByRole("button", { name: LOGIN_CONTROL_NAME }))
.first();
const loginStillVisible = await loginControl
.waitFor({ state: "hidden", timeout: resolveTimeout(12_000) })
.then(() => false)
.catch(() => loginControl.isVisible().catch(() => false));
if (loginStillVisible) {
expect(
false,
`biber's OIDC login did NOT establish a session on ${canonicalDomain}: the ` +
`round-trip returned to the app but a Login control is still visible — the ` +
`code→token exchange did not complete (over Tor this is usually the OIDC ` +
`adapter/token timeout in personas/utils/keycloak.js, not a logout problem). ` +
`Current URL: ${page.url()}.`,
).toBe(true);
return;
}
}
if (!reachedAuthenticated) {
expect(
false,
`biber did NOT reach an authenticated surface on ${canonicalDomain}. ` +
`Either the role's auth chain is broken (OIDC mapping, post-login UI, logout button) ` +
`or biber legitimately has no access here, in which case the role MUST declare ` +
`\`PERSONA_BIBER_BLOCKED=true\` in templates/playwright.env.j2. ` +
`Current URL: ${page.url()}.`,
).toBe(true);
return;
}
await runRoleInteraction(page, { canonicalDomain, roleInteraction: opts.biberInteraction });
await inAppLogout(page);
await assertUnauthenticatedLanding(page, appBaseUrl);
}
module.exports = { runBiberFlow };