Initial Playwright ERP UAT VAPT test suite v2.4.1 IMAP
This commit is contained in:
+188
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = async () => {
|
||||
if (global.__ERP_PROCESS__) {
|
||||
try { global.__ERP_PROCESS__.kill(); } catch (_) {}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
Generated
+453
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
});
|
||||
@@ -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}`);
|
||||
})();
|
||||
@@ -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.');
|
||||
@@ -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}`);
|
||||
@@ -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);
|
||||
@@ -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}`);
|
||||
@@ -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()
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
MZ fake executable content - should be rejected by secure backend.
|
||||
@@ -0,0 +1 @@
|
||||
Valid sample upload for Playwright UAT/VAPT.
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user