Files
computer-playbook/roles/web-app-suitecrm/files/playwright/ldap.spec.js
kevinveenbirkenbachandClaude Opus 5 7125cb9d2c test(suitecrm): cover both login paths with role-local specs
The role's only login coverage was the shared biber flow, and that flow can
only drive OIDC: personas/biber.js authenticates solely when the URL contains
`openid-connect/auth`. SAML redirects to /protocol/saml and the ldap variant
uses SuiteCRM's own form, so neither path could be exercised — across every
persona helper there are 15 hits for `openid-connect` and none for `saml`.

Both specs assert through /session-status rather than by hunting a logout
control in the DOM. That is what the whole SSO change is about — whether
SuiteCRM owns a session or only the edge does — and it does not race an
asynchronously rendering Angular surface, which is exactly where the 2026-08-08
run tore.

Three timing decisions are load-bearing, each from a defect found while
reviewing the first draft:

- The ACS response is awaited explicitly instead of polling the URL.
  performKeycloakLogin only checks that page.url() CONTAINS the canonical
  domain, and php-saml puts the SP's own URL in RelayState, so Keycloak's login
  URL already contains it: a URL wait returns before the assertion is posted.
- The `POST /login` response is awaited explicitly in the ldap spec. SuiteCRM 8
  logs in by XHR with no document navigation, so waitForLoadState resolves while
  the request is still in flight.
- /session-status is requested exactly once, after the login completed.
  SecurityController's unauthenticated branch calls $session->invalidate(),
  so a probe fired mid-login destroys the session it reports on. The first draft
  did all three wrongly and would have gone red against a WORKING deployment.

The ldap spec clicks #login-button because the login form declares no
(ngSubmit); Enter only worked through HTML implicit submission.

Verified in the production Playwright image against a protocol stub that
replays the choreography (fake IdP with a Keycloak-shaped form, auto-posted
assertion, XHR login): saml.spec.js 2 passed, ldap.spec.js 2 passed. The
counter-runs matter more — against an app that does not redirect to Keycloak the
SAML spec fails with its own message, and against an app with no login form the
ldap spec times out on #login-button, so neither is vacuously green. Also
verified: collection of all 9 tests, the three service-gate combinations, and a
missing credential failing loudly instead of skipping.

playwright.env.j2's persona rationale said biber has no account when SSO is off.
He does — LDAP_AUTO_CREATE mints one on first bind. He is blocked because the
shared flow cannot drive that login, which is what ldap.spec.js now covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:02:30 +02:00

73 lines
3.2 KiB
JavaScript

// @ts-check
const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const {
decodeDotenvQuoted,
gotoOnion,
normalizeBaseUrl,
safeIsEnabled,
} = require("./personas");
test.use({ ignoreHTTPSErrors: true });
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL);
const biberUsername = decodeDotenvQuoted(process.env.BIBER_USERNAME);
const biberPassword = decodeDotenvQuoted(process.env.BIBER_PASSWORD);
const adminUsername = decodeDotenvQuoted(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuoted(process.env.ADMIN_PASSWORD);
test.beforeEach(async ({ page }) => {
test.skip(safeIsEnabled("sso"), "SSO is enabled — templates/env.j2 selects saml, not ldap");
test.skip(!safeIsEnabled("ldap"), "LDAP is disabled — AUTH_TYPE is native here");
expect(appBaseUrl, "APP_BASE_URL must be set").toBeTruthy();
expect(biberUsername, "BIBER_USERNAME must be set").toBeTruthy();
expect(biberPassword, "BIBER_PASSWORD must be set").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set").toBeTruthy();
await page.context().clearCookies();
});
/** Sign in through SuiteCRM's own login view and return its session state. */
async function formLogin(page, username, password) {
const landing = await gotoOnion(page, `${appBaseUrl}/`, { waitUntil: "domcontentloaded" });
expect(landing, "the app must answer the initial navigation").toBeTruthy();
expect(landing.status(), "the login view must render, not error").toBeLessThan(400);
const usernameField = page.locator("input[name='username']").first();
await usernameField.waitFor({ state: "visible", timeout: resolveTimeout(60_000) });
await usernameField.fill(username);
await page.locator("input[type='password']").first().fill(password);
const loggedIn = page.waitForResponse(
(response) => response.url().includes("/login") && response.request().method() === "POST",
{ timeout: resolveTimeout(120_000) },
);
await page.locator("#login-button").click({ timeout: resolveTimeout(30_000) });
const login = await loggedIn;
const response = await page.request.get(`${appBaseUrl}/session-status`, {
timeout: resolveTimeout(30_000),
});
expect(response.status(), "session-status must answer").toBeLessThan(400);
return { login, status: await response.json() };
}
test("biber: the directory account signs in and is created from the directory entry", async ({ page }) => {
const { login, status } = await formLogin(page, biberUsername, biberPassword);
expect(login.status(), "the directory bind must be accepted").toBeLessThan(400);
expect(
status.active,
"LDAP_AUTO_CREATE must mint the account from the bind and leave a session behind",
).toBe(true);
expect(status.userName).toBe(biberUsername);
});
test("administrator: the directory account signs in", async ({ page }) => {
const { login, status } = await formLogin(page, adminUsername, adminPassword);
expect(login.status(), "the directory bind must be accepted").toBeLessThan(400);
expect(status.active, "the administrator must reach an authenticated session").toBe(true);
expect(status.userName).toBe(adminUsername);
});