Files

183 lines
8.3 KiB
TypeScript
Raw Permalink Normal View History

Extract PassDashboard Playwright e2e suite into standalone repo Copies the Playwright end-to-end suite out of the PassDashboard app repo so it can be run and deployed independently. Nothing was removed from the app repo - this is a copy. Carried over verbatim: tests/ (10 spec files, 4 helper modules, README) plus playwright.config.ts and .env.test.example. The suite drives the deployed app over HTTP and imports nothing from src/, which is what makes it standalone. New for this repo: - package.json with only the deps the suite actually uses (@playwright/test, allure-playwright, xlsx, typescript, @types/node) and the e2e scripts. - tsconfig.json covering tests/ - the app repo's tsconfig.app.json only included src/, so these files were never typechecked before. - .gitea/workflows/e2e-tests.yml for Gitea Actions. The app repo derived ENV from github.ref_name; that doesn't apply here since this repo has no per-environment branches, so the target is an explicit workflow_dispatch input and scheduled/push runs default to develop. Reports are uploaded as artifacts rather than deleted. - .gitignore and README covering local setup and the required CI secrets. The Jest unit tests under src/__tests__/ were deliberately left in the app repo: they import application source directly (AuthContext, ProtectedRoute, cryptoUtils, api) and cannot run without it. Verified: npx tsc --noEmit is clean and playwright collects all 43 tests across all 10 spec files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 15:50:50 +03:00
import { test, expect } from '@playwright/test';
import { login, loginAs } from './helpers/auth';
import { epic, feature, severity, description, tag } from './helpers/allure';
import { navigateToManagement } from './helpers/navigation';
// Confirmed live by actually logging in as each role: the nav menu is
// restricted per role, and Terminal Manager lands on /terminals (not
// /dashboard) since Dashboard isn't in its menu at all.
const EXPECTED_MENUS: Record<string, { visible: string[]; hidden: string[]; landingPath: string }> = {
CASHIER: {
visible: ['Dashboard', 'Transactions', 'Terminals', 'Refunds', 'Settings'],
hidden: ['Discounts & fees', 'Reconciliation', 'Settlement', 'Management'],
landingPath: '/dashboard',
},
FINANCE: {
visible: ['Dashboard', 'Transactions', 'Discounts & fees', 'Reconciliation', 'Refunds', 'Settlement', 'Settings'],
hidden: ['Terminals', 'Management'],
landingPath: '/dashboard',
},
TERMINAL_MANAGER: {
visible: ['Terminals', 'Settings'],
hidden: ['Dashboard', 'Transactions', 'Discounts & fees', 'Reconciliation', 'Refunds', 'Settlement', 'Management'],
landingPath: '/terminals',
},
};
async function createRoleUser(
page: import('@playwright/test').Page,
role: keyof typeof EXPECTED_MENUS,
fullName: string,
identifier: string,
password: string
): Promise<void> {
await navigateToManagement(page);
await page.waitForTimeout(2000);
const addUserBtn = page.locator('button').filter({ hasText: 'Add User' }).first();
await addUserBtn.click({ force: true });
await page.waitForTimeout(1500);
await page.locator('#fullName').first().fill(fullName);
await page.locator('#username').first().fill(identifier);
await page.locator('#password').first().fill(password);
await page.locator('#confirmPassword').first().fill(password);
if (role !== 'CASHIER') {
const roleSelect = page.locator('.ant-select:has(.anticon-solution)').first();
await roleSelect.locator('.ant-select-selector').click();
await page.waitForTimeout(800);
const label = role === 'FINANCE' ? 'Finance' : 'Terminal Manager';
const option = page.locator('.ant-select-item-option-content', { hasText: label }).first();
await option.waitFor({ state: 'visible', timeout: 5000 });
await option.click();
// Wait for the dropdown to fully close - a leftover open dropdown here
// caused the *next* select's option locator to resolve to a stale,
// invisible element from this role dropdown instead.
await page.waitForSelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)', { state: 'hidden', timeout: 5000 }).catch(() => {});
await page.waitForTimeout(800);
}
const branchSelect = page.locator('.ant-select').filter({ hasText: /Select a branch/i }).first();
if (await branchSelect.isVisible({ timeout: 3000 }).catch(() => false)) {
await branchSelect.click();
await page.waitForTimeout(800);
const options = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option');
const count = await options.count();
for (let i = 0; i < count; i++) {
const t = (await options.nth(i).textContent())?.trim();
if (t && t !== '-' && t.length > 1) {
await options.nth(i).click();
break;
}
}
await page.waitForTimeout(500);
}
if (role === 'CASHIER') {
// Cashiers additionally require an unassociated Terminal Id.
const terminalSelect = page.locator('.ant-select:has(.anticon-mobile)').first();
await expect(terminalSelect, 'Terminal Id select should be visible for Cashier role').toBeVisible({ timeout: 5000 });
await terminalSelect.locator('.ant-select-selector').click();
await page.waitForTimeout(1200);
const dropdownOptions = page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option');
const count = await dropdownOptions.count();
let selected = false;
for (let i = 0; i < count; i++) {
const optText = (await dropdownOptions.nth(i).textContent())?.trim();
const idMatch = optText?.match(/Id:\s*(\d+)/i);
if (idMatch && idMatch[1].length >= 8) {
await dropdownOptions.nth(i).click({ force: true });
await page.waitForTimeout(500);
selected = true;
break;
}
}
expect(selected, 'Should be able to select an unassociated terminal for the new cashier').toBe(true);
}
const submitBtn = page.locator('.ant-modal button:has-text("Add User")').last();
await submitBtn.click();
await page.waitForTimeout(3000);
const modalStillOpen = await page.locator('.ant-modal-content').first().isVisible().catch(() => false);
expect(modalStillOpen, `${role} user creation should succeed (the modal should close)`).toBe(false);
}
test.describe('Role-Based Access Tests', () => {
test.setTimeout(120000);
for (const role of Object.keys(EXPECTED_MENUS) as Array<keyof typeof EXPECTED_MENUS>) {
test(`should show correct restricted navigation for ${role} role after login`, async ({ page }) => {
epic('Role-Based Access Control');
feature('Role Permissions');
tag('functional');
severity('critical');
description(`Create a ${role} user as admin, then actually log in as that user and verify their navigation menu matches expected role permissions`);
console.log(`Testing ${role} role-based access...`);
const timestamp = Date.now();
const fullName = `RoleTest ${role} ${timestamp}`;
const identifier = role === 'CASHIER'
? `roletest${role.toLowerCase()}${timestamp}`
: `roletest${role.toLowerCase().replace('_', '')}${timestamp}@test.com`;
const password = 'Mav@1234';
await test.step(`Log in as admin and create a new ${role} user`, async () => {
await login(page);
await createRoleUser(page, role, fullName, identifier, password);
console.log(`✓ Created ${role} user: ${identifier}`);
});
await test.step('Log out of the admin session', async () => {
// Navigating straight to the login URL while still authenticated as
// admin just auto-redirects back to /dashboard - the login form
// never renders, so filling it later times out. Log out explicitly.
const logoutLink = page.locator('text=Logout').first();
await logoutLink.waitFor({ state: 'visible', timeout: 10000 });
await logoutLink.click();
await page.waitForURL(/\/login/, { timeout: 10000 }).catch(() => {});
await page.waitForTimeout(1500);
console.log('✓ Logged out of admin session, URL:', page.url());
});
await test.step(`Log in as the new ${role} user`, async () => {
// A just-created account can take a moment to become authenticatable
// (backend provisioning lag) - retry the login itself rather than
// failing on the first attempt landing back on the login page.
let landed = false;
for (let attempt = 1; attempt <= 3; attempt++) {
await loginAs(page, identifier, password);
console.log(`✓ Logged in as ${role}, landed on: ${page.url()}`);
landed = page.url().includes(EXPECTED_MENUS[role].landingPath);
if (landed) break;
console.log(`Login attempt ${attempt} did not land on ${EXPECTED_MENUS[role].landingPath} - retrying...`);
await page.waitForTimeout(3000);
}
expect(landed, `${role} should land on ${EXPECTED_MENUS[role].landingPath} after login`).toBe(true);
});
await test.step('Verify the navigation menu matches expected role permissions', async () => {
const { visible, hidden } = EXPECTED_MENUS[role];
for (const item of visible) {
const menuItem = page.locator('button').filter({ hasText: new RegExp(`^${item}$`) }).first();
await expect(menuItem, `${role} should see "${item}" in the menu`).toBeVisible({ timeout: 10000 });
}
console.log(`${role} sees all expected menu items: ${visible.join(', ')}`);
for (const item of hidden) {
const menuItem = page.locator('button').filter({ hasText: new RegExp(`^${item}$`) });
const count = await menuItem.count();
expect(count, `${role} should NOT see "${item}" in the menu`).toBe(0);
}
console.log(`${role} correctly does not see: ${hidden.join(', ')}`);
});
console.log(`${role} role-based access test completed`);
});
}
});