Files
computer-playbook/roles/web-app-mailu/files/playwright/playwright.spec.js
kevinveenbirkenbachandClaude Opus 5 dcb9ddb9cf fix(playwright): stop stretching waits on server-side clocks by the onion multiplier
The matrix DM spec sleeps 120s before the administrator signs in and 30s
between the two sign-ins, and the rate-limit dialog handler cools down
for 45s, all so Synapse's rc_login bucket (per_second 0.17 in the
compose flavor) can refill. That refill runs on Synapse's clock, yet the
sleeps went through resolveTimeout and became 600s, 150s and 225s over
Tor. They now use fixed constants with the clearnet values, which pass
there as they are. The 5s pause before the marker is sent stays scaled:
Element shares its megolm session through the homeserver, so that one
does depend on the network.

Mailu's two poll intervals (500ms for a visible element, 3s for an inbox
refresh) were scaled as well, so an arrived mail was noticed up to 15s
later over Tor. They are fixed now; the deadlines still scale. Named
constants are what the raw-timeout lints reserve for intervals that must
not scale.

Across runs 34421944328 and 34419507038 the matrix DM test took 960s
over Tor against 210s on clearnet; the two refill sleeps alone account
for 600s of that difference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 15:51:29 +02:00

334 lines
15 KiB
JavaScript

