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