Upgrade Playwright suite to v2.5.1 SQLite deep testing

This commit is contained in:
A R R R Associates
2026-06-24 20:05:36 +05:30
parent 89ba3e85a1
commit ba1075ef19
33 changed files with 16167 additions and 240 deletions
+33 -2
View File
@@ -141,9 +141,9 @@ BRANCH_A_CODE=UAT-BA
BRANCH_B_CODE=UAT-BB
TENANT_B_NAME=UAT Tenant B
TENANT_B_ADMIN_EMAIL=uat.firmadmin@vavalam.com
TENANT_B_ADMIN_EMAIL=uat.firmadminb@vavalam.com
TENANT_B_ADMIN_PASSWORD=Password@123
TENANT_B_ADMIN_IMAP_USER=uat.firmadmin@vavalam.com
TENANT_B_ADMIN_IMAP_USER=uat.firmadminb@vavalam.com
TENANT_B_ADMIN_IMAP_PASSWORD=
# =========================
@@ -186,3 +186,34 @@ CONTEXT_HEADER_SECRET=
MASTER_EXCEL=Audit_Firm_ERP_Master_UAT_VAPT_Checklist.xlsx
UPDATED_EXCEL=results/Audit_Firm_ERP_Master_UAT_VAPT_Checklist_v2_4_Results.xlsx
API_CHECK_RESULTS_FILE=results/api-check-results.json
# =========================
# V2.5 OPTIONAL DIRECT IDS
# These are only needed for direct-ID/deep module tests. If blank, affected tests skip gracefully.
# =========================
EMPLOYEE_A_ID=
EMPLOYEE_B_ID=
PAYROLL_RUN_ID=
SALARY_STRUCTURE_ID=
LEAVE_TYPE_ID=
LEAVE_REQUEST_ID=
ATTENDANCE_ID=
REG_REQUEST_ID=
ONBOARDING_TASK_ID=
OFFBOARDING_REQ_ID=
EMP_DOCUMENT_ID=
INVOICE_A_ID=
PAYMENT_A_ID=
PARTNER_TASK_A_ID=
CONSULTANT_A_ID=
CONSULTANT_B_ID=
CONVERSION_REQUEST_ID=
SERVICE_REQUEST_ID=
CONSULTANT_DOCUMENT_ID=
DOCUMENT_A_ID=
DOCUMENT_B_ID=
CASE_DOCUMENT_ID=
SUBSCRIPTION_A_ID=
TASK_A_ID=
WORK_ITEM_A_ID=
+2
View File
@@ -4,3 +4,5 @@ results/
playwright-report/
test-results/
*.log
*.sqlite
*.db
+8 -1
View File
@@ -9,4 +9,11 @@ COPY . .
RUN mkdir -p results test-results playwright-report
CMD ["bash", "-lc", "node run-api-checks.js && npx playwright test --reporter=list --workers=1; npm run report:json; npm run update:excel; echo 'Tests completed. Starting result server.'; node scripts/result-server.js"]
# SQLite-first execution flow:
# 1. API checks
# 2. Import test matrix into SQLite
# 3. Run Playwright; custom reporter records every test result immediately in SQLite
# 4. Export SQLite to JSON and Excel
# 5. Update original master Excel from merged JSON
# 6. Start single-page result download server on port 3000
CMD ["bash", "-lc", "node run-api-checks.js; npm run db:import-cases; npx playwright test --workers=1; npm run db:summary; npm run db:export-json; npm run db:export-excel; npm run update:excel; echo 'Tests completed. Starting result server.'; node scripts/result-server.js"]
+48
View File
@@ -0,0 +1,48 @@
# v2.5 SQLite Merged Suite
This package merges the v2.4.2 SQLite-first base suite with Claude's v2.5 new-module Playwright tests.
## Coverage retained
- v2.4.2 generated Excel/JSON matrix: 1296 variants.
- v2.5 new module tests added: 142 tests.
- Expected SQLite `test_cases` after import: 1438 rows.
## How the merge avoids duplicate IDs
Some v2.5 tests had IDs that overlap existing matrix IDs, especially `SVC-001`, `DOC-001`, etc. To avoid collisions, every v2.5 test is namespaced in its Playwright title and SQLite `variant_id` as:
```text
V25-<original-id>
```
Example:
```text
[V25-SVC-001] SVC-001 Bulk imports page loads
```
## SQLite-first flow
```text
data/generated-test-matrix.json
+ data/v2_5_test_cases.json
SQLite test_cases
Playwright execution
SQLite test_results / test_attachments
JSON + Excel exports + download dashboard
```
## Important command
Do not override reporters with `--reporter=list`. Run:
```bash
npx playwright test --workers=1
```
The SQLite reporter will record every test as it completes.
+43
View File
@@ -0,0 +1,43 @@
# v2.5.1 Deep Seed Extension
This suite now includes a seed extension for the additional v2.5.1 deep test cases.
## Files
```text
seed/seed_uat_data_v2_5_1.py
```
## Run order inside ERP container
Copy the file into the ERP app under `scripts/` and run:
```bash
cd /app
PYTHONPATH=/app python scripts/seed_uat_data.py
PYTHONPATH=/app python scripts/seed_uat_data_v2_5_1.py
```
The first command is your existing core seed. The second command adds supporting data for the new v2.5.1 tests.
## What it seeds
- Email settings, templates, queue, logs and inbox sample
- Marketplace/public leads including converted lead
- Platform billing plan, account, subscription, invoice and payment
- RBAC limited role and UAT permissions
- Audit log sample events
- Locked previous financial year and backup export placeholder
- Client-visible, deleted and multi-version document samples
- Storage-agent security node/job
- Attendance, leave balance, leave request, payroll run and payslip
- Draft, issued and cancelled billing invoices with payment
- Deep notice case with hearing/event and client/internal note separation
## Idempotency
The extension uses UAT-coded lookup values and can be rerun safely. If a table or column is not present in your current ERP build, it skips that section and prints a warning.
## Important
This is for UAT only. Do not run against live production data.
+89
View File
@@ -0,0 +1,89 @@
# SQLite-first UAT/VAPT Results
This build records Playwright UAT/VAPT execution directly into SQLite.
## Output files
After a run, the important files are:
```text
results/uat_vapt_results.sqlite
results/UAT_VAPT_SQLite_Export.xlsx
results/merged-results.json
results/Audit_Firm_ERP_Master_UAT_VAPT_Checklist_v2_4_Results.xlsx
results/playwright-results.json
```
## How it works
1. `data/generated-test-matrix.json` is imported into SQLite table `test_cases`.
2. A new row is created in `test_runs`.
3. Every Playwright test result is inserted immediately into `test_results` by `reporters/sqlite-reporter.js`.
4. Screenshots, traces, and videos are recorded in `test_attachments`.
5. SQLite is exported to JSON and Excel after the run.
## Important command
Use this command, without overriding the reporter:
```bash
cd /tests
node run-api-checks.js
npm run db:import-cases
npx playwright test --workers=1
npm run db:summary
npm run db:export-json
npm run db:export-excel
npm run update:excel
node scripts/result-server.js
```
Do not run with `--reporter=list`, because that disables the configured SQLite/json/html reporters.
## Query SQLite
Summary:
```bash
sqlite3 results/uat_vapt_results.sqlite "SELECT status, COUNT(*) FROM test_results GROUP BY status;"
```
Latest failures:
```bash
sqlite3 results/uat_vapt_results.sqlite "
SELECT tc.variant_id, tc.module, tc.scenario, tr.status, substr(tr.error_message,1,250) AS error
FROM test_results tr
LEFT JOIN test_cases tc ON tc.variant_id = tr.variant_id
WHERE tr.run_id = (SELECT MAX(id) FROM test_runs)
AND tr.status != 'passed'
ORDER BY tc.module, tc.variant_id
LIMIT 100;
"
```
Not-run cases:
```bash
sqlite3 results/uat_vapt_results.sqlite "
SELECT tc.variant_id, tc.module, tc.scenario
FROM test_cases tc
LEFT JOIN test_results tr
ON tr.variant_id = tc.variant_id
AND tr.run_id = (SELECT MAX(id) FROM test_runs)
WHERE tr.id IS NULL AND tc.automation != 'manual'
ORDER BY tc.variant_id;
"
```
## Result dashboard
The Dockerfile starts `scripts/result-server.js` on port `3000` after tests complete. In Coolify expose port `3000` and protect it using Basic Auth or IP restriction.
Suggested persistent volumes:
```text
/tests/results
/tests/test-results
/tests/playwright-report
```
+33
View File
@@ -0,0 +1,33 @@
# v2.5.1 Deep Gap Tests Added
This release extends the SQLite-first v2.5 merged suite with additional ERP gap tests.
Added automated cases: 302
New spec files:
- tests/api-auth-rbac.spec.js (35 cases)
- tests/audit-logs.spec.js (20 cases)
- tests/billing-business-rules.spec.js (27 cases)
- tests/document-security-deep.spec.js (28 cases)
- tests/email-integration.spec.js (26 cases)
- tests/employee-hr-business-rules.spec.js (20 cases)
- tests/marketplace-leads.spec.js (22 cases)
- tests/notice-case-business-rules.spec.js (23 cases)
- tests/platform-billing.spec.js (35 cases)
- tests/system-settings-tenancy.spec.js (36 cases)
- tests/work-lifecycle-e2e.spec.js (30 cases)
SQLite import now loads both:
- data/v2_5_test_cases.json
- data/v2_5_1_test_cases.json
Do not run with `--reporter=list`; allow `playwright.config.js` to load the SQLite reporter.
Recommended run:
```bash
npm run db:import-cases
npx playwright test --workers=1
npm run db:summary
npm run db:export-json
npm run db:export-excel
```
+30 -3
View File
@@ -1,5 +1,5 @@
{
"suiteVersion": "2.4.0",
"suiteVersion": "2.5.0-sqlite-merged",
"sourceRows": 272,
"generatedVariants": 1296,
"automatedVariants": 1291,
@@ -9,5 +9,32 @@
"fyLockBackupStatus": "UNTESTED - all test.fixme(), require human verification before counting as coverage",
"checklistReconciled": true,
"checklistFile": "Audit_Firm_ERP_Master_UAT_VAPT_Checklist_v2.4.xlsx",
"totalChecklistRows": 302
}
"totalChecklistRows": 302,
"v25NewSpecs": {
"employees": {
"file": "tests/employees.spec.js",
"tests": 53,
"status": "runnable-sqlite-recorded"
},
"partners_billing": {
"file": "tests/partners-billing.spec.js",
"tests": 25,
"status": "runnable-sqlite-recorded"
},
"consultants_documents": {
"file": "tests/consultants-documents.spec.js",
"tests": 30,
"status": "runnable-sqlite-recorded"
},
"noticecases_services_work": {
"file": "tests/noticecases-services-work.spec.js",
"tests": 34,
"status": "runnable-sqlite-recorded"
}
},
"v25TotalNewTests": 142,
"sqliteTotalImportedCasesExpected": 1438,
"v2_5_1_additional_cases": 302,
"v2_5_1_additional_unique_variant_ids": 302,
"total_expected_cases_after_v2_5_1": 1740
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -1,18 +1,19 @@
{
"name": "playwright-full-erp-uat-vapt-suite-v2-3",
"version": "2.4.1",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "playwright-full-erp-uat-vapt-suite-v2-3",
"version": "2.4.1",
"version": "2.5.1",
"dependencies": {
"@playwright/test": "^1.48.2",
"dotenv": "^16.4.5",
"imapflow": "^1.4.2",
"xlsx": "^0.18.5"
}
},
"description": "SQLite-first full ERP UAT/VAPT suite v2.5 with v2.4.2 matrix coverage plus employees, partners, billing, consultants, documents, notice cases, services and work depth tests."
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
@@ -450,4 +451,4 @@
}
}
}
}
}
+27 -4
View File
@@ -1,8 +1,8 @@
{
"name": "playwright-full-erp-uat-vapt-suite-v2-3",
"version": "2.4.1",
"version": "2.5.1",
"private": true,
"description": "Full Excel-mapped UAT + VAPT suite for Audit Firm ERP with v2.0.4 hardening, v2.4 FY-lock scaffolding, matrix-reconciled checklist, and IMAP OTP support.",
"description": "SQLite-first full ERP UAT/VAPT suite v2.5.1 with v2.4.2 matrix, v2.5 modules, and additional deep ERP gap tests.",
"scripts": {
"test": "playwright test",
"test:excel": "playwright test tests/full-excel-variants.spec.js",
@@ -14,7 +14,30 @@
"show-report": "playwright show-report",
"test:v204": "playwright test tests/v204-security-additions.spec.js",
"test:all": "playwright test",
"test:fy": "playwright test tests/fy-lock-backup.spec.js"
"test:fy": "playwright test tests/fy-lock-backup.spec.js",
"db:init": "python3 scripts/sqlite_db.py init",
"db:import-cases": "python3 scripts/sqlite_db.py import-cases",
"db:summary": "python3 scripts/sqlite_db.py summary",
"db:export-json": "python3 scripts/sqlite_db.py export-json --output results/merged-results.json",
"db:export-excel": "python3 scripts/sqlite_db.py export-xlsx --output results/UAT_VAPT_SQLite_Export.xlsx",
"test:sqlite": "npm run db:import-cases; npx playwright test --workers=1; npm run db:export-json; npm run db:export-excel",
"test:employees": "playwright test tests/employees.spec.js",
"test:partners-billing": "playwright test tests/partners-billing.spec.js",
"test:consultants-docs": "playwright test tests/consultants-documents.spec.js",
"test:cases-services-work": "playwright test tests/noticecases-services-work.spec.js",
"test:new-modules": "playwright test tests/employees.spec.js tests/partners-billing.spec.js tests/consultants-documents.spec.js tests/noticecases-services-work.spec.js",
"test:v251-additions": "playwright test tests/api-auth-rbac.spec.js tests/audit-logs.spec.js tests/billing-business-rules.spec.js tests/document-security-deep.spec.js tests/email-integration.spec.js tests/employee-hr-business-rules.spec.js tests/marketplace-leads.spec.js tests/notice-case-business-rules.spec.js tests/platform-billing.spec.js tests/system-settings-tenancy.spec.js tests/work-lifecycle-e2e.spec.js",
"test:email": "playwright test tests/email-integration.spec.js",
"test:marketplace": "playwright test tests/marketplace-leads.spec.js",
"test:platform-billing": "playwright test tests/platform-billing.spec.js",
"test:system-settings": "playwright test tests/system-settings-tenancy.spec.js",
"test:api-auth-rbac": "playwright test tests/api-auth-rbac.spec.js",
"test:audit-logs": "playwright test tests/audit-logs.spec.js",
"test:document-security-deep": "playwright test tests/document-security-deep.spec.js",
"test:work-lifecycle": "playwright test tests/work-lifecycle-e2e.spec.js",
"test:billing-rules": "playwright test tests/billing-business-rules.spec.js",
"test:notice-rules": "playwright test tests/notice-case-business-rules.spec.js",
"test:hr-rules": "playwright test tests/employee-hr-business-rules.spec.js"
},
"dependencies": {
"@playwright/test": "^1.48.2",
@@ -22,4 +45,4 @@
"imapflow": "^1.4.2",
"xlsx": "^0.18.5"
}
}
}
+1
View File
@@ -11,6 +11,7 @@ module.exports = defineConfig({
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['json', { outputFile: 'results/playwright-results.json' }],
['./reporters/sqlite-reporter.js', { dbPath: 'results/uat_vapt_results.sqlite' }],
['list']
],
use: {
+94
View File
@@ -0,0 +1,94 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function runPython(args, inputObj) {
const result = spawnSync('python3', ['scripts/sqlite_db.py', ...args], {
cwd: process.cwd(),
input: inputObj ? JSON.stringify(inputObj) : undefined,
encoding: 'utf8',
env: process.env,
maxBuffer: 1024 * 1024 * 10,
});
if (result.status !== 0) {
const msg = result.stderr || result.stdout || 'Unknown sqlite_db.py error';
throw new Error(msg);
}
const out = (result.stdout || '').trim();
return out ? JSON.parse(out) : {};
}
class SqliteReporter {
constructor(options = {}) {
this.dbPath = options.dbPath || process.env.SQLITE_RESULTS_DB || 'results/uat_vapt_results.sqlite';
this.runName = options.runName || process.env.TEST_RUN_NAME || `Run ${new Date().toISOString()}`;
this.runId = null;
}
onBegin(config, suite) {
fs.mkdirSync(path.dirname(this.dbPath), { recursive: true });
runPython(['--db', this.dbPath, 'init']);
const matrixPath = process.env.TEST_MATRIX_JSON || 'data/generated-test-matrix.json';
if (fs.existsSync(matrixPath)) {
runPython(['--db', this.dbPath, '--matrix', matrixPath, 'import-cases']);
}
const started = runPython(['--db', this.dbPath, '--run-name', this.runName, '--base-url', process.env.BASE_URL || '', 'begin-run']);
this.runId = started.run_id;
console.log(`SQLite reporter writing to ${this.dbPath}; run_id=${this.runId}`);
}
extractVariantId(test) {
const titlePath = test.titlePath ? test.titlePath() : [test.title];
const fullTitle = titlePath.join(' ');
const bracket = fullTitle.match(/\[([^\]]+)\]/);
if (bracket) return bracket[1];
const fallback = fullTitle.match(/((?:V25-)?[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)/);
return fallback ? fallback[1] : '';
}
onTestEnd(test, result) {
const location = test.location || {};
const attachments = (result.attachments || []).map(a => ({
name: a.name || '',
contentType: a.contentType || '',
path: a.path || '',
}));
const record = {
run_id: this.runId,
variant_id: this.extractVariantId(test),
title: test.title,
title_path: test.titlePath ? test.titlePath() : [test.title],
project_name: test.parent && test.parent.project ? (test.parent.project()?.name || '') : '',
location,
status: result.status,
expected_status: test.expectedStatus,
duration_ms: result.duration || 0,
retry: result.retry || 0,
error: result.error ? { message: result.error.message || '', stack: result.error.stack || '' } : null,
stdout: (result.stdout || []).map(x => Buffer.isBuffer(x) ? x.toString('utf8') : String(x)).join('\n'),
stderr: (result.stderr || []).map(x => Buffer.isBuffer(x) ? x.toString('utf8') : String(x)).join('\n'),
attachments,
started_at: result.startTime ? result.startTime.toISOString() : null,
finished_at: new Date().toISOString(),
};
try {
runPython(['--db', this.dbPath, 'record-result'], record);
} catch (e) {
console.error('Failed to record SQLite test result:', e.message);
}
}
onEnd(result) {
if (!this.runId) return;
try {
runPython(['--db', this.dbPath, '--run-id', String(this.runId), '--status', result.status || 'completed', 'finish-run']);
runPython(['--db', this.dbPath, '--run-id', String(this.runId), '--output', 'results/merged-results.json', 'export-json']);
runPython(['--db', this.dbPath, '--run-id', String(this.runId), '--output', 'results/UAT_VAPT_SQLite_Export.xlsx', 'export-xlsx']);
console.log(`SQLite run ${this.runId} finished and exported.`);
} catch (e) {
console.error('Failed to finish/export SQLite run:', e.message);
}
}
}
module.exports = SqliteReporter;
+24 -2
View File
@@ -1,8 +1,30 @@
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const sqliteDb = process.env.SQLITE_RESULTS_DB || 'results/uat_vapt_results.sqlite';
const outPath = 'results/merged-results.json';
function exportFromSqliteIfAvailable() {
if (!fs.existsSync(sqliteDb)) return false;
const result = spawnSync('python3', ['scripts/sqlite_db.py', '--db', sqliteDb, '--output', outPath, 'export-json'], {
cwd: process.cwd(),
encoding: 'utf8',
env: process.env,
});
if (result.status === 0) {
console.log((result.stdout || '').trim());
console.log(`Wrote ${outPath} from SQLite database ${sqliteDb}`);
return true;
}
console.warn('SQLite export failed, falling back to Playwright JSON:', result.stderr || result.stdout);
return false;
}
if (exportFromSqliteIfAvailable()) process.exit(0);
const matrix = JSON.parse(fs.readFileSync('data/generated-test-matrix.json','utf8'));
const resultPath = 'results/playwright-results.json';
const outPath = 'results/merged-results.json';
let results = {};
if (fs.existsSync(resultPath)) {
const raw = JSON.parse(fs.readFileSync(resultPath,'utf8'));
@@ -21,4 +43,4 @@ if (fs.existsSync(resultPath)) {
const merged = matrix.map(v => ({...v, result: results[v.variantId] || (v.automation === 'manual' ? 'manual' : 'not_run')}));
fs.mkdirSync(path.dirname(outPath), {recursive:true});
fs.writeFileSync(outPath, JSON.stringify(merged,null,2));
console.log(`Wrote ${outPath}`);
console.log(`Wrote ${outPath} from Playwright JSON fallback`);
+41 -224
View File
@@ -1,38 +1,23 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = Number(process.env.RESULT_SERVER_PORT || 3000);
const ROOT = process.env.RESULT_ROOT || "/tests";
const ROOT = process.env.RESULT_ROOT || process.cwd();
const FILES = [
{
title: "Updated Excel Result",
path: "results/Audit_Firm_ERP_Master_UAT_VAPT_Checklist_v2_4_Results.xlsx",
type: "Excel",
},
{
title: "Merged Results JSON",
path: "results/merged-results.json",
type: "JSON",
},
{
title: "API Check Results JSON",
path: "results/api-check-results.json",
type: "JSON",
},
{
title: "Playwright Report Index",
path: "playwright-report/index.html",
type: "HTML",
},
{ title: 'SQLite Database', path: 'results/uat_vapt_results.sqlite', type: 'SQLite' },
{ title: 'SQLite Excel Export', path: 'results/UAT_VAPT_SQLite_Export.xlsx', type: 'Excel' },
{ title: 'Updated Master Excel Result', path: 'results/Audit_Firm_ERP_Master_UAT_VAPT_Checklist_v2_4_Results.xlsx', type: 'Excel' },
{ title: 'Merged Results JSON', path: 'results/merged-results.json', type: 'JSON' },
{ title: 'Playwright Results JSON', path: 'results/playwright-results.json', type: 'JSON' },
{ title: 'API Check Results JSON', path: 'results/api-check-results.json', type: 'JSON' },
{ title: 'Playwright Report Index', path: 'playwright-report/index.html', type: 'HTML' },
];
function safeJoin(relativePath) {
const fullPath = path.resolve(ROOT, relativePath);
if (!fullPath.startsWith(path.resolve(ROOT))) {
throw new Error("Invalid path");
}
if (!fullPath.startsWith(path.resolve(ROOT))) throw new Error('Invalid path');
return fullPath;
}
@@ -40,218 +25,50 @@ function fileInfo(relativePath) {
try {
const full = safeJoin(relativePath);
const stat = fs.statSync(full);
return {
exists: true,
size: stat.size,
mtime: stat.mtime,
};
} catch {
return {
exists: false,
size: 0,
mtime: null,
};
}
return { exists: true, size: stat.size, mtime: stat.mtime };
} catch { return { exists: false, size: 0, mtime: null }; }
}
function formatBytes(bytes) {
if (!bytes) return "-";
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size = size / 1024;
unit++;
}
return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
if (!bytes) return '-';
const units = ['B','KB','MB','GB']; let size = bytes; let u = 0;
while (size >= 1024 && u < units.length - 1) { size /= 1024; u++; }
return `${size.toFixed(u === 0 ? 0 : 2)} ${units[u]}`;
}
function contentType(filePath) {
if (filePath.endsWith(".xlsx")) {
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
if (filePath.endsWith(".json")) return "application/json";
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".png")) return "image/png";
if (filePath.endsWith(".webm")) return "video/webm";
if (filePath.endsWith(".zip")) return "application/zip";
return "application/octet-stream";
if (filePath.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
if (filePath.endsWith('.sqlite') || filePath.endsWith('.db')) return 'application/vnd.sqlite3';
if (filePath.endsWith('.json')) return 'application/json';
if (filePath.endsWith('.html')) return 'text/html; charset=utf-8';
if (filePath.endsWith('.png')) return 'image/png';
if (filePath.endsWith('.webm')) return 'video/webm';
if (filePath.endsWith('.zip')) return 'application/zip';
return 'application/octet-stream';
}
function renderHome() {
const rows = FILES.map((item) => {
const rows = FILES.map(item => {
const info = fileInfo(item.path);
const status = info.exists ? "Available" : "Missing";
const downloadLink = info.exists
? `<a class="btn" href="/download?file=${encodeURIComponent(item.path)}">Download</a>`
: `<span class="btn disabled">Not available</span>`;
return `
<tr>
<td>${item.title}</td>
<td>${item.type}</td>
<td class="${info.exists ? "ok" : "bad"}">${status}</td>
<td>${formatBytes(info.size)}</td>
<td>${info.mtime ? info.mtime.toLocaleString() : "-"}</td>
<td>${downloadLink}</td>
</tr>
`;
}).join("");
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Playwright UAT/VAPT Results</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 32px;
background: #f7f7f7;
color: #222;
}
.card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 10px rgba(0,0,0,.08);
max-width: 1100px;
margin: auto;
}
h1 {
margin-top: 0;
font-size: 26px;
}
.sub {
color: #666;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
}
th, td {
padding: 12px;
border-bottom: 1px solid #e5e5e5;
text-align: left;
font-size: 14px;
}
th {
background: #fafafa;
}
.ok {
color: #0a7a2f;
font-weight: bold;
}
.bad {
color: #b00020;
font-weight: bold;
}
.btn {
display: inline-block;
padding: 8px 12px;
background: #1f6feb;
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 13px;
}
.disabled {
background: #999;
pointer-events: none;
}
.note {
margin-top: 18px;
padding: 12px;
background: #fff8dc;
border: 1px solid #f0dc8c;
border-radius: 8px;
font-size: 14px;
}
code {
background: #eee;
padding: 2px 5px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="card">
<h1>Playwright UAT/VAPT Results</h1>
<div class="sub">Last refreshed: ${new Date().toLocaleString()}</div>
<table>
<thead>
<tr>
<th>File</th>
<th>Type</th>
<th>Status</th>
<th>Size</th>
<th>Modified</th>
<th>Action</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
<div class="note">
Keep this page private. Test reports may contain screenshots, URLs, user emails, and error details.
Recommended Coolify port: <code>3000</code>.
</div>
</div>
</body>
</html>`;
const action = info.exists ? `<a class="btn" href="/download?file=${encodeURIComponent(item.path)}">Download</a>` : '<span class="btn disabled">Missing</span>';
return `<tr><td>${item.title}</td><td>${item.type}</td><td class="${info.exists ? 'ok':'bad'}">${info.exists ? 'Available':'Missing'}</td><td>${formatBytes(info.size)}</td><td>${info.mtime ? info.mtime.toLocaleString() : '-'}</td><td>${action}</td></tr>`;
}).join('');
return `<!doctype html><html><head><meta charset="utf-8"><title>Playwright UAT/VAPT Results</title><style>
body{font-family:Arial,sans-serif;margin:32px;background:#f7f7f7;color:#222}.card{background:#fff;border-radius:12px;padding:24px;box-shadow:0 2px 10px rgba(0,0,0,.08);max-width:1150px;margin:auto}h1{margin-top:0}.sub{color:#666;margin-bottom:20px}table{width:100%;border-collapse:collapse}th,td{padding:12px;border-bottom:1px solid #e5e5e5;text-align:left;font-size:14px}th{background:#fafafa}.ok{color:#0a7a2f;font-weight:bold}.bad{color:#b00020;font-weight:bold}.btn{display:inline-block;padding:8px 12px;background:#1f6feb;color:#fff;text-decoration:none;border-radius:6px;font-size:13px}.disabled{background:#999;pointer-events:none}.note{margin-top:18px;padding:12px;background:#fff8dc;border:1px solid #f0dc8c;border-radius:8px;font-size:14px}code{background:#eee;padding:2px 5px;border-radius:4px}
</style></head><body><div class="card"><h1>Playwright UAT/VAPT Results</h1><div class="sub">Last refreshed: ${new Date().toLocaleString()}</div><table><thead><tr><th>File</th><th>Type</th><th>Status</th><th>Size</th><th>Modified</th><th>Action</th></tr></thead><tbody>${rows}</tbody></table><div class="note">Keep this page private. Reports may contain screenshots, URLs, user emails, and error details. Recommended port: <code>3000</code>.</div></div></body></html>`;
}
function sendFile(res, relativePath) {
let fullPath;
try {
fullPath = safeJoin(relativePath);
} catch {
res.writeHead(400);
res.end("Invalid file path");
return;
}
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
res.writeHead(404);
res.end("File not found");
return;
}
res.writeHead(200, {
"Content-Type": contentType(fullPath),
"Content-Disposition": `attachment; filename="${path.basename(fullPath)}"`,
});
try { fullPath = safeJoin(relativePath); } catch { res.writeHead(400); res.end('Invalid file path'); return; }
if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': contentType(fullPath), 'Content-Disposition': `attachment; filename="${path.basename(fullPath)}"` });
fs.createReadStream(fullPath).pipe(res);
}
const server = http.createServer((req, res) => {
http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === "/") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(renderHome());
return;
}
if (url.pathname === "/download") {
const file = url.searchParams.get("file");
if (!file) {
res.writeHead(400);
res.end("Missing file parameter");
return;
}
sendFile(res, file);
return;
}
res.writeHead(404);
res.end("Not found");
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`Playwright result server running on http://0.0.0.0:${PORT}`);
});
if (url.pathname === '/') { res.writeHead(200, {'Content-Type':'text/html; charset=utf-8'}); res.end(renderHome()); return; }
if (url.pathname === '/download') { const file = url.searchParams.get('file'); if (!file) { res.writeHead(400); res.end('Missing file parameter'); return; } sendFile(res, file); return; }
res.writeHead(404); res.end('Not found');
}).listen(PORT, '0.0.0.0', () => console.log(`Result server running on http://0.0.0.0:${PORT}`));
+531
View File
@@ -0,0 +1,531 @@
#!/usr/bin/env python3
"""SQLite-first UAT/VAPT result database helper.
No third-party Python packages are required. It can:
- create schema
- import generated Playwright matrix as test_cases
- create a test run
- record every Playwright test result immediately
- finish a run and update counts
- export latest run to JSON and a simple XLSX workbook
"""
import argparse
import csv
import datetime as dt
import html
import json
import os
import re
import sqlite3
import sys
import zipfile
from pathlib import Path
DB_DEFAULT = os.environ.get("SQLITE_RESULTS_DB", "results/uat_vapt_results.sqlite")
MATRIX_DEFAULT = os.environ.get("TEST_MATRIX_JSON", "data/generated-test-matrix.json")
EXTRA_CASES_DEFAULT = os.environ.get("EXTRA_TEST_CASES_JSON", "data/v2_5_test_cases.json,data/v2_5_1_test_cases.json")
def now_iso():
return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def connect(db_path: str):
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def init_db(conn):
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS test_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id TEXT,
variant_id TEXT UNIQUE,
sheet TEXT,
module TEXT,
role TEXT,
scenario TEXT,
steps TEXT,
expected TEXT,
priority TEXT,
type TEXT,
route TEXT,
variant_name TEXT,
variant_type TEXT,
automation TEXT,
manual_reason TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS test_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_name TEXT,
base_url TEXT,
status TEXT,
started_at TEXT DEFAULT CURRENT_TIMESTAMP,
finished_at TEXT,
total_cases INTEGER DEFAULT 0,
executed_count INTEGER DEFAULT 0,
passed_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
skipped_count INTEGER DEFAULT 0,
timedout_count INTEGER DEFAULT 0,
interrupted_count INTEGER DEFAULT 0,
not_run_count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS test_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
variant_id TEXT,
test_title TEXT,
title_path TEXT,
project_name TEXT,
file TEXT,
line INTEGER,
column INTEGER,
status TEXT,
expected_status TEXT,
duration_ms INTEGER,
retry INTEGER DEFAULT 0,
error_message TEXT,
error_stack TEXT,
stdout TEXT,
stderr TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(run_id) REFERENCES test_runs(id),
FOREIGN KEY(variant_id) REFERENCES test_cases(variant_id)
);
CREATE TABLE IF NOT EXISTS test_attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
result_id INTEGER NOT NULL,
run_id INTEGER NOT NULL,
variant_id TEXT,
attachment_name TEXT,
content_type TEXT,
file_path TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(result_id) REFERENCES test_results(id),
FOREIGN KEY(run_id) REFERENCES test_runs(id)
);
CREATE INDEX IF NOT EXISTS idx_test_cases_variant_id ON test_cases(variant_id);
CREATE INDEX IF NOT EXISTS idx_test_results_run_id ON test_results(run_id);
CREATE INDEX IF NOT EXISTS idx_test_results_variant_id ON test_results(variant_id);
CREATE INDEX IF NOT EXISTS idx_test_results_status ON test_results(status);
CREATE INDEX IF NOT EXISTS idx_test_attachments_run_id ON test_attachments(run_id);
"""
)
conn.commit()
def norm(v):
return "" if v is None else str(v)
def _load_case_rows(path: str):
if not path or not Path(path).exists():
return []
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def import_cases(conn, matrix_path: str):
"""Import primary generated matrix plus optional v2.5/additional test-case JSON.
matrix_path may be a comma-separated list. In addition, EXTRA_TEST_CASES_JSON
defaults to data/v2_5_test_cases.json when present.
"""
init_db(conn)
paths = []
for part in str(matrix_path or "").split(','):
part = part.strip()
if part:
paths.append(part)
extra = EXTRA_CASES_DEFAULT
for part in str(extra or "").split(','):
part = part.strip()
if part and part not in paths:
paths.append(part)
sql = """
INSERT INTO test_cases (
source_id, variant_id, sheet, module, role, scenario, steps, expected,
priority, type, route, variant_name, variant_type, automation, manual_reason,
is_active, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)
ON CONFLICT(variant_id) DO UPDATE SET
source_id=excluded.source_id,
sheet=excluded.sheet,
module=excluded.module,
role=excluded.role,
scenario=excluded.scenario,
steps=excluded.steps,
expected=excluded.expected,
priority=excluded.priority,
type=excluded.type,
route=excluded.route,
variant_name=excluded.variant_name,
variant_type=excluded.variant_type,
automation=excluded.automation,
manual_reason=excluded.manual_reason,
is_active=1,
updated_at=excluded.updated_at
"""
ts = now_iso()
count = 0
for path in paths:
rows = _load_case_rows(path)
for r in rows:
variant_id = r.get("variantId") or r.get("variant_id") or ""
if not variant_id:
continue
conn.execute(sql, (
norm(r.get("sourceId") or r.get("source_id")),
norm(variant_id),
norm(r.get("sheet")),
norm(r.get("module")),
norm(r.get("role")),
norm(r.get("scenario")),
norm(r.get("steps")),
norm(r.get("expected")),
norm(r.get("priority")),
norm(r.get("type")),
norm(r.get("route")),
norm(r.get("variantName") or r.get("variant_name")),
norm(r.get("variantType") or r.get("variant_type")),
norm(r.get("automation")),
norm(r.get("manualReason") or r.get("manual_reason")),
ts,
))
count += 1
conn.commit()
return count
def begin_run(conn, run_name: str, base_url: str):
init_db(conn)
total_cases = conn.execute("SELECT COUNT(*) AS c FROM test_cases WHERE is_active=1").fetchone()["c"]
cur = conn.execute(
"INSERT INTO test_runs (run_name, base_url, status, started_at, total_cases) VALUES (?, ?, ?, ?, ?)",
(run_name, base_url, "running", now_iso(), total_cases),
)
conn.commit()
return cur.lastrowid
def record_result(conn, record: dict):
init_db(conn)
run_id = int(record.get("run_id") or record.get("runId") or 0)
if not run_id:
raise ValueError("run_id is required")
variant_id = record.get("variant_id") or record.get("variantId") or extract_variant_id(" ".join(record.get("title_path") or record.get("titlePath") or []))
if isinstance(record.get("title_path"), list):
title_path = json.dumps(record.get("title_path"), ensure_ascii=False)
elif isinstance(record.get("titlePath"), list):
title_path = json.dumps(record.get("titlePath"), ensure_ascii=False)
else:
title_path = norm(record.get("title_path") or record.get("titlePath"))
err = record.get("error") or {}
if isinstance(err, str):
error_message, error_stack = err, ""
else:
error_message = norm(err.get("message"))
error_stack = norm(err.get("stack"))
loc = record.get("location") or {}
cur = conn.execute(
"""
INSERT INTO test_results (
run_id, variant_id, test_title, title_path, project_name, file, line, column,
status, expected_status, duration_ms, retry, error_message, error_stack,
stdout, stderr, started_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run_id,
norm(variant_id),
norm(record.get("title") or record.get("test_title")),
title_path,
norm(record.get("project_name") or record.get("projectName")),
norm(loc.get("file") or record.get("file")),
int(loc.get("line") or record.get("line") or 0),
int(loc.get("column") or record.get("column") or 0),
norm(record.get("status")),
norm(record.get("expected_status") or record.get("expectedStatus")),
int(record.get("duration_ms") or record.get("duration") or 0),
int(record.get("retry") or 0),
error_message,
error_stack,
norm(record.get("stdout")),
norm(record.get("stderr")),
norm(record.get("started_at") or record.get("startedAt")),
norm(record.get("finished_at") or record.get("finishedAt") or now_iso()),
),
)
result_id = cur.lastrowid
for a in record.get("attachments") or []:
conn.execute(
"""
INSERT INTO test_attachments (result_id, run_id, variant_id, attachment_name, content_type, file_path)
VALUES (?, ?, ?, ?, ?, ?)
""",
(result_id, run_id, norm(variant_id), norm(a.get("name")), norm(a.get("contentType") or a.get("content_type")), norm(a.get("path") or a.get("file_path"))),
)
conn.commit()
return result_id
def finish_run(conn, run_id: int, status: str):
init_db(conn)
rows = conn.execute("SELECT status, COUNT(*) AS c FROM test_results WHERE run_id=? GROUP BY status", (run_id,)).fetchall()
counts = {r["status"]: r["c"] for r in rows}
executed = sum(counts.values())
total_cases = conn.execute("SELECT COUNT(*) AS c FROM test_cases WHERE is_active=1").fetchone()["c"]
not_run = max(total_cases - executed, 0)
conn.execute(
"""
UPDATE test_runs SET
status=?, finished_at=?, total_cases=?, executed_count=?, passed_count=?, failed_count=?,
skipped_count=?, timedout_count=?, interrupted_count=?, not_run_count=?
WHERE id=?
""",
(
status,
now_iso(),
total_cases,
executed,
counts.get("passed", 0),
counts.get("failed", 0),
counts.get("skipped", 0),
counts.get("timedOut", 0) + counts.get("timedout", 0),
counts.get("interrupted", 0),
not_run,
run_id,
),
)
conn.commit()
return counts
def latest_run_id(conn):
row = conn.execute("SELECT id FROM test_runs ORDER BY id DESC LIMIT 1").fetchone()
return row["id"] if row else None
def merged_rows(conn, run_id: int):
# Use the latest result per variant_id within the run. Retries become later rows; last row wins.
sql = """
WITH latest AS (
SELECT tr.*
FROM test_results tr
JOIN (
SELECT variant_id, MAX(id) AS max_id
FROM test_results
WHERE run_id=?
GROUP BY variant_id
) x ON x.max_id = tr.id
)
SELECT
tc.source_id AS sourceId,
tc.sheet,
tc.module,
tc.role,
tc.scenario,
tc.steps,
tc.expected,
tc.priority,
tc.type,
tc.route,
tc.variant_id AS variantId,
tc.variant_name AS variantName,
tc.variant_type AS variantType,
tc.automation,
COALESCE(latest.status, CASE WHEN tc.automation='manual' THEN 'manual' ELSE 'not_run' END) AS result,
latest.duration_ms AS durationMs,
latest.retry,
latest.error_message AS errorMessage,
latest.started_at AS startedAt,
latest.finished_at AS finishedAt
FROM test_cases tc
LEFT JOIN latest ON latest.variant_id = tc.variant_id
WHERE tc.is_active=1
ORDER BY tc.sheet, tc.source_id, tc.variant_id
"""
return [dict(r) for r in conn.execute(sql, (run_id,)).fetchall()]
def export_merged_json(conn, run_id: int, output_path: str):
rows = merged_rows(conn, run_id)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(rows, f, indent=2, ensure_ascii=False)
return len(rows)
def export_csv(conn, run_id: int, output_path: str):
rows = merged_rows(conn, run_id)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", newline="", encoding="utf-8") as f:
if rows:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
return len(rows)
def xlsx_col(n):
s = ""
while n:
n, r = divmod(n - 1, 26)
s = chr(65 + r) + s
return s
def sheet_xml(rows):
out = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>', '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>']
for r_idx, row in enumerate(rows, start=1):
out.append(f'<row r="{r_idx}">')
for c_idx, val in enumerate(row, start=1):
ref = f"{xlsx_col(c_idx)}{r_idx}"
if val is None:
val = ""
# Keep everything as inline string for maximum compatibility with no sharedStrings table.
txt = html.escape(str(val), quote=False)
out.append(f'<c r="{ref}" t="inlineStr"><is><t>{txt}</t></is></c>')
out.append('</row>')
out.append('</sheetData></worksheet>')
return "".join(out)
def write_xlsx(path_out: str, sheets: dict):
Path(path_out).parent.mkdir(parents=True, exist_ok=True)
names = list(sheets.keys())
with zipfile.ZipFile(path_out, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("[Content_Types].xml", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
""" + "".join(f'<Override PartName="/xl/worksheets/sheet{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' for i in range(1, len(names)+1)) + "</Types>")
z.writestr("_rels/.rels", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>""")
z.writestr("xl/workbook.xml", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>""" + "".join(f'<sheet name="{html.escape(name[:31])}" sheetId="{i}" r:id="rId{i}"/>' for i, name in enumerate(names, start=1)) + "</sheets></workbook>")
z.writestr("xl/_rels/workbook.xml.rels", """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">""" + "".join(f'<Relationship Id="rId{i}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{i}.xml"/>' for i in range(1, len(names)+1)) + "</Relationships>")
for i, name in enumerate(names, start=1):
z.writestr(f"xl/worksheets/sheet{i}.xml", sheet_xml(sheets[name]))
def rows_to_aoa(rows):
if not rows:
return []
headers = list(rows[0].keys())
return [headers] + [[r.get(h, "") for h in headers] for r in rows]
def export_xlsx(conn, run_id: int, output_path: str):
run = conn.execute("SELECT * FROM test_runs WHERE id=?", (run_id,)).fetchone()
summary = conn.execute("SELECT status, COUNT(*) AS count FROM test_results WHERE run_id=? GROUP BY status", (run_id,)).fetchall()
results = merged_rows(conn, run_id)
attachments = conn.execute(
"""
SELECT tr.variant_id, tr.test_title, ta.attachment_name, ta.content_type, ta.file_path
FROM test_attachments ta
JOIN test_results tr ON tr.id = ta.result_id
WHERE ta.run_id=?
ORDER BY tr.variant_id, ta.attachment_name
""",
(run_id,),
).fetchall()
failures = [r for r in results if r.get("result") not in ("passed", "manual", "skipped")]
sheets = {
"Run": rows_to_aoa([dict(run)] if run else []),
"Summary": rows_to_aoa([dict(r) for r in summary]),
"Failures": rows_to_aoa(failures),
"Results": rows_to_aoa(results),
"Attachments": rows_to_aoa([dict(r) for r in attachments]),
}
write_xlsx(output_path, sheets)
return len(results)
def extract_variant_id(text: str):
text = text or ""
m = re.search(r"\[([^\]]+)\]", text)
if m:
return m.group(1)
# Fallback for legacy/custom tests whose title starts with an ID.
m = re.search(r"\b((?:V25-)?[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)+)\b", text)
return m.group(1) if m else ""
def main():
p = argparse.ArgumentParser()
p.add_argument("command", choices=["init", "import-cases", "begin-run", "record-result", "finish-run", "export-json", "export-csv", "export-xlsx", "summary"])
p.add_argument("--db", default=DB_DEFAULT)
p.add_argument("--matrix", default=MATRIX_DEFAULT)
p.add_argument("--run-id", type=int)
p.add_argument("--run-name", default=os.environ.get("TEST_RUN_NAME") or f"Run {now_iso()}")
p.add_argument("--base-url", default=os.environ.get("BASE_URL", ""))
p.add_argument("--status", default="completed")
p.add_argument("--output", default="")
args = p.parse_args()
conn = connect(args.db)
try:
if args.command == "init":
init_db(conn)
print(json.dumps({"ok": True, "db": args.db}))
elif args.command == "import-cases":
count = import_cases(conn, args.matrix)
print(json.dumps({"ok": True, "imported": count, "db": args.db}))
elif args.command == "begin-run":
run_id = begin_run(conn, args.run_name, args.base_url)
print(json.dumps({"ok": True, "run_id": run_id}))
elif args.command == "record-result":
record = json.load(sys.stdin)
result_id = record_result(conn, record)
print(json.dumps({"ok": True, "result_id": result_id}))
elif args.command == "finish-run":
run_id = args.run_id or latest_run_id(conn)
counts = finish_run(conn, run_id, args.status)
print(json.dumps({"ok": True, "run_id": run_id, "counts": counts}))
elif args.command == "export-json":
run_id = args.run_id or latest_run_id(conn)
output = args.output or "results/merged-results.json"
count = export_merged_json(conn, run_id, output)
print(json.dumps({"ok": True, "run_id": run_id, "rows": count, "output": output}))
elif args.command == "export-csv":
run_id = args.run_id or latest_run_id(conn)
output = args.output or "results/UAT_VAPT_SQLite_Export.csv"
count = export_csv(conn, run_id, output)
print(json.dumps({"ok": True, "run_id": run_id, "rows": count, "output": output}))
elif args.command == "export-xlsx":
run_id = args.run_id or latest_run_id(conn)
output = args.output or "results/UAT_VAPT_SQLite_Export.xlsx"
count = export_xlsx(conn, run_id, output)
print(json.dumps({"ok": True, "run_id": run_id, "rows": count, "output": output}))
elif args.command == "summary":
run_id = args.run_id or latest_run_id(conn)
rows = conn.execute("SELECT status, COUNT(*) AS count FROM test_results WHERE run_id=? GROUP BY status", (run_id,)).fetchall()
print(json.dumps({r["status"]: r["count"] for r in rows}, indent=2))
finally:
conn.close()
if __name__ == "__main__":
main()
+514
View File
@@ -0,0 +1,514 @@
"""
Audit Firm ERP -- UAT seed extension for Playwright v2.5.1 Deep SQLite suite.
Purpose:
Extend the existing core UAT seed with supporting records for the new v2.5.1
deep tests: email integration, marketplace leads, platform billing, RBAC,
audit logs, document security, HR, billing rules, notice-case rules and
financial-year locking.
Run inside ERP container, AFTER the core seed:
cd /app
PYTHONPATH=/app python scripts/seed_uat_data.py
PYTHONPATH=/app python scripts/seed_uat_data_v2_5_1.py
Safe/idempotent for UAT: it inserts or updates only UAT-coded records where the
matching tables/columns exist. If a module table is not present in this ERP build,
the script skips that area and prints a warning instead of failing.
Do NOT run against live production data.
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
from datetime import date, datetime, timedelta, timezone
UAT_DOMAIN = os.getenv("UAT_EMAIL_DOMAIN", "vavalam.com")
ACTIVE_FY = os.getenv("ACTIVE_FY", "2025-26")
PREVIOUS_FY = os.getenv("PREVIOUS_FY", "2024-25")
ASSESSMENT_YEAR = os.getenv("ASSESSMENT_YEAR", "2026-27")
LSA_SECRET = os.getenv("UAT_LSA_SECRET", "uat-local-storage-agent-secret")
SMTP_PASSWORD = os.getenv("UAT_SMTP_PASSWORD", "Pass@123##")
def fail(msg: str) -> None:
print(f"\n[v2.5.1 seed] ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def today() -> date:
return date.today()
def main() -> None:
try:
from sqlalchemy import MetaData, and_, inspect, select, update
from app.core.db.common import CommonSessionLocal
except Exception as exc:
fail(
"Could not import ERP DB modules. Run this from ERP project root inside the app container. "
f"Underlying import error: {exc!r}"
)
db = CommonSessionLocal()
engine = db.get_bind()
meta = MetaData()
meta.reflect(bind=engine)
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
report: list[str] = []
ids: dict[str, object] = {}
def norm(name: str) -> str:
return name.lower().replace("_", "").replace("-", "")
def find_table(*candidates: str):
# exact match first
for name in candidates:
if name in meta.tables:
return meta.tables[name]
# normalized exact match
normalized = {norm(t): t for t in table_names}
for name in candidates:
hit = normalized.get(norm(name))
if hit:
return meta.tables[hit]
# token contains fallback
for name in candidates:
tokens = [tok for tok in name.lower().replace("-", "_").split("_") if tok]
for t in table_names:
low = t.lower()
if all(tok in low for tok in tokens):
return meta.tables[t]
return None
def valid(table, data: dict) -> dict:
if table is None:
return {}
cols = set(table.c.keys())
return {k: v for k, v in data.items() if k in cols}
def one(table, lookup: dict):
if table is None:
return None
data = valid(table, lookup)
if not data:
return None
stmt = select(table).where(and_(*[table.c[k] == v for k, v in data.items()]))
return db.execute(stmt).mappings().first()
def first(table, **lookup):
return one(table, lookup)
def first_by_any(table, lookups: list[dict]):
for lookup in lookups:
row = one(table, lookup)
if row:
return row
return None
def upsert(table, lookup: dict, defaults: dict | None = None, label: str = "record"):
if table is None:
report.append(f"SKIP {label}: table missing")
return None
lookup_v = valid(table, lookup)
if not lookup_v:
report.append(f"SKIP {label}: no matching lookup columns in {table.name}")
return None
defaults_v = valid(table, defaults or {})
row = one(table, lookup_v)
if row:
if defaults_v:
db.execute(update(table).where(and_(*[table.c[k] == v for k, v in lookup_v.items()])).values(**defaults_v))
db.flush()
return one(table, lookup_v)
data = {**lookup_v, **defaults_v}
result = db.execute(table.insert().values(**data))
db.flush()
pk = result.inserted_primary_key[0] if result.inserted_primary_key else None
if pk and "id" in table.c:
return one(table, {"id": pk})
return one(table, lookup_v)
def update_existing(table, lookup: dict, values: dict, label: str):
if table is None:
report.append(f"SKIP {label}: table missing")
return None
lookup_v = valid(table, lookup)
values_v = valid(table, values)
if not lookup_v or not values_v:
report.append(f"SKIP {label}: no matching columns in {table.name}")
return None
db.execute(update(table).where(and_(*[table.c[k] == v for k, v in lookup_v.items()])).values(**values_v))
db.flush()
return one(table, lookup_v)
def val(row, key: str, default=None):
return row[key] if row and key in row else default
# Core table references from earlier seed
tenants = find_table("tenants", "tenant")
branches = find_table("branches", "branch")
users = find_table("users", "user")
clients = find_table("clients", "client")
employees = find_table("employees", "employee")
financial_years = find_table("financial_years", "financial_year")
documents = find_table("engagement_documents", "documents", "client_documents")
document_versions = find_table("engagement_document_versions", "document_versions")
storage_nodes = find_table("branch_storage_nodes", "storage_nodes")
storage_jobs = find_table("document_storage_jobs", "storage_jobs")
notice_cases = find_table("notice_cases", "cases")
subscriptions = find_table("client_service_subscriptions", "subscriptions", "engagements")
task_instances = find_table("client_service_task_instances", "task_instances", "tasks")
services = find_table("service_catalogue", "service_catalogues", "services")
roles = find_table("roles", "rbac_roles")
permissions = find_table("permissions", "rbac_permissions")
role_permissions = find_table("role_permissions", "rbac_role_permissions")
tenant_a = first_by_any(tenants, [{"code": "UAT-A"}, {"tenant_code": "UAT-A"}, {"name": "UAT Tenant A"}])
tenant_b = first_by_any(tenants, [{"code": "UAT-B"}, {"tenant_code": "UAT-B"}, {"name": "UAT Tenant B"}])
branch_a = first_by_any(branches, [{"code": "UAT-BA"}, {"branch_code": "UAT-BA"}, {"name": "UAT Branch A"}])
branch_b = first_by_any(branches, [{"code": "UAT-BB"}, {"branch_code": "UAT-BB"}, {"name": "UAT Branch B"}])
firm_admin = first(users, email=f"uat.firmadmin@{UAT_DOMAIN}")
system_admin = first(users, email=f"uat.admin@{UAT_DOMAIN}")
partner = first(users, email=f"uat.partner@{UAT_DOMAIN}")
manager = first(users, email=f"uat.manager@{UAT_DOMAIN}")
staff = first(users, email=f"uat.staff@{UAT_DOMAIN}")
staff2 = first(users, email=f"uat.staff2@{UAT_DOMAIN}")
client_user = first(users, email=f"uat.client@{UAT_DOMAIN}")
consultant = first(users, email=f"uat.consultant@{UAT_DOMAIN}")
client_a = first_by_any(clients, [{"client_code": "UAT-CL-A"}, {"code": "UAT-CL-A"}, {"client_name": "UAT Client A Pvt Ltd"}])
client_b = first_by_any(clients, [{"client_code": "UAT-CL-B"}, {"code": "UAT-CL-B"}, {"client_name": "UAT Client B Pvt Ltd"}])
service_a = first_by_any(services, [{"service_code": "GST-GSTR3B-M"}, {"code": "GST-GSTR3B-M"}, {"service_name": "GSTR-3B Monthly Filing"}])
sub_a = first_by_any(subscriptions, [{"financial_year": ACTIVE_FY, "client_id": val(client_a, "id")}, {"client_id": val(client_a, "id")}])
task_a = first_by_any(task_instances, [{"client_id": val(client_a, "id")}, {"assigned_to_user_id": val(staff, "id")}])
if not tenant_a or not branch_a or not firm_admin:
fail("Core UAT seed appears missing. Run seed_uat_data.py first.")
tenant_a_id = val(tenant_a, "id")
tenant_b_id = val(tenant_b, "id")
branch_a_id = val(branch_a, "id")
branch_b_id = val(branch_b, "id")
firm_admin_id = val(firm_admin, "id")
system_admin_id = val(system_admin, "id", firm_admin_id)
partner_id = val(partner, "id", firm_admin_id)
manager_id = val(manager, "id", firm_admin_id)
staff_id = val(staff, "id", firm_admin_id)
staff2_id = val(staff2, "id", staff_id)
client_user_id = val(client_user, "id", firm_admin_id)
consultant_id = val(consultant, "id", firm_admin_id)
client_a_id = val(client_a, "id")
client_b_id = val(client_b, "id")
service_a_id = val(service_a, "id")
sub_a_id = val(sub_a, "id")
task_a_id = val(task_a, "id")
common_scope = {
"tenant_id": tenant_a_id,
"branch_id": branch_a_id,
"created_by_user_id": firm_admin_id,
"updated_by_user_id": firm_admin_id,
"created_by_id": firm_admin_id,
"updated_by_id": firm_admin_id,
"created_at": utcnow(),
"updated_at": utcnow(),
"is_active": True,
}
# 1. Email integration seed
email_settings = find_table("email_settings", "firm_email_settings", "smtp_settings", "mail_settings")
smtp = upsert(email_settings, {"tenant_id": tenant_a_id, "setting_code": "UAT-SMTP-A"}, {
**common_scope,
"name": "UAT SMTP Settings",
"smtp_host": "mail.vavalam.com",
"smtp_port": 587,
"smtp_username": "no-reply@vavalam.com",
"smtp_password": SMTP_PASSWORD,
"from_email": "no-reply@vavalam.com",
"from_name": "ARRR ERP UAT",
"use_tls": True,
"imap_host": "mail.vavalam.com",
"imap_port": 993,
"imap_username": f"uat.firmadmin@{UAT_DOMAIN}",
"imap_password": SMTP_PASSWORD,
"status": "active",
"is_default": True,
}, "email settings")
ids["EMAIL_SETTINGS_ID"] = val(smtp, "id")
email_templates = find_table("email_templates", "mail_templates")
for code, subject, body in [
("UAT_OTP", "Your ERP OTP", "Your OTP is {{ otp }}"),
("UAT_INVITE", "ERP Invitation", "Please accept invitation: {{ invite_url }}"),
("UAT_NOTICE", "Notice update", "Notice {{ reference_no }} requires attention"),
]:
upsert(email_templates, {"tenant_id": tenant_a_id, "template_code": code}, {
**common_scope, "name": code.replace("_", " "), "subject": subject, "body": body, "body_html": body,
"template_type": "system", "is_active": True,
}, f"email template {code}")
email_queue = find_table("email_queue", "queued_emails", "mail_queue")
upsert(email_queue, {"tenant_id": tenant_a_id, "queue_code": "UAT-EMAIL-Q-001"}, {
**common_scope, "to_email": f"uat.client@{UAT_DOMAIN}", "from_email": "no-reply@vavalam.com",
"subject": "UAT queued email", "body": "Queued email for UAT", "status": "pending",
"priority": 5, "scheduled_at": utcnow(), "attempts": 0,
}, "email queue")
email_logs = find_table("email_logs", "mail_logs", "email_delivery_logs")
upsert(email_logs, {"tenant_id": tenant_a_id, "message_id": "UAT-EMAIL-LOG-001"}, {
**common_scope, "to_email": f"uat.client@{UAT_DOMAIN}", "from_email": "no-reply@vavalam.com",
"subject": "UAT delivered email", "status": "sent", "sent_at": utcnow(), "provider_response": "UAT seeded",
}, "email log")
inbox = find_table("email_inbox_messages", "email_inbox", "inbound_emails", "mail_inbox")
upsert(inbox, {"tenant_id": tenant_a_id, "message_id": "UAT-INBOX-001"}, {
**common_scope, "from_email": f"uat.client@{UAT_DOMAIN}", "to_email": "support@vavalam.com",
"subject": "UAT client document submission", "body": "Please map this email to the UAT client.",
"received_at": utcnow(), "status": "unmapped", "client_id": client_a_id,
}, "email inbox")
# 2. Marketplace / public lead seed
leads = find_table("marketplace_leads", "leads", "public_leads")
lead1 = upsert(leads, {"tenant_id": tenant_a_id, "lead_code": "UAT-LEAD-001"}, {
**common_scope, "source": "public_form", "lead_type": "compliance", "name": "UAT Marketplace Lead",
"company_name": "UAT Lead Pvt Ltd", "contact_name": "UAT Lead Contact", "email": "lead@example.com",
"mobile": "9000000101", "service_interest": "GST", "status": "new", "assigned_to_user_id": manager_id,
"notes": "Seeded marketplace lead for v2.5.1 tests",
}, "marketplace lead new")
lead2 = upsert(leads, {"tenant_id": tenant_a_id, "lead_code": "UAT-LEAD-CONVERTED"}, {
**common_scope, "source": "referral", "name": "UAT Converted Lead", "email": "converted@example.com",
"mobile": "9000000102", "status": "converted", "assigned_to_user_id": partner_id, "converted_client_id": client_a_id,
}, "marketplace lead converted")
ids["MARKETPLACE_LEAD_ID"] = val(lead1, "id")
# 3. Platform billing seed
plans = find_table("platform_billing_plans", "billing_plans", "plans")
plan = upsert(plans, {"plan_code": "UAT-PLAN-PRO"}, {
"name": "UAT Professional Plan", "description": "Seed plan for platform billing tests",
"monthly_price": 9999, "annual_price": 99990, "currency": "INR", "max_users": 25,
"max_clients": 500, "is_active": True, "created_at": utcnow(), "updated_at": utcnow(),
}, "platform plan")
accounts = find_table("platform_billing_accounts", "billing_accounts", "accounts")
account = upsert(accounts, {"tenant_id": tenant_a_id, "account_code": "UAT-PLAT-ACC-A"}, {
**common_scope, "account_name": "UAT Tenant A Billing Account", "billing_email": "support@vavalam.com",
"gstin": "33AABCU1111A1Z5", "status": "active",
}, "platform billing account")
plat_subs = find_table("platform_subscriptions", "platform_billing_subscriptions", "audit_firm_subscriptions", "subscriptions")
plat_sub = upsert(plat_subs, {"tenant_id": tenant_a_id, "subscription_code": "UAT-PLAT-SUB-A"}, {
**common_scope, "account_id": val(account, "id"), "plan_id": val(plan, "id"), "status": "active",
"start_date": today(), "end_date": today() + timedelta(days=365), "billing_cycle": "monthly",
}, "platform subscription")
plat_invoices = find_table("platform_invoices", "platform_billing_invoices", "invoices")
p_inv = upsert(plat_invoices, {"tenant_id": tenant_a_id, "invoice_no": "UAT-PLAT-INV-001"}, {
**common_scope, "account_id": val(account, "id"), "subscription_id": val(plat_sub, "id"),
"invoice_date": today(), "due_date": today() + timedelta(days=15), "status": "issued",
"subtotal": 9999, "tax_amount": 1799.82, "total_amount": 11798.82, "balance_amount": 11798.82,
}, "platform invoice")
payments = find_table("platform_payments", "invoice_payments", "payments")
upsert(payments, {"tenant_id": tenant_a_id, "payment_ref": "UAT-PLAT-PAY-001"}, {
**common_scope, "invoice_id": val(p_inv, "id"), "payment_date": today(), "amount": 5000,
"payment_mode": "bank_transfer", "status": "posted", "remarks": "Seed partial platform payment",
}, "platform payment")
ids["PLATFORM_INVOICE_ID"] = val(p_inv, "id")
# 4. RBAC seed
perm_codes = [
"uat.clients.read", "uat.documents.read", "uat.billing.read", "uat.email.manage", "uat.marketplace.manage",
]
for code in perm_codes:
upsert(permissions, {"code": code}, {
"name": code, "description": f"UAT permission {code}", "module": "UAT", "is_active": True,
"created_at": utcnow(), "updated_at": utcnow(),
}, f"permission {code}")
role = upsert(roles, {"name": "UAT Limited Tester"}, {
"description": "Seeded limited role for RBAC tests", "is_system": False, "is_active": True,
"tenant_id": tenant_a_id, "created_at": utcnow(), "updated_at": utcnow(),
}, "rbac role")
if role_permissions is not None and role is not None:
for code in perm_codes[:2]:
perm = first(permissions, code=code) if permissions is not None else None
upsert(role_permissions, {"role_id": val(role, "id"), "permission_id": val(perm, "id")}, {
"created_at": utcnow(), "created_by_user_id": firm_admin_id,
}, f"role permission {code}")
# 5. Audit logs seed
audit_logs = find_table("audit_logs", "activity_logs", "system_audit_logs")
for code, action, entity in [
("UAT-AUD-LOGIN-FAIL", "login_failed", "User"),
("UAT-AUD-DOC-DOWNLOAD", "document_download", "EngagementDocument"),
("UAT-AUD-PERM-DENIED", "permission_denied", "RBAC"),
]:
upsert(audit_logs, {"tenant_id": tenant_a_id, "event_code": code}, {
**common_scope, "user_id": firm_admin_id, "action": action, "event_type": action,
"entity_type": entity, "entity_id": client_a_id or 1, "ip_address": "127.0.0.1",
"user_agent": "UAT seed", "details": json.dumps({"seed": True, "no_secret": True}),
"message": f"Seed audit event {action}",
}, f"audit log {code}")
# 6. Financial-year states
update_existing(financial_years, {"tenant_id": tenant_a_id, "year_code": PREVIOUS_FY}, {
"is_locked": True, "locked_at": utcnow(), "locked_by_user_id": firm_admin_id,
"lock_reason": "UAT locked FY for v2.5.1 tests",
}, "previous FY lock")
update_existing(financial_years, {"tenant_id": tenant_a_id, "year_code": ACTIVE_FY}, {
"is_locked": False, "is_current": True,
}, "active FY current/unlocked")
backups = find_table("financial_year_backups", "fy_backups", "backup_exports")
fy = first(financial_years, tenant_id=tenant_a_id, year_code=PREVIOUS_FY)
backup = upsert(backups, {"tenant_id": tenant_a_id, "backup_code": "UAT-FY-BACKUP-001"}, {
**common_scope, "financial_year_id": val(fy, "id"), "year_code": PREVIOUS_FY,
"status": "completed", "file_name": "uat-fy-backup-001.zip", "file_size_bytes": 1024,
"created_by_user_id": firm_admin_id,
}, "FY backup")
ids["YEAR_BACKUP_EXPORT_ID"] = val(backup, "id")
ids["LOCKED_FY"] = PREVIOUS_FY
# 7. Document security/deep seed
secret_hash = hashlib.sha256(LSA_SECRET.encode()).hexdigest()
node = upsert(storage_nodes, {"tenant_id": tenant_a_id, "branch_id": branch_a_id, "node_code": "UAT-LSA-SEC-A"}, {
**common_scope, "node_name": "UAT Security Storage Agent", "secret_key_hash": secret_hash,
"storage_root_path": "D:/UAT/Security", "storage_mode": "pull_jobs", "status": "active",
"quota_limit_bytes": 1024 * 1024 * 1024, "used_storage_bytes": 0,
}, "security storage node")
ids["STORAGE_NODE_SECURITY_ID"] = val(node, "id")
doc_sec = upsert(documents, {"tenant_id": tenant_a_id, "document_code": "UAT-DOC-SEC-001"}, {
**common_scope, "branch_id": branch_a_id, "client_id": client_a_id, "engagement_id": sub_a_id,
"task_instance_id": task_a_id, "financial_year": ACTIVE_FY, "assessment_year": ASSESSMENT_YEAR,
"document_type": "SECURITY", "title": "UAT Client Visible Document", "description": "Client visible UAT doc",
"current_version_no": 2, "status": "active", "is_deleted": False, "is_client_visible": True,
}, "client visible document")
ids["CLIENT_VISIBLE_DOCUMENT_ID"] = val(doc_sec, "id")
for version, filename in [(1, "uat-sec-v1.txt"), (2, "uat-sec-v2.txt")]:
content = f"UAT secure version {version}\n".encode()
upsert(document_versions, {"document_id": val(doc_sec, "id"), "version_no": version}, {
**common_scope, "tenant_id": tenant_a_id, "branch_id": branch_a_id, "client_id": client_a_id,
"engagement_id": sub_a_id, "original_filename": filename, "stored_filename": filename,
"content_type": "text/plain", "file_size_bytes": len(content),
"file_hash_sha256": hashlib.sha256(content).hexdigest(), "storage_backend": "LOCAL_YEAR_WISE",
"local_relative_path": f"UAT-A/UAT-CL-A/{ACTIVE_FY}/{filename}", "storage_status": "stored",
"uploaded_by_user_id": staff_id,
}, f"document version {version}")
doc_deleted = upsert(documents, {"tenant_id": tenant_a_id, "document_code": "UAT-DOC-DELETED-001"}, {
**common_scope, "branch_id": branch_a_id, "client_id": client_a_id, "engagement_id": sub_a_id,
"financial_year": ACTIVE_FY, "document_type": "SECURITY", "title": "UAT Deleted Document",
"status": "deleted", "is_deleted": True, "deleted_at": utcnow(), "deleted_by_user_id": firm_admin_id,
}, "deleted document")
ids["DELETED_DOCUMENT_ID"] = val(doc_deleted, "id")
storage_job = upsert(storage_jobs, {"tenant_id": tenant_a_id, "job_code": "UAT-STORAGE-JOB-SEC-001"}, {
**common_scope, "storage_node_id": val(node, "id"), "document_id": val(doc_sec, "id"),
"job_type": "store_version", "status": "pending", "priority": 1,
"staging_relative_path": "staging/uat-sec-v2.txt", "target_relative_path": f"UAT-A/UAT-CL-A/{ACTIVE_FY}/uat-sec-v2.txt",
"file_size_bytes": 21, "expected_hash_sha256": hashlib.sha256(b"UAT secure version 2\n").hexdigest(),
"attempts": 0,
}, "storage job security")
ids["STORAGE_JOB_SECURITY_ID"] = val(storage_job, "id")
# 8. HR seed
attendance = find_table("employee_attendance", "attendance", "employee_attendances")
upsert(attendance, {"tenant_id": tenant_a_id, "employee_id": val(first(employees, user_id=staff_id), "id"), "attendance_date": today()}, {
**common_scope, "user_id": staff_id, "status": "present", "check_in_time": utcnow(), "source": "seed",
"remarks": "Seed attendance for duplicate/manager tests",
}, "attendance")
leave_balances = find_table("employee_leave_balances", "leave_balances")
leave_balance = upsert(leave_balances, {"tenant_id": tenant_a_id, "user_id": staff_id, "leave_type": "casual", "financial_year": ACTIVE_FY}, {
**common_scope, "employee_id": val(first(employees, user_id=staff_id), "id"), "opening_balance": 12,
"availed": 2, "balance": 10, "is_active": True,
}, "leave balance")
leave_requests = find_table("employee_leave_requests", "leave_requests", "leaves")
leave_req = upsert(leave_requests, {"tenant_id": tenant_a_id, "leave_code": "UAT-LEAVE-PENDING-001"}, {
**common_scope, "user_id": staff_id, "employee_id": val(first(employees, user_id=staff_id), "id"),
"leave_type": "casual", "from_date": today() + timedelta(days=5), "to_date": today() + timedelta(days=6),
"days": 2, "status": "pending", "reason": "UAT seeded pending leave", "approver_user_id": manager_id,
"balance_id": val(leave_balance, "id"),
}, "leave request pending")
ids["PENDING_LEAVE_ID"] = val(leave_req, "id")
payroll_runs = find_table("payroll_runs", "employee_payroll_runs", "payroll")
payroll = upsert(payroll_runs, {"tenant_id": tenant_a_id, "run_code": "UAT-PAYROLL-001"}, {
**common_scope, "financial_year": ACTIVE_FY, "period_month": today().month, "period_year": today().year,
"status": "generated", "gross_amount": 50000, "deduction_amount": 5000, "net_amount": 45000,
}, "payroll run")
payslips = find_table("payslips", "employee_payslips")
upsert(payslips, {"tenant_id": tenant_a_id, "payslip_code": "UAT-PAYSLIP-001"}, {
**common_scope, "payroll_run_id": val(payroll, "id"), "employee_id": val(first(employees, user_id=staff_id), "id"),
"user_id": staff_id, "gross_amount": 50000, "deduction_amount": 5000, "net_amount": 45000,
"status": "generated", "file_name": "uat-payslip-001.pdf",
}, "payslip")
# 9. Firm billing business seed
firm_invoices = find_table("billing_invoices", "firm_invoices", "client_invoices", "invoices")
draft_inv = upsert(firm_invoices, {"tenant_id": tenant_a_id, "invoice_no": "UAT-BILL-DRAFT-001"}, {
**common_scope, "client_id": client_a_id, "invoice_date": today(), "due_date": today() + timedelta(days=15),
"status": "draft", "subtotal": 10000, "tax_amount": 1800, "total_amount": 11800, "balance_amount": 11800,
"financial_year": ACTIVE_FY,
}, "billing draft invoice")
issued_inv = upsert(firm_invoices, {"tenant_id": tenant_a_id, "invoice_no": "UAT-BILL-ISSUED-001"}, {
**common_scope, "client_id": client_a_id, "invoice_date": today(), "due_date": today() + timedelta(days=15),
"status": "issued", "subtotal": 20000, "tax_amount": 3600, "total_amount": 23600, "balance_amount": 18600,
"financial_year": ACTIVE_FY,
}, "billing issued invoice")
cancel_inv = upsert(firm_invoices, {"tenant_id": tenant_a_id, "invoice_no": "UAT-BILL-CANCELLED-001"}, {
**common_scope, "client_id": client_a_id, "invoice_date": today(), "status": "cancelled",
"subtotal": 1000, "tax_amount": 180, "total_amount": 1180, "balance_amount": 0,
"financial_year": ACTIVE_FY,
}, "billing cancelled invoice")
firm_payments = find_table("billing_payments", "client_payments", "invoice_payments", "payments")
upsert(firm_payments, {"tenant_id": tenant_a_id, "payment_ref": "UAT-BILL-PAY-001"}, {
**common_scope, "invoice_id": val(issued_inv, "id"), "client_id": client_a_id,
"payment_date": today(), "amount": 5000, "payment_mode": "upi", "status": "posted",
}, "billing partial payment")
ids["BILLING_DRAFT_INVOICE_ID"] = val(draft_inv, "id")
ids["BILLING_ISSUED_INVOICE_ID"] = val(issued_inv, "id")
ids["BILLING_CANCELLED_INVOICE_ID"] = val(cancel_inv, "id")
# 10. Notice case business seed
if notice_cases is not None:
nc = upsert(notice_cases, {"tenant_id": tenant_a_id, "case_code": "UAT-NC-DEEP-001"}, {
**common_scope, "branch_id": branch_a_id, "client_id": client_a_id, "reference_no": "UAT-NOTICE-DEEP-001",
"department": "GST", "case_type": "Notice", "title": "UAT Deep GST Notice", "status": "Hearing Scheduled",
"notice_date": today() - timedelta(days=10), "hearing_date": today() + timedelta(days=10),
"order_date": None, "due_date": today() + timedelta(days=20), "assigned_to_user_id": staff_id,
"partner_user_id": partner_id, "internal_notes": "Internal note must not be client-visible",
"client_visible_notes": "Client visible notice update",
"issue_summary": "Seeded v2.5.1 notice case for business-rule testing",
}, "deep notice case")
ids["NOTICE_CASE_DEEP_ID"] = val(nc, "id")
nc_events = find_table("notice_case_events", "case_events", "notice_case_activities")
upsert(nc_events, {"tenant_id": tenant_a_id, "event_code": "UAT-NC-EVT-001"}, {
**common_scope, "case_id": val(nc, "id"), "event_type": "hearing_scheduled", "event_date": today(),
"notes": "Seeded hearing event", "is_client_visible": True, "created_by_user_id": staff_id,
}, "notice event")
db.commit()
print("\n[v2.5.1 seed] Completed seed extension.")
print("[v2.5.1 seed] Created/updated data for email, marketplace, platform billing, RBAC, audit, FY, documents, HR, billing and notice-case tests.")
print("\nUseful IDs for Playwright .env if needed:\n")
for k, v in ids.items():
if v is not None:
print(f"{k}={v}")
print("\nSkipped/missing-module notes:")
for line in report:
print(f"- {line}")
print("\nIf many rows were skipped, that means the corresponding ERP module/table is not present yet; tests for those modules should then fail/skip as ERP gaps, not seed failures.")
db.close()
if __name__ == "__main__":
try:
main()
except Exception:
raise
+529
View File
@@ -0,0 +1,529 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-API-001",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API auth token rejects blank credentials",
"type": "VAPT",
"route": "/api/auth/token",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-002",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API refresh rejects missing refresh token",
"type": "VAPT",
"route": "/api/auth/refresh",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-003",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API logout without token handled safely",
"type": "VAPT",
"route": "/api/auth/logout",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-004",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API me requires authentication",
"type": "VAPT",
"route": "/api/auth/me",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-005",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API forgot-password rejects invalid email safely",
"type": "VAPT",
"route": "/api/auth/forgot-password",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-006",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API reset-password rejects bogus token",
"type": "VAPT",
"route": "/api/auth/reset-password",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-007",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API invite accept rejects bogus token",
"type": "VAPT",
"route": "/api/auth/invite/accept",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-008",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API users requires authorization",
"type": "VAPT",
"route": "/api/users",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-009",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API users create CSRF/auth rejected",
"type": "VAPT",
"route": "/api/users",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-010",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API RBAC roles requires authorization",
"type": "VAPT",
"route": "/api/rbac/roles",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-011",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API RBAC roles create auth rejected",
"type": "VAPT",
"route": "/api/rbac/roles",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-012",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API RBAC permissions requires authorization",
"type": "VAPT",
"route": "/api/rbac/permissions",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-013",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API tenancy tenants requires authorization",
"type": "VAPT",
"route": "/api/tenancy/tenants",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-014",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API tenancy branches requires authorization",
"type": "VAPT",
"route": "/api/tenancy/branches",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-015",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API clients requires authorization",
"type": "VAPT",
"route": "/api/v1/clients",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-016",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API clients create auth rejected",
"type": "VAPT",
"route": "/api/v1/clients",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-017",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API client invalid ID safe",
"type": "VAPT",
"route": "/api/v1/clients/999999",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-018",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API client update invalid ID auth rejected",
"type": "VAPT",
"route": "/api/v1/clients/999999",
"_kind": "api",
"_method": "PUT",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-019",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API client delete invalid ID auth rejected",
"type": "VAPT",
"route": "/api/v1/clients/999999",
"_kind": "api",
"_method": "DELETE",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-020",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API cross-tenant query does not leak",
"type": "VAPT",
"route": "/api/v1/clients?tenant_id=999999",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-021",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API roles duplicate invalid POST safe",
"type": "VAPT",
"route": "/api/rbac/roles",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-022",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API permission elevation attempt rejected",
"type": "VAPT",
"route": "/api/rbac/roles/999999/permissions",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-023",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API tenant create auth rejected",
"type": "VAPT",
"route": "/api/tenancy/tenants",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-024",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API branch create auth rejected",
"type": "VAPT",
"route": "/api/tenancy/branches",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-025",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API malformed JSON safe",
"type": "VAPT",
"route": "/api/auth/token",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-026",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API token response does not leak password",
"type": "VAPT",
"route": "/api/auth/token",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-027",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API forgot password does not disclose account existence",
"type": "VAPT",
"route": "/api/auth/forgot-password",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-028",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API reset token replay rejected",
"type": "VAPT",
"route": "/api/auth/reset-password",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-029",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API rate-limit/bruteforce endpoint safe",
"type": "VAPT",
"route": "/api/auth/token",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-030",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API CORS preflight handled safely",
"type": "VAPT",
"route": "/api/auth/me",
"_kind": "api",
"_method": "OPTIONS",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-031",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API export clients requires authorization",
"type": "VAPT",
"route": "/api/v1/clients/export",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-032",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API users invalid ID safe",
"type": "VAPT",
"route": "/api/users/999999",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-033",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API users role update auth rejected",
"type": "VAPT",
"route": "/api/users/999999/roles",
"_kind": "api",
"_method": "POST",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-034",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API permission denied response does not include stack trace",
"type": "VAPT",
"route": "/api/users",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
},
{
"variantId": "V251-API-035",
"module": "API Auth / RBAC",
"role": "API/Anonymous",
"scenario": "API unknown endpoint returns safe 404",
"type": "VAPT",
"route": "/api/does-not-exist-uat-vapt",
"_kind": "api",
"_method": "GET",
"_file": "api-auth-rbac.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - API Auth / RBAC", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+349
View File
@@ -0,0 +1,349 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-AUD-001",
"module": "Audit Logs",
"role": "System Admin",
"scenario": "Audit logs page loads for System Admin",
"type": "UAT",
"route": "/system-settings/audit-logs",
"_kind": "page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-AUD-002",
"module": "Audit Logs",
"role": "Firm Admin",
"scenario": "Firm Admin audit logs page loads or safely restricted",
"type": "UAT",
"route": "/system-settings/audit-logs",
"_kind": "page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-AUD-003",
"module": "Audit Logs",
"role": "Staff",
"scenario": "Staff cannot access audit logs",
"type": "VAPT",
"route": "/system-settings/audit-logs",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-AUD-004",
"module": "Audit Logs",
"role": "Client",
"scenario": "Client cannot access audit logs",
"type": "VAPT",
"route": "/system-settings/audit-logs",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-AUD-005",
"module": "Audit Logs",
"role": "System Admin",
"scenario": "Audit log invalid detail safe",
"type": "UAT",
"route": "/system-settings/audit-logs/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-AUD-006",
"module": "Audit Logs",
"role": "Staff",
"scenario": "Audit log export requires permission",
"type": "VAPT",
"route": "/system-settings/audit-logs/export",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-AUD-007",
"module": "Audit Logs",
"role": "System Admin",
"scenario": "Audit log filters do not crash",
"type": "UAT",
"route": "/system-settings/audit-logs?module=clients&action=create",
"_kind": "page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-AUD-008",
"module": "Audit Logs",
"role": "System Admin",
"scenario": "Audit logs do not expose password",
"type": "VAPT",
"route": "/system-settings/audit-logs",
"_kind": "no-secret",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-AUD-009",
"module": "Audit Logs",
"role": "System Admin",
"scenario": "Audit logs do not expose OTP",
"type": "VAPT",
"route": "/system-settings/audit-logs",
"_kind": "no-secret",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-AUD-010",
"module": "Audit Logs",
"role": "System Admin",
"scenario": "Audit logs do not expose reset token",
"type": "VAPT",
"route": "/system-settings/audit-logs",
"_kind": "no-secret",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-AUD-011",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Audit log export CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/audit-logs/export",
"_kind": "post",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-012",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Audit log delete invalid ID rejected",
"type": "VAPT",
"route": "/system-settings/audit-logs/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-013",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Audit log tamper invalid POST rejected",
"type": "VAPT",
"route": "/system-settings/audit-logs/999999",
"_kind": "post",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-014",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Permission denied event generated safely by blocked route",
"type": "VAPT",
"route": "/system-settings/rbac/roles",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-015",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Login failure is handled without stack trace",
"type": "VAPT",
"route": "/login",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-016",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Document download audit invalid ID safe",
"type": "VAPT",
"route": "/documents/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-017",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Billing payment audit invalid ID safe",
"type": "VAPT",
"route": "/billing/invoices/999999/payments",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-018",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Notice case audit invalid ID safe",
"type": "VAPT",
"route": "/notice-cases/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-019",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Audit log tenant scope query safe",
"type": "VAPT",
"route": "/system-settings/audit-logs?tenant_id=999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
},
{
"variantId": "V251-AUD-020",
"module": "Audit Logs",
"role": "Anonymous/Attacker",
"scenario": "Audit log branch scope query safe",
"type": "VAPT",
"route": "/system-settings/audit-logs?branch_id=999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "audit-logs.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - Audit Logs", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+433
View File
@@ -0,0 +1,433 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-BIZBILL-001",
"module": "Billing Business Rules",
"role": "Firm Admin",
"scenario": "Billing dashboard loads",
"type": "UAT",
"route": "/billing",
"_kind": "page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-BIZBILL-002",
"module": "Billing Business Rules",
"role": "Firm Admin",
"scenario": "Invoices list loads",
"type": "UAT",
"route": "/billing/invoices",
"_kind": "page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-BIZBILL-003",
"module": "Billing Business Rules",
"role": "Firm Admin",
"scenario": "Invoice create page loads",
"type": "UAT",
"route": "/billing/invoices/new",
"_kind": "page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-BIZBILL-004",
"module": "Billing Business Rules",
"role": "Firm Admin",
"scenario": "Payments list loads",
"type": "UAT",
"route": "/billing/payments",
"_kind": "page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-BIZBILL-005",
"module": "Billing Business Rules",
"role": "Firm Admin",
"scenario": "Invalid invoice detail safe",
"type": "VAPT",
"route": "/billing/invoices/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-BIZBILL-006",
"module": "Billing Business Rules",
"role": "Client",
"scenario": "Client cannot access all invoices",
"type": "VAPT",
"route": "/billing/invoices",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-BIZBILL-007",
"module": "Billing Business Rules",
"role": "Staff",
"scenario": "Staff cannot access billing",
"type": "VAPT",
"route": "/billing",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-BIZBILL-008",
"module": "Billing Business Rules",
"role": "Consultant",
"scenario": "Consultant cannot access billing",
"type": "VAPT",
"route": "/billing",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-BIZBILL-009",
"module": "Billing Business Rules",
"role": "Client",
"scenario": "Client own invoice invalid ID safe",
"type": "VAPT",
"route": "/client/invoices/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-BIZBILL-010",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice create without line items rejected",
"type": "VAPT",
"route": "/billing/invoices",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-011",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice duplicate number invalid POST safe",
"type": "VAPT",
"route": "/billing/invoices",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-012",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice invalid GST/tax values rejected",
"type": "VAPT",
"route": "/billing/invoices",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-013",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice issue CSRF-less POST rejected",
"type": "VAPT",
"route": "/billing/invoices/999999/issue",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-014",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice cancel CSRF-less POST rejected",
"type": "VAPT",
"route": "/billing/invoices/999999/cancel",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-015",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Void invoice cannot accept payment",
"type": "VAPT",
"route": "/billing/invoices/999999/payments",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-016",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Partial payment invalid ID safe",
"type": "VAPT",
"route": "/billing/invoices/999999/payments",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-017",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Overpayment blocked",
"type": "VAPT",
"route": "/billing/invoices/999999/payments",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-018",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Payment receipt invalid ID safe",
"type": "VAPT",
"route": "/billing/payments/999999/receipt",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-019",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice PDF invalid ID safe",
"type": "VAPT",
"route": "/billing/invoices/999999/pdf",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-020",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice print invalid ID safe",
"type": "VAPT",
"route": "/billing/invoices/999999/print",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-021",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice internal notes hidden from client route safe",
"type": "VAPT",
"route": "/client/invoices/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-022",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Payment delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/billing/payments/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-023",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice line update invalid ID rejected",
"type": "VAPT",
"route": "/billing/invoices/999999/lines/999999",
"_kind": "post",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-024",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice export requires authorization",
"type": "VAPT",
"route": "/billing/invoices/export",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-025",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Payment export requires authorization",
"type": "VAPT",
"route": "/billing/payments/export",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-026",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Cross-client invoice IDOR safe",
"type": "VAPT",
"route": "/billing/invoices/999999?client_id=999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-BIZBILL-027",
"module": "Billing Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invoice number sequence endpoint safe",
"type": "VAPT",
"route": "/billing/invoices/next-number",
"_kind": "safe-page",
"_method": "GET",
"_file": "billing-business-rules.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - Billing Business Rules", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+280
View File
@@ -0,0 +1,280 @@
/**
* =============================================================================
* UAT_Consultants_Documents -- Consultants portal + document storage module
* =============================================================================
*
* Covers:
* CONS-* : Consultant portal — list, detail, links, conversion requests,
* service requests
* DOC-STOR-*: Document storage — permanent vault, storage nodes, branch
* storage dashboard, download requests
* SEC-* : CSRF rejection, anonymous probes, IDOR on storage endpoints
*
* Required .env additions:
* CONSULTANT_A_ID= # a seeded consultant id
* CONVERSION_REQUEST_ID= # a seeded managed-client conversion request id
* SERVICE_REQUEST_ID= # a seeded consultant service request id
* CLIENT_A_ID= # already used in main suite; reused here
* STORAGE_NODE_ID= # a seeded storage node id
* DOWNLOAD_REQUEST_ID= # a seeded download request id
* PERM_DOCUMENT_ID= # a seeded permanent document id
* PERM_VERSION_ID= # a seeded permanent document version id
*
* =============================================================================
*/
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound, uploadPath } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
function idOr(envKey, fallback = '1') {
return process.env[envKey] || fallback;
}
function skipIfMissing(envKey) {
if (!process.env[envKey]) test.skip(true, `Set ${envKey} in .env after seeding`);
}
// ---------------------------------------------------------------------------
// Consultants
// ---------------------------------------------------------------------------
test.describe('CONS: Consultant portal', () => {
test('[V25-CONS-001] CONS-001 Consultant list loads for Firm Admin', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/consultants');
await expectNoBackendError(page);
});
test('[V25-CONS-002] CONS-002 Consultant detail page loads', async ({ page }) => {
skipIfMissing('CONSULTANT_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/consultants/${idOr('CONSULTANT_A_ID')}`);
await expectNoBackendError(page);
});
test('[V25-CONS-003] CONS-003 Consultant edit form loads', async ({ page }) => {
skipIfMissing('CONSULTANT_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/consultants/${idOr('CONSULTANT_A_ID')}/edit`);
await expectNoBackendError(page);
});
test('[V25-CONS-004] CONS-004 Consultant links page loads', async ({ page }) => {
skipIfMissing('CONSULTANT_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/consultants/${idOr('CONSULTANT_A_ID')}/links`);
await expectNoBackendError(page);
});
test('[V25-CONS-005] CONS-005 Conversion requests list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/consultants/conversion-requests');
await expectNoBackendError(page);
});
test('[V25-CONS-006] CONS-006 Conversion request detail loads', async ({ page }) => {
skipIfMissing('CONVERSION_REQUEST_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/consultants/conversion-requests/${idOr('CONVERSION_REQUEST_ID')}`);
await expectNoBackendError(page);
});
test('[V25-CONS-007] CONS-007 Conversion request review CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('CONVERSION_REQUEST_ID');
const resp = await request.post(
`${process.env.BASE_URL}/consultants/conversion-requests/${idOr('CONVERSION_REQUEST_ID')}/review`,
{ form: { decision: 'approve', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-CONS-008] CONS-008 Service requests list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/consultants/service-requests');
await expectNoBackendError(page);
});
test('[V25-CONS-009] CONS-009 Service request detail loads', async ({ page }) => {
skipIfMissing('SERVICE_REQUEST_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/consultants/service-requests/${idOr('SERVICE_REQUEST_ID')}`);
await expectNoBackendError(page);
});
test('[V25-CONS-010] CONS-010 Service request status update CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('SERVICE_REQUEST_ID');
const resp = await request.post(
`${process.env.BASE_URL}/consultants/service-requests/${idOr('SERVICE_REQUEST_ID')}/status`,
{ form: { status: 'approved', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-CONS-011] CONS-011 Staff cannot access consultant list', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/consultants');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-CONS-012] CONS-012 Client cannot access consultant detail', async ({ page }) => {
skipIfMissing('CONSULTANT_A_ID');
await login(page, 'Client');
const resp = await safeGoto(page, `/consultants/${idOr('CONSULTANT_A_ID')}`);
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-CONS-013] CONS-013 Non-existent consultant ID returns safe response', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/consultants/999999999');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-CONS-014] CONS-014 Consultant self-service dashboard loads', async ({ page }) => {
await login(page, 'Consultant');
const resp = await safeGoto(page, '/consultant/dashboard');
// may 404 if no consultant portal route — acceptable; must not 500
expect(resp.status()).toBeLessThan(500);
await expectNoBackendError(page);
});
});
// ---------------------------------------------------------------------------
// Document storage module
// ---------------------------------------------------------------------------
test.describe('DOC-STOR: Document storage and permanent vault', () => {
test('[V25-DOC-STOR-001] DOC-STOR-001 Branch storage dashboard loads for Firm Admin', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/documents/branch-storage-dashboard');
await expectNoBackendError(page);
});
test('[V25-DOC-STOR-002] DOC-STOR-002 Permanent document vault list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/documents/permanent');
await expectNoBackendError(page);
});
test('[V25-DOC-STOR-003] DOC-STOR-003 Permanent vault for specific client loads', async ({ page }) => {
skipIfMissing('CLIENT_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/documents/permanent/clients/${idOr('CLIENT_A_ID')}`);
await expectNoBackendError(page);
});
test('[V25-DOC-STOR-004] DOC-STOR-004 Permanent vault upload CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('CLIENT_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/documents/permanent/clients/${idOr('CLIENT_A_ID')}/upload`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-DOC-STOR-005] DOC-STOR-005 Permanent document download requires auth', async ({ page }) => {
skipIfMissing('PERM_DOCUMENT_ID');
// anonymous access
const resp = await safeGoto(page, `/documents/permanent/${idOr('PERM_DOCUMENT_ID')}/download`);
const body = await readBody(page);
await blockedOrNotFound(resp, body);
});
test('[V25-DOC-STOR-006] DOC-STOR-006 Storage jobs list loads for System Admin', async ({ page }) => {
await login(page, 'System Admin');
await safeGoto(page, '/documents/storage-jobs');
await expectNoBackendError(page);
});
test('[V25-DOC-STOR-007] DOC-STOR-007 Storage nodes list loads for System Admin', async ({ page }) => {
await login(page, 'System Admin');
await safeGoto(page, '/documents/storage-nodes');
await expectNoBackendError(page);
});
test('[V25-DOC-STOR-008] DOC-STOR-008 Download requests list loads', async ({ page }) => {
await login(page, 'System Admin');
await safeGoto(page, '/documents/download-requests');
await expectNoBackendError(page);
});
test('[V25-DOC-STOR-009] DOC-STOR-009 Storage agent jobs endpoint requires auth', async ({ request }) => {
const resp = await request.get(`${process.env.BASE_URL}/documents/storage-agent/jobs/pending`).catch(() => null);
if (!resp) test.skip(true, 'Request failed at network level');
expect(resp.status()).toBeLessThan(500);
expect([400, 401, 403, 404, 405, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-DOC-STOR-010] DOC-STOR-010 Storage agent download-requests endpoint requires auth', async ({ request }) => {
const resp = await request.get(`${process.env.BASE_URL}/documents/storage-agent/download-requests/pending`).catch(() => null);
if (!resp) test.skip(true, 'Request failed at network level');
expect(resp.status()).toBeLessThan(500);
expect([400, 401, 403, 404, 405, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-DOC-STOR-011] DOC-STOR-011 Storage node toggle CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('STORAGE_NODE_ID');
const resp = await request.post(
`${process.env.BASE_URL}/documents/storage-nodes/${idOr('STORAGE_NODE_ID')}/toggle`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-DOC-STOR-012] DOC-STOR-012 Staff cannot access branch storage dashboard', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/documents/branch-storage-dashboard');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-DOC-STOR-013] DOC-STOR-013 Client cannot access permanent vault admin', async ({ page }) => {
await login(page, 'Client');
const resp = await safeGoto(page, '/documents/permanent');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-DOC-STOR-014] DOC-STOR-014 Permanent document delete CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('PERM_DOCUMENT_ID');
const resp = await request.post(
`${process.env.BASE_URL}/documents/permanent/${idOr('PERM_DOCUMENT_ID')}/delete`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-DOC-STOR-015] DOC-STOR-015 Non-existent permanent document download is handled safely', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/documents/permanent/999999999/download');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-DOC-STOR-016] DOC-STOR-016 Storage agent agent-package download requires node auth', async ({ request }) => {
const resp = await request.get(`${process.env.BASE_URL}/documents/storage-nodes/download-agent-package`).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect(resp.status()).toBeLessThan(500);
expect([400, 401, 403, 404, 422].includes(resp.status())).toBeTruthy();
});
});
+445
View File
@@ -0,0 +1,445 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-DSEC-001",
"module": "Document Security Deep",
"role": "Firm Admin",
"scenario": "Invalid document download returns safe response",
"type": "VAPT",
"route": "/documents/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-DSEC-002",
"module": "Document Security Deep",
"role": "Firm Admin",
"scenario": "Invalid document version download safe",
"type": "VAPT",
"route": "/documents/999999/versions/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-DSEC-003",
"module": "Document Security Deep",
"role": "Firm Admin",
"scenario": "Invalid permanent document download safe",
"type": "VAPT",
"route": "/documents/permanent/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-DSEC-004",
"module": "Document Security Deep",
"role": "Firm Admin",
"scenario": "Deleted document invalid download safe",
"type": "VAPT",
"route": "/documents/999999/deleted/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-DSEC-005",
"module": "Document Security Deep",
"role": "Client",
"scenario": "Client cannot download other document ID",
"type": "VAPT",
"route": "/documents/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-DSEC-006",
"module": "Document Security Deep",
"role": "Consultant",
"scenario": "Consultant cannot download unshared document",
"type": "VAPT",
"route": "/documents/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-DSEC-007",
"module": "Document Security Deep",
"role": "Staff",
"scenario": "Staff cannot access storage admin",
"type": "VAPT",
"route": "/documents/storage/nodes",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-DSEC-008",
"module": "Document Security Deep",
"role": "Public",
"scenario": "Storage agent jobs require auth",
"type": "VAPT",
"route": "/storage-agent/jobs",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-009",
"module": "Document Security Deep",
"role": "Public",
"scenario": "Storage agent heartbeat without secret rejected",
"type": "VAPT",
"route": "/storage-agent/heartbeat",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-010",
"module": "Document Security Deep",
"role": "Public",
"scenario": "Download token invalid safe",
"type": "VAPT",
"route": "/documents/download-token/invalid-token",
"_kind": "safe-page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-011",
"module": "Document Security Deep",
"role": "Firm Admin",
"scenario": "Document search route does not leak across tenant",
"type": "VAPT",
"route": "/documents?tenant_id=999999",
"_kind": "page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-DSEC-012",
"module": "Document Security Deep",
"role": "Firm Admin",
"scenario": "Document repository loads for Firm Admin",
"type": "UAT",
"route": "/documents",
"_kind": "page",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-DSEC-013",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document upload double extension rejected",
"type": "VAPT",
"route": "/documents/upload",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-014",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document upload MIME mismatch rejected",
"type": "VAPT",
"route": "/documents/upload",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-015",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document upload large file rejected or handled",
"type": "VAPT",
"route": "/documents/upload",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-016",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document upload path traversal filename rejected",
"type": "VAPT",
"route": "/documents/upload",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-017",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/documents/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-018",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document version delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/documents/999999/versions/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-019",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Permanent document delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/documents/permanent/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-020",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document move cross-tenant CSRF-less POST rejected",
"type": "VAPT",
"route": "/documents/999999/move",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-021",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document share with client invalid ID rejected",
"type": "VAPT",
"route": "/documents/999999/share-client",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-022",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document share with consultant invalid ID rejected",
"type": "VAPT",
"route": "/documents/999999/share-consultant",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-023",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Storage node create CSRF-less POST rejected",
"type": "VAPT",
"route": "/documents/storage/nodes",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-024",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Storage agent ack without secret rejected",
"type": "VAPT",
"route": "/storage-agent/jobs/999999/ack",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-025",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Storage agent upload without secret rejected",
"type": "VAPT",
"route": "/storage-agent/jobs/999999/upload",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-026",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Download request expiry invalid safe",
"type": "VAPT",
"route": "/documents/download-requests/999999",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-027",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document tag update CSRF-less POST rejected",
"type": "VAPT",
"route": "/documents/999999/tags",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
},
{
"variantId": "V251-DSEC-028",
"module": "Document Security Deep",
"role": "Anonymous/Attacker",
"scenario": "Document metadata update invalid ID rejected",
"type": "VAPT",
"route": "/documents/999999/edit",
"_kind": "post",
"_method": "GET",
"_file": "document-security-deep.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - Document Security Deep", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+421
View File
@@ -0,0 +1,421 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-EMAIL-001",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email settings page loads",
"type": "UAT",
"route": "/email/settings",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-002",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email test settings endpoint visible/safe",
"type": "UAT",
"route": "/email/settings/test",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-003",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email logs page loads",
"type": "UAT",
"route": "/email/logs",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-004",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email queue page loads",
"type": "UAT",
"route": "/email/queue",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-005",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email inbox page loads",
"type": "UAT",
"route": "/email/inbox",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-006",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email templates page loads",
"type": "UAT",
"route": "/email/templates",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-007",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email template detail invalid ID safe",
"type": "UAT",
"route": "/email/templates/999999",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-008",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email inbox invalid message detail safe",
"type": "UAT",
"route": "/email/inbox/999999",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-009",
"module": "Email Integration",
"role": "Anonymous/Attacker",
"scenario": "SMTP settings CSRF-less save rejected",
"type": "VAPT",
"route": "/email/settings",
"_kind": "post",
"_method": "POST",
"_file": "email-integration.spec.js",
"_login_role": null
},
{
"variantId": "V251-EMAIL-010",
"module": "Email Integration",
"role": "Anonymous/Attacker",
"scenario": "SMTP test CSRF-less POST rejected",
"type": "VAPT",
"route": "/email/settings/test",
"_kind": "post",
"_method": "POST",
"_file": "email-integration.spec.js",
"_login_role": null
},
{
"variantId": "V251-EMAIL-011",
"module": "Email Integration",
"role": "Anonymous/Attacker",
"scenario": "Email queue process CSRF-less POST rejected",
"type": "VAPT",
"route": "/email/queue/process",
"_kind": "post",
"_method": "POST",
"_file": "email-integration.spec.js",
"_login_role": null
},
{
"variantId": "V251-EMAIL-012",
"module": "Email Integration",
"role": "Anonymous/Attacker",
"scenario": "Inbox fetch CSRF-less POST rejected",
"type": "VAPT",
"route": "/email/inbox/fetch",
"_kind": "post",
"_method": "POST",
"_file": "email-integration.spec.js",
"_login_role": null
},
{
"variantId": "V251-EMAIL-013",
"module": "Email Integration",
"role": "Anonymous/Attacker",
"scenario": "Inbox map-all CSRF-less POST rejected",
"type": "VAPT",
"route": "/email/inbox/map-all",
"_kind": "post",
"_method": "POST",
"_file": "email-integration.spec.js",
"_login_role": null
},
{
"variantId": "V251-EMAIL-014",
"module": "Email Integration",
"role": "Anonymous/Attacker",
"scenario": "Inbox message map CSRF-less POST rejected",
"type": "VAPT",
"route": "/email/inbox/999999/map",
"_kind": "post",
"_method": "POST",
"_file": "email-integration.spec.js",
"_login_role": null
},
{
"variantId": "V251-EMAIL-015",
"module": "Email Integration",
"role": "Anonymous/Attacker",
"scenario": "Email template save CSRF-less POST rejected",
"type": "VAPT",
"route": "/email/templates",
"_kind": "post",
"_method": "POST",
"_file": "email-integration.spec.js",
"_login_role": null
},
{
"variantId": "V251-EMAIL-016",
"module": "Email Integration",
"role": "Anonymous/Attacker",
"scenario": "Email template update invalid ID CSRF-less POST rejected",
"type": "VAPT",
"route": "/email/templates/999999",
"_kind": "post",
"_method": "POST",
"_file": "email-integration.spec.js",
"_login_role": null
},
{
"variantId": "V251-EMAIL-017",
"module": "Email Integration",
"role": "Staff",
"scenario": "Staff cannot access email settings",
"type": "VAPT",
"route": "/email/settings",
"_kind": "safe-page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-EMAIL-018",
"module": "Email Integration",
"role": "Client",
"scenario": "Client cannot access email logs",
"type": "VAPT",
"route": "/email/logs",
"_kind": "safe-page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-EMAIL-019",
"module": "Email Integration",
"role": "Consultant",
"scenario": "Consultant cannot access email inbox",
"type": "VAPT",
"route": "/email/inbox",
"_kind": "safe-page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-EMAIL-020",
"module": "Email Integration",
"role": "Partner",
"scenario": "Partner email queue access is blocked or safe",
"type": "VAPT",
"route": "/email/queue",
"_kind": "safe-page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Partner"
},
{
"variantId": "V251-EMAIL-021",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Invalid email attachment download does not crash",
"type": "UAT",
"route": "/email/inbox/999999/attachments/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-022",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email logs do not expose SMTP password",
"type": "VAPT",
"route": "/email/logs",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-023",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email settings page does not render SMTP password in clear text",
"type": "VAPT",
"route": "/email/settings",
"_kind": "page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-024",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email queue invalid retry action safe",
"type": "UAT",
"route": "/email/queue/999999/retry",
"_kind": "safe-page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-025",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email queue invalid delete action safe",
"type": "UAT",
"route": "/email/queue/999999/delete",
"_kind": "safe-page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-EMAIL-026",
"module": "Email Integration",
"role": "Firm Admin",
"scenario": "Email template preview invalid ID safe",
"type": "UAT",
"route": "/email/templates/999999/preview",
"_kind": "safe-page",
"_method": "GET",
"_file": "email-integration.spec.js",
"_login_role": "Firm Admin"
}
];
test.describe("v2.5.1 Additions - Email Integration", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+349
View File
@@ -0,0 +1,349 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-HRRULE-001",
"module": "Employee HR Business Rules",
"role": "Staff",
"scenario": "Attendance page loads",
"type": "UAT",
"route": "/employee/attendance",
"_kind": "page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-HRRULE-002",
"module": "Employee HR Business Rules",
"role": "Staff",
"scenario": "Leave page loads",
"type": "UAT",
"route": "/employee/leaves",
"_kind": "page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-HRRULE-003",
"module": "Employee HR Business Rules",
"role": "Manager",
"scenario": "Manager team attendance loads",
"type": "UAT",
"route": "/employees/attendance",
"_kind": "page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": "Manager"
},
{
"variantId": "V251-HRRULE-004",
"module": "Employee HR Business Rules",
"role": "Manager",
"scenario": "Manager team leave loads",
"type": "UAT",
"route": "/employees/leave",
"_kind": "page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": "Manager"
},
{
"variantId": "V251-HRRULE-005",
"module": "Employee HR Business Rules",
"role": "Firm Admin",
"scenario": "Payroll page loads for Firm Admin",
"type": "UAT",
"route": "/employees/payroll",
"_kind": "page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-HRRULE-006",
"module": "Employee HR Business Rules",
"role": "Firm Admin",
"scenario": "Employee documents page loads",
"type": "UAT",
"route": "/employees/documents",
"_kind": "page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-HRRULE-007",
"module": "Employee HR Business Rules",
"role": "Client",
"scenario": "Client cannot access attendance",
"type": "VAPT",
"route": "/employee/attendance",
"_kind": "safe-page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-HRRULE-008",
"module": "Employee HR Business Rules",
"role": "Consultant",
"scenario": "Consultant cannot access payroll",
"type": "VAPT",
"route": "/employees/payroll",
"_kind": "safe-page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-HRRULE-009",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Duplicate attendance same date rejected",
"type": "VAPT",
"route": "/employee/attendance",
"_kind": "post",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-010",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Manual attendance CSRF-less POST rejected",
"type": "VAPT",
"route": "/employees/attendance/manual",
"_kind": "post",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-011",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Leave exceeding balance rejected",
"type": "VAPT",
"route": "/employee/leaves",
"_kind": "post",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-012",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Leave approve invalid ID rejected",
"type": "VAPT",
"route": "/employees/leave/999999/approve",
"_kind": "post",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-013",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Leave reject invalid ID rejected",
"type": "VAPT",
"route": "/employees/leave/999999/reject",
"_kind": "post",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-014",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Payroll generate CSRF-less POST rejected",
"type": "VAPT",
"route": "/employees/payroll/generate",
"_kind": "post",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-015",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Payroll approve before generate invalid safe",
"type": "VAPT",
"route": "/employees/payroll/999999/approve",
"_kind": "safe-page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-016",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Payroll paid before approval invalid safe",
"type": "VAPT",
"route": "/employees/payroll/999999/mark-paid",
"_kind": "safe-page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-017",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Payslip invalid ID safe",
"type": "VAPT",
"route": "/employees/payroll/999999/payslip",
"_kind": "safe-page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-018",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Offboarding complete invalid employee safe",
"type": "VAPT",
"route": "/employees/999999/offboarding/complete",
"_kind": "safe-page",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-019",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Employee document approve invalid ID rejected",
"type": "VAPT",
"route": "/employees/documents/999999/approve",
"_kind": "post",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-HRRULE-020",
"module": "Employee HR Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Employee document reject invalid ID rejected",
"type": "VAPT",
"route": "/employees/documents/999999/reject",
"_kind": "post",
"_method": "GET",
"_file": "employee-hr-business-rules.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - Employee HR Business Rules", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+519
View File
@@ -0,0 +1,519 @@
/**
* =============================================================================
* UAT_Employees -- HR / Employee Self-Service module
* =============================================================================
*
* Covers:
* EMP-HR-* : HR admin routes (/employees/...)
* EMP-ESS-* : Employee self-service portal (/employee/...)
* EMP-RBAC-* : Access control — staff should not reach HR admin pages
* EMP-SEC-* : CSRF, upload security, IDOR probes
*
* Required .env additions (run seed/seed_uat_data_production_gitea.py first):
* EMPLOYEE_A_ID= # a seeded active employee id
* EMPLOYEE_B_ID= # a second employee in the same tenant (for IDOR)
* PAYROLL_RUN_ID= # a seeded payroll run id (status: draft)
* SALARY_STRUCTURE_ID= # a seeded salary structure id
* LEAVE_TYPE_ID= # a seeded leave type id
* LEAVE_REQUEST_ID= # a seeded pending leave request id
* ATTENDANCE_ID= # a seeded manual attendance record id
* REG_REQUEST_ID= # a seeded pending employee registration request id
* ONBOARDING_TASK_ID= # a seeded onboarding task id
* OFFBOARDING_REQ_ID= # a seeded offboarding request id
* EMP_DOCUMENT_ID= # a seeded employee document id
*
* Tests that require specific IDs skip gracefully when the env var is absent.
* =============================================================================
*/
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
function idOr(envKey, fallback = '1') {
return process.env[envKey] || fallback;
}
function skipIfMissing(envKey) {
if (!process.env[envKey]) test.skip(true, `Set ${envKey} in .env after seeding`);
}
// ---------------------------------------------------------------------------
// HR Dashboard
// ---------------------------------------------------------------------------
test.describe('EMP-HR: HR dashboard and employee list', () => {
test('[V25-EMP-HR-001] EMP-HR-001 HR dashboard loads without error for Firm Admin', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/employees/dashboard');
await expectNoBackendError(page);
expect(resp.status()).toBeLessThan(500);
});
test('[V25-EMP-HR-002] EMP-HR-002 Employee list loads for Firm Admin', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees');
await expectNoBackendError(page);
const body = await readBody(page);
// should see a list or empty state — no crash
expect(body.length).toBeGreaterThan(0);
});
test('[V25-EMP-HR-003] EMP-HR-003 Employee list search does not crash', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees?q=test&link_status=linked');
await expectNoBackendError(page);
});
test('[V25-EMP-HR-004] EMP-HR-004 Employee create form loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/new');
await expectNoBackendError(page);
const body = await readBody(page);
expect(/full.?name|employee.?code|create|add/i.test(body)).toBeTruthy();
});
test('[V25-EMP-HR-005] EMP-HR-005 Employee create with blank form shows validation, not 500', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/new');
const submit = page.locator('button[type="submit"], input[type="submit"]').first();
if (await submit.count()) {
await submit.click().catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
}
await expectNoBackendError(page);
});
test('[V25-EMP-HR-006] EMP-HR-006 Employee detail page loads', async ({ page }) => {
skipIfMissing('EMPLOYEE_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/employees/${idOr('EMPLOYEE_A_ID')}`);
await expectNoBackendError(page);
});
test('[V25-EMP-HR-007] EMP-HR-007 Employee edit form loads', async ({ page }) => {
skipIfMissing('EMPLOYEE_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/employees/${idOr('EMPLOYEE_A_ID')}/edit`);
await expectNoBackendError(page);
});
});
// ---------------------------------------------------------------------------
// Attendance (HR admin)
// ---------------------------------------------------------------------------
test.describe('EMP-HR: Attendance management', () => {
test('[V25-EMP-ATT-001] EMP-ATT-001 HR attendance list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/attendance');
await expectNoBackendError(page);
});
test('[V25-EMP-ATT-002] EMP-ATT-002 HR attendance list with filters does not crash', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/attendance?status=present&approval_status=pending');
await expectNoBackendError(page);
});
test('[V25-EMP-ATT-003] EMP-ATT-003 Manual attendance CSRF-less POST is rejected', async ({ request }) => {
const resp = await request.post(`${process.env.BASE_URL}/employees/attendance/manual`, {
form: { employee_id: idOr('EMPLOYEE_A_ID'), attendance_date: '2025-01-01', status: 'present', csrf_token: '' },
}).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-EMP-ATT-004] EMP-ATT-004 Attendance review CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('ATTENDANCE_ID');
const resp = await request.post(
`${process.env.BASE_URL}/employees/attendance/${idOr('ATTENDANCE_ID')}/review`,
{ form: { approval_status: 'approved', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// Leave types & balances (HR admin)
// ---------------------------------------------------------------------------
test.describe('EMP-HR: Leave management', () => {
test('[V25-EMP-LEAVE-001] EMP-LEAVE-001 Leave types list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/leave-types');
await expectNoBackendError(page);
});
test('[V25-EMP-LEAVE-002] EMP-LEAVE-002 Leave balances list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/leave-balances');
await expectNoBackendError(page);
});
test('[V25-EMP-LEAVE-003] EMP-LEAVE-003 Leave requests list (pending) loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/leave?status=pending');
await expectNoBackendError(page);
});
test('[V25-EMP-LEAVE-004] EMP-LEAVE-004 Leave requests list (all) loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/leave?status=all');
await expectNoBackendError(page);
});
test('[V25-EMP-LEAVE-005] EMP-LEAVE-005 Leave review CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('LEAVE_REQUEST_ID');
const resp = await request.post(
`${process.env.BASE_URL}/employees/leave/${idOr('LEAVE_REQUEST_ID')}/review`,
{ form: { status: 'approved', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// Onboarding / Offboarding (HR admin)
// ---------------------------------------------------------------------------
test.describe('EMP-HR: Onboarding and offboarding', () => {
test('[V25-EMP-OB-001] EMP-OB-001 Onboarding checklist template page loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/onboarding-checklist');
await expectNoBackendError(page);
});
test('[V25-EMP-OB-002] EMP-OB-002 Onboarding tasks list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/onboarding');
await expectNoBackendError(page);
});
test('[V25-EMP-OB-003] EMP-OB-003 Offboarding requests list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/offboarding');
await expectNoBackendError(page);
});
test('[V25-EMP-OB-004] EMP-OB-004 Registration requests list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/registration-requests');
await expectNoBackendError(page);
});
test('[V25-EMP-OB-005] EMP-OB-005 Registration request detail loads', async ({ page }) => {
skipIfMissing('REG_REQUEST_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/employees/registration-requests/${idOr('REG_REQUEST_ID')}`);
await expectNoBackendError(page);
});
});
// ---------------------------------------------------------------------------
// Documents (HR admin)
// ---------------------------------------------------------------------------
test.describe('EMP-HR: Employee documents', () => {
test('[V25-EMP-DOC-001] EMP-DOC-001 Document types list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/document-types');
await expectNoBackendError(page);
});
test('[V25-EMP-DOC-002] EMP-DOC-002 Employee documents list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/documents');
await expectNoBackendError(page);
});
test('[V25-EMP-DOC-003] EMP-DOC-003 Executable upload to employee documents is blocked', async ({ page }) => {
skipIfMissing('EMPLOYEE_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/employees/${idOr('EMPLOYEE_A_ID')}`);
await expectNoBackendError(page);
const fileInput = page.locator('input[type="file"]').first();
if (!(await fileInput.count())) test.skip(true, 'No file input on employee detail page');
const { uploadPath } = require('../fixtures/v204-helpers');
await fileInput.setInputFiles(uploadPath('not-a-pdf.exe'));
const submit = page.locator('form:has(input[type="file"]) button[type="submit"]').first();
if (await submit.count()) await submit.click().catch(() => {});
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('[V25-EMP-DOC-004] EMP-DOC-004 Document review CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('EMP_DOCUMENT_ID');
const resp = await request.post(
`${process.env.BASE_URL}/employees/documents/${idOr('EMP_DOCUMENT_ID')}/review`,
{ form: { status: 'verified', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// Payroll (HR admin)
// ---------------------------------------------------------------------------
test.describe('EMP-HR: Payroll', () => {
test('[V25-EMP-PAY-001] EMP-PAY-001 Salary structures list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/payroll/structures');
await expectNoBackendError(page);
});
test('[V25-EMP-PAY-002] EMP-PAY-002 Payroll runs list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/payroll/runs');
await expectNoBackendError(page);
});
test('[V25-EMP-PAY-003] EMP-PAY-003 Payslips list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/payroll/payslips');
await expectNoBackendError(page);
});
test('[V25-EMP-PAY-004] EMP-PAY-004 Payslips filtered by run loads', async ({ page }) => {
skipIfMissing('PAYROLL_RUN_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/employees/payroll/payslips?payroll_run_id=${idOr('PAYROLL_RUN_ID')}`);
await expectNoBackendError(page);
});
test('[V25-EMP-PAY-005] EMP-PAY-005 Payroll run generate CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('PAYROLL_RUN_ID');
const resp = await request.post(
`${process.env.BASE_URL}/employees/payroll/runs/${idOr('PAYROLL_RUN_ID')}/generate`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-EMP-PAY-006] EMP-PAY-006 Payroll run approve CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('PAYROLL_RUN_ID');
const resp = await request.post(
`${process.env.BASE_URL}/employees/payroll/runs/${idOr('PAYROLL_RUN_ID')}/approve`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// Work assignment dashboard (HR admin)
// ---------------------------------------------------------------------------
test.describe('EMP-HR: Work allocation', () => {
test('[V25-EMP-WORK-001] EMP-WORK-001 Work allocation dashboard loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/work');
await expectNoBackendError(page);
});
test('[V25-EMP-WORK-002] EMP-WORK-002 Engagement progress dashboard loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/progress');
await expectNoBackendError(page);
});
test('[V25-EMP-WORK-003] EMP-WORK-003 HR Excel imports page loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/imports');
await expectNoBackendError(page);
});
});
// ---------------------------------------------------------------------------
// Employee Self-Service portal (/employee/...)
// ---------------------------------------------------------------------------
test.describe('EMP-ESS: Employee self-service portal', () => {
test('[V25-EMP-ESS-001] EMP-ESS-001 ESS dashboard loads for Staff', async ({ page }) => {
await login(page, 'Staff');
await safeGoto(page, '/employee/dashboard');
await expectNoBackendError(page);
});
test('[V25-EMP-ESS-002] EMP-ESS-002 My attendance page loads', async ({ page }) => {
await login(page, 'Staff');
await safeGoto(page, '/employee/attendance');
await expectNoBackendError(page);
});
test('[V25-EMP-ESS-003] EMP-ESS-003 My leave page loads', async ({ page }) => {
await login(page, 'Staff');
await safeGoto(page, '/employee/leave');
await expectNoBackendError(page);
});
test('[V25-EMP-ESS-004] EMP-ESS-004 My documents page loads', async ({ page }) => {
await login(page, 'Staff');
await safeGoto(page, '/employee/documents');
await expectNoBackendError(page);
});
test('[V25-EMP-ESS-005] EMP-ESS-005 My payslips page loads', async ({ page }) => {
await login(page, 'Staff');
await safeGoto(page, '/employee/payslips');
await expectNoBackendError(page);
});
test('[V25-EMP-ESS-006] EMP-ESS-006 My work kanban loads', async ({ page }) => {
await login(page, 'Staff');
await safeGoto(page, '/employee/work');
await expectNoBackendError(page);
});
test('[V25-EMP-ESS-007] EMP-ESS-007 My offboarding page loads', async ({ page }) => {
await login(page, 'Staff');
await safeGoto(page, '/employee/offboarding');
await expectNoBackendError(page);
});
test('[V25-EMP-ESS-008] EMP-ESS-008 My profile page loads', async ({ page }) => {
await login(page, 'Staff');
await safeGoto(page, '/employee/profile');
await expectNoBackendError(page);
});
test('[V25-EMP-ESS-009] EMP-ESS-009 Employee registration form loads for unlinked user', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/employee/register');
await expectNoBackendError(page);
// may redirect to dashboard if already registered — both are fine
expect(resp.status()).toBeLessThan(500);
});
test('[V25-EMP-ESS-010] EMP-ESS-010 Leave application CSRF-less POST is rejected', async ({ request }) => {
const resp = await request.post(`${process.env.BASE_URL}/employee/leave/apply`, {
form: { leave_type_id: '1', from_date: '2025-06-01', to_date: '2025-06-01', csrf_token: '' },
}).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// RBAC: Staff must not reach HR admin pages
// ---------------------------------------------------------------------------
test.describe('EMP-RBAC: Access control', () => {
test('[V25-EMP-RBAC-001] EMP-RBAC-001 Staff cannot access HR dashboard', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/employees/dashboard');
await expectNoBackendError(page);
const body = await readBody(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-EMP-RBAC-002] EMP-RBAC-002 Staff cannot access employee list', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/employees');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-EMP-RBAC-003] EMP-RBAC-003 Staff cannot access payroll runs', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/employees/payroll/runs');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-EMP-RBAC-004] EMP-RBAC-004 Client cannot access employee portal', async ({ page }) => {
await login(page, 'Client');
const resp = await safeGoto(page, '/employee/dashboard');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-EMP-RBAC-005] EMP-RBAC-005 Anonymous cannot access any employee route', async ({ page }) => {
for (const route of ['/employees', '/employees/dashboard', '/employee/dashboard']) {
const resp = await safeGoto(page, route);
await expectBlockedOrSafe(page, resp);
}
});
});
// ---------------------------------------------------------------------------
// IDOR / Security probes
// ---------------------------------------------------------------------------
test.describe('EMP-SEC: Security probes', () => {
test('[V25-EMP-SEC-001] EMP-SEC-001 Non-existent employee ID returns safe response', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/employees/999999999');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-EMP-SEC-002] EMP-SEC-002 Non-existent payroll run ID returns safe response', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/employees/payroll/payslips?payroll_run_id=999999999');
await expectNoBackendError(page);
expect(resp.status()).toBeLessThan(500);
});
test('[V25-EMP-SEC-003] EMP-SEC-003 HR import commit without preview session is rejected safely', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/employees/imports');
// Attempt to POST commit without having a session preview
const resp = await page.request.post(`${process.env.BASE_URL}/employees/imports/commit`, {
form: { csrf_token: 'invalid' },
}).catch(() => null);
if (!resp) test.skip(true, 'Request failed at network level');
expect(resp.status()).toBeLessThan(500);
});
test('[V25-EMP-SEC-004] EMP-SEC-004 Payroll run generate by Staff is blocked', async ({ request }) => {
skipIfMissing('PAYROLL_RUN_ID');
// Staff should not be able to trigger payroll — attempt raw API call
const resp = await request.post(
`${process.env.BASE_URL}/employees/payroll/runs/${idOr('PAYROLL_RUN_ID')}/generate`,
{ form: { csrf_token: 'invalid' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
});
+373
View File
@@ -0,0 +1,373 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-MKT-001",
"module": "Marketplace / Leads",
"role": "Firm Admin",
"scenario": "Marketplace dashboard loads",
"type": "UAT",
"route": "/marketplace",
"_kind": "page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-MKT-002",
"module": "Marketplace / Leads",
"role": "Firm Admin",
"scenario": "Leads list loads",
"type": "UAT",
"route": "/marketplace/leads",
"_kind": "page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-MKT-003",
"module": "Marketplace / Leads",
"role": "Firm Admin",
"scenario": "New lead page loads",
"type": "UAT",
"route": "/marketplace/leads/new",
"_kind": "page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-MKT-004",
"module": "Marketplace / Leads",
"role": "Public",
"scenario": "Public lead page loads without auth",
"type": "UAT",
"route": "/marketplace/public-lead",
"_kind": "public",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-005",
"module": "Marketplace / Leads",
"role": "Firm Admin",
"scenario": "Invalid lead detail safe",
"type": "VAPT",
"route": "/marketplace/leads/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-MKT-006",
"module": "Marketplace / Leads",
"role": "Firm Admin",
"scenario": "Lead assign invalid ID safe",
"type": "VAPT",
"route": "/marketplace/leads/999999/assign",
"_kind": "safe-page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-MKT-007",
"module": "Marketplace / Leads",
"role": "Firm Admin",
"scenario": "Lead status invalid ID safe",
"type": "VAPT",
"route": "/marketplace/leads/999999/status",
"_kind": "safe-page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-MKT-008",
"module": "Marketplace / Leads",
"role": "Firm Admin",
"scenario": "Lead convert invalid ID safe",
"type": "VAPT",
"route": "/marketplace/leads/999999/convert-client",
"_kind": "safe-page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-MKT-009",
"module": "Marketplace / Leads",
"role": "Staff",
"scenario": "Staff lead list access blocked or safe",
"type": "VAPT",
"route": "/marketplace/leads",
"_kind": "safe-page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-MKT-010",
"module": "Marketplace / Leads",
"role": "Client",
"scenario": "Client lead list access blocked",
"type": "VAPT",
"route": "/marketplace/leads",
"_kind": "safe-page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-MKT-011",
"module": "Marketplace / Leads",
"role": "Consultant",
"scenario": "Consultant internal leads access blocked",
"type": "VAPT",
"route": "/marketplace/leads",
"_kind": "safe-page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-MKT-012",
"module": "Marketplace / Leads",
"role": "Public",
"scenario": "Public route does not expose internal list",
"type": "VAPT",
"route": "/marketplace/public-lead",
"_kind": "public-no-sensitive",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-013",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Public lead blank CSRF-less POST handled safely",
"type": "VAPT",
"route": "/marketplace/public-lead",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-014",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Internal lead create CSRF-less POST rejected",
"type": "VAPT",
"route": "/marketplace/leads/new",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-015",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Lead assign CSRF-less POST rejected",
"type": "VAPT",
"route": "/marketplace/leads/999999/assign",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-016",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Lead status CSRF-less POST rejected",
"type": "VAPT",
"route": "/marketplace/leads/999999/status",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-017",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Lead convert-client CSRF-less POST rejected",
"type": "VAPT",
"route": "/marketplace/leads/999999/convert-client",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-018",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Lead duplicate check invalid POST safe",
"type": "VAPT",
"route": "/marketplace/leads/check-duplicate",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-019",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Lead note add CSRF-less POST rejected",
"type": "VAPT",
"route": "/marketplace/leads/999999/notes",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-020",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Lead attachment upload CSRF-less POST rejected",
"type": "VAPT",
"route": "/marketplace/leads/999999/upload",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-021",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Cross-tenant lead probe safe",
"type": "VAPT",
"route": "/marketplace/leads/999999?tenant_id=999999",
"_kind": "post",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
},
{
"variantId": "V251-MKT-022",
"module": "Marketplace / Leads",
"role": "Anonymous/Attacker",
"scenario": "Lead export requires authorization",
"type": "VAPT",
"route": "/marketplace/leads/export",
"_kind": "safe-page",
"_method": "GET",
"_file": "marketplace-leads.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - Marketplace / Leads", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+385
View File
@@ -0,0 +1,385 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-NCASE-001",
"module": "Notice Case Business Rules",
"role": "Firm Admin",
"scenario": "Notice cases list loads",
"type": "UAT",
"route": "/notice-cases",
"_kind": "page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-NCASE-002",
"module": "Notice Case Business Rules",
"role": "Firm Admin",
"scenario": "Notice case new page loads",
"type": "UAT",
"route": "/notice-cases/new",
"_kind": "page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-NCASE-003",
"module": "Notice Case Business Rules",
"role": "Firm Admin",
"scenario": "Invalid case detail safe",
"type": "VAPT",
"route": "/notice-cases/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-NCASE-004",
"module": "Notice Case Business Rules",
"role": "Client",
"scenario": "Client cannot access internal case list",
"type": "VAPT",
"route": "/notice-cases",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-NCASE-005",
"module": "Notice Case Business Rules",
"role": "Consultant",
"scenario": "Consultant cannot access internal case list",
"type": "VAPT",
"route": "/notice-cases",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-NCASE-006",
"module": "Notice Case Business Rules",
"role": "Staff",
"scenario": "Staff case list access safe",
"type": "UAT",
"route": "/notice-cases",
"_kind": "page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-NCASE-007",
"module": "Notice Case Business Rules",
"role": "Firm Admin",
"scenario": "Invalid case events safe",
"type": "VAPT",
"route": "/notice-cases/999999/events",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-NCASE-008",
"module": "Notice Case Business Rules",
"role": "Firm Admin",
"scenario": "Invalid case hearing safe",
"type": "VAPT",
"route": "/notice-cases/999999/hearings",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-NCASE-009",
"module": "Notice Case Business Rules",
"role": "Firm Admin",
"scenario": "Invalid case order safe",
"type": "VAPT",
"route": "/notice-cases/999999/orders",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-NCASE-010",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Create notice case CSRF-less POST rejected",
"type": "VAPT",
"route": "/notice-cases/new",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-011",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Notice date/hearing date invalid POST safe",
"type": "VAPT",
"route": "/notice-cases/999999/hearings",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-012",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Order date before hearing invalid POST safe",
"type": "VAPT",
"route": "/notice-cases/999999/orders",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-013",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Invalid case status transition rejected",
"type": "VAPT",
"route": "/notice-cases/999999/status",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-014",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Case document upload invalid ID rejected",
"type": "VAPT",
"route": "/notice-cases/999999/documents/upload",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-015",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Cross-case document IDOR safe",
"type": "VAPT",
"route": "/notice-cases/999999/documents/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-016",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Client-visible document invalid route safe",
"type": "VAPT",
"route": "/client/notice-cases/999999/documents/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-017",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Internal note hidden from client route safe",
"type": "VAPT",
"route": "/client/notice-cases/999999/notes",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-018",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Case deadline reminder invalid ID safe",
"type": "VAPT",
"route": "/notice-cases/999999/reminders",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-019",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Case assignment CSRF-less POST rejected",
"type": "VAPT",
"route": "/notice-cases/999999/assign",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-020",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Case event delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/notice-cases/events/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-021",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Case hearing delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/notice-cases/hearings/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-022",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Case order delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/notice-cases/orders/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
},
{
"variantId": "V251-NCASE-023",
"module": "Notice Case Business Rules",
"role": "Anonymous/Attacker",
"scenario": "Cross-tenant notice case IDOR safe",
"type": "VAPT",
"route": "/notice-cases/999999?tenant_id=999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "notice-case-business-rules.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - Notice Case Business Rules", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+331
View File
@@ -0,0 +1,331 @@
/**
* =============================================================================
* UAT_NoticeCases_Services_Work -- Notice/case depth, services depth,
* work detail and task operations
* =============================================================================
*
* Covers:
* CASE-* : Notice case detail, edit, sub-pages (events, hearings, orders)
* SVC-* : Services — bulk imports, bulk lock, task operations, subscriptions
* WORK-* : Work detail — task status, comments, engagement detail
* SEC-* : CSRF rejection and IDOR probes for all three modules
*
* Required .env additions:
* NOTICE_CASE_A_ID= # already in main suite; also used here for sub-pages
* CASE_DOCUMENT_A_ID= # a seeded notice case document id
* SUBSCRIPTION_A_ID= # a seeded client service subscription id
* TASK_A_ID= # a seeded service execution task id
* ENGAGEMENT_A_ID= # a seeded service engagement id
*
* =============================================================================
*/
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
function idOr(envKey, fallback = '1') {
return process.env[envKey] || fallback;
}
function skipIfMissing(envKey) {
if (!process.env[envKey]) test.skip(true, `Set ${envKey} in .env after seeding`);
}
// ---------------------------------------------------------------------------
// Notice cases — sub-pages not covered in existing vapt-targeted.spec.js
// ---------------------------------------------------------------------------
test.describe('CASE: Notice case sub-pages', () => {
test('[V25-CASE-001] CASE-001 Notice case list loads', async ({ page }) => {
await login(page, 'System Admin');
await safeGoto(page, '/notice-cases');
await expectNoBackendError(page);
});
test('[V25-CASE-002] CASE-002 Notice case detail page loads', async ({ page }) => {
skipIfMissing('NOTICE_CASE_A_ID');
await login(page, 'System Admin');
await safeGoto(page, `/notice-cases/${idOr('NOTICE_CASE_A_ID')}`);
await expectNoBackendError(page);
});
test('[V25-CASE-003] CASE-003 Notice case edit page loads', async ({ page }) => {
skipIfMissing('NOTICE_CASE_A_ID');
await login(page, 'System Admin');
await safeGoto(page, `/notice-cases/${idOr('NOTICE_CASE_A_ID')}/edit`);
await expectNoBackendError(page);
});
test('[V25-CASE-004] CASE-004 Notice case events sub-page loads', async ({ page }) => {
skipIfMissing('NOTICE_CASE_A_ID');
await login(page, 'System Admin');
await safeGoto(page, `/notice-cases/${idOr('NOTICE_CASE_A_ID')}/events`);
await expectNoBackendError(page);
});
test('[V25-CASE-005] CASE-005 Notice case hearings sub-page loads', async ({ page }) => {
skipIfMissing('NOTICE_CASE_A_ID');
await login(page, 'System Admin');
await safeGoto(page, `/notice-cases/${idOr('NOTICE_CASE_A_ID')}/hearings`);
await expectNoBackendError(page);
});
test('[V25-CASE-006] CASE-006 Notice case orders sub-page loads', async ({ page }) => {
skipIfMissing('NOTICE_CASE_A_ID');
await login(page, 'System Admin');
await safeGoto(page, `/notice-cases/${idOr('NOTICE_CASE_A_ID')}/orders`);
await expectNoBackendError(page);
});
test('[V25-CASE-007] CASE-007 Notice case document download requires auth', async ({ page }) => {
skipIfMissing('CASE_DOCUMENT_A_ID');
// test as anonymous
const resp = await safeGoto(page, `/notice-cases/documents/${idOr('CASE_DOCUMENT_A_ID')}/download`);
const body = await readBody(page);
await blockedOrNotFound(resp, body);
});
test('[V25-CASE-008] CASE-008 Notice case document delete CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('CASE_DOCUMENT_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/notice-cases/documents/${idOr('CASE_DOCUMENT_A_ID')}/delete`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-CASE-009] CASE-009 Notice case upload CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('NOTICE_CASE_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/notice-cases/${idOr('NOTICE_CASE_A_ID')}/documents/upload`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-CASE-010] CASE-010 Client cannot access notice cases', async ({ page }) => {
await login(page, 'Client');
const resp = await safeGoto(page, '/notice-cases');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-CASE-011] CASE-011 IDOR: cross-case document download blocked', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/notice-cases/999999999/documents/999999/download');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-CASE-012] CASE-012 Non-existent case detail returns safe response', async ({ page }) => {
await login(page, 'System Admin');
const resp = await safeGoto(page, '/notice-cases/999999999');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
});
// ---------------------------------------------------------------------------
// Services — bulk imports, bulk lock, subscription ops
// ---------------------------------------------------------------------------
test.describe('SVC: Services depth', () => {
test('[V25-SVC-001] SVC-001 Bulk imports page loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/services/bulk-imports');
await expectNoBackendError(page);
});
test('[V25-SVC-002] SVC-002 Bulk imports service-master template downloads', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/services/bulk-imports/templates/service-master.xlsx');
expect(resp.status()).toBeLessThan(500);
});
test('[V25-SVC-003] SVC-003 Bulk imports engagement-assignments template downloads', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/services/bulk-imports/templates/engagement-assignments.xlsx');
expect(resp.status()).toBeLessThan(500);
});
test('[V25-SVC-004] SVC-004 Bulk imports firm-task-templates template downloads', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/services/bulk-imports/templates/firm-task-templates.xlsx');
expect(resp.status()).toBeLessThan(500);
});
test('[V25-SVC-005] SVC-005 Bulk import preview CSRF-less POST is rejected', async ({ request }) => {
const resp = await request.post(
`${process.env.BASE_URL}/services/bulk-imports/service-master`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-SVC-006] SVC-006 Subscription detail loads', async ({ page }) => {
skipIfMissing('SUBSCRIPTION_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/services/${idOr('SUBSCRIPTION_A_ID')}`);
await expectNoBackendError(page);
});
test('[V25-SVC-007] SVC-007 Subscription edit page loads', async ({ page }) => {
skipIfMissing('SUBSCRIPTION_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/services/${idOr('SUBSCRIPTION_A_ID')}/edit`);
await expectNoBackendError(page);
});
test('[V25-SVC-008] SVC-008 Subscription lock CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('SUBSCRIPTION_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/services/${idOr('SUBSCRIPTION_A_ID')}/lock`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-SVC-009] SVC-009 Bulk lock CSRF-less POST is rejected', async ({ request }) => {
const resp = await request.post(
`${process.env.BASE_URL}/services/bulk-lock`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-SVC-010] SVC-010 Subscription generate CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('SUBSCRIPTION_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/services/subscriptions/${idOr('SUBSCRIPTION_A_ID')}/generate`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-SVC-011] SVC-011 Client cannot access services admin', async ({ page }) => {
await login(page, 'Client');
const resp = await safeGoto(page, '/services/bulk-imports');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-SVC-012] SVC-012 Non-existent subscription ID returns safe response', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/services/999999999');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-SVC-013] SVC-013 Task bulk-update CSRF-less POST is rejected', async ({ request }) => {
const resp = await request.post(
`${process.env.BASE_URL}/services/tasks/bulk-update`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// Work detail — task operations
// ---------------------------------------------------------------------------
test.describe('WORK: Work detail and task operations', () => {
test('[V25-WORK-001] WORK-001 Engagement work detail page loads', async ({ page }) => {
skipIfMissing('ENGAGEMENT_A_ID');
await login(page, 'System Admin');
await safeGoto(page, `/work/engagements/${idOr('ENGAGEMENT_A_ID')}`);
await expectNoBackendError(page);
});
test('[V25-WORK-002] WORK-002 Task status update CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('TASK_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/work/tasks/${idOr('TASK_A_ID')}/status`,
{ form: { status: 'completed', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-WORK-003] WORK-003 Task comment POST CSRF-less is rejected', async ({ request }) => {
skipIfMissing('TASK_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/work/tasks/${idOr('TASK_A_ID')}/comment`,
{ form: { message: 'test comment', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-WORK-004] WORK-004 Task comments list page loads', async ({ page }) => {
skipIfMissing('TASK_A_ID');
await login(page, 'System Admin');
await safeGoto(page, `/services/tasks/${idOr('TASK_A_ID')}/comments`);
await expectNoBackendError(page);
});
test('[V25-WORK-005] WORK-005 Task edit page loads', async ({ page }) => {
skipIfMissing('TASK_A_ID');
await login(page, 'System Admin');
await safeGoto(page, `/services/tasks/${idOr('TASK_A_ID')}/edit`);
await expectNoBackendError(page);
});
test('[V25-WORK-006] WORK-006 Task upload CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('TASK_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/documents/tasks/${idOr('TASK_A_ID')}/upload`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-WORK-007] WORK-007 Non-existent task returns safe response', async ({ page }) => {
await login(page, 'System Admin');
const resp = await safeGoto(page, '/services/tasks/999999999/edit');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-WORK-008] WORK-008 IDOR: Staff cannot update task assigned to another user via direct POST', async ({ request }) => {
skipIfMissing('TASK_A_ID');
// Raw API attempt without valid session for a different user
const resp = await request.post(
`${process.env.BASE_URL}/work/tasks/${idOr('TASK_A_ID')}/status`,
{ form: { status: 'completed', csrf_token: 'invalid' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-WORK-009] WORK-009 Client cannot access work detail', async ({ page }) => {
skipIfMissing('ENGAGEMENT_A_ID');
await login(page, 'Client');
const resp = await safeGoto(page, `/work/engagements/${idOr('ENGAGEMENT_A_ID')}`);
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
});
+233
View File
@@ -0,0 +1,233 @@
/**
* =============================================================================
* UAT_Partners_Billing -- Partners portal + firm-level billing
* =============================================================================
*
* Covers:
* PART-* : Partner portal (/partner/...) and partner-facing review routes
* BILL-* : Firm billing — invoices, payments, receipts, fee structures
* RBAC-* : Access control checks across both modules
* SEC-* : CSRF rejection and anonymous access probes
*
* Required .env additions:
* INVOICE_A_ID= # a seeded firm invoice id (status: draft)
* PAYMENT_A_ID= # a seeded payment id against INVOICE_A_ID
* PARTNER_TASK_A_ID= # a seeded service task assigned for partner review
*
* Tests skip gracefully when env vars are absent.
* =============================================================================
*/
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
function idOr(envKey, fallback = '1') {
return process.env[envKey] || fallback;
}
function skipIfMissing(envKey) {
if (!process.env[envKey]) test.skip(true, `Set ${envKey} in .env after seeding`);
}
// ---------------------------------------------------------------------------
// Partners portal
// ---------------------------------------------------------------------------
test.describe('PART: Partner portal', () => {
test('[V25-PART-001] PART-001 Partner dashboard loads', async ({ page }) => {
await login(page, 'Partner');
const resp = await safeGoto(page, '/partner/dashboard');
await expectNoBackendError(page);
expect(resp.status()).toBeLessThan(500);
});
test('[V25-PART-002] PART-002 Partner client list loads', async ({ page }) => {
await login(page, 'Partner');
await safeGoto(page, '/partner/clients');
await expectNoBackendError(page);
});
test('[V25-PART-003] PART-003 Partner reviews list loads', async ({ page }) => {
await login(page, 'Partner');
await safeGoto(page, '/partner/reviews');
await expectNoBackendError(page);
});
test('[V25-PART-004] PART-004 Partner task review page loads', async ({ page }) => {
skipIfMissing('PARTNER_TASK_A_ID');
await login(page, 'Partner');
await safeGoto(page, `/partner/tasks/${idOr('PARTNER_TASK_A_ID')}/review`);
await expectNoBackendError(page);
});
test('[V25-PART-005] PART-005 Partner task review CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('PARTNER_TASK_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/partner/tasks/${idOr('PARTNER_TASK_A_ID')}/review`,
{ form: { status: 'approved', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-PART-006] PART-006 Staff cannot access partner dashboard', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/partner/dashboard');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-PART-007] PART-007 Client cannot access partner reviews', async ({ page }) => {
await login(page, 'Client');
const resp = await safeGoto(page, '/partner/reviews');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-PART-008] PART-008 Anonymous access to partner dashboard is blocked', async ({ page }) => {
const resp = await safeGoto(page, '/partner/dashboard');
await expectBlockedOrSafe(page, resp);
});
test('[V25-PART-009] PART-009 Non-existent task review ID returns safe response', async ({ page }) => {
await login(page, 'Partner');
const resp = await safeGoto(page, '/partner/tasks/999999999/review');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
});
// ---------------------------------------------------------------------------
// Firm billing — invoices
// ---------------------------------------------------------------------------
test.describe('BILL: Invoices and payments', () => {
test('[V25-BILL-001] BILL-001 Invoice list loads for Firm Admin', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/billing');
await expectNoBackendError(page);
});
test('[V25-BILL-002] BILL-002 Invoice create form loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/billing/invoices/new');
await expectNoBackendError(page);
});
test('[V25-BILL-003] BILL-003 Invoice detail page loads', async ({ page }) => {
skipIfMissing('INVOICE_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}`);
await expectNoBackendError(page);
});
test('[V25-BILL-004] BILL-004 Invoice print page loads', async ({ page }) => {
skipIfMissing('INVOICE_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}/print`);
await expectNoBackendError(page);
});
test('[V25-BILL-005] BILL-005 Invoice payments subpage loads', async ({ page }) => {
skipIfMissing('INVOICE_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/billing/invoices/${idOr('INVOICE_A_ID')}/payments`);
await expectNoBackendError(page);
});
test('[V25-BILL-006] BILL-006 Payment receipt loads', async ({ page }) => {
skipIfMissing('PAYMENT_A_ID');
await login(page, 'Firm Admin');
await safeGoto(page, `/billing/payments/${idOr('PAYMENT_A_ID')}/receipt`);
await expectNoBackendError(page);
});
test('[V25-BILL-007] BILL-007 Payments list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/billing/payments');
await expectNoBackendError(page);
});
test('[V25-BILL-008] BILL-008 Fee structures list loads', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/billing/fee-structures/list');
await expectNoBackendError(page);
});
test('[V25-BILL-009] BILL-009 Invoice post CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('INVOICE_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/billing/invoices/${idOr('INVOICE_A_ID')}/issue`,
{ form: { csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-BILL-010] BILL-010 New payment CSRF-less POST is rejected', async ({ request }) => {
skipIfMissing('INVOICE_A_ID');
const resp = await request.post(
`${process.env.BASE_URL}/billing/invoices/${idOr('INVOICE_A_ID')}/payments/new`,
{ form: { amount: '1000', csrf_token: '' } }
).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) test.skip(true, 'Route not available');
expect([400, 401, 403, 422].includes(resp.status())).toBeTruthy();
});
test('[V25-BILL-011] BILL-011 Staff cannot access invoice list', async ({ page }) => {
await login(page, 'Staff');
const resp = await safeGoto(page, '/billing');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-BILL-012] BILL-012 Client cannot access billing admin', async ({ page }) => {
await login(page, 'Client');
const resp = await safeGoto(page, '/billing/invoices/new');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-BILL-013] BILL-013 Anonymous access to billing is blocked', async ({ page }) => {
const resp = await safeGoto(page, '/billing');
await expectBlockedOrSafe(page, resp);
});
test('[V25-BILL-014] BILL-014 Non-existent invoice ID returns safe response', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/billing/invoices/999999999');
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
test('[V25-BILL-015] BILL-015 Invoice create with blank form shows validation, not 500', async ({ page }) => {
await login(page, 'Firm Admin');
await safeGoto(page, '/billing/invoices/new');
const submit = page.locator('button[type="submit"], input[type="submit"]').first();
if (await submit.count()) {
await submit.click().catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
}
await expectNoBackendError(page);
});
test('[V25-BILL-016] BILL-016 Fee structure import template download works', async ({ page }) => {
await login(page, 'Firm Admin');
const resp = await safeGoto(page, '/billing/fee-structures/template');
// should download or redirect, not crash
expect(resp.status()).toBeLessThan(500);
});
});
+529
View File
@@ -0,0 +1,529 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-PBILL-001",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Plans page loads",
"type": "UAT",
"route": "/platform-billing/plans",
"_kind": "page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-002",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Accounts page loads",
"type": "UAT",
"route": "/platform-billing/accounts",
"_kind": "page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-003",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Audit firm subscriptions page loads",
"type": "UAT",
"route": "/platform-billing/audit-firm-subscriptions",
"_kind": "page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-004",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Client dashboard subscriptions page loads",
"type": "UAT",
"route": "/platform-billing/client-dashboard-subscriptions",
"_kind": "page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-005",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Consultant subscriptions page loads",
"type": "UAT",
"route": "/platform-billing/consultant-subscriptions",
"_kind": "page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-006",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Subscriptions page loads",
"type": "UAT",
"route": "/platform-billing/subscriptions",
"_kind": "page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-007",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Invoices page loads",
"type": "UAT",
"route": "/platform-billing/invoices",
"_kind": "page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-008",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Invalid invoice detail/post safe",
"type": "UAT",
"route": "/platform-billing/invoices/999999/post",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-009",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Invalid invoice payments safe",
"type": "UAT",
"route": "/platform-billing/invoices/999999/payments",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-010",
"module": "Platform Billing",
"role": "System Admin",
"scenario": "Invalid plan detail safe",
"type": "UAT",
"route": "/platform-billing/plans/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-PBILL-011",
"module": "Platform Billing",
"role": "Firm",
"scenario": "Firm Admin cannot manage platform plans",
"type": "VAPT",
"route": "/platform-billing/plans",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-PBILL-012",
"module": "Platform Billing",
"role": "Staff",
"scenario": "Staff cannot access platform accounts",
"type": "VAPT",
"route": "/platform-billing/accounts",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-PBILL-013",
"module": "Platform Billing",
"role": "Client",
"scenario": "Client cannot access platform invoices list",
"type": "VAPT",
"route": "/platform-billing/invoices",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-PBILL-014",
"module": "Platform Billing",
"role": "Consultant",
"scenario": "Consultant cannot access platform invoice list",
"type": "VAPT",
"route": "/platform-billing/invoices",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-PBILL-015",
"module": "Platform Billing",
"role": "Partner",
"scenario": "Partner cannot post platform invoice",
"type": "VAPT",
"route": "/platform-billing/invoices/999999/post",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": "Partner"
},
{
"variantId": "V251-PBILL-016",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Plan create CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/plans",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-017",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Plan update invalid ID CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/plans/999999",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-018",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Account create CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/accounts",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-019",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Audit firm subscription create CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/audit-firm-subscriptions",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-020",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Client dashboard subscription create CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/client-dashboard-subscriptions",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-021",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Consultant subscription create CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/consultant-subscriptions",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-022",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Platform subscription create CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/subscriptions",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-023",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Invoice create CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/invoices",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-024",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Invoice post CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/invoices/999999/post",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-025",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Invoice payment CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/invoices/999999/payments",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-026",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Invoice cancel CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/invoices/999999/cancel",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-027",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Payment over-amount invalid POST safe",
"type": "VAPT",
"route": "/platform-billing/invoices/999999/payments",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-028",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Duplicate plan code invalid POST safe",
"type": "VAPT",
"route": "/platform-billing/plans",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-029",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Duplicate invoice number invalid POST safe",
"type": "VAPT",
"route": "/platform-billing/invoices",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-030",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Invoice export requires auth",
"type": "VAPT",
"route": "/platform-billing/invoices/export",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-031",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Invoice PDF invalid ID safe",
"type": "VAPT",
"route": "/platform-billing/invoices/999999/pdf",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-032",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Cross-account invoice IDOR blocked",
"type": "VAPT",
"route": "/platform-billing/invoices/999999?account_id=999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-033",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Payment receipt invalid ID safe",
"type": "VAPT",
"route": "/platform-billing/payments/999999/receipt",
"_kind": "safe-page",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-034",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Billing account delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/accounts/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
},
{
"variantId": "V251-PBILL-035",
"module": "Platform Billing",
"role": "Anonymous/Attacker",
"scenario": "Plan delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/platform-billing/plans/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "platform-billing.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - Platform Billing", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+541
View File
@@ -0,0 +1,541 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-SYS-001",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Tenants page loads",
"type": "UAT",
"route": "/system-settings/tenants",
"_kind": "page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-002",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Branches page loads",
"type": "UAT",
"route": "/system-settings/branches",
"_kind": "page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-003",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Branding page loads",
"type": "UAT",
"route": "/system-settings/branding",
"_kind": "page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-004",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Financial years page loads",
"type": "UAT",
"route": "/system-settings/financial-years",
"_kind": "page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-005",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "RBAC page loads",
"type": "UAT",
"route": "/system-settings/rbac",
"_kind": "page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-006",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Roles page loads",
"type": "UAT",
"route": "/system-settings/rbac/roles",
"_kind": "page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-007",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Permissions page loads",
"type": "UAT",
"route": "/system-settings/rbac/permissions",
"_kind": "page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-008",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Audit logs page loads",
"type": "UAT",
"route": "/system-settings/audit-logs",
"_kind": "page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-009",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Invalid tenant context switch safe",
"type": "UAT",
"route": "/system-settings/context/tenant/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-010",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Invalid branch context switch safe",
"type": "UAT",
"route": "/system-settings/context/branch/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-011",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "Invalid financial year context switch safe",
"type": "UAT",
"route": "/system-settings/context/financial-year/INVALID",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-012",
"module": "System Settings / Tenancy / FY",
"role": "System Admin",
"scenario": "FY backup export page/action safe",
"type": "UAT",
"route": "/system-settings/financial-years/backup-export",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "System Admin"
},
{
"variantId": "V251-SYS-013",
"module": "System Settings / Tenancy / FY",
"role": "Staff",
"scenario": "Staff cannot access tenants",
"type": "VAPT",
"route": "/system-settings/tenants",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-SYS-014",
"module": "System Settings / Tenancy / FY",
"role": "Client",
"scenario": "Client cannot access branches",
"type": "VAPT",
"route": "/system-settings/branches",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-SYS-015",
"module": "System Settings / Tenancy / FY",
"role": "Consultant",
"scenario": "Consultant cannot access financial years",
"type": "VAPT",
"route": "/system-settings/financial-years",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-SYS-016",
"module": "System Settings / Tenancy / FY",
"role": "Partner",
"scenario": "Partner cannot manage RBAC roles unless permitted",
"type": "VAPT",
"route": "/system-settings/rbac/roles",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "Partner"
},
{
"variantId": "V251-SYS-017",
"module": "System Settings / Tenancy / FY",
"role": "Manager",
"scenario": "Manager cannot switch unauthorized tenant",
"type": "VAPT",
"route": "/system-settings/context/tenant/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "Manager"
},
{
"variantId": "V251-SYS-018",
"module": "System Settings / Tenancy / FY",
"role": "Firm Admin",
"scenario": "Firm Admin cannot switch to unauthorized branch",
"type": "VAPT",
"route": "/system-settings/context/branch/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-SYS-019",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Tenant create CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/tenants",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-020",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Tenant update invalid ID CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/tenants/999999",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-021",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Branch create CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/branches",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-022",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Branch update invalid ID CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/branches/999999",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-023",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Branding save CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/branding",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-024",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Financial year create CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/financial-years",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-025",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Make current FY CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/financial-years/999999/make-current",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-026",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Lock FY CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/financial-years/999999/lock",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-027",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Unlock FY CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/financial-years/999999/unlock",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-028",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "FY backup export CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/financial-years/999999/backup-export",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-029",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "FY backup download invalid ID safe",
"type": "VAPT",
"route": "/system-settings/financial-years/backups/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-030",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Role create CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/rbac/roles",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-031",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Role update invalid ID CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/rbac/roles/999999",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-032",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Permission assign CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/rbac/roles/999999/permissions",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-033",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Permission remove CSRF-less POST rejected",
"type": "VAPT",
"route": "/system-settings/rbac/roles/999999/permissions/999999/remove",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-034",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Duplicate role invalid POST safe",
"type": "VAPT",
"route": "/system-settings/rbac/roles",
"_kind": "post",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-035",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Unauthorized branch context query safe",
"type": "VAPT",
"route": "/system-settings/context/branch/999999?tenant_id=999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
},
{
"variantId": "V251-SYS-036",
"module": "System Settings / Tenancy / FY",
"role": "Anonymous/Attacker",
"scenario": "Audit log export requires authorization",
"type": "VAPT",
"route": "/system-settings/audit-logs/export",
"_kind": "safe-page",
"_method": "GET",
"_file": "system-settings-tenancy.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - System Settings / Tenancy / FY", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});
+469
View File
@@ -0,0 +1,469 @@
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const { expectNoBackendError, readBody, blockedOrNotFound } = require('../fixtures/v204-helpers');
const { expectBlockedOrSafe } = require('../fixtures/assertions');
async function safeGoto(page, route) {
const resp = await page.goto(route).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
return resp;
}
async function expectRouteAvailable(page, resp) {
await expectNoBackendError(page);
if (resp) {
expect(resp.status(), `Expected route to exist but got ${resp.status()} at ${page.url()}`).not.toBe(404);
expect(resp.status()).toBeLessThan(500);
}
}
async function expectNoSecrets(page) {
const body = await readBody(page);
expect(body).not.toMatch(/Password@123|Pass@123|smtp_password|IMAP_PASSWORD|SMTP_PASSWORD|otp\s*[:=]\s*\d{4,8}|reset_token|access_token|refresh_token/i);
}
async function expectPublicNoSensitive(page) {
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(/tenant_id|branch_id|internal note|audit log|smtp|password|secret|token/i);
}
async function apiCall(request, method, route) {
const url = `${process.env.BASE_URL}${route}`;
const opts = { data: { csrf_token: '', test_payload: 'uat-vapt' }, headers: { 'Content-Type': 'application/json' } };
if (method === 'GET') return await request.get(url).catch(() => null);
if (method === 'POST') return await request.post(url, opts).catch(() => null);
if (method === 'PUT') return await request.put(url, opts).catch(() => null);
if (method === 'DELETE') return await request.delete(url).catch(() => null);
if (method === 'OPTIONS') return await request.fetch(url, { method: 'OPTIONS' }).catch(() => null);
return await request.fetch(url, { method }).catch(() => null);
}
async function expectApiSafe(resp) {
expect(resp, 'API response should be available').toBeTruthy();
expect(resp.status()).toBeLessThan(500);
const text = await resp.text().catch(() => '');
expect(text).not.toMatch(/Traceback|Exception in ASGI application|OperationalError|ProgrammingError|AttributeError|UndefinedError|Password@123|Pass@123/i);
}
const cases = [
{
"variantId": "V251-WORK2-001",
"module": "Work Lifecycle E2E",
"role": "Firm Admin",
"scenario": "Engagement create page/list loads",
"type": "UAT",
"route": "/work/engagements",
"_kind": "page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-WORK2-002",
"module": "Work Lifecycle E2E",
"role": "Firm Admin",
"scenario": "Existing engagement detail loads",
"type": "VAPT",
"route": "/work/engagements/1",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Firm Admin"
},
{
"variantId": "V251-WORK2-003",
"module": "Work Lifecycle E2E",
"role": "Staff",
"scenario": "Staff work board loads",
"type": "UAT",
"route": "/work",
"_kind": "page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-WORK2-004",
"module": "Work Lifecycle E2E",
"role": "Manager",
"scenario": "Manager team work board loads",
"type": "UAT",
"route": "/manager/work",
"_kind": "page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Manager"
},
{
"variantId": "V251-WORK2-005",
"module": "Work Lifecycle E2E",
"role": "Partner",
"scenario": "Partner reviews page loads",
"type": "UAT",
"route": "/partner/reviews",
"_kind": "page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Partner"
},
{
"variantId": "V251-WORK2-006",
"module": "Work Lifecycle E2E",
"role": "Client",
"scenario": "Client dashboard compliance item list loads",
"type": "UAT",
"route": "/client/dashboard",
"_kind": "page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-WORK2-007",
"module": "Work Lifecycle E2E",
"role": "Consultant",
"scenario": "Consultant assignment area loads",
"type": "UAT",
"route": "/consultants",
"_kind": "page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-WORK2-008",
"module": "Work Lifecycle E2E",
"role": "Staff",
"scenario": "Invalid engagement ID is safe",
"type": "VAPT",
"route": "/work/engagements/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Staff"
},
{
"variantId": "V251-WORK2-009",
"module": "Work Lifecycle E2E",
"role": "Client",
"scenario": "Client cannot access internal engagement detail",
"type": "VAPT",
"route": "/work/engagements/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Client"
},
{
"variantId": "V251-WORK2-010",
"module": "Work Lifecycle E2E",
"role": "Consultant",
"scenario": "Consultant cannot access internal engagement detail",
"type": "VAPT",
"route": "/work/engagements/999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": "Consultant"
},
{
"variantId": "V251-WORK2-011",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Create engagement CSRF-less POST rejected",
"type": "VAPT",
"route": "/work/engagements",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-012",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Generate tasks CSRF-less POST rejected",
"type": "VAPT",
"route": "/work/engagements/999999/generate-tasks",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-013",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Assign task CSRF-less POST rejected",
"type": "VAPT",
"route": "/work/tasks/999999/assign",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-014",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Staff start task invalid ID safe",
"type": "VAPT",
"route": "/work/tasks/999999/start",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-015",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Staff complete task invalid ID safe",
"type": "VAPT",
"route": "/work/tasks/999999/complete",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-016",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Task upload document invalid ID rejected",
"type": "VAPT",
"route": "/work/tasks/999999/documents/upload",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-017",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Manager review invalid task rejected",
"type": "VAPT",
"route": "/work/tasks/999999/review",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-018",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Partner approve invalid engagement rejected",
"type": "VAPT",
"route": "/work/engagements/999999/approve",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-019",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Partner reject invalid engagement rejected",
"type": "VAPT",
"route": "/work/engagements/999999/reject",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-020",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Add internal comment CSRF-less POST rejected",
"type": "VAPT",
"route": "/work/engagements/999999/comments",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-021",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Add client-visible comment invalid route safe",
"type": "VAPT",
"route": "/work/engagements/999999/client-comments",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-022",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Blocked task without reason rejected",
"type": "VAPT",
"route": "/work/tasks/999999/block",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-023",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Completed task edit invalid ID safe",
"type": "VAPT",
"route": "/work/tasks/999999/edit",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-024",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Locked FY task change rejected or safe",
"type": "VAPT",
"route": "/work/tasks/999999/status",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-025",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Task priority update CSRF-less POST rejected",
"type": "VAPT",
"route": "/work/tasks/999999/priority",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-026",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Task due date update CSRF-less POST rejected",
"type": "VAPT",
"route": "/work/tasks/999999/due-date",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-027",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Work document download invalid ID safe",
"type": "VAPT",
"route": "/work/documents/999999/download",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-028",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Work communication internal note hidden from client route safe",
"type": "VAPT",
"route": "/client/work/engagements/999999/comments",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-029",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Cross-tenant engagement IDOR safe",
"type": "VAPT",
"route": "/work/engagements/999999?tenant_id=999999",
"_kind": "safe-page",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
},
{
"variantId": "V251-WORK2-030",
"module": "Work Lifecycle E2E",
"role": "Anonymous/Attacker",
"scenario": "Engagement delete CSRF-less POST rejected",
"type": "VAPT",
"route": "/work/engagements/999999/delete",
"_kind": "post",
"_method": "GET",
"_file": "work-lifecycle-e2e.spec.js",
"_login_role": null
}
];
test.describe("v2.5.1 Additions - Work Lifecycle E2E", () => {
for (const c of cases) {
test(`[${c.variantId}] ${c.scenario}`, async ({ page, request }) => {
const role = c._login_role || c.role;
const kind = c._kind;
const method = c._method || 'GET';
if (kind === 'api') {
const resp = await apiCall(request, method, c.route);
await expectApiSafe(resp);
return;
}
if (kind === 'post') {
const resp = await apiCall(request, method || 'POST', c.route);
await expectApiSafe(resp);
const safe = [400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status());
expect(safe, `Unsafe POST status ${resp.status()} for ${c.route}`).toBeTruthy();
return;
}
if (role && role !== 'Public' && !/Anonymous|Attacker|API/.test(role)) {
await login(page, role);
}
const resp = await safeGoto(page, c.route);
if (kind === 'page') {
await expectRouteAvailable(page, resp);
if (/password|secret|token|otp/i.test(c.scenario)) await expectNoSecrets(page);
return;
}
if (kind === 'public') {
await expectRouteAvailable(page, resp);
return;
}
if (kind === 'public-no-sensitive') {
await expectPublicNoSensitive(page);
return;
}
if (kind === 'no-secret') {
await expectRouteAvailable(page, resp);
await expectNoSecrets(page);
return;
}
await expectNoBackendError(page);
await expectBlockedOrSafe(page, resp);
});
}
});