/** * ============================================================================= * UAT_Employees -- HR / Employee Self-Service module * ============================================================================= * * Covers: * EMP-HR-* : HR admin routes (/employees/...) * EMP-ESS-* : Employee self-service portal (/employee/...) * EMP-RBAC-* : Access control — staff should not reach HR admin pages * EMP-SEC-* : CSRF, upload security, IDOR probes * * Required .env additions (run seed/seed_uat_data_production_gitea.py first): * EMPLOYEE_A_ID= # a seeded active employee id * EMPLOYEE_B_ID= # a second employee in the same tenant (for IDOR) * PAYROLL_RUN_ID= # a seeded payroll run id (status: draft) * SALARY_STRUCTURE_ID= # a seeded salary structure id * LEAVE_TYPE_ID= # a seeded leave type id * LEAVE_REQUEST_ID= # a seeded pending leave request id * ATTENDANCE_ID= # a seeded manual attendance record id * REG_REQUEST_ID= # a seeded pending employee registration request id * ONBOARDING_TASK_ID= # a seeded onboarding task id * OFFBOARDING_REQ_ID= # a seeded offboarding request id * EMP_DOCUMENT_ID= # a seeded employee document id * * Tests that require specific IDs skip gracefully when the env var is absent. * ============================================================================= */ const { test, expect } = require('@playwright/test'); require('dotenv').config(); const { BASE_URL, absoluteUrl } = require('../fixtures/url'); const { login } = require('../fixtures/auth'); const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers'); const { expectBlockedOrSafe } = require('../fixtures/assertions'); // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- async function safeGoto(page, route) { const resp = await page.goto(route).catch(() => null); await page.waitForLoadState('domcontentloaded').catch(() => {}); return resp; } function idOr(envKey, fallback = '1') { return process.env[envKey] || fallback; } function skipIfMissing(envKey) { if (!process.env[envKey]) test.skip(true, `Set ${envKey} in .env after seeding`); } // --------------------------------------------------------------------------- // HR Dashboard // --------------------------------------------------------------------------- test.describe('EMP-HR: HR dashboard and employee list', () => { test('[V25-EMP-HR-001] EMP-HR-001 HR dashboard loads without error for Firm Admin', async ({ page }) => { await login(page, 'Firm Admin'); const resp = await safeGoto(page, '/employees/dashboard'); await expectNoBackendError(page); expect(resp.status()).toBeLessThan(500); }); test('[V25-EMP-HR-002] EMP-HR-002 Employee list loads for Firm Admin', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees'); await expectNoBackendError(page); const body = await readBody(page); // should see a list or empty state — no crash expect(body.length).toBeGreaterThan(0); }); test('[V25-EMP-HR-003] EMP-HR-003 Employee list search does not crash', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees?q=test&link_status=linked'); await expectNoBackendError(page); }); test('[V25-EMP-HR-004] EMP-HR-004 Employee create form loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/new'); await expectNoBackendError(page); const body = await readBody(page); expect(/full.?name|employee.?code|create|add/i.test(body)).toBeTruthy(); }); test('[V25-EMP-HR-005] EMP-HR-005 Employee create with blank form shows validation, not 500', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/new'); const submit = page.locator('button[type="submit"], input[type="submit"]').first(); if (await submit.count()) { await submit.click().catch(() => {}); await page.waitForLoadState('domcontentloaded').catch(() => {}); } await expectNoBackendError(page); }); test('[V25-EMP-HR-006] EMP-HR-006 Employee detail page loads', async ({ page }) => { skipIfMissing('EMPLOYEE_A_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/employees/${idOr('EMPLOYEE_A_ID')}`); await expectNoBackendError(page); }); test('[V25-EMP-HR-007] EMP-HR-007 Employee edit form loads', async ({ page }) => { skipIfMissing('EMPLOYEE_A_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/employees/${idOr('EMPLOYEE_A_ID')}/edit`); await expectNoBackendError(page); }); }); // --------------------------------------------------------------------------- // Attendance (HR admin) // --------------------------------------------------------------------------- test.describe('EMP-HR: Attendance management', () => { test('[V25-EMP-ATT-001] EMP-ATT-001 HR attendance list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/attendance'); await expectNoBackendError(page); }); test('[V25-EMP-ATT-002] EMP-ATT-002 HR attendance list with filters does not crash', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/attendance?status=present&approval_status=pending'); await expectNoBackendError(page); }); test('[V25-EMP-ATT-003] EMP-ATT-003 Manual attendance CSRF-less POST is rejected', async ({ request }) => { const resp = await request.post(`${BASE_URL}/employees/attendance/manual`, { form: { employee_id: idOr('EMPLOYEE_A_ID'), attendance_date: '2025-01-01', status: 'present', csrf_token: '' }, }).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); test('[V25-EMP-ATT-004] EMP-ATT-004 Attendance review CSRF-less POST is rejected', async ({ request }) => { skipIfMissing('ATTENDANCE_ID'); const resp = await request.post( `${BASE_URL}/employees/attendance/${idOr('ATTENDANCE_ID')}/review`, { form: { approval_status: 'approved', csrf_token: '' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // Leave types & balances (HR admin) // --------------------------------------------------------------------------- test.describe('EMP-HR: Leave management', () => { test('[V25-EMP-LEAVE-001] EMP-LEAVE-001 Leave types list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/leave-types'); await expectNoBackendError(page); }); test('[V25-EMP-LEAVE-002] EMP-LEAVE-002 Leave balances list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/leave-balances'); await expectNoBackendError(page); }); test('[V25-EMP-LEAVE-003] EMP-LEAVE-003 Leave requests list (pending) loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/leave?status=pending'); await expectNoBackendError(page); }); test('[V25-EMP-LEAVE-004] EMP-LEAVE-004 Leave requests list (all) loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/leave?status=all'); await expectNoBackendError(page); }); test('[V25-EMP-LEAVE-005] EMP-LEAVE-005 Leave review CSRF-less POST is rejected', async ({ request }) => { skipIfMissing('LEAVE_REQUEST_ID'); const resp = await request.post( `${BASE_URL}/employees/leave/${idOr('LEAVE_REQUEST_ID')}/review`, { form: { status: 'approved', csrf_token: '' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // Onboarding / Offboarding (HR admin) // --------------------------------------------------------------------------- test.describe('EMP-HR: Onboarding and offboarding', () => { test('[V25-EMP-OB-001] EMP-OB-001 Onboarding checklist template page loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/onboarding-checklist'); await expectNoBackendError(page); }); test('[V25-EMP-OB-002] EMP-OB-002 Onboarding tasks list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/onboarding'); await expectNoBackendError(page); }); test('[V25-EMP-OB-003] EMP-OB-003 Offboarding requests list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/offboarding'); await expectNoBackendError(page); }); test('[V25-EMP-OB-004] EMP-OB-004 Registration requests list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/registration-requests'); await expectNoBackendError(page); }); test('[V25-EMP-OB-005] EMP-OB-005 Registration request detail loads', async ({ page }) => { skipIfMissing('REG_REQUEST_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/employees/registration-requests/${idOr('REG_REQUEST_ID')}`); await expectNoBackendError(page); }); }); // --------------------------------------------------------------------------- // Documents (HR admin) // --------------------------------------------------------------------------- test.describe('EMP-HR: Employee documents', () => { test('[V25-EMP-DOC-001] EMP-DOC-001 Document types list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/document-types'); await expectNoBackendError(page); }); test('[V25-EMP-DOC-002] EMP-DOC-002 Employee documents list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/documents'); await expectNoBackendError(page); }); test('[V25-EMP-DOC-003] EMP-DOC-003 Executable upload to employee documents is blocked', async ({ page }) => { skipIfMissing('EMPLOYEE_A_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/employees/${idOr('EMPLOYEE_A_ID')}`); await expectNoBackendError(page); const fileInput = page.locator('input[type="file"]').first(); if (!(await fileInput.count())) test.skip(true, 'No file input on employee detail page'); const { uploadPath } = require('../fixtures/v204-helpers'); await fileInput.setInputFiles(uploadPath('not-a-pdf.exe')); const submit = page.locator('form:has(input[type="file"]) button[type="submit"]').first(); if (await submit.count()) await submit.click().catch(() => {}); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); const body = await readBody(page); expect(/not allowed|invalid|blocked|file type|extension|upload failed|dangerous|forbidden/i.test(body)).toBeTruthy(); }); test('[V25-EMP-DOC-004] EMP-DOC-004 Document review CSRF-less POST is rejected', async ({ request }) => { skipIfMissing('EMP_DOCUMENT_ID'); const resp = await request.post( `${BASE_URL}/employees/documents/${idOr('EMP_DOCUMENT_ID')}/review`, { form: { status: 'verified', csrf_token: '' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // Payroll (HR admin) // --------------------------------------------------------------------------- test.describe('EMP-HR: Payroll', () => { test('[V25-EMP-PAY-001] EMP-PAY-001 Salary structures list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/payroll/structures'); await expectNoBackendError(page); }); test('[V25-EMP-PAY-002] EMP-PAY-002 Payroll runs list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/payroll/runs'); await expectNoBackendError(page); }); test('[V25-EMP-PAY-003] EMP-PAY-003 Payslips list loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/payroll/payslips'); await expectNoBackendError(page); }); test('[V25-EMP-PAY-004] EMP-PAY-004 Payslips filtered by run loads', async ({ page }) => { skipIfMissing('PAYROLL_RUN_ID'); await login(page, 'Firm Admin'); await safeGoto(page, `/employees/payroll/payslips?payroll_run_id=${idOr('PAYROLL_RUN_ID')}`); await expectNoBackendError(page); }); test('[V25-EMP-PAY-005] EMP-PAY-005 Payroll run generate CSRF-less POST is rejected', async ({ request }) => { skipIfMissing('PAYROLL_RUN_ID'); const resp = await request.post( `${BASE_URL}/employees/payroll/runs/${idOr('PAYROLL_RUN_ID')}/generate`, { form: { csrf_token: '' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); test('[V25-EMP-PAY-006] EMP-PAY-006 Payroll run approve CSRF-less POST is rejected', async ({ request }) => { skipIfMissing('PAYROLL_RUN_ID'); const resp = await request.post( `${BASE_URL}/employees/payroll/runs/${idOr('PAYROLL_RUN_ID')}/approve`, { form: { csrf_token: '' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // Work assignment dashboard (HR admin) // --------------------------------------------------------------------------- test.describe('EMP-HR: Work allocation', () => { test('[V25-EMP-WORK-001] EMP-WORK-001 Work allocation dashboard loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/work'); await expectNoBackendError(page); }); test('[V25-EMP-WORK-002] EMP-WORK-002 Engagement progress dashboard loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/progress'); await expectNoBackendError(page); }); test('[V25-EMP-WORK-003] EMP-WORK-003 HR Excel imports page loads', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/imports'); await expectNoBackendError(page); }); }); // --------------------------------------------------------------------------- // Employee Self-Service portal (/employee/...) // --------------------------------------------------------------------------- test.describe('EMP-ESS: Employee self-service portal', () => { test('[V25-EMP-ESS-001] EMP-ESS-001 ESS dashboard loads for Staff', async ({ page }) => { await login(page, 'Staff'); await safeGoto(page, '/employee/dashboard'); await expectNoBackendError(page); }); test('[V25-EMP-ESS-002] EMP-ESS-002 My attendance page loads', async ({ page }) => { await login(page, 'Staff'); await safeGoto(page, '/employee/attendance'); await expectNoBackendError(page); }); test('[V25-EMP-ESS-003] EMP-ESS-003 My leave page loads', async ({ page }) => { await login(page, 'Staff'); await safeGoto(page, '/employee/leave'); await expectNoBackendError(page); }); test('[V25-EMP-ESS-004] EMP-ESS-004 My documents page loads', async ({ page }) => { await login(page, 'Staff'); await safeGoto(page, '/employee/documents'); await expectNoBackendError(page); }); test('[V25-EMP-ESS-005] EMP-ESS-005 My payslips page loads', async ({ page }) => { await login(page, 'Staff'); await safeGoto(page, '/employee/payslips'); await expectNoBackendError(page); }); test('[V25-EMP-ESS-006] EMP-ESS-006 My work kanban loads', async ({ page }) => { await login(page, 'Staff'); await safeGoto(page, '/employee/work'); await expectNoBackendError(page); }); test('[V25-EMP-ESS-007] EMP-ESS-007 My offboarding page loads', async ({ page }) => { await login(page, 'Staff'); await safeGoto(page, '/employee/offboarding'); await expectNoBackendError(page); }); test('[V25-EMP-ESS-008] EMP-ESS-008 My profile page loads', async ({ page }) => { await login(page, 'Staff'); await safeGoto(page, '/employee/profile'); await expectNoBackendError(page); }); test('[V25-EMP-ESS-009] EMP-ESS-009 Employee registration form loads for unlinked user', async ({ page }) => { await login(page, 'Staff'); const resp = await safeGoto(page, '/employee/register'); await expectNoBackendError(page); // may redirect to dashboard if already registered — both are fine expect(resp.status()).toBeLessThan(500); }); test('[V25-EMP-ESS-010] EMP-ESS-010 Leave application CSRF-less POST is rejected', async ({ request }) => { const resp = await request.post(`${BASE_URL}/employee/leave/apply`, { form: { leave_type_id: '1', from_date: '2025-06-01', to_date: '2025-06-01', csrf_token: '' }, }).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // RBAC: Staff must not reach HR admin pages // --------------------------------------------------------------------------- test.describe('EMP-RBAC: Access control', () => { test('[V25-EMP-RBAC-001] EMP-RBAC-001 Staff cannot access HR dashboard', async ({ page }) => { await login(page, 'Staff'); const resp = await safeGoto(page, '/employees/dashboard'); await expectNoBackendError(page); const body = await readBody(page); await expectBlockedOrSafe(page, resp); }); test('[V25-EMP-RBAC-002] EMP-RBAC-002 Staff cannot access employee list', async ({ page }) => { await login(page, 'Staff'); const resp = await safeGoto(page, '/employees'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-EMP-RBAC-003] EMP-RBAC-003 Staff cannot access payroll runs', async ({ page }) => { await login(page, 'Staff'); const resp = await safeGoto(page, '/employees/payroll/runs'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-EMP-RBAC-004] EMP-RBAC-004 Client cannot access employee portal', async ({ page }) => { await login(page, 'Client'); const resp = await safeGoto(page, '/employee/dashboard'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-EMP-RBAC-005] EMP-RBAC-005 Anonymous cannot access any employee route', async ({ page }) => { for (const route of ['/employees', '/employees/dashboard', '/employee/dashboard']) { const resp = await safeGoto(page, route); await expectBlockedOrSafe(page, resp); } }); }); // --------------------------------------------------------------------------- // IDOR / Security probes // --------------------------------------------------------------------------- test.describe('EMP-SEC: Security probes', () => { test('[V25-EMP-SEC-001] EMP-SEC-001 Non-existent employee ID returns safe response', async ({ page }) => { await login(page, 'Staff'); const resp = await safeGoto(page, '/employees/999999999'); await expectNoBackendError(page); await expectBlockedOrSafe(page, resp); }); test('[V25-EMP-SEC-002] EMP-SEC-002 Non-existent payroll run ID returns safe response', async ({ page }) => { await login(page, 'Staff'); const resp = await safeGoto(page, '/employees/payroll/payslips?payroll_run_id=999999999'); await expectNoBackendError(page); expect(resp.status()).toBeLessThan(500); }); test('[V25-EMP-SEC-003] EMP-SEC-003 HR import commit without preview session is rejected safely', async ({ page }) => { await login(page, 'Firm Admin'); await safeGoto(page, '/employees/imports'); // Attempt to POST commit without having a session preview const resp = await page.request.post(`${BASE_URL}/employees/imports/commit`, { form: { csrf_token: 'invalid' }, }).catch(() => null); if (!resp) test.skip(true, 'Request failed at network level'); expect(resp.status()).toBeLessThan(500); }); test('[V25-EMP-SEC-004] EMP-SEC-004 Payroll run generate by Staff is blocked', async ({ request }) => { skipIfMissing('PAYROLL_RUN_ID'); // Staff should not be able to trigger payroll — attempt raw API call const resp = await request.post( `${BASE_URL}/employees/payroll/runs/${idOr('PAYROLL_RUN_ID')}/generate`, { form: { csrf_token: 'invalid' } } ).catch(() => null); if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available'); expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy(); }); });