fix web ui smoke and brand assets
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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_VENDORS_TIMEOUT_MS || 30000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
const vendorName = '自动化8.4供应商';
|
||||
const primaryGatewayName = '自动化8.4主落地网关';
|
||||
const backupGatewayName = '自动化8.4备落地网关';
|
||||
const lineGroupName = '自动化8.4线路组';
|
||||
|
||||
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-vendors-line-groups/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, '\\|')} |`;
|
||||
}
|
||||
|
||||
async function ensureVendor(accessToken) {
|
||||
const list = await requestApi('/api/v2/vendors', { accessToken });
|
||||
const items = parseJson(list);
|
||||
const existing = Array.isArray(items) ? items.find((item) => item.name === vendorName) : null;
|
||||
const body = {
|
||||
name: vendorName,
|
||||
contactName: 'Codex 8.4',
|
||||
phone: '13900138400',
|
||||
email: 'codex-84-vendor@example.test',
|
||||
creditLimit: '200.000000',
|
||||
settlement: '月结',
|
||||
notes: 'Codex 8.4 vendor test fixture',
|
||||
};
|
||||
if (existing) {
|
||||
const updated = await requestApi(`/api/v2/vendors/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
|
||||
return { action: 'updated', result: updated, vendor: parseJson(updated) };
|
||||
}
|
||||
const created = await requestApi('/api/v2/vendors', { method: 'POST', accessToken, body });
|
||||
return { action: 'created', result: created, vendor: parseJson(created) };
|
||||
}
|
||||
|
||||
function gatewayBody(vendorId, name, host, priorityOffset = 0) {
|
||||
return {
|
||||
vendorId,
|
||||
name,
|
||||
authMode: 'IP',
|
||||
host,
|
||||
port: 5060,
|
||||
transport: 'udp',
|
||||
cpsLimit: 30 + priorityOffset,
|
||||
concurrencyLimit: 300 + priorityOffset,
|
||||
billingCycleSec: 60,
|
||||
cycleRate: priorityOffset === 0 ? '0.030000' : '0.035000',
|
||||
landingCalleePrefix: '86',
|
||||
status: 'ENABLED',
|
||||
forbiddenPeriods: [{ weekdayMask: 127, startTime: '00:00:00', endTime: '00:05:00' }],
|
||||
codecs: [
|
||||
{ codec: 'PCMA', priority: 10 },
|
||||
{ codec: 'PCMU', priority: 20 },
|
||||
],
|
||||
prefixRules: [
|
||||
{ direction: 'CALLEE', matchPrefix: '84', replacePrefix: '86', priority: 10 },
|
||||
{ direction: 'CALLER', matchPrefix: '0', replacePrefix: '', priority: 10 },
|
||||
],
|
||||
callerRewritePool: [{ caller: priorityOffset === 0 ? '0551840001' : '0551840002', weight: 100, status: 'ENABLED' }],
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureVendorGateway(accessToken, vendorId, name, host, priorityOffset = 0) {
|
||||
const list = await requestApi(`/api/v2/vendor-gateways?vendorId=${encodeURIComponent(vendorId)}`, { accessToken });
|
||||
const items = parseJson(list);
|
||||
const existing = Array.isArray(items) ? items.find((item) => item.name === name) : null;
|
||||
const body = gatewayBody(vendorId, name, host, priorityOffset);
|
||||
if (existing) {
|
||||
const updated = await requestApi(`/api/v2/vendor-gateways/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
|
||||
return { action: 'updated', result: updated, gateway: parseJson(updated) };
|
||||
}
|
||||
const created = await requestApi('/api/v2/vendor-gateways', { method: 'POST', accessToken, body });
|
||||
return { action: 'created', result: created, gateway: parseJson(created) };
|
||||
}
|
||||
|
||||
async function ensureLineGroup(accessToken) {
|
||||
const list = await requestApi('/api/v2/landing-line-groups', { accessToken });
|
||||
const items = parseJson(list);
|
||||
const existing = Array.isArray(items) ? items.find((item) => item.name === lineGroupName) : null;
|
||||
const body = { name: lineGroupName, status: 'ENABLED', notes: 'Codex 8.4 line group test fixture' };
|
||||
if (existing) {
|
||||
const updated = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
|
||||
return { action: 'updated', result: updated, group: parseJson(updated) };
|
||||
}
|
||||
const created = await requestApi('/api/v2/landing-line-groups', { method: 'POST', accessToken, body });
|
||||
return { action: 'created', result: created, group: parseJson(created) };
|
||||
}
|
||||
|
||||
async function ensureLineGroupItem(accessToken, group, gateway, priority, weight) {
|
||||
const existing = group.items?.find((item) => item.vendorGatewayId === gateway.id);
|
||||
if (existing) {
|
||||
const updated = await requestApi(`/api/v2/landing-line-groups/items/${encodeURIComponent(existing.id)}`, {
|
||||
method: 'PATCH',
|
||||
accessToken,
|
||||
body: { priority, weight, concurrencyCap: priority === 10 ? 100 : 80, status: 'ENABLED' },
|
||||
});
|
||||
return { action: 'updated', result: updated, item: parseJson(updated) };
|
||||
}
|
||||
const added = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(group.id)}/items`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { vendorGatewayId: gateway.id, priority, weight, concurrencyCap: priority === 10 ? 100 : 80, status: 'ENABLED' },
|
||||
});
|
||||
const addedGroup = parseJson(added);
|
||||
return { action: 'added', result: added, item: addedGroup?.items?.find((item) => item.vendorGatewayId === gateway.id), group: addedGroup };
|
||||
}
|
||||
|
||||
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 lowVendorList = await requestApi('/api/v2/vendors', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list vendors', lowVendorList, 403));
|
||||
const lowLineGroupList = await requestApi('/api/v2/landing-line-groups', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list line groups', lowLineGroupList, 403));
|
||||
|
||||
const invalidVendor = await requestApi('/api/v2/vendors', { method: 'POST', accessToken, body: { name: '', creditLimit: '0.000000' } });
|
||||
checks.push(statusCheck('invalid vendor is rejected', invalidVendor, 400));
|
||||
|
||||
const vendor = await ensureVendor(accessToken);
|
||||
checks.push(statusCheck(`vendor is ${vendor.action}`, vendor.result, vendor.action === 'created' ? 201 : 200));
|
||||
checks.push(check('vendor has expected credit limit', vendor.vendor?.creditLimit === '200.000000', `vendorId=${vendor.vendor?.id}, creditLimit=${vendor.vendor?.creditLimit}`));
|
||||
|
||||
const invalidGateway = await requestApi('/api/v2/vendor-gateways', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { vendorId: vendor.vendor?.id, name: '自动化8.4无效落地网关', authMode: 'IP', host: 'bad host', cpsLimit: 1 },
|
||||
});
|
||||
const invalidGatewayBody = parseJson(invalidGateway);
|
||||
checks.push(statusCheck('invalid vendor gateway host is rejected', invalidGateway, 400));
|
||||
checks.push(check('invalid host returns HOST_INVALID', invalidGatewayBody?.code === 'HOST_INVALID', `code=${invalidGatewayBody?.code || 'n/a'}`));
|
||||
|
||||
const weakSipGateway = await requestApi('/api/v2/vendor-gateways', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { vendorId: vendor.vendor?.id, name: '自动化8.4弱SIP网关', authMode: 'SIP_DIGEST', host: '10.84.0.9', sipUsername: 'vgw84', sipPassword: 'short' },
|
||||
});
|
||||
checks.push(statusCheck('weak SIP password is rejected', weakSipGateway, 400));
|
||||
|
||||
const primary = await ensureVendorGateway(accessToken, vendor.vendor?.id, primaryGatewayName, '10.84.0.10', 0);
|
||||
checks.push(statusCheck(`primary vendor gateway is ${primary.action}`, primary.result, primary.action === 'created' ? 201 : 200));
|
||||
checks.push(check('primary gateway has child config', primary.gateway?.codecs?.length === 2 && primary.gateway?.prefixRules?.length === 2 && primary.gateway?.callerRewritePool?.length === 1, `codecs=${primary.gateway?.codecs?.length}, prefixRules=${primary.gateway?.prefixRules?.length}, callerRewrite=${primary.gateway?.callerRewritePool?.length}`));
|
||||
|
||||
const backup = await ensureVendorGateway(accessToken, vendor.vendor?.id, backupGatewayName, '10.84.0.11', 5);
|
||||
checks.push(statusCheck(`backup vendor gateway is ${backup.action}`, backup.result, backup.action === 'created' ? 201 : 200));
|
||||
|
||||
const disableGateway = await requestApi(`/api/v2/vendor-gateways/${encodeURIComponent(backup.gateway?.id)}/disable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('vendor gateway can be disabled', disableGateway, 201));
|
||||
checks.push(check('disabled vendor gateway status is DISABLED', parseJson(disableGateway)?.status === 'DISABLED', `status=${parseJson(disableGateway)?.status}`));
|
||||
|
||||
const enableGateway = await requestApi(`/api/v2/vendor-gateways/${encodeURIComponent(backup.gateway?.id)}/enable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('vendor gateway can be enabled', enableGateway, 201));
|
||||
checks.push(check('enabled vendor gateway status is ENABLED', parseJson(enableGateway)?.status === 'ENABLED', `status=${parseJson(enableGateway)?.status}`));
|
||||
|
||||
const lineGroup = await ensureLineGroup(accessToken);
|
||||
checks.push(statusCheck(`line group is ${lineGroup.action}`, lineGroup.result, lineGroup.action === 'created' ? 201 : 200));
|
||||
|
||||
const primaryItem = await ensureLineGroupItem(accessToken, lineGroup.group, primary.gateway, 10, 70);
|
||||
checks.push(statusInCheck(`primary line group item is ${primaryItem.action}`, primaryItem.result, primaryItem.action === 'added' ? [201] : [200]));
|
||||
const refreshedGroup = primaryItem.group || parseJson(await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}`, { accessToken }));
|
||||
const backupItem = await ensureLineGroupItem(accessToken, refreshedGroup, backup.gateway, 20, 30);
|
||||
checks.push(statusInCheck(`backup line group item is ${backupItem.action}`, backupItem.result, backupItem.action === 'added' ? [201] : [200]));
|
||||
|
||||
const groupDetail = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}`, { accessToken });
|
||||
const groupBody = parseJson(groupDetail);
|
||||
checks.push(statusCheck('line group detail can be fetched', groupDetail, 200));
|
||||
checks.push(check('line group contains two enabled items', groupBody?.enabledItemCount >= 2 && groupBody?.items?.some((item) => item.vendorGatewayId === primary.gateway?.id) && groupBody?.items?.some((item) => item.vendorGatewayId === backup.gateway?.id), `enabledItemCount=${groupBody?.enabledItemCount}, itemCount=${groupBody?.itemCount}`));
|
||||
|
||||
const itemIds = groupBody?.items?.map((item) => item.id).reverse() || [];
|
||||
const reorder = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}/items/reorder`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { itemIds },
|
||||
});
|
||||
checks.push(statusCheck('line group items can be reordered', reorder, 201));
|
||||
checks.push(check('reorder preserves item set', parseJson(reorder)?.items?.length === groupBody?.items?.length, `before=${groupBody?.items?.length}, after=${parseJson(reorder)?.items?.length}`));
|
||||
|
||||
const duplicateItem = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}/items`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { vendorGatewayId: primary.gateway?.id, priority: 99, weight: 1, concurrencyCap: 1 },
|
||||
});
|
||||
checks.push(statusCheck('duplicate line group gateway item is rejected', duplicateItem, 409));
|
||||
|
||||
const deleteGatewayInGroup = await requestApi(`/api/v2/vendor-gateways/${encodeURIComponent(primary.gateway?.id)}`, { method: 'DELETE', accessToken });
|
||||
const deleteGatewayInGroupBody = parseJson(deleteGatewayInGroup);
|
||||
checks.push(statusCheck('vendor gateway referenced by line group cannot be deleted', deleteGatewayInGroup, 400));
|
||||
checks.push(check('referenced gateway returns VENDOR_GATEWAY_IN_LINE_GROUP', deleteGatewayInGroupBody?.code === 'VENDOR_GATEWAY_IN_LINE_GROUP', `code=${deleteGatewayInGroupBody?.code || 'n/a'}`));
|
||||
|
||||
const disableLineGroup = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}/disable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('line group can be disabled', disableLineGroup, 201));
|
||||
checks.push(check('disabled line group status is DISABLED', parseJson(disableLineGroup)?.status === 'DISABLED', `status=${parseJson(disableLineGroup)?.status}`));
|
||||
|
||||
const enableLineGroup = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}/enable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('line group can be enabled', enableLineGroup, 201));
|
||||
checks.push(check('enabled line group status is ENABLED', parseJson(enableLineGroup)?.status === 'ENABLED', `status=${parseJson(enableLineGroup)?.status}`));
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_VENDORS_LINE_GROUPS_${stamp}.md`);
|
||||
const failed = checks.filter((item) => !item.pass);
|
||||
const report = [
|
||||
'# Remote Vendors, Vendor Gateways, and Landing Line Groups Test Report',
|
||||
'',
|
||||
`Date: ${now.toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Username: ${username}`,
|
||||
`Low-Privilege Username: ${lowUsername}`,
|
||||
`Vendor Name: ${vendorName}`,
|
||||
`Line Group Name: ${lineGroupName}`,
|
||||
'',
|
||||
'| Result | Check | Detail |',
|
||||
'| --- | --- | --- |',
|
||||
...checks.map(reportLine),
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- Password and token values are intentionally omitted.',
|
||||
'- Vendor gateway and line group 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;
|
||||
Reference in New Issue
Block a user