- 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.
582 lines
18 KiB
TypeScript
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}`);
|
|
}
|