Upgrade Playwright suite to v2.5.1 SQLite deep testing
This commit is contained in:
+33
-2
@@ -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=
|
||||
|
||||
@@ -4,3 +4,5 @@ results/
|
||||
playwright-report/
|
||||
test-results/
|
||||
*.log
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
+8
-1
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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
Generated
+5
-4
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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}`));
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user