Files
arrr-erp-test-v2/tests/v204-security-additions.spec.js
T
A R R R Associates 3bcb6ed093 latest error resolved
2026-06-30 23:18:42 +05:30

303 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 page.goto(`/system-settings/context/financial-year/${encodeURIComponent(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 page.goto(`/system-settings/context/financial-year/${encodeURIComponent(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);
});
});