mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
The helper raced a wait for the issuer redirect against a branch that waited for the SPA login control and clicked it. Promise.race does not cancel the loser, and those CTA selectors also match Keycloak own sign-in control, so after the redirect the orphaned branch clicked Keycloak submit button and posted the still empty form. Keycloak re-rendered the login page with an error, the two fills then landed on different documents, and the Enter press posted a half-filled form that the helper reported as a credential failure. The access log of the failing run settles it: of 34 logins, 27 posted once and produced no error at all, 7 posted twice, and all 9 LOGIN_ERROR events of the realm belong to those 7. Every first post measured either 3421 or 3434 bytes, and the 13 byte difference is exactly the length of the username, so the password field was empty in all of them. The trace of the failing attempt shows the click overlapping both fills. Five of the seven still passed on the second post, which is why it read as a flake. The race now races waits only and clicks once afterwards, a missing redirect raises instead of passing silently, and the submit goes through the form own control after a poll has proven both fields still hold their values. The poll yields a boolean so no password can reach the report. The shared Keycloak helper that 57 roles use has no such orphaned click and is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
6.5 KiB
JavaScript
162 lines
6.5 KiB
JavaScript
const { expect } = require("@playwright/test");
|
|
const { resolveTimeout } = require("./timeouts");
|
|
|
|
const { decodeDotenvQuotedValue, gotoOnion } = require("./personas");
|
|
|
|
const baseUrl = decodeDotenvQuotedValue(process.env.APP_BASE_URL);
|
|
const issuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL);
|
|
const adminUsername = decodeDotenvQuotedValue(process.env.LOGIN_USERNAME);
|
|
const adminPassword = decodeDotenvQuotedValue(process.env.LOGIN_PASSWORD);
|
|
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
|
|
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
|
|
|
|
const issuerHost = new URL(issuerUrl).host;
|
|
const issuerPattern = new RegExp(`^https?://${issuerHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
const baseUrlPattern = new RegExp(`^${baseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
|
|
|
|
function attachDiagnostics(page, _label) {
|
|
const diagnostics = { console: [], requests: [], errors: [] };
|
|
page.on("console", (msg) => {
|
|
diagnostics.console.push(`[${msg.type()}] ${msg.text()}`);
|
|
});
|
|
page.on("pageerror", (err) => {
|
|
diagnostics.errors.push(String(err));
|
|
});
|
|
page.on("requestfailed", (req) => {
|
|
diagnostics.requests.push(`${req.failure()?.errorText || ""} ${req.method()} ${req.url()}`);
|
|
});
|
|
return diagnostics;
|
|
}
|
|
|
|
async function ssoLoginAndAssertUsername(page, username, password) {
|
|
const diagnostics = attachDiagnostics(page, username);
|
|
// OpenCloud Web's index.html shows a "browser not supported" splash unless
|
|
// `forceAllowOldBrowser` is present in localStorage. Playwright's headless
|
|
// Chromium UA is not on its allow list, so prime the flag for the host
|
|
// before any navigation happens.
|
|
await page.addInitScript(() => {
|
|
try {
|
|
window.localStorage.setItem(
|
|
"forceAllowOldBrowser",
|
|
JSON.stringify({ expiry: Date.now() + 30 * 24 * 60 * 60 * 1000 })
|
|
);
|
|
} catch {}
|
|
});
|
|
await gotoOnion(page, baseUrl);
|
|
|
|
const onIssuer = async () => issuerPattern.test(page.url());
|
|
if (!(await onIssuer())) {
|
|
const loginCta = page.locator(
|
|
'button:has-text("Login"), button:has-text("Sign in"), button:has-text("Log in"), [data-test-id="login-button"], a[href*="oidc"]'
|
|
);
|
|
const reachedIssuer = page
|
|
.waitForURL(issuerPattern, { timeout: resolveTimeout(60_000) })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
const ctaReady = loginCta
|
|
.first()
|
|
.waitFor({ state: "visible", timeout: resolveTimeout(60_000) })
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
try {
|
|
await Promise.race([reachedIssuer, ctaReady]);
|
|
if (!(await onIssuer()) && (await ctaReady)) {
|
|
await loginCta.first().click({ timeout: resolveTimeout(30_000) });
|
|
}
|
|
if (!(await reachedIssuer)) {
|
|
throw new Error("the SPA neither redirected to Keycloak nor offered a Login control that did");
|
|
}
|
|
} catch (err) {
|
|
const summary = [
|
|
`URL when stuck: ${page.url()}`,
|
|
`Console (${diagnostics.console.length}):`,
|
|
...diagnostics.console.slice(-25),
|
|
`Page errors (${diagnostics.errors.length}):`,
|
|
...diagnostics.errors.slice(-10),
|
|
`Failed requests (${diagnostics.requests.length}):`,
|
|
...diagnostics.requests.slice(-10),
|
|
].join("\n");
|
|
throw new Error(`OpenCloud SPA never reached Keycloak.\n${summary}\nOriginal error: ${err}`, { cause: err });
|
|
}
|
|
}
|
|
|
|
const usernameField = page.locator('input[name="username"], #username');
|
|
const passwordField = page.locator('input[name="password"], #password');
|
|
await usernameField.fill(username);
|
|
await passwordField.fill(password);
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await usernameField.inputValue()) === username &&
|
|
(await passwordField.inputValue()) === password,
|
|
{
|
|
timeout: resolveTimeout(10_000),
|
|
message: "Expected the Keycloak login form to still carry both credentials at submit time",
|
|
},
|
|
)
|
|
.toBe(true);
|
|
|
|
const leftKeycloak = page
|
|
.waitForURL((url) => !issuerPattern.test(url.toString()), { timeout: resolveTimeout(60_000) })
|
|
.catch(() => {});
|
|
await page
|
|
.locator('#kc-login, input[type="submit"], button[type="submit"]')
|
|
.first()
|
|
.click({ timeout: resolveTimeout(30_000) })
|
|
.catch(() => {});
|
|
await leftKeycloak;
|
|
|
|
// Fast-fail on a Keycloak credential rejection: the form re-renders with
|
|
// `#input-error` / `.alert-error` instead of redirecting to the callback,
|
|
// and the 90s banner wait below would otherwise bury the real cause.
|
|
const credentialError = page
|
|
.locator('#input-error, .alert-error, .kc-feedback-text')
|
|
.first();
|
|
if (await credentialError.isVisible().catch(() => false)) {
|
|
const message = ((await credentialError.textContent().catch(() => "")) || "").trim();
|
|
throw new Error(
|
|
`Keycloak rejected credentials for ${username}: "${message || "<no message>"}" (final URL: ${page.url()})`,
|
|
);
|
|
}
|
|
|
|
// Wait for the redirect chain back to OpenCloud to finish before
|
|
// asserting on SPA elements. Without this the banner assertion races
|
|
// the OIDC callback hops (auth → /oidc-callback → /web-oidc-callback →
|
|
// SPA) and times out before the Files view actually mounts.
|
|
try {
|
|
await page.waitForURL(baseUrlPattern, { timeout: resolveTimeout(60_000), waitUntil: "load" });
|
|
} catch {
|
|
// SPA may have already navigated past the matcher window; fall
|
|
// through to the visibility assertions which carry their own waits.
|
|
}
|
|
|
|
// Bypass OpenCloud's "browser not supported" splash if it appears.
|
|
// Playwright's Chromium UA is not on the OpenCloud allow list, so the SPA
|
|
// shows a splash with a "I want to continue anyway" button.
|
|
const continueAnyway = page.getByRole("button", { name: /continue anyway/i });
|
|
if (await continueAnyway.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
|
|
await continueAnyway.click();
|
|
}
|
|
|
|
// The Files view is the post-login landing route; assert against the SPA
|
|
// navigation banner instead of body text because OpenCloud shows the
|
|
// username inside the user-menu drawer rather than in the page body.
|
|
await expect(page.getByRole("banner", { name: /top bar/i })).toBeVisible({ timeout: resolveTimeout(90_000) });
|
|
await expect(page.getByRole("link", { name: /personal files/i })).toBeVisible({ timeout: resolveTimeout(30_000) });
|
|
}
|
|
|
|
module.exports = {
|
|
baseUrl,
|
|
issuerUrl,
|
|
adminUsername,
|
|
adminPassword,
|
|
biberUsername,
|
|
biberPassword,
|
|
issuerHost,
|
|
issuerPattern,
|
|
baseUrlPattern,
|
|
attachDiagnostics,
|
|
ssoLoginAndAssertUsername,
|
|
};
|