import { expect, type Locator, type Page } from "@playwright/test"; export type TenantCredentials = { email: string; password: string; }; export function getTenantCredentials(): TenantCredentials | null { const email = process.env.E2E_TENANT_EMAIL; const password = process.env.E2E_TENANT_PASSWORD; if (!email || !password) { return null; } return { email, password }; } const LOGIN_URL_PATTERN = /\/login(?:[?#].*)?$/u; // Server-side auth throttling (apps/web/app/modules/users/services/AuthService.ts: // checkAuthRateLimit, 10 logins / 60s keyed by IP + email) trips during large // serial runs where every spec re-logs-in as the same tenant user from one IP. // On a 429 the action re-renders the login form (the URL stays on /login), so a // single submit can fail for an environmental reason rather than a real auth // failure. We retry the submit with a bounded backoff so the window can reset, // without weakening the final off-/login assertion. const LOGIN_MAX_ATTEMPTS = 4; const LOGIN_RETRY_DELAY_MS = 15_000; const LOGIN_OFF_URL_TIMEOUT_MS = 15_000; // Matches the "Too many attempts" copy (EN + DE) surfaced on a 429 so we only // back off for the rate-limit case and surface other errors immediately. const RATE_LIMIT_TEXT_PATTERN = /too many|zu viele/iu; async function attemptLogin(page: Page, credentials: TenantCredentials): Promise { await page.goto("/login", { waitUntil: "domcontentloaded" }); await page.locator('[data-ui-id="ui.auth.login.email"], input[type="email"], input[name="email"], input#email').first().fill(credentials.email); await page .locator('[data-ui-id="ui.auth.login.password"], input[type="password"], input[name="password"], input#password') .first() .fill(credentials.password); await Promise.all([page.waitForLoadState("domcontentloaded"), page.locator('button[type="submit"]').first().click()]); // Give the session redirect time to complete before deciding the attempt // failed; waitForURL resolves as soon as we leave /login. await page.waitForURL((url) => !LOGIN_URL_PATTERN.test(url.toString()), { timeout: LOGIN_OFF_URL_TIMEOUT_MS }).catch(() => undefined); return !LOGIN_URL_PATTERN.test(page.url()); } // Cache the authenticated cookies after the first real login and reuse them for // every later spec in the same (workers:1) run. Re-logging-in per test // saturates the server auth rate limit (10/60s per IP+email), which surfaces as // flaky "stayed on /login" failures across the large serial suite. /login // redirects an authenticated session away, so the cache can be validated // cheaply and a stale entry (e.g. after a sign-out test) falls back to a real // login. // // The cache is keyed by EMAIL: the suite logs in as multiple users (the tenant // admin AND a read-only fixture). A single global cache would hand a later // `loginAsTenantUser(readonlyCredentials)` call the admin's still-valid session // (so /login redirects away and the cache "matches"), silently running the // read-only test as the admin — which then sees mutation controls and fails. type StoredCookies = Awaited["cookies"]>>; const cachedCookiesByEmail = new Map(); async function reuseCachedLogin(page: Page, credentials: TenantCredentials): Promise { const cached = cachedCookiesByEmail.get(credentials.email); if (!cached || cached.length === 0) return false; await page.context().addCookies(cached); await page.goto("/login", { waitUntil: "domcontentloaded" }); if (!LOGIN_URL_PATTERN.test(page.url())) return true; cachedCookiesByEmail.delete(credentials.email); return false; } export async function loginAsTenantUser(page: Page, credentials: TenantCredentials): Promise { if (await reuseCachedLogin(page, credentials)) { return; } let rateLimited = false; for (let attempt = 0; attempt < LOGIN_MAX_ATTEMPTS; attempt += 1) { if (await attemptLogin(page, credentials)) { cachedCookiesByEmail.set(credentials.email, await page.context().cookies()); return; } // Still on /login. If the page is showing a rate-limit error, wait for the // window to reset and retry; anything else is a genuine failure. const rateLimitVisible = await page .getByText(RATE_LIMIT_TEXT_PATTERN) .first() .isVisible() .catch(() => false); rateLimited = rateLimited || rateLimitVisible; if (!rateLimitVisible || attempt === LOGIN_MAX_ATTEMPTS - 1) { break; } await page.waitForTimeout(LOGIN_RETRY_DELAY_MS); } const hint = rateLimited ? " Server auth rate limit (10 logins / 60s per IP+email) appears to be throttling repeated logins; consider reusing a shared storageState for large runs." : ""; await expect(page, `Expected to leave /login after submitting tenant credentials but stayed on ${page.url()}.${hint}`).not.toHaveURL(LOGIN_URL_PATTERN); } export async function expectPasswordRequirementsVisible(requirements: Locator): Promise { await expect(requirements).toBeVisible(); const items = requirements.locator("li"); await expect.poll(async () => items.count()).toBeGreaterThan(0); const labels = await items.evaluateAll((requirementsItems) => requirementsItems.map((item) => item.getAttribute("aria-label") ?? "")); for (const label of labels) { expect(label).toMatch(/^(met|not met|erfüllt|nicht erfüllt|account\.register\.passwordRequirements\.(?:met|notMet)):/iu); } } export async function expectSomePasswordRequirementsMissing(requirements: Locator): Promise { await expectPasswordRequirementsVisible(requirements); const labels = await requirements.locator("li").evaluateAll((items) => items.map((item) => item.getAttribute("aria-label") ?? "")); expect(labels.some((label) => /^(not met|nicht erfüllt|account\.register\.passwordRequirements\.notMet):/iu.test(label))).toBe(true); } export async function expectAllPasswordRequirementsMet(requirements: Locator): Promise { await expectPasswordRequirementsVisible(requirements); const items = requirements.locator("li"); const count = await items.count(); for (let index = 0; index < count; index += 1) { await expect(items.nth(index)).toHaveAttribute("aria-label", /^(met|erfüllt|account\.register\.passwordRequirements\.met):/iu); await expect(items.nth(index)).toContainText("[x]"); } } export function tenantSettingsProfilePath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "profile", process.env.E2E_TENANT_SETTINGS_PROFILE_PATH ?? "/app/settings/profile"); } export function tenantSettingsAccountPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "account", process.env.E2E_TENANT_SETTINGS_ACCOUNT_PATH ?? "/app/settings/account"); } export function tenantSettingsSubscriptionPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "subscription", process.env.E2E_TENANT_SUBSCRIPTION_PATH ?? "/app/settings/subscription"); } export function tenantSettingsPoliciesPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "policies", process.env.E2E_TENANT_POLICIES_PATH ?? "/app/settings/policies"); } export function tenantSettingsNotificationsPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "notifications", process.env.E2E_TENANT_NOTIFICATIONS_PATH ?? "/app/settings/notifications"); } export function tenantSettingsMembersPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "members", process.env.E2E_TENANT_MEMBERS_PATH ?? "/app/settings/members"); } export function tenantSettingsLogsPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "logs", process.env.E2E_TENANT_LOGS_PATH ?? "/app/settings/logs"); } export function tenantSettingsLogEventsPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "logs/events", process.env.E2E_TENANT_LOG_EVENTS_PATH ?? "/app/settings/logs/events"); } export function tenantSettingsAuditExportPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "audit-export", process.env.E2E_TENANT_AUDIT_EXPORT_PATH ?? "/app/settings/audit-export"); } export function tenantSettingsChatbotPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "chatbot", process.env.E2E_TENANT_CHATBOT_PATH ?? "/app/settings/chatbot"); } export function tenantRolesAndPermissionsPath(dashboardPath: string): string { return tenantSettingsPath(dashboardPath, "roles-and-permissions", process.env.E2E_TENANT_ROLES_AND_PERMISSIONS_PATH ?? "/app/settings/roles-and-permissions"); } function tenantSettingsPath(dashboardPath: string, section: string, fallbackPath: string): string { const normalized = dashboardPath.startsWith("/") ? dashboardPath : `/${dashboardPath}`; const marker = "/dashboard"; if (!normalized.endsWith(marker)) { return fallbackPath; } return `${normalized.slice(0, -marker.length)}/settings/${section}`; }