237 lines
11 KiB
TypeScript
237 lines
11 KiB
TypeScript
import { expect, type Locator, type Page } from "@playwright/test";
|
|
|
|
import { waitForHydration } from "./navigation";
|
|
|
|
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, loginPageUrl: string): Promise<boolean> {
|
|
await page.goto(loginPageUrl, { waitUntil: "domcontentloaded" });
|
|
await waitForHydration(page);
|
|
|
|
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. The cache is
|
|
// validated against an authenticated surface; 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<ReturnType<ReturnType<Page["context"]>["cookies"]>>;
|
|
const cachedCookiesByIdentity = new Map<string, StoredCookies>();
|
|
|
|
async function reuseCachedLogin(page: Page, credentials: TenantCredentials, loginPageUrl: string, sessionProbeUrl: string): Promise<boolean> {
|
|
const cacheKey = loginCacheKey(credentials, loginPageUrl);
|
|
const cached = cachedCookiesByIdentity.get(cacheKey);
|
|
if (!cached || cached.length === 0) return false;
|
|
await page.context().addCookies(cached);
|
|
await page.goto(sessionProbeUrl, { waitUntil: "domcontentloaded" });
|
|
if (!LOGIN_URL_PATTERN.test(page.url())) return true;
|
|
cachedCookiesByIdentity.delete(cacheKey);
|
|
return false;
|
|
}
|
|
|
|
async function reuseContextLogin(page: Page, sessionProbeUrl: string): Promise<boolean> {
|
|
const probeUrl = new URL(sessionProbeUrl, process.env.E2E_BASE_URL ?? "http://localhost:3000");
|
|
const cookies = await page.context().cookies(probeUrl.origin);
|
|
if (cookies.length === 0) return false;
|
|
await page.goto(sessionProbeUrl, { waitUntil: "domcontentloaded" });
|
|
return !LOGIN_URL_PATTERN.test(page.url());
|
|
}
|
|
|
|
export async function loginAsTenantUser(page: Page, credentials: TenantCredentials): Promise<void> {
|
|
await loginAt(page, credentials, "/login");
|
|
}
|
|
|
|
export async function loginAsAdminUser(page: Page, credentials: TenantCredentials): Promise<void> {
|
|
await loginAt(page, credentials, resolveAdminLoginUrl(), resolveAdminDashboardUrl());
|
|
}
|
|
|
|
export function resolveAdminLoginUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
return resolveAdminSurfaceUrl("/login", env);
|
|
}
|
|
|
|
export function resolveAdminDashboardUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
return resolveAdminSurfaceUrl("/dashboard", env);
|
|
}
|
|
|
|
function resolveAdminSurfaceUrl(pathname: string, env: NodeJS.ProcessEnv): string {
|
|
const explicitAdminUrl = env.E2E_ADMIN_BASE_URL?.trim();
|
|
const baseUrl = explicitAdminUrl || env.E2E_BASE_URL?.trim() || "http://localhost:3000";
|
|
const url = new URL(baseUrl);
|
|
if (!explicitAdminUrl) {
|
|
url.hostname = url.hostname === "localhost" ? "admin.localhost" : url.hostname.replace(/^app\./u, "admin.");
|
|
}
|
|
url.pathname = pathname;
|
|
url.search = "";
|
|
url.hash = "";
|
|
return url.toString();
|
|
}
|
|
|
|
async function loginAt(page: Page, credentials: TenantCredentials, loginPageUrl: string, sessionProbeUrl: string = loginPageUrl): Promise<void> {
|
|
if (await reuseContextLogin(page, sessionProbeUrl)) {
|
|
return;
|
|
}
|
|
|
|
const cacheKey = loginCacheKey(credentials, loginPageUrl);
|
|
if (await reuseCachedLogin(page, credentials, loginPageUrl, sessionProbeUrl)) {
|
|
return;
|
|
}
|
|
|
|
let rateLimited = false;
|
|
for (let attempt = 0; attempt < LOGIN_MAX_ATTEMPTS; attempt += 1) {
|
|
if (await attemptLogin(page, credentials, loginPageUrl)) {
|
|
cachedCookiesByIdentity.set(cacheKey, 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);
|
|
}
|
|
|
|
function loginCacheKey(credentials: TenantCredentials, loginPageUrl: string): string {
|
|
return `${credentials.email.trim().toLowerCase()}@${new URL(loginPageUrl, "http://localhost").origin}`;
|
|
}
|
|
|
|
export async function expectPasswordRequirementsVisible(requirements: Locator): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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, "workspace", process.env.E2E_TENANT_SETTINGS_ACCOUNT_PATH ?? "/app/settings/workspace");
|
|
}
|
|
|
|
export function tenantSettingsSubscriptionPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "billing", process.env.E2E_TENANT_SUBSCRIPTION_PATH ?? "/app/settings/billing");
|
|
}
|
|
|
|
export function tenantSettingsPoliciesPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "workspace/policies", process.env.E2E_TENANT_POLICIES_PATH ?? "/app/settings/workspace/policies");
|
|
}
|
|
|
|
export function tenantSettingsNotificationsPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "profile/notifications", process.env.E2E_TENANT_NOTIFICATIONS_PATH ?? "/app/settings/profile/notifications");
|
|
}
|
|
|
|
export function tenantSettingsMembersPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "team", process.env.E2E_TENANT_MEMBERS_PATH ?? "/app/settings/team");
|
|
}
|
|
|
|
export function tenantSettingsLogsPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "compliance", process.env.E2E_TENANT_LOGS_PATH ?? "/app/settings/compliance");
|
|
}
|
|
|
|
export function tenantSettingsLogEventsPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "compliance/events", process.env.E2E_TENANT_LOG_EVENTS_PATH ?? "/app/settings/compliance/events");
|
|
}
|
|
|
|
export function tenantSettingsAuditExportPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "compliance/audit-export", process.env.E2E_TENANT_AUDIT_EXPORT_PATH ?? "/app/settings/compliance/audit-export");
|
|
}
|
|
|
|
export function tenantRolesAndPermissionsPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "team/roles", process.env.E2E_TENANT_ROLES_AND_PERMISSIONS_PATH ?? "/app/settings/team/roles");
|
|
}
|
|
|
|
export function tenantLegacyRolesAndPermissionsPath(dashboardPath: string): string {
|
|
return tenantSettingsPath(dashboardPath, "roles-and-permissions", "/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}`;
|
|
}
|