fix web ui smoke and brand assets

This commit is contained in:
hectorzhao
2026-06-30 11:13:30 +08:00
parent 0dfb2988b2
commit 9920575bba
103 changed files with 6650 additions and 135 deletions
+30
View File
@@ -0,0 +1,30 @@
# Automated Test Workspace
This directory holds executable test assets derived from `docs/TEST_PLAN_AND_CASES.md`.
- `api/`: API and full `AppModule` E2E suites.
- `web/`: Browser automation suites.
- `smoke/`: Environment smoke checks and optional call-flow probes.
- `fixtures/`: Shared static fixtures used by tests.
- `reports/`: Generated test result files.
Current runnable entry points:
- `pnpm test:baseline`
- `pnpm test:api`
- `pnpm test:smoke`
- `pnpm test:remote-smoke`
- `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`
- `pnpm test:remote-web-ui`
- `pnpm db:seed:test`
`pnpm lint` now includes `apps/web/src/**`, so undefined frontend identifiers such as missing page normalizers fail before build.
Use `pnpm test:remote-web-ui` after Web releases. It logs in with `LISGLOSIPS_AUTH_USERNAME` / `LISGLOSIPS_AUTH_PASSWORD`, clicks every non-pending core menu, and fails on blank pages, `API 数据不可用`, `pageerror`, or console errors.
+17
View File
@@ -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
```
+435
View File
@@ -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;
}
+261
View File
@@ -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;
}
+348
View File
@@ -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;
}
+359
View File
@@ -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;
}
+314
View File
@@ -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;
}
+282
View File
@@ -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;
+343
View File
@@ -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;
+3
View File
@@ -0,0 +1,3 @@
# Fixtures
Shared fixture payloads for automated tests.
@@ -0,0 +1,61 @@
# Automation Step 1 Foundation Report
Date: 2026-06-29
## Scope
- Created the shared automated test workspace under `tests/`.
- Added root test script entry points.
- Added an idempotent fixed test data seed script at `prisma/seed-test.ts`.
## Changed Files
- `package.json`
- `prisma/seed-test.ts`
- `tests/README.md`
- `tests/api/README.md`
- `tests/web/README.md`
- `tests/smoke/README.md`
- `tests/fixtures/README.md`
- `tests/reports/README.md`
## Script Entry Points
- `pnpm test:baseline`
- `pnpm test:api`
- `pnpm test:all-local`
- `pnpm db:seed:test`
## Verification
| Check | Result | Notes |
| --- | --- | --- |
| `package.json` parse | PASS | JSON parsed successfully. |
| `tsc --noEmit prisma/seed-test.ts` | PASS | Prisma unique keys and TypeScript types compile. |
| `eslint prisma/seed-test.ts --max-warnings=0` | PASS | No lint violations. |
| `pnpm db:seed:test` | BLOCKED | Script starts correctly but local MySQL is not reachable at `127.0.0.1:3306`. |
## Seed Data Coverage
- Test users and roles for admin, viewer, finance, quality, customer gateway, vendor gateway, active call, and audit flows.
- Customer fixtures for normal balance and low balance scenarios.
- Business prefixes `671` and `672`.
- Vendor, primary/backup vendor gateways, codecs, prefix rewrite, forbidden period, caller rewrite pool.
- Landing line group with weighted primary/backup gateway items.
- Customer IP and SIP gateways, gateway IP, business prefix bindings, caller prefix, and policy.
- Number library seed for city, mobile segment, area code, and carrier prefix rule.
- Rated CDR, recording, sampling rule, and quality review samples.
## Current Blocker
Local database service is not running or not reachable:
```text
Can't reach database server at `127.0.0.1:3306`
```
After MySQL is available, rerun:
```powershell
corepack pnpm@10.33.0 db:seed:test
```
+3
View File
@@ -0,0 +1,3 @@
# Reports
Generated reports and run summaries can be saved here. Keep committed reports small and intentional.
@@ -0,0 +1,35 @@
# Remote Auth, Session, and Permission Test Report
Date: 2026-06-29T05:05:54.328Z
Base URL: https://100.90.90.91
Username: admin
| Result | Check | Detail |
| --- | --- | --- |
| PASS | captcha endpoint is public | status=200, duration=1787ms |
| PASS | captcha returns id, SVG image, and expiry | captchaId=9de040cf-75c5-440f-a5cc-4bbf16d052c3, expiresAt=2026-06-29T05:10:43.316Z |
| PASS | protected API rejects anonymous request | status=401, duration=1025ms |
| PASS | login rejects invalid captcha | status=401, duration=1152ms |
| PASS | invalid captcha returns AUTH_CAPTCHA_INVALID | code=AUTH_CAPTCHA_INVALID |
| PASS | captcha answer can be parsed from SVG | length=5 |
| PASS | login rejects invalid password with valid captcha | status=401, duration=489ms |
| PASS | invalid credentials code is returned | code=AUTH_INVALID_CREDENTIALS |
| PASS | login succeeds with valid captcha and password | status=200, duration=456ms |
| PASS | login returns access token | tokenLength=296 |
| PASS | login returns user profile and permissions | {"id":"usr_admin","username":"admin","displayName":"系统管理员","roles":["超级管理员"],"permissionCount":24} |
| PASS | login sets HttpOnly refresh cookie | refresh cookie present |
| PASS | bearer token can access protected dashboard summary | status=200, duration=402ms |
| PASS | invalid bearer token is rejected | status=401, duration=790ms |
| PASS | refresh rotates session and returns new token | status=200, duration=392ms |
| PASS | refresh returns access token | tokenChanged=true |
| PASS | refresh sets a rotated refresh cookie | rotated cookie present |
| PASS | refreshed bearer token can access protected dashboard summary | status=200, duration=411ms |
| PASS | logout revokes current refresh session | status=204, duration=399ms |
| PASS | refresh after logout is rejected | status=401, duration=376ms |
| PASS | admin account has non-empty permission set | permissionCount=24, roles=超级管理员 |
## Notes
- Password and token values are intentionally omitted.
- This run uses the remote B service as a black-box API target.
- Permission-denied 403 checks require a low-privilege account and are not asserted by this admin-only run.
@@ -0,0 +1,45 @@
# Remote Auth, Session, and Permission Test Report
Date: 2026-06-29T05:09:44.031Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | captcha endpoint is public | status=200, duration=2229ms |
| PASS | captcha returns id, SVG image, and expiry | captchaId=e003b59f-ddd2-4732-a0eb-8e7ff9e2a30e, expiresAt=2026-06-29T05:14:28.340Z |
| PASS | protected API rejects anonymous request | status=401, duration=664ms |
| PASS | login rejects invalid captcha | status=401, duration=375ms |
| PASS | invalid captcha returns AUTH_CAPTCHA_INVALID | code=AUTH_CAPTCHA_INVALID |
| PASS | captcha answer can be parsed from SVG | length=5 |
| PASS | login rejects invalid password with valid captcha | status=401, duration=613ms |
| PASS | invalid credentials code is returned | code=AUTH_INVALID_CREDENTIALS |
| PASS | login succeeds with valid captcha and password | status=200, duration=581ms |
| PASS | login returns access token | tokenLength=296 |
| PASS | login returns user profile and permissions | {"id":"usr_admin","username":"admin","displayName":"系统管理员","roles":["超级管理员"],"permissionCount":24} |
| PASS | login sets HttpOnly refresh cookie | refresh cookie present |
| PASS | low-privilege role is created | status=201, duration=555ms |
| PASS | low-privilege role only has dashboard.view | roleId=rol_1f4592370a1941cf860ff1afdc92, permissions=dashboard.view |
| PASS | low-privilege user is created | status=201, duration=449ms |
| PASS | low-privilege user is bound to low role | userId=usr_102e3dcda767449f9f288ec09e8b, roleIds=rol_1f4592370a1941cf860ff1afdc92 |
| PASS | bearer token can access protected dashboard summary | status=200, duration=387ms |
| PASS | invalid bearer token is rejected | status=401, duration=378ms |
| PASS | refresh rotates session and returns new token | status=200, duration=391ms |
| PASS | refresh returns access token | tokenChanged=true |
| PASS | refresh sets a rotated refresh cookie | rotated cookie present |
| PASS | refreshed bearer token can access protected dashboard summary | status=200, duration=383ms |
| PASS | logout revokes current refresh session | status=204, duration=384ms |
| PASS | refresh after logout is rejected | status=401, duration=825ms |
| PASS | low-privilege captcha answer can be parsed from SVG | length=5 |
| PASS | low-privilege user can login | status=200, duration=636ms |
| PASS | low-privilege login returns dashboard.view only | {"id":"usr_102e3dcda767449f9f288ec09e8b","username":"codex.low","displayName":"Codex低权限测试用户","roles":["自动化低权限角色"],"permissionCount":1} |
| PASS | low-privilege user can access allowed dashboard summary | status=200, duration=697ms |
| PASS | low-privilege user is forbidden from users.manage endpoint | status=403, duration=1132ms |
| PASS | admin account has non-empty permission set | permissionCount=24, roles=超级管理员 |
## 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.
@@ -0,0 +1,45 @@
# Remote Auth, Session, and Permission Test Report
Date: 2026-06-29T05:10:08.459Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | captcha endpoint is public | status=200, duration=2754ms |
| PASS | captcha returns id, SVG image, and expiry | captchaId=ffef58d0-1acf-43f7-adf2-6a97bf64960d, expiresAt=2026-06-29T05:14:51.988Z |
| PASS | protected API rejects anonymous request | status=401, duration=387ms |
| PASS | login rejects invalid captcha | status=401, duration=857ms |
| PASS | invalid captcha returns AUTH_CAPTCHA_INVALID | code=AUTH_CAPTCHA_INVALID |
| PASS | captcha answer can be parsed from SVG | length=5 |
| PASS | login rejects invalid password with valid captcha | status=401, duration=441ms |
| PASS | invalid credentials code is returned | code=AUTH_INVALID_CREDENTIALS |
| PASS | login succeeds with valid captcha and password | status=200, duration=455ms |
| PASS | login returns access token | tokenLength=296 |
| PASS | login returns user profile and permissions | {"id":"usr_admin","username":"admin","displayName":"系统管理员","roles":["超级管理员"],"permissionCount":24} |
| PASS | login sets HttpOnly refresh cookie | refresh cookie present |
| PASS | low-privilege role is updated | status=200, duration=400ms |
| PASS | low-privilege role only has dashboard.view | roleId=rol_1f4592370a1941cf860ff1afdc92, permissions=dashboard.view |
| FAIL | low-privilege user is updated | status=201, duration=583ms |
| PASS | low-privilege user is bound to low role | userId=usr_102e3dcda767449f9f288ec09e8b, roleIds=rol_1f4592370a1941cf860ff1afdc92 |
| PASS | bearer token can access protected dashboard summary | status=200, duration=430ms |
| PASS | invalid bearer token is rejected | status=401, duration=379ms |
| PASS | refresh rotates session and returns new token | status=200, duration=412ms |
| PASS | refresh returns access token | tokenChanged=true |
| PASS | refresh sets a rotated refresh cookie | rotated cookie present |
| PASS | refreshed bearer token can access protected dashboard summary | status=200, duration=979ms |
| PASS | logout revokes current refresh session | status=204, duration=531ms |
| PASS | refresh after logout is rejected | status=401, duration=373ms |
| PASS | low-privilege captcha answer can be parsed from SVG | length=5 |
| PASS | low-privilege user can login | status=200, duration=709ms |
| PASS | low-privilege login returns dashboard.view only | {"id":"usr_102e3dcda767449f9f288ec09e8b","username":"codex.low","displayName":"Codex低权限测试用户","roles":["自动化低权限角色"],"permissionCount":1} |
| PASS | low-privilege user can access allowed dashboard summary | status=200, duration=572ms |
| PASS | low-privilege user is forbidden from users.manage endpoint | status=403, duration=383ms |
| PASS | admin account has non-empty permission set | permissionCount=24, roles=超级管理员 |
## 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.
@@ -0,0 +1,45 @@
# Remote Auth, Session, and Permission Test Report
Date: 2026-06-29T05:11:04.930Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | captcha endpoint is public | status=200, duration=2542ms |
| PASS | captcha returns id, SVG image, and expiry | captchaId=3bae4958-534c-4c78-a720-24f0324c4afc, expiresAt=2026-06-29T05:15:46.682Z |
| PASS | protected API rejects anonymous request | status=401, duration=390ms |
| PASS | login rejects invalid captcha | status=401, duration=856ms |
| PASS | invalid captcha returns AUTH_CAPTCHA_INVALID | code=AUTH_CAPTCHA_INVALID |
| PASS | captcha answer can be parsed from SVG | length=5 |
| PASS | login rejects invalid password with valid captcha | status=401, duration=1026ms |
| PASS | invalid credentials code is returned | code=AUTH_INVALID_CREDENTIALS |
| PASS | login succeeds with valid captcha and password | status=200, duration=452ms |
| PASS | login returns access token | tokenLength=296 |
| PASS | login returns user profile and permissions | {"id":"usr_admin","username":"admin","displayName":"系统管理员","roles":["超级管理员"],"permissionCount":24} |
| PASS | login sets HttpOnly refresh cookie | refresh cookie present |
| PASS | low-privilege role is updated | status=200, duration=1123ms |
| PASS | low-privilege role only has dashboard.view | roleId=rol_1f4592370a1941cf860ff1afdc92, permissions=dashboard.view |
| PASS | low-privilege user is updated | status=201, expected=200/201, duration=705ms |
| PASS | low-privilege user is bound to low role | userId=usr_102e3dcda767449f9f288ec09e8b, roleIds=rol_1f4592370a1941cf860ff1afdc92 |
| PASS | bearer token can access protected dashboard summary | status=200, duration=393ms |
| PASS | invalid bearer token is rejected | status=401, duration=392ms |
| PASS | refresh rotates session and returns new token | status=200, duration=392ms |
| PASS | refresh returns access token | tokenChanged=true |
| PASS | refresh sets a rotated refresh cookie | rotated cookie present |
| PASS | refreshed bearer token can access protected dashboard summary | status=200, duration=387ms |
| PASS | logout revokes current refresh session | status=204, duration=386ms |
| PASS | refresh after logout is rejected | status=401, duration=374ms |
| PASS | low-privilege captcha answer can be parsed from SVG | length=5 |
| PASS | low-privilege user can login | status=200, duration=1152ms |
| PASS | low-privilege login returns dashboard.view only | {"id":"usr_102e3dcda767449f9f288ec09e8b","username":"codex.low","displayName":"Codex低权限测试用户","roles":["自动化低权限角色"],"permissionCount":1} |
| PASS | low-privilege user can access allowed dashboard summary | status=200, duration=538ms |
| PASS | low-privilege user is forbidden from users.manage endpoint | status=403, duration=449ms |
| PASS | admin account has non-empty permission set | permissionCount=24, roles=超级管理员 |
## 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.
@@ -0,0 +1,48 @@
# Remote SIP Calls, CDR, and Billing API Test Report
Date: 2026-06-29T05:34:25.003Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=1308ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=1060ms |
| PASS | low-privilege user cannot list CDRs | status=403, duration=477ms |
| PASS | low-privilege user cannot list active calls | status=403, duration=464ms |
| PASS | CDR list can be queried | status=200, duration=1818ms |
| PASS | CDR list returns page shape | total=56, take=20, skip=0, hasMore=true |
| PASS | CDR list items do not expose secrets | items=20 |
| PASS | CDR detail can be fetched | status=200, duration=683ms |
| PASS | CDR detail matches list item id and event id | id=raw_4281ea422d1c4465964c6bf054a90115, eventId=s28-ok-1782701051-2331-s40-01-1782701049649-q3u-fde892c670e534f8 |
| PASS | CDR detail does not expose secrets | id=raw_4281ea422d1c4465964c6bf054a90115 |
| PASS | rated CDR detail has numeric fee fields | billSec=6, customerFee=0.012000, vendorCost=0.012000, grossProfit=0.000000 |
| PASS | CDR caller filter can be queried | status=200, duration=2032ms |
| PASS | CDR caller filter returns matching rows | rows=9, caller=s36-1001 |
| PASS | CDR carrier filter can be queried | status=200, duration=1585ms |
| PASS | CDR carrier filter returns matching rows | rows=10, carrier=UNKNOWN |
| PASS | invalid CDR carrier is rejected | status=400, duration=774ms |
| PASS | invalid carrier returns CARRIER_INVALID | code=CARRIER_INVALID |
| PASS | invalid CDR pagination is rejected | status=400, duration=464ms |
| PASS | invalid pagination returns QUERY_INVALID | code=QUERY_INVALID |
| PASS | invalid CDR time range is rejected | status=400, duration=458ms |
| PASS | invalid time range returns TIME_RANGE_INVALID | code=TIME_RANGE_INVALID |
| PASS | missing CDR detail returns 404 | status=404, duration=389ms |
| PASS | missing CDR returns CDR_NOT_FOUND | code=CDR_NOT_FOUND |
| PASS | active calls list can be queried | status=200, duration=969ms |
| PASS | active calls response has normalized shape | source=opensips-mi, total=0 |
| PASS | invalid active call hangup id is rejected before MI call | status=400, duration=561ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID | code=ACTIVE_CALL_ID_INVALID |
## 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.
@@ -0,0 +1,38 @@
# Remote Customers, Recharge, and Balance Test Report
Date: 2026-06-29T05:18:50.822Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Customer Name: 自动化8.2客户
Customer Domain: codex-82.example.test
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=544ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=454ms |
| PASS | low-privilege user cannot list customers | status=403, duration=395ms |
| PASS | test customer is created | status=201, duration=1007ms |
| PASS | test customer has expected credit/min balance | creditLimit=100.000000, minBalance=5.000000 |
| PASS | customer detail can be fetched | status=200, duration=528ms |
| PASS | available balance equals balance plus credit limit | balance=0.000000, creditLimit=100.000000, available=100.000000 |
| PASS | customer can be disabled | status=201, duration=401ms |
| PASS | disabled customer status is DISABLED | status=DISABLED |
| PASS | customer can be enabled | status=201, duration=403ms |
| PASS | enabled customer status is ENABLED | status=ENABLED |
| PASS | positive customer recharge succeeds | status=201, duration=410ms |
| PASS | positive recharge balance delta is +10.000000 | before=0.000000, after=10.000000 |
| PASS | same idempotency key with same body returns cached success | status=201, duration=412ms |
| PASS | idempotent duplicate returns same recharge id | first=rch_3dd6d1209d2f4d12ad69926097d7f2aa, duplicate=rch_3dd6d1209d2f4d12ad69926097d7f2aa |
| PASS | same idempotency key with different body is rejected | status=409, duration=404ms |
| FAIL | negative customer recharge deducts balance | status=400, duration=866ms |
| FAIL | negative recharge balance delta is -3.000000 | before=undefined, after=undefined, code=MONEY_INVALID |
| PASS | customer recharge list can be filtered by account | status=200, duration=1857ms |
| PASS | recharge list contains positive recharge record | total=1 |
| PASS | low-privilege user cannot recharge customer | status=403, duration=618ms |
## Notes
- Password and token values are intentionally omitted.
- Negative recharge is asserted as a required business rule: negative amount should deduct customer balance.
@@ -0,0 +1,49 @@
# Remote Customer Gateways, Business Prefixes, and Config Intent Test Report
Date: 2026-06-29T05:22:45.686Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Business Prefix: C83
Customer Name: 自动化8.3客户
Gateway Name: 自动化8.3客户网关
Gateway IP: 100.83.0.10
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=902ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=830ms |
| PASS | low-privilege user cannot list business prefixes | status=403, duration=614ms |
| PASS | low-privilege user cannot list customer gateways | status=403, duration=417ms |
| PASS | invalid business prefix is rejected | status=400, duration=421ms |
| PASS | invalid business prefix returns BUSINESS_PREFIX_INVALID | code=BUSINESS_PREFIX_INVALID |
| PASS | business prefix is created | status=201, duration=424ms |
| PASS | business prefix has expected values | id=bp_b9988f12a1bb418c9e64ce0754d32, prefix=C83, priority=83 |
| PASS | business prefix can be disabled | status=201, duration=432ms |
| PASS | disabled business prefix status is DISABLED | status=DISABLED |
| PASS | business prefix can be enabled | status=201, duration=427ms |
| PASS | enabled business prefix status is ENABLED | status=ENABLED |
| PASS | test customer is created | status=201, duration=482ms |
| PASS | landing line group list can be fetched | status=200, duration=1279ms |
| PASS | at least one landing line group is available for gateway binding | lineGroupId=llg_4e6997487b774379836631aee19c, name=S36 Flow 20260623160046 Line Group |
| PASS | invalid customer gateway source IP is rejected | status=400, duration=477ms |
| PASS | invalid source IP returns SOURCE_IP_INVALID | code=SOURCE_IP_INVALID |
| PASS | SIP gateway without password is rejected | status=400, duration=444ms |
| FAIL | missing SIP password returns SIP_PASSWORD_REQUIRED | code=VALIDATION_ERROR |
| PASS | customer gateway is created | status=201, duration=1048ms |
| PASS | customer gateway binds IP, caller prefix, and business prefix | gatewayId=cgw_16f4a342caf74ab5b3e34457113c, sourceIps=100.83.0.10, callerPrefixes=055183 |
| PASS | duplicate source IP and business prefix gateway is rejected | status=409, duration=786ms |
| PASS | duplicate gateway returns match conflict code | code=CUSTOMER_GATEWAY_MATCH_CONFLICT |
| PASS | customer gateway can be disabled | status=201, duration=587ms |
| PASS | disabled customer gateway status is DISABLED | status=DISABLED |
| PASS | customer gateway can be enabled | status=201, duration=440ms |
| PASS | enabled customer gateway status is ENABLED | status=ENABLED |
| PASS | business prefix in use cannot be deleted | status=400, duration=390ms |
| PASS | business prefix in use returns BUSINESS_PREFIX_IN_USE | code=BUSINESS_PREFIX_IN_USE |
## 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.
@@ -0,0 +1,49 @@
# Remote Customer Gateways, Business Prefixes, and Config Intent Test Report
Date: 2026-06-29T05:23:46.288Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Business Prefix: C83
Customer Name: 自动化8.3客户
Gateway Name: 自动化8.3客户网关
Gateway IP: 100.83.0.10
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=597ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=452ms |
| PASS | low-privilege user cannot list business prefixes | status=403, duration=389ms |
| PASS | low-privilege user cannot list customer gateways | status=403, duration=833ms |
| PASS | invalid business prefix is rejected | status=400, duration=380ms |
| PASS | invalid business prefix returns BUSINESS_PREFIX_INVALID | code=BUSINESS_PREFIX_INVALID |
| PASS | business prefix is updated | status=200, duration=429ms |
| PASS | business prefix has expected values | id=bp_b9988f12a1bb418c9e64ce0754d32, prefix=C83, priority=83 |
| PASS | business prefix can be disabled | status=201, duration=394ms |
| PASS | disabled business prefix status is DISABLED | status=DISABLED |
| PASS | business prefix can be enabled | status=201, duration=387ms |
| PASS | enabled business prefix status is ENABLED | status=ENABLED |
| PASS | test customer is updated | status=200, duration=430ms |
| PASS | landing line group list can be fetched | status=200, duration=578ms |
| PASS | at least one landing line group is available for gateway binding | lineGroupId=llg_4e6997487b774379836631aee19c, name=S36 Flow 20260623160046 Line Group |
| PASS | invalid customer gateway source IP is rejected | status=400, duration=795ms |
| PASS | invalid source IP returns SOURCE_IP_INVALID | code=SOURCE_IP_INVALID |
| PASS | SIP gateway without password is rejected | status=400, duration=382ms |
| PASS | missing SIP password returns a validation error | code=VALIDATION_ERROR |
| PASS | customer gateway is updated | status=200, duration=452ms |
| PASS | customer gateway binds IP, caller prefix, and business prefix | gatewayId=cgw_16f4a342caf74ab5b3e34457113c, sourceIps=100.83.0.10, callerPrefixes=055183 |
| PASS | duplicate source IP and business prefix gateway is rejected | status=409, duration=585ms |
| PASS | duplicate gateway returns match conflict code | code=CUSTOMER_GATEWAY_MATCH_CONFLICT |
| PASS | customer gateway can be disabled | status=201, duration=469ms |
| PASS | disabled customer gateway status is DISABLED | status=DISABLED |
| PASS | customer gateway can be enabled | status=201, duration=598ms |
| PASS | enabled customer gateway status is ENABLED | status=ENABLED |
| PASS | business prefix in use cannot be deleted | status=400, duration=385ms |
| PASS | business prefix in use returns BUSINESS_PREFIX_IN_USE | code=BUSINESS_PREFIX_IN_USE |
## 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.
@@ -0,0 +1,60 @@
# Remote Dashboard, Active Calls, and Audit Test Report
Date: 2026-06-29T05:51:04.581Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=1046ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=675ms |
| PASS | dashboard summary can be queried | status=200, duration=539ms |
| PASS | dashboard summary shape and Shanghai day window are valid | {"window":{"start":"2026-06-28T16:00:00.000Z","end":"2026-06-29T05:50:47.137Z","timezone":"Asia/Shanghai"},"calls":{"totalCalls":1,"answeredCalls":1,"failedCalls":0,"answerRate":"1.0000","totalDurationSec":6},"money":{"customerFee":"0.012000","vendorCost":"0.012000","grossProfit":"0.000000"},"quality":{"pendingReviews":54}} |
| PASS | dashboard trends can be queried with fixed range | status=200, duration=408ms |
| PASS | dashboard trends return fixed contiguous buckets | bucketCount=2 |
| PASS | invalid dashboard trend bucket is rejected | status=400, duration=392ms |
| PASS | invalid trend bucket returns DASHBOARD_BUCKET_INVALID | code=DASHBOARD_BUCKET_INVALID |
| PASS | too-large dashboard trend range is rejected | status=400, duration=379ms |
| PASS | too-large trend range returns INTEGER_INVALID | code=INTEGER_INVALID |
| PASS | low dashboard-only user can query dashboard summary | status=200, duration=975ms |
| PASS | active calls can be listed | status=200, duration=840ms |
| PASS | active calls response shape is stable | {"total":0,"source":"opensips-mi"} |
| PASS | low dashboard-only user cannot list active calls | status=403, duration=379ms |
| PASS | invalid active call id is rejected (../x) | status=400, duration=410ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (../x) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (;rm -rf) | status=400, duration=388ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (;rm -rf) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (contains space) | status=400, duration=384ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (contains space) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (line\nbreak) | status=400, duration=385ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (line\nbreak) | code=ACTIVE_CALL_ID_INVALID |
| FAIL | invalid active call id is rejected (xxxxxxxxxxxxxxxxxxxx) | status=404, duration=377ms |
| FAIL | invalid active call id returns ACTIVE_CALL_ID_INVALID (xxxxxxxxxxxxxxxxxxxx) | code=undefined |
| PASS | low dashboard-only user cannot hang up calls | status=403, duration=380ms |
| PASS | audit logs can be listed | status=200, duration=756ms |
| PASS | audit list shape is valid | count=10, total=113 |
| PASS | low dashboard-only user cannot list audit logs | status=403, duration=404ms |
| PASS | invalid audit result filter is rejected | status=400, duration=380ms |
| PASS | invalid audit result returns AUDIT_RESULT_INVALID | code=AUDIT_RESULT_INVALID |
| PASS | audit logs can be filtered by result | status=200, duration=899ms |
| PASS | audit success filter only returns SUCCESS rows | count=5 |
| PASS | roles can be listed for temporary audit user setup | status=200, duration=758ms |
| PASS | dashboard-capable role is available | roleId=ROLE_TECH_OPS |
| PASS | temporary user with sensitive password can be created | status=201, expected=201, duration=776ms |
| PASS | created temporary user response does not expose password fields | {"id":"usr_ddd21fd564f6456a8c120dc940d4","username":"codex.audit.1782712243816"} |
| PASS | temporary user password reset succeeds | status=201, duration=580ms |
| PASS | password reset response does not expose sensitive fields | {"id":"usr_ddd21fd564f6456a8c120dc940d4","username":"codex.audit.1782712243816"} |
| PASS | password reset audit can be filtered by module/action/object/result | status=200, duration=437ms |
| PASS | password reset audit row exists | auditId=aud_09853278ea1a4d7b8ff64275a926f9ac |
| PASS | password reset audit detail can be fetched | status=200, duration=1000ms |
| FAIL | password reset audit detail redacts sensitive body fields | {"id":"aud_09853278ea1a4d7b8ff64275a926f9ac","redactedPassword":"[REDACTED]"} |
| PASS | temporary audit user cleanup is stable | status=200, expected=200/404, duration=1695ms |
## 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.
@@ -0,0 +1,60 @@
# Remote Dashboard, Active Calls, and Audit Test Report
Date: 2026-06-29T05:52:07.874Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=457ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=1291ms |
| PASS | dashboard summary can be queried | status=200, duration=433ms |
| PASS | dashboard summary shape and Shanghai day window are valid | {"window":{"start":"2026-06-28T16:00:00.000Z","end":"2026-06-29T05:51:50.712Z","timezone":"Asia/Shanghai"},"calls":{"totalCalls":1,"answeredCalls":1,"failedCalls":0,"answerRate":"1.0000","totalDurationSec":6},"money":{"customerFee":"0.012000","vendorCost":"0.012000","grossProfit":"0.000000"},"quality":{"pendingReviews":54}} |
| PASS | dashboard trends can be queried with fixed range | status=200, duration=382ms |
| PASS | dashboard trends return fixed contiguous buckets | bucketCount=2 |
| PASS | invalid dashboard trend bucket is rejected | status=400, duration=800ms |
| PASS | invalid trend bucket returns DASHBOARD_BUCKET_INVALID | code=DASHBOARD_BUCKET_INVALID |
| PASS | too-large dashboard trend range is rejected | status=400, duration=1413ms |
| PASS | too-large trend range returns INTEGER_INVALID | code=INTEGER_INVALID |
| PASS | low dashboard-only user can query dashboard summary | status=200, duration=806ms |
| PASS | active calls can be listed | status=200, duration=807ms |
| PASS | active calls response shape is stable | {"total":0,"source":"opensips-mi"} |
| PASS | low dashboard-only user cannot list active calls | status=403, duration=379ms |
| PASS | invalid active call id is rejected (../x) | status=400, duration=382ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (../x) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (;rm -rf) | status=400, duration=867ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (;rm -rf) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (contains space) | status=400, duration=385ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (contains space) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (line\nbreak) | status=400, duration=386ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (line\nbreak) | code=ACTIVE_CALL_ID_INVALID |
| FAIL | invalid active call id is rejected (xxxxxxxxxxxxxxxxxxxx) | status=404, duration=437ms |
| FAIL | invalid active call id returns ACTIVE_CALL_ID_INVALID (xxxxxxxxxxxxxxxxxxxx) | code=undefined |
| PASS | low dashboard-only user cannot hang up calls | status=403, duration=414ms |
| PASS | audit logs can be listed | status=200, duration=1314ms |
| PASS | audit list shape is valid | count=10, total=120 |
| PASS | low dashboard-only user cannot list audit logs | status=403, duration=463ms |
| PASS | invalid audit result filter is rejected | status=400, duration=377ms |
| PASS | invalid audit result returns AUDIT_RESULT_INVALID | code=AUDIT_RESULT_INVALID |
| PASS | audit logs can be filtered by result | status=200, duration=558ms |
| PASS | audit success filter only returns SUCCESS rows | count=5 |
| PASS | roles can be listed for temporary audit user setup | status=200, duration=424ms |
| PASS | dashboard-capable role is available | roleId=ROLE_TECH_OPS |
| PASS | temporary user with sensitive password can be created | status=201, expected=201, duration=432ms |
| PASS | created temporary user response does not expose password fields | {"id":"usr_b548190698424ba9b7b4a6c37bb2","username":"codex.audit.1782712309898"} |
| PASS | temporary user password reset succeeds | status=201, duration=432ms |
| PASS | password reset response does not expose sensitive fields | {"id":"usr_b548190698424ba9b7b4a6c37bb2","username":"codex.audit.1782712309898"} |
| PASS | password reset audit can be filtered by module/action/object/result | status=200, duration=695ms |
| PASS | password reset audit row exists | auditId=aud_a5b773a3ee0c4397a81eaf2b8fbac455 |
| PASS | password reset audit detail can be fetched | status=200, duration=580ms |
| PASS | password reset audit detail redacts sensitive body fields | {"id":"aud_a5b773a3ee0c4397a81eaf2b8fbac455","redactedPassword":"[REDACTED]"} |
| PASS | temporary audit user cleanup is stable | status=200, expected=200/404, duration=475ms |
## 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.
@@ -0,0 +1,51 @@
# 8.10 运维发布、备份与回滚远程测试报告
- Target: `https://100.90.90.91/`
- SSH target: `lisglosips-b`, `lisglosips-a`, `lisglosips-t`
- Started at: `2026-06-29T06:30:00Z`
- Completed at: `2026-06-29T06:42:36Z`
- Scope: 8.10 发布工件、发布前检查、备份、恢复、灰度呼叫、回滚与迁移复核
## Summary
| Result | Count |
| --- | ---: |
| PASS | 5 |
| PARTIAL | 1 |
| BLOCKED | 6 |
| N/A | 1 |
## Findings
| Case | Result | Evidence |
| --- | --- | --- |
| OPS-000 发布工件完整性检查 | PASS | `pnpm release:artifact -- --release-id codex-ops-check-20260629063000 --check --allow-non-linux` 通过;输出确认 39 个必需条目齐全。 |
| OPS-001 B 发布前检查 | PARTIAL | `/opt/lisglosips/current` 指向 `/opt/lisglosips/releases/s45-vendor-cps-sipstate-20260629113500`B 上 `mysql``redis-server``nginx``lisglosips@api``lisglosips@cdr-worker``lisglosips@recording-worker``lisglosips@config-publisher``heplify-server``grafana-server``lisglosips-prometheus.service` 均为 active`/api/v2/health/ready` 返回 database/redis ok;但非 sudo 用户无法读取或执行 `/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh`,脚本级 preflight 被权限阻塞。 |
| OPS-002 MySQL 备份 | BLOCKED | 非 sudo 用户访问 `/data/backups/mysql` 返回 `权限不够`;无法触发 `lisglosips-backup.service` 或校验最新备份文件。 |
| OPS-003 Redis 备份 | BLOCKED | 非 sudo 用户访问 `/data/backups/redis` 返回 `权限不够`;无法触发备份或校验最新 RDB/元数据。 |
| OPS-004 隔离恢复演练 | BLOCKED | 需要可读备份文件、临时恢复目录/容器或 root 级恢复权限;本轮未获得 sudo,未执行恢复演练。 |
| OPS-005 灰度呼叫验收 | PASS | 从 T 发起呼叫成功:Call-ID `s28-1782715222404-1w73ad89@lisglosips-t`INVITE 收到 `SIP/2.0 200 OK`BYE 收到 `SIP/2.0 200 OK`。API 反查 CDR 成功,`sampleId=raw_95fb2de5aafb4b0a808ddccc6293d7da`;录音列表找到对应记录,`sampleId=rec_47012fa5341e42f19fec400c1972b6ae`,状态 `READY`。 |
| OPS-006 回滚脚本语法检查 | BLOCKED | 本机无 `bash`B 上 `/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh``lisglosips-release-rollback.sh` 对当前用户不可读;未能执行 `bash -n`。 |
| OPS-007 应用回滚演练 | BLOCKED | 需要修改 `/opt/lisglosips/current` 指针并重启服务,属于 root/维护窗口动作;当前 sudo 不可用,未执行真实回滚。 |
| OPS-008 OpenSIPS 配置恢复演练 | BLOCKED | A 上以非 root 执行 `opensips -C -f /etc/opensips/opensips.cfg` 因读取配置权限不足失败;配置恢复脚本路径/权限未满足,未执行恢复演练。 |
| OPS-009 阿里云迁移复核 | N/A | 本轮目标为现有测试机 `100.90.90.91`,不是阿里云迁移后的新环境;仅做当前环境可用性复核。 |
| 发布后 HTTP/API 健康回归 | PASS | `GET /` 返回 200`/api/v2/health/ready` 返回 `{"config":"ok","database":"ok","redis":"ok"}`。 |
| 远程 smoke 回归 | PASS | `pnpm test:remote-smoke` 全部通过,报告:`tests/reports/REMOTE_SMOKE_20260629T064035Z.md`。 |
## Blocking Notes
- B 上当前 SSH 用户无法免密 sudo,`sudo -n true` 返回需要密码。
- `/data/backups/mysql``/data/backups/redis`、发布 preflight/rollback 脚本均对当前用户不可读。
- 因此备份触发、备份文件校验、隔离恢复、真实应用回滚、OpenSIPS 配置恢复只能在具备 root 权限或维护窗口时继续。
## Commands Run
```powershell
corepack pnpm@10.33.0 release:artifact -- --release-id codex-ops-check-20260629063000 --check --allow-non-linux
ssh -F .codex-private\ssh\config lisglosips-b "readlink -f /opt/lisglosips/current"
ssh -F .codex-private\ssh\config lisglosips-b "systemctl is-active mysql redis-server nginx lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher heplify-server grafana-server lisglosips-prometheus.service"
ssh -F .codex-private\ssh\config lisglosips-b "find /data/backups/mysql -mindepth 1 -maxdepth 1 -type d"
ssh -F .codex-private\ssh\config lisglosips-b "find /data/backups/redis -mindepth 1 -maxdepth 1 -type d"
ssh -F .codex-private\ssh\config lisglosips-t "python3 /opt/lisglosips-s28/lisglosips-s28-sip.py invite --hold 3 --timeout 6 --media-port 31500"
corepack pnpm@10.33.0 test:remote-smoke
```
@@ -0,0 +1,43 @@
# Remote Performance, Fault, and Security Test Report
Date: 2026-06-29T06:16:21.982Z
Base URL: https://100.90.90.91
Concurrency: 12 ready + 12 captcha requests
| Result | Check | Detail |
| --- | --- | --- |
| PASS | HTTPS root is reachable before light concurrency | status=200, duration=2087ms |
| PASS | HTTPS root contains app root | contentType=text/html, bytes=434 |
| PASS | ready health is ok before light concurrency | status=200, duration=389ms |
| PASS | ready health reports database and redis ok before light concurrency | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:15:52.352Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | admin can login | status=200, duration=1103ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=773ms |
| PASS | PERF light API burst returns only 200 responses | requests=24, failed=0, wallMs=15477 |
| FAIL | PERF light API burst p95 stays under 10s | p95=14919ms, max=15441ms |
| PASS | ready health recovers after light concurrency | status=200, duration=604ms |
| PASS | ready health reports database and redis ok after light concurrency | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:16:11.657Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | SEC forged bearer token is rejected | status=401, duration=372ms |
| PASS | SEC forged token error does not leak internals | body={"code":"AUTH_REQUIRED","message":"Authentication is required."} |
| PASS | SEC unauthenticated write is rejected | status=401, duration=775ms |
| PASS | SEC unauthenticated write error does not leak internals | body={"code":"AUTH_REQUIRED","message":"Authentication is required."} |
| PASS | SEC low-privilege user cannot create customer | status=403, duration=605ms |
| PASS | SEC low-privilege write error does not leak internals | body={"code":"RBAC_FORBIDDEN","message":"Permission denied."} |
| PASS | SEC replay test customer is created | status=201, duration=1141ms |
| PASS | SEC first idempotent recharge succeeds | status=201, duration=535ms |
| PASS | SEC exact idempotent replay returns success | status=201, duration=456ms |
| PASS | SEC exact idempotent replay returns same recharge id | first=rch_44943fc6e1d3483ab17eee61fe279b1d, replay=rch_44943fc6e1d3483ab17eee61fe279b1d |
| PASS | SEC conflicting idempotency replay is rejected | status=409, duration=601ms |
| PASS | SEC conflicting replay error does not leak original body | body={"code":"IDEMPOTENCY_KEY_CONFLICT","message":"Idempotency key was used by another request."} |
| PASS | SEC low-privilege user cannot replay/write recharge | status=403, duration=371ms |
| PASS | SEC missing recording playback returns 404 | status=404, duration=575ms |
| PASS | SEC missing recording playback error does not expose paths | body={"code":"RECORDING_NOT_READY","message":"Recording is not available for playback."} |
| PASS | SEC encoded traversal recording id is rejected safely | status=404, body={"code":"RECORDING_NOT_READY","message":"Recording is not available for playback."} |
| PASS | SEC traversal playback error does not expose filesystem paths | body={"code":"RECORDING_NOT_READY","message":"Recording is not available for playback."} |
## 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.
@@ -0,0 +1,76 @@
# Remote Performance, Fault, and Security Disruptive Test Report
Date: 2026-06-29T06:29:20Z
Base URL: https://100.90.90.91
Scope: 8.9 disruptive / SIP-side follow-up after `REMOTE_PERFORMANCE_SECURITY_20260629T061621Z.md`
## Baseline
| Result | Check | Detail |
| --- | --- | --- |
| PASS | B services active before/after run | `lisglosips@api`, `lisglosips@cdr-worker`, `lisglosips@recording-worker`, `mysql`, `redis-server` all active |
| PASS | A services active before run | `opensips`, `rtpengine-daemon`, `rtpengine-recording-daemon`, `lisglosips-redis-auth-proxy` all active |
| PASS | T services active before run | `lisglosips-s28-uas`, `opensips` active |
| PASS | Final HTTPS smoke | `/`, `/api/v2/health/live`, `/api/v2/health/ready`, `/api/v2/auth/captcha` all PASS; latest smoke report `REMOTE_SMOKE_20260629T062920Z.md` |
## SIP Concurrency
| Result | Check | Detail |
| --- | --- | --- |
| PASS | PERF-001 5 concurrent calls | 5/5 received `100 Giving it a try`, `200 OK`, and BYE `200 OK` |
| PASS | PERF-001 CDR verification | 5/5 call IDs found in `/api/v2/cdrs?take=100` |
| PASS | PERF-001 recording verification | 5/5 call IDs found in `/api/v2/recordings?limit=100` |
| PASS | PERF-002 12 short-burst calls | 12/12 received `100 Giving it a try`, `200 OK`, and BYE `200 OK` |
| PASS | PERF-002 CDR verification | 12/12 call IDs found in `/api/v2/cdrs?take=100` |
| PASS | PERF-002 recording verification | 12/12 call IDs found in `/api/v2/recordings?limit=100` after worker catch-up wait |
5-call IDs:
- `s28-1782714314674-tprj1vk2@lisglosips-t`
- `s28-1782714314655-ye44k7c7@lisglosips-t`
- `s28-1782714314670-zqsgouuh@lisglosips-t`
- `s28-1782714314677-vkrthfau@lisglosips-t`
- `s28-1782714314679-j47gb9tl@lisglosips-t`
12-call IDs:
- `s28-1782714339590-pi088vy9@lisglosips-t`
- `s28-1782714339585-t0wbt40b@lisglosips-t`
- `s28-1782714339580-ooqd773x@lisglosips-t`
- `s28-1782714339585-b38gwxb1@lisglosips-t`
- `s28-1782714339585-wn61wl1x@lisglosips-t`
- `s28-1782714339573-w6xxwzmp@lisglosips-t`
- `s28-1782714339589-lxrh6ht3@lisglosips-t`
- `s28-1782714339581-usu8mnjx@lisglosips-t`
- `s28-1782714339590-51r89nel@lisglosips-t`
- `s28-1782714339589-63h8mpr7@lisglosips-t`
- `s28-1782714339584-kmbwlurm@lisglosips-t`
- `s28-1782714339575-kv082r5b@lisglosips-t`
## Security Probes
| Result | Check | Detail |
| --- | --- | --- |
| PASS | SEC-001 illegal-source SIP probe | From B to A `100.90.90.90:15060`, Call-ID `codex89-illegal-1782714470-8090@lisglosips-b`, no response within 3s |
| WARN | SEC-002 20-call CPS burst | 20/20 INVITE received `200 OK`, but 20/20 BYE returned `403 Rate Limited` |
| PASS | SEC-002 recovery after burst | One normal call after 5s recovered and received BYE `200 OK`; Call-ID `s28-1782714527323-zdscgrrx@lisglosips-t` |
## Fault Injection
| Result | Check | Detail |
| --- | --- | --- |
| BLOCKED | FAIL-001 Recording Worker stop/recover | B `sudo -n true` returns `sudo-needs-password`; current SSH user cannot stop/start services non-interactively |
| BLOCKED | FAIL-002 CDR Worker stop/recover | Same sudo blocker |
| BLOCKED | FAIL-003 MySQL short outage | Same sudo blocker |
| BLOCKED | FAIL-004 Redis short outage | Same sudo blocker |
## Findings
- CPS burst behavior needs review: rate limiting appears to affect in-dialog BYE requests after the INVITE has already succeeded with `200 OK`. The system recovers for subsequent calls, but BYE `403 Rate Limited` can leave call teardown semantics ambiguous.
- Service-stop fault tests remain blocked until non-interactive sudo is available or the sudo password is provided through an approved secure channel. I did not attempt to guess or bypass sudo.
## Final State
- B services checked active after the run.
- HTTPS smoke after the run passed.
- No service was left intentionally stopped.
@@ -0,0 +1,52 @@
# Remote Recordings, Playback, and Quality Test Report
Date: 2026-06-29T05:40:18.380Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=944ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=608ms |
| PASS | low-privilege user cannot list recordings | status=403, duration=465ms |
| PASS | low-privilege user cannot list quality rules | status=403, duration=380ms |
| PASS | invalid recording status is rejected | status=400, duration=865ms |
| PASS | invalid recording status returns RECORDING_STATUS_INVALID | code=RECORDING_STATUS_INVALID |
| PASS | invalid recording list limit is rejected | status=400, duration=383ms |
| PASS | invalid recording limit returns INTEGER_INVALID | code=INTEGER_INVALID |
| PASS | READY recordings can be listed | status=200, duration=1272ms |
| PASS | recording list shape is valid | count=20 |
| PASS | recording detail can be fetched | status=200, duration=2056ms |
| PASS | recording detail matches list item | id=rec_3329d4ba78c441f19ca3ca0dc241758c, reviews=0 |
| PASS | READY recording playback returns X-Accel response or served media | status=200, expected=200/206, duration=5094ms |
| PASS | playback response avoids real filesystem path exposure | xAccel=n/a, contentType=audio/wav |
| PASS | low-privilege user cannot play recording | status=403, duration=379ms |
| PASS | decimal review score is rejected by current API | status=400, duration=394ms |
| PASS | decimal review score returns INTEGER_INVALID | code=INTEGER_INVALID |
| PASS | invalid review result is rejected | status=400, duration=1213ms |
| PASS | invalid review result returns QUALITY_REVIEW_RESULT_INVALID | code=QUALITY_REVIEW_RESULT_INVALID |
| PASS | low-privilege user cannot save review | status=403, duration=1109ms |
| PASS | quality review can be saved | status=200, duration=392ms |
| PASS | quality review contains expected score/result/tags | score=88, result=ISSUE, tags=["noise","script"] |
| PASS | reviewed recording list can be filtered | status=200, duration=889ms |
| PASS | reviewed list contains reviewed recording | count=3 |
| PASS | missing recording playback returns 404 | status=404, duration=402ms |
| PASS | missing recording playback returns RECORDING_NOT_READY | code=RECORDING_NOT_READY |
| PASS | invalid quality sampling ratio is rejected | status=400, duration=415ms |
| PASS | invalid ratio returns QUALITY_RATIO_INVALID | code=QUALITY_RATIO_INVALID |
| PASS | quality sampling rule is created | status=201, duration=521ms |
| PASS | quality sampling rule has 100 percent ratio | ruleId=qsr_dfdc7cbfa8254a90848bd599e5bb, ratio=100.00, status=ENABLED |
| PASS | quality sampling rule can be disabled | status=201, duration=970ms |
| PASS | disabled quality rule status is DISABLED | status=DISABLED |
| PASS | quality sampling rule can be enabled | status=201, duration=1030ms |
| PASS | enabled quality rule status is ENABLED | status=ENABLED |
| PASS | recording list includes stable sampling payload after rule change | status=200, duration=1813ms |
| PASS | sampling payload shape is present | count=5 |
## 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.
@@ -0,0 +1,22 @@
# Remote Smoke Test Report
Date: 2026-06-29T04:58:50.321Z
Base URL: https://100.90.90.91
| Result | Check | Detail |
| --- | --- | --- |
| FAIL | / returns 200 | Request timed out after 10000ms |
| FAIL | frontend HTML contains app root | contentType=, bytes=0 |
| FAIL | /api/v2/health/live returns 200 | Request timed out after 10000ms |
| FAIL | live health reports ok | body=null |
| FAIL | /api/v2/health/ready returns 200 | Request timed out after 10000ms |
| FAIL | ready health reports database and redis ok | body=null |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=5795ms |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=9f416a91-55b6-423b-bcce-a554c9c8a0f3, expiresAt=2026-06-29T05:03:45.635Z |
## Raw Endpoints
- /: status=0, duration=10048ms, contentType=n/a
- /api/v2/health/live: status=0, duration=10014ms, contentType=n/a
- /api/v2/health/ready: status=0, duration=10009ms, contentType=n/a
- /api/v2/auth/captcha: status=200, duration=5795ms, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T04:59:32.371Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=4433ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=558ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T04:59:25.206Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=970ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T04:59:26.180Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=2232ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=39237aa0-fecc-4529-a1ef-63484e303e6f, expiresAt=2026-06-29T05:04:27.702Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=4433ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=558ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=970ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=2232ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T06:03:57.618Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=3727ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=640ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:03:52.452Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=703ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:03:53.162Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=559ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=cb19317a-2c46-4a16-b743-48e918856745, expiresAt=2026-06-29T06:08:53.546Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=3727ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=640ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=703ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=559ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T06:29:20.572Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1886ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=576ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:29:13.960Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=1953ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:29:14.536Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=608ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=31787ad3-c22c-4e87-9fec-6b0ff0e289d0, expiresAt=2026-06-29T06:34:16.507Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1886ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=576ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=1953ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=608ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T06:40:35.637Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=3959ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=1537ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:40:27.517Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=1801ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:40:29.889Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1477ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=99000b78-6bfd-42d4-80a2-9412dee38796, expiresAt=2026-06-29T06:45:30.853Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=3959ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=1537ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=1801ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1477ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T07:18:37.925Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=2367ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=751ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:18:31.443Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=636ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:18:31.841Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=2012ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=aa4e3357-09da-4932-bad0-515a65986984, expiresAt=2026-06-29T07:23:32.938Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=2367ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=751ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=636ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=2012ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T07:40:52.298Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=2220ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=423ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:40:46.672Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=425ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:40:47.093Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1375ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=24741c6c-8af6-4b26-a519-568b975e6b33, expiresAt=2026-06-29T07:45:48.366Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=2220ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=423ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=425ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1375ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T07:51:38.171Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=2931ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=613ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:51:32.713Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=420ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:51:33.130Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1217ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=28804ba6-99f0-4cdd-a966-8878ccd9abdc, expiresAt=2026-06-29T07:56:33.737Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=2931ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=613ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=420ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1217ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T07:55:04.361Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=2948ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=851ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:54:58.998Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=425ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:54:59.434Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1103ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=201d00b8-4096-4e9b-a478-09f38e8507e7, expiresAt=2026-06-29T08:00:00.361Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=2948ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=851ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=425ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1103ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T08:09:17.242Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=3371ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=461ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T08:09:11.715Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=604ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T08:09:12.137Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1123ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=58d3d4a2-6f71-4a78-a102-995ae8ebe444, expiresAt=2026-06-29T08:14:12.744Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=3371ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=461ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=604ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1123ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T09:47:44.237Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=100ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=17ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T09:47:40.645Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=17ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T09:47:40.663Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=14ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=e1750f45-4291-4e38-b949-4f1332525147, expiresAt=2026-06-29T09:52:40.680Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=100ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=17ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=17ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=14ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T10:11:22.445Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1632ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=1227ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T10:11:14.818Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=2785ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T10:11:17.451Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1161ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=f1e5fa0e-ffc1-4ec5-b193-d9551d47eae5, expiresAt=2026-06-29T10:16:18.141Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1632ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=1227ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=2785ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1161ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T10:34:20.124Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=5834ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=1112ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T10:34:18.013Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=869ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T10:34:18.683Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1078ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=1ee70823-7d33-4647-82e6-aa0099364891, expiresAt=2026-06-29T10:39:19.553Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=5834ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=1112ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=869ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1078ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-30T01:28:30.863Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1035ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=296ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T01:28:30.512Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=314ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T01:28:30.820Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=410ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=ded8cb2e-9ccc-4e5d-90f0-b4e0e0187a47, expiresAt=2026-06-30T01:33:31.152Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1035ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=296ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=314ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=410ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-30T02:44:12.572Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=5040ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=466, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=324ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T02:44:12.723Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=161ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T02:44:12.890Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=271ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=784f66e9-ac29-4fa2-a330-aeac063e5ea1, expiresAt=2026-06-30T02:49:13.159Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=5040ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=324ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=161ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=271ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-30T03:11:46.183Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1652ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=466, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=319ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T03:11:45.886Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=301ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T03:11:46.200Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=449ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=713febe8-036e-4596-b5a5-a8ad95e42a6d, expiresAt=2026-06-30T03:16:46.503Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1652ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=319ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=301ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=449ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,49 @@
# Remote Vendors, Vendor Gateways, and Landing Line Groups Test Report
Date: 2026-06-29T05:28:04.913Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Vendor Name: 自动化8.4供应商
Line Group Name: 自动化8.4线路组
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=791ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=624ms |
| PASS | low-privilege user cannot list vendors | status=403, duration=388ms |
| PASS | low-privilege user cannot list line groups | status=403, duration=381ms |
| PASS | invalid vendor is rejected | status=400, duration=382ms |
| PASS | vendor is created | status=201, duration=416ms |
| PASS | vendor has expected credit limit | vendorId=ven_a81199989064479697afadcfdd9b, creditLimit=200.000000 |
| PASS | invalid vendor gateway host is rejected | status=400, duration=400ms |
| PASS | invalid host returns HOST_INVALID | code=HOST_INVALID |
| PASS | weak SIP password is rejected | status=400, duration=384ms |
| PASS | primary vendor gateway is created | status=201, duration=602ms |
| PASS | primary gateway has child config | codecs=2, prefixRules=2, callerRewrite=1 |
| PASS | backup vendor gateway is created | status=201, duration=977ms |
| PASS | vendor gateway can be disabled | status=201, duration=601ms |
| PASS | disabled vendor gateway status is DISABLED | status=DISABLED |
| PASS | vendor gateway can be enabled | status=201, duration=745ms |
| PASS | enabled vendor gateway status is ENABLED | status=ENABLED |
| PASS | line group is created | status=201, duration=436ms |
| PASS | primary line group item is added | status=201, expected=201, duration=412ms |
| PASS | backup line group item is added | status=201, expected=201, duration=420ms |
| PASS | line group detail can be fetched | status=200, duration=387ms |
| PASS | line group contains two enabled items | enabledItemCount=2, itemCount=2 |
| PASS | line group items can be reordered | status=201, duration=404ms |
| PASS | reorder preserves item set | before=2, after=2 |
| PASS | duplicate line group gateway item is rejected | status=409, duration=406ms |
| PASS | vendor gateway referenced by line group cannot be deleted | status=400, duration=391ms |
| PASS | referenced gateway returns VENDOR_GATEWAY_IN_LINE_GROUP | code=VENDOR_GATEWAY_IN_LINE_GROUP |
| PASS | line group can be disabled | status=201, duration=395ms |
| PASS | disabled line group status is DISABLED | status=DISABLED |
| PASS | line group can be enabled | status=201, duration=965ms |
| PASS | enabled line group status is ENABLED | status=ENABLED |
## 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.
@@ -0,0 +1,49 @@
# Remote Vendors, Vendor Gateways, and Landing Line Groups Test Report
Date: 2026-06-29T05:28:35.481Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Vendor Name: 自动化8.4供应商
Line Group Name: 自动化8.4线路组
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=442ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=429ms |
| PASS | low-privilege user cannot list vendors | status=403, duration=381ms |
| PASS | low-privilege user cannot list line groups | status=403, duration=384ms |
| PASS | invalid vendor is rejected | status=400, duration=383ms |
| PASS | vendor is updated | status=200, duration=413ms |
| PASS | vendor has expected credit limit | vendorId=ven_a81199989064479697afadcfdd9b, creditLimit=200.000000 |
| PASS | invalid vendor gateway host is rejected | status=400, duration=394ms |
| PASS | invalid host returns HOST_INVALID | code=HOST_INVALID |
| PASS | weak SIP password is rejected | status=400, duration=383ms |
| PASS | primary vendor gateway is updated | status=200, duration=751ms |
| PASS | primary gateway has child config | codecs=2, prefixRules=2, callerRewrite=1 |
| PASS | backup vendor gateway is updated | status=200, duration=789ms |
| PASS | vendor gateway can be disabled | status=201, duration=998ms |
| PASS | disabled vendor gateway status is DISABLED | status=DISABLED |
| PASS | vendor gateway can be enabled | status=201, duration=573ms |
| PASS | enabled vendor gateway status is ENABLED | status=ENABLED |
| PASS | line group is updated | status=200, duration=778ms |
| PASS | primary line group item is updated | status=200, expected=200, duration=913ms |
| PASS | backup line group item is updated | status=200, expected=200, duration=656ms |
| PASS | line group detail can be fetched | status=200, duration=557ms |
| PASS | line group contains two enabled items | enabledItemCount=2, itemCount=2 |
| PASS | line group items can be reordered | status=201, duration=437ms |
| PASS | reorder preserves item set | before=2, after=2 |
| PASS | duplicate line group gateway item is rejected | status=409, duration=393ms |
| PASS | vendor gateway referenced by line group cannot be deleted | status=400, duration=390ms |
| PASS | referenced gateway returns VENDOR_GATEWAY_IN_LINE_GROUP | code=VENDOR_GATEWAY_IN_LINE_GROUP |
| PASS | line group can be disabled | status=201, duration=623ms |
| PASS | disabled line group status is DISABLED | status=DISABLED |
| PASS | line group can be enabled | status=201, duration=1180ms |
| PASS | enabled line group status is ENABLED | status=ENABLED |
## 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.
@@ -0,0 +1,35 @@
# Remote Web UI Test Report
Date: 2026-06-29T06:01:31.528Z
Base URL: https://100.90.90.91
Browser path: Browser plugin attempted first; fallback to local Chrome Playwright because in-app browser stopped at net::ERR_CERT_AUTHORITY_INVALID for the B HTTPS certificate.
Viewport: 1440x900
## Findings
- Browser automation failed before completing all 8.8 checks: locator.click: Timeout 10000ms exceeded. Call log:  - waiting for getByRole('button', { name: '号码库' }) at D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\.node_repl_cell_6.mjs:101:55
| Result | Check | Detail |
| --- | --- | --- |
| FAIL | WEB-001 homepage renders login shell | title=LisgloSIPS - 聆界SIP管理平台, hasLogin=true |
| PASS | WEB-001 access token is not present in URL before login | https://100.90.90.91/ |
| PASS | WEB-001 admin login enters dashboard | url=https://100.90.90.91/ |
| PASS | WEB-001 access token is not present in URL after login | https://100.90.90.91/ |
| PASS | WEB-001 refresh restores authenticated dashboard | hasDashboard=true |
| FAIL | WEB-005 admin sees all non-pending core menus | missing=号码库 |
| FAIL | WEB automation completed without unhandled exception | locator.click: Timeout 10000ms exceeded.
Call log:
 - waiting for getByRole('button', { name: '号码库' })
