mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
Playwright declares the timeout option on locator.isVisible() and isHidden() as deprecated and ignored - both return immediately.7538b8a0balready fixed two call sites in this file and deliberately left four, arguing that is the password field STILL there and is a Login control STILL showing are correctly answered at once. That holds for the native login path it measured. It does not hold for the OIDC popup path. keycloak.js polls the POPUP url and is satisfied once that lands on the callback, while the opener has not re-rendered yet - and admin.js inspects the opener. The trace of EspoCRM variant 0 shows the check starting 5.5 ms after the credential submit and taking 10.7 ms, the error-context snapshot shows the Sign in button visible but disabled, mid-submit, and the network log has oauth-callback.php and api/v1/App/user both returning 200 about 150 ms after the assertion had already failed. Biber passed the same journey in one attempt because biber.js:147 waits; administrator burned three attempts because it did not. That commit assumed EspoCRM 0 failed for an unrelated back-channel reason; it fails at admin.js:191, inside this branch. The wait uses base 12000, not the 60000 biber carried. resolveTimeout multiplies by 5 on an onion target, so this is 60 s there. Across 13 onion traces the largest wait of any kind that ever SUCCEEDED was 790 ms and the worst navigation 1893 ms; 60 s is 76 times the former. At 60000 the same line would burn 300 s per attempt, and it burns only on the path that already fails, which is the path that has to stay diagnosable. Biber moves to the same base so the two personas cannot drift again. The three remaining ignored arguments are deleted rather than kept.7538b8a0bcalled them inert but harmless; inert is right, harmless is the part that cost it two call sites - the argument reads as a wait that never existed. A repository lint now rejects the construct for isVisible and isHidden only; isChecked, isDisabled, isEditable and isEnabled take a real timeout and are deliberately not matched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
100 lines
3.5 KiB
JavaScript
100 lines
3.5 KiB
JavaScript
/**
|
|
* `guest` persona: cannot log in anywhere.
|
|
*
|
|
* The guest is an unauthenticated visitor with NO Keycloak account.
|
|
* The flow visits the role's canonical surface and verifies the
|
|
* resulting state is NEVER an authenticated session — the page either
|
|
* sits on a public landing or has been pushed into the auth chain
|
|
* (`/sso/login`, `/oauth2/start`, Keycloak `openid-connect/auth`),
|
|
* but it MUST NOT render a signed-in surface.
|
|
*
|
|
* Deliberately minimal. The flow does NOT attempt to drive the login
|
|
* form: that would risk a 5-minute timeout against any role whose form
|
|
* loops back on empty submission. The "guest cannot log in anywhere"
|
|
* invariant is fully captured by "after one anonymous visit, no
|
|
* signed-in marker is visible".
|
|
*/
|
|
|
|
const { test, expect } = require("@playwright/test");
|
|
const { normalizeUrl, readEnv, safeIsEnabled, gotoOnion, assertCspInjections } = require("./utils");
|
|
const { resolveTimeout } = require("../timeouts");
|
|
|
|
async function runGuestFlow(page = {}) {
|
|
if ((process.env.PERSONA_GUEST_BLOCKED || "").toLowerCase() === "true") {
|
|
test.skip(
|
|
true,
|
|
`guest persona is explicitly blocked by the role contract (PERSONA_GUEST_BLOCKED=true). See the role's TODO.md for the rationale and the path back to a runnable journey.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const appBaseUrl = normalizeUrl(process.env.APP_BASE_URL);
|
|
const canonicalDomain = readEnv("CANONICAL_DOMAIN");
|
|
|
|
// Persona-collapse exception: roles whose env does not
|
|
// expose CANONICAL_DOMAIN are auth-less by construction (web-svc-*
|
|
// and federation-only web-app-*). The guest persona is skipped
|
|
// cleanly; the role's own baseline scenario carries the unauth
|
|
// probe.
|
|
if (!canonicalDomain) {
|
|
test.skip(
|
|
true,
|
|
"Auth-less role (no CANONICAL_DOMAIN) — guest persona scenario collapsed.",
|
|
);
|
|
return;
|
|
}
|
|
if (!appBaseUrl) {
|
|
test.skip(
|
|
true,
|
|
"Auth-less role with no public surface (no APP_BASE_URL) — guest persona scenario collapsed.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
test.setTimeout(resolveTimeout(60_000));
|
|
|
|
const startUrl = appBaseUrl;
|
|
|
|
await page.context().clearCookies();
|
|
|
|
let response;
|
|
try {
|
|
response = await gotoOnion(page, startUrl, { waitUntil: "domcontentloaded" });
|
|
} catch (err) {
|
|
throw new Error(
|
|
`guest: page.goto(${startUrl}) failed after Tor-transport retries: ${err.message}`,
|
|
{ cause: err },
|
|
);
|
|
}
|
|
|
|
if (response) {
|
|
expect(response.status(), "guest visit must not 5xx").toBeLessThan(500);
|
|
}
|
|
|
|
// CSP must be enforced for the guest too: the page MUST not load
|
|
// resources outside the declared policy regardless of auth state.
|
|
await assertCspInjections(page, { isEnabled: safeIsEnabled }).catch(() => {});
|
|
|
|
const finalUrl = page.url();
|
|
const reachedAuthChain =
|
|
/openid-connect\/auth/.test(finalUrl) ||
|
|
/\/sso\/login/.test(finalUrl) ||
|
|
/\/oauth2\/start/.test(finalUrl) ||
|
|
/\/login(\b|\?|\/)/i.test(finalUrl);
|
|
|
|
// Whether the URL stayed on the role's public surface or was
|
|
// redirected into the auth chain, the body MUST NOT expose a
|
|
// signed-in marker (logout link/button visible to authenticated
|
|
// users only).
|
|
const signedInMarker = page
|
|
.getByRole("button", { name: /^(log\s*out|sign\s*out|abmelden)$/i })
|
|
.or(page.getByRole("link", { name: /^(log\s*out|sign\s*out|abmelden)$/i }));
|
|
const isSignedIn = await signedInMarker.first().isVisible().catch(() => false);
|
|
expect(
|
|
isSignedIn,
|
|
`guest must NOT end on a signed-in surface (URL ${finalUrl}, reachedAuthChain=${reachedAuthChain})`,
|
|
).toBe(false);
|
|
}
|
|
|
|
module.exports = { runGuestFlow };
|