mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
182 lines
6.8 KiB
JavaScript
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 };
|