Initial Playwright ERP UAT VAPT test suite v2.4.1 IMAP

This commit is contained in:
A R R R Associates
2026-06-22 12:52:00 +05:30
parent 970d5df926
commit 865f19cb84
29 changed files with 26786 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
const { test, expect, request } = require('@playwright/test');
require('dotenv').config();
const matrix = require('../data/generated-test-matrix.json');
const { login, logout, fillFirst, clickFirst } = require('../fixtures/auth');
const { expectNoServerError, expectBlockedOrSafe, expectSecurityHeaders, expectCookieFlags } = require('../fixtures/assertions');
function title(v) {
return `[${v.variantId}] ${v.sheet} :: ${v.scenario} :: ${v.variantName}`;
}
function unauthorizedRole(v) {
const r = (v.role || '').toLowerCase();
if (r.includes('client')) return 'Staff';
if (r.includes('staff') || r.includes('employee')) return 'Client';
if (r.includes('consultant')) return 'Client';
return 'Staff';
}
async function safeGoto(page, route) {
const resp = await page.goto(route || '/employee/dashboard');
await page.waitForLoadState('domcontentloaded');
return resp;
}
async function submitBlankFormIfAny(page) {
const submit = page.locator('form button[type="submit"], form input[type="submit"]').first();
if (await submit.count()) {
await submit.click().catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
}
}
async function boundaryInputIfAny(page) {
const longText = 'X'.repeat(2048);
const input = page.locator('form input[type="text"], form textarea').first();
if (await input.count()) await input.fill(longText).catch(() => {});
await submitBlankFormIfAny(page);
}
async function runSecuritySpecific(page, context, v) {
const id = v.sourceId;
if (/SEC-001/i.test(id)) {
await page.goto('/login');
for (let i = 0; i < 5; i++) {
await fillFirst(page, ['input[name="email"]','input[name="login_id"]','input[type="email"]'], process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local');
await fillFirst(page, ['input[name="password"]','input[type="password"]'], 'wrong-password-' + i);
await clickFirst(page, ['button[type="submit"]','input[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(() => {});
}
await expectNoServerError(page);
return;
}
if (/SEC-002/i.test(id)) {
await login(page, 'System Admin');
await logout(page);
const resp = await safeGoto(page, '/employee/dashboard');
await expectBlockedOrSafe(page, resp);
return;
}
if (/SEC-007|GEN-011/i.test(id)) {
const api = await request.newContext({ baseURL: process.env.BASE_URL });
const resp = await api.post('/notice-cases/new', { form: { title: 'csrf-test-no-token' } });
expect([400,401,403,404,405,422,303].includes(resp.status())).toBeTruthy();
return;
}
if (/SEC-017/i.test(id)) {
await login(page, 'System Admin');
await expectCookieFlags(context);
return;
}
if (/SEC-026/i.test(id)) {
const resp = await page.goto('/login');
await expectSecurityHeaders(resp);
return;
}
if (/SEC-010|SEC-103|SEC-104|SEC-110|SEC-111|DOM-VAPT/i.test(id)) {
await login(page, 'Staff');
const probes = ['/documents/../../app/main.py','/documents/999999/download','/notice-cases/999999999','/notice-cases/999999999/documents/999999/download','/domains/999999999/edit'];
for (const p of probes) {
const resp = await safeGoto(page, p);
await expectBlockedOrSafe(page, resp);
}
return;
}
await login(page, v.role || 'System Admin');
const resp = await safeGoto(page, v.route);
await expectNoServerError(page);
if (v.variantType === 'headers') await expectCookieFlags(context);
}
for (const v of matrix) {
test(title(v), async ({ page, context }) => {
test.info().annotations.push({ type: 'sourceId', description: v.sourceId });
test.info().annotations.push({ type: 'variantType', description: v.variantType });
test.info().annotations.push({ type: 'expected', description: String(v.expected || '').slice(0, 250) });
if (v.automation === 'manual') {
test.skip(true, v.manualReason || 'Marked manual in generated matrix');
}
if (v.variantType === 'security' || v.sheet.startsWith('VAPT_')) {
await runSecuritySpecific(page, context, v);
return;
}
if (v.variantType === 'rbac') {
await login(page, unauthorizedRole(v));
const resp = await safeGoto(page, v.route);
await expectNoServerError(page);
// A safe result may be allowed page for common dashboards, but must not leak obvious restricted data or crash.
if (!['/login','/employee/dashboard'].includes(v.route)) {
const body = await page.locator('body').innerText().catch(()=>'');
expect(body).not.toMatch(/Traceback|Internal Server Error|Exception/i);
}
return;
}
await login(page, v.role || 'System Admin');
const resp = await safeGoto(page, v.route);
await expectNoServerError(page);
if (v.variantType === 'positive') {
expect(resp.status()).toBeLessThan(500);
} else if (v.variantType === 'negative' || v.variantType === 'validation') {
await submitBlankFormIfAny(page);
await expectNoServerError(page);
} else if (v.variantType === 'boundary') {
await boundaryInputIfAny(page);
await expectNoServerError(page);
}
});
}
+84
View File
@@ -0,0 +1,84 @@
/**
* =============================================================================
* UAT_FY_Lock_Backup -- Financial Year Lock & Backup UAT scaffolding
* =============================================================================
*
* ⚠️ UNTESTED SCAFFOLDING -- READ BEFORE RUNNING ⚠️
*
* These 30 cases correspond to the UAT_FY_Lock_Backup sheet, which existed in
* the checklist but was NOT part of the generated automation matrix. This file
* was added in v2.4 to close that gap.
*
* It has NOT been executed or verified against a running ERP. The route paths,
* selectors, and assertions below are best-effort guesses based on the ERP's
* known routes (e.g. /system-settings/financial-years...). They are very likely
* to need adjustment for your build. Every case is marked test.fixme() so the
* suite does NOT report these as passing until a human has:
* 1. Confirmed the real routes/selectors for each step,
* 2. Implemented the assertion,
* 3. Removed the test.fixme() wrapper for that case.
*
* DO NOT report these as automated coverage until that work is done.
* Corresponding checklist rows remain "Not Started" / manual.
* =============================================================================
*/
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const FY_BASE = '/system-settings/financial-years';
const activeFY = process.env.ACTIVE_FY || process.env.DEFAULT_YEAR_CODE || '2025-26';
// Each entry maps to a checklist Test ID. status: 'fixme' until verified by a human.
const FY_CASES = [
['FY-001', 'Create financial year'],
['FY-002', 'Duplicate FY prevention'],
['FY-003', 'Mark current FY'],
['FY-004', 'Header FY selector persistence'],
['FY-005', 'Logout/login FY reset/default'],
['FY-006', 'Staff tenant/branch from session'],
['FY-007', 'Tenant switch updates session'],
['FY-008', 'Branch switch updates session'],
['FY-009', 'Engagement list active FY'],
['FY-010', 'Direct URL cross-FY engagement'],
['FY-011', 'Task list active FY'],
['FY-012', 'Direct URL cross-FY task update'],
['FY-013', 'Task/engagement document FY path'],
['FY-014', 'Cross-FY document download'],
['FY-015', 'Notice/case list active FY'],
['FY-016', 'Notice engagement link same FY'],
['FY-017', 'Invoice list active FY'],
['FY-018', 'Client portal billing active FY'],
['FY-019', 'Lock financial year'],
['FY-020', 'Blocked writes in locked FY'],
['FY-021', 'Unlock/reopen control'],
['FY-022', 'Generate FY backup ZIP'],
['FY-023', 'Backup export completeness'],
['FY-024', 'Unauthorized backup access'],
['FY-025', 'Backup not in static path'],
['FY-026', 'Context switch auditability'],
['FY-027', 'Fresh PostgreSQL upgrade'],
['FY-028', 'Billing FY backfill'],
['FY-029', 'Public header spoof ignored'],
['FY-030', 'Trusted header requires secret'],
];
test.describe('UAT_FY_Lock_Backup (UNTESTED SCAFFOLDING - all fixme)', () => {
for (const [id, scenario] of FY_CASES) {
// test.fixme keeps these visibly pending and NEVER green until implemented.
test.fixme(`${id} :: ${scenario}`, async ({ page }) => {
// TODO(human-verify): implement against real routes/selectors for this build.
// Reference starting points (verify before trusting):
// list: GET ${FY_BASE}
// create: GET/POST ${FY_BASE}/new
// lock: POST ${FY_BASE}/{fy_id}/lock
// unlock: POST ${FY_BASE}/{fy_id}/unlock
// backup: POST ${FY_BASE}/{fy_id}/backup/export
// download: GET ${FY_BASE}/backups/{export_id}/download
await login(page, 'System Admin');
await page.goto(FY_BASE);
// assertion intentionally omitted -- must be written per case before enabling.
expect(true).toBeTruthy();
});
}
});
+257
View File
@@ -0,0 +1,257 @@
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);
});
});
+43
View File
@@ -0,0 +1,43 @@
const { test, expect, request } = require('@playwright/test');
require('dotenv').config();
const { login, fillFirst, clickFirst } = require('../fixtures/auth');
const { expectBlockedOrSafe, expectNoServerError, expectSecurityHeaders } = require('../fixtures/assertions');
test('VAPT-TARGET-001 security headers on login', async ({ page }) => {
const resp = await page.goto('/login');
await expectSecurityHeaders(resp);
});
test('VAPT-TARGET-002 unauthorized protected URL redirects or blocks', async ({ page }) => {
const resp = await page.goto('/notice-cases');
await expectBlockedOrSafe(page, resp);
});
test('VAPT-TARGET-003 CSRF negative request should not create case', async () => {
const api = await request.newContext({ baseURL: process.env.BASE_URL });
const resp = await api.post('/notice-cases/new', { form: { title: 'csrf-negative', reference_no: 'CSRF-NEG' } });
expect([400,401,403,404,405,422,303].includes(resp.status())).toBeTruthy();
});
test('VAPT-TARGET-004 basic XSS payload should not execute in notice case list', async ({ page }) => {
await login(page, 'System Admin');
await page.goto('/notice-cases');
await expectNoServerError(page);
const body = await page.locator('body').innerText().catch(()=>'');
expect(body).not.toContain('<script>alert(1)</script>');
});
test('VAPT-TARGET-005 OTP brute force does not crash and should throttle/block', async ({ page }) => {
await page.goto('/login');
await fillFirst(page, ['input[name="email"]','input[name="login_id"]','input[type="email"]'], process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local');
await fillFirst(page, ['input[name="password"]','input[type="password"]'], process.env.SYSTEM_ADMIN_PASSWORD || 'admin123');
await clickFirst(page, ['button[type="submit"]','input[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(()=>{});
if (!page.url().includes('/otp')) test.skip(true, 'OTP page not enabled in this environment');
for (let i=0;i<8;i++) {
await fillFirst(page, ['input[name="otp"]','input[name="code"]','input[type="text"]'], '000000');
await clickFirst(page, ['button[type="submit"]','input[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(()=>{});
}
await expectNoServerError(page);
});