mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Sixteen role specs decided something immediately after an action that had not settled, because locator.isVisible() discards the timeout it is handed. Three shapes: an assertion that could never fire, a login outcome read off the DOM before the app re-rendered, and a branch that selected a locator by whether an iframe had mounted yet. The ones that could never fire are the worst of them. taiga test-seaweedfs polled for a rejection toast right after saveAction.click(), so a real upload rejection was swallowed and misreported as a SeaweedFS storage failure; friendica ldapauth decided whether to run its wrong-password test at the instant a fresh context finished goto, so a slow render skipped a security check silently. wordpress test-discourse-roundtrip picked titleBox by whether the Gutenberg iframe had mounted and, when it had not, bound the wrong one and then burned the full budget failing with a misleading message. Every conversion keeps the control flow: the .catch(() => false) fallback stays, so a waitFor that throws still yields the boolean the caller expects. Twenty of the twenty-two waits use base 2000, one 3000, one 5000. resolveTimeout multiplies by five on an onion target, so the common case is ten seconds there. Across thirteen onion traces the largest wait of any kind that ever succeeded was 790 ms, so ten seconds is a twelvefold margin; the bases started higher and were pulled down because a swallowed deadline is a duration in disguise - where the optional element is normally absent, the full timeout is paid on every run, including green ones. Not exercised: no spec was run. Three of these make a test stricter, so a job that turns red on them is surfacing a real failure that was previously hidden, not regressing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
183 lines
8.4 KiB
JavaScript
183 lines
8.4 KiB
JavaScript
const { test, expect } = require("@playwright/test");
|
|
const { resolveTimeout, isOnionTarget, isSplitRealmOidc } = require("./timeouts");
|
|
const { skipUnlessServiceEnabled } = require("./service-gating");
|
|
|
|
const { assertCspResponseHeader, decodeDotenvQuotedValue, expectNoCspViolations, gotoOnion, installCspViolationObserver, normalizeBaseUrl, performKeycloakLoginForm, runGuestFlow } = require("./personas");
|
|
test.use({ ignoreHTTPSErrors: true });
|
|
|
|
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 };
|
|
}
|
|
|
|
const oidcIssuerUrl = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
|
|
const bbbBaseUrl = normalizeBaseUrl(process.env.BBB_BASE_URL || "");
|
|
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
|
|
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
|
|
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
|
|
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
|
|
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN);
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.setViewportSize({ width: 1440, height: 1100 });
|
|
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set").toBeTruthy();
|
|
expect(bbbBaseUrl, "BBB_BASE_URL must be set").toBeTruthy();
|
|
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
|
|
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
|
|
expect(biberUsername, "BIBER_USERNAME must be set").toBeTruthy();
|
|
expect(biberPassword, "BIBER_PASSWORD must be set").toBeTruthy();
|
|
expect(canonicalDomain, "CANONICAL_DOMAIN must be set").toBeTruthy();
|
|
await page.context().clearCookies();
|
|
await installCspViolationObserver(page);
|
|
});
|
|
|
|
test("bigbluebutton enforces Content-Security-Policy and exposes canonical domain from applications lookup", async ({ page }) => {
|
|
test.skip(isOnionTarget(), "BigBlueButton is WebRTC (UDP/ICE); not served over Tor");
|
|
const diagnostics = attachDiagnostics(page);
|
|
const response = await gotoOnion(page, `${bbbBaseUrl}/`);
|
|
expect(response, "Expected BBB landing response").toBeTruthy();
|
|
expect(response.status(), "Expected BBB landing response to be successful").toBeLessThan(400);
|
|
assertCspResponseHeader(response, "bigbluebutton landing");
|
|
const documentUrl = response.url();
|
|
expect(
|
|
documentUrl.includes(canonicalDomain),
|
|
`Expected canonical domain "${canonicalDomain}" to back the BBB URL`
|
|
).toBe(true);
|
|
await expectNoCspViolations(page, diagnostics, "bigbluebutton landing");
|
|
});
|
|
|
|
// Log out via the universal logout endpoint. Every app's nginx vhost intercepts
|
|
// `location = /logout` and proxies it to web-svc-logout, which terminates both
|
|
// the Greenlight session and the Keycloak SSO. `waitUntil: 'commit'` avoids
|
|
// stalling on any provider-side teardown.
|
|
async function bbbLogout(page, bbbBaseUrl) {
|
|
await gotoOnion(page, `${bbbBaseUrl}/logout`, { waitUntil: "commit" })
|
|
.catch(() => {});
|
|
await page.context().clearCookies();
|
|
}
|
|
|
|
// Greenlight (BBB) auto-submits its SSO form when `?sso=true` is present on the
|
|
// SPA root (see greenlight App.jsx#autoSignIn). This is a deterministic SSO
|
|
// entry that doesn't depend on React button labels or DOM timing.
|
|
async function signInViaBbbOidc(page, username, password, personaLabel) {
|
|
const expectedOidcAuthUrl = `${oidcIssuerUrl}/protocol/openid-connect/auth`;
|
|
|
|
await gotoOnion(page, `${bbbBaseUrl}/?sso=true`);
|
|
|
|
// Greenlight's autoSignIn (App.jsx) fires on the SPA root when `?sso=true`
|
|
// is present. Older Greenlight versions or first-render races may drop the
|
|
// user on `/signin` instead. Fall back to clicking the explicit OIDC button
|
|
// that Greenlight renders when `OPENID_CONNECT_*` env is set.
|
|
await page
|
|
.waitForURL((u) => u.toString().includes(expectedOidcAuthUrl), { timeout: resolveTimeout(10_000) })
|
|
.catch(async () => {
|
|
const oidcButton = page
|
|
.locator("a, button")
|
|
.filter({ hasText: /sign\s*in\s*with|openid|oidc|sso|single\s*sign[-\s]*on/i })
|
|
.first();
|
|
if (await oidcButton.waitFor({ state: "visible", timeout: resolveTimeout(2_000) }).then(() => true).catch(() => false)) {
|
|
await oidcButton.click({ timeout: resolveTimeout(30_000) }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
await page.waitForURL((u) => u.toString().includes(expectedOidcAuthUrl), {
|
|
timeout: resolveTimeout(120_000)
|
|
});
|
|
|
|
await performKeycloakLoginForm(page, username, password);
|
|
|
|
await page.waitForURL((u) => u.toString().startsWith(`${bbbBaseUrl}/`) && !u.toString().includes("/auth/openid_connect") && !u.toString().includes("?sso=true"), {
|
|
timeout: resolveTimeout(120_000)
|
|
});
|
|
|
|
// Authenticated Greenlight renders a header "Sign Out" control in the nav
|
|
// menu. The unauthenticated landing has "Sign In" / "Sign Up" buttons and
|
|
// never exposes "Sign Out". This is the load-bearing assertion that
|
|
// distinguishes real auth from a short-circuited navigation.
|
|
await gotoOnion(page, `${bbbBaseUrl}/rooms`, { waitUntil: "domcontentloaded" });
|
|
await expect
|
|
.poll(() => page.url(), {
|
|
timeout: resolveTimeout(60_000),
|
|
message: `${personaLabel}: expected /rooms to remain accessible post-login (not to be redirected to / or /signin)`
|
|
})
|
|
.toMatch(new RegExp(`^${bbbBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/rooms(/|\\?|$)`));
|
|
}
|
|
|
|
async function assertLoggedOut(page, bbbBaseUrl, personaLabel) {
|
|
// After logout, /rooms must no longer render the authenticated shell; the
|
|
// unauthenticated Greenlight landing exposes "Sign In" / "Sign Up" or
|
|
// redirects to the sign-in route.
|
|
await gotoOnion(page, `${bbbBaseUrl}/rooms`, { waitUntil: "domcontentloaded" }).catch(() => {});
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const url = page.url();
|
|
if (/\/(signin|login|ldap_signin)(\/|\?|$)/i.test(url)) return "signin";
|
|
const signInVisible = await page
|
|
.getByRole("link", { name: /sign\s*in|log\s*in|anmelden/i })
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
if (signInVisible) return "signin";
|
|
const signInButtonVisible = await page
|
|
.getByRole("button", { name: /sign\s*in|log\s*in|anmelden/i })
|
|
.first()
|
|
.isVisible()
|
|
.catch(() => false);
|
|
if (signInButtonVisible) return "signin";
|
|
return "pending";
|
|
},
|
|
{
|
|
timeout: resolveTimeout(60_000),
|
|
message: `${personaLabel}: expected bigbluebutton to require a new sign-in after logout`
|
|
}
|
|
)
|
|
.toBe("signin");
|
|
}
|
|
|
|
test("administrator: bigbluebutton OIDC login and logout", async ({ page }) => {
|
|
test.skip(isOnionTarget(), "BigBlueButton is WebRTC (UDP/ICE); not served over Tor");
|
|
test.skip(isSplitRealmOidc(), "clearnet app with an onion OIDC issuer: unreachable from one browser");
|
|
skipUnlessServiceEnabled("sso");
|
|
const diagnostics = attachDiagnostics(page);
|
|
await signInViaBbbOidc(page, adminUsername, adminPassword, "administrator");
|
|
await bbbLogout(page, bbbBaseUrl);
|
|
await assertLoggedOut(page, bbbBaseUrl, "administrator");
|
|
await expectNoCspViolations(page, diagnostics, "bigbluebutton administrator OIDC");
|
|
});
|
|
|
|
test("biber: bigbluebutton OIDC login and logout", async ({ page }) => {
|
|
test.skip(isOnionTarget(), "BigBlueButton is WebRTC (UDP/ICE); not served over Tor");
|
|
test.skip(isSplitRealmOidc(), "clearnet app with an onion OIDC issuer: unreachable from one browser");
|
|
skipUnlessServiceEnabled("sso");
|
|
const diagnostics = attachDiagnostics(page);
|
|
await signInViaBbbOidc(page, biberUsername, biberPassword, "biber");
|
|
await bbbLogout(page, bbbBaseUrl);
|
|
await assertLoggedOut(page, bbbBaseUrl, "biber");
|
|
await expectNoCspViolations(page, diagnostics, "bigbluebutton biber OIDC");
|
|
});
|
|
|
|
|
|
// Persona scenarios.
|
|
// Bodies live in the shared persona helpers under
|
|
// roles/test-e2e-playwright/files/personas/{guest,biber,admin}.js.
|
|
|
|
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
|
|
await runGuestFlow(page);
|
|
});
|