Files
pleno-vue/tests/e2e/fixtures/authHelpers.ts
T
Jeppe Bundgaard 6c4b9cb599 Add and improve e2e tests for Edge Gateway flows and configure pre-commit hooks:
- Introduced live smoke tests for Edge Gateways, verifying gateway routes and destructive-action prevention.
- Added e2e scenarios for deep-link navigation, unavailable gateway recovery, token rotation, and background page refresh.
- Refactored test helpers for streamlined functional validation in gateway scenarios.
- Updated `EdgeGatewayTerminal.vue` with test IDs for enhanced testability.
- Added and configured Husky pre-commit hooks for automated test file formatting and validation.
2026-04-16 10:52:56 +02:00

582 lines
18 KiB
TypeScript

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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<void>
) {
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<boolean> {
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}`);
}