Files
lisglosips/tests/api/remote-dashboard-active-audit.mjs

360 lines
16 KiB
JavaScript

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;
}