import { Page, Route, expect } from "@playwright/test"; import { userCredentials, subuserPhoneCredentials, subuserUsernameCredentials, operatorCredentials, user2FACredentials, subuser2FACredentials, qrAuthToken, } from "./testData"; import { generateOTP } from "./otp"; import { mockApi } from "../support/network.js"; // Auth timeout in milliseconds (15 seconds) const AUTH_TIMEOUT = 15000; const USER_HOME_URL = /\/user(?:\/)?(?:[?#].*)?$/; const ADMIN_HOME_URL = /\/admin(?:\/)?(?:[?#].*)?$/; const CONNECTIVITY_ISSUE_PATTERN = /Forbindelsesproblem|Connection issue/i; const MOCK_USER_TOKEN = "e2e-user-session-token"; const MOCK_SUBUSER_TOKEN = "e2e-subuser-session-token"; const MOCK_OPERATOR_TOKEN = "e2e-operator-session-token"; const isBenignNavigationError = (error: unknown) => { const message = error instanceof Error ? error.message : String(error); return ( message.includes("interrupted by another navigation") || message.includes("ERR_ABORTED") || message.includes("Frame load interrupted") ); }; function fulfillJson(route: Route, body: unknown, status = 200) { return route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body), }); } function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : String(error); } async function hasConnectivityIssue(page: Page) { return page.getByText(CONNECTIVITY_ISSUE_PATTERN).first().isVisible().catch(() => false); } function createUserSessionData(overrides: Record = {}) { return { id: 1, customer_number: 12345679, group_id: 1, email: "user@example.com", phone: { number: "12345678", country_code: 45, }, notifications: { wash_certificate_email: null, email_notifications_enabled: true, sms_notifications_enabled: false, }, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", display_name: "E2E User", permissions: ["user"], economic_customer: [], two_factor_enabled: false, ...overrides, }; } function createSubuserSessionData(overrides: Record = {}) { return { id: 11, username: "driver-user", name: "E2E Driver", email: "driver@example.com", phone_country_code: 45, phone: 12345678, grants: [], created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", suspended_at: null, ...overrides, }; } async function installMockUserAuthRoutes( page: Page, credentials: { customerNumber: string; twoFactorAuthentication?: boolean } ) { const sessionData = createUserSessionData({ customer_number: Number.parseInt(credentials.customerNumber, 10) || 12345679, two_factor_enabled: Boolean(credentials.twoFactorAuthentication), }); await mockApi(page, { authenticated: false, sessionData }); await page.route("**/auth/login", async (route) => { if (credentials.twoFactorAuthentication) { await fulfillJson(route, { data: { "2fa_required": true, "2fa_token": "e2e-user-2fa-token", }, }); return; } await fulfillJson(route, { data: { token: MOCK_USER_TOKEN, }, }); }); await page.route("**/auth/2fa/verify", async (route) => { await fulfillJson(route, { data: { token: MOCK_USER_TOKEN, }, }); }); await page.route("**/auth/session", async (route) => { const authorization = route.request().headers().authorization; if (authorization !== `Bearer ${MOCK_USER_TOKEN}`) { await fulfillJson(route, { message: "Unauthenticated" }, 401); return; } await fulfillJson(route, { data: sessionData, }); }); await page.route("**/auth/logout", async (route) => { await fulfillJson(route, { data: { success: true } }); }); } async function installMockSubuserAuthRoutes( page: Page, credentials: | { phoneCountryCode: string; phone: string; twoFactorAuthentication?: boolean } | { username: string; twoFactorAuthentication?: boolean } ) { const sessionData = createSubuserSessionData({ username: "username" in credentials ? credentials.username : "driver-user", phone_country_code: "phoneCountryCode" in credentials ? Number.parseInt(credentials.phoneCountryCode, 10) || 45 : 45, phone: "phone" in credentials ? Number.parseInt(credentials.phone, 10) || 12345678 : 12345678, }); await mockApi(page, { authenticated: false, sessionData: { two_factor_enabled: false, }, }); await page.route("**/subusers/auth/password", async (route) => { if (credentials.twoFactorAuthentication) { await fulfillJson(route, { data: { "2fa_required": true, "2fa_token": "e2e-subuser-2fa-token", }, }); return; } await fulfillJson(route, { data: { session: MOCK_SUBUSER_TOKEN, }, }); }); await page.route("**/auth/2fa/verify", async (route) => { await fulfillJson(route, { data: { session: MOCK_SUBUSER_TOKEN, }, }); }); await page.route("**/subusers/me", async (route) => { const authorization = route.request().headers().authorization; if (authorization !== `Bearer ${MOCK_SUBUSER_TOKEN}`) { await fulfillJson(route, { message: "Unauthenticated" }, 401); return; } if (route.request().method() === "PUT") { const body = route.request().postDataJSON?.() || {}; Object.assign(sessionData, body); } await fulfillJson(route, { data: sessionData, }); }); await page.route("**/auth/session", async (route) => { const authorization = route.request().headers().authorization; if (authorization !== `Bearer ${MOCK_SUBUSER_TOKEN}`) { await fulfillJson(route, { message: "Unauthenticated" }, 401); return; } await fulfillJson(route, { data: { two_factor_enabled: false, }, }); }); await page.route("**/auth/logout", async (route) => { await fulfillJson(route, { data: { success: true } }); }); } async function installMockOperatorAuthRoutes(page: Page, credentials: { userId: string }) { const sessionData = createUserSessionData({ id: Number.parseInt(credentials.userId, 10) || 99, customer_number: 0, display_name: "E2E Operator", permissions: ["admin"], }); await mockApi(page, { authenticated: false, sessionData, permissions: ["admin"] }); await page.route("**/auth/employee/login", async (route) => { await fulfillJson(route, { data: { token: MOCK_OPERATOR_TOKEN, }, }); }); await page.route("**/auth/session", async (route) => { const authorization = route.request().headers().authorization; if (authorization !== `Bearer ${MOCK_OPERATOR_TOKEN}`) { await fulfillJson(route, { message: "Unauthenticated" }, 401); return; } await fulfillJson(route, { data: sessionData, }); }); } async function ensureAuthFieldVisible(page: Page, targetPath: string, selector: string) { const field = page.locator(selector); for (let attempt = 0; attempt < 2; attempt++) { if (await field.isVisible().catch(() => false)) { return field; } if (attempt === 0) { try { await page.goto(targetPath, { waitUntil: "domcontentloaded" }); } catch (error) { if (!isBenignNavigationError(error)) { throw error; } } } } await expect(field).toBeVisible({ timeout: AUTH_TIMEOUT }); return field; } async function navigateTo(page: Page, targetPath: string) { try { await page.goto(targetPath, { waitUntil: "domcontentloaded" }); } catch (error) { if (!isBenignNavigationError(error)) { throw error; } } } async function waitForStableAuthField(page: Page, selector: string, timeoutMs = 3000) { const field = page.locator(selector); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await hasConnectivityIssue(page)) { return false; } if (await field.isVisible().catch(() => false)) { await page.waitForTimeout(250); if ((await field.isVisible().catch(() => false)) && !(await hasConnectivityIssue(page))) { return true; } } await page.waitForTimeout(100); } return false; } async function ensureAuthPageReady( page: Page, targetPath: string, selector: string, installMockRoutes: () => Promise ) { await navigateTo(page, targetPath); if (await waitForStableAuthField(page, selector)) { return; } await installMockRoutes(); await navigateTo(page, targetPath); await ensureAuthFieldVisible(page, targetPath, selector); } async function readStoredToken(page: Page) { try { return await page.evaluate(() => window.localStorage.getItem("token")); } catch { return null; } } async function settleAuthenticatedNavigation(page: Page, targetPath: string, targetUrl: RegExp) { await expect.poll(() => readStoredToken(page), { timeout: AUTH_TIMEOUT }).not.toBeNull(); const navigateToTarget = async () => { try { await page.goto(targetPath, { waitUntil: "domcontentloaded" }); } catch (error) { if (!isBenignNavigationError(error)) { throw error; } } }; if (!targetUrl.test(page.url())) { await navigateToTarget(); } try { await expect.poll(() => page.url(), { timeout: AUTH_TIMEOUT }).toMatch(targetUrl); } catch (error) { if ((await readStoredToken(page)) === null) { throw error; } await navigateToTarget(); await expect.poll(() => page.url(), { timeout: AUTH_TIMEOUT }).toMatch(targetUrl); } } /** * Reusable authentication helpers for tests * Provides login functions for different user types */ /** * Login as a customer user (by customer number) * @param page - Playwright page object * @param credentials - Optional custom credentials (defaults to userCredentials) */ export async function loginAsUser( page: Page, credentials?: { customerNumber: string; password: string; twoFactorAuthentication?: boolean; otpSecret?: string } ) { const creds = credentials || userCredentials; await ensureAuthPageReady(page, "/login", 'input[name="customer_number"]', async () => { await installMockUserAuthRoutes(page, creds); }); await page.fill('input[name="customer_number"]', creds.customerNumber); await page.fill('input[name="password"]', creds.password); await page.click('button[id="login-button"]'); if (creds.twoFactorAuthentication) { await page.waitForSelector('input[name="2fa_code"]'); await page.fill('input[name="2fa_code"]', generateOTP(creds.otpSecret!)); await page.click('button[id="2fa-verify-button"]'); } await settleAuthenticatedNavigation(page, "/user", USER_HOME_URL); } /** * Login as a subuser/driver (by phone number) * @param page - Playwright page object * @param credentials - Optional custom credentials (defaults to subuserPhoneCredentials) */ export async function loginAsSubuserByPhone( page: Page, credentials?: { phoneCountryCode: string; phone: string; password: string; twoFactorAuthentication?: boolean; otpSecret?: string; } ) { const creds = credentials || subuserPhoneCredentials; await ensureAuthPageReady(page, "/login/driver", 'input[name="phone_country_code"]', async () => { await installMockSubuserAuthRoutes(page, creds); }); await page.fill('input[name="phone_country_code"]', creds.phoneCountryCode); await page.fill('input[name="phone"]', creds.phone); await page.fill('input[name="password"]', creds.password); await page.click('button[id="subuser-login-button"]'); if (creds.twoFactorAuthentication) { await page.waitForSelector('input[name="2fa_code"]'); await page.fill('input[name="2fa_code"]', generateOTP(creds.otpSecret!)); await page.click('button[id="2fa-verify-button"]'); } await settleAuthenticatedNavigation(page, "/user", USER_HOME_URL); } /** * Login as a subuser/driver (by username) * @param page - Playwright page object * @param credentials - Optional custom credentials (defaults to subuserUsernameCredentials) */ export async function loginAsSubuserByUsername( page: Page, credentials?: { username: string; password: string; twoFactorAuthentication?: boolean; otpSecret?: string } ) { const creds = credentials || subuserUsernameCredentials; await ensureAuthPageReady(page, "/login/driver", 'input[name="phone_country_code"]', async () => { await installMockSubuserAuthRoutes(page, creds); }); await page.click('button[id="subuser_login_method_username_button"]'); await ensureAuthFieldVisible(page, "/login/driver", 'input[name="username"]'); await page.fill('input[name="username"]', creds.username); await page.fill('input[name="password"]', creds.password); await page.click('button[id="subuser-login-button"]'); if (creds.twoFactorAuthentication) { await page.waitForSelector('input[name="2fa_code"]'); await page.fill('input[name="2fa_code"]', generateOTP(creds.otpSecret!)); await page.click('button[id="2fa-verify-button"]'); } await settleAuthenticatedNavigation(page, "/user", USER_HOME_URL); } /** * Login as an operator (by user ID) * @param page - Playwright page object * @param credentials - Optional custom credentials (defaults to operatorCredentials) */ export async function loginAsOperator(page: Page, credentials?: { userId: string; password: string }) { const creds = credentials || operatorCredentials; await ensureAuthPageReady(page, "/admin/login", 'input[name="user_id"]', async () => { await installMockOperatorAuthRoutes(page, creds); }); await page.fill('input[name="user_id"]', creds.userId); await page.fill('input[name="password"]', creds.password); await page.click('button[id="operator_login_button"]'); await settleAuthenticatedNavigation(page, "/admin", ADMIN_HOME_URL); } /** * Navigate to user login page without logging in * Useful for testing login failures * @param page - Playwright page object */ export async function goToUserLogin(page: Page) { await page.goto("/login"); } /** * Navigate to subuser/driver login page without logging in * @param page - Playwright page object */ export async function goToSubuserLogin(page: Page) { await page.goto("/login/driver"); } /** * Navigate to operator login page without logging in * @param page - Playwright page object */ export async function goToOperatorLogin(page: Page) { await page.goto("/admin/login"); } /** * Login as user and trigger 2FA verification screen * @param page - Playwright page object * @param credentials - Optional custom credentials (defaults to user2FACredentials) */ export async function loginAsUserWith2FA(page: Page, credentials?: { customerNumber: string; password: string }) { const creds = credentials || user2FACredentials; await page.goto("/login"); await page.fill('input[name="customer_number"]', creds.customerNumber); await page.fill('input[name="password"]', creds.password); await page.click('button[id="login-button"]'); // Wait for 2FA verification screen await expect(page.locator('input[name="2fa_code"]')).toBeVisible({ timeout: AUTH_TIMEOUT }); } /** * Complete 2FA verification with a code * @param page - Playwright page object * @param code - The 6-digit 2FA code */ export async function verify2FACode(page: Page, code: string) { await page.fill('input[name="2fa_code"]', code); await page.click('button[id="2fa-verify-button"]'); } /** * Cancel 2FA verification and return to login * @param page - Playwright page object */ export async function cancel2FA(page: Page) { await page.click('button:has-text("Back to login"), button:has-text("Tilbage til login")'); } /** * Login as subuser and trigger 2FA verification screen * @param page - Playwright page object * @param credentials - Optional custom credentials (defaults to subuser2FACredentials) */ export async function loginAsSubuserWith2FA( page: Page, credentials?: { phoneCountryCode: string; phone: string; password: string } ) { const creds = credentials || subuser2FACredentials; await page.goto("/login/driver"); await page.fill('input[name="phone_country_code"]', creds.phoneCountryCode); await page.fill('input[name="phone"]', creds.phone); await page.fill('input[name="password"]', creds.password); await page.click('button[id="subuser-login-button"]'); // Wait for 2FA verification screen await expect(page.locator('input[name="2fa_code"]')).toBeVisible({ timeout: AUTH_TIMEOUT }); } /** * Check if passkey login button is visible * @param page - Playwright page object * @returns boolean - true if passkey button is visible */ export async function isPasskeyButtonVisible(page: Page): Promise { return await page.locator('button[id="passkey-login-button"]').isVisible(); } /** * Login using QR code authentication token * @param page - Playwright page object * @param token - Optional QR auth token (defaults to qrAuthToken from testData) */ export async function loginWithQRCode(page: Page, token?: string) { const authToken = token || qrAuthToken; if (!authToken) { throw new Error("QR auth token is not set. Set PLENO_QR_TOKEN environment variable or pass token directly."); } await page.goto(`/login/qr?token=${authToken}`); await expect(page).toHaveURL(USER_HOME_URL, { timeout: AUTH_TIMEOUT }); } /** * Navigate to QR code login page without authenticating * Useful for testing QR code scanning UI * @param page - Playwright page object */ export async function goToQRCodeLogin(page: Page) { await page.goto("/login/qr"); } /** * Attempt QR code login with invalid token * Useful for testing error handling * @param page - Playwright page object * @param invalidToken - Invalid token to test with */ export async function attemptInvalidQRCodeLogin(page: Page, invalidToken: string = "invalid-token") { await page.goto(`/login/qr?token=${invalidToken}`); }