mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
546 lines
19 KiB
JavaScript
546 lines
19 KiB
JavaScript
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/<realm>/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,
|
|
};
|