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