|
## Screenshots
- Admin dashboard: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060046Z_admin-dashboard.png
- Low-privilege nav: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060046Z_low-nav.png
- Logout/login page: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060046Z_logout.png
## Notes
- CAPTCHA was read from the page image data URL with explicit user permission for this test run.
- WEB-002 forced API 500/network-failure states and WEB-003 dedicated empty-data states were not injected against B to avoid disturbing the shared service.
- WEB-006 was checked read-only from the served homepage; no release switch or deployment mutation was performed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

@@ -0,0 +1,30 @@
# Remote Web UI Test Report
Date: 2026-06-29T06:03:37.427Z
Base URL: https://100.90.90.91
Browser path: Browser plugin attempted first; fallback to local Chrome Playwright because in-app browser stopped at net::ERR_CERT_AUTHORITY_INVALID for the B HTTPS certificate.
Viewport: 1440x900
## Findings
- Browser automation failed before completing all 8.8 checks: page.goto: Timeout 30000ms exceeded. Call log:  - navigating to "https://100.90.90.91/", waiting until "domcontentloaded" at D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\.node_repl_cell_7.mjs:79:17
| Result | Check | Detail |
| --- | --- | --- |
| FAIL | WEB automation completed without unhandled exception | page.goto: Timeout 30000ms exceeded.
Call log:
 - navigating to "https://100.90.90.91/", waiting until "domcontentloaded"
