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 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) }, ); } }, }); });