Files
lisglosips/tests/api/remote-customer-gateways-prefixes.mjs
T

452 lines
17 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_GATEWAYS_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const prefixValue = process.env.LISGLOSIPS_83_PREFIX || 'C83';
const prefixName = process.env.LISGLOSIPS_83_PREFIX_NAME || '自动化8.3业务前缀';
const customerName = process.env.LISGLOSIPS_83_CUSTOMER_NAME || '自动化8.3客户';
const customerDomain = process.env.LISGLOSIPS_83_CUSTOMER_DOMAIN || 'codex-83.example.test';
const gatewayName = process.env.LISGLOSIPS_83_GATEWAY_NAME || '自动化8.3客户网关';
const gatewayIp = process.env.LISGLOSIPS_83_GATEWAY_IP || '100.83.0.10';
const callerPrefix = process.env.LISGLOSIPS_83_CALLER_PREFIX || '055183';
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-customer-gateways-prefixes/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, '\\|')} |`;
}
async function ensureCustomer(accessToken) {
const list = await requestApi('/api/v2/customers', { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.name === customerName || item.domain === customerDomain) : null;
if (existing) {
const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.id)}`, {
method: 'PATCH',
accessToken,
body: {
name: customerName,
contactName: 'Codex 8.3',
phone: '13800138300',
email: 'codex-83@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.3 customer gateway test customer',
},
});
return { action: 'updated', result: updated, customer: parseJson(updated) };
}
const created = await requestApi('/api/v2/customers', {
method: 'POST',
accessToken,
body: {
name: customerName,
contactName: 'Codex 8.3',
phone: '13800138300',
email: 'codex-83@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.3 customer gateway test customer',
},
});
return { action: 'created', result: created, customer: parseJson(created) };
}
async function ensureBusinessPrefix(accessToken) {
const list = await requestApi(`/api/v2/business-prefixes?keyword=${encodeURIComponent(prefixValue)}`, { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.prefix === prefixValue || item.name === prefixName) : null;
if (existing) {
const updated = await requestApi(`/api/v2/business-prefixes/${encodeURIComponent(existing.id)}`, {
method: 'PATCH',
accessToken,
body: {
prefix: prefixValue,
name: prefixName,
description: 'Codex 8.3 business prefix',
priority: 83,
status: 'ENABLED',
},
});
return { action: 'updated', result: updated, prefix: parseJson(updated) };
}
const created = await requestApi('/api/v2/business-prefixes', {
method: 'POST',
accessToken,
body: {
prefix: prefixValue,
name: prefixName,
description: 'Codex 8.3 business prefix',
priority: 83,
status: 'ENABLED',
},
});
return { action: 'created', result: created, prefix: parseJson(created) };
}
async function firstEnabledLineGroup(accessToken) {
const list = await requestApi('/api/v2/landing-line-groups', { accessToken });
const items = parseJson(list);
return {
result: list,
lineGroup: Array.isArray(items) ? items.find((item) => item.status === 'ENABLED') || items[0] : null,
};
}
async function ensureGateway(accessToken, customerId, lineGroupId, businessPrefixId) {
const list = await requestApi(`/api/v2/customer-gateways?customerId=${encodeURIComponent(customerId)}`, { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.name === gatewayName) : null;
const body = {
customerId,
name: gatewayName,
authMode: 'IP',
sourceIps: [gatewayIp],
lineGroupId,
billingCycleSec: 60,
cycleRate: '0.080000',
callerMatchMode: 'PREFIXES',
callerPrefixes: [callerPrefix],
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixIds: [businessPrefixId],
};
if (existing) {
const updated = await requestApi(`/api/v2/customer-gateways/${encodeURIComponent(existing.id)}`, {
method: 'PATCH',
accessToken,
body,
});
return { action: 'updated', result: updated, gateway: parseJson(updated) };
}
const created = await requestApi('/api/v2/customer-gateways', {
method: 'POST',
accessToken,
body,
});
return { action: 'created', result: created, gateway: parseJson(created) };
}
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 lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const lowPrefixList = await requestApi('/api/v2/business-prefixes', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list business prefixes', lowPrefixList, 403));
const lowGatewayList = await requestApi('/api/v2/customer-gateways', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list customer gateways', lowGatewayList, 403));
const accessToken = adminLogin.body?.accessToken;
const invalidPrefix = await requestApi('/api/v2/business-prefixes', {
method: 'POST',
accessToken,
body: {
prefix: '8.3-*',
name: 'Invalid 8.3 prefix',
priority: 83,
},
});
const invalidPrefixBody = parseJson(invalidPrefix);
checks.push(statusCheck('invalid business prefix is rejected', invalidPrefix, 400));
checks.push(check('invalid business prefix returns BUSINESS_PREFIX_INVALID', invalidPrefixBody?.code === 'BUSINESS_PREFIX_INVALID', `code=${invalidPrefixBody?.code || 'n/a'}`));
const businessPrefix = await ensureBusinessPrefix(accessToken);
checks.push(statusCheck(`business prefix is ${businessPrefix.action}`, businessPrefix.result, businessPrefix.action === 'created' ? 201 : 200));
checks.push(check('business prefix has expected values', businessPrefix.prefix?.prefix === prefixValue && businessPrefix.prefix?.priority === 83, `id=${businessPrefix.prefix?.id}, prefix=${businessPrefix.prefix?.prefix}, priority=${businessPrefix.prefix?.priority}`));
const disablePrefix = await requestApi(`/api/v2/business-prefixes/${encodeURIComponent(businessPrefix.prefix?.id)}/disable`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('business prefix can be disabled', disablePrefix, 201));
checks.push(check('disabled business prefix status is DISABLED', parseJson(disablePrefix)?.status === 'DISABLED', `status=${parseJson(disablePrefix)?.status}`));
const enablePrefix = await requestApi(`/api/v2/business-prefixes/${encodeURIComponent(businessPrefix.prefix?.id)}/enable`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('business prefix can be enabled', enablePrefix, 201));
checks.push(check('enabled business prefix status is ENABLED', parseJson(enablePrefix)?.status === 'ENABLED', `status=${parseJson(enablePrefix)?.status}`));
const customer = await ensureCustomer(accessToken);
checks.push(statusCheck(`test customer is ${customer.action}`, customer.result, customer.action === 'created' ? 201 : 200));
const lineGroup = await firstEnabledLineGroup(accessToken);
checks.push(statusCheck('landing line group list can be fetched', lineGroup.result, 200));
checks.push(check('at least one landing line group is available for gateway binding', Boolean(lineGroup.lineGroup?.id), `lineGroupId=${lineGroup.lineGroup?.id || 'n/a'}, name=${lineGroup.lineGroup?.name || 'n/a'}`));
const invalidGatewayIp = await requestApi('/api/v2/customer-gateways', {
method: 'POST',
accessToken,
body: {
customerId: customer.customer?.id,
name: '自动化8.3无效IP网关',
authMode: 'IP',
sourceIps: ['999.999.999.999'],
lineGroupId: lineGroup.lineGroup?.id,
callerMatchMode: 'ANY',
calleeMatchMode: 'ANY',
},
});
const invalidGatewayIpBody = parseJson(invalidGatewayIp);
checks.push(statusCheck('invalid customer gateway source IP is rejected', invalidGatewayIp, 400));
checks.push(check('invalid source IP returns SOURCE_IP_INVALID', invalidGatewayIpBody?.code === 'SOURCE_IP_INVALID', `code=${invalidGatewayIpBody?.code || 'n/a'}`));
const missingSipPassword = await requestApi('/api/v2/customer-gateways', {
method: 'POST',
accessToken,
body: {
customerId: customer.customer?.id,
name: '自动化8.3无密码SIP网关',
authMode: 'SIP_DIGEST',
sipUsername: 'codex83sip',
sipDomain: customerDomain,
lineGroupId: lineGroup.lineGroup?.id,
callerMatchMode: 'ANY',
calleeMatchMode: 'ANY',
},
});
const missingSipPasswordBody = parseJson(missingSipPassword);
checks.push(statusCheck('SIP gateway without password is rejected', missingSipPassword, 400));
checks.push(
check(
'missing SIP password returns a validation error',
['SIP_PASSWORD_REQUIRED', 'VALIDATION_ERROR'].includes(missingSipPasswordBody?.code),
`code=${missingSipPasswordBody?.code || 'n/a'}`
)
);
const gateway = await ensureGateway(accessToken, customer.customer?.id, lineGroup.lineGroup?.id, businessPrefix.prefix?.id);
checks.push(statusCheck(`customer gateway is ${gateway.action}`, gateway.result, gateway.action === 'created' ? 201 : 200));
checks.push(
check(
'customer gateway binds IP, caller prefix, and business prefix',
gateway.gateway?.sourceIps?.includes(gatewayIp) &&
gateway.gateway?.callerPrefixes?.includes(callerPrefix) &&
gateway.gateway?.businessPrefixes?.some((item) => item.id === businessPrefix.prefix?.id),
`gatewayId=${gateway.gateway?.id}, sourceIps=${gateway.gateway?.sourceIps?.join(',')}, callerPrefixes=${gateway.gateway?.callerPrefixes?.join(',')}`
)
);
const duplicateGateway = await requestApi('/api/v2/customer-gateways', {
method: 'POST',
accessToken,
body: {
customerId: customer.customer?.id,
name: '自动化8.3冲突客户网关',
authMode: 'IP',
sourceIps: [gatewayIp],
lineGroupId: lineGroup.lineGroup?.id,
billingCycleSec: 60,
cycleRate: '0.080000',
callerMatchMode: 'ANY',
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixIds: [businessPrefix.prefix?.id],
},
});
const duplicateGatewayBody = parseJson(duplicateGateway);
checks.push(statusCheck('duplicate source IP and business prefix gateway is rejected', duplicateGateway, 409));
checks.push(check('duplicate gateway returns match conflict code', duplicateGatewayBody?.code === 'CUSTOMER_GATEWAY_MATCH_CONFLICT', `code=${duplicateGatewayBody?.code || 'n/a'}`));
const disableGateway = await requestApi(`/api/v2/customer-gateways/${encodeURIComponent(gateway.gateway?.id)}/disable`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('customer gateway can be disabled', disableGateway, 201));
checks.push(check('disabled customer gateway status is DISABLED', parseJson(disableGateway)?.status === 'DISABLED', `status=${parseJson(disableGateway)?.status}`));
const enableGateway = await requestApi(`/api/v2/customer-gateways/${encodeURIComponent(gateway.gateway?.id)}/enable`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('customer gateway can be enabled', enableGateway, 201));
checks.push(check('enabled customer gateway status is ENABLED', parseJson(enableGateway)?.status === 'ENABLED', `status=${parseJson(enableGateway)?.status}`));
const deletePrefixInUse = await requestApi(`/api/v2/business-prefixes/${encodeURIComponent(businessPrefix.prefix?.id)}`, {
method: 'DELETE',
accessToken,
});
const deletePrefixInUseBody = parseJson(deletePrefixInUse);
checks.push(statusCheck('business prefix in use cannot be deleted', deletePrefixInUse, 400));
checks.push(check('business prefix in use returns BUSINESS_PREFIX_IN_USE', deletePrefixInUseBody?.code === 'BUSINESS_PREFIX_IN_USE', `code=${deletePrefixInUseBody?.code || 'n/a'}`));
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_CUSTOMER_GATEWAYS_PREFIXES_${stamp}.md`);
const failed = checks.filter((item) => !item.pass);
const report = [
'# Remote Customer Gateways, Business Prefixes, and Config Intent Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
`Business Prefix: ${prefixValue}`,
`Customer Name: ${customerName}`,
`Gateway Name: ${gatewayName}`,
`Gateway IP: ${gatewayIp}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Notes',
'',
'- Password and token values are intentionally omitted.',
'- Business prefix and customer gateway mutations enqueue config outbox events server-side.',
'- Config publication manifest is not exposed by the black-box API; DB or Redis observation is required to assert worker publication directly.',
'',
].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;
}