|
## Screenshots
- Admin dashboard: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060305Z_admin-dashboard.png
- Low-privilege nav: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060305Z_low-nav.png
- Logout/login page: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060305Z_logout.png
## Notes
- CAPTCHA was read from the page image data URL with explicit user permission for this test run.
- Initial unauthenticated /auth/refresh 401 console noise and navigation-cancelled net::ERR_ABORTED requests were excluded from console/network health because they are expected during login and rapid page switching.
- WEB-002 forced API 500/network-failure states and WEB-003 dedicated empty-data states were not injected against B to avoid disturbing the shared service.
- WEB-006 was checked read-only from the served homepage; no release switch or deployment mutation was performed.
@@ -0,0 +1,40 @@
# Remote Web UI Test Report
Date: 2026-06-29T06:07:19.550Z
Base URL: https://100.90.90.91
Browser path: Browser plugin attempted first; fallback to local Chrome Playwright because in-app browser stopped at net::ERR_CERT_AUTHORITY_INVALID for the B HTTPS certificate.
Viewport: 1440x900
## Findings
- Admin menu is missing expected core entries: Missing: 号码库.
- Browser automation failed before completing all 8.8 checks: page.waitForSelector: Timeout 30000ms exceeded. Call log:  - waiting for locator('input[autocomplete="username"]') to be visible at login883 (D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\.node_repl_cell_9.mjs:68:14) at async D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\.node_repl_cell_9.mjs:149:3
| Result | Check | Detail |
| --- | --- | --- |
| PASS | WEB-001 homepage renders login shell and captcha | title=LisgloSIPS - 聆界SIP管理平台, hasLogin=true |
| PASS | WEB-001 access token is not present in URL before login | https://100.90.90.91/ |
| PASS | WEB-001 admin login enters dashboard | url=https://100.90.90.91/ |
| PASS | WEB-001 access token is not present in URL after login | https://100.90.90.91/ |
| PASS | WEB-001 refresh restores authenticated dashboard | hasDashboard=true |
| FAIL | WEB-005 admin sees all non-pending core menus | missing=号码库 |
| FAIL | WEB-005 admin can switch available core pages without blank screen | failed=概览 Dashboard(textLength=604),客户管理(textLength=337),客户网关管理(textLength=346),业务前缀管理(textLength=353),充值记录(textLength=358),供应商管理(textLength=307),落地网关管理(textLength=376),落地线路组(textLength=310),当前通话(textLength=396),话单中心(textLength=773),质检中心(textLength=360),用户管理(textLength=575),角色与权限(textLength=541),操作日志(textLength=10683) |
| PASS | WEB-004 customer empty form stays on validation surface | modalBefore=true, modalAfter=true |
| FAIL | WEB-001 logout button is visible | 退出登录 count=0 |
| FAIL | WEB automation completed without unhandled exception | page.waitForSelector: Timeout 30000ms exceeded.
Call log:
 - waiting for locator('input[autocomplete="username"]') to be visible
