const { expect } = require("@playwright/test"); const { resolveTimeout } = require("./timeouts"); const { isServiceEnabled } = require("./service-gating"); const { decodeDotenvQuotedValue, gotoOnion, installCspViolationObserver, normalizeBaseUrl, } = require("./personas"); const env = { appBaseUrl: normalizeBaseUrl(process.env.APP_BASE_URL || ""), keycloakBaseUrl: normalizeBaseUrl(process.env.KEYCLOAK_BASE_URL || ""), realmName: decodeDotenvQuotedValue(process.env.KEYCLOAK_REALM_NAME), wpBaseUrl: normalizeBaseUrl(process.env.WORDPRESS_BASE_URL || ""), oidcIssuerUrl: decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL), superAdminUsername: decodeDotenvQuotedValue(process.env.SUPER_ADMIN_USERNAME), superAdminPassword: decodeDotenvQuotedValue(process.env.SUPER_ADMIN_PASSWORD), adminUsername: decodeDotenvQuotedValue(process.env.ADMIN_USERNAME), adminPassword: decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD), biberUsername: decodeDotenvQuotedValue(process.env.BIBER_USERNAME), biberPassword: decodeDotenvQuotedValue(process.env.BIBER_PASSWORD), canonicalDomain: decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN), rbacGroupPathPrefix: decodeDotenvQuotedValue(process.env.RBAC_GROUP_PATH_PREFIX), multisiteEnabled: (process.env.WORDPRESS_MULTISITE_ENABLED || "").toLowerCase() === "true", discourseBaseUrl: normalizeBaseUrl(process.env.DISCOURSE_BASE_URL || ""), discourseApiKey: decodeDotenvQuotedValue(process.env.DISCOURSE_API_KEY), discourseApiUsername: decodeDotenvQuotedValue(process.env.DISCOURSE_API_USERNAME), }; function attachDiagnostics(page) { const consoleErrors = []; const pageErrors = []; const cspRelated = []; page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); if (/content security policy|csp/i.test(message.text())) { cspRelated.push({ source: "console", text: message.text() }); } }); page.on("pageerror", (error) => { const text = String(error); pageErrors.push(text); if (/content security policy|csp/i.test(text)) { cspRelated.push({ source: "pageerror", text }); } }); return { consoleErrors, pageErrors, cspRelated }; } async function fillKeycloakLoginForm(page, username, password) { const usernameField = page .locator("input[name='username'], input#username") .first(); const passwordField = page .locator("input[name='password'], input#password") .first(); const signInButton = page .locator( "input#kc-login, button#kc-login, button[type='submit'], input[type='submit']" ) .first(); await expect( usernameField, "Expected Keycloak username field to be visible" ).toBeVisible({ timeout: resolveTimeout(60_000) }); await usernameField.fill(username); await passwordField.fill(password); await signInButton.click({ timeout: resolveTimeout(30_000) }); } // WP uses login_type=auto — visiting wp-login.php triggers OIDC redirect when // there's no WP session. We land at Keycloak, sign in, and get redirected // back to /wp-admin/. async function wpAdminLoginViaOidc(page, wpBaseUrl, username, password) { await gotoOnion(page, `${wpBaseUrl}/wp-login.php`, { waitUntil: "domcontentloaded" }); const url = page.url(); if (!url.includes(wpBaseUrl)) { await fillKeycloakLoginForm(page, username, password); } await expect .poll(() => page.url(), { timeout: resolveTimeout(60_000), message: `Expected redirect back to ${wpBaseUrl}/wp-admin after OIDC login`, }) .toContain("/wp-admin"); } // Client-side sign-out: clearing cookies achieves the same test-isolation // goal as wp-login.php?action=logout but avoids the OIDC plugin's // `redirect_on_logout: true` SLO-confirmation hop which is fragile to // navigate out of inside a Playwright flow. async function wpSignOut(page, wpBaseUrl) { await page.context().clearCookies().catch(() => {}); await gotoOnion(page, `${wpBaseUrl}/`, { waitUntil: "domcontentloaded" }).catch(() => {}); } async function keycloakAdminOpenUserProfile( page, keycloakBaseUrl, realmName, username ) { await gotoOnion(page, `${keycloakBaseUrl}/admin/master/console/#/${realmName}/users`, { waitUntil: "domcontentloaded", }); const searchInput = page .locator("input[placeholder*='Search'], input[name='search']") .first(); await expect(searchInput).toBeVisible({ timeout: resolveTimeout(60_000) }); await searchInput.fill(username); await searchInput.press("Enter"); const userRowLink = page .locator("table a, [role='gridcell'] a, a[data-testid='user-row']") .filter({ hasText: new RegExp(`^${username}$`, "i") }) .first(); await expect(userRowLink).toBeVisible({ timeout: resolveTimeout(60_000) }); await userRowLink.click({ timeout: resolveTimeout(30_000) }); await expect .poll(() => page.url(), { timeout: resolveTimeout(30_000), message: `Expected Keycloak user profile URL after clicking "${username}"`, }) .toMatch(/\/users\/[^/]+/); } // Scope strictly to role=tab so we don't accidentally hit the left-nav // "Groups" link, which would navigate away from the user profile back // to the Groups overview. async function keycloakAdminOpenUserGroupsTab(page) { const groupsTab = page .locator("[role='tab']") .filter({ hasText: /^Groups$/ }) .first(); await expect(groupsTab).toBeVisible({ timeout: resolveTimeout(30_000) }); await groupsTab.click(); await expect .poll(() => page.url(), { timeout: resolveTimeout(30_000), message: "Expected Keycloak user profile to switch to the Groups tab", }) .toMatch(/\/users\/[^/]+\/groups/); } // Returned tokens are short-lived and intentionally not cached across // calls so the helpers stay safe to use after redeploys. async function keycloakAdminToken(request, keycloakBaseUrl) { const tokenResp = await request.post( `${keycloakBaseUrl}/realms/master/protocol/openid-connect/token`, { form: { client_id: "admin-cli", grant_type: "password", username: env.superAdminUsername, password: env.superAdminPassword, }, headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: resolveTimeout(30_000), } ); if (!tokenResp.ok()) { throw new Error( `Keycloak admin token request failed: ${tokenResp.status()} ${await tokenResp.text()}` ); } const json = await tokenResp.json(); if (!json.access_token) { throw new Error("Keycloak admin token response missing access_token"); } return json.access_token; } // Resolve via /admin/realms//group-by-path/...; fall back to a // segment walk if the endpoint is missing on older Keycloak versions. async function keycloakResolveGroupId( request, keycloakBaseUrl, realmName, accessToken, groupPath ) { const headers = { Authorization: `Bearer ${accessToken}` }; const trimmed = groupPath.replace(/^\//, ""); const byPath = await request.get( `${keycloakBaseUrl}/admin/realms/${encodeURIComponent(realmName)}/group-by-path/${trimmed}`, { headers, timeout: resolveTimeout(30_000) } ); if (byPath.ok()) { const group = await byPath.json(); if (group?.id) return group.id; } const segments = trimmed.split("/").filter((s) => s !== ""); if (segments.length === 0) { throw new Error(`Empty Keycloak group path: ${groupPath}`); } let parentId = null; for (let i = 0; i < segments.length; i++) { const wanted = segments[i]; const url = parentId === null ? `${keycloakBaseUrl}/admin/realms/${realmName}/groups?max=500&search=${encodeURIComponent(wanted)}` : `${keycloakBaseUrl}/admin/realms/${realmName}/groups/${parentId}/children?max=500`; const resp = await request.get(url, { headers, timeout: resolveTimeout(30_000) }); if (!resp.ok()) { throw new Error( `Keycloak groups lookup failed at segment ${i} (${wanted}): ${resp.status()} ${await resp.text()}` ); } const items = await resp.json(); let match; if (parentId === null) { const walk = (nodes, depth) => { for (const n of nodes) { if (depth === 0 && n.name === wanted) return n; if (n.subGroups && n.subGroups.length) { const r = walk(n.subGroups, depth - 1); if (r) return r; } } return null; }; match = walk(items, 0); } else { match = items.find((n) => n.name === wanted) || null; } if (!match) { throw new Error( `Keycloak group "${groupPath}" not found while resolving segment "${wanted}"` ); } parentId = match.id; } return parentId; } // Returns: // true — the user was not a member and the helper joined them. // false — the user was ALREADY a member; caller MUST NOT teardown-remove // (auto-add idempotency contract). // // The Keycloak admin "Join Group" dialog filters by leaf name only and // paginates. Once the RBAC tree contains many subgroups whose leaf is // the same role name, the WordPress entry can fall outside the first // page and the dialog stops being a reliable test driver. We drive the // join through the Admin REST API while still asserting the tree shape // that the OIDC `groups` claim must mirror. async function keycloakAdminAddUserToGroup( page, keycloakBaseUrl, realmName, targetGroupPath, username ) { const request = page.context().request; const accessToken = await keycloakAdminToken(request, keycloakBaseUrl); const headers = { Authorization: `Bearer ${accessToken}` }; const userResp = await request.get( `${keycloakBaseUrl}/admin/realms/${realmName}/users?username=${encodeURIComponent(username)}&exact=true`, { headers, timeout: resolveTimeout(30_000) } ); if (!userResp.ok()) { throw new Error( `Keycloak user lookup failed: ${userResp.status()} ${await userResp.text()}` ); } const users = await userResp.json(); const user = users.find((u) => u.username === username); if (!user) { throw new Error(`Keycloak user "${username}" not found`); } const groupId = await keycloakResolveGroupId( request, keycloakBaseUrl, realmName, accessToken, targetGroupPath ); const memberResp = await request.get( `${keycloakBaseUrl}/admin/realms/${realmName}/users/${user.id}/groups?max=500`, { headers, timeout: resolveTimeout(30_000) } ); if (!memberResp.ok()) { throw new Error( `Keycloak user-groups lookup failed: ${memberResp.status()} ${await memberResp.text()}` ); } const currentGroups = await memberResp.json(); if (currentGroups.some((g) => g.id === groupId || g.path === targetGroupPath)) { return false; } const joinResp = await request.put( `${keycloakBaseUrl}/admin/realms/${realmName}/users/${user.id}/groups/${groupId}`, { headers, timeout: resolveTimeout(30_000) } ); if (!joinResp.ok()) { throw new Error( `Keycloak join-group failed (user=${username}, group=${targetGroupPath}): ${joinResp.status()} ${await joinResp.text()}` ); } return true; } // Legacy UI-driven variant kept for re-enabling once Keycloak's admin // "Join Group" dialog grows a non-paginated tree view. The REST variant // above is the authoritative driver; this one is preserved through the // module export so eslint sees it as "used". async function keycloakAdminAddUserToGroupViaUi( page, keycloakBaseUrl, realmName, targetGroupPath, username ) { const pathSegments = targetGroupPath.replace(/^\//, "").split("/"); const searchTerm = pathSegments[pathSegments.length - 1]; await keycloakAdminOpenUserProfile(page, keycloakBaseUrl, realmName, username); await keycloakAdminOpenUserGroupsTab(page); const joinButton = page .locator("button") .filter({ hasText: /join\s*group/i }) .first(); await expect( joinButton, "Expected the 'Join Group' button on the user's Groups tab" ).toBeVisible({ timeout: resolveTimeout(30_000) }); await joinButton.click(); const dialog = page.getByRole("dialog", { name: /join groups/i }).first(); await expect(dialog).toBeVisible({ timeout: resolveTimeout(30_000) }); const dialogSearchBox = dialog.getByRole("textbox", { name: /search/i }).first(); await expect(dialogSearchBox).toBeVisible({ timeout: resolveTimeout(30_000) }); await dialogSearchBox.fill(searchTerm); await dialogSearchBox.press("Enter"); // The Keycloak admin "Join Group" dialog paginates (default 10/page); // for role names that recur across applications, the WordPress entry // can fall outside the first page. Click "Next" until the exact target // path appears or pagination is exhausted. const targetCheckbox = dialog .getByRole("checkbox", { name: targetGroupPath, exact: true }) .first(); for (let pageIndex = 0; pageIndex < 10; pageIndex++) { if (await targetCheckbox.isVisible().catch(() => false)) { break; } const nextButton = dialog .getByRole("button", { name: /^next/i }) .first(); const nextVisible = await nextButton.isVisible().catch(() => false); const nextEnabled = nextVisible ? await nextButton.isEnabled().catch(() => false) : false; if (!nextEnabled) { break; } await nextButton.click(); await page.waitForTimeout(resolveTimeout(500)); } // When the user is already a member, Keycloak's "Join Group" dialog // hides that group entirely. Treat a missing checkbox as "already a // member" and let the caller skip teardown removal. const targetCheckboxVisible = await targetCheckbox .isVisible() .catch(() => false); if (!targetCheckboxVisible) { await dialog .getByRole("button", { name: /^cancel|^close$/i }) .first() .click() .catch(() => {}); await expect(dialog).toBeHidden({ timeout: resolveTimeout(30_000) }); return false; } if (await targetCheckbox.isDisabled()) { await dialog .getByRole("button", { name: /^close$/i }) .first() .click() .catch(() => {}); await expect(dialog).toBeHidden({ timeout: resolveTimeout(30_000) }); return false; } await targetCheckbox.check(); const confirmJoin = dialog.getByRole("button", { name: /^join$/i }).first(); await expect(confirmJoin).toBeEnabled({ timeout: resolveTimeout(30_000) }); await confirmJoin.click(); await expect(dialog).toBeHidden({ timeout: resolveTimeout(30_000) }); const lastSegment = pathSegments[pathSegments.length - 1]; const membershipRow = page .locator("tr, li") .filter({ hasText: new RegExp(lastSegment) }) .first(); await expect( membershipRow, `Expected "${targetGroupPath}" to appear as a membership on the user's Groups tab after joining.` ).toBeVisible({ timeout: resolveTimeout(30_000) }); return true; } // REST teardown: the admin-UI Groups tab's row-level Leave affordance is // fragile across Keycloak UI versions; the REST API keeps the // idempotency guarantee deterministic. async function keycloakRemoveUserFromGroupViaRest( request, keycloakBaseUrl, realmName, adminUsername, adminPassword, groupPath, username ) { const tokenResp = await request.post( `${keycloakBaseUrl}/realms/master/protocol/openid-connect/token`, { form: { client_id: "admin-cli", grant_type: "password", username: adminUsername, password: adminPassword, }, timeout: resolveTimeout(30_000), } ); if (!tokenResp.ok()) { throw new Error( `Admin token request failed (${tokenResp.status()}): ${await tokenResp.text()}` ); } const { access_token: accessToken } = await tokenResp.json(); const auth = { Authorization: `Bearer ${accessToken}` }; const usersResp = await request.get( `${keycloakBaseUrl}/admin/realms/${encodeURIComponent(realmName)}/users?username=${encodeURIComponent(username)}&exact=true`, { headers: auth, timeout: resolveTimeout(30_000) } ); const users = await usersResp.json(); const userId = users?.[0]?.id; if (!userId) return; const groupResp = await request.get( `${keycloakBaseUrl}/admin/realms/${encodeURIComponent(realmName)}/group-by-path/${groupPath.replace(/^\//, "")}`, { headers: auth, timeout: resolveTimeout(30_000) } ); if (!groupResp.ok()) return; const group = await groupResp.json(); if (!group?.id) return; await request.delete( `${keycloakBaseUrl}/admin/realms/${encodeURIComponent(realmName)}/users/${userId}/groups/${group.id}`, { headers: auth, timeout: resolveTimeout(30_000) } ); } async function discourseApiRequest(request, path, init = {}) { if (!env.discourseBaseUrl) { throw new Error("DISCOURSE_BASE_URL is not set"); } if (!env.discourseApiKey) { throw new Error("DISCOURSE_API_KEY is not set"); } const headers = { "Api-Key": env.discourseApiKey, "Api-Username": env.discourseApiUsername || "system", Accept: "application/json", ...(init.headers || {}), }; const url = `${env.discourseBaseUrl}${path}`; const method = (init.method || "GET").toUpperCase(); if (method === "GET") { return request.get(url, { headers, timeout: resolveTimeout(30_000) }); } if (method === "DELETE") { return request.delete(url, { headers, timeout: resolveTimeout(30_000) }); } throw new Error(`discourseApiRequest: unsupported method ${method}`); } async function discourseSearchTopicByTitle(request, title) { const resp = await discourseApiRequest( request, `/search.json?q=${encodeURIComponent(title)}` ); if (!resp.ok()) return null; const body = await resp.json(); const topics = Array.isArray(body?.topics) ? body.topics : []; return topics.find((t) => (t.title || "").trim() === title.trim()) || null; } async function discourseDeleteTopic(request, topicId) { if (!topicId) return; await discourseApiRequest(request, `/t/${topicId}.json`, { method: "DELETE", }).catch(() => {}); } async function beforeEach({ page }) { await page.setViewportSize({ width: 1440, height: 1100 }); expect(env.appBaseUrl, "APP_BASE_URL must be set").toBeTruthy(); expect(env.wpBaseUrl, "WORDPRESS_BASE_URL must be set").toBeTruthy(); if (isServiceEnabled("sso")) { expect(env.keycloakBaseUrl, "KEYCLOAK_BASE_URL must be set").toBeTruthy(); expect(env.realmName, "KEYCLOAK_REALM_NAME must be set").toBeTruthy(); expect(env.oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy(); expect(env.superAdminUsername, "SUPER_ADMIN_USERNAME must be set").toBeTruthy(); expect(env.superAdminPassword, "SUPER_ADMIN_PASSWORD must be set").toBeTruthy(); } expect(env.adminUsername, "ADMIN_USERNAME must be set").toBeTruthy(); expect(env.adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy(); expect(env.biberUsername, "BIBER_USERNAME must be set").toBeTruthy(); expect(env.biberPassword, "BIBER_PASSWORD must be set").toBeTruthy(); expect(env.canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy(); expect(env.rbacGroupPathPrefix, "RBAC_GROUP_PATH_PREFIX must be set").toBeTruthy(); await page.context().clearCookies(); await installCspViolationObserver(page); } module.exports = { env, attachDiagnostics, fillKeycloakLoginForm, wpAdminLoginViaOidc, wpSignOut, keycloakAdminToken, keycloakResolveGroupId, keycloakAdminAddUserToGroup, keycloakAdminOpenUserProfile, keycloakAdminOpenUserGroupsTab, keycloakAdminAddUserToGroupViaUi, keycloakRemoveUserFromGroupViaRest, discourseApiRequest, discourseSearchTopicByTitle, discourseDeleteTopic, beforeEach, };