const { test, expect, request } = require('@playwright/test'); require('dotenv').config(); const { BASE_URL, absoluteUrl } = require('../fixtures/url'); const { login, fillFirst, clickFirst } = require('../fixtures/auth'); const { expectBlockedOrSafe, expectCookieFlags, expectSecurityHeaders } = require('../fixtures/assertions'); const { readBody, expectNoBackendError, extractCsrfFromPage, blockedOrNotFound, switchFinancialYearIfPossible, uploadPath, } = require('../fixtures/v204-helpers'); const activeFY = process.env.ACTIVE_FY || process.env.DEFAULT_YEAR_CODE || '2025-26'; const previousFY = process.env.PREVIOUS_FY || '2024-25'; const tenantBName = process.env.TENANT_B_NAME || 'UAT Tenant B'; test.describe('v2.0.4 additional security / FY / context checks', () => { test('V204-SEC-001 forgot-password API must not expose reset token', async () => { const api = await request.newContext({ baseURL: BASE_URL }); const email = process.env.FIRM_ADMIN_EMAIL || process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local'; const candidates = [ { url: '/auth/forgot-password', opts: { data: { email } } }, { url: '/auth/forgot-password', opts: { form: { email } } }, { url: '/forgot-password', opts: { form: { email } } }, ]; let checked = false; for (const c of candidates) { const resp = await api.post(c.url, c.opts).catch(() => null); if (!resp) continue; if ([404,405].includes(resp.status())) continue; checked = true; expect(resp.status()).toBeLessThan(500); const text = await resp.text(); expect(text).not.toMatch(/reset_token|password_reset_token|invite_token/i); expect(text).not.toMatch(/[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}/); } expect(checked).toBeTruthy(); }); test('V204-SEC-002 reset-password with random token is rejected safely', async () => { const api = await request.newContext({ baseURL: BASE_URL }); const resp = await api.post('/auth/reset-password', { data: { token: 'invalid-token-for-vapt', password: 'NewPassword@123' }, }).catch(() => null); if (!resp || [404,405].includes(resp.status())) test.skip(true, 'Reset password API route not available in this environment'); expect(resp.status()).toBeLessThan(500); expect([400,401,403,404,405,409,422,429].includes(resp.status())).toBeTruthy(); }); test('V204-SEC-003 API token/login brute force attempts do not create 500 errors', async () => { const api = await request.newContext({ baseURL: BASE_URL }); const email = process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local'; for (let i = 0; i < 6; i++) { const resp = await api.post('/auth/token', { form: { username: email, email, password: `wrong-password-${i}` }, }).catch(() => null); if (!resp || [404,405].includes(resp.status())) test.skip(true, 'API token route not available in this environment'); expect(resp.status()).toBeLessThan(500); expect([400,401,403,422,429].includes(resp.status())).toBeTruthy(); } }); test('V204-CTX-001 public tenant/branch/FY headers are ignored without secret', async ({ browser }) => { const context = await browser.newContext({ baseURL: BASE_URL, extraHTTPHeaders: { 'X-Tenant-Code': process.env.TENANT_B_CODE || 'UAT-B', 'X-Branch-Code': process.env.BRANCH_B_CODE || 'UAT-BB', 'X-Year-Code': previousFY, }, }); const page = await context.newPage(); await login(page, 'Staff'); await page.goto('/system-settings'); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); const body = await readBody(page); expect(body).not.toMatch(new RegExp(tenantBName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')); await context.close(); }); test('V204-CTX-002 wrong context secret must not enable spoofed headers', async ({ browser }) => { const context = await browser.newContext({ baseURL: BASE_URL, extraHTTPHeaders: { 'X-Tenant-Code': process.env.TENANT_B_CODE || 'UAT-B', 'X-Branch-Code': process.env.BRANCH_B_CODE || 'UAT-BB', 'X-Year-Code': previousFY, 'X-AuditFirm-Context-Secret': 'wrong-secret-for-vapt', }, }); const page = await context.newPage(); await login(page, 'Staff'); await page.goto('/billing'); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); const body = await readBody(page); expect(body).not.toMatch(new RegExp(tenantBName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')); await context.close(); }); test('V204-CTX-003 trusted context headers require explicit secret when enabled', async ({ browser }) => { if ((process.env.TRUST_CONTEXT_HEADERS || '').toLowerCase() !== 'true') { test.skip(true, 'Trusted context header mode is disabled, as expected for public UAT/prod'); } if (!process.env.CONTEXT_HEADER_SECRET) test.fail(true, 'TRUST_CONTEXT_HEADERS=true but CONTEXT_HEADER_SECRET is empty'); const context = await browser.newContext({ baseURL: BASE_URL, extraHTTPHeaders: { 'X-Tenant-Code': process.env.TENANT_A_CODE || 'UAT-A', 'X-Branch-Code': process.env.BRANCH_A_CODE || 'UAT-BA', 'X-Year-Code': activeFY, 'X-AuditFirm-Context-Secret': process.env.CONTEXT_HEADER_SECRET, }, }); const page = await context.newPage(); await login(page, 'System Admin'); await page.goto('/system-settings'); await expectNoBackendError(page); await context.close(); }); test('V204-FY-001 FY selector/session context is visible after login', async ({ page }) => { await login(page, 'System Admin'); await expect(page).not.toHaveURL(/\/login|\/otp/i); const candidateRoutes = [ '/system-settings', '/dashboard', '/', ]; let found = false; for (const route of candidateRoutes) { await page.goto(route).catch(() => null); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); const body = await readBody(page); const cssSelectorCount = await page.locator( [ 'select[name="active_financial_year"]', 'select[name="financial_year"]', 'select[name="fy"]', '[data-testid="active-fy-select"]', '[data-testid="financial-year"]', '[data-active-fy]', '.active-fy', '.financial-year', '#active-fy', '#financial-year', '#active_financial_year' ].join(', ') ).count().catch(() => 0); const textSelectorCount = await page.getByText( /Active Financial Year|Financial Year|Active FY|Current FY|Active Scope|FY\s*:|FY\s+20\d{2}|20\d{2}\s*[-–]\s*\d{2}/i ).count().catch(() => 0); if ( cssSelectorCount || textSelectorCount || /active financial year|financial year|active fy|current fy|active scope|fy\s*:|fy\s+20\d{2}|20\d{2}\s*[-–]\s*\d{2}/i.test(body) ) { found = true; break; } } expect(found).toBeTruthy(); }); test('V204-FY-002 switching FY does not crash core transactional pages', async ({ page }) => { await login(page, 'System Admin'); await page.goto('/system-settings'); await switchFinancialYearIfPossible(page, activeFY); for (const route of ['/services/engagements', '/documents', '/notice-cases', '/billing', '/billing/payments']) { const resp = await page.goto(route).catch(() => null); if (resp && [404,405].includes(resp.status())) continue; await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); } }); test('V204-LOCK-001 locked FY write attempt should be blocked or safely rejected', async ({ page }) => { if (!process.env.LOCKED_FY) test.skip(true, 'Set LOCKED_FY in .env after locking a financial year'); await login(page, 'System Admin'); await page.goto('/system-settings'); await switchFinancialYearIfPossible(page, process.env.LOCKED_FY); await page.goto('/notice-cases/new'); await page.waitForLoadState('domcontentloaded').catch(() => {}); const csrf = await extractCsrfFromPage(page); if (!csrf) test.skip(true, 'Notice create form/CSRF not available'); await fillFirst(page, ['input[name="title"]', 'textarea[name="title"]'], 'Locked FY write negative'); await fillFirst(page, ['input[name="reference_no"]'], `LOCKED-FY-${Date.now()}`); await clickFirst(page, ['button[type="submit"]', 'input[type="submit"]']); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); const body = await readBody(page); expect(/locked|closed|not allowed|cannot|permission|forbidden|access denied/i.test(body) || !page.url().match(/notice-cases\/\d+$/)).toBeTruthy(); }); test('V204-BACKUP-001 backup screens/files are not accessible anonymously', async ({ page }) => { const probes = [ '/system-settings/financial-years/1/backup', '/system-settings/financial-years/backup', '/system-settings/year-backups/1/download', '/data/year_backups/test.zip', '/year_backups/test.zip', '/static/year_backups/test.zip', ]; for (const p of probes) { const resp = await page.goto(p).catch(() => null); await page.waitForLoadState('domcontentloaded').catch(() => {}); const body = await readBody(page); await blockedOrNotFound(resp, body); } }); test('V204-BACKUP-002 low privilege user cannot access backup export', async ({ page }) => { await login(page, 'Staff'); for (const p of ['/system-settings/financial-years/1/backup', '/system-settings/year-backups/1/download']) { const resp = await page.goto(p).catch(() => null); await page.waitForLoadState('domcontentloaded').catch(() => {}); const body = await readBody(page); await blockedOrNotFound(resp, body); await expectNoBackendError(page); } }); test('V204-UPLOAD-001 executable upload to notice/case documents is blocked', async ({ page }) => { if (!process.env.NOTICE_CASE_A_ID) test.skip(true, 'Set NOTICE_CASE_A_ID for upload security test'); await login(page, 'System Admin'); await page.goto(`/notice-cases/${process.env.NOTICE_CASE_A_ID}`); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); const fileInput = page.locator('input[type="file"]').first(); if (!(await fileInput.count())) test.skip(true, 'No file input found on notice case detail page'); await fileInput.setInputFiles(uploadPath('not-a-pdf.exe')); await fillFirst(page, ['input[name="title"]'], 'Executable upload negative'); await clickFirst(page, ['form:has(input[type="file"]) button[type="submit"]', 'button[type="submit"]']); 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('V204-UPLOAD-002 oversized upload is blocked or rejected safely', async ({ page }) => { if (!process.env.NOTICE_CASE_A_ID) test.skip(true, 'Set NOTICE_CASE_A_ID for upload size test'); await login(page, 'System Admin'); await page.goto(`/notice-cases/${process.env.NOTICE_CASE_A_ID}`); const fileInput = page.locator('input[type="file"]').first(); if (!(await fileInput.count())) test.skip(true, 'No file input found on notice case detail page'); await fileInput.setInputFiles(uploadPath('large-file.bin')); await fillFirst(page, ['input[name="title"]'], 'Oversized upload negative'); await clickFirst(page, ['form:has(input[type="file"]) button[type="submit"]', 'button[type="submit"]']); await page.waitForLoadState('domcontentloaded').catch(() => {}); await expectNoBackendError(page); const body = await readBody(page); expect(/too large|size|not allowed|invalid|blocked|upload failed|forbidden/i.test(body)).toBeTruthy(); }); test('V204-STORAGE-001 storage agent endpoints require node authentication', async () => { const api = await request.newContext({ baseURL: BASE_URL }); const endpoints = [ '/documents/storage-agent/jobs/pending', '/documents/storage-agent/download-requests/pending', ]; for (const url of endpoints) { const resp = await api.get(url); expect(resp.status()).toBeLessThan(500); expect([400,401,403,404,405,422].includes(resp.status())).toBeTruthy(); } }); test('V204-HEADERS-001 security headers and cookie flags remain valid', async ({ page, context }) => { const resp = await page.goto('/login'); await expectSecurityHeaders(resp); await login(page, 'System Admin'); await expectCookieFlags(context); if ((process.env.EXPECT_SECURE_COOKIES || '').toLowerCase() === 'true') { const cookies = await context.cookies(); const session = cookies.find(c => /session|auth|token|sid/i.test(c.name)); if (session) expect(session.secure).toBeTruthy(); } }); test('V204-CSP-001 strict CSP mode should not use unsafe-inline when enforced', async ({ page }) => { if ((process.env.EXPECT_STRICT_CSP || '').toLowerCase() !== 'true') { test.skip(true, 'Set EXPECT_STRICT_CSP=true after inline JS/CSS has been removed'); } const resp = await page.goto('/login'); const csp = resp.headers()['content-security-policy'] || ''; expect(csp).toBeTruthy(); expect(csp).not.toMatch(/unsafe-inline/i); }); });