const { test, expect } = require("@playwright/test");
const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, gotoOnion, isOnionCanonical, performKeycloakLoginForm, runAdminFlow, runBiberFlow, runGuestFlow, safeSkipUnlessEnabled } = require("./personas");
test.use({
ignoreHTTPSErrors: true
});
// `docker --env-file` preserves the quotes emitted by `dotenv_quote`,
// so normalize these values before building URLs or typing credentials.
const oidcIssuerUrl = decodeDotenvQuotedValue(process.env.OIDC_ISSUER_URL);
const mailuBaseUrl = decodeDotenvQuotedValue(process.env.MAILU_BASE_URL);
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL);
const biberUsername = decodeDotenvQuotedValue(process.env.BIBER_USERNAME);
const biberPassword = decodeDotenvQuotedValue(process.env.BIBER_PASSWORD);
const biberEmail = decodeDotenvQuotedValue(process.env.BIBER_EMAIL);
const POLL_INTERVAL_MS = 500;
const INBOX_REFRESH_INTERVAL_MS = 3_000;
async function waitForFirstVisible(page, locators, timeout = resolveTimeout(60_000)) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
for (const locator of locators) {
if (await locator.first().isVisible().catch(() => false)) {
return locator.first();
}
}
await page.waitForTimeout(POLL_INTERVAL_MS);
}
throw new Error("Timed out waiting for one of the expected selectors to become visible");
}
// Mailu's heviat OIDC fork shows its own /sso/login page with a local login form AND an
// "SSO Login" link that redirects to Keycloak. Click that link specifically.
// Use openid-connect/auth (not just openid-connect) to avoid accidentally clicking the logout
// link (openid-connect/logout) which is also present in the Roundcube interface.
async function clickThroughMailuSsoPage(frame) {
const oidcLink = frame.locator("a[href*='openid-connect/auth']").first();
// Over Tor, Matomo link-tracking intercepts the click to fire a beacon that
// stalls, so the click never commits the navigation; goto the href directly.
if (isOnionCanonical()) {
const href = await oidcLink.getAttribute("href", { timeout: resolveTimeout(15_000) }).catch(() => null);
if (href) {
await frame.goto(href);
}
return;
}
if (await oidcLink.waitFor({ state: "visible", timeout: resolveTimeout(3_000) }).then(() => true).catch(() => false)) {
await oidcLink.click({ timeout: resolveTimeout(30_000) });
}
}
// Perform SSO login via Keycloak inside a frame context (or page context for direct navigation).
// Works for both iframe-embedded and full-page Mailu flows.
// Wait for an email with the given subject to appear in the current view.
// Retries for up to `timeout` ms to account for delivery delay.
async function waitForEmailInInbox(page, subjectText, timeout = resolveTimeout(60_000)) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
// Roundcube Elastic renders emails as <tr> rows inside #messagelist tbody
const emailRow = page.locator("#messagelist tbody tr, table.messagelist tbody tr").filter({ hasText: subjectText });
if (await emailRow.first().isVisible().catch(() => false)) {
return emailRow.first();
}
// Refresh inbox by clicking the inbox folder
await page.getByRole("link", { name: "Inbox" }).first().click().catch(() => {});
await page.waitForTimeout(INBOX_REFRESH_INTERVAL_MS);
}
throw new Error(`Timed out waiting for email with subject "${subjectText}" to arrive`);
}
test.beforeEach(() => {
expect(oidcIssuerUrl, "OIDC_ISSUER_URL must be set in the Playwright env file").toBeTruthy();
expect(mailuBaseUrl, "MAILU_BASE_URL must be set in the Playwright env file").toBeTruthy();
expect(adminUsername, "ADMIN_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(adminPassword, "ADMIN_PASSWORD must be set in the Playwright env file").toBeTruthy();
expect(adminEmail, "ADMIN_EMAIL must be set in the Playwright env file").toBeTruthy();
expect(biberUsername, "BIBER_USERNAME must be set in the Playwright env file").toBeTruthy();
expect(biberPassword, "BIBER_PASSWORD must be set in the Playwright env file").toBeTruthy();
expect(biberEmail, "BIBER_EMAIL must be set in the Playwright env file").toBeTruthy();
});
// Scenario I: direct mailu → SSO login → webinterface → admin interface → logout
test("mailu: sso login, open admin interface, logout", async ({ page }) => {
safeSkipUnlessEnabled("sso");
const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`;
const expectedMailuBaseUrl = mailuBaseUrl.replace(/\/$/, "");
// 1. Navigate directly to Mailu
await gotoOnion(page, `${expectedMailuBaseUrl}/`);
// 2. Mailu's SSO fork may land on /sso/login before redirecting to Keycloak — click through it
await page.waitForTimeout(resolveTimeout(2_000));
await clickThroughMailuSsoPage(page);
// 3. Wait for redirect to Keycloak OIDC auth
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `Expected Mailu to navigate to Keycloak OIDC: ${expectedOidcAuthUrl}`
})
.toContain(expectedOidcAuthUrl);
// 4. Fill credentials and sign in via Keycloak
await performKeycloakLoginForm(page, adminUsername, adminPassword);
// 5. Wait for redirect back to Mailu webmail
await expect
.poll(() => page.url(), {
timeout: resolveTimeout(60_000),
message: `Expected Mailu to redirect back after login: ${expectedMailuBaseUrl}`
})
.toContain(expectedMailuBaseUrl);
// 6. Verify logged in — look for compose button or inbox folder link
const composeButton = page.getByRole("button", { name: /compose/i });
const inboxContainer = page.getByRole("link", { name: /inbox/i });
await waitForFirstVisible(page, [composeButton, inboxContainer], resolveTimeout(30_000));
// 7. Navigate to the admin interface
await gotoOnion(page, `${expectedMailuBaseUrl}/admin`);
// 8. Verify admin interface loaded — match any heading visible in Mailu's admin panel
await expect(
page.locator("h1, h2, h3, .nav-title, .sidebar-heading").filter({ hasText: /administration|domains|user|mail/i }).filter({ visible: true }).first()
).toBeVisible({ timeout: resolveTimeout(30_000) });
// 9. Logout — Mailu admin logout is a link with /logout or /signout in the href
const logoutByHref = page.locator("a[href*='logout'], a[href*='signout']");
const logoutVisible = await logoutByHref.first().isVisible().catch(() => false);
if (logoutVisible) {
await logoutByHref.first().click({ timeout: resolveTimeout(30_000) });
} else {
// Fallback: navigate directly to the admin logout endpoint
await gotoOnion(page, `${expectedMailuBaseUrl}/admin/ui/logout`);
}
});
// Scenario II: biber logs in → sends email to administrator → administrator logs in
// (separate browser) → waits for email → logs out
//
// biber and the administrator are two different people on separate machines.
// Using isolated browser contexts models this correctly: no shared cookies, no shared
// Keycloak SSO session. This avoids any logout/session-cleanup race condition entirely.
test("mailu: biber sends email to administrator, administrator receives it", async ({ browser }) => {
safeSkipUnlessEnabled("sso");
const expectedOidcAuthUrl = `${oidcIssuerUrl.replace(/\/$/, "")}/protocol/openid-connect/auth`;
const testSubject = `Playwright test ${Date.now()}`;
// Separate contexts = separate browser profiles (no shared cookies or SSO session)
const biberContext = await browser.newContext({ ignoreHTTPSErrors: true });
const adminContext = await browser.newContext({ ignoreHTTPSErrors: true });
try {
// --- Part 1: biber logs in and sends email ---
const biberPage = await biberContext.newPage();
await biberPage.goto(mailuBaseUrl);
// Mailu webmail may show an SSO button or redirect directly to Keycloak
const ssoButton = biberPage.getByRole("button", { name: /sso|single sign.?on|login with/i });
const ssoButtonVisible = await ssoButton.first().isVisible().catch(() => false);
if (ssoButtonVisible) {
await ssoButton.first().click({ timeout: resolveTimeout(30_000) });
}
// Click through Mailu's own /sso/login intermediate page if present
await clickThroughMailuSsoPage(biberPage);
// Wait for Keycloak OIDC auth page
await expect
.poll(() => biberPage.url(), {
timeout: resolveTimeout(30_000),
message: `Expected redirect to Keycloak OIDC: ${expectedOidcAuthUrl}`
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(biberPage, biberUsername, biberPassword);
// Wait for redirect back to Mailu webmail — require /webmail/ in the URL to confirm
// the OIDC callback was fully processed and the PHP session cookie was set.
// Matching only mailuBaseUrl would pass prematurely on the callback URL itself
// (e.g. /sso/login?code=...) before Mailu finishes the auth-code exchange.
await expect
.poll(() => biberPage.url(), {
timeout: resolveTimeout(60_000),
message: "Expected redirect back to Mailu webmail after biber login"
})
.toContain(`${mailuBaseUrl.replace(/\/$/, "")}/webmail/`);
// Navigate directly to Roundcube compose URL — clicking the compose button requires
// rcmail.js to fully execute, direct navigation is more reliable in Playwright.
// Selectors confirmed from rendered DOM: id="_to", id="compose-subject",
// id="composebody", button.btn.btn-primary.send inside .formbuttons
await biberPage.goto(`${mailuBaseUrl.replace(/\/$/, "")}/webmail/?_task=mail&_action=compose`);
await biberPage.waitForLoadState("networkidle", { timeout: resolveTimeout(15_000) }).catch(() => {});
const toField = biberPage.locator("#_to, input[name='_to']").first();
const subjectField = biberPage.locator("#compose-subject, input[name='_subject']").first();
const bodyField = biberPage.locator("#composebody, textarea[name='_message'], [contenteditable='true']").first();
const sendButton = biberPage.locator(".formbuttons .send, button.send, a.send");
await toField.waitFor({ state: "visible", timeout: resolveTimeout(30_000) });
await toField.fill(adminEmail);
await subjectField.fill(testSubject);
await bodyField.click();
await bodyField.fill("Hello Administrator, this is an automated Playwright test email.");
await sendButton.first().waitFor({ state: "visible", timeout: resolveTimeout(10_000) });
await sendButton.first().click({ timeout: resolveTimeout(30_000) });
// After send, Roundcube redirects away from _action=compose
await expect.poll(() => biberPage.url(), { timeout: resolveTimeout(30_000) })
.not.toContain("_action=compose");
// Logout as biber — click the visible Logout button in Roundcube's sidebar
const biberLogoutLink = biberPage.locator("a[href*='logout'], a[href*='signout']")
.or(biberPage.getByRole("button", { name: /logout/i }))
.or(biberPage.getByRole("link", { name: /logout/i }));
await biberLogoutLink.first().waitFor({ state: "visible", timeout: resolveTimeout(10_000) });
await biberLogoutLink.first().click({ timeout: resolveTimeout(30_000) });
// --- Part 2: administrator logs in and checks inbox (fresh browser context) ---
const adminPage = await adminContext.newPage();
await adminPage.goto(mailuBaseUrl);
const ssoButtonAdmin = adminPage.getByRole("button", { name: /sso|single sign.?on|login with/i });
const ssoAdminVisible = await ssoButtonAdmin.first().isVisible().catch(() => false);
if (ssoAdminVisible) {
await ssoButtonAdmin.first().click({ timeout: resolveTimeout(30_000) });
}
// Click through Mailu's own /sso/login intermediate page if present
await clickThroughMailuSsoPage(adminPage);
await expect
.poll(() => adminPage.url(), {
timeout: resolveTimeout(30_000),
message: `Expected redirect to Keycloak OIDC: ${expectedOidcAuthUrl}`
})
.toContain(expectedOidcAuthUrl);
await performKeycloakLoginForm(adminPage, adminUsername, adminPassword);
await expect
.poll(() => adminPage.url(), {
timeout: resolveTimeout(60_000),
message: "Expected redirect back to Mailu webmail after admin login"
})
.toContain(`${mailuBaseUrl.replace(/\/$/, "")}/webmail/`);
// Wait for inbox to load
const inboxFolder = adminPage.getByRole("link", { name: "Inbox" });
await inboxFolder.first().waitFor({ state: "visible", timeout: resolveTimeout(30_000) });
await inboxFolder.first().click();
// Wait for biber's email to arrive (email delivery may take a few seconds)
const emailRow = await waitForEmailInInbox(adminPage, testSubject, resolveTimeout(60_000));
await expect(emailRow).toBeVisible();
await emailRow.click();
// Verify email content is visible (Roundcube shows message body in #messagecontframe iframe or preview pane)
await expect(
adminPage.locator("#messagecontframe, #mailview-right, .message-part").first()
).toBeVisible({ timeout: resolveTimeout(15_000) });
// Logout as administrator
const adminLogoutLink = adminPage.locator("a[href*='logout'], a[href*='signout']")
.or(adminPage.getByRole("button", { name: /logout/i }))
.or(adminPage.getByRole("link", { name: /logout/i }));
await adminLogoutLink.first().waitFor({ state: "visible", timeout: resolveTimeout(10_000) });
await adminLogoutLink.first().click({ timeout: resolveTimeout(30_000) });
} finally {
await biberContext.close().catch(() => {});
await adminContext.close().catch(() => {});
}
});
// Persona scenarios.
// Bodies live in the shared helper roles/test-e2e-playwright/files/personas.js
// so every role's persona flow stays consistent.
test("guest: public-landing → auth chain → never authenticated", async ({ page }) => {
await runGuestFlow(page);
});
test("biber: app → universal logout", async ({ page }) => {
await runBiberFlow(page);
});
test("administrator: app → universal logout", async ({ page }) => {
await runAdminFlow(page, {
adminInteraction: async (interactivePage) => {
// web-app-mailu admin-only interaction: open a management surface.
const link = interactivePage
.getByRole("link", { name: /^(admin|administration|domains|users|fetchmail|aliases)$/i })
.first();
if (await link.isVisible().catch(() => false)) {
await link.click({ timeout: resolveTimeout(30_000) }).catch(() => {});
await interactivePage.waitForLoadState("domcontentloaded", { timeout: resolveTimeout(30_000) }).catch(() => {});
await expect(interactivePage.locator("body")).toContainText(
/domains|users|fetchmail|aliases|relays|administration/i,
{ timeout: resolveTimeout(30_000) },
);
}
},
});
});