|
## Screenshots
- Admin dashboard: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060555Z_admin-dashboard.png
- Low-privilege nav: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060555Z_low-nav.png
- Logout/login page: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060555Z_logout.png
## Notes
- CAPTCHA was read from the page image data URL with explicit user permission for this test run.
- Initial unauthenticated /auth/refresh 401 console noise and navigation-cancelled net::ERR_ABORTED requests were excluded from console/network health because they are expected during login and rapid page switching.
- WEB-002 forced API 500/network-failure states and WEB-003 dedicated empty-data states were not injected against B to avoid disturbing the shared service.
- WEB-006 was checked read-only from the served homepage; no release switch or deployment mutation was performed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

@@ -0,0 +1,43 @@
# Remote Web UI Test Report
Date: 2026-06-29T06:10:12.850Z
Base URL: https://100.90.90.91
Browser path: Browser plugin attempted first; fallback to local Chrome Playwright because in-app browser stopped at net::ERR_CERT_AUTHORITY_INVALID for the B HTTPS certificate.
Viewport: 1440x900
## Findings
- Admin menu is missing expected core entries: Missing: 号码库.
| Result | Check | Detail |
| --- | --- | --- |
| PASS | WEB-001 homepage renders login shell and captcha | title=LisgloSIPS - 聆界SIP管理平台 |
| PASS | WEB-001 access token is not present in URL before login | https://100.90.90.91/ |
| PASS | WEB-001 admin login enters dashboard | url=https://100.90.90.91/ |
| PASS | WEB-001 access token is not present in URL after login | https://100.90.90.91/ |
| PASS | WEB-001 refresh restores authenticated dashboard | hasDashboard=true |
| FAIL | WEB-005 admin sees all non-pending core menus | missing=号码库 |
| FAIL | WEB-005 admin can switch available core pages without blank screen | failed=概览 Dashboard(blank/login),客户管理(blank/login),客户网关管理(blank/login),业务前缀管理(blank/login),充值记录(blank/login),供应商管理(blank/login),落地网关管理(blank/login),落地线路组(blank/login),当前通话(blank/login),话单中心(blank/login),质检中心(blank/login),用户管理(blank/login),角色与权限(blank/login),操作日志(blank/login) |
| FAIL | WEB-002 navigation does not show API unavailable state | pages=业务前缀管理 |
| PASS | WEB-004 customer empty form stays on validation surface | modalBefore=true, modalAfter=true |
| PASS | WEB-001 logout button is visible | 退出 count=1 |
| PASS | WEB-001 logout returns to login and clears access token | tokenCleared=true |
| PASS | WEB-005 low-privilege menu is permission-pruned | visibleForbidden=none, labels=概概览 Dashboard/退出/刷新指标 |
| PASS | WEB-002 low-privilege dashboard does not show bulk 403 error state | hasApiError=false, hasNoPermission=false |
| PASS | WEB-006 HTTPS homepage returns HTML with hashed assets | status=200, assets=/assets/index-CBRZZA7t.js,/assets/index-B2uYBtS3.css |
| PASS | WEB-006 homepage does not reference access tokens or env files | bytes=422 |
| PASS | WEB console has no relevant error or warning entries | none |
| PASS | WEB network has no relevant failed requests | none |
## Screenshots
- Admin dashboard: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060852Z_admin-dashboard.png
- Low-privilege nav: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060852Z_low-nav.png
- Logout/login page: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060852Z_logout.png
## Notes
- CAPTCHA was read from the page image data URL with explicit user permission for this test run.
- Initial unauthenticated /auth/refresh 401 console noise and navigation-cancelled net::ERR_ABORTED requests were excluded from console/network health because they are expected during login and rapid page switching.
- WEB-002 forced API 500/network-failure states and WEB-003 dedicated empty-data states were not injected against B to avoid disturbing the shared service.
- WEB-006 was checked read-only from the served homepage; no release switch or deployment mutation was performed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,43 @@
# Remote Web UI Test Report
Date: 2026-06-29T06:12:23.366Z
Base URL: https://100.90.90.91
Browser path: Browser plugin attempted first; fallback to local Chrome Playwright because in-app browser stopped at net::ERR_CERT_AUTHORITY_INVALID for the B HTTPS certificate.
Viewport: 1440x900
## Findings
- Admin menu is missing expected core entries: Missing: 号码库.
| Result | Check | Detail |
| --- | --- | --- |
| PASS | WEB-001 homepage renders login shell and captcha | title=LisgloSIPS - 聆界SIP管理平台 |
| PASS | WEB-001 access token is not present in URL before login | https://100.90.90.91/ |
| PASS | WEB-001 admin login enters dashboard | url=https://100.90.90.91/ |
| PASS | WEB-001 access token is not present in URL after login | https://100.90.90.91/ |
| PASS | WEB-001 refresh restores authenticated dashboard | hasDashboard=true |
| FAIL | WEB-005 admin sees all non-pending core menus | missing=号码库 |
| PASS | WEB-005 admin can switch available core pages without blank screen | failed=none |
| PASS | WEB-002 navigation does not show API unavailable state | pages=none |
| PASS | WEB-004 customer empty form stays on validation surface | modalBefore=true, modalAfter=true |
| PASS | WEB-001 logout button is visible | 退出 count=1 |
| PASS | WEB-001 logout returns to login and clears access token | tokenCleared=true |
| PASS | WEB-005 low-privilege menu is permission-pruned | visibleForbidden=none, labels=概概览 Dashboard/退出/刷新指标 |
| PASS | WEB-002 low-privilege dashboard does not show bulk 403 error state | hasApiError=false, hasNoPermission=false |
| PASS | WEB-006 HTTPS homepage returns HTML with hashed assets | status=200, assets=/assets/index-CBRZZA7t.js,/assets/index-B2uYBtS3.css |
| PASS | WEB-006 homepage does not reference access tokens or env files | bytes=422 |
| PASS | WEB console has no relevant error or warning entries | none |
| PASS | WEB network has no relevant failed requests | none |
## Screenshots
- Admin dashboard: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T061117Z_admin-dashboard.png
- Low-privilege nav: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T061117Z_low-nav.png
- Logout/login page: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T061117Z_logout.png
## Notes
- CAPTCHA was read from the page image data URL with explicit user permission for this test run.
- Initial unauthenticated /auth/refresh 401 console noise and navigation-cancelled net::ERR_ABORTED requests were excluded from console/network health because they are expected during login and rapid page switching.
- WEB-002 forced API 500/network-failure states and WEB-003 dedicated empty-data states were not injected against B to avoid disturbing the shared service.
- WEB-006 was checked read-only from the served homepage; no release switch or deployment mutation was performed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

