fix web ui smoke and brand assets
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { request } from 'node:https';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
|
||||
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
|
||||
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
|
||||
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
|
||||
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
|
||||
const timeoutMs = Number(process.env.LISGLOSIPS_PERF_SECURITY_TIMEOUT_MS || 30000);
|
||||
const concurrency = Number(process.env.LISGLOSIPS_PERF_SECURITY_CONCURRENCY || 12);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
const customerName = '自动化8.9安全客户';
|
||||
const customerDomain = 'codex89.example.test';
|
||||
|
||||
if (!password) {
|
||||
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function requestApi(path, options = {}) {
|
||||
return new Promise((resolveRequest) => {
|
||||
const startedAt = Date.now();
|
||||
const url = new URL(path, baseUrl);
|
||||
const method = options.method || 'GET';
|
||||
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
||||
const headers = {
|
||||
Accept: 'application/json,text/html;q=0.9,*/*;q=0.8',
|
||||
'User-Agent': 'lisglosips-remote-performance-security/1.0',
|
||||
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
|
||||
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...(options.authorization ? { Authorization: options.authorization } : {}),
|
||||
};
|
||||
|
||||
console.log(`REQ ${method} ${path}`);
|
||||
let settled = false;
|
||||
let req;
|
||||
const hardTimer = setTimeout(() => req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(hardTimer);
|
||||
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
|
||||
resolveRequest(result);
|
||||
};
|
||||
|
||||
req = request(
|
||||
url,
|
||||
{
|
||||
method,
|
||||
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
|
||||
timeout: timeoutMs,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
finish({
|
||||
path,
|
||||
method,
|
||||
ok: true,
|
||||
statusCode: res.statusCode || 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
contentType: String(res.headers['content-type'] || ''),
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)));
|
||||
req.on('error', (error) => {
|
||||
finish({ path, method, ok: false, statusCode: 0, durationMs: Date.now() - startedAt, contentType: '', headers: {}, body: '', error: error.message });
|
||||
});
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(result) {
|
||||
try {
|
||||
return JSON.parse(result.body);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCaptcha(imageDataUrl) {
|
||||
const encoded = String(imageDataUrl || '').split(',', 2)[1];
|
||||
if (!encoded) return '';
|
||||
const svg = Buffer.from(encoded, 'base64').toString('utf8');
|
||||
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
|
||||
}
|
||||
|
||||
async function login(loginUsername, loginPassword) {
|
||||
const captcha = await requestApi('/api/v2/auth/captcha');
|
||||
const captchaBody = parseJson(captcha);
|
||||
const result = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username: loginUsername, password: loginPassword, captchaId: captchaBody?.captchaId, captchaCode: decodeCaptcha(captchaBody?.imageDataUrl) },
|
||||
});
|
||||
return { result, body: parseJson(result) };
|
||||
}
|
||||
|
||||
function check(name, pass, detail) {
|
||||
return { name, pass, detail };
|
||||
}
|
||||
|
||||
function statusCheck(name, result, expectedStatus) {
|
||||
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
|
||||
}
|
||||
|
||||
function reportLine(item) {
|
||||
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
|
||||
}
|
||||
|
||||
function bodyLooksSafe(result) {
|
||||
return !/stack|trace|DATABASE_URL|JWT_SECRET|password|secret|\/etc\/|\/var\/|[A-Z]:\\/i.test(result.body || '');
|
||||
}
|
||||
|
||||
async function findCustomer(accessToken) {
|
||||
const list = await requestApi('/api/v2/customers', { accessToken });
|
||||
const body = parseJson(list);
|
||||
const customer = Array.isArray(body) ? body.find((item) => item.name === customerName || item.domain === customerDomain) : null;
|
||||
return { list, customer };
|
||||
}
|
||||
|
||||
async function ensureCustomer(accessToken) {
|
||||
const existing = await findCustomer(accessToken);
|
||||
if (!existing.customer) {
|
||||
const created = await requestApi('/api/v2/customers', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
name: customerName,
|
||||
contactName: 'Codex测试联系人',
|
||||
phone: '13800138900',
|
||||
email: 'codex-89@example.test',
|
||||
domain: customerDomain,
|
||||
billingMode: 'PREPAID',
|
||||
creditLimit: '100.000000',
|
||||
minBalance: '5.000000',
|
||||
notes: 'Codex 8.9 replay/security test customer',
|
||||
},
|
||||
});
|
||||
return { action: 'created', result: created, customer: parseJson(created) };
|
||||
}
|
||||
|
||||
const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.customer.id)}`, {
|
||||
method: 'PATCH',
|
||||
accessToken,
|
||||
body: {
|
||||
name: customerName,
|
||||
contactName: 'Codex测试联系人',
|
||||
phone: '13800138900',
|
||||
email: 'codex-89@example.test',
|
||||
domain: customerDomain,
|
||||
billingMode: 'PREPAID',
|
||||
creditLimit: '100.000000',
|
||||
minBalance: '5.000000',
|
||||
notes: 'Codex 8.9 replay/security test customer',
|
||||
},
|
||||
});
|
||||
return { action: 'updated', result: updated, customer: parseJson(updated) };
|
||||
}
|
||||
|
||||
function percentile(values, ratio) {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * ratio))] ?? 0;
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
|
||||
const root = await requestApi('/');
|
||||
checks.push(statusCheck('HTTPS root is reachable before light concurrency', root, 200));
|
||||
checks.push(check('HTTPS root contains app root', root.contentType.includes('text/html') && root.body.includes('<div id="root"></div>'), `contentType=${root.contentType}, bytes=${Buffer.byteLength(root.body, 'utf8')}`));
|
||||
|
||||
const readyBefore = await requestApi('/api/v2/health/ready');
|
||||
checks.push(statusCheck('ready health is ok before light concurrency', readyBefore, 200));
|
||||
checks.push(check('ready health reports database and redis ok before light concurrency', parseJson(readyBefore)?.checks?.database === 'ok' && parseJson(readyBefore)?.checks?.redis === 'ok', `body=${JSON.stringify(parseJson(readyBefore))}`));
|
||||
|
||||
const adminLogin = await login(username, password);
|
||||
checks.push(statusCheck('admin can login', adminLogin.result, 200));
|
||||
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
|
||||
const accessToken = adminLogin.body?.accessToken;
|
||||
|
||||
const lowLogin = await login(lowUsername, lowPassword);
|
||||
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
|
||||
|
||||
const burstStartedAt = Date.now();
|
||||
const burst = await Promise.all([
|
||||
...Array.from({ length: concurrency }, () => requestApi('/api/v2/health/ready')),
|
||||
...Array.from({ length: concurrency }, () => requestApi('/api/v2/auth/captcha')),
|
||||
]);
|
||||
const burstDurations = burst.map((item) => item.durationMs);
|
||||
const burstFailed = burst.filter((item) => !item.ok || item.statusCode !== 200);
|
||||
checks.push(check('PERF light API burst returns only 200 responses', burstFailed.length === 0, `requests=${burst.length}, failed=${burstFailed.length}, wallMs=${Date.now() - burstStartedAt}`));
|
||||
checks.push(check('PERF light API burst p95 stays under 10s', percentile(burstDurations, 0.95) < 10000, `p95=${percentile(burstDurations, 0.95)}ms, max=${Math.max(...burstDurations)}ms`));
|
||||
|
||||
const readyAfter = await requestApi('/api/v2/health/ready');
|
||||
checks.push(statusCheck('ready health recovers after light concurrency', readyAfter, 200));
|
||||
checks.push(check('ready health reports database and redis ok after light concurrency', parseJson(readyAfter)?.checks?.database === 'ok' && parseJson(readyAfter)?.checks?.redis === 'ok', `body=${JSON.stringify(parseJson(readyAfter))}`));
|
||||
|
||||
const forgedToken = await requestApi('/api/v2/dashboard/summary', { authorization: 'Bearer not.a.valid.jwt' });
|
||||
checks.push(statusCheck('SEC forged bearer token is rejected', forgedToken, 401));
|
||||
checks.push(check('SEC forged token error does not leak internals', bodyLooksSafe(forgedToken), `body=${forgedToken.body.slice(0, 160)}`));
|
||||
|
||||
const unauthWrite = await requestApi('/api/v2/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'unauth should not create' },
|
||||
});
|
||||
checks.push(statusCheck('SEC unauthenticated write is rejected', unauthWrite, 401));
|
||||
checks.push(check('SEC unauthenticated write error does not leak internals', bodyLooksSafe(unauthWrite), `body=${unauthWrite.body.slice(0, 160)}`));
|
||||
|
||||
const lowWrite = await requestApi('/api/v2/customers', {
|
||||
method: 'POST',
|
||||
accessToken: lowLogin.body?.accessToken,
|
||||
body: {
|
||||
name: '低权限不应创建客户',
|
||||
contactName: 'Forbidden',
|
||||
phone: '13800138999',
|
||||
email: 'forbidden@example.test',
|
||||
domain: 'forbidden.example.test',
|
||||
billingMode: 'PREPAID',
|
||||
},
|
||||
});
|
||||
checks.push(statusCheck('SEC low-privilege user cannot create customer', lowWrite, 403));
|
||||
checks.push(check('SEC low-privilege write error does not leak internals', bodyLooksSafe(lowWrite), `body=${lowWrite.body.slice(0, 160)}`));
|
||||
|
||||
const customerSetup = await ensureCustomer(accessToken);
|
||||
checks.push(statusCheck(`SEC replay test customer is ${customerSetup.action}`, customerSetup.result, customerSetup.action === 'created' ? 201 : 200));
|
||||
const customerId = customerSetup.customer?.id;
|
||||
|
||||
const replayKey = `codex89:replay:${Date.now()}`;
|
||||
const firstRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { amount: '1.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay probe' },
|
||||
});
|
||||
const firstRechargeBody = parseJson(firstRecharge);
|
||||
checks.push(statusCheck('SEC first idempotent recharge succeeds', firstRecharge, 201));
|
||||
|
||||
const replayRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { amount: '1.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay probe' },
|
||||
});
|
||||
const replayRechargeBody = parseJson(replayRecharge);
|
||||
checks.push(statusCheck('SEC exact idempotent replay returns success', replayRecharge, 201));
|
||||
checks.push(check('SEC exact idempotent replay returns same recharge id', replayRechargeBody?.id === firstRechargeBody?.id, `first=${firstRechargeBody?.id}, replay=${replayRechargeBody?.id}`));
|
||||
|
||||
const conflictingReplay = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { amount: '2.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay conflict' },
|
||||
});
|
||||
checks.push(statusCheck('SEC conflicting idempotency replay is rejected', conflictingReplay, 409));
|
||||
checks.push(check('SEC conflicting replay error does not leak original body', !conflictingReplay.body.includes('Codex 8.9 replay probe') && bodyLooksSafe(conflictingReplay), `body=${conflictingReplay.body.slice(0, 160)}`));
|
||||
|
||||
const lowRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken: lowLogin.body?.accessToken,
|
||||
body: { amount: '1.000000', idempotencyKey: `codex89:low:${Date.now()}`, remark: 'Forbidden replay probe' },
|
||||
});
|
||||
checks.push(statusCheck('SEC low-privilege user cannot replay/write recharge', lowRecharge, 403));
|
||||
|
||||
const missingRecording = await requestApi('/api/v2/recordings/not-a-real-recording/play', { accessToken });
|
||||
checks.push(statusCheck('SEC missing recording playback returns 404', missingRecording, 404));
|
||||
checks.push(check('SEC missing recording playback error does not expose paths', bodyLooksSafe(missingRecording), `body=${missingRecording.body.slice(0, 160)}`));
|
||||
|
||||
const traversalRecording = await requestApi('/api/v2/recordings/%2E%2E%2F%2E%2E%2Fetc%2Fpasswd/play', { accessToken });
|
||||
checks.push(check('SEC encoded traversal recording id is rejected safely', [400, 404].includes(traversalRecording.statusCode), `status=${traversalRecording.statusCode}, body=${traversalRecording.body.slice(0, 120)}`));
|
||||
checks.push(check('SEC traversal playback error does not expose filesystem paths', bodyLooksSafe(traversalRecording), `body=${traversalRecording.body.slice(0, 160)}`));
|
||||
|
||||
const stamp = new Date().toISOString().replaceAll(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_PERFORMANCE_SECURITY_${stamp}.md`);
|
||||
const notes = [
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- This run intentionally avoids disruptive fault injection: no Worker, MySQL, Redis, OpenSIPS, or SIP traffic was stopped or modified.',
|
||||
'- PERF-001/PERF-002 SIP call concurrency, SEC-001 illegal-source SIP probe, and SEC-002 CPS probe still require A/T-side SIP tooling and CDR/recording verification.',
|
||||
'- FAIL-001 through FAIL-004 require an explicit maintenance window, rollback point, and service-stop approval before execution.',
|
||||
'- The executed subset covers HTTPS/API light concurrency, service recovery after burst, forged/unauthenticated/low-privilege access, idempotency replay, and recording path-safety black-box checks.',
|
||||
];
|
||||
const report = [
|
||||
'# Remote Performance, Fault, and Security Test Report',
|
||||
'',
|
||||
`Date: ${new Date().toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Concurrency: ${concurrency} ready + ${concurrency} captcha requests`,
|
||||
'',
|
||||
'| Result | Check | Detail |',
|
||||
'| --- | --- | --- |',
|
||||
...checks.map(reportLine),
|
||||
...notes,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
await writeFile(reportPath, report, 'utf8');
|
||||
|
||||
for (const item of checks) {
|
||||
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
|
||||
if (checks.some((item) => !item.pass)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user