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 };