fix web ui smoke and brand assets
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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_CALLS_TIMEOUT_MS || 30000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
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-calls-cdr-billing/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 reportLine(item) {
|
||||
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
|
||||
}
|
||||
|
||||
function decimalLike(value) {
|
||||
return typeof value === 'string' && /^-?\d+\.\d{6}$/.test(value);
|
||||
}
|
||||
|
||||
function cdrItemLooksSafe(item) {
|
||||
const text = JSON.stringify(item);
|
||||
return !/password|secret|token|ha1/i.test(text);
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
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 lowCdrList = await requestApi('/api/v2/cdrs?take=1', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list CDRs', lowCdrList, 403));
|
||||
|
||||
const lowActiveCalls = await requestApi('/api/v2/active-calls', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list active calls', lowActiveCalls, 403));
|
||||
|
||||
const cdrList = await requestApi('/api/v2/cdrs?take=20', { accessToken });
|
||||
const cdrListBody = parseJson(cdrList);
|
||||
checks.push(statusCheck('CDR list can be queried', cdrList, 200));
|
||||
checks.push(
|
||||
check(
|
||||
'CDR list returns page shape',
|
||||
Array.isArray(cdrListBody?.items) &&
|
||||
Number.isInteger(cdrListBody?.meta?.total) &&
|
||||
cdrListBody.meta.take === 20 &&
|
||||
cdrListBody.meta.skip === 0 &&
|
||||
typeof cdrListBody.meta.hasMore === 'boolean',
|
||||
`total=${cdrListBody?.meta?.total}, take=${cdrListBody?.meta?.take}, skip=${cdrListBody?.meta?.skip}, hasMore=${cdrListBody?.meta?.hasMore}`
|
||||
)
|
||||
);
|
||||
checks.push(check('CDR list items do not expose secrets', (cdrListBody?.items || []).every(cdrItemLooksSafe), `items=${cdrListBody?.items?.length || 0}`));
|
||||
|
||||
const firstCdr = cdrListBody?.items?.[0];
|
||||
if (firstCdr) {
|
||||
const detail = await requestApi(`/api/v2/cdrs/${encodeURIComponent(firstCdr.id)}`, { accessToken });
|
||||
const detailBody = parseJson(detail);
|
||||
checks.push(statusCheck('CDR detail can be fetched', detail, 200));
|
||||
checks.push(check('CDR detail matches list item id and event id', detailBody?.id === firstCdr.id && detailBody?.eventId === firstCdr.eventId, `id=${detailBody?.id}, eventId=${detailBody?.eventId}`));
|
||||
checks.push(check('CDR detail does not expose secrets', cdrItemLooksSafe(detailBody), `id=${detailBody?.id}`));
|
||||
if (detailBody?.rated) {
|
||||
checks.push(
|
||||
check(
|
||||
'rated CDR detail has numeric fee fields',
|
||||
decimalLike(detailBody.rated.customerFee) && decimalLike(detailBody.rated.vendorCost) && decimalLike(detailBody.rated.grossProfit) && Number.isInteger(detailBody.rated.billSec),
|
||||
`billSec=${detailBody.rated.billSec}, customerFee=${detailBody.rated.customerFee}, vendorCost=${detailBody.rated.vendorCost}, grossProfit=${detailBody.rated.grossProfit}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
checks.push(check('unrated/skipped CDR detail has no rated fee payload', detailBody?.ratingStatus !== 'RATED', `ratingStatus=${detailBody?.ratingStatus}`));
|
||||
}
|
||||
|
||||
const callerFilter = await requestApi(`/api/v2/cdrs?caller=${encodeURIComponent(firstCdr.caller)}&take=10`, { accessToken });
|
||||
const callerFilterBody = parseJson(callerFilter);
|
||||
checks.push(statusCheck('CDR caller filter can be queried', callerFilter, 200));
|
||||
checks.push(check('CDR caller filter returns matching rows', (callerFilterBody?.items || []).every((item) => String(item.caller).includes(firstCdr.caller)), `rows=${callerFilterBody?.items?.length || 0}, caller=${firstCdr.caller}`));
|
||||
|
||||
if (firstCdr.calleeOperator) {
|
||||
const carrierFilter = await requestApi(`/api/v2/cdrs?carrier=${encodeURIComponent(firstCdr.calleeOperator)}&take=10`, { accessToken });
|
||||
const carrierFilterBody = parseJson(carrierFilter);
|
||||
checks.push(statusCheck('CDR carrier filter can be queried', carrierFilter, 200));
|
||||
checks.push(check('CDR carrier filter returns matching rows', (carrierFilterBody?.items || []).every((item) => item.calleeOperator === firstCdr.calleeOperator), `rows=${carrierFilterBody?.items?.length || 0}, carrier=${firstCdr.calleeOperator}`));
|
||||
}
|
||||
} else {
|
||||
checks.push(check('CDR detail checks skipped because no CDR exists', true, 'No CDR rows returned by remote service.'));
|
||||
}
|
||||
|
||||
const invalidCarrier = await requestApi('/api/v2/cdrs?carrier=BAD&take=10', { accessToken });
|
||||
checks.push(statusCheck('invalid CDR carrier is rejected', invalidCarrier, 400));
|
||||
checks.push(check('invalid carrier returns CARRIER_INVALID', parseJson(invalidCarrier)?.code === 'CARRIER_INVALID', `code=${parseJson(invalidCarrier)?.code || 'n/a'}`));
|
||||
|
||||
const invalidTake = await requestApi('/api/v2/cdrs?take=0', { accessToken });
|
||||
checks.push(statusCheck('invalid CDR pagination is rejected', invalidTake, 400));
|
||||
checks.push(check('invalid pagination returns QUERY_INVALID', parseJson(invalidTake)?.code === 'QUERY_INVALID', `code=${parseJson(invalidTake)?.code || 'n/a'}`));
|
||||
|
||||
const invalidTimeRange = await requestApi('/api/v2/cdrs?startedFrom=2026-01-02T00:00:00.000Z&startedTo=2026-01-01T00:00:00.000Z', { accessToken });
|
||||
checks.push(statusCheck('invalid CDR time range is rejected', invalidTimeRange, 400));
|
||||
checks.push(check('invalid time range returns TIME_RANGE_INVALID', parseJson(invalidTimeRange)?.code === 'TIME_RANGE_INVALID', `code=${parseJson(invalidTimeRange)?.code || 'n/a'}`));
|
||||
|
||||
const missingCdr = await requestApi('/api/v2/cdrs/not-a-real-cdr-id', { accessToken });
|
||||
checks.push(statusCheck('missing CDR detail returns 404', missingCdr, 404));
|
||||
checks.push(check('missing CDR returns CDR_NOT_FOUND', parseJson(missingCdr)?.code === 'CDR_NOT_FOUND', `code=${parseJson(missingCdr)?.code || 'n/a'}`));
|
||||
|
||||
const activeCalls = await requestApi('/api/v2/active-calls', { accessToken });
|
||||
const activeCallsBody = parseJson(activeCalls);
|
||||
checks.push(statusCheck('active calls list can be queried', activeCalls, 200));
|
||||
checks.push(
|
||||
check(
|
||||
'active calls response has normalized shape',
|
||||
typeof activeCallsBody?.generatedAt === 'string' &&
|
||||
activeCallsBody?.source === 'opensips-mi' &&
|
||||
Number.isInteger(activeCallsBody?.total) &&
|
||||
Array.isArray(activeCallsBody?.items),
|
||||
`source=${activeCallsBody?.source}, total=${activeCallsBody?.total}`
|
||||
)
|
||||
);
|
||||
|
||||
const invalidHangup = await requestApi(`/api/v2/active-calls/${encodeURIComponent('bad id!')}/hangup`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
});
|
||||
checks.push(statusCheck('invalid active call hangup id is rejected before MI call', invalidHangup, 400));
|
||||
checks.push(check('invalid active call id returns ACTIVE_CALL_ID_INVALID', parseJson(invalidHangup)?.code === 'ACTIVE_CALL_ID_INVALID', `code=${parseJson(invalidHangup)?.code || 'n/a'}`));
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_CALLS_CDR_BILLING_${stamp}.md`);
|
||||
const failed = checks.filter((item) => !item.pass);
|
||||
const report = [
|
||||
'# Remote SIP Calls, CDR, and Billing API Test Report',
|
||||
'',
|
||||
`Date: ${now.toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Username: ${username}`,
|
||||
`Low-Privilege Username: ${lowUsername}`,
|
||||
'',
|
||||
'| Result | Check | Detail |',
|
||||
'| --- | --- | --- |',
|
||||
...checks.map(reportLine),
|
||||
'',
|
||||
'## Not Executed By This Black-Box API Run',
|
||||
'',
|
||||
'- Real IP/SIP customer calls from T through A to UAS.',
|
||||
'- SIP Digest wrong-password REGISTER/INVITE signaling assertions.',
|
||||
'- Low-balance hot-path rejection assertions.',
|
||||
'- Primary/backup route failover proven by live call CDR vendorGatewayId.',
|
||||
'- Redis Stream CDR injection, duplicate event idempotency, deadletter, and retry/pending checks.',
|
||||
'- Direct customer balance deduction by CDR Worker transaction.',
|
||||
'',
|
||||
'These require A/B/T SIP tooling or Redis/DB side access in addition to the HTTPS API.',
|
||||
'',
|
||||
].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 (failed.length > 0) process.exitCode = 1;
|
||||
Reference in New Issue
Block a user