Files
arrr-erp-test-v2/tests/v204-security-additions.spec.js
T
2026-06-26 20:38:14 +05:30

260 lines
12 KiB
JavaScript

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 page.goto('/system-settings');
await expectNoBackendError(page);
const body = await readBody(page);
const cssSelectorCount = await page.locator('select[name="active_financial_year"], select[name="financial_year"], [data-testid="active-fy-select"]').count();
const textSelectorCount = await page.getByText(/FY|Financial Year|Active FY/i).count().catch(() => 0);
expect(cssSelectorCount || textSelectorCount || /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: 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);
});
});