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

233 lines
9.2 KiB
JavaScript

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 readCredentialRejection(page) {
const text = await page
.locator(
"[role='alert']:visible, .alert:visible, .error:visible, .v-alert:visible, [class*='error']:visible",
)
.first()
.innerText({ timeout: resolveTimeout(2_000) })
.catch(() => "");
const line = text.trim().split("\n")[0].slice(0, 200);
return /invalid|incorrect|wrong|failed|denied|do(es)? not match/i.test(line) ? line : "";
}
async function runAdminFlow(page, opts = {}) {
if ((process.env.PERSONA_ADMINISTRATOR_BLOCKED || "").toLowerCase() === "true") {
test.skip(
true,
`administrator persona is explicitly blocked by the role contract (PERSONA_ADMINISTRATOR_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 adminUsername = readEnv("ADMIN_USERNAME");
const adminPassword = readEnv("ADMIN_PASSWORD");
const adminNativePassword = readEnv("ADMIN_NATIVE_PASSWORD");
if (!appBaseUrl || !canonicalDomain) {
test.skip(
true,
"Auth-less role (no APP_BASE_URL / CANONICAL_DOMAIN) — persona scenario collapsed.",
);
return;
}
await page.context().clearCookies();
const oidcEnabled = safeIsEnabled("sso");
await gotoOnion(page,`${appBaseUrl}/`, { waitUntil: "domcontentloaded" }).catch(() => {});
let keycloakRoundTripCompleted = false;
let authPage = page;
if (adminUsername && adminPassword) {
if (oidcEnabled && !page.url().includes("openid-connect/auth")) {
const strictLogin = page
.getByRole("link", { name: /^\s*(log\s*in|sign\s*in|login|sso|admin)\s*$/i })
.or(page.getByRole("button", { name: /^\s*(log\s*in|sign\s*in|login|sso|admin)\s*$/i }))
.first();
const looseLogin = page
.getByRole("link", { name: /log\s*in|sign\s*in|sso|admin/i })
.or(page.getByRole("button", { name: /log\s*in|sign\s*in|sso|admin/i }))
.first();
authPage = (await clickOidcLoginLink(page, strictLogin, looseLogin)) || page;
}
if (authPage.url().includes("openid-connect/auth")) {
await performKeycloakLogin(authPage, adminUsername, adminPassword, canonicalDomain);
keycloakRoundTripCompleted = true;
}
}
let nativeLoginCompleted = false;
if (!oidcEnabled && adminUsername && adminPassword) {
const base = appBaseUrl.replace(/\/$/, "");
const tryNativeLogin = async (probeTimeout = 5_000) => {
const passwordField = page.locator("input[type='password']:visible").first();
const passwordReady = await passwordField
.waitFor({ state: "visible", timeout: resolveTimeout(probeTimeout) })
.then(() => true)
.catch(() => false);
if (!passwordReady) {
return false;
}
const usernameField = page
.locator(
"input[name='username']:visible, input[name='email']:visible, input[name='login']:visible, input[name$='[username]']:visible, input[name$='[email]']:visible, input[type='email']:visible, input[autocomplete='username']:visible",
)
.first();
if (await usernameField.isVisible().catch(() => false)) {
await usernameField.fill(adminUsername).catch(() => {});
}
await passwordField.fill(adminNativePassword || adminPassword).catch(() => {});
await passwordField.press("Enter").catch(() => {});
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(15_000) }).catch(() => {});
if (await passwordField.isVisible().catch(() => false)) {
await page
.getByRole("button", { name: /log\s*in|sign\s*in|login|submit/i })
.or(page.locator("button[type='submit'], input[type='submit']"))
.first()
.click({ timeout: resolveTimeout(30_000) })
.catch(() => {});
await page.waitForLoadState("networkidle", { timeout: resolveTimeout(15_000) }).catch(() => {});
}
return true;
};
let loginAttempted = await tryNativeLogin(15_000);
if (!loginAttempted) {
for (const loginPath of ["/login", "/admin/", "/admin"]) {
await gotoOnion(page,`${base}${loginPath}`, { waitUntil: "domcontentloaded" }).catch(() => {});
if (await tryNativeLogin()) {
loginAttempted = true;
break;
}
}
}
const passwordStillVisible = await page
.locator("input[type='password']:visible")
.first()
.isVisible()
.catch(() => false);
nativeLoginCompleted =
loginAttempted &&
!passwordStillVisible &&
new URL(page.url()).hostname.endsWith(canonicalDomain);
}
const adminAuthMarker = (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: /(profile|user.?menu|^menu$|signed\s*in)/i }))
.or(surface.getByRole("link", { name: /(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 adminReachedAuthenticated =
(keycloakRoundTripCompleted || nativeLoginCompleted) &&
new URL(page.url()).hostname.endsWith(canonicalDomain);
if (!adminReachedAuthenticated) {
adminReachedAuthenticated = await adminAuthMarker(page)
.first()
.waitFor({ state: "visible", timeout: resolveTimeout(15_000) })
.then(() => true)
.catch(() => false);
}
if (!adminReachedAuthenticated) {
for (const frame of page.frames()) {
if (frame === page.mainFrame()) continue;
const fUrl = frame.url();
if (!fUrl || fUrl === "about:blank") continue;
if (await adminAuthMarker(frame).first().isVisible().catch(() => false)) {
adminReachedAuthenticated = true;
break;
}
}
}
if (!adminReachedAuthenticated) {
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) {
adminReachedAuthenticated = true;
break;
}
if (appBaseUrl && fUrl.startsWith(appBaseUrl)) {
adminReachedAuthenticated = true;
break;
}
}
}
if (adminReachedAuthenticated) {
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,
`administrator'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 (!adminReachedAuthenticated) {
const rejection = await readCredentialRejection(page);
const rejected =
`administrator's credential was rejected by the app on ${canonicalDomain}: "${rejection}". ` +
`The password typed here does not match what the app stores, so the role seeds it at install ` +
`and never re-applies it: a rotation between the sync and async pass leaves the app behind. `;
const unreached =
`administrator did NOT reach an authenticated surface on ${canonicalDomain}. ` +
`Either the role's auth chain is broken or administrator legitimately has no OIDC-driven admin path here, ` +
`in which case the role MUST declare \`PERSONA_ADMINISTRATOR_BLOCKED=true\` in templates/playwright.env.j2. `;
expect(false, `${rejection ? rejected : unreached}Current URL: ${page.url()}.`).toBe(true);
return;
}
await assertCspInjections(page, { isEnabled: safeIsEnabled });
await runRoleInteraction(page, { canonicalDomain, roleInteraction: opts.adminInteraction });
await inAppLogout(page);
await assertUnauthenticatedLanding(page, appBaseUrl);
}
module.exports = { runAdminFlow };