@@ -0,0 +1,57 @@
# Remote Web UI Smoke Test Report
Date: 2026-06-29T10:04:19.835Z
Base URL: https://100.90.90.91
Username: admin
Headless: true
Browser Channel: chrome
| Result | Check | Detail |
| --- | --- | --- |
| PASS | API login succeeds for browser smoke | status=200, captchaLength=5, permissionCount=26 |
| PASS | UI login succeeds before menu smoke | captchaLength=5 |
| PASS | 概览 Dashboard renders without browser errors | textLength=500, api-ok, authenticated, errors=0 |
| PASS | 概览 Dashboard action 刷新 works without browser errors | errors=0 |
| PASS | 客户管理 renders without browser errors | textLength=190, api-ok, authenticated, errors=0 |
| PASS | 客户管理 safe action is available | no visible enabled action among 编辑; skipped |
| PASS | 客户网关管理 renders without browser errors | textLength=536, api-ok, authenticated, errors=0 |
| FAIL | 客户网关管理 action 编辑 works without browser errors | errors=Error: Minified React error #137; visit https://reactjs.org/docs/error-decoder.html?invariant=137&args[]=input for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
at ws (https://100.90.90.91/assets/index-D90saQl_.js:37:7909)
at Wp (https://100.90.90.91/assets/index-D90saQl_.js:40:17537)
at kd (https://100.90.90.91/assets/index-D90saQl_.js:40:40074)
at wd (https://100.90.90.91/assets/index-D90saQl_.js:40:39827)
at Zp (https://100.90.90.91/assets/index-D90saQl_.js:40:39694)
at ca (https://100.90.90.91/assets/index-D90saQl_.js:40:39547)
at ti (https://100.90.90.91/assets/index-D90saQl_.js:40:35914)
at ou (https://100.90.90.91/assets/index-D90saQl_.js:40:36717)
at Rn (https://100.90.90.91/assets/index-D90saQl_.js:38:3274)
at https://100.90.90.91/assets/index-D90saQl_.js:40:34246 \|\| Error: Minified React error #137; visit https://reactjs.org/docs/error-decoder.html?invariant=137&args[]=input for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
at ws (https://100.90.90.91/assets/index-D90saQl_.js:37:7909)
at Wp (https://100.90.90.91/assets/index-D90saQl_.js:40:17537)
at kd (https://100.90.90.91/assets/index-D90saQl_.js:40:40074)
at wd (https://100.90.90.91/assets/index-D90saQl_.js:40:39827)
at Zp (https://100.90.90.91/assets/index-D90saQl_.js:40:39694)
at ca (https://100.90.90.91/assets/index-D90saQl_.js:40:39547)
at ti (https://100.90.90.91/assets/index-D90saQl_.js:40:35914)
at ou (https://100.90.90.91/assets/index-D90saQl_.js:40:36717)
at Rn (https://100.90.90.91/assets/index-D90saQl_.js:38:3274)
at https://100.90.90.91/assets/index-D90saQl_.js:40:34246 \|\| Minified React error #137; visit https://reactjs.org/docs/error-decoder.html?invariant=137&args[]=input for the full message or use the non-minified dev environment for full errors and additional helpful warnings. |
| FAIL | 业务前缀管理 menu is visible | menu button not found or not visible |
| FAIL | 充值记录 menu is visible | menu button not found or not visible |
| FAIL | 供应商管理 menu is visible | menu button not found or not visible |
| FAIL | 落地网关管理 menu is visible | menu button not found or not visible |
| FAIL | 落地线路组 menu is visible | menu button not found or not visible |
| FAIL | 号码库 menu is visible | menu button not found or not visible |
| FAIL | 当前通话 menu is visible | menu button not found or not visible |
| FAIL | 话单中心 menu is visible | menu button not found or not visible |
| FAIL | 质检中心 menu is visible | menu button not found or not visible |
| FAIL | 用户管理 menu is visible | menu button not found or not visible |
| FAIL | 角色与权限 menu is visible | menu button not found or not visible |
| FAIL | 操作日志 menu is visible | menu button not found or not visible |
## Guardrails
- Fails on browser `pageerror` and console `error` events.
- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.
- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.
- Password and token values are intentionally omitted.
@@ -0,0 +1,49 @@
# Remote Web UI Smoke Test Report
Date: 2026-06-29T10:19:11.811Z
Base URL: https://100.90.90.91
Username: admin
Headless: true
Browser Channel: chrome
| Result | Check | Detail |
| --- | --- | --- |
| PASS | API login succeeds for browser smoke | status=200, captchaLength=5, permissionCount=26 |
| PASS | UI login succeeds before menu smoke | captchaLength=5 |
| PASS | 概览 Dashboard renders without browser errors | textLength=500, api-ok, authenticated, errors=0 |
| PASS | 概览 Dashboard action 刷新 works without browser errors | errors=0 |
| PASS | 客户管理 renders without browser errors | textLength=190, api-ok, authenticated, errors=0 |
| PASS | 客户管理 safe action is available | no visible enabled action among 编辑; skipped |
| PASS | 客户网关管理 renders without browser errors | textLength=536, api-ok, authenticated, errors=0 |
| PASS | 客户网关管理 action 编辑 works without browser errors | errors=0 |
| PASS | 业务前缀管理 renders without browser errors | textLength=239, api-ok, authenticated, errors=0 |
| PASS | 业务前缀管理 action 编辑 works without browser errors | errors=0 |
| PASS | 充值记录 renders without browser errors | textLength=1110, api-ok, authenticated, errors=0 |
| PASS | 充值记录 safe action is available | no visible enabled action among 刷新; skipped |
| PASS | 供应商管理 renders without browser errors | textLength=296, api-ok, authenticated, errors=0 |
| PASS | 供应商管理 action 编辑 works without browser errors | errors=0 |
| PASS | 落地网关管理 renders without browser errors | textLength=492, api-ok, authenticated, errors=0 |
| PASS | 落地网关管理 action 编辑 works without browser errors | errors=0 |
| PASS | 落地线路组 renders without browser errors | textLength=200, api-ok, authenticated, errors=0 |
| PASS | 落地线路组 action 编辑 works without browser errors | errors=0 |
| PASS | 号码库 renders without browser errors | textLength=176, api-ok, authenticated, errors=0 |
| PASS | 号码库 action 刷新 works without browser errors | errors=0 |
| PASS | 当前通话 renders without browser errors | textLength=206, api-ok, authenticated, errors=0 |
| PASS | 当前通话 action 刷新通话 works without browser errors | errors=0 |
| PASS | 话单中心 renders without browser errors | textLength=6481, api-ok, authenticated, errors=0 |
| PASS | 话单中心 safe action is available | no visible enabled action among 查看详情; skipped |
| PASS | 质检中心 renders without browser errors | textLength=11656, api-ok, authenticated, errors=0 |
| PASS | 质检中心 action 刷新 works without browser errors | errors=0 |
| PASS | 用户管理 renders without browser errors | textLength=370, api-ok, authenticated, errors=0 |
| PASS | 用户管理 action 编辑 works without browser errors | errors=0 |
| PASS | 角色与权限 renders without browser errors | textLength=335, api-ok, authenticated, errors=0 |
| PASS | 角色与权限 action 编辑 works without browser errors | errors=0 |
| PASS | 操作日志 renders without browser errors | textLength=9746, api-ok, authenticated, errors=0 |
| PASS | 操作日志 action 查看详情 works without browser errors | errors=0 |
## Guardrails
- Fails on browser `pageerror` and console `error` events.
- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.
- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.
- Password and token values are intentionally omitted.
@@ -0,0 +1,49 @@
# Remote Web UI Smoke Test Report
Date: 2026-06-29T10:40:40.403Z
Base URL: https://100.90.90.91
Username: admin
Headless: true
Browser Channel: chrome
| Result | Check | Detail |
| --- | --- | --- |
| PASS | API login succeeds for browser smoke | status=200, captchaLength=5, permissionCount=26 |
| PASS | UI login succeeds before menu smoke | captchaLength=5 |
| PASS | 概览 Dashboard renders without browser errors | textLength=500, api-ok, authenticated, errors=0 |
| PASS | 概览 Dashboard action 刷新 works without browser errors | errors=0 |
| PASS | 客户管理 renders without browser errors | textLength=614, api-ok, authenticated, errors=0 |
| PASS | 客户管理 action 编辑 works without browser errors | errors=0 |
| PASS | 客户网关管理 renders without browser errors | textLength=480, api-ok, authenticated, errors=0 |
| PASS | 客户网关管理 action 编辑 works without browser errors | errors=0, business-prefix-checkbox-toggle-ok: false->true->false |
| PASS | 业务前缀管理 renders without browser errors | textLength=239, api-ok, authenticated, errors=0 |
| PASS | 业务前缀管理 action 编辑 works without browser errors | errors=0 |
| PASS | 充值记录 renders without browser errors | textLength=1110, api-ok, authenticated, errors=0 |
| PASS | 充值记录 safe action is available | no visible enabled action among 刷新; skipped |
| PASS | 供应商管理 renders without browser errors | textLength=296, api-ok, authenticated, errors=0 |
| PASS | 供应商管理 action 编辑 works without browser errors | errors=0 |
| PASS | 落地网关管理 renders without browser errors | textLength=492, api-ok, authenticated, errors=0 |
| PASS | 落地网关管理 action 编辑 works without browser errors | errors=0 |
| PASS | 落地线路组 renders without browser errors | textLength=200, api-ok, authenticated, errors=0 |
| PASS | 落地线路组 action 编辑 works without browser errors | errors=0 |
| PASS | 号码库 renders without browser errors | textLength=176, api-ok, authenticated, errors=0 |
| PASS | 号码库 action 刷新 works without browser errors | errors=0 |
| PASS | 当前通话 renders without browser errors | textLength=206, api-ok, authenticated, errors=0 |
| PASS | 当前通话 action 刷新通话 works without browser errors | errors=0 |
| PASS | 话单中心 renders without browser errors | textLength=6481, api-ok, authenticated, errors=0 |
| PASS | 话单中心 safe action is available | no visible enabled action among 查看详情; skipped |
| PASS | 质检中心 renders without browser errors | textLength=11656, api-ok, authenticated, errors=0 |
| PASS | 质检中心 action 刷新 works without browser errors | errors=0 |
| PASS | 用户管理 renders without browser errors | textLength=370, api-ok, authenticated, errors=0 |
| PASS | 用户管理 action 编辑 works without browser errors | errors=0 |
| PASS | 角色与权限 renders without browser errors | textLength=335, api-ok, authenticated, errors=0 |
| PASS | 角色与权限 action 编辑 works without browser errors | errors=0 |
| PASS | 操作日志 renders without browser errors | textLength=9746, api-ok, authenticated, errors=0 |
| PASS | 操作日志 action 查看详情 works without browser errors | errors=0 |
## Guardrails
- Fails on browser `pageerror` and console `error` events.
- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.
- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.
- Password and token values are intentionally omitted.
+16
View File
@@ -0,0 +1,16 @@
# Smoke Tests
Smoke tests should verify service startup, health checks, database connectivity, and later SIP/media-adjacent probes.
Runnable entry point:
```powershell
pnpm test:remote-smoke
```
Override the target with:
```powershell
$env:LISGLOSIPS_BASE_URL = 'https://100.90.90.91'
pnpm test:remote-smoke
```
+198
View File
@@ -0,0 +1,198 @@
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 timeoutMs = Number(process.env.LISGLOSIPS_SMOKE_TIMEOUT_MS || 30000);
const maxAttempts = Number(process.env.LISGLOSIPS_SMOKE_ATTEMPTS || 2);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
function requestUrlOnce(path, attempt) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const req = request(
url,
{
method: 'GET',
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers: {
Accept: 'application/json,text/html;q=0.9,*/*;q=0.8',
'User-Agent': 'lisglosips-remote-smoke/1.0',
},
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf8');
resolveRequest({
path,
url: url.toString(),
attempt,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
body,
});
});
}
);
req.on('timeout', () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('error', (error) => {
resolveRequest({
path,
url: url.toString(),
attempt,
ok: false,
statusCode: 0,
durationMs: Date.now() - startedAt,
contentType: '',
body: '',
error: error.message,
});
});
req.end();
});
}
async function requestUrl(path) {
let lastResult;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
lastResult = await requestUrlOnce(path, attempt);
if (lastResult.ok) {
return lastResult;
}
}
return lastResult;
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function expectStatus(result, expectedStatus) {
return {
name: `${result.path} returns ${expectedStatus}`,
pass: result.ok && result.statusCode === expectedStatus,
detail: result.error || `status=${result.statusCode}, duration=${result.durationMs}ms, attempt=${result.attempt}`,
};
}
function expectBody(result, name, predicate, detail) {
return {
name,
pass: result.ok && predicate(result),
detail: detail(result),
};
}
function reportLine(check) {
return `| ${check.pass ? 'PASS' : 'FAIL'} | ${check.name} | ${check.detail.replace(/\|/g, '\\|')} |`;
}
const checks = [];
const results = {
root: await requestUrl('/'),
live: await requestUrl('/api/v2/health/live'),
ready: await requestUrl('/api/v2/health/ready'),
captcha: await requestUrl('/api/v2/auth/captcha'),
};
checks.push(expectStatus(results.root, 200));
checks.push(
expectBody(
results.root,
'frontend HTML contains app root',
(result) => result.contentType.includes('text/html') && result.body.includes('<div id="root"></div>'),
(result) => `contentType=${result.contentType}, bytes=${Buffer.byteLength(result.body, 'utf8')}, attempt=${result.attempt}`
)
);
checks.push(expectStatus(results.live, 200));
checks.push(
expectBody(
results.live,
'live health reports ok',
(result) => {
const body = parseJson(result);
return body?.status === 'ok' && body?.service === 'api' && body?.checks?.process === 'ok';
},
(result) => `body=${JSON.stringify(parseJson(result))}`
)
);
checks.push(expectStatus(results.ready, 200));
checks.push(
expectBody(
results.ready,
'ready health reports database and redis ok',
(result) => {
const body = parseJson(result);
return body?.status === 'ok' && body?.checks?.database === 'ok' && body?.checks?.redis === 'ok';
},
(result) => `body=${JSON.stringify(parseJson(result))}`
)
);
checks.push(expectStatus(results.captcha, 200));
checks.push(
expectBody(
results.captcha,
'captcha endpoint returns id, image, and expiry',
(result) => {
const body = parseJson(result);
return typeof body?.captchaId === 'string' && body.imageDataUrl?.startsWith('data:image/svg+xml;base64,') && typeof body.expiresAt === 'string';
},
(result) => {
const body = parseJson(result);
return body ? `captchaId=${body.captchaId}, expiresAt=${body.expiresAt}, attempt=${result.attempt}` : 'body is not JSON';
}
)
);
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_SMOKE_${stamp}.md`);
const failed = checks.filter((check) => !check.pass);
const report = [
'# Remote Smoke Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Timeout: ${timeoutMs}ms`,
`Attempts: ${maxAttempts}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Raw Endpoints',
'',
...Object.values(results).map((result) => `- ${result.path}: status=${result.statusCode}, duration=${result.durationMs}ms, attempt=${result.attempt}, contentType=${result.contentType || 'n/a'}`),
'',
].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;
}
+3
View File
@@ -0,0 +1,3 @@
# Web Tests
Browser automation will be added after API fixtures and E2E helpers are stable.
+389
View File
@@ -0,0 +1,389 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium, request as playwrightRequest } from 'playwright';
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 timeoutMs = Number(process.env.LISGLOSIPS_WEB_UI_TIMEOUT_MS || 30000);
const stepTimeoutMs = Number(process.env.LISGLOSIPS_WEB_UI_STEP_TIMEOUT_MS || 10000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const headless = process.env.LISGLOSIPS_WEB_UI_HEADLESS !== '0';
const browserChannel = process.env.LISGLOSIPS_WEB_UI_CHANNEL || '';
const coreMenus = [
'概览 Dashboard',
'客户管理',
'客户网关管理',
'业务前缀管理',
'充值记录',
'供应商管理',
'落地网关管理',
'落地线路组',
'号码库',
'当前通话',
'话单中心',
'质检中心',
'用户管理',
'角色与权限',
'操作日志',
];
const safeActionsByMenu = {
'概览 Dashboard': ['刷新'],
'客户管理': ['编辑'],
'客户网关管理': ['编辑'],
'业务前缀管理': ['编辑'],
'充值记录': ['刷新'],
'供应商管理': ['编辑'],
'落地网关管理': ['编辑'],
'落地线路组': ['编辑'],
'号码库': ['刷新'],
'当前通话': ['刷新通话'],
'话单中心': ['查看详情'],
'质检中心': ['查看详情', '刷新'],
'用户管理': ['编辑'],
'角色与权限': ['编辑'],
'操作日志': ['查看详情'],
};
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
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 parseSetCookie(value) {
const firstCookie = Array.isArray(value) ? value[0] : String(value || '').split('\n')[0];
const [pair, ...attributes] = firstCookie.split(';').map((item) => item.trim());
const index = pair.indexOf('=');
if (index <= 0) {
return null;
}
const cookie = {
name: pair.slice(0, index),
value: pair.slice(index + 1),
url: baseUrl,
path: '/',
httpOnly: false,
secure: baseUrl.startsWith('https://'),
sameSite: 'Lax',
};
for (const attribute of attributes) {
const [rawName, rawValue] = attribute.split('=');
const name = rawName.toLowerCase();
if (name === 'path' && rawValue) cookie.path = rawValue;
if (name === 'httponly') cookie.httpOnly = true;
if (name === 'secure') cookie.secure = true;
if (name === 'samesite' && rawValue && ['Strict', 'Lax', 'None'].includes(rawValue)) cookie.sameSite = rawValue;
}
return cookie;
}
function reportLine(check) {
return `| ${check.pass ? 'PASS' : 'FAIL'} | ${check.name} | ${String(check.detail).replace(/\|/g, '\\|')} |`;
}
async function loginThroughUi(page) {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: timeoutMs });
const usernameInput = page.getByLabel('用户名');
const firstScreen = await Promise.race([
usernameInput.waitFor({ state: 'visible', timeout: timeoutMs }).then(() => 'login').catch(() => null),
page.getByText('当前页面').waitFor({ state: 'visible', timeout: timeoutMs }).then(() => 'app').catch(() => null),
]);
if (firstScreen === 'app') {
return { skipped: true, captchaCodeLength: 0 };
}
if (firstScreen !== 'login') {
throw new Error('Neither login form nor authenticated app shell became visible.');
}
const captchaImage = page.locator('.captcha-image img');
await captchaImage.waitFor({ state: 'visible', timeout: timeoutMs });
const captchaCode = decodeCaptcha(await captchaImage.getAttribute('src'));
await usernameInput.fill(username);
await page.getByLabel('密码').fill(password);
await page.getByLabel('图形验证码').fill(captchaCode);
await page.getByRole('button', { name: '登录' }).click();
await page.getByText('当前页面').waitFor({ state: 'visible', timeout: timeoutMs });
return { skipped: false, captchaCodeLength: captchaCode.length };
}
async function loginByApi() {
const api = await playwrightRequest.newContext({
baseURL: baseUrl,
ignoreHTTPSErrors: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED !== '1',
extraHTTPHeaders: {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-web-ui-smoke/1.0',
},
});
try {
const captchaResponse = await api.get('/api/v2/auth/captcha', { timeout: timeoutMs });
const captcha = await captchaResponse.json();
const captchaCode = decodeCaptcha(captcha.imageDataUrl);
const loginResponse = await api.post('/api/v2/auth/login', {
timeout: timeoutMs,
data: {
username,
password,
captchaId: captcha.captchaId,
captchaCode,
},
});
const loginBody = await loginResponse.json().catch(() => null);
return {
ok: loginResponse.ok(),
status: loginResponse.status(),
accessToken: loginBody?.accessToken,
user: loginBody?.user,
refreshCookie: parseSetCookie(loginResponse.headers()['set-cookie']),
captchaCodeLength: captchaCode.length,
};
} finally {
await api.dispose();
}
}
async function checkMenu(page, label) {
const beforeErrors = errorEvents.length;
const button = page.locator('button').filter({ hasText: label }).first();
const found = await button.isVisible({ timeout: stepTimeoutMs }).catch(() => false);
if (!found) {
return {
name: `${label} menu is visible`,
pass: false,
detail: 'menu button not found or not visible',
};
}
await button.click();
await page.waitForLoadState('networkidle', { timeout: stepTimeoutMs }).catch(() => {});
await page.getByText(label, { exact: true }).first().waitFor({ state: 'visible', timeout: stepTimeoutMs }).catch(() => {});
await page.waitForTimeout(300);
const bodyText = await page.locator('body').innerText({ timeout: stepTimeoutMs });
const afterErrors = errorEvents.slice(beforeErrors);
const visibleTextLength = bodyText.replace(/\s+/g, '').length;
const hasApiUnavailable = bodyText.includes('API 数据不可用');
const isLoginScreen = bodyText.includes('请输入用户名') && bodyText.includes('图形验证码');
return {
name: `${label} renders without browser errors`,
pass: afterErrors.length === 0 && visibleTextLength > 80 && !hasApiUnavailable && !isLoginScreen,
detail: [
`textLength=${visibleTextLength}`,
hasApiUnavailable ? 'api-unavailable' : 'api-ok',
isLoginScreen ? 'login-screen' : 'authenticated',
afterErrors.length ? `errors=${afterErrors.map((item) => item.message).join(' || ')}` : 'errors=0',
].join(', '),
};
}
async function closeOverlay(page) {
const closeButtons = [
page.getByRole('button', { name: '取消' }),
page.getByRole('button', { name: '关闭' }),
page.getByRole('button', { name: '收起' }),
];
for (const closeButton of closeButtons) {
const count = await closeButton.count().catch(() => 0);
if (count > 0 && await closeButton.first().isVisible().catch(() => false)) {
await closeButton.first().click().catch(() => {});
await page.waitForTimeout(200);
return;
}
}
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(200);
}
async function verifyCustomerGatewayPrefixToggle(page) {
const modalVisible = await page.getByRole('dialog', { name: '编辑客户网关' }).isVisible({ timeout: stepTimeoutMs }).catch(() => false);
if (!modalVisible) {
return 'customer-gateway-edit-modal-not-visible';
}
const calleeMode = page.locator('select').filter({ has: page.locator('option[value="BUSINESS_PREFIXES"]') }).first();
const canSelectMode = await calleeMode.isVisible({ timeout: stepTimeoutMs }).catch(() => false);
if (!canSelectMode) {
return 'business-prefix-mode-select-not-visible';
}
await calleeMode.selectOption('BUSINESS_PREFIXES');
await page.waitForTimeout(200);
const firstPrefixInput = page.locator('.checkbox-grid input[type="checkbox"]').first();
const hasPrefix = await firstPrefixInput.count().catch(() => 0);
if (!hasPrefix) {
return 'no-business-prefix-option; skipped';
}
const firstPrefixLabel = page.locator('.checkbox-grid .ui-check').first();
const before = await firstPrefixInput.isChecked();
await firstPrefixLabel.click();
await page.waitForTimeout(100);
const afterFirstClick = await firstPrefixInput.isChecked();
await firstPrefixLabel.click();
await page.waitForTimeout(100);
const afterSecondClick = await firstPrefixInput.isChecked();
if (afterFirstClick === before || afterSecondClick !== before) {
throw new Error(`customer gateway prefix checkbox did not toggle correctly: before=${before}, afterFirst=${afterFirstClick}, afterSecond=${afterSecondClick}`);
}
return `business-prefix-checkbox-toggle-ok: ${before}->${afterFirstClick}->${afterSecondClick}`;
}
async function checkSafeAction(page, label) {
const actions = safeActionsByMenu[label] || [];
if (!actions.length) {
return {
name: `${label} safe action is configured`,
pass: true,
detail: 'no action configured',
};
}
for (const action of actions) {
const locator = page.getByRole('button', { name: action }).first();
const count = await locator.count().catch(() => 0);
const visible = count > 0 && await locator.isVisible().catch(() => false);
const enabled = visible && await locator.isEnabled().catch(() => false);
if (!enabled) {
continue;
}
const beforeErrors = errorEvents.length;
await locator.click();
await page.waitForLoadState('networkidle', { timeout: stepTimeoutMs }).catch(() => {});
await page.waitForTimeout(500);
const actionDetails = [];
if (label === '客户网关管理' && action === '编辑') {
actionDetails.push(await verifyCustomerGatewayPrefixToggle(page));
}
const bodyText = await page.locator('body').innerText({ timeout: stepTimeoutMs });
const afterErrors = errorEvents.slice(beforeErrors);
await closeOverlay(page);
return {
name: `${label} action ${action} works without browser errors`,
pass: afterErrors.length === 0 && !bodyText.includes('API 数据不可用'),
detail: [
afterErrors.length ? `errors=${afterErrors.map((item) => item.message).join(' || ')}` : 'errors=0',
...actionDetails,
].join(', '),
};
}
return {
name: `${label} safe action is available`,
pass: true,
detail: `no visible enabled action among ${actions.join('/')}; skipped`,
};
}
const checks = [];
const errorEvents = [];
const login = await loginByApi();
checks.push({
name: 'API login succeeds for browser smoke',
pass: login.ok && typeof login.accessToken === 'string' && Boolean(login.refreshCookie),
detail: `status=${login.status}, captchaLength=${login.captchaCodeLength}, permissionCount=${Array.isArray(login.user?.permissions) ? login.user.permissions.length : 0}`,
});
let browser;
let context;
if (checks[0].pass) {
browser = await chromium.launch({ headless, ...(browserChannel ? { channel: browserChannel } : {}) });
context = await browser.newContext({
baseURL: baseUrl,
ignoreHTTPSErrors: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED !== '1',
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.on('console', (message) => {
if (message.type() === 'error') {
errorEvents.push({ source: 'console', message: message.text() });
}
});
page.on('pageerror', (error) => {
errorEvents.push({ source: 'pageerror', message: error.message });
});
const uiLogin = await loginThroughUi(page);
checks.push({
name: 'UI login succeeds before menu smoke',
pass: uiLogin.skipped || uiLogin.captchaCodeLength >= 4,
detail: uiLogin.skipped ? 'already authenticated' : `captchaLength=${uiLogin.captchaCodeLength}`,
});
for (const label of coreMenus) {
console.log(`CHECK ${label}`);
const menuCheck = await checkMenu(page, label);
checks.push(menuCheck);
if (menuCheck.pass) {
console.log(`ACTION ${label}`);
checks.push(await checkSafeAction(page, label));
}
}
}
await context?.close();
await browser?.close();
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_WEB_UI_SMOKE_${stamp}.md`);
const failed = checks.filter((check) => !check.pass);
const report = [
'# Remote Web UI Smoke Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Headless: ${headless}`,
`Browser Channel: ${browserChannel || 'playwright-default'}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Guardrails',
'',
'- Fails on browser `pageerror` and console `error` events.',
'- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.',
'- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.',
'- Password and token values are intentionally omitted.',
'',
].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;
}