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>
233 lines
9.2 KiB
JavaScript
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 };
|