const { test, expect, request } = require('@playwright/test'); require('dotenv').config(); const { BASE_URL, absoluteUrl } = require('../fixtures/url'); 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').catch(() => {}); 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); } function sec101TenantBCaseId() { return process.env.TENANT_B_NOTICE_CASE_ID || process.env.CROSS_TENANT_NOTICE_CASE_ID || '7'; } async function expectNoTenantBNoticeLeak(page, resp) { // A correct ERP may reject with 401/403/404/405/422 or redirect away. // If it returns 200/303, the page still must not disclose Tenant B notice data. await expectBlockedOrSafe(page, resp); const body = await page.locator('body').innerText().catch(() => ''); expect(body).not.toMatch(/Tenant B SEC-101 Cross Tenant Notice/i); expect(body).not.toMatch(/UAT-B-NOTICE-SEC101/i); expect(body).not.toMatch(/UAT-NC-B-SEC101/i); expect(body).not.toMatch(/Traceback|Internal Server Error|Exception/i); } async function runSec101CrossTenant(page, context, v) { const tenantBNoticeCaseId = sec101TenantBCaseId(); await login(page, 'System Admin'); // Tenant A system admin from env, e.g. uat.admin@vavalam.com. const variant = String(v.variantType || '').toLowerCase(); const probes = []; if (variant === 'negative') { probes.push(`/notice-cases/${tenantBNoticeCaseId}?tamper=1`); probes.push(`/notice-cases/${tenantBNoticeCaseId}/../../${tenantBNoticeCaseId}`); } else if (variant === 'rbac') { await logout(page); await login(page, 'Staff'); probes.push(`/notice-cases/${tenantBNoticeCaseId}`); } else if (variant === 'idor') { probes.push(`/notice-cases/${tenantBNoticeCaseId}`); probes.push(`/notice-cases/${tenantBNoticeCaseId}/edit`); probes.push(`/notice-cases/${tenantBNoticeCaseId}/documents`); } else if (variant === 'headers') { await context.setExtraHTTPHeaders({ 'X-Tenant-ID': '3', 'X-Branch-ID': '3', 'X-Tenant-Code': 'UAT-B', 'X-Branch-Code': 'UAT-BB', }); probes.push(`/notice-cases/${tenantBNoticeCaseId}`); } else { probes.push(`/notice-cases/${tenantBNoticeCaseId}`); } for (const route of probes) { const resp = await safeGoto(page, route); await expectNoTenantBNoticeLeak(page, resp); } } async function runSecuritySpecific(page, context, v) { const id = v.sourceId; if (/SEC-101/i.test(id)) { await runSec101CrossTenant(page, context, v); return; } 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 || 'uat.admin@vavalam.com'); 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: 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) }); // SEC-101 was marked manual in the generated matrix, but it is now automated here // using a seeded Tenant B notice case. This keeps ERP features unchanged and only // replaces the scaffold/manual handling for these five variants. if (/SEC-101/i.test(v.sourceId || '')) { await runSec101CrossTenant(page, context, v); return; } 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); } }); }