Upgrade Playwright suite to v2.5.1 SQLite deep testing
This commit is contained in:
@@ -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