258 lines
12 KiB
JavaScript
258 lines
12 KiB
JavaScript
const { test, expect, request } = require('@playwright/test');
|
|
require('dotenv').config();
|
|
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: process.env.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: process.env.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: process.env.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: process.env.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: process.env.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: process.env.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 page.goto('/system-settings');
|
|
await expectNoBackendError(page);
|
|
const body = await readBody(page);
|
|
const selectorExists = await page.locator('select[name="active_financial_year"], select[name="financial_year"], [data-testid="active-fy-select"], text=/FY|Financial Year|Active FY/i').count();
|
|
expect(selectorExists || /financial year|active fy|fy\s*[:\-]/i.test(body)).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: process.env.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);
|
|
});
|
|
});
|