Initial Playwright ERP UAT VAPT test suite v2.4.1 IMAP
This commit is contained in:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user