fix web ui smoke and brand assets
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
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_DASHBOARD_TIMEOUT_MS || 30000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
const tempUsername = `codex.audit.${Date.now()}`;
|
||||
const tempPassword = 'SensitivePass-001';
|
||||
const resetPassword = 'SensitivePass-002';
|
||||
|
||||
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',
|
||||
'User-Agent': 'lisglosips-remote-dashboard-active-audit/1.0',
|
||||
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
|
||||
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
};
|
||||
|
||||
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'] || ''),
|
||||
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: '', 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 statusInCheck(name, result, statuses) {
|
||||
return check(name, result.ok && statuses.includes(result.statusCode), result.error || `status=${result.statusCode}, expected=${statuses.join('/')}, duration=${result.durationMs}ms`);
|
||||
}
|
||||
|
||||
function reportLine(item) {
|
||||
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
|
||||
}
|
||||
|
||||
function decimal6(value) {
|
||||
return typeof value === 'string' && /^-?\d+\.\d{6}$/.test(value);
|
||||
}
|
||||
|
||||
function ratio4(value) {
|
||||
return typeof value === 'string' && /^\d+\.\d{4}$/.test(value);
|
||||
}
|
||||
|
||||
function isRecord(value) {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function auditTextLooksSafe(value) {
|
||||
const text = JSON.stringify(value);
|
||||
return !text.includes(tempPassword) && !text.includes(resetPassword) && !/passwordHash|sipHa1/i.test(text);
|
||||
}
|
||||
|
||||
function trendBucketsAreContiguous(buckets) {
|
||||
if (!Array.isArray(buckets)) return false;
|
||||
for (let index = 1; index < buckets.length; index += 1) {
|
||||
if (buckets[index - 1].end !== buckets[index].start) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function findDashboardRole(accessToken) {
|
||||
const roles = await requestApi('/api/v2/roles', { accessToken });
|
||||
const body = parseJson(roles);
|
||||
const role = Array.isArray(body) ? body.find((item) => Array.isArray(item.permissionIds) && item.permissionIds.includes('dashboard.view')) : null;
|
||||
return { roles, role };
|
||||
}
|
||||
|
||||
async function createTempUser(accessToken, roleId) {
|
||||
return requestApi('/api/v2/users', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
username: tempUsername,
|
||||
displayName: 'Codex 8.7 审计脱敏临时用户',
|
||||
password: tempPassword,
|
||||
requirePasswordChange: false,
|
||||
roleIds: [roleId],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
const cleanup = [];
|
||||
|
||||
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 lowToken = lowLogin.body?.accessToken;
|
||||
|
||||
const dashboardSummary = await requestApi('/api/v2/dashboard/summary', { accessToken });
|
||||
const dashboardBody = parseJson(dashboardSummary);
|
||||
checks.push(statusCheck('dashboard summary can be queried', dashboardSummary, 200));
|
||||
checks.push(
|
||||
check(
|
||||
'dashboard summary shape and Shanghai day window are valid',
|
||||
isRecord(dashboardBody) &&
|
||||
dashboardBody.window?.timezone === 'Asia/Shanghai' &&
|
||||
dashboardBody.window?.start?.endsWith('T16:00:00.000Z') &&
|
||||
Number.isInteger(dashboardBody.calls?.totalCalls) &&
|
||||
Number.isInteger(dashboardBody.calls?.answeredCalls) &&
|
||||
Number.isInteger(dashboardBody.calls?.failedCalls) &&
|
||||
ratio4(dashboardBody.calls?.answerRate) &&
|
||||
decimal6(dashboardBody.money?.customerFee) &&
|
||||
decimal6(dashboardBody.money?.vendorCost) &&
|
||||
decimal6(dashboardBody.money?.grossProfit) &&
|
||||
Number.isInteger(dashboardBody.quality?.pendingReviews),
|
||||
JSON.stringify({ window: dashboardBody?.window, calls: dashboardBody?.calls, money: dashboardBody?.money, quality: dashboardBody?.quality })
|
||||
)
|
||||
);
|
||||
|
||||
const dashboardTrends = await requestApi('/api/v2/dashboard/trends?hours=2&bucketMinutes=60', { accessToken });
|
||||
const trendsBody = parseJson(dashboardTrends);
|
||||
checks.push(statusCheck('dashboard trends can be queried with fixed range', dashboardTrends, 200));
|
||||
checks.push(
|
||||
check(
|
||||
'dashboard trends return fixed contiguous buckets',
|
||||
Array.isArray(trendsBody?.buckets) &&
|
||||
trendsBody.buckets.length === 2 &&
|
||||
trendBucketsAreContiguous(trendsBody.buckets) &&
|
||||
trendsBody.buckets.every((bucket) => Number.isInteger(bucket.calls?.totalCalls) && decimal6(bucket.money?.customerFee)),
|
||||
`bucketCount=${Array.isArray(trendsBody?.buckets) ? trendsBody.buckets.length : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const invalidTrendBucket = await requestApi('/api/v2/dashboard/trends?hours=2&bucketMinutes=10', { accessToken });
|
||||
checks.push(statusCheck('invalid dashboard trend bucket is rejected', invalidTrendBucket, 400));
|
||||
checks.push(check('invalid trend bucket returns DASHBOARD_BUCKET_INVALID', parseJson(invalidTrendBucket)?.code === 'DASHBOARD_BUCKET_INVALID', `code=${parseJson(invalidTrendBucket)?.code}`));
|
||||
|
||||
const invalidTrendHours = await requestApi('/api/v2/dashboard/trends?hours=169&bucketMinutes=60', { accessToken });
|
||||
checks.push(statusCheck('too-large dashboard trend range is rejected', invalidTrendHours, 400));
|
||||
checks.push(check('too-large trend range returns INTEGER_INVALID', parseJson(invalidTrendHours)?.code === 'INTEGER_INVALID', `code=${parseJson(invalidTrendHours)?.code}`));
|
||||
|
||||
const lowDashboard = await requestApi('/api/v2/dashboard/summary', { accessToken: lowToken });
|
||||
checks.push(statusCheck('low dashboard-only user can query dashboard summary', lowDashboard, 200));
|
||||
|
||||
const activeCalls = await requestApi('/api/v2/active-calls', { accessToken });
|
||||
const activeCallsBody = parseJson(activeCalls);
|
||||
checks.push(statusCheck('active calls can be listed', activeCalls, 200));
|
||||
checks.push(
|
||||
check(
|
||||
'active calls response shape is stable',
|
||||
isRecord(activeCallsBody) && activeCallsBody.source === 'opensips-mi' && Number.isInteger(activeCallsBody.total) && Array.isArray(activeCallsBody.items),
|
||||
JSON.stringify({ total: activeCallsBody?.total, source: activeCallsBody?.source })
|
||||
)
|
||||
);
|
||||
|
||||
const lowActiveCalls = await requestApi('/api/v2/active-calls', { accessToken: lowToken });
|
||||
checks.push(statusCheck('low dashboard-only user cannot list active calls', lowActiveCalls, 403));
|
||||
|
||||
for (const badId of ['../x', ';rm -rf', 'contains space', 'line\nbreak', 'x'.repeat(221)]) {
|
||||
const encoded = encodeURIComponent(badId);
|
||||
const invalidHangup = await requestApi(`/api/v2/active-calls/${encoded}/hangup`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck(`invalid active call id is rejected (${badId.replace(/\n/g, '\\n').slice(0, 20)})`, invalidHangup, 400));
|
||||
checks.push(
|
||||
check(
|
||||
`invalid active call id returns ACTIVE_CALL_ID_INVALID (${badId.replace(/\n/g, '\\n').slice(0, 20)})`,
|
||||
parseJson(invalidHangup)?.code === 'ACTIVE_CALL_ID_INVALID',
|
||||
`code=${parseJson(invalidHangup)?.code}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const lowHangup = await requestApi('/api/v2/active-calls/safe-dialog-001/hangup', { method: 'POST', accessToken: lowToken });
|
||||
checks.push(statusCheck('low dashboard-only user cannot hang up calls', lowHangup, 403));
|
||||
|
||||
const auditList = await requestApi('/api/v2/audit-logs?take=10', { accessToken });
|
||||
const auditListBody = parseJson(auditList);
|
||||
checks.push(statusCheck('audit logs can be listed', auditList, 200));
|
||||
checks.push(check('audit list shape is valid', Array.isArray(auditListBody?.items) && Number.isInteger(auditListBody?.total), `count=${auditListBody?.items?.length ?? 'n/a'}, total=${auditListBody?.total ?? 'n/a'}`));
|
||||
|
||||
const lowAuditList = await requestApi('/api/v2/audit-logs?take=1', { accessToken: lowToken });
|
||||
checks.push(statusCheck('low dashboard-only user cannot list audit logs', lowAuditList, 403));
|
||||
|
||||
const invalidAuditResult = await requestApi('/api/v2/audit-logs?result=BAD', { accessToken });
|
||||
checks.push(statusCheck('invalid audit result filter is rejected', invalidAuditResult, 400));
|
||||
checks.push(check('invalid audit result returns AUDIT_RESULT_INVALID', parseJson(invalidAuditResult)?.code === 'AUDIT_RESULT_INVALID', `code=${parseJson(invalidAuditResult)?.code}`));
|
||||
|
||||
const auditSuccessList = await requestApi('/api/v2/audit-logs?result=SUCCESS&take=5', { accessToken });
|
||||
const auditSuccessBody = parseJson(auditSuccessList);
|
||||
checks.push(statusCheck('audit logs can be filtered by result', auditSuccessList, 200));
|
||||
checks.push(check('audit success filter only returns SUCCESS rows', Array.isArray(auditSuccessBody?.items) && auditSuccessBody.items.every((item) => item.result === 'SUCCESS'), `count=${auditSuccessBody?.items?.length ?? 'n/a'}`));
|
||||
|
||||
const rolesLookup = await findDashboardRole(accessToken);
|
||||
checks.push(statusCheck('roles can be listed for temporary audit user setup', rolesLookup.roles, 200));
|
||||
checks.push(check('dashboard-capable role is available', typeof rolesLookup.role?.id === 'string', `roleId=${rolesLookup.role?.id || 'n/a'}`));
|
||||
|
||||
let tempUser = null;
|
||||
if (rolesLookup.role?.id) {
|
||||
const createdUser = await createTempUser(accessToken, rolesLookup.role.id);
|
||||
tempUser = parseJson(createdUser);
|
||||
checks.push(statusInCheck('temporary user with sensitive password can be created', createdUser, [201]));
|
||||
checks.push(check('created temporary user response does not expose password fields', auditTextLooksSafe(tempUser), JSON.stringify({ id: tempUser?.id, username: tempUser?.username })));
|
||||
|
||||
if (tempUser?.id) {
|
||||
cleanup.push(() => requestApi(`/api/v2/users/${encodeURIComponent(tempUser.id)}`, { method: 'DELETE', accessToken }));
|
||||
const reset = await requestApi(`/api/v2/users/${encodeURIComponent(tempUser.id)}/reset-password`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { password: resetPassword, nested: { refreshToken: 'nested-token-probe' } },
|
||||
});
|
||||
checks.push(statusCheck('temporary user password reset succeeds', reset, 201));
|
||||
checks.push(check('password reset response does not expose sensitive fields', auditTextLooksSafe(parseJson(reset)), JSON.stringify({ id: parseJson(reset)?.id, username: parseJson(reset)?.username })));
|
||||
|
||||
const resetAudit = await requestApi(`/api/v2/audit-logs?module=users&action=reset_password&objectId=${encodeURIComponent(tempUser.id)}&result=SUCCESS&take=5`, { accessToken });
|
||||
const resetAuditBody = parseJson(resetAudit);
|
||||
const resetAuditItem = Array.isArray(resetAuditBody?.items) ? resetAuditBody.items[0] : null;
|
||||
checks.push(statusCheck('password reset audit can be filtered by module/action/object/result', resetAudit, 200));
|
||||
checks.push(check('password reset audit row exists', typeof resetAuditItem?.id === 'string', `auditId=${resetAuditItem?.id || 'n/a'}`));
|
||||
|
||||
if (resetAuditItem?.id) {
|
||||
const auditDetail = await requestApi(`/api/v2/audit-logs/${encodeURIComponent(resetAuditItem.id)}`, { accessToken });
|
||||
const auditDetailBody = parseJson(auditDetail);
|
||||
checks.push(statusCheck('password reset audit detail can be fetched', auditDetail, 200));
|
||||
checks.push(
|
||||
check(
|
||||
'password reset audit detail redacts sensitive body fields',
|
||||
auditTextLooksSafe(auditDetailBody) && auditDetailBody?.beforeSummary?.body?.password === '[REDACTED]',
|
||||
JSON.stringify({ id: auditDetailBody?.id, redactedPassword: auditDetailBody?.beforeSummary?.body?.password })
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of cleanup.reverse()) {
|
||||
const cleanupResult = await item();
|
||||
checks.push(statusInCheck('temporary audit user cleanup is stable', cleanupResult, [200, 404]));
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replaceAll(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_DASHBOARD_ACTIVE_AUDIT_${stamp}.md`);
|
||||
const notes = [
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- Password and token values are intentionally omitted from console and report details.',
|
||||
'- DASH-001 aggregate accuracy and DASH-002 exact Shanghai day-boundary attribution still require SQL comparison against seeded boundary CDRs.',
|
||||
'- ACT-001/ACT-002 real long-call normalization and successful hangup require an active OpenSIPS dialog on A; this black-box run verifies list contract, RBAC, and invalid dialog-id safety.',
|
||||
'- AUD-002 application log full-text checks require host-side log access; this run verifies API response and audit detail redaction.',
|
||||
];
|
||||
const report = [
|
||||
'# Remote Dashboard, Active Calls, and Audit Test Report',
|
||||
'',
|
||||
`Date: ${new Date().toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Username: ${username}`,
|
||||
`Low-Privilege Username: ${lowUsername}`,
|
||||
'',
|
||||
'| 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