fix web ui smoke and brand assets
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# API E2E Tests
|
||||
|
||||
API automation should prefer full `AppModule` E2E coverage for cross-module workflows, with `hookTimeout` kept at 60 seconds for slow module bootstrap.
|
||||
|
||||
Remote black-box API checks:
|
||||
|
||||
```powershell
|
||||
$env:LISGLOSIPS_AUTH_PASSWORD = '<password>'
|
||||
pnpm test:remote-auth
|
||||
pnpm test:remote-customers
|
||||
pnpm test:remote-gateways
|
||||
pnpm test:remote-vendors
|
||||
pnpm test:remote-calls
|
||||
pnpm test:remote-recordings
|
||||
pnpm test:remote-dashboard
|
||||
pnpm test:remote-perf-security
|
||||
```
|
||||
@@ -0,0 +1,435 @@
|
||||
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 lowRoleName = process.env.LISGLOSIPS_LOW_ROLE_NAME || '自动化低权限角色';
|
||||
const timeoutMs = Number(process.env.LISGLOSIPS_AUTH_TIMEOUT_MS || 30000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
if (!password) {
|
||||
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const cookieJar = new Map();
|
||||
|
||||
function storeCookies(headers) {
|
||||
const setCookie = headers['set-cookie'];
|
||||
const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
||||
for (const cookie of cookies) {
|
||||
const [pair] = cookie.split(';');
|
||||
const index = pair.indexOf('=');
|
||||
if (index <= 0) {
|
||||
continue;
|
||||
}
|
||||
const name = pair.slice(0, index);
|
||||
const value = pair.slice(index + 1);
|
||||
if (value) {
|
||||
cookieJar.set(name, value);
|
||||
} else {
|
||||
cookieJar.delete(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cookieHeader() {
|
||||
return [...cookieJar.entries()].map(([name, value]) => `${name}=${value}`).join('; ');
|
||||
}
|
||||
|
||||
function requestApi(path, options = {}) {
|
||||
return new Promise((resolveRequest) => {
|
||||
const startedAt = Date.now();
|
||||
const url = new URL(path, baseUrl);
|
||||
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
||||
const headers = {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'lisglosips-remote-auth-rbac/1.0',
|
||||
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
|
||||
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...(options.withCookies && cookieHeader() ? { Cookie: cookieHeader() } : {}),
|
||||
};
|
||||
|
||||
const req = request(
|
||||
url,
|
||||
{
|
||||
method: options.method || 'GET',
|
||||
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
|
||||
timeout: timeoutMs,
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (chunk) => chunks.push(chunk));
|
||||
res.on('end', () => {
|
||||
const responseBody = Buffer.concat(chunks).toString('utf8');
|
||||
storeCookies(res.headers);
|
||||
resolveRequest({
|
||||
path,
|
||||
method: options.method || 'GET',
|
||||
ok: true,
|
||||
statusCode: res.statusCode || 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
contentType: String(res.headers['content-type'] || ''),
|
||||
setCookie: Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : [],
|
||||
body: responseBody,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
|
||||
});
|
||||
req.on('error', (error) => {
|
||||
resolveRequest({
|
||||
path,
|
||||
method: options.method || 'GET',
|
||||
ok: false,
|
||||
statusCode: 0,
|
||||
durationMs: Date.now() - startedAt,
|
||||
contentType: '',
|
||||
setCookie: [],
|
||||
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('');
|
||||
}
|
||||
|
||||
function makeCheck(name, pass, detail) {
|
||||
return { name, pass, detail };
|
||||
}
|
||||
|
||||
function statusCheck(name, result, expectedStatus) {
|
||||
return makeCheck(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
|
||||
}
|
||||
|
||||
function statusInCheck(name, result, expectedStatuses) {
|
||||
return makeCheck(
|
||||
name,
|
||||
result.ok && expectedStatuses.includes(result.statusCode),
|
||||
result.error || `status=${result.statusCode}, expected=${expectedStatuses.join('/')}, duration=${result.durationMs}ms`
|
||||
);
|
||||
}
|
||||
|
||||
function redactedUser(user) {
|
||||
if (!user || typeof user !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
roles: Array.isArray(user.roles) ? user.roles : [],
|
||||
permissionCount: Array.isArray(user.permissions) ? user.permissions.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function getJson(path, accessToken) {
|
||||
const result = await requestApi(path, { accessToken });
|
||||
return { result, body: parseJson(result) };
|
||||
}
|
||||
|
||||
async function ensureLowPrivilegeRole(accessToken) {
|
||||
const permissionIds = ['dashboard.view'];
|
||||
const rolesBefore = await getJson('/api/v2/roles', accessToken);
|
||||
let role = Array.isArray(rolesBefore.body) ? rolesBefore.body.find((item) => item.name === lowRoleName) : null;
|
||||
|
||||
if (!role) {
|
||||
const created = await requestApi('/api/v2/roles', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
name: lowRoleName,
|
||||
description: 'Codex remote auth/RBAC test role',
|
||||
permissionIds,
|
||||
},
|
||||
});
|
||||
role = parseJson(created);
|
||||
return { role, action: 'created', result: created };
|
||||
}
|
||||
|
||||
const updated = await requestApi(`/api/v2/roles/${encodeURIComponent(role.id)}`, {
|
||||
method: 'PATCH',
|
||||
accessToken,
|
||||
body: {
|
||||
name: lowRoleName,
|
||||
description: 'Codex remote auth/RBAC test role',
|
||||
status: 'ENABLED',
|
||||
permissionIds,
|
||||
},
|
||||
});
|
||||
role = parseJson(updated);
|
||||
return { role, action: 'updated', result: updated };
|
||||
}
|
||||
|
||||
async function ensureLowPrivilegeUser(accessToken, roleId) {
|
||||
const usersBefore = await getJson('/api/v2/users', accessToken);
|
||||
let user = Array.isArray(usersBefore.body) ? usersBefore.body.find((item) => item.username === lowUsername) : null;
|
||||
|
||||
if (!user) {
|
||||
const created = await requestApi('/api/v2/users', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
username: lowUsername,
|
||||
displayName: 'Codex低权限测试用户',
|
||||
password: lowPassword,
|
||||
requirePasswordChange: false,
|
||||
roleIds: [roleId],
|
||||
},
|
||||
});
|
||||
user = parseJson(created);
|
||||
return { user, action: 'created', createResult: created, updateResult: null, resetResult: null };
|
||||
}
|
||||
|
||||
const updated = await requestApi(`/api/v2/users/${encodeURIComponent(user.id)}`, {
|
||||
method: 'PATCH',
|
||||
accessToken,
|
||||
body: {
|
||||
displayName: 'Codex低权限测试用户',
|
||||
status: 'ENABLED',
|
||||
roleIds: [roleId],
|
||||
},
|
||||
});
|
||||
const reset = await requestApi(`/api/v2/users/${encodeURIComponent(user.id)}/reset-password`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { password: lowPassword },
|
||||
});
|
||||
user = parseJson(reset);
|
||||
return { user, action: 'updated', createResult: null, updateResult: updated, resetResult: reset };
|
||||
}
|
||||
|
||||
async function loginWithCaptcha(loginUsername, loginPassword) {
|
||||
const captcha = await requestApi('/api/v2/auth/captcha');
|
||||
const captchaBody = parseJson(captcha);
|
||||
const captchaCode = decodeCaptcha(captchaBody?.imageDataUrl);
|
||||
const loginResult = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: loginUsername,
|
||||
password: loginPassword,
|
||||
captchaId: captchaBody?.captchaId,
|
||||
captchaCode,
|
||||
},
|
||||
});
|
||||
|
||||
return { captcha, captchaBody, captchaCode, loginResult, loginBody: parseJson(loginResult) };
|
||||
}
|
||||
|
||||
function reportLine(check) {
|
||||
return `| ${check.pass ? 'PASS' : 'FAIL'} | ${check.name} | ${String(check.detail).replace(/\|/g, '\\|')} |`;
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
|
||||
const publicCaptcha = await requestApi('/api/v2/auth/captcha');
|
||||
const publicCaptchaBody = parseJson(publicCaptcha);
|
||||
checks.push(statusCheck('captcha endpoint is public', publicCaptcha, 200));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'captcha returns id, SVG image, and expiry',
|
||||
typeof publicCaptchaBody?.captchaId === 'string' &&
|
||||
String(publicCaptchaBody?.imageDataUrl || '').startsWith('data:image/svg+xml;base64,') &&
|
||||
typeof publicCaptchaBody?.expiresAt === 'string',
|
||||
publicCaptchaBody ? `captchaId=${publicCaptchaBody.captchaId}, expiresAt=${publicCaptchaBody.expiresAt}` : 'body is not JSON'
|
||||
)
|
||||
);
|
||||
|
||||
const protectedWithoutToken = await requestApi('/api/v2/customers');
|
||||
checks.push(statusCheck('protected API rejects anonymous request', protectedWithoutToken, 401));
|
||||
|
||||
const invalidCaptchaLogin = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username, password, captchaId: publicCaptchaBody?.captchaId || 'missing', captchaCode: 'WRONG' },
|
||||
});
|
||||
const invalidCaptchaBody = parseJson(invalidCaptchaLogin);
|
||||
checks.push(statusCheck('login rejects invalid captcha', invalidCaptchaLogin, 401));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'invalid captcha returns AUTH_CAPTCHA_INVALID',
|
||||
invalidCaptchaBody?.code === 'AUTH_CAPTCHA_INVALID',
|
||||
`code=${invalidCaptchaBody?.code || 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const loginCaptcha = await requestApi('/api/v2/auth/captcha');
|
||||
const loginCaptchaBody = parseJson(loginCaptcha);
|
||||
const captchaCode = decodeCaptcha(loginCaptchaBody?.imageDataUrl);
|
||||
checks.push(makeCheck('captcha answer can be parsed from SVG', captchaCode.length >= 4, `length=${captchaCode.length}`));
|
||||
|
||||
const badPasswordCaptcha = await requestApi('/api/v2/auth/captcha');
|
||||
const badPasswordCaptchaBody = parseJson(badPasswordCaptcha);
|
||||
const badPasswordCaptchaCode = decodeCaptcha(badPasswordCaptchaBody?.imageDataUrl);
|
||||
const badPasswordLogin = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username,
|
||||
password: `${password}-wrong`,
|
||||
captchaId: badPasswordCaptchaBody?.captchaId,
|
||||
captchaCode: badPasswordCaptchaCode,
|
||||
},
|
||||
});
|
||||
const badPasswordBody = parseJson(badPasswordLogin);
|
||||
checks.push(statusCheck('login rejects invalid password with valid captcha', badPasswordLogin, 401));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'invalid credentials code is returned',
|
||||
badPasswordBody?.code === 'AUTH_INVALID_CREDENTIALS',
|
||||
`code=${badPasswordBody?.code || 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const login = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username,
|
||||
password,
|
||||
captchaId: loginCaptchaBody?.captchaId,
|
||||
captchaCode,
|
||||
},
|
||||
});
|
||||
const loginBody = parseJson(login);
|
||||
const loginCookie = login.setCookie.find((cookie) => cookie.startsWith('lisglosips_refresh='));
|
||||
checks.push(statusCheck('login succeeds with valid captcha and password', login, 200));
|
||||
checks.push(makeCheck('login returns access token', typeof loginBody?.accessToken === 'string' && loginBody.accessToken.length > 20, `tokenLength=${loginBody?.accessToken?.length || 0}`));
|
||||
checks.push(makeCheck('login returns user profile and permissions', Array.isArray(loginBody?.user?.permissions), JSON.stringify(redactedUser(loginBody?.user))));
|
||||
checks.push(makeCheck('login sets HttpOnly refresh cookie', Boolean(loginCookie && /HttpOnly/i.test(loginCookie)), loginCookie ? 'refresh cookie present' : 'refresh cookie missing'));
|
||||
|
||||
const lowRole = await ensureLowPrivilegeRole(loginBody?.accessToken);
|
||||
checks.push(statusCheck(`low-privilege role is ${lowRole.action}`, lowRole.result, lowRole.action === 'created' ? 201 : 200));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'low-privilege role only has dashboard.view',
|
||||
Array.isArray(lowRole.role?.permissionIds) && lowRole.role.permissionIds.length === 1 && lowRole.role.permissionIds[0] === 'dashboard.view',
|
||||
`roleId=${lowRole.role?.id || 'n/a'}, permissions=${Array.isArray(lowRole.role?.permissionIds) ? lowRole.role.permissionIds.join(',') : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const lowUser = await ensureLowPrivilegeUser(loginBody?.accessToken, lowRole.role?.id);
|
||||
const lowUserResult = lowUser.createResult || lowUser.resetResult || lowUser.updateResult;
|
||||
checks.push(statusInCheck(`low-privilege user is ${lowUser.action}`, lowUserResult, lowUser.action === 'created' ? [201] : [200, 201]));
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'low-privilege user is bound to low role',
|
||||
Array.isArray(lowUser.user?.roleIds) && lowUser.user.roleIds.includes(lowRole.role?.id),
|
||||
`userId=${lowUser.user?.id || 'n/a'}, roleIds=${Array.isArray(lowUser.user?.roleIds) ? lowUser.user.roleIds.join(',') : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const authorizedDashboard = await requestApi('/api/v2/dashboard/summary', { accessToken: loginBody?.accessToken });
|
||||
checks.push(statusCheck('bearer token can access protected dashboard summary', authorizedDashboard, 200));
|
||||
|
||||
const invalidToken = await requestApi('/api/v2/dashboard/summary', { accessToken: 'invalid.token.value' });
|
||||
checks.push(statusCheck('invalid bearer token is rejected', invalidToken, 401));
|
||||
|
||||
const refresh = await requestApi('/api/v2/auth/refresh', { method: 'POST', withCookies: true });
|
||||
const refreshBody = parseJson(refresh);
|
||||
const rotatedCookie = refresh.setCookie.find((cookie) => cookie.startsWith('lisglosips_refresh='));
|
||||
checks.push(statusCheck('refresh rotates session and returns new token', refresh, 200));
|
||||
checks.push(makeCheck('refresh returns access token', typeof refreshBody?.accessToken === 'string' && refreshBody.accessToken !== loginBody?.accessToken, `tokenChanged=${refreshBody?.accessToken !== loginBody?.accessToken}`));
|
||||
checks.push(makeCheck('refresh sets a rotated refresh cookie', Boolean(rotatedCookie && /HttpOnly/i.test(rotatedCookie)), rotatedCookie ? 'rotated cookie present' : 'rotated cookie missing'));
|
||||
|
||||
const authorizedAfterRefresh = await requestApi('/api/v2/dashboard/summary', { accessToken: refreshBody?.accessToken });
|
||||
checks.push(statusCheck('refreshed bearer token can access protected dashboard summary', authorizedAfterRefresh, 200));
|
||||
|
||||
const logout = await requestApi('/api/v2/auth/logout', { method: 'POST', withCookies: true });
|
||||
checks.push(statusCheck('logout revokes current refresh session', logout, 204));
|
||||
|
||||
const refreshAfterLogout = await requestApi('/api/v2/auth/refresh', { method: 'POST', withCookies: true });
|
||||
checks.push(statusCheck('refresh after logout is rejected', refreshAfterLogout, 401));
|
||||
|
||||
const lowLogin = await loginWithCaptcha(lowUsername, lowPassword);
|
||||
checks.push(makeCheck('low-privilege captcha answer can be parsed from SVG', lowLogin.captchaCode.length >= 4, `length=${lowLogin.captchaCode.length}`));
|
||||
checks.push(statusCheck('low-privilege user can login', lowLogin.loginResult, 200));
|
||||
checks.push(makeCheck('low-privilege login returns dashboard.view only', Array.isArray(lowLogin.loginBody?.user?.permissions) && lowLogin.loginBody.user.permissions.length === 1 && lowLogin.loginBody.user.permissions[0] === 'dashboard.view', JSON.stringify(redactedUser(lowLogin.loginBody?.user))));
|
||||
|
||||
const lowDashboard = await requestApi('/api/v2/dashboard/summary', { accessToken: lowLogin.loginBody?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user can access allowed dashboard summary', lowDashboard, 200));
|
||||
|
||||
const lowForbidden = await requestApi('/api/v2/users', {
|
||||
method: 'POST',
|
||||
accessToken: lowLogin.loginBody?.accessToken,
|
||||
body: {
|
||||
username: 'should.not.create',
|
||||
displayName: 'Should Not Create',
|
||||
password: 'ShouldNotCreate2026',
|
||||
roleIds: [lowRole.role?.id],
|
||||
},
|
||||
});
|
||||
checks.push(statusCheck('low-privilege user is forbidden from users.manage endpoint', lowForbidden, 403));
|
||||
|
||||
const permissionCount = Array.isArray(loginBody?.user?.permissions) ? loginBody.user.permissions.length : 0;
|
||||
checks.push(
|
||||
makeCheck(
|
||||
'admin account has non-empty permission set',
|
||||
permissionCount > 0,
|
||||
`permissionCount=${permissionCount}, roles=${Array.isArray(loginBody?.user?.roles) ? loginBody.user.roles.join(',') : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_AUTH_RBAC_${stamp}.md`);
|
||||
const failed = checks.filter((check) => !check.pass);
|
||||
|
||||
const report = [
|
||||
'# Remote Auth, Session, and Permission Test Report',
|
||||
'',
|
||||
`Date: ${now.toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Username: ${username}`,
|
||||
`Low-Privilege Username: ${lowUsername}`,
|
||||
'',
|
||||
'| Result | Check | Detail |',
|
||||
'| --- | --- | --- |',
|
||||
...checks.map(reportLine),
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- Password and token values are intentionally omitted.',
|
||||
'- This run uses the remote B service as a black-box API target.',
|
||||
'- The low-privilege role and user are created or updated through the admin API before RBAC assertions.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
await mkdir(reportDir, { recursive: true });
|
||||
await writeFile(reportPath, report, 'utf8');
|
||||
|
||||
for (const check of checks) {
|
||||
console.log(`${check.pass ? 'PASS' : 'FAIL'} ${check.name} - ${check.detail}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
|
||||
if (failed.length > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,451 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
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_CUSTOMERS_TIMEOUT_MS || 30000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
const customerName = process.env.LISGLOSIPS_CUSTOMER_TEST_NAME || '自动化8.2客户';
|
||||
const customerDomain = process.env.LISGLOSIPS_CUSTOMER_TEST_DOMAIN || 'codex-82.example.test';
|
||||
|
||||
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-customers-balance/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('timeout', () => {
|
||||
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 captchaCode = decodeCaptcha(captchaBody?.imageDataUrl);
|
||||
const result = await requestApi('/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: loginUsername,
|
||||
password: loginPassword,
|
||||
captchaId: captchaBody?.captchaId,
|
||||
captchaCode,
|
||||
},
|
||||
});
|
||||
return { captcha, captchaCode, result, body: parseJson(result) };
|
||||
}
|
||||
|
||||
function micros(value) {
|
||||
const raw = String(value);
|
||||
const negative = raw.startsWith('-');
|
||||
const normalized = negative ? raw.slice(1) : raw;
|
||||
const [integerPart, fractionPart = ''] = normalized.split('.');
|
||||
const amount = BigInt(integerPart || '0') * 1_000_000n + BigInt(fractionPart.padEnd(6, '0').slice(0, 6) || '0');
|
||||
return negative ? -amount : amount;
|
||||
}
|
||||
|
||||
function fixed(value) {
|
||||
const negative = value < 0n;
|
||||
const absolute = negative ? -value : value;
|
||||
const integerPart = absolute / 1_000_000n;
|
||||
const fractionPart = String(absolute % 1_000_000n).padStart(6, '0');
|
||||
return `${negative ? '-' : ''}${integerPart}.${fractionPart}`;
|
||||
}
|
||||
|
||||
function addDecimal(left, right) {
|
||||
return fixed(micros(left) + micros(right));
|
||||
}
|
||||
|
||||
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 findCustomer(accessToken) {
|
||||
const list = await requestApi('/api/v2/customers', { accessToken });
|
||||
const body = parseJson(list);
|
||||
const customer = Array.isArray(body) ? body.find((item) => item.name === customerName || item.domain === customerDomain) : null;
|
||||
return { list, customer };
|
||||
}
|
||||
|
||||
async function ensureCustomer(accessToken) {
|
||||
const existing = await findCustomer(accessToken);
|
||||
if (!existing.customer) {
|
||||
const created = await requestApi('/api/v2/customers', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
name: customerName,
|
||||
contactName: 'Codex测试联系人',
|
||||
phone: '13800138200',
|
||||
email: 'codex-82@example.test',
|
||||
domain: customerDomain,
|
||||
billingMode: 'PREPAID',
|
||||
creditLimit: '100.000000',
|
||||
minBalance: '5.000000',
|
||||
notes: 'Codex 8.2 automated customer',
|
||||
},
|
||||
});
|
||||
return { action: 'created', result: created, customer: parseJson(created) };
|
||||
}
|
||||
|
||||
const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.customer.id)}`, {
|
||||
method: 'PATCH',
|
||||
accessToken,
|
||||
body: {
|
||||
name: customerName,
|
||||
contactName: 'Codex测试联系人',
|
||||
phone: '13800138200',
|
||||
email: 'codex-82@example.test',
|
||||
domain: customerDomain,
|
||||
billingMode: 'PREPAID',
|
||||
creditLimit: '100.000000',
|
||||
minBalance: '5.000000',
|
||||
notes: 'Codex 8.2 automated customer',
|
||||
},
|
||||
});
|
||||
return { action: 'updated', result: updated, customer: parseJson(updated) };
|
||||
}
|
||||
|
||||
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 lowCustomersList = await requestApi('/api/v2/customers', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list customers', lowCustomersList, 403));
|
||||
|
||||
const customerSetup = await ensureCustomer(accessToken);
|
||||
checks.push(statusCheck(`test customer is ${customerSetup.action}`, customerSetup.result, customerSetup.action === 'created' ? 201 : 200));
|
||||
checks.push(check('test customer has expected credit/min balance', customerSetup.customer?.creditLimit === '100.000000' && customerSetup.customer?.minBalance === '5.000000', `creditLimit=${customerSetup.customer?.creditLimit}, minBalance=${customerSetup.customer?.minBalance}`));
|
||||
|
||||
const customerId = customerSetup.customer?.id;
|
||||
const customerGet = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}`, { accessToken });
|
||||
const customerBefore = parseJson(customerGet);
|
||||
checks.push(statusCheck('customer detail can be fetched', customerGet, 200));
|
||||
checks.push(check('available balance equals balance plus credit limit', customerBefore?.availableBalance === addDecimal(customerBefore?.balance || '0.000000', customerBefore?.creditLimit || '0.000000'), `balance=${customerBefore?.balance}, creditLimit=${customerBefore?.creditLimit}, available=${customerBefore?.availableBalance}`));
|
||||
|
||||
const disable = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/disable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('customer can be disabled', disable, 201));
|
||||
checks.push(check('disabled customer status is DISABLED', parseJson(disable)?.status === 'DISABLED', `status=${parseJson(disable)?.status}`));
|
||||
|
||||
const enable = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/enable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('customer can be enabled', enable, 201));
|
||||
checks.push(check('enabled customer status is ENABLED', parseJson(enable)?.status === 'ENABLED', `status=${parseJson(enable)?.status}`));
|
||||
|
||||
const positiveKey = `codex82:positive:${Date.now()}`;
|
||||
const positiveRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
amount: '10.000000',
|
||||
idempotencyKey: positiveKey,
|
||||
remark: 'Codex 8.2 positive recharge',
|
||||
},
|
||||
});
|
||||
const positiveBody = parseJson(positiveRecharge);
|
||||
checks.push(statusCheck('positive customer recharge succeeds', positiveRecharge, 201));
|
||||
checks.push(check('positive recharge balance delta is +10.000000', positiveBody?.afterBalance === addDecimal(positiveBody?.beforeBalance || '0.000000', '10.000000'), `before=${positiveBody?.beforeBalance}, after=${positiveBody?.afterBalance}`));
|
||||
|
||||
const duplicatePositive = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
amount: '10.000000',
|
||||
idempotencyKey: positiveKey,
|
||||
remark: 'Codex 8.2 positive recharge',
|
||||
},
|
||||
});
|
||||
const duplicatePositiveBody = parseJson(duplicatePositive);
|
||||
checks.push(statusCheck('same idempotency key with same body returns cached success', duplicatePositive, 201));
|
||||
checks.push(check('idempotent duplicate returns same recharge id', duplicatePositiveBody?.id === positiveBody?.id, `first=${positiveBody?.id}, duplicate=${duplicatePositiveBody?.id}`));
|
||||
|
||||
const conflictingIdempotency = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
amount: '11.000000',
|
||||
idempotencyKey: positiveKey,
|
||||
remark: 'Codex 8.2 idempotency conflict',
|
||||
},
|
||||
});
|
||||
checks.push(statusCheck('same idempotency key with different body is rejected', conflictingIdempotency, 409));
|
||||
|
||||
const negativeKey = `codex82:negative:${Date.now()}`;
|
||||
const negativeRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
amount: '-3.000000',
|
||||
idempotencyKey: negativeKey,
|
||||
remark: 'Codex 8.2 negative adjustment',
|
||||
},
|
||||
});
|
||||
const negativeBody = parseJson(negativeRecharge);
|
||||
checks.push(statusCheck('negative customer recharge deducts balance', negativeRecharge, 201));
|
||||
checks.push(check('negative recharge balance delta is -3.000000', negativeBody?.afterBalance === addDecimal(negativeBody?.beforeBalance || '0.000000', '-3.000000'), `before=${negativeBody?.beforeBalance}, after=${negativeBody?.afterBalance}, code=${negativeBody?.code || 'n/a'}`));
|
||||
|
||||
const rechargeList = await requestApi(`/api/v2/recharges?accountType=CUSTOMER&accountId=${encodeURIComponent(customerId)}&take=20`, { accessToken });
|
||||
const rechargeListBody = parseJson(rechargeList);
|
||||
checks.push(statusCheck('customer recharge list can be filtered by account', rechargeList, 200));
|
||||
checks.push(check('recharge list contains positive recharge record', Array.isArray(rechargeListBody?.items) && rechargeListBody.items.some((item) => item.id === positiveBody?.id), `total=${rechargeListBody?.total}`));
|
||||
|
||||
const lowRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken: lowLogin.body?.accessToken,
|
||||
body: {
|
||||
amount: '1.000000',
|
||||
idempotencyKey: `codex82:low:${Date.now()}`,
|
||||
remark: 'Should be forbidden',
|
||||
},
|
||||
});
|
||||
checks.push(statusCheck('low-privilege user cannot recharge customer', lowRecharge, 403));
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_CUSTOMERS_BALANCE_${stamp}.md`);
|
||||
const failed = checks.filter((item) => !item.pass);
|
||||
|
||||
const report = [
|
||||
'# Remote Customers, Recharge, and Balance Test Report',
|
||||
'',
|
||||
`Date: ${now.toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Username: ${username}`,
|
||||
`Low-Privilege Username: ${lowUsername}`,
|
||||
`Customer Name: ${customerName}`,
|
||||
`Customer Domain: ${customerDomain}`,
|
||||
'',
|
||||
'| Result | Check | Detail |',
|
||||
'| --- | --- | --- |',
|
||||
...checks.map(reportLine),
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- Password and token values are intentionally omitted.',
|
||||
'- Negative recharge is asserted as a required business rule: negative amount should deduct customer balance.',
|
||||
'',
|
||||
].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;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
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\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, '\\|')} |`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
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_PERF_SECURITY_TIMEOUT_MS || 30000);
|
||||
const concurrency = Number(process.env.LISGLOSIPS_PERF_SECURITY_CONCURRENCY || 12);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
const customerName = '自动化8.9安全客户';
|
||||
const customerDomain = 'codex89.example.test';
|
||||
|
||||
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,text/html;q=0.9,*/*;q=0.8',
|
||||
'User-Agent': 'lisglosips-remote-performance-security/1.0',
|
||||
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
|
||||
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...(options.authorization ? { Authorization: options.authorization } : {}),
|
||||
};
|
||||
|
||||
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'] || ''),
|
||||
headers: res.headers,
|
||||
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: '', headers: {}, 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 bodyLooksSafe(result) {
|
||||
return !/stack|trace|DATABASE_URL|JWT_SECRET|password|secret|\/etc\/|\/var\/|[A-Z]:\\/i.test(result.body || '');
|
||||
}
|
||||
|
||||
async function findCustomer(accessToken) {
|
||||
const list = await requestApi('/api/v2/customers', { accessToken });
|
||||
const body = parseJson(list);
|
||||
const customer = Array.isArray(body) ? body.find((item) => item.name === customerName || item.domain === customerDomain) : null;
|
||||
return { list, customer };
|
||||
}
|
||||
|
||||
async function ensureCustomer(accessToken) {
|
||||
const existing = await findCustomer(accessToken);
|
||||
if (!existing.customer) {
|
||||
const created = await requestApi('/api/v2/customers', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: {
|
||||
name: customerName,
|
||||
contactName: 'Codex测试联系人',
|
||||
phone: '13800138900',
|
||||
email: 'codex-89@example.test',
|
||||
domain: customerDomain,
|
||||
billingMode: 'PREPAID',
|
||||
creditLimit: '100.000000',
|
||||
minBalance: '5.000000',
|
||||
notes: 'Codex 8.9 replay/security test customer',
|
||||
},
|
||||
});
|
||||
return { action: 'created', result: created, customer: parseJson(created) };
|
||||
}
|
||||
|
||||
const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.customer.id)}`, {
|
||||
method: 'PATCH',
|
||||
accessToken,
|
||||
body: {
|
||||
name: customerName,
|
||||
contactName: 'Codex测试联系人',
|
||||
phone: '13800138900',
|
||||
email: 'codex-89@example.test',
|
||||
domain: customerDomain,
|
||||
billingMode: 'PREPAID',
|
||||
creditLimit: '100.000000',
|
||||
minBalance: '5.000000',
|
||||
notes: 'Codex 8.9 replay/security test customer',
|
||||
},
|
||||
});
|
||||
return { action: 'updated', result: updated, customer: parseJson(updated) };
|
||||
}
|
||||
|
||||
function percentile(values, ratio) {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * ratio))] ?? 0;
|
||||
}
|
||||
|
||||
const checks = [];
|
||||
|
||||
const root = await requestApi('/');
|
||||
checks.push(statusCheck('HTTPS root is reachable before light concurrency', root, 200));
|
||||
checks.push(check('HTTPS root contains app root', root.contentType.includes('text/html') && root.body.includes('<div id="root"></div>'), `contentType=${root.contentType}, bytes=${Buffer.byteLength(root.body, 'utf8')}`));
|
||||
|
||||
const readyBefore = await requestApi('/api/v2/health/ready');
|
||||
checks.push(statusCheck('ready health is ok before light concurrency', readyBefore, 200));
|
||||
checks.push(check('ready health reports database and redis ok before light concurrency', parseJson(readyBefore)?.checks?.database === 'ok' && parseJson(readyBefore)?.checks?.redis === 'ok', `body=${JSON.stringify(parseJson(readyBefore))}`));
|
||||
|
||||
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 burstStartedAt = Date.now();
|
||||
const burst = await Promise.all([
|
||||
...Array.from({ length: concurrency }, () => requestApi('/api/v2/health/ready')),
|
||||
...Array.from({ length: concurrency }, () => requestApi('/api/v2/auth/captcha')),
|
||||
]);
|
||||
const burstDurations = burst.map((item) => item.durationMs);
|
||||
const burstFailed = burst.filter((item) => !item.ok || item.statusCode !== 200);
|
||||
checks.push(check('PERF light API burst returns only 200 responses', burstFailed.length === 0, `requests=${burst.length}, failed=${burstFailed.length}, wallMs=${Date.now() - burstStartedAt}`));
|
||||
checks.push(check('PERF light API burst p95 stays under 10s', percentile(burstDurations, 0.95) < 10000, `p95=${percentile(burstDurations, 0.95)}ms, max=${Math.max(...burstDurations)}ms`));
|
||||
|
||||
const readyAfter = await requestApi('/api/v2/health/ready');
|
||||
checks.push(statusCheck('ready health recovers after light concurrency', readyAfter, 200));
|
||||
checks.push(check('ready health reports database and redis ok after light concurrency', parseJson(readyAfter)?.checks?.database === 'ok' && parseJson(readyAfter)?.checks?.redis === 'ok', `body=${JSON.stringify(parseJson(readyAfter))}`));
|
||||
|
||||
const forgedToken = await requestApi('/api/v2/dashboard/summary', { authorization: 'Bearer not.a.valid.jwt' });
|
||||
checks.push(statusCheck('SEC forged bearer token is rejected', forgedToken, 401));
|
||||
checks.push(check('SEC forged token error does not leak internals', bodyLooksSafe(forgedToken), `body=${forgedToken.body.slice(0, 160)}`));
|
||||
|
||||
const unauthWrite = await requestApi('/api/v2/customers', {
|
||||
method: 'POST',
|
||||
body: { name: 'unauth should not create' },
|
||||
});
|
||||
checks.push(statusCheck('SEC unauthenticated write is rejected', unauthWrite, 401));
|
||||
checks.push(check('SEC unauthenticated write error does not leak internals', bodyLooksSafe(unauthWrite), `body=${unauthWrite.body.slice(0, 160)}`));
|
||||
|
||||
const lowWrite = await requestApi('/api/v2/customers', {
|
||||
method: 'POST',
|
||||
accessToken: lowLogin.body?.accessToken,
|
||||
body: {
|
||||
name: '低权限不应创建客户',
|
||||
contactName: 'Forbidden',
|
||||
phone: '13800138999',
|
||||
email: 'forbidden@example.test',
|
||||
domain: 'forbidden.example.test',
|
||||
billingMode: 'PREPAID',
|
||||
},
|
||||
});
|
||||
checks.push(statusCheck('SEC low-privilege user cannot create customer', lowWrite, 403));
|
||||
checks.push(check('SEC low-privilege write error does not leak internals', bodyLooksSafe(lowWrite), `body=${lowWrite.body.slice(0, 160)}`));
|
||||
|
||||
const customerSetup = await ensureCustomer(accessToken);
|
||||
checks.push(statusCheck(`SEC replay test customer is ${customerSetup.action}`, customerSetup.result, customerSetup.action === 'created' ? 201 : 200));
|
||||
const customerId = customerSetup.customer?.id;
|
||||
|
||||
const replayKey = `codex89:replay:${Date.now()}`;
|
||||
const firstRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { amount: '1.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay probe' },
|
||||
});
|
||||
const firstRechargeBody = parseJson(firstRecharge);
|
||||
checks.push(statusCheck('SEC first idempotent recharge succeeds', firstRecharge, 201));
|
||||
|
||||
const replayRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { amount: '1.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay probe' },
|
||||
});
|
||||
const replayRechargeBody = parseJson(replayRecharge);
|
||||
checks.push(statusCheck('SEC exact idempotent replay returns success', replayRecharge, 201));
|
||||
checks.push(check('SEC exact idempotent replay returns same recharge id', replayRechargeBody?.id === firstRechargeBody?.id, `first=${firstRechargeBody?.id}, replay=${replayRechargeBody?.id}`));
|
||||
|
||||
const conflictingReplay = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { amount: '2.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay conflict' },
|
||||
});
|
||||
checks.push(statusCheck('SEC conflicting idempotency replay is rejected', conflictingReplay, 409));
|
||||
checks.push(check('SEC conflicting replay error does not leak original body', !conflictingReplay.body.includes('Codex 8.9 replay probe') && bodyLooksSafe(conflictingReplay), `body=${conflictingReplay.body.slice(0, 160)}`));
|
||||
|
||||
const lowRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
|
||||
method: 'POST',
|
||||
accessToken: lowLogin.body?.accessToken,
|
||||
body: { amount: '1.000000', idempotencyKey: `codex89:low:${Date.now()}`, remark: 'Forbidden replay probe' },
|
||||
});
|
||||
checks.push(statusCheck('SEC low-privilege user cannot replay/write recharge', lowRecharge, 403));
|
||||
|
||||
const missingRecording = await requestApi('/api/v2/recordings/not-a-real-recording/play', { accessToken });
|
||||
checks.push(statusCheck('SEC missing recording playback returns 404', missingRecording, 404));
|
||||
checks.push(check('SEC missing recording playback error does not expose paths', bodyLooksSafe(missingRecording), `body=${missingRecording.body.slice(0, 160)}`));
|
||||
|
||||
const traversalRecording = await requestApi('/api/v2/recordings/%2E%2E%2F%2E%2E%2Fetc%2Fpasswd/play', { accessToken });
|
||||
checks.push(check('SEC encoded traversal recording id is rejected safely', [400, 404].includes(traversalRecording.statusCode), `status=${traversalRecording.statusCode}, body=${traversalRecording.body.slice(0, 120)}`));
|
||||
checks.push(check('SEC traversal playback error does not expose filesystem paths', bodyLooksSafe(traversalRecording), `body=${traversalRecording.body.slice(0, 160)}`));
|
||||
|
||||
const stamp = new Date().toISOString().replaceAll(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_PERFORMANCE_SECURITY_${stamp}.md`);
|
||||
const notes = [
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- This run intentionally avoids disruptive fault injection: no Worker, MySQL, Redis, OpenSIPS, or SIP traffic was stopped or modified.',
|
||||
'- PERF-001/PERF-002 SIP call concurrency, SEC-001 illegal-source SIP probe, and SEC-002 CPS probe still require A/T-side SIP tooling and CDR/recording verification.',
|
||||
'- FAIL-001 through FAIL-004 require an explicit maintenance window, rollback point, and service-stop approval before execution.',
|
||||
'- The executed subset covers HTTPS/API light concurrency, service recovery after burst, forged/unauthenticated/low-privilege access, idempotency replay, and recording path-safety black-box checks.',
|
||||
];
|
||||
const report = [
|
||||
'# Remote Performance, Fault, and Security Test Report',
|
||||
'',
|
||||
`Date: ${new Date().toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Concurrency: ${concurrency} ready + ${concurrency} captcha requests`,
|
||||
'',
|
||||
'| 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;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
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_RECORDINGS_TIMEOUT_MS || 30000);
|
||||
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
|
||||
|
||||
const ruleName = '自动化8.6质检抽样规则';
|
||||
|
||||
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: options.accept || 'application/json',
|
||||
'User-Agent': 'lisglosips-remote-recordings-quality/1.0',
|
||||
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
|
||||
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
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'] || ''),
|
||||
headers: res.headers,
|
||||
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: '', headers: {}, 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 ensureQualityRule(accessToken) {
|
||||
const list = await requestApi('/api/v2/quality/rules', { accessToken });
|
||||
const items = parseJson(list);
|
||||
const existing = Array.isArray(items) ? items.find((item) => item.name === ruleName) : null;
|
||||
const body = { name: ruleName, customerId: null, lineGroupId: null, ratio: '100.00', status: 'ENABLED' };
|
||||
if (existing) {
|
||||
const updated = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
|
||||
return { action: 'updated', result: updated, rule: parseJson(updated) };
|
||||
}
|
||||
const created = await requestApi('/api/v2/quality/rules', { method: 'POST', accessToken, body });
|
||||
return { action: 'created', result: created, rule: 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 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 lowRecordings = await requestApi('/api/v2/recordings?limit=1', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list recordings', lowRecordings, 403));
|
||||
const lowRules = await requestApi('/api/v2/quality/rules', { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot list quality rules', lowRules, 403));
|
||||
|
||||
const invalidStatus = await requestApi('/api/v2/recordings?status=BAD', { accessToken });
|
||||
checks.push(statusCheck('invalid recording status is rejected', invalidStatus, 400));
|
||||
checks.push(check('invalid recording status returns RECORDING_STATUS_INVALID', parseJson(invalidStatus)?.code === 'RECORDING_STATUS_INVALID', `code=${parseJson(invalidStatus)?.code || 'n/a'}`));
|
||||
|
||||
const invalidLimit = await requestApi('/api/v2/recordings?limit=0', { accessToken });
|
||||
checks.push(statusCheck('invalid recording list limit is rejected', invalidLimit, 400));
|
||||
checks.push(check('invalid recording limit returns INTEGER_INVALID', parseJson(invalidLimit)?.code === 'INTEGER_INVALID', `code=${parseJson(invalidLimit)?.code || 'n/a'}`));
|
||||
|
||||
const readyList = await requestApi('/api/v2/recordings?status=READY&limit=20', { accessToken });
|
||||
const readyListBody = parseJson(readyList);
|
||||
checks.push(statusCheck('READY recordings can be listed', readyList, 200));
|
||||
checks.push(check('recording list shape is valid', Array.isArray(readyListBody), `count=${Array.isArray(readyListBody) ? readyListBody.length : 'n/a'}`));
|
||||
|
||||
const readyRecording = Array.isArray(readyListBody) ? readyListBody[0] : null;
|
||||
if (readyRecording) {
|
||||
const detail = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}`, { accessToken });
|
||||
const detailBody = parseJson(detail);
|
||||
checks.push(statusCheck('recording detail can be fetched', detail, 200));
|
||||
checks.push(check('recording detail matches list item', detailBody?.id === readyRecording.id && Array.isArray(detailBody?.reviews), `id=${detailBody?.id}, reviews=${detailBody?.reviews?.length}`));
|
||||
|
||||
const playback = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/play`, { accessToken, accept: '*/*' });
|
||||
checks.push(statusInCheck('READY recording playback returns X-Accel response or served media', playback, [200, 206]));
|
||||
checks.push(
|
||||
check(
|
||||
'playback response avoids real filesystem path exposure',
|
||||
!JSON.stringify(playback.headers).match(/[A-Z]:\\|\/var\/|\/home\/|\/etc\//i),
|
||||
`xAccel=${playback.headers['x-accel-redirect'] || 'n/a'}, contentType=${playback.headers['content-type'] || playback.contentType}`
|
||||
)
|
||||
);
|
||||
|
||||
const lowPlayback = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/play`, { accessToken: lowLogin.body?.accessToken });
|
||||
checks.push(statusCheck('low-privilege user cannot play recording', lowPlayback, 403));
|
||||
|
||||
const invalidScore = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
|
||||
method: 'PUT',
|
||||
accessToken,
|
||||
body: { score: 88.5, result: 'ISSUE', issueTags: ['noise'], notes: 'Codex 8.6 invalid decimal score' },
|
||||
});
|
||||
checks.push(statusCheck('decimal review score is rejected by current API', invalidScore, 400));
|
||||
checks.push(check('decimal review score returns INTEGER_INVALID', parseJson(invalidScore)?.code === 'INTEGER_INVALID', `code=${parseJson(invalidScore)?.code || 'n/a'}`));
|
||||
|
||||
const invalidResult = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
|
||||
method: 'PUT',
|
||||
accessToken,
|
||||
body: { score: 88, result: 'BAD', issueTags: ['noise'], notes: 'Codex 8.6 invalid result' },
|
||||
});
|
||||
checks.push(statusCheck('invalid review result is rejected', invalidResult, 400));
|
||||
checks.push(check('invalid review result returns QUALITY_REVIEW_RESULT_INVALID', parseJson(invalidResult)?.code === 'QUALITY_REVIEW_RESULT_INVALID', `code=${parseJson(invalidResult)?.code || 'n/a'}`));
|
||||
|
||||
const lowReview = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
|
||||
method: 'PUT',
|
||||
accessToken: lowLogin.body?.accessToken,
|
||||
body: { score: 88, result: 'PASS', issueTags: [], notes: 'Should be forbidden' },
|
||||
});
|
||||
checks.push(statusCheck('low-privilege user cannot save review', lowReview, 403));
|
||||
|
||||
const review = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
|
||||
method: 'PUT',
|
||||
accessToken,
|
||||
body: { score: 88, result: 'ISSUE', issueTags: ['noise', 'script'], notes: 'Codex 8.6 review' },
|
||||
});
|
||||
const reviewBody = parseJson(review);
|
||||
checks.push(statusCheck('quality review can be saved', review, 200));
|
||||
checks.push(check('quality review contains expected score/result/tags', reviewBody?.score === 88 && reviewBody?.result === 'ISSUE' && Array.isArray(reviewBody?.issueTags), `score=${reviewBody?.score}, result=${reviewBody?.result}, tags=${JSON.stringify(reviewBody?.issueTags)}`));
|
||||
|
||||
const reviewedList = await requestApi('/api/v2/recordings?reviewStatus=REVIEWED&limit=20', { accessToken });
|
||||
const reviewedListBody = parseJson(reviewedList);
|
||||
checks.push(statusCheck('reviewed recording list can be filtered', reviewedList, 200));
|
||||
checks.push(check('reviewed list contains reviewed recording', Array.isArray(reviewedListBody) && reviewedListBody.some((item) => item.id === readyRecording.id), `count=${Array.isArray(reviewedListBody) ? reviewedListBody.length : 'n/a'}`));
|
||||
} else {
|
||||
checks.push(check('recording detail/play/review checks skipped because no READY recording exists', true, 'No READY recordings returned by remote service.'));
|
||||
}
|
||||
|
||||
const missingPlayback = await requestApi('/api/v2/recordings/not-a-real-recording/play', { accessToken });
|
||||
checks.push(statusCheck('missing recording playback returns 404', missingPlayback, 404));
|
||||
checks.push(check('missing recording playback returns RECORDING_NOT_READY', parseJson(missingPlayback)?.code === 'RECORDING_NOT_READY', `code=${parseJson(missingPlayback)?.code || 'n/a'}`));
|
||||
|
||||
const invalidRatio = await requestApi('/api/v2/quality/rules', {
|
||||
method: 'POST',
|
||||
accessToken,
|
||||
body: { name: '自动化8.6非法比例', ratio: '100.01', status: 'ENABLED' },
|
||||
});
|
||||
checks.push(statusCheck('invalid quality sampling ratio is rejected', invalidRatio, 400));
|
||||
checks.push(check('invalid ratio returns QUALITY_RATIO_INVALID', parseJson(invalidRatio)?.code === 'QUALITY_RATIO_INVALID', `code=${parseJson(invalidRatio)?.code || 'n/a'}`));
|
||||
|
||||
const rule = await ensureQualityRule(accessToken);
|
||||
checks.push(statusCheck(`quality sampling rule is ${rule.action}`, rule.result, rule.action === 'created' ? 201 : 200));
|
||||
checks.push(check('quality sampling rule has 100 percent ratio', rule.rule?.ratio === '100.00' && rule.rule?.status === 'ENABLED', `ruleId=${rule.rule?.id}, ratio=${rule.rule?.ratio}, status=${rule.rule?.status}`));
|
||||
|
||||
const disableRule = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(rule.rule?.id)}/disable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('quality sampling rule can be disabled', disableRule, 201));
|
||||
checks.push(check('disabled quality rule status is DISABLED', parseJson(disableRule)?.status === 'DISABLED', `status=${parseJson(disableRule)?.status}`));
|
||||
|
||||
const enableRule = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(rule.rule?.id)}/enable`, { method: 'POST', accessToken });
|
||||
checks.push(statusCheck('quality sampling rule can be enabled', enableRule, 201));
|
||||
checks.push(check('enabled quality rule status is ENABLED', parseJson(enableRule)?.status === 'ENABLED', `status=${parseJson(enableRule)?.status}`));
|
||||
|
||||
const listAfterRule = await requestApi('/api/v2/recordings?status=READY&limit=5', { accessToken });
|
||||
const listAfterRuleBody = parseJson(listAfterRule);
|
||||
checks.push(statusCheck('recording list includes stable sampling payload after rule change', listAfterRule, 200));
|
||||
checks.push(
|
||||
check(
|
||||
'sampling payload shape is present',
|
||||
Array.isArray(listAfterRuleBody) && listAfterRuleBody.every((item) => item.sampling && typeof item.sampling.selected === 'boolean' && Array.isArray(item.sampling.matches)),
|
||||
`count=${Array.isArray(listAfterRuleBody) ? listAfterRuleBody.length : 'n/a'}`
|
||||
)
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
|
||||
const reportPath = resolve(reportDir, `REMOTE_RECORDINGS_QUALITY_${stamp}.md`);
|
||||
const failed = checks.filter((item) => !item.pass);
|
||||
const report = [
|
||||
'# Remote Recordings, Playback, and Quality Test Report',
|
||||
'',
|
||||
`Date: ${now.toISOString()}`,
|
||||
`Base URL: ${baseUrl}`,
|
||||
`Username: ${username}`,
|
||||
`Low-Privilege Username: ${lowUsername}`,
|
||||
'',
|
||||
'| Result | Check | Detail |',
|
||||
'| --- | --- | --- |',
|
||||
...checks.map(reportLine),
|
||||
'',
|
||||
'## Notes',
|
||||
'',
|
||||
'- Password and token values are intentionally omitted.',
|
||||
'- Recording Worker file movement, checksum mismatch retention, source cleanup, and browser Range playback need A/B filesystem or browser-side verification.',
|
||||
'- Current API accepts integer review scores only; decimal score examples from the plan are asserted as rejected by this deployed service.',
|
||||
'',
|
||||
].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;
|
||||
@@ -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