Initial Playwright ERP UAT VAPT test suite v2.4.1 IMAP

This commit is contained in:
A R R R Associates
2026-06-22 12:52:00 +05:30
parent 970d5df926
commit 865f19cb84
29 changed files with 26786 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
# =========================
# ERP TEST TARGET
# =========================
BASE_URL=https://erp-test.vavalam.com
START_ERP=false
ERP_WORKDIR=
ERP_COMMAND=uvicorn app.main:app --reload
# =========================
# OTP MODE
# Order used by fixtures/auth.js:
# 1) STATIC_OTP if set
# 2) results/current-otp.txt if present
# 3) IMAP when OTP_FROM_IMAP=true
# =========================
OTP_FROM_CONSOLE=false
OTP_FROM_IMAP=true
STATIC_OTP=
# =========================
# COMMON IMAP SETTINGS
# Used with per-role IMAP credentials below.
# IMAP_OTP_REGEX may include one capture group; if present, group 1 is used.
# =========================
IMAP_HOST=mail.vavalam.com
IMAP_PORT=993
IMAP_SECURE=true
IMAP_MAILBOX=INBOX
IMAP_SEARCH_FROM=no-reply@vavalam.com
IMAP_OTP_REGEX=\b\d{6}\b
IMAP_OTP_REGEX_FLAGS=m
IMAP_WAIT_SECONDS=60
IMAP_LOOKBACK_MINUTES=10
IMAP_MAX_MESSAGES=20
IMAP_FAIL_FAST=false
IMAP_THROW_ON_TIMEOUT=false
# =========================
# SMTP / SYSTEM MAILS
# These are reference values for the UAT system and documentation.
# The Playwright suite reads OTP from recipient IMAP accounts, not from the sender account.
# =========================
SMTP_FROM_EMAIL=no-reply@vavalam.com
SUPPORT_EMAIL=support@vavalam.com
# =========================
# SYSTEM ADMIN
# =========================
SYSTEM_ADMIN_EMAIL=uat.admin@vavalam.com
SYSTEM_ADMIN_PASSWORD=Password@123
SYSTEM_ADMIN_IMAP_USER=uat.admin@vavalam.com
SYSTEM_ADMIN_IMAP_PASSWORD=
# =========================
# FIRM ADMIN
# =========================
FIRM_ADMIN_EMAIL=uat.firmadmin@vavalam.com
FIRM_ADMIN_PASSWORD=Password@123
FIRM_ADMIN_IMAP_USER=uat.firmadmin@vavalam.com
FIRM_ADMIN_IMAP_PASSWORD=
# =========================
# PARTNER
# =========================
PARTNER_EMAIL=uat.partner@vavalam.com
PARTNER_PASSWORD=Password@123
PARTNER_IMAP_USER=uat.partner@vavalam.com
PARTNER_IMAP_PASSWORD=
# =========================
# MANAGER / BRANCH MANAGER
# =========================
MANAGER_EMAIL=uat.manager@vavalam.com
MANAGER_PASSWORD=Password@123
MANAGER_IMAP_USER=uat.manager@vavalam.com
MANAGER_IMAP_PASSWORD=
# =========================
# STAFF USERS
# =========================
STAFF_EMAIL=uat.staff@vavalam.com
STAFF_PASSWORD=Password@123
STAFF_IMAP_USER=uat.staff@vavalam.com
STAFF_IMAP_PASSWORD=
STAFF2_EMAIL=uat.staff2@vavalam.com
STAFF2_PASSWORD=Password@123
STAFF2_IMAP_USER=uat.staff2@vavalam.com
STAFF2_IMAP_PASSWORD=
BRANCH2_STAFF_EMAIL=uat.branch2staff@vavalam.com
BRANCH2_STAFF_PASSWORD=Password@123
BRANCH2_STAFF_IMAP_USER=uat.branch2staff@vavalam.com
BRANCH2_STAFF_IMAP_PASSWORD=
# =========================
# CLIENT USERS
# =========================
CLIENT_EMAIL=uat.client@vavalam.com
CLIENT_PASSWORD=Password@123
CLIENT_IMAP_USER=uat.client@vavalam.com
CLIENT_IMAP_PASSWORD=
CLIENT2_EMAIL=uat.client2@vavalam.com
CLIENT2_PASSWORD=Password@123
CLIENT2_IMAP_USER=uat.client2@vavalam.com
CLIENT2_IMAP_PASSWORD=
# =========================
# CONSULTANT
# =========================
CONSULTANT_EMAIL=uat.consultant@vavalam.com
CONSULTANT_PASSWORD=Password@123
CONSULTANT_IMAP_USER=uat.consultant@vavalam.com
CONSULTANT_IMAP_PASSWORD=
# =========================
# INVITE / RESET / LOCKED USER TESTS
# =========================
INVITE_TEST_EMAIL=uat.invite@vavalam.com
INVITE_TEST_PASSWORD=Password@123
INVITE_TEST_IMAP_USER=uat.invite@vavalam.com
INVITE_TEST_IMAP_PASSWORD=
RESET_TEST_EMAIL=uat.reset@vavalam.com
RESET_TEST_PASSWORD=Password@123
RESET_TEST_IMAP_USER=uat.reset@vavalam.com
RESET_TEST_IMAP_PASSWORD=
LOCKED_USER_EMAIL=uat.lockeduser@vavalam.com
LOCKED_USER_PASSWORD=Password@123
LOCKED_USER_IMAP_USER=uat.lockeduser@vavalam.com
LOCKED_USER_IMAP_PASSWORD=
# =========================
# TENANT / BRANCH TEST CONTEXT
# =========================
TENANT_A_CODE=UAT-A
TENANT_B_CODE=UAT-B
BRANCH_A_CODE=UAT-BA
BRANCH_B_CODE=UAT-BB
TENANT_B_NAME=UAT Tenant B
TENANT_B_ADMIN_EMAIL=uat.firmadmin@vavalam.com
TENANT_B_ADMIN_PASSWORD=Password@123
TENANT_B_ADMIN_IMAP_USER=uat.firmadmin@vavalam.com
TENANT_B_ADMIN_IMAP_PASSWORD=
# =========================
# FINANCIAL YEAR
# =========================
ACTIVE_FY=2025-26
PREVIOUS_FY=2024-25
LOCKED_FY=
DEFAULT_YEAR_CODE=2025-26
# =========================
# OPTIONAL DIRECT IDS
# Leave blank initially. Fill only when direct-ID tests require them.
# =========================
TENANT_A_ID=
TENANT_B_ID=
BRANCH_A_ID=
BRANCH_B_ID=
CLIENT_A_ID=
CLIENT_B_ID=
SERVICE_A_ID=
ENGAGEMENT_A_ID=
NOTICE_CASE_A_ID=
TASK_DOCUMENT_A_ID=
CASE_DOCUMENT_A_ID=
YEAR_BACKUP_EXPORT_ID=
# =========================
# SECURITY EXPECTATIONS
# Set true later after production hardening.
# =========================
EXPECT_SECURE_COOKIES=false
EXPECT_STRICT_CSP=false
TRUST_CONTEXT_HEADERS=false
CONTEXT_HEADER_SECRET=
# =========================
# RESULT FILES
# =========================
MASTER_EXCEL=Audit_Firm_ERP_Master_UAT_VAPT_Checklist.xlsx
UPDATED_EXCEL=results/Audit_Firm_ERP_Master_UAT_VAPT_Checklist_v2_4_Results.xlsx
API_CHECK_RESULTS_FILE=results/api-check-results.json
+6
View File
@@ -0,0 +1,6 @@
.env
node_modules/
results/
playwright-report/
test-results/
*.log
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"suiteVersion": "2.4.0",
"sourceRows": 272,
"generatedVariants": 1296,
"automatedVariants": 1291,
"manualVariants": 5,
"v204AdditionalChecks": 16,
"fyLockBackupScaffolding": 30,
"fyLockBackupStatus": "UNTESTED - all test.fixme(), require human verification before counting as coverage",
"checklistReconciled": true,
"checklistFile": "Audit_Firm_ERP_Master_UAT_VAPT_Checklist_v2.4.xlsx",
"totalChecklistRows": 302
}
+33
View File
@@ -0,0 +1,33 @@
const { expect } = require('@playwright/test');
async function expectNoServerError(page) {
const body = await page.locator('body').innerText().catch(()=>'');
expect(page.url()).not.toContain('500');
expect(body).not.toMatch(/Internal Server Error|Traceback|Exception in ASGI application|AttributeError|UndefinedError/i);
}
async function expectBlockedOrSafe(page, response) {
const status = response ? response.status() : 0;
const body = await page.locator('body').innerText().catch(()=>'');
const safeStatus = [401,403,404,405,422].includes(status);
const safeText = /access denied|forbidden|unauthorized|not found|permission|login|required|invalid/i.test(body);
const redirectedLogin = page.url().includes('/login');
expect(safeStatus || safeText || redirectedLogin).toBeTruthy();
await expectNoServerError(page);
}
async function expectSecurityHeaders(response) {
const headers = response.headers();
expect(headers['x-frame-options'] || headers['content-security-policy']).toBeTruthy();
expect(headers['x-content-type-options'] || '').toMatch(/nosniff/i);
}
async function expectCookieFlags(context) {
const cookies = await context.cookies();
const session = cookies.find(c => /session|auth|token/i.test(c.name));
if (!session) return;
expect(session.httpOnly).toBeTruthy();
expect(['Lax','Strict','None'].includes(session.sameSite)).toBeTruthy();
}
module.exports = { expectNoServerError, expectBlockedOrSafe, expectSecurityHeaders, expectCookieFlags };
+187
View File
@@ -0,0 +1,187 @@
const fs = require('fs');
const path = require('path');
let ImapFlow;
try {
({ ImapFlow } = require('imapflow'));
} catch (_) {
ImapFlow = null;
}
function roleKey(roleText='') {
const r = String(roleText || '').toLowerCase();
if (r.includes('tenant b')) return 'TENANT_B_ADMIN';
if (r.includes('system')) return 'SYSTEM_ADMIN';
if (r.includes('firm admin')) return 'FIRM_ADMIN';
if (r.includes('branch') && r.includes('staff')) return 'BRANCH2_STAFF';
if (r.includes('branch manager')) return 'MANAGER';
if (r.includes('partner')) return 'PARTNER';
if (r.includes('manager')) return 'MANAGER';
if (r.includes('staff2')) return 'STAFF2';
if (r.includes('staff') || r.includes('employee')) return 'STAFF';
if (r.includes('client2')) return 'CLIENT2';
if (r.includes('client')) return 'CLIENT';
if (r.includes('consultant')) return 'CONSULTANT';
if (r.includes('invite')) return 'INVITE_TEST';
if (r.includes('reset')) return 'RESET_TEST';
if (r.includes('locked')) return 'LOCKED_USER';
return 'SYSTEM_ADMIN';
}
function roleCredentials(roleText='') {
const key = roleKey(roleText);
return [process.env[`${key}_EMAIL`], process.env[`${key}_PASSWORD`]];
}
function envBool(name, defaultValue = false) {
const value = process.env[name];
if (value === undefined || value === null || value === '') return defaultValue;
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
function readOtpFromFile() {
const otpPath = path.resolve('results/current-otp.txt');
if (fs.existsSync(otpPath)) return fs.readFileSync(otpPath, 'utf8').trim();
return '';
}
function firstOtpMatch(text) {
const pattern = process.env.IMAP_OTP_REGEX || '\\b\\d{6}\\b';
const flags = process.env.IMAP_OTP_REGEX_FLAGS || 'm';
const regex = new RegExp(pattern, flags);
const match = String(text || '').match(regex);
if (!match) return '';
return match[1] || match[0];
}
function imapConfigForRole(roleText='', email='') {
const key = roleKey(roleText);
const user = process.env[`${key}_IMAP_USER`] || email || process.env.IMAP_USER;
const password = process.env[`${key}_IMAP_PASSWORD`] || process.env.IMAP_PASSWORD;
return {
host: process.env.IMAP_HOST,
port: Number(process.env.IMAP_PORT || 993),
secure: envBool('IMAP_SECURE', true),
auth: { user, pass: password },
mailbox: process.env.IMAP_MAILBOX || 'INBOX',
searchFrom: process.env[`${key}_IMAP_SEARCH_FROM`] || process.env.IMAP_SEARCH_FROM || '',
waitSeconds: Number(process.env.IMAP_WAIT_SECONDS || 60),
lookbackMinutes: Number(process.env.IMAP_LOOKBACK_MINUTES || 10),
maxMessages: Number(process.env.IMAP_MAX_MESSAGES || 20),
};
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function fetchOtpOnceFromImap(config, sinceDate) {
if (!ImapFlow) {
throw new Error('OTP_FROM_IMAP=true but package "imapflow" is not installed. Run: npm install');
}
if (!config.host || !config.auth.user || !config.auth.pass) {
throw new Error('Missing IMAP_HOST, role IMAP user, or role IMAP password for OTP fetch.');
}
const client = new ImapFlow({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
logger: false,
});
await client.connect();
let lock;
try {
lock = await client.getMailboxLock(config.mailbox);
const query = { since: sinceDate };
if (config.searchFrom) query.from = config.searchFrom;
const uids = await client.search(query, { uid: true });
const recentUids = (uids || []).slice(-config.maxMessages).reverse();
if (!recentUids.length) return '';
for await (const message of client.fetch(recentUids.join(','), { envelope: true, source: true }, { uid: true })) {
const envelopeText = JSON.stringify(message.envelope || {});
const sourceText = message.source ? message.source.toString('utf8') : '';
const otp = firstOtpMatch(`${envelopeText}\n${sourceText}`);
if (otp) return otp;
}
return '';
} finally {
if (lock) lock.release();
try { await client.logout(); } catch (_) {}
}
}
async function getOtp(roleText='', email='') {
if (process.env.STATIC_OTP) return process.env.STATIC_OTP;
const fileOtp = readOtpFromFile();
if (fileOtp) return fileOtp;
if (!envBool('OTP_FROM_IMAP', false)) return '';
const config = imapConfigForRole(roleText, email);
const startedAt = Date.now();
const sinceDate = new Date(startedAt - config.lookbackMinutes * 60 * 1000);
const deadline = startedAt + config.waitSeconds * 1000;
let lastError;
while (Date.now() <= deadline) {
try {
const otp = await fetchOtpOnceFromImap(config, sinceDate);
if (otp) return otp;
} catch (error) {
lastError = error;
if (envBool('IMAP_FAIL_FAST', false)) throw error;
}
await sleep(3000);
}
if (lastError && envBool('IMAP_THROW_ON_TIMEOUT', false)) throw lastError;
return '';
}
async function fillFirst(page, selectors, value) {
for (const sel of selectors) {
const loc = page.locator(sel).first();
if (await loc.count()) {
try { await loc.fill(value); return true; } catch (_) {}
}
}
return false;
}
async function clickFirst(page, selectors) {
for (const sel of selectors) {
const loc = page.locator(sel).first();
if (await loc.count()) {
try { await loc.click(); return true; } catch (_) {}
}
}
return false;
}
async function login(page, roleText='System Admin') {
const [email, password] = roleCredentials(roleText);
await page.goto('/login');
await fillFirst(page, ['input[name="email"]','input[name="login_id"]','input[type="email"]','input[name="username"]','input[name="user"]'], email || '');
await fillFirst(page, ['input[name="password"]','input[type="password"]'], password || '');
await clickFirst(page, ['button[type="submit"]','input[type="submit"]','text=/login/i']);
await page.waitForLoadState('domcontentloaded');
if (page.url().includes('/otp')) {
const otp = await getOtp(roleText, email);
if (otp) {
await fillFirst(page, ['input[name="otp"]','input[name="code"]','input[type="text"]'], otp);
await clickFirst(page, ['button[type="submit"]','input[type="submit"]','text=/verify/i']);
await page.waitForLoadState('domcontentloaded');
}
}
}
async function logout(page) {
try { await page.goto('/logout'); await page.waitForLoadState('domcontentloaded'); } catch (_) {}
}
module.exports = { login, logout, roleCredentials, roleKey, getOtp, fillFirst, clickFirst };
+25
View File
@@ -0,0 +1,25 @@
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
require('dotenv').config();
module.exports = async () => {
fs.mkdirSync(path.resolve('results'), { recursive: true });
if (process.env.START_ERP !== 'true') return;
const cwd = process.env.ERP_WORKDIR;
const cmd = process.env.ERP_COMMAND || 'uvicorn app.main:app --reload';
const child = spawn(cmd, { cwd, shell: true, env: process.env });
global.__ERP_PROCESS__ = child;
const logFile = path.resolve('results/erp-console.log');
fs.writeFileSync(logFile, '');
const append = (buf) => {
const text = buf.toString();
fs.appendFileSync(logFile, text);
const otp = text.match(/\[DEV OTP\].*?code=(\d{4,8})/i);
if (otp) fs.writeFileSync(path.resolve('results/current-otp.txt'), otp[1]);
};
child.stdout.on('data', append);
child.stderr.on('data', append);
await new Promise((resolve) => setTimeout(resolve, 5000));
};
+5
View File
@@ -0,0 +1,5 @@
module.exports = async () => {
if (global.__ERP_PROCESS__) {
try { global.__ERP_PROCESS__.kill(); } catch (_) {}
}
};
+74
View File
@@ -0,0 +1,74 @@
const { expect } = require('@playwright/test');
const path = require('path');
async function readBody(page) {
return await page.locator('body').innerText().catch(() => '');
}
async function expectNoBackendError(page) {
const body = await readBody(page);
expect(page.url()).not.toContain('500');
expect(body).not.toMatch(/Internal Server Error|Traceback|Exception in ASGI application|UndefinedError|AttributeError|OperationalError|ProgrammingError/i);
}
async function extractCsrfFromPage(page) {
const token = await page.locator('input[name="csrf_token"]').first().getAttribute('value').catch(() => null);
return token || '';
}
async function blockedOrNotFound(response, text = '') {
const status = response ? response.status() : 0;
const safeStatus = [400,401,403,404,405,409,422,429].includes(status);
const safeText = /login|required|forbidden|unauthorized|not found|access denied|permission|invalid|csrf|locked|closed/i.test(text || '');
expect(safeStatus || safeText).toBeTruthy();
}
async function switchFinancialYearIfPossible(page, yearCode) {
if (!yearCode) return false;
const selectors = [
'select[name="active_financial_year"]',
'select[name="financial_year"]',
'select[name="year_code"]',
'[data-testid="active-fy-select"]',
'[data-testid="financial-year-select"]'
];
for (const sel of selectors) {
const loc = page.locator(sel).first();
if (await loc.count()) {
try {
await loc.selectOption({ value: yearCode });
} catch (_) {
try { await loc.selectOption({ label: yearCode }); } catch (__) { continue; }
}
const formSubmit = loc.locator('xpath=ancestor::form[1]//button[@type="submit" or not(@type)]').first();
if (await formSubmit.count()) await formSubmit.click().catch(() => {});
else await loc.press('Enter').catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
return true;
}
}
const link = page.getByRole('link', { name: new RegExp(yearCode.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') }).first();
if (await link.count()) {
await link.click().catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
return true;
}
return false;
}
function uploadPath(fileName) {
return path.resolve(__dirname, '..', 'test-data', 'uploads', fileName);
}
module.exports = {
readBody,
expectNoBackendError,
extractCsrfFromPage,
blockedOrNotFound,
switchFinancialYearIfPossible,
uploadPath,
};
+453
View File
@@ -0,0 +1,453 @@
{
"name": "playwright-full-erp-uat-vapt-suite-v2-3",
"version": "2.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "playwright-full-erp-uat-vapt-suite-v2-3",
"version": "2.4.1",
"dependencies": {
"@playwright/test": "^1.48.2",
"dotenv": "^16.4.5",
"imapflow": "^1.4.2",
"xlsx": "^0.18.5"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.12",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz",
"integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libqp": "2.1.1"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.4.2.tgz",
"integrity": "sha512-73CGfb5+W0FkZ5CY4GfSdsoXyQ+17wdKkpMN2vwJHdLtOOFQWxv0ilG7KYY79XHBYg5njjqxXYB2FPw5Tl81zQ==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.12",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.8",
"libqp": "2.1.1",
"nodemailer": "9.0.1",
"pino": "10.3.1",
"socks": "2.8.9"
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz",
"integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "playwright-full-erp-uat-vapt-suite-v2-3",
"version": "2.4.1",
"private": true,
"description": "Full Excel-mapped UAT + VAPT suite for Audit Firm ERP with v2.0.4 hardening, v2.4 FY-lock scaffolding, matrix-reconciled checklist, and IMAP OTP support.",
"scripts": {
"test": "playwright test",
"test:excel": "playwright test tests/full-excel-variants.spec.js",
"test:vapt": "playwright test tests/vapt-targeted.spec.js",
"test:seed-check": "node scripts/check-seed-requirements.js",
"matrix": "node scripts/print-matrix-summary.js",
"update:excel": "node scripts/update-excel-results.js",
"report:json": "node scripts/merge-results.js",
"show-report": "playwright show-report",
"test:v204": "playwright test tests/v204-security-additions.spec.js",
"test:all": "playwright test",
"test:fy": "playwright test tests/fy-lock-backup.spec.js"
},
"dependencies": {
"@playwright/test": "^1.48.2",
"dotenv": "^16.4.5",
"imapflow": "^1.4.2",
"xlsx": "^0.18.5"
}
}
+24
View File
@@ -0,0 +1,24 @@
const { defineConfig } = require('@playwright/test');
require('dotenv').config();
module.exports = defineConfig({
testDir: './tests',
timeout: 45000,
expect: { timeout: 7000 },
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [
['html', { outputFolder: 'playwright-report', open: 'never' }],
['json', { outputFile: 'results/playwright-results.json' }],
['list']
],
use: {
baseURL: process.env.BASE_URL || 'http://127.0.0.1:8000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
globalSetup: require.resolve('./fixtures/global-setup'),
globalTeardown: require.resolve('./fixtures/global-teardown')
});
+158
View File
@@ -0,0 +1,158 @@
// Standalone runner: executes the API-level (no-browser) security checks
// from the v2.0.4 suite against a live ERP. Produces JSON results.
const { request } = require('@playwright/test');
const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:8000';
const results = [];
function record(id, title, status, detail) {
results.push({ id, title, status, detail });
const tag = status === 'PASS' ? 'PASS' : status === 'SKIP' ? 'SKIP' : 'FAIL';
console.log(`[${tag}] ${id} ${title}${detail ? ' -- ' + detail : ''}`);
}
(async () => {
const api = await request.newContext({ baseURL: BASE_URL });
// V204-SEC-001: forgot-password must not leak a reset token
try {
const email = process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local';
const candidates = [
{ url: '/api/auth/forgot-password', opts: { data: { email } } },
{ url: '/api/auth/forgot-password', opts: { form: { email } } },
{ url: '/forgot-password', opts: { form: { email } } },
];
let checked = false, leaked = false, serverErr = false;
for (const c of candidates) {
const resp = await api.post(c.url, c.opts).catch(() => null);
if (!resp) continue;
if ([404, 405].includes(resp.status())) continue;
checked = true;
if (resp.status() >= 500) serverErr = true;
const text = await resp.text();
if (/reset_token|password_reset_token|invite_token/i.test(text)) leaked = true;
if (/[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}/.test(text)) leaked = true;
}
if (!checked) record('V204-SEC-001', 'forgot-password token leak', 'SKIP', 'no forgot-password route responded');
else if (serverErr) record('V204-SEC-001', 'forgot-password token leak', 'FAIL', '500 from endpoint');
else if (leaked) record('V204-SEC-001', 'forgot-password token leak', 'FAIL', 'token-like value in response body');
else record('V204-SEC-001', 'forgot-password token leak', 'PASS', 'no token leaked');
} catch (e) { record('V204-SEC-001', 'forgot-password token leak', 'FAIL', String(e)); }
// V204-SEC-002: reset-password with bogus token rejected safely
try {
const resp = await api.post('/api/auth/reset-password', {
data: { token: 'invalid-token-for-vapt', password: 'NewPassword@123' },
}).catch(() => null);
if (!resp || [404, 405].includes(resp.status()))
record('V204-SEC-002', 'reset-password bogus token', 'SKIP', 'route not available');
else if (resp.status() >= 500)
record('V204-SEC-002', 'reset-password bogus token', 'FAIL', `status ${resp.status()}`);
else if ([400, 401, 403, 404, 405, 409, 422, 429].includes(resp.status()))
record('V204-SEC-002', 'reset-password bogus token', 'PASS', `safely rejected (${resp.status()})`);
else record('V204-SEC-002', 'reset-password bogus token', 'FAIL', `unexpected status ${resp.status()}`);
} catch (e) { record('V204-SEC-002', 'reset-password bogus token', 'FAIL', String(e)); }
// V204-SEC-003: brute-force login attempts don't 500
try {
const email = process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local';
let skip = false, bad = null;
for (let i = 0; i < 6; i++) {
const resp = await api.post('/api/auth/token', {
form: { username: email, email, password: `wrong-password-${i}` },
}).catch(() => null);
if (!resp || [404, 405].includes(resp.status())) { skip = true; break; }
if (resp.status() >= 500) { bad = resp.status(); break; }
if (![400, 401, 403, 422, 429].includes(resp.status())) { bad = resp.status(); break; }
}
if (skip) record('V204-SEC-003', 'login brute-force no 500', 'SKIP', '/api/auth/token not available');
else if (bad) record('V204-SEC-003', 'login brute-force no 500', 'FAIL', `status ${bad}`);
else record('V204-SEC-003', 'login brute-force no 500', 'PASS', 'all attempts safely handled');
} catch (e) { record('V204-SEC-003', 'login brute-force no 500', 'FAIL', String(e)); }
// V204-STORAGE-001: storage-agent endpoints require node auth
try {
const endpoints = [
'/documents/storage-agent/jobs/pending',
'/documents/storage-agent/download-requests/pending',
];
let bad = null, anySeen = false;
for (const url of endpoints) {
const resp = await api.get(url).catch(() => null);
if (!resp) continue;
anySeen = true;
if (resp.status() >= 500) { bad = `${url} -> ${resp.status()}`; break; }
if (![400, 401, 403, 404, 405, 422].includes(resp.status())) { bad = `${url} -> ${resp.status()} (unauth access?)`; break; }
}
if (!anySeen) record('V204-STORAGE-001', 'storage-agent requires auth', 'SKIP', 'no endpoint responded');
else if (bad) record('V204-STORAGE-001', 'storage-agent requires auth', 'FAIL', bad);
else record('V204-STORAGE-001', 'storage-agent requires auth', 'PASS', 'endpoints require authentication');
} catch (e) { record('V204-STORAGE-001', 'storage-agent requires auth', 'FAIL', String(e)); }
// V204-BACKUP-001: backup screens not accessible anonymously
try {
const paths = [
'/system-settings/financial-years',
'/system-settings/year-backup',
'/system-settings/year-backups',
];
let bad = null, anySeen = false;
for (const p of paths) {
const resp = await api.get(p, { maxRedirects: 0 }).catch(() => null);
if (!resp) continue;
anySeen = true;
const st = resp.status();
// anonymous: must redirect to login (3xx) or be denied (401/403/404). 200 = leak.
if (st === 200) { bad = `${p} returned 200 anonymously`; break; }
if (st >= 500) { bad = `${p} -> ${st}`; break; }
}
if (!anySeen) record('V204-BACKUP-001', 'backup screens not anon-accessible', 'SKIP', 'no route responded');
else if (bad) record('V204-BACKUP-001', 'backup screens not anon-accessible', 'FAIL', bad);
else record('V204-BACKUP-001', 'backup screens not anon-accessible', 'PASS', 'anonymous access blocked/redirected');
} catch (e) { record('V204-BACKUP-001', 'backup screens not anon-accessible', 'FAIL', String(e)); }
// V204-HEADERS-001 (partial): security headers on /login
try {
const resp = await api.get('/login').catch(() => null);
if (!resp) record('V204-HEADERS-001', 'security headers on /login', 'SKIP', '/login no response');
else {
const h = resp.headers();
const missing = [];
if (!h['x-content-type-options']) missing.push('X-Content-Type-Options');
if (!h['x-frame-options'] && !h['content-security-policy']) missing.push('X-Frame-Options/CSP');
if (missing.length) record('V204-HEADERS-001', 'security headers on /login', 'FAIL', 'missing: ' + missing.join(', '));
else record('V204-HEADERS-001', 'security headers on /login', 'PASS', 'core security headers present');
}
} catch (e) { record('V204-HEADERS-001', 'security headers on /login', 'FAIL', String(e)); }
// VAPT: unauthenticated access to a protected page redirects to login
try {
const resp = await api.get('/system-settings/users', { maxRedirects: 0 }).catch(() => null);
if (!resp) record('VAPT-AUTH-001', 'protected route requires auth', 'SKIP', 'no response');
else {
const st = resp.status();
if (st === 200) record('VAPT-AUTH-001', 'protected route requires auth', 'FAIL', 'users page served without auth');
else if (st >= 500) record('VAPT-AUTH-001', 'protected route requires auth', 'FAIL', `status ${st}`);
else record('VAPT-AUTH-001', 'protected route requires auth', 'PASS', `blocked/redirected (${st})`);
}
} catch (e) { record('VAPT-AUTH-001', 'protected route requires auth', 'FAIL', String(e)); }
await api.dispose();
const summary = {
pass: results.filter(r => r.status === 'PASS').length,
fail: results.filter(r => r.status === 'FAIL').length,
skip: results.filter(r => r.status === 'SKIP').length,
};
console.log(`\nSUMMARY: ${summary.pass} passed, ${summary.fail} failed, ${summary.skip} skipped`);
// Write results to a portable path instead of a machine-specific /home/claude path.
// Override with API_CHECK_RESULTS_FILE if needed, otherwise defaults to results/api-check-results.json.
const fs = require('fs');
const path = require('path');
const outputFile = process.env.API_CHECK_RESULTS_FILE || path.join('results', 'api-check-results.json');
const outputDir = path.dirname(outputFile);
fs.mkdirSync(outputDir, { recursive: true });
fs.writeFileSync(outputFile, JSON.stringify({ summary, results }, null, 2));
console.log(`Results written to ${outputFile}`);
})();
+10
View File
@@ -0,0 +1,10 @@
require('dotenv').config();
const required = [
'SYSTEM_ADMIN_EMAIL','SYSTEM_ADMIN_PASSWORD','FIRM_ADMIN_EMAIL','FIRM_ADMIN_PASSWORD','PARTNER_EMAIL','PARTNER_PASSWORD','MANAGER_EMAIL','MANAGER_PASSWORD','STAFF_EMAIL','STAFF_PASSWORD','CLIENT_EMAIL','CLIENT_PASSWORD','CONSULTANT_EMAIL','CONSULTANT_PASSWORD','TENANT_B_ADMIN_EMAIL','TENANT_B_ADMIN_PASSWORD'
];
const missing = required.filter(k => !process.env[k]);
if (missing.length) {
console.error('Missing required .env values:', missing.join(', '));
process.exit(1);
}
console.log('Seed credential configuration looks complete. For full IDOR/cross-tenant tests also fill TENANT_A_ID, TENANT_B_ID, CLIENT_A_ID, CLIENT_B_ID, NOTICE_CASE_A_ID, etc.');
+24
View File
@@ -0,0 +1,24 @@
const fs = require('fs');
const path = require('path');
const matrix = JSON.parse(fs.readFileSync('data/generated-test-matrix.json','utf8'));
const resultPath = 'results/playwright-results.json';
const outPath = 'results/merged-results.json';
let results = {};
if (fs.existsSync(resultPath)) {
const raw = JSON.parse(fs.readFileSync(resultPath,'utf8'));
function walk(suite) {
for (const spec of suite.specs || []) {
for (const test of spec.tests || []) {
const title = spec.title;
const m = title.match(/\[([^\]]+)\]/);
if (m) results[m[1]] = test.outcome || test.status || 'unknown';
}
}
for (const child of suite.suites || []) walk(child);
}
for (const s of raw.suites || []) walk(s);
}
const merged = matrix.map(v => ({...v, result: results[v.variantId] || (v.automation === 'manual' ? 'manual' : 'not_run')}));
fs.mkdirSync(path.dirname(outPath), {recursive:true});
fs.writeFileSync(outPath, JSON.stringify(merged,null,2));
console.log(`Wrote ${outPath}`);
+10
View File
@@ -0,0 +1,10 @@
const fs = require('fs');
const rows = JSON.parse(fs.readFileSync('data/checklist-rows.json','utf8'));
const vars = JSON.parse(fs.readFileSync('data/generated-test-matrix.json','utf8'));
const bySheet = {};
for (const r of rows) bySheet[r.Sheet] = (bySheet[r.Sheet] || 0) + 1;
console.log('Source Excel rows:', rows.length);
console.log('Generated variants:', vars.length);
console.log('Automated variants:', vars.filter(v=>v.automation==='automated').length);
console.log('Manual variants:', vars.filter(v=>v.automation==='manual').length);
console.table(bySheet);
+48
View File
@@ -0,0 +1,48 @@
const fs = require('fs');
const path = require('path');
const XLSX = require('xlsx');
require('dotenv').config();
const source = process.env.MASTER_EXCEL || 'Audit_Firm_ERP_Master_UAT_VAPT_Checklist.xlsx';
const output = process.env.UPDATED_EXCEL || 'results/Audit_Firm_ERP_Master_UAT_VAPT_Checklist_v2_2_Results.xlsx';
const mergedPath = 'results/merged-results.json';
if (!fs.existsSync(mergedPath)) {
console.error('Run npm run report:json first.'); process.exit(1);
}
const merged = JSON.parse(fs.readFileSync(mergedPath,'utf8'));
const wb = XLSX.readFile(source);
const byId = {};
for (const v of merged) {
byId[v.sourceId] = byId[v.sourceId] || [];
byId[v.sourceId].push(v);
}
for (const sheet of wb.SheetNames) {
if (!sheet.startsWith('UAT_') && !sheet.startsWith('VAPT_')) continue;
const ws = wb.Sheets[sheet];
const data = XLSX.utils.sheet_to_json(ws, {header:1, blankrows:false});
if (data.length < 2) continue;
const headers = data[1];
let statusCol = headers.indexOf('Status');
let remarksCol = headers.indexOf('Remarks');
if (statusCol < 0) { statusCol = headers.length; headers.push('Status'); }
if (remarksCol < 0) { remarksCol = headers.length; headers.push('Remarks'); }
for (let r=2; r<data.length; r++) {
const id = data[r][0];
if (!id || !byId[id]) continue;
const vars = byId[id];
const failed = vars.filter(v => ['failed','timedOut'].includes(v.result));
const passed = vars.filter(v => v.result === 'passed');
const manual = vars.filter(v => v.result === 'manual');
const notRun = vars.filter(v => v.result === 'not_run');
let status = 'Not Run';
if (failed.length) status = 'Fail';
else if (notRun.length && passed.length) status = 'Partial';
else if (manual.length === vars.length) status = 'Manual';
else if (passed.length === vars.length || passed.length + manual.length === vars.length) status = 'Pass';
data[r][statusCol] = status;
data[r][remarksCol] = `v2.2 variants: ${passed.length} pass, ${failed.length} fail, ${manual.length} manual, ${notRun.length} not run.`;
}
wb.Sheets[sheet] = XLSX.utils.aoa_to_sheet(data);
}
fs.mkdirSync(path.dirname(output), {recursive:true});
XLSX.writeFile(wb, output);
console.log(`Updated Excel written: ${output}`);
+225
View File
@@ -0,0 +1,225 @@
"""
Audit Firm ERP Playwright seed data helper (v2.2).
Run from your ERP project root after migrations:
python path\to\seed\seed_data.py
This script is intentionally defensive because different project phases may have
slightly different model names/columns. It uses SQLAlchemy model introspection,
creates only records where the model/table exists, and prints created IDs that
can be pasted into .env for cross-tenant/IDOR tests.
"""
from __future__ import annotations
import importlib
import os
import sys
from datetime import date, datetime, timedelta
from typing import Any, Dict, Optional
PASSWORD = os.getenv("UAT_SEED_PASSWORD", "Password@123")
def import_any(*paths):
for p in paths:
mod_name, attr = p.rsplit('.', 1)
try:
mod = importlib.import_module(mod_name)
return getattr(mod, attr)
except Exception:
continue
return None
def get_session():
SessionLocal = import_any(
'app.core.database.SessionLocal',
'app.core.db.SessionLocal',
'app.db.session.SessionLocal',
'app.database.SessionLocal',
)
if SessionLocal:
return SessionLocal()
raise RuntimeError('Could not locate SQLAlchemy SessionLocal. Run this from ERP project root or update import paths in seed_data.py.')
def get_model(name: str):
candidates = [
f'app.modules.core.iam.models.{name}',
f'app.modules.core.models.{name}',
f'app.modules.tenants.models.{name}',
f'app.modules.clients.models.{name}',
f'app.modules.services.models.{name}',
f'app.modules.notice_cases.models.{name}',
f'app.modules.employees.models.{name}',
f'app.modules.consultants.models.{name}',
f'app.models.{name}',
]
return import_any(*candidates)
def cols(Model):
return {c.name for c in Model.__table__.columns}
def pick(Model, **values):
c = cols(Model)
return {k: v for k, v in values.items() if k in c}
def first_by(db, Model, **criteria):
c = cols(Model)
q = db.query(Model)
for k, v in criteria.items():
if k in c:
q = q.filter(getattr(Model, k) == v)
return q.first()
def get_or_create(db, Model, defaults: Optional[Dict[str, Any]] = None, **criteria):
defaults = defaults or {}
obj = first_by(db, Model, **criteria)
if obj:
return obj, False
data = pick(Model, **criteria, **defaults)
obj = Model(**data)
db.add(obj)
db.flush()
return obj, True
def set_password(user):
# Try project hash helpers first.
helpers = [
'app.modules.core.iam.password_service.hash_password',
'app.modules.core.iam.security.hash_password',
'app.core.security.hash_password',
'app.core.auth.hash_password',
]
for h in helpers:
fn = import_any(h)
if fn:
for col in ('password_hash', 'hashed_password'):
if hasattr(user, col):
setattr(user, col, fn(PASSWORD))
return
# Fallback to passlib if available.
try:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto')
hashed = pwd_context.hash(PASSWORD)
for col in ('password_hash', 'hashed_password'):
if hasattr(user, col):
setattr(user, col, hashed)
return
except Exception:
pass
def main():
db = get_session()
User = get_model('User')
Tenant = get_model('Tenant')
Branch = get_model('Branch')
Client = get_model('Client')
Service = get_model('Service')
Engagement = get_model('Engagement')
NoticeCase = get_model('NoticeCase')
ids: Dict[str, Any] = {}
try:
tenant_a = tenant_b = None
if Tenant:
tenant_a, _ = get_or_create(db, Tenant, name='UAT Tenant A', defaults={'code':'UAT-A','is_active':True}, code='UAT-A')
tenant_b, _ = get_or_create(db, Tenant, name='UAT Tenant B', defaults={'code':'UAT-B','is_active':True}, code='UAT-B')
ids['TENANT_A_ID'] = getattr(tenant_a, 'id', '')
ids['TENANT_B_ID'] = getattr(tenant_b, 'id', '')
branch_a = branch_b = None
if Branch:
branch_a, _ = get_or_create(db, Branch, name='UAT Branch A', defaults={'code':'UAT-BA','tenant_id':getattr(tenant_a,'id',None),'is_active':True}, code='UAT-BA')
branch_b, _ = get_or_create(db, Branch, name='UAT Branch B', defaults={'code':'UAT-BB','tenant_id':getattr(tenant_b,'id',None),'is_active':True}, code='UAT-BB')
ids['BRANCH_A_ID'] = getattr(branch_a, 'id', '')
ids['BRANCH_B_ID'] = getattr(branch_b, 'id', '')
if User:
users = [
('uat.firmadmin@tenant-a.test','Firm Admin','firm_admin',tenant_a,branch_a),
('uat.partner@tenant-a.test','Partner','partner',tenant_a,branch_a),
('uat.manager@tenant-a.test','Manager','manager',tenant_a,branch_a),
('uat.staff@tenant-a.test','Staff','staff',tenant_a,branch_a),
('uat.client@tenant-a.test','Client','client',tenant_a,branch_a),
('uat.consultant@tenant-a.test','Consultant','consultant',tenant_a,branch_a),
('uat.firmadmin@tenant-b.test','Firm Admin B','firm_admin',tenant_b,branch_b),
]
for email, full_name, role, tenant, branch in users:
criteria = {'email': email}
if 'login_id' in cols(User): criteria = {'login_id': email}
user, created = get_or_create(db, User, defaults={
'email': email, 'login_id': email, 'full_name': full_name, 'name': full_name,
'role': role, 'is_active': True, 'is_verified': True,
'tenant_id': getattr(tenant,'id',None), 'branch_id': getattr(branch,'id',None),
'must_change_password': False,
}, **criteria)
set_password(user)
client_a = client_b = None
if Client:
client_a, _ = get_or_create(db, Client, defaults={
'tenant_id':getattr(tenant_a,'id',None),'branch_id':getattr(branch_a,'id',None),
'client_code':'UAT-CL-A','name':'UAT Client A Pvt Ltd','company_name':'UAT Client A Pvt Ltd',
'pan':'AABCU1111A','gstin':'33AABCU1111A1Z5','email':'client.a@uat.test','mobile':'9000000001',
'status':'active','is_active':True
}, client_code='UAT-CL-A')
client_b, _ = get_or_create(db, Client, defaults={
'tenant_id':getattr(tenant_b,'id',None),'branch_id':getattr(branch_b,'id',None),
'client_code':'UAT-CL-B','name':'UAT Client B Pvt Ltd','company_name':'UAT Client B Pvt Ltd',
'pan':'AABCU2222A','gstin':'33AABCU2222A1Z5','email':'client.b@uat.test','mobile':'9000000002',
'status':'active','is_active':True
}, client_code='UAT-CL-B')
ids['CLIENT_A_ID'] = getattr(client_a, 'id', '')
ids['CLIENT_B_ID'] = getattr(client_b, 'id', '')
service_a = None
if Service:
service_a, _ = get_or_create(db, Service, defaults={
'tenant_id':getattr(tenant_a,'id',None),'branch_id':getattr(branch_a,'id',None),
'service_code':'UAT-SVC-GST','name':'UAT GST Compliance','description':'Seed service for Playwright tests',
'is_active':True,'base_rate':1000
}, service_code='UAT-SVC-GST')
ids['SERVICE_A_ID'] = getattr(service_a, 'id', '')
engagement_a = None
if Engagement:
engagement_a, _ = get_or_create(db, Engagement, defaults={
'tenant_id':getattr(tenant_a,'id',None),'branch_id':getattr(branch_a,'id',None),
'client_id':getattr(client_a,'id',None),'service_id':getattr(service_a,'id',None),
'engagement_code':'UAT-ENG-GST-001','title':'UAT GST Compliance Engagement',
'status':'open','financial_year':'2025-26','period':'2025-26','start_date':date.today()
}, engagement_code='UAT-ENG-GST-001')
ids['ENGAGEMENT_A_ID'] = getattr(engagement_a, 'id', '')
if NoticeCase:
nc, _ = get_or_create(db, NoticeCase, defaults={
'tenant_id':getattr(tenant_a,'id',None),'branch_id':getattr(branch_a,'id',None),
'client_id':getattr(client_a,'id',None),'department':'GST','case_type':'Notice',
'reference_no':'UAT-NOTICE-001','title':'UAT GST Notice','status':'Open',
'notice_date':date.today(),'due_date':date.today()+timedelta(days=15),
'description':'Seed notice case for Playwright tests'
}, reference_no='UAT-NOTICE-001')
ids['NOTICE_CASE_A_ID'] = getattr(nc, 'id', '')
db.commit()
print('\nSeed completed. Paste these values into the Playwright .env file if shown:')
for k, v in ids.items():
print(f'{k}={v}')
print('\nSeed user password for all uat.* users:', PASSWORD)
except Exception as exc:
db.rollback()
raise
finally:
db.close()
if __name__ == '__main__':
main()
+206
View File
@@ -0,0 +1,206 @@
"""
Audit Firm ERP (Production_gitea) -- Playwright UAT/VAPT seed helper.
CORRECTED for the Production_gitea schema:
- session factory: app.core.db.common.CommonSessionLocal
- User has no 'role' column; roles are assigned via the user_roles join table
- Real role names: "Firm Admin", "Partner", "Branch Manager", "Staff",
"Client", "Consultant"
- Client requires client_name (not 'name'); Tenant/Branch use code+name
- NoticeCase uses case_code + reference_no
HOW TO RUN (inside the ERP container, from the project root, AFTER migrations):
alembic upgrade head
UAT_SEED_PASSWORD='YourStrongTestPass@123' python seed_uat_data.py
It is idempotent: re-running updates/re-uses existing UAT records rather than
duplicating them. It prints IDs to paste into the Playwright .env.
SAFETY: run this against a UAT/staging database, NOT live production data.
"""
from __future__ import annotations
import os
import sys
from datetime import date, timedelta
PASSWORD = os.getenv("UAT_SEED_PASSWORD", "Password@123")
# Map UAT logical role -> actual Role.name in Production_gitea
ROLE_NAME = {
"firm_admin": "Firm Admin",
"partner": "Partner",
"manager": "Branch Manager",
"staff": "Staff",
"client": "Client",
"consultant": "Consultant",
}
def fail(msg: str) -> None:
print(f"\n[seed] ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def main() -> None:
# --- imports from the live ERP project ------------------------------------
try:
from app.core.db.common import CommonSessionLocal
from app.core.security.passwords import hash_password
from app.modules.core.iam.models import User
from app.modules.core.tenancy.models import Tenant, Branch
from app.modules.core.rbac.models import Role, UserRole
from app.modules.clients.models import Client
except Exception as exc: # pragma: no cover
fail(
"Could not import ERP modules. Run this from the ERP project root "
f"inside the app container. Underlying import error: {exc!r}"
)
# NoticeCase is optional (only if module present)
try:
from app.modules.notice_cases.models import NoticeCase
except Exception:
NoticeCase = None
db = CommonSessionLocal()
ids: dict[str, object] = {}
def get_or_create(Model, lookup: dict, defaults: dict):
obj = db.query(Model).filter_by(**lookup).first()
if obj:
return obj, False
data = {**lookup, **defaults}
# keep only real columns
valid = {c.name for c in Model.__table__.columns}
obj = Model(**{k: v for k, v in data.items() if k in valid})
db.add(obj)
db.flush()
return obj, True
def assign_role(user, role_label: str) -> None:
role_name = ROLE_NAME[role_label]
role = db.query(Role).filter(Role.name == role_name).first()
if not role:
print(f"[seed] WARNING: role '{role_name}' not found; "
f"run the app once so DEFAULT_ROLES are created. Skipping.")
return
exists = (
db.query(UserRole)
.filter(UserRole.user_id == user.id, UserRole.role_id == role.id)
.first()
)
if not exists:
db.add(UserRole(user_id=user.id, role_id=role.id))
try:
# --- Tenants ----------------------------------------------------------
tenant_a, _ = get_or_create(
Tenant, {"code": "UAT-A"},
{"name": "UAT Tenant A", "is_active": True},
)
tenant_b, _ = get_or_create(
Tenant, {"code": "UAT-B"},
{"name": "UAT Tenant B", "is_active": True},
)
ids["TENANT_A_ID"] = tenant_a.id
ids["TENANT_B_ID"] = tenant_b.id
# --- Branches ---------------------------------------------------------
branch_a, _ = get_or_create(
Branch, {"code": "UAT-BA"},
{"name": "UAT Branch A", "tenant_id": tenant_a.id, "is_active": True},
)
branch_b, _ = get_or_create(
Branch, {"code": "UAT-BB"},
{"name": "UAT Branch B", "tenant_id": tenant_b.id, "is_active": True},
)
ids["BRANCH_A_ID"] = branch_a.id
ids["BRANCH_B_ID"] = branch_b.id
# --- Users (+ roles via user_roles) -----------------------------------
users = [
("uat.firmadmin@tenant-a.test", "Firm Admin", "firm_admin", tenant_a, branch_a),
("uat.partner@tenant-a.test", "Partner", "partner", tenant_a, branch_a),
("uat.manager@tenant-a.test", "Manager", "manager", tenant_a, branch_a),
("uat.staff@tenant-a.test", "Staff", "staff", tenant_a, branch_a),
("uat.client@tenant-a.test", "Client", "client", tenant_a, branch_a),
("uat.consultant@tenant-a.test","Consultant", "consultant", tenant_a, branch_a),
("uat.firmadmin@tenant-b.test", "Firm Admin B","firm_admin", tenant_b, branch_b),
]
for email, full_name, role_label, tenant, branch in users:
user, created = get_or_create(
User, {"email": email},
{
"full_name": full_name,
"password_hash": hash_password(PASSWORD),
"tenant_id": tenant.id,
"branch_id": branch.id,
"is_active": True,
"allow_login": True,
"is_locked": False,
"must_change_password": False,
},
)
if not created:
# refresh password on existing UAT users so logins stay known
user.password_hash = hash_password(PASSWORD)
db.flush()
assign_role(user, role_label)
# --- Clients (note: client_name is required) --------------------------
client_a, _ = get_or_create(
Client, {"client_code": "UAT-CL-A"},
{
"tenant_id": tenant_a.id, "branch_id": branch_a.id,
"client_name": "UAT Client A Pvt Ltd", "client_type": "Company",
"pan": "AABCU1111A", "gstin": "33AABCU1111A1Z5",
"email": "client.a@uat.test", "mobile": "9000000001",
"status": "active", "is_active": True,
},
)
client_b, _ = get_or_create(
Client, {"client_code": "UAT-CL-B"},
{
"tenant_id": tenant_b.id, "branch_id": branch_b.id,
"client_name": "UAT Client B Pvt Ltd", "client_type": "Company",
"pan": "AABCU2222A", "gstin": "33AABCU2222A1Z5",
"email": "client.b@uat.test", "mobile": "9000000002",
"status": "active", "is_active": True,
},
)
ids["CLIENT_A_ID"] = client_a.id
ids["CLIENT_B_ID"] = client_b.id
# --- Notice case (optional) -------------------------------------------
if NoticeCase is not None:
nc, _ = get_or_create(
NoticeCase, {"reference_no": "UAT-NOTICE-001"},
{
"tenant_id": tenant_a.id, "branch_id": branch_a.id,
"client_id": client_a.id, "case_code": "UAT-NC-A-001",
"department": "GST", "case_type": "Notice",
"title": "UAT GST Notice", "status": "Open",
"notice_date": date.today(),
"due_date": date.today() + timedelta(days=15),
"issue_summary": "Seed notice case for Playwright tests",
},
)
ids["NOTICE_CASE_A_ID"] = nc.id
db.commit()
print("\nSeed completed. Paste these into the Playwright .env:\n")
for k, v in ids.items():
print(f"{k}={v}")
print(f"\nSeed password for all uat.* users: {PASSWORD}")
except Exception:
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
main()
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
MZ fake executable content - should be rejected by secure backend.
+1
View File
@@ -0,0 +1 @@
Valid sample upload for Playwright UAT/VAPT.
+132
View File
@@ -0,0 +1,132 @@
const { test, expect, request } = require('@playwright/test');
require('dotenv').config();
const matrix = require('../data/generated-test-matrix.json');
const { login, logout, fillFirst, clickFirst } = require('../fixtures/auth');
const { expectNoServerError, expectBlockedOrSafe, expectSecurityHeaders, expectCookieFlags } = require('../fixtures/assertions');
function title(v) {
return `[${v.variantId}] ${v.sheet} :: ${v.scenario} :: ${v.variantName}`;
}
function unauthorizedRole(v) {
const r = (v.role || '').toLowerCase();
if (r.includes('client')) return 'Staff';
if (r.includes('staff') || r.includes('employee')) return 'Client';
if (r.includes('consultant')) return 'Client';
return 'Staff';
}
async function safeGoto(page, route) {
const resp = await page.goto(route || '/employee/dashboard');
await page.waitForLoadState('domcontentloaded');
return resp;
}
async function submitBlankFormIfAny(page) {
const submit = page.locator('form button[type="submit"], form input[type="submit"]').first();
if (await submit.count()) {
await submit.click().catch(() => {});
await page.waitForLoadState('domcontentloaded').catch(() => {});
}
}
async function boundaryInputIfAny(page) {
const longText = 'X'.repeat(2048);
const input = page.locator('form input[type="text"], form textarea').first();
if (await input.count()) await input.fill(longText).catch(() => {});
await submitBlankFormIfAny(page);
}
async function runSecuritySpecific(page, context, v) {
const id = v.sourceId;
if (/SEC-001/i.test(id)) {
await page.goto('/login');
for (let i = 0; i < 5; i++) {
await fillFirst(page, ['input[name="email"]','input[name="login_id"]','input[type="email"]'], process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local');
await fillFirst(page, ['input[name="password"]','input[type="password"]'], 'wrong-password-' + i);
await clickFirst(page, ['button[type="submit"]','input[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(() => {});
}
await expectNoServerError(page);
return;
}
if (/SEC-002/i.test(id)) {
await login(page, 'System Admin');
await logout(page);
const resp = await safeGoto(page, '/employee/dashboard');
await expectBlockedOrSafe(page, resp);
return;
}
if (/SEC-007|GEN-011/i.test(id)) {
const api = await request.newContext({ baseURL: process.env.BASE_URL });
const resp = await api.post('/notice-cases/new', { form: { title: 'csrf-test-no-token' } });
expect([400,401,403,404,405,422,303].includes(resp.status())).toBeTruthy();
return;
}
if (/SEC-017/i.test(id)) {
await login(page, 'System Admin');
await expectCookieFlags(context);
return;
}
if (/SEC-026/i.test(id)) {
const resp = await page.goto('/login');
await expectSecurityHeaders(resp);
return;
}
if (/SEC-010|SEC-103|SEC-104|SEC-110|SEC-111|DOM-VAPT/i.test(id)) {
await login(page, 'Staff');
const probes = ['/documents/../../app/main.py','/documents/999999/download','/notice-cases/999999999','/notice-cases/999999999/documents/999999/download','/domains/999999999/edit'];
for (const p of probes) {
const resp = await safeGoto(page, p);
await expectBlockedOrSafe(page, resp);
}
return;
}
await login(page, v.role || 'System Admin');
const resp = await safeGoto(page, v.route);
await expectNoServerError(page);
if (v.variantType === 'headers') await expectCookieFlags(context);
}
for (const v of matrix) {
test(title(v), async ({ page, context }) => {
test.info().annotations.push({ type: 'sourceId', description: v.sourceId });
test.info().annotations.push({ type: 'variantType', description: v.variantType });
test.info().annotations.push({ type: 'expected', description: String(v.expected || '').slice(0, 250) });
if (v.automation === 'manual') {
test.skip(true, v.manualReason || 'Marked manual in generated matrix');
}
if (v.variantType === 'security' || v.sheet.startsWith('VAPT_')) {
await runSecuritySpecific(page, context, v);
return;
}
if (v.variantType === 'rbac') {
await login(page, unauthorizedRole(v));
const resp = await safeGoto(page, v.route);
await expectNoServerError(page);
// A safe result may be allowed page for common dashboards, but must not leak obvious restricted data or crash.
if (!['/login','/employee/dashboard'].includes(v.route)) {
const body = await page.locator('body').innerText().catch(()=>'');
expect(body).not.toMatch(/Traceback|Internal Server Error|Exception/i);
}
return;
}
await login(page, v.role || 'System Admin');
const resp = await safeGoto(page, v.route);
await expectNoServerError(page);
if (v.variantType === 'positive') {
expect(resp.status()).toBeLessThan(500);
} else if (v.variantType === 'negative' || v.variantType === 'validation') {
await submitBlankFormIfAny(page);
await expectNoServerError(page);
} else if (v.variantType === 'boundary') {
await boundaryInputIfAny(page);
await expectNoServerError(page);
}
});
}
+84
View File
@@ -0,0 +1,84 @@
/**
* =============================================================================
* UAT_FY_Lock_Backup -- Financial Year Lock & Backup UAT scaffolding
* =============================================================================
*
* ⚠️ UNTESTED SCAFFOLDING -- READ BEFORE RUNNING ⚠️
*
* These 30 cases correspond to the UAT_FY_Lock_Backup sheet, which existed in
* the checklist but was NOT part of the generated automation matrix. This file
* was added in v2.4 to close that gap.
*
* It has NOT been executed or verified against a running ERP. The route paths,
* selectors, and assertions below are best-effort guesses based on the ERP's
* known routes (e.g. /system-settings/financial-years...). They are very likely
* to need adjustment for your build. Every case is marked test.fixme() so the
* suite does NOT report these as passing until a human has:
* 1. Confirmed the real routes/selectors for each step,
* 2. Implemented the assertion,
* 3. Removed the test.fixme() wrapper for that case.
*
* DO NOT report these as automated coverage until that work is done.
* Corresponding checklist rows remain "Not Started" / manual.
* =============================================================================
*/
const { test, expect } = require('@playwright/test');
require('dotenv').config();
const { login } = require('../fixtures/auth');
const FY_BASE = '/system-settings/financial-years';
const activeFY = process.env.ACTIVE_FY || process.env.DEFAULT_YEAR_CODE || '2025-26';
// Each entry maps to a checklist Test ID. status: 'fixme' until verified by a human.
const FY_CASES = [
['FY-001', 'Create financial year'],
['FY-002', 'Duplicate FY prevention'],
['FY-003', 'Mark current FY'],
['FY-004', 'Header FY selector persistence'],
['FY-005', 'Logout/login FY reset/default'],
['FY-006', 'Staff tenant/branch from session'],
['FY-007', 'Tenant switch updates session'],
['FY-008', 'Branch switch updates session'],
['FY-009', 'Engagement list active FY'],
['FY-010', 'Direct URL cross-FY engagement'],
['FY-011', 'Task list active FY'],
['FY-012', 'Direct URL cross-FY task update'],
['FY-013', 'Task/engagement document FY path'],
['FY-014', 'Cross-FY document download'],
['FY-015', 'Notice/case list active FY'],
['FY-016', 'Notice engagement link same FY'],
['FY-017', 'Invoice list active FY'],
['FY-018', 'Client portal billing active FY'],
['FY-019', 'Lock financial year'],
['FY-020', 'Blocked writes in locked FY'],
['FY-021', 'Unlock/reopen control'],
['FY-022', 'Generate FY backup ZIP'],
['FY-023', 'Backup export completeness'],
['FY-024', 'Unauthorized backup access'],
['FY-025', 'Backup not in static path'],
['FY-026', 'Context switch auditability'],
['FY-027', 'Fresh PostgreSQL upgrade'],
['FY-028', 'Billing FY backfill'],
['FY-029', 'Public header spoof ignored'],
['FY-030', 'Trusted header requires secret'],
];
test.describe('UAT_FY_Lock_Backup (UNTESTED SCAFFOLDING - all fixme)', () => {
for (const [id, scenario] of FY_CASES) {
// test.fixme keeps these visibly pending and NEVER green until implemented.
test.fixme(`${id} :: ${scenario}`, async ({ page }) => {
// TODO(human-verify): implement against real routes/selectors for this build.
// Reference starting points (verify before trusting):
// list: GET ${FY_BASE}
// create: GET/POST ${FY_BASE}/new
// lock: POST ${FY_BASE}/{fy_id}/lock
// unlock: POST ${FY_BASE}/{fy_id}/unlock
// backup: POST ${FY_BASE}/{fy_id}/backup/export
// download: GET ${FY_BASE}/backups/{export_id}/download
await login(page, 'System Admin');
await page.goto(FY_BASE);
// assertion intentionally omitted -- must be written per case before enabling.
expect(true).toBeTruthy();
});
}
});
+257
View File
@@ -0,0 +1,257 @@
const { test, expect, request } = require('@playwright/test');
require('dotenv').config();
const { login, fillFirst, clickFirst } = require('../fixtures/auth');
const { expectBlockedOrSafe, expectCookieFlags, expectSecurityHeaders } = require('../fixtures/assertions');
const {
readBody,
expectNoBackendError,
extractCsrfFromPage,
blockedOrNotFound,
switchFinancialYearIfPossible,
uploadPath,
} = require('../fixtures/v204-helpers');
const activeFY = process.env.ACTIVE_FY || process.env.DEFAULT_YEAR_CODE || '2025-26';
const previousFY = process.env.PREVIOUS_FY || '2024-25';
const tenantBName = process.env.TENANT_B_NAME || 'UAT Tenant B';
test.describe('v2.0.4 additional security / FY / context checks', () => {
test('V204-SEC-001 forgot-password API must not expose reset token', async () => {
const api = await request.newContext({ baseURL: process.env.BASE_URL });
const email = process.env.FIRM_ADMIN_EMAIL || process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local';
const candidates = [
{ url: '/auth/forgot-password', opts: { data: { email } } },
{ url: '/auth/forgot-password', opts: { form: { email } } },
{ url: '/forgot-password', opts: { form: { email } } },
];
let checked = false;
for (const c of candidates) {
const resp = await api.post(c.url, c.opts).catch(() => null);
if (!resp) continue;
if ([404,405].includes(resp.status())) continue;
checked = true;
expect(resp.status()).toBeLessThan(500);
const text = await resp.text();
expect(text).not.toMatch(/reset_token|password_reset_token|invite_token/i);
expect(text).not.toMatch(/[A-Za-z0-9_\-]{32,}\.[A-Za-z0-9_\-]{16,}/);
}
expect(checked).toBeTruthy();
});
test('V204-SEC-002 reset-password with random token is rejected safely', async () => {
const api = await request.newContext({ baseURL: process.env.BASE_URL });
const resp = await api.post('/auth/reset-password', {
data: { token: 'invalid-token-for-vapt', password: 'NewPassword@123' },
}).catch(() => null);
if (!resp || [404,405].includes(resp.status())) test.skip(true, 'Reset password API route not available in this environment');
expect(resp.status()).toBeLessThan(500);
expect([400,401,403,404,405,409,422,429].includes(resp.status())).toBeTruthy();
});
test('V204-SEC-003 API token/login brute force attempts do not create 500 errors', async () => {
const api = await request.newContext({ baseURL: process.env.BASE_URL });
const email = process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local';
for (let i = 0; i < 6; i++) {
const resp = await api.post('/auth/token', {
form: { username: email, email, password: `wrong-password-${i}` },
}).catch(() => null);
if (!resp || [404,405].includes(resp.status())) test.skip(true, 'API token route not available in this environment');
expect(resp.status()).toBeLessThan(500);
expect([400,401,403,422,429].includes(resp.status())).toBeTruthy();
}
});
test('V204-CTX-001 public tenant/branch/FY headers are ignored without secret', async ({ browser }) => {
const context = await browser.newContext({
baseURL: process.env.BASE_URL,
extraHTTPHeaders: {
'X-Tenant-Code': process.env.TENANT_B_CODE || 'UAT-B',
'X-Branch-Code': process.env.BRANCH_B_CODE || 'UAT-BB',
'X-Year-Code': previousFY,
},
});
const page = await context.newPage();
await login(page, 'Staff');
await page.goto('/system-settings');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(new RegExp(tenantBName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'));
await context.close();
});
test('V204-CTX-002 wrong context secret must not enable spoofed headers', async ({ browser }) => {
const context = await browser.newContext({
baseURL: process.env.BASE_URL,
extraHTTPHeaders: {
'X-Tenant-Code': process.env.TENANT_B_CODE || 'UAT-B',
'X-Branch-Code': process.env.BRANCH_B_CODE || 'UAT-BB',
'X-Year-Code': previousFY,
'X-AuditFirm-Context-Secret': 'wrong-secret-for-vapt',
},
});
const page = await context.newPage();
await login(page, 'Staff');
await page.goto('/billing');
await page.waitForLoadState('domcontentloaded').catch(() => {});
await expectNoBackendError(page);
const body = await readBody(page);
expect(body).not.toMatch(new RegExp(tenantBName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'));
await context.close();
});
test('V204-CTX-003 trusted context headers require explicit secret when enabled', async ({ browser }) => {
if ((process.env.TRUST_CONTEXT_HEADERS || '').toLowerCase() !== 'true') {
test.skip(true, 'Trusted context header mode is disabled, as expected for public UAT/prod');
}
if (!process.env.CONTEXT_HEADER_SECRET) test.fail(true, 'TRUST_CONTEXT_HEADERS=true but CONTEXT_HEADER_SECRET is empty');
const context = await browser.newContext({
baseURL: process.env.BASE_URL,
extraHTTPHeaders: {
'X-Tenant-Code': process.env.TENANT_A_CODE || 'UAT-A',
'X-Branch-Code': process.env.BRANCH_A_CODE || 'UAT-BA',
'X-Year-Code': activeFY,
'X-AuditFirm-Context-Secret': process.env.CONTEXT_HEADER_SECRET,
},
});
const page = await context.newPage();
await login(page, 'System Admin');
await page.goto('/system-settings');
await expectNoBackendError(page);
await context.close();
});
test('V204-FY-001 FY selector/session context is visible after login', async ({ page }) => {
await login(page, 'System Admin');
await page.goto('/system-settings');
await expectNoBackendError(page);
const body = await readBody(page);
const selectorExists = await page.locator('select[name="active_financial_year"], select[name="financial_year"], [data-testid="active-fy-select"], text=/FY|Financial Year|Active FY/i').count();
expect(selectorExists || /financial year|active fy|fy\s*[:\-]/i.test(body)).toBeTruthy();
});
test('V204-FY-002 switching FY does not crash core transactional pages', async ({ page }) => {
await login(page, 'System Admin');
await page.goto('/system-settings');
await switchFinancialYearIfPossible(page, activeFY);
for (const route of ['/services/engagements', '/documents', '/notice-cases', '/billing', '/billing/payments']) {
const resp = await page.goto(route).catch(() => null);
if (resp && [404,405].includes(resp.status())) continue;
await page.waitForLoadState('domcontentloaded').catch(() => {});
await expectNoBackendError(page);
}
});
test('V204-LOCK-001 locked FY write attempt should be blocked or safely rejected', async ({ page }) => {
if (!process.env.LOCKED_FY) test.skip(true, 'Set LOCKED_FY in .env after locking a financial year');
await login(page, 'System Admin');
await page.goto('/system-settings');
await switchFinancialYearIfPossible(page, process.env.LOCKED_FY);
await page.goto('/notice-cases/new');
await page.waitForLoadState('domcontentloaded').catch(() => {});
const csrf = await extractCsrfFromPage(page);
if (!csrf) test.skip(true, 'Notice create form/CSRF not available');
await fillFirst(page, ['input[name="title"]', 'textarea[name="title"]'], 'Locked FY write negative');
await fillFirst(page, ['input[name="reference_no"]'], `LOCKED-FY-${Date.now()}`);
await clickFirst(page, ['button[type="submit"]', 'input[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(() => {});
await expectNoBackendError(page);
const body = await readBody(page);
expect(/locked|closed|not allowed|cannot|permission|forbidden|access denied/i.test(body) || !page.url().match(/notice-cases\/\d+$/)).toBeTruthy();
});
test('V204-BACKUP-001 backup screens/files are not accessible anonymously', async ({ page }) => {
const probes = [
'/system-settings/financial-years/1/backup',
'/system-settings/financial-years/backup',
'/system-settings/year-backups/1/download',
'/data/year_backups/test.zip',
'/year_backups/test.zip',
'/static/year_backups/test.zip',
];
for (const p of probes) {
const resp = await page.goto(p).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
const body = await readBody(page);
await blockedOrNotFound(resp, body);
}
});
test('V204-BACKUP-002 low privilege user cannot access backup export', async ({ page }) => {
await login(page, 'Staff');
for (const p of ['/system-settings/financial-years/1/backup', '/system-settings/year-backups/1/download']) {
const resp = await page.goto(p).catch(() => null);
await page.waitForLoadState('domcontentloaded').catch(() => {});
const body = await readBody(page);
await blockedOrNotFound(resp, body);
await expectNoBackendError(page);
}
});
test('V204-UPLOAD-001 executable upload to notice/case documents is blocked', async ({ page }) => {
if (!process.env.NOTICE_CASE_A_ID) test.skip(true, 'Set NOTICE_CASE_A_ID for upload security test');
await login(page, 'System Admin');
await page.goto(`/notice-cases/${process.env.NOTICE_CASE_A_ID}`);
await page.waitForLoadState('domcontentloaded').catch(() => {});
await expectNoBackendError(page);
const fileInput = page.locator('input[type="file"]').first();
if (!(await fileInput.count())) test.skip(true, 'No file input found on notice case detail page');
await fileInput.setInputFiles(uploadPath('not-a-pdf.exe'));
await fillFirst(page, ['input[name="title"]'], 'Executable upload negative');
await clickFirst(page, ['form:has(input[type="file"]) button[type="submit"]', 'button[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(() => {});
await expectNoBackendError(page);
const body = await readBody(page);
expect(/not allowed|invalid|blocked|file type|extension|upload failed|dangerous|forbidden/i.test(body)).toBeTruthy();
});
test('V204-UPLOAD-002 oversized upload is blocked or rejected safely', async ({ page }) => {
if (!process.env.NOTICE_CASE_A_ID) test.skip(true, 'Set NOTICE_CASE_A_ID for upload size test');
await login(page, 'System Admin');
await page.goto(`/notice-cases/${process.env.NOTICE_CASE_A_ID}`);
const fileInput = page.locator('input[type="file"]').first();
if (!(await fileInput.count())) test.skip(true, 'No file input found on notice case detail page');
await fileInput.setInputFiles(uploadPath('large-file.bin'));
await fillFirst(page, ['input[name="title"]'], 'Oversized upload negative');
await clickFirst(page, ['form:has(input[type="file"]) button[type="submit"]', 'button[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(() => {});
await expectNoBackendError(page);
const body = await readBody(page);
expect(/too large|size|not allowed|invalid|blocked|upload failed|forbidden/i.test(body)).toBeTruthy();
});
test('V204-STORAGE-001 storage agent endpoints require node authentication', async () => {
const api = await request.newContext({ baseURL: process.env.BASE_URL });
const endpoints = [
'/documents/storage-agent/jobs/pending',
'/documents/storage-agent/download-requests/pending',
];
for (const url of endpoints) {
const resp = await api.get(url);
expect(resp.status()).toBeLessThan(500);
expect([400,401,403,404,405,422].includes(resp.status())).toBeTruthy();
}
});
test('V204-HEADERS-001 security headers and cookie flags remain valid', async ({ page, context }) => {
const resp = await page.goto('/login');
await expectSecurityHeaders(resp);
await login(page, 'System Admin');
await expectCookieFlags(context);
if ((process.env.EXPECT_SECURE_COOKIES || '').toLowerCase() === 'true') {
const cookies = await context.cookies();
const session = cookies.find(c => /session|auth|token|sid/i.test(c.name));
if (session) expect(session.secure).toBeTruthy();
}
});
test('V204-CSP-001 strict CSP mode should not use unsafe-inline when enforced', async ({ page }) => {
if ((process.env.EXPECT_STRICT_CSP || '').toLowerCase() !== 'true') {
test.skip(true, 'Set EXPECT_STRICT_CSP=true after inline JS/CSS has been removed');
}
const resp = await page.goto('/login');
const csp = resp.headers()['content-security-policy'] || '';
expect(csp).toBeTruthy();
expect(csp).not.toMatch(/unsafe-inline/i);
});
});
+43
View File
@@ -0,0 +1,43 @@
const { test, expect, request } = require('@playwright/test');
require('dotenv').config();
const { login, fillFirst, clickFirst } = require('../fixtures/auth');
const { expectBlockedOrSafe, expectNoServerError, expectSecurityHeaders } = require('../fixtures/assertions');
test('VAPT-TARGET-001 security headers on login', async ({ page }) => {
const resp = await page.goto('/login');
await expectSecurityHeaders(resp);
});
test('VAPT-TARGET-002 unauthorized protected URL redirects or blocks', async ({ page }) => {
const resp = await page.goto('/notice-cases');
await expectBlockedOrSafe(page, resp);
});
test('VAPT-TARGET-003 CSRF negative request should not create case', async () => {
const api = await request.newContext({ baseURL: process.env.BASE_URL });
const resp = await api.post('/notice-cases/new', { form: { title: 'csrf-negative', reference_no: 'CSRF-NEG' } });
expect([400,401,403,404,405,422,303].includes(resp.status())).toBeTruthy();
});
test('VAPT-TARGET-004 basic XSS payload should not execute in notice case list', async ({ page }) => {
await login(page, 'System Admin');
await page.goto('/notice-cases');
await expectNoServerError(page);
const body = await page.locator('body').innerText().catch(()=>'');
expect(body).not.toContain('<script>alert(1)</script>');
});
test('VAPT-TARGET-005 OTP brute force does not crash and should throttle/block', async ({ page }) => {
await page.goto('/login');
await fillFirst(page, ['input[name="email"]','input[name="login_id"]','input[type="email"]'], process.env.SYSTEM_ADMIN_EMAIL || 'admin@auditfirm.local');
await fillFirst(page, ['input[name="password"]','input[type="password"]'], process.env.SYSTEM_ADMIN_PASSWORD || 'admin123');
await clickFirst(page, ['button[type="submit"]','input[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(()=>{});
if (!page.url().includes('/otp')) test.skip(true, 'OTP page not enabled in this environment');
for (let i=0;i<8;i++) {
await fillFirst(page, ['input[name="otp"]','input[name="code"]','input[type="text"]'], '000000');
await clickFirst(page, ['button[type="submit"]','input[type="submit"]']);
await page.waitForLoadState('domcontentloaded').catch(()=>{});
}
await expectNoServerError(page);
});