diff --git a/apps/web/src/pages/CustomersPage.jsx b/apps/web/src/pages/CustomersPage.jsx
index 9487e8e..b24a5aa 100644
--- a/apps/web/src/pages/CustomersPage.jsx
+++ b/apps/web/src/pages/CustomersPage.jsx
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
-import { gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function CustomersPage({ customerRows, setCustomerRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateCustomer, onUpdateCustomer, onToggleCustomerStatus, onDeleteCustomer, onRechargeCustomer }) {
diff --git a/apps/web/src/pages/NumberLibraryPage.jsx b/apps/web/src/pages/NumberLibraryPage.jsx
index 20b73f7..c2c3e48 100644
--- a/apps/web/src/pages/NumberLibraryPage.jsx
+++ b/apps/web/src/pages/NumberLibraryPage.jsx
@@ -4,6 +4,50 @@ import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, SimpleTable } from '
import { formatDate, zhStatus, carrierLabel } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
+const numberLibraryTabs = [
+ { value: 'cities', label: '地级市字典' },
+ { value: 'phoneSegments', label: '手机号码库' },
+ { value: 'areaCodes', label: '城市区号' },
+ { value: 'carrierPrefixRules', label: '运营商号码段规则' },
+];
+
+const numberLibraryImportExamples = {
+ cities: [
+ { code: '340100', provinceCode: '340000', provinceName: '安徽省', cityName: '合肥市', cityLevel: 'PREFECTURE' },
+ ],
+ phoneSegments: [
+ { segment7: '1380013', provinceName: '北京市', cityCode: '110100', cityName: '北京市', carrier: 'MOBILE' },
+ ],
+ areaCodes: [
+ { areaCode: '0551', provinceName: '安徽省', cityCode: '340100', cityName: '合肥市' },
+ ],
+ carrierPrefixRules: [
+ { prefix: '138', carrier: 'MOBILE', priority: 100 },
+ ],
+};
+
+const emptyNumberLibraryRows = {
+ cities: [],
+ phoneSegments: [],
+ areaCodes: [],
+ carrierPrefixRules: [],
+};
+
+const emptyNumberLibraryTotals = {
+ cities: 0,
+ phoneSegments: 0,
+ areaCodes: 0,
+ carrierPrefixRules: 0,
+};
+
+function normalizeNumberLibraryList(payload, mapItem) {
+ const items = Array.isArray(payload?.items) ? payload.items : [];
+ return {
+ rows: items.map(mapItem),
+ total: payload?.total ?? items.length,
+ };
+}
+
export function NumberLibraryPage({ can = () => true }) {
const [activeTab, setActiveTab] = useState('cities');
const [rows, setRows] = useState(emptyNumberLibraryRows);
diff --git a/apps/web/src/pages/OperationLogsPage.jsx b/apps/web/src/pages/OperationLogsPage.jsx
index 79e55eb..26b1152 100644
--- a/apps/web/src/pages/OperationLogsPage.jsx
+++ b/apps/web/src/pages/OperationLogsPage.jsx
@@ -3,6 +3,10 @@ import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Drawer, SimpleTable, KeyValue } from '../components/layout.jsx';
import { operationLogRows } from '../fixtures/devFixtures.js';
+function toneForStatus(status) {
+ return status === '成功' || status === 'SUCCESS' ? 'success' : status === '失败' || status === 'FAILURE' ? 'danger' : 'neutral';
+}
+
export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiError, refreshApi }) {
const [keyword, setKeyword] = useState('');
const [moduleFilter, setModuleFilter] = useState('all');
@@ -64,4 +68,3 @@ export function OperationLogsPage({ logRows = operationLogRows, apiLoading, apiE
>
);
}
-
diff --git a/apps/web/src/pages/RoutesPage.jsx b/apps/web/src/pages/RoutesPage.jsx
index a5f013f..d990580 100644
--- a/apps/web/src/pages/RoutesPage.jsx
+++ b/apps/web/src/pages/RoutesPage.jsx
@@ -1,6 +1,6 @@
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, SimpleTable } from '../components/layout.jsx';
-import { customers, gateways, customerGatewayPolicies, vendorGatewayPolicies, routeGroups, routeRules } from '../fixtures/devFixtures.js';
+import { customerGatewayPolicies, vendorGatewayPolicies, routeGroups, routeRules } from '../fixtures/devFixtures.js';
export function RoutesPage() {
return (
@@ -57,4 +57,3 @@ export function RoutesPage() {
>
);
}
-
diff --git a/apps/web/src/pages/SettingsPage.jsx b/apps/web/src/pages/SettingsPage.jsx
index 0bee800..03d9194 100644
--- a/apps/web/src/pages/SettingsPage.jsx
+++ b/apps/web/src/pages/SettingsPage.jsx
@@ -34,6 +34,3 @@ export function SettingsPage() {
);
}
-const emptyUserForm = { username: '', name: '', phone: '', email: '', roleId: 'R002', status: '启用' };
-const emptyRoleForm = { name: '', description: '', status: '启用' };
-
diff --git a/apps/web/src/pages/SipOpsPage.jsx b/apps/web/src/pages/SipOpsPage.jsx
index 10ee540..1d8b122 100644
--- a/apps/web/src/pages/SipOpsPage.jsx
+++ b/apps/web/src/pages/SipOpsPage.jsx
@@ -1,4 +1,4 @@
-import { Badge, Button, Progress } from '../components/ui.jsx';
+import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, StatusBadge, SimpleTable } from '../components/layout.jsx';
import { sipAccounts, opsItems } from '../fixtures/devFixtures.js';
@@ -22,4 +22,3 @@ export function SipOpsPage() {
>
);
}
-
diff --git a/apps/web/src/pages/VendorLineGroupsPage.jsx b/apps/web/src/pages/VendorLineGroupsPage.jsx
index e692659..a5f5898 100644
--- a/apps/web/src/pages/VendorLineGroupsPage.jsx
+++ b/apps/web/src/pages/VendorLineGroupsPage.jsx
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Badge, Button, Field, Input, Select } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, SimpleTable } from '../components/layout.jsx';
-import { formatDate, zhStatus } from '../utils/formatters.js';
import { gateways, vendorLineGroups } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
@@ -183,18 +182,3 @@ export function VendorLineGroupsPage({ lineGroupRows: apiLineGroupRows, setLineG
>
);
}
-
-const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' };
-
-function normalizeBusinessPrefix(item) {
- return {
- id: item.id,
- prefix: item.prefix,
- name: item.name,
- description: item.description || '-',
- priority: item.priority ?? 100,
- status: zhStatus(item.status),
- gatewayCount: item.gatewayCount ?? 0,
- createdAt: formatDate(item.createdAt),
- };
-}
diff --git a/apps/web/src/pages/VendorsPage.jsx b/apps/web/src/pages/VendorsPage.jsx
index 38b1838..6b470ff 100644
--- a/apps/web/src/pages/VendorsPage.jsx
+++ b/apps/web/src/pages/VendorsPage.jsx
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Button, Field, Input, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable, KeyValue } from '../components/layout.jsx';
-import { gateways } from '../fixtures/devFixtures.js';
import { explainApiError } from '../api.js';
export function VendorsPage({ vendorRows, setVendorRows, addRechargeRecord, apiLoading, apiError, refreshApi, can = () => true, onCreateVendor, onUpdateVendor, onDeleteVendor, onRechargeVendor }) {
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index f125cf6..5ba7225 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -156,13 +156,48 @@ button:disabled {
.brand-block {
display: grid;
- grid-template-columns: 38px minmax(0, 1fr) 34px;
+ grid-template-columns: minmax(0, 1fr) 34px;
align-items: center;
- gap: 6px;
+ gap: 10px;
padding: 16px 12px;
border-bottom: 1px solid var(--line);
}
+.brand-logo {
+ display: block;
+ object-fit: contain;
+ min-width: 0;
+}
+
+.brand-expanded {
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+ gap: 8px;
+}
+
+.brand-logo-expanded {
+ width: min(100%, 152px);
+ flex: 0 1 auto;
+ height: 38px;
+ object-position: left center;
+}
+
+.brand-sip-text {
+ flex: 0 0 auto;
+ color: var(--brand);
+ font-size: 19px;
+ font-weight: 900;
+ line-height: 1;
+ letter-spacing: 0;
+}
+
+.brand-logo-collapsed {
+ display: none;
+ width: 38px;
+ height: 38px;
+}
+
.brand-mark {
display: grid;
place-items: center;
@@ -175,7 +210,7 @@ button:disabled {
}
.brand-copy {
- display: grid;
+ display: none;
min-width: 0;
gap: 2px;
}
@@ -296,6 +331,18 @@ button:disabled {
padding: 14px 10px;
}
+.sidebar-collapsed .brand-logo-expanded {
+ display: none;
+}
+
+.sidebar-collapsed .brand-expanded {
+ display: none;
+}
+
+.sidebar-collapsed .brand-logo-collapsed {
+ display: block;
+}
+
.sidebar-collapsed .brand-copy,
.sidebar-collapsed .nav-group p,
.sidebar-collapsed .nav-label,
@@ -862,6 +909,60 @@ button:disabled {
gap: 8px;
}
+.prefix-picker {
+ display: grid;
+ gap: 10px;
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--surface-muted);
+}
+
+.prefix-picker-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 750;
+}
+
+.prefix-picker-actions {
+ display: inline-flex;
+ gap: 8px;
+}
+
+.checkbox-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 10px 14px;
+}
+
+.checkbox-grid .ui-check {
+ width: 100%;
+ min-width: 0;
+ padding: 8px 10px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--surface);
+}
+
+.checkbox-grid .ui-check > span:last-child {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.empty-inline {
+ padding: 12px;
+ color: var(--muted);
+ border: 1px dashed var(--line);
+ border-radius: 6px;
+ background: var(--surface);
+}
+
.mini-chart {
display: grid;
grid-template-columns: repeat(12, 1fr);
@@ -1680,12 +1781,23 @@ button:disabled {
}
.sidebar-collapsed .brand-block {
- grid-template-columns: 38px minmax(0, 1fr) 34px;
+ grid-template-columns: minmax(0, 1fr) 34px;
justify-items: stretch;
padding: 12px;
}
- .sidebar-collapsed .brand-copy,
+ .sidebar-collapsed .brand-logo-expanded {
+ display: block;
+ }
+
+ .sidebar-collapsed .brand-expanded {
+ display: inline-flex;
+ }
+
+ .sidebar-collapsed .brand-logo-collapsed {
+ display: none;
+ }
+
.sidebar-collapsed .nav-label {
display: grid;
}
diff --git a/apps/web/src/utils/formatters.js b/apps/web/src/utils/formatters.js
index 1b8f479..8a19e21 100644
--- a/apps/web/src/utils/formatters.js
+++ b/apps/web/src/utils/formatters.js
@@ -1,6 +1,4 @@
-const selectedBlue = '#2563EB';
-
function formatCurrency(value, digits = 2) {
const numeric = Number(value || 0);
return `¥${numeric.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits })}`;
diff --git a/docs/TEST_PLAN_AND_CASES.md b/docs/TEST_PLAN_AND_CASES.md
index 8164653..874edd8 100644
--- a/docs/TEST_PLAN_AND_CASES.md
+++ b/docs/TEST_PLAN_AND_CASES.md
@@ -1637,8 +1637,8 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
| 目的 | 验证核心菜单逐页切换无空白、标题正确、权限裁剪有效。 |
| 前置条件 | 管理员和只读账号可登录。 |
| 测试数据 | 全部核心菜单:Dashboard、当前通话、客户、网关、业务前缀、充值、供应商、落地、线路组、号码库、话单、质检、用户、角色、操作日志。 |
-| 步骤 | 1. 管理员逐个点击菜单。2. 记录 h1/页面标题。3. 只读账号登录检查菜单。4. 浏览器刷新每个关键页面。 |
-| 预期结果 | 每页标题正确;无 runtime error;权限菜单和按钮符合权限矩阵;刷新不丢状态。 |
+| 步骤 | 1. 执行 `pnpm test:remote-web-ui`,由 Playwright 使用管理员账号登录并逐个点击核心菜单。2. 每个页面加载后点击一个安全主操作(如编辑、查看详情、刷新)以覆盖弹窗/抽屉渲染。3. 记录页面标题、可见文本长度、console error 和 pageerror。4. 只读账号登录检查菜单。5. 浏览器刷新每个关键页面。 |
+| 预期结果 | 每页标题正确;菜单切换和安全主操作均无 runtime error;无空白页;不出现 `API 数据不可用`;权限菜单和按钮符合权限矩阵;刷新不丢状态;任一 console error/pageerror 均判失败。 |
| 数据检查 | 只请求当前权限允许的全局 API,避免批量 403。 |
| 安全检查 | 手动输入无权限路由显示无权限或跳转。 |
@@ -1650,8 +1650,8 @@ corepack pnpm@10.33.0 exec vitest run apps/worker-recording/src/transfer.spec.ts
| 目的 | 验证前端 build 产物被正确发布到 B 当前 release,Nginx 首页引用新资源。 |
| 前置条件 | 本地 build 成功;B 当前 release 明确;有回滚点。 |
| 测试数据 | 新 JS/CSS hash。 |
-| 步骤 | 1. 执行 build。2. 发布 dist。3. `curl -k https://127.0.0.1/` 检查资源引用。4. 浏览器强刷首页。 |
-| 预期结果 | 首页引用新 JS/CSS;旧资源不被引用;Nginx 返回 200;页面可登录。 |
+| 步骤 | 1. 执行 `pnpm lint`,确认 `apps/web/src/**` 前端源码未出现未定义标识。2. 执行 build。3. 发布 dist。4. `curl -k https://127.0.0.1/` 检查资源引用。5. 执行 `pnpm test:remote-web-ui`。6. 浏览器强刷首页。 |
+| 预期结果 | 前端 lint 覆盖源码并通过;首页引用新 JS/CSS;旧资源不被引用;Nginx 返回 200;页面可登录;核心菜单逐页切换无空白、无 console error/pageerror。 |
| 数据检查 | release 目录和 public 目录一致。 |
| 安全检查 | 发布不覆盖后端 env、node_modules 或用户上传录音。 |
diff --git a/eslint.config.mjs b/eslint.config.mjs
index df75c12..71b91f2 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -11,7 +11,6 @@ export default [
'**/dist/**',
'**/vendor/**',
'**/*.tsbuildinfo',
- 'apps/web/src/**',
'apps/web/dist/**',
'.codex-private/**'
]
@@ -31,5 +30,21 @@ export default [
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }]
}
+ },
+ {
+ files: ['apps/web/src/**/*.{js,jsx}'],
+ languageOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true
+ }
+ },
+ globals: {
+ ...globals.browser,
+ ...globals.es2022
+ }
+ }
}
];
diff --git a/infra/ops/startup/lisglosips-start-a.sh b/infra/ops/startup/lisglosips-start-a.sh
new file mode 100644
index 0000000..5305d3c
--- /dev/null
+++ b/infra/ops/startup/lisglosips-start-a.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+ exec sudo "$0" "$@"
+fi
+
+echo "== LisgloSIPS A startup =="
+hostname
+
+systemctl start lisglosips-redis-hotpath-load.service
+systemctl start \
+ opensips \
+ rtpengine-daemon \
+ rtpengine-recording-daemon \
+ lisglosips-redis-auth-proxy \
+ lisglosips-node-exporter \
+ lisglosips-recording-finalize.timer
+
+opensips -C -f /etc/opensips/opensips.cfg >/tmp/lisglosips-opensips-check.log
+
+echo "== services =="
+systemctl is-active \
+ opensips \
+ rtpengine-daemon \
+ rtpengine-recording-daemon \
+ lisglosips-redis-auth-proxy \
+ lisglosips-node-exporter \
+ lisglosips-recording-finalize.timer
+
+echo "== hotpath =="
+systemctl show -p Result lisglosips-redis-hotpath-load.service
+
+echo "== failed units =="
+systemctl --failed --no-pager
+
diff --git a/infra/ops/startup/lisglosips-start-b.sh b/infra/ops/startup/lisglosips-start-b.sh
new file mode 100644
index 0000000..9f11ff1
--- /dev/null
+++ b/infra/ops/startup/lisglosips-start-b.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+ exec sudo "$0" "$@"
+fi
+
+echo "== LisgloSIPS B startup =="
+hostname
+
+systemctl start \
+ mysql \
+ redis-server \
+ nginx \
+ heplify-server \
+ lisglosips-prometheus \
+ grafana-server
+
+systemctl start \
+ lisglosips@api \
+ lisglosips@cdr-worker \
+ lisglosips@recording-worker \
+ lisglosips@config-publisher
+
+nginx -t
+
+echo "== release =="
+readlink -f /opt/lisglosips/current
+
+echo "== services =="
+systemctl is-active \
+ mysql \
+ redis-server \
+ nginx \
+ lisglosips@api \
+ lisglosips@cdr-worker \
+ lisglosips@recording-worker \
+ lisglosips@config-publisher \
+ heplify-server \
+ lisglosips-prometheus \
+ grafana-server
+
+echo "== api ready =="
+curl -fsS http://127.0.0.1:3000/api/v2/health/ready
+echo
+
+echo "== preflight =="
+/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh
+
+echo "== failed units =="
+systemctl --failed --no-pager
+
diff --git a/infra/ops/startup/lisglosips-start-t.sh b/infra/ops/startup/lisglosips-start-t.sh
new file mode 100644
index 0000000..84bf29a
--- /dev/null
+++ b/infra/ops/startup/lisglosips-start-t.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [ "$(id -u)" -ne 0 ]; then
+ exec sudo "$0" "$@"
+fi
+
+echo "== LisgloSIPS T startup =="
+hostname
+
+systemctl reset-failed opensips || true
+systemctl start \
+ mariadb \
+ apache2 \
+ rtpengine-daemon \
+ rtpengine-recording-daemon \
+ opensips \
+ lisglosips-s28-uas
+
+opensips -C -f /etc/opensips/opensips.cfg >/tmp/lisglosips-t-opensips-check.log
+
+echo "== services =="
+systemctl is-active \
+ opensips \
+ rtpengine-daemon \
+ rtpengine-recording-daemon \
+ lisglosips-s28-uas \
+ apache2 \
+ mariadb
+
+echo "== failed units =="
+systemctl --failed --no-pager
+
diff --git a/logo/fav.ico b/logo/fav.ico
new file mode 100644
index 0000000..d3475ba
Binary files /dev/null and b/logo/fav.ico differ
diff --git a/logo/logo1.png b/logo/logo1.png
new file mode 100644
index 0000000..33fe077
Binary files /dev/null and b/logo/logo1.png differ
diff --git a/logo/logo2.png b/logo/logo2.png
new file mode 100644
index 0000000..eb2dd55
Binary files /dev/null and b/logo/logo2.png differ
diff --git a/package.json b/package.json
index cd09ffa..3de7607 100644
--- a/package.json
+++ b/package.json
@@ -37,11 +37,26 @@
"lint": "eslint .",
"typecheck": "tsc -b tsconfig.build.json --pretty",
"test": "vitest run",
+ "test:baseline": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm prisma:validate",
+ "test:api": "vitest run \"apps/api/src/**/*.e2e.spec.ts\" --hookTimeout=60000",
+ "test:all-local": "pnpm test:baseline",
+ "test:smoke": "node tests/smoke/remote-smoke.mjs",
+ "test:remote-smoke": "node tests/smoke/remote-smoke.mjs",
+ "test:remote-auth": "node tests/api/remote-auth-rbac.mjs",
+ "test:remote-customers": "node tests/api/remote-customers-balance.mjs",
+ "test:remote-gateways": "node tests/api/remote-customer-gateways-prefixes.mjs",
+ "test:remote-vendors": "node tests/api/remote-vendors-line-groups.mjs",
+ "test:remote-calls": "node tests/api/remote-calls-cdr-billing.mjs",
+ "test:remote-recordings": "node tests/api/remote-recordings-quality.mjs",
+ "test:remote-dashboard": "node tests/api/remote-dashboard-active-audit.mjs",
+ "test:remote-perf-security": "node tests/api/remote-performance-security.mjs",
+ "test:remote-web-ui": "node tests/web/remote-web-ui-smoke.mjs",
"ci": "node scripts/pnpm-run.mjs lint && node scripts/pnpm-run.mjs typecheck && node scripts/pnpm-run.mjs test && node scripts/pnpm-run.mjs build",
"release:artifact": "node scripts/build-release-artifact.mjs",
"prisma:generate": "prisma generate",
"prisma:validate": "cross-env DATABASE_URL=mysql://lisglosips_app@127.0.0.1:3306/lisglosips prisma validate",
- "db:seed": "prisma db seed"
+ "db:seed": "prisma db seed",
+ "db:seed:test": "cross-env DATABASE_URL=mysql://lisglosips_app@127.0.0.1:3306/lisglosips tsx prisma/seed-test.ts"
},
"devDependencies": {
"@eslint/js": "9.39.1",
@@ -51,10 +66,11 @@
"@types/supertest": "6.0.3",
"@typescript-eslint/eslint-plugin": "8.48.0",
"@typescript-eslint/parser": "8.48.0",
+ "cross-env": "10.1.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
- "cross-env": "10.1.0",
"globals": "16.5.0",
+ "playwright": "1.57.0",
"prisma": "6.19.0",
"supertest": "7.1.4",
"tsx": "4.20.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ec86335..a5929f4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
globals:
specifier: 16.5.0
version: 16.5.0
+ playwright:
+ specifier: 1.57.0
+ version: 1.57.0
prisma:
specifier: 6.19.0
version: 6.19.0(typescript@5.9.3)
@@ -1737,6 +1740,11 @@ packages:
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
engines: {node: '>=14.0.0'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2115,6 +2123,16 @@ packages:
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
+ playwright-core@1.57.0:
+ resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.57.0:
+ resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==}
+ engines: {node: '>=18'}
+ hasBin: true
+
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
@@ -3969,6 +3987,9 @@ snapshots:
dezalgo: 1.0.4
once: 1.4.0
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -4334,6 +4355,14 @@ snapshots:
exsolve: 1.0.8
pathe: 2.0.3
+ playwright-core@1.57.0: {}
+
+ playwright@1.57.0:
+ dependencies:
+ playwright-core: 1.57.0
+ optionalDependencies:
+ fsevents: 2.3.2
+
postcss@8.5.15:
dependencies:
nanoid: 3.3.14
diff --git a/prisma/migrations/20260629150000_number_library_builtin_role_permissions/migration.sql b/prisma/migrations/20260629150000_number_library_builtin_role_permissions/migration.sql
new file mode 100644
index 0000000..558c288
--- /dev/null
+++ b/prisma/migrations/20260629150000_number_library_builtin_role_permissions/migration.sql
@@ -0,0 +1,21 @@
+-- Backfill number-library RBAC rows for existing environments where the
+-- application seed was not rerun after the number library module landed.
+
+INSERT INTO `permissions` (`id`, `module`, `action`, `description`, `created_at`, `updated_at`)
+VALUES
+ ('number_library.view', 'number_library', 'view', '查看号码库', CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)),
+ ('number_library.manage', 'number_library', 'manage', '管理号码库', CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3))
+ON DUPLICATE KEY UPDATE
+ `module` = VALUES(`module`),
+ `action` = VALUES(`action`),
+ `description` = VALUES(`description`),
+ `updated_at` = CURRENT_TIMESTAMP(3);
+
+INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
+VALUES
+ ('ROLE_SUPER_ADMIN', 'number_library.view'),
+ ('ROLE_SUPER_ADMIN', 'number_library.manage'),
+ ('ROLE_OPERATOR', 'number_library.view'),
+ ('ROLE_OPERATOR', 'number_library.manage'),
+ ('ROLE_TECH_OPS', 'number_library.view'),
+ ('ROLE_TECH_OPS', 'number_library.manage');
diff --git a/prisma/seed-test.ts b/prisma/seed-test.ts
new file mode 100644
index 0000000..edfdc0a
--- /dev/null
+++ b/prisma/seed-test.ts
@@ -0,0 +1,844 @@
+import { createHash } from 'node:crypto';
+
+import { PrismaClient } from '@prisma/client';
+import { hashPasswordArgon2id } from '../packages/auth/src/index';
+
+const prisma = new PrismaClient();
+
+const ACTOR_ID = 'usr_test_admin';
+const TEST_PASSWORD = 'Test@123456';
+
+const permissions = [
+ 'dashboard.view',
+ 'active_calls.view',
+ 'active_calls.manage',
+ 'customers.view',
+ 'customers.manage',
+ 'customer_gateways.view',
+ 'customer_gateways.manage',
+ 'vendors.view',
+ 'vendors.manage',
+ 'vendor_gateways.view',
+ 'vendor_gateways.manage',
+ 'line_groups.view',
+ 'line_groups.manage',
+ 'number_library.view',
+ 'number_library.manage',
+ 'recharges.view',
+ 'recharges.manage',
+ 'cdr.view',
+ 'recordings.play',
+ 'quality.view',
+ 'quality.manage',
+ 'users.view',
+ 'users.manage',
+ 'roles.view',
+ 'roles.manage',
+ 'audit.view',
+] as const;
+
+const roles = [
+ { id: 'ROLE_TEST_ADMIN', name: '测试管理员', permissionIds: [...permissions] },
+ {
+ id: 'ROLE_TEST_VIEWER',
+ name: '测试只读员',
+ permissionIds: permissions.filter((permission) => permission.endsWith('.view') || permission === 'cdr.view'),
+ },
+ { id: 'ROLE_TEST_FINANCE', name: '测试财务员', permissionIds: ['customers.view', 'recharges.view', 'recharges.manage'] },
+ { id: 'ROLE_TEST_QUALITY', name: '测试质检员', permissionIds: ['quality.view', 'quality.manage', 'recordings.play', 'cdr.view'] },
+ {
+ id: 'ROLE_TEST_GATEWAY',
+ name: '测试客户网关员',
+ permissionIds: ['customers.view', 'customer_gateways.view', 'customer_gateways.manage', 'line_groups.view'],
+ },
+ {
+ id: 'ROLE_TEST_VENDOR',
+ name: '测试供应商线路员',
+ permissionIds: ['vendors.view', 'vendors.manage', 'vendor_gateways.view', 'vendor_gateways.manage', 'line_groups.view', 'line_groups.manage'],
+ },
+ { id: 'ROLE_TEST_ACTIVE', name: '测试话务员', permissionIds: ['active_calls.view', 'active_calls.manage'] },
+ { id: 'ROLE_TEST_AUDIT', name: '测试审计员', permissionIds: ['audit.view'] },
+] as const;
+
+const users = [
+ { id: ACTOR_ID, username: 'test.admin', displayName: '测试管理员', roleId: 'ROLE_TEST_ADMIN' },
+ { id: 'usr_test_viewer', username: 'test.viewer', displayName: '测试只读员', roleId: 'ROLE_TEST_VIEWER' },
+ { id: 'usr_test_fin', username: 'test.finance', displayName: '测试财务员', roleId: 'ROLE_TEST_FINANCE' },
+ { id: 'usr_test_quality', username: 'test.quality', displayName: '测试质检员', roleId: 'ROLE_TEST_QUALITY' },
+ { id: 'usr_test_gateway', username: 'test.gateway', displayName: '测试客户网关员', roleId: 'ROLE_TEST_GATEWAY' },
+ { id: 'usr_test_vendor', username: 'test.vendor', displayName: '测试供应商线路员', roleId: 'ROLE_TEST_VENDOR' },
+ { id: 'usr_test_active', username: 'test.active', displayName: '测试话务员', roleId: 'ROLE_TEST_ACTIVE' },
+ { id: 'usr_test_audit', username: 'test.audit', displayName: '测试审计员', roleId: 'ROLE_TEST_AUDIT' },
+] as const;
+
+function sipHa1(username: string, domain: string, password: string): string {
+ return createHash('md5').update(`${username}:${domain}:${password}`).digest('hex');
+}
+
+async function seedAuth() {
+ for (const id of permissions) {
+ const [module, action] = id.split('.');
+ await prisma.permission.upsert({
+ where: { id },
+ update: {},
+ create: {
+ id,
+ module,
+ action,
+ description: `测试权限 ${id}`,
+ },
+ });
+ }
+
+ for (const role of roles) {
+ await prisma.role.upsert({
+ where: { id: role.id },
+ update: {
+ name: role.name,
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: role.id,
+ name: role.name,
+ description: '自动化测试角色',
+ builtIn: false,
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ for (const permissionId of role.permissionIds) {
+ await prisma.rolePermission.upsert({
+ where: { roleId_permissionId: { roleId: role.id, permissionId } },
+ update: {},
+ create: { roleId: role.id, permissionId, createdBy: ACTOR_ID },
+ });
+ }
+ }
+
+ const passwordHash = await hashPasswordArgon2id(TEST_PASSWORD, { memoryKiB: 1024, passes: 1 });
+ for (const user of users) {
+ await prisma.user.upsert({
+ where: { id: user.id },
+ update: {
+ username: user.username,
+ displayName: user.displayName,
+ passwordHash,
+ passwordAlgo: 'argon2id',
+ status: 'ENABLED',
+ failedLoginCount: 0,
+ lockedUntil: null,
+ requirePasswordChange: false,
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: user.id,
+ username: user.username,
+ displayName: user.displayName,
+ email: `${user.username}@example.test`,
+ passwordHash,
+ passwordAlgo: 'argon2id',
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.userRole.upsert({
+ where: { userId_roleId: { userId: user.id, roleId: user.roleId } },
+ update: {},
+ create: { userId: user.id, roleId: user.roleId, createdBy: ACTOR_ID },
+ });
+ }
+}
+
+async function seedNumberLibrary() {
+ await prisma.geoCity.upsert({
+ where: { code: 'geo_340100' },
+ update: {
+ provinceCode: '340000',
+ provinceName: '安徽省',
+ cityCode: '340100',
+ cityName: '合肥市',
+ cityLevel: '地级市',
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ code: 'geo_340100',
+ provinceCode: '340000',
+ provinceName: '安徽省',
+ cityCode: '340100',
+ cityName: '合肥市',
+ cityLevel: '地级市',
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.phoneNumberSegment.upsert({
+ where: { segment7: '1380013' },
+ update: {
+ cityCode: '340100',
+ provinceName: '安徽省',
+ cityName: '合肥市',
+ carrier: 'MOBILE',
+ source: 'test-seed',
+ batchId: 'test-seed',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ segment7: '1380013',
+ cityCode: '340100',
+ provinceName: '安徽省',
+ cityName: '合肥市',
+ carrier: 'MOBILE',
+ source: 'test-seed',
+ batchId: 'test-seed',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.phoneAreaCode.upsert({
+ where: { areaCode: '0551' },
+ update: {
+ cityCode: '340100',
+ provinceName: '安徽省',
+ cityName: '合肥市',
+ source: 'test-seed',
+ batchId: 'test-seed',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ areaCode: '0551',
+ cityCode: '340100',
+ provinceName: '安徽省',
+ cityName: '合肥市',
+ source: 'test-seed',
+ batchId: 'test-seed',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.carrierPrefixRule.upsert({
+ where: { prefix: '138' },
+ update: {
+ carrier: 'MOBILE',
+ priority: 10,
+ source: 'test-seed',
+ batchId: 'test-seed',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ prefix: '138',
+ carrier: 'MOBILE',
+ priority: 10,
+ source: 'test-seed',
+ batchId: 'test-seed',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+}
+
+async function seedBusinessData() {
+ await prisma.customer.upsert({
+ where: { id: 'cus_auto_001' },
+ update: {
+ name: '自动化测试客户A',
+ contactName: '测试联系人',
+ phone: '13800138000',
+ email: 'customer-a@example.test',
+ domain: 'customer-a.example.test',
+ status: 'ENABLED',
+ billingMode: 'PREPAID',
+ balance: '1000.000000',
+ creditLimit: '200.000000',
+ minBalance: '10.000000',
+ notes: '自动化测试固定客户,可用于充值、扣款、网关和话单测试',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'cus_auto_001',
+ name: '自动化测试客户A',
+ contactName: '测试联系人',
+ phone: '13800138000',
+ email: 'customer-a@example.test',
+ domain: 'customer-a.example.test',
+ status: 'ENABLED',
+ billingMode: 'PREPAID',
+ balance: '1000.000000',
+ creditLimit: '200.000000',
+ minBalance: '10.000000',
+ notes: '自动化测试固定客户,可用于充值、扣款、网关和话单测试',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.customer.upsert({
+ where: { id: 'cus_low_001' },
+ update: {
+ name: '自动化低余额客户',
+ status: 'ENABLED',
+ billingMode: 'PREPAID',
+ balance: '3.000000',
+ creditLimit: '0.000000',
+ minBalance: '10.000000',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'cus_low_001',
+ name: '自动化低余额客户',
+ domain: 'customer-low.example.test',
+ status: 'ENABLED',
+ billingMode: 'PREPAID',
+ balance: '3.000000',
+ creditLimit: '0.000000',
+ minBalance: '10.000000',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ const businessPrefixes = [
+ { id: 'bp_auto_671', prefix: '671', name: '自动化业务前缀671', priority: 10 },
+ { id: 'bp_auto_672', prefix: '672', name: '自动化业务前缀672', priority: 20 },
+ ] as const;
+ for (const prefix of businessPrefixes) {
+ await prisma.businessPrefix.upsert({
+ where: { id: prefix.id },
+ update: {
+ prefix: prefix.prefix,
+ name: prefix.name,
+ description: '自动化测试业务前缀',
+ priority: prefix.priority,
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: prefix.id,
+ prefix: prefix.prefix,
+ name: prefix.name,
+ description: '自动化测试业务前缀',
+ priority: prefix.priority,
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+ }
+
+ await prisma.vendor.upsert({
+ where: { id: 'ven_auto_001' },
+ update: {
+ name: '自动化测试供应商A',
+ contactName: '供应商联系人',
+ phone: '13900139000',
+ email: 'vendor-a@example.test',
+ status: 'ENABLED',
+ balance: '5000.000000',
+ creditLimit: '500.000000',
+ settlement: '月结',
+ notes: '自动化测试固定供应商',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'ven_auto_001',
+ name: '自动化测试供应商A',
+ contactName: '供应商联系人',
+ phone: '13900139000',
+ email: 'vendor-a@example.test',
+ status: 'ENABLED',
+ balance: '5000.000000',
+ creditLimit: '500.000000',
+ settlement: '月结',
+ notes: '自动化测试固定供应商',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ const vendorGateways = [
+ { id: 'vgw_auto_primary', name: '自动化主落地网关', host: '10.88.0.10', priority: 10, weight: 70 },
+ { id: 'vgw_auto_backup', name: '自动化备落地网关', host: '10.88.0.11', priority: 20, weight: 30 },
+ ] as const;
+ for (const gateway of vendorGateways) {
+ await prisma.vendorGateway.upsert({
+ where: { id: gateway.id },
+ update: {
+ vendorId: 'ven_auto_001',
+ name: gateway.name,
+ authMode: 'IP',
+ host: gateway.host,
+ port: 5060,
+ transport: 'udp',
+ cpsLimit: 50,
+ concurrencyLimit: 500,
+ billingCycleSec: 60,
+ cycleRate: gateway.id === 'vgw_auto_primary' ? '0.035000' : '0.040000',
+ landingCalleePrefix: '86',
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: gateway.id,
+ vendorId: 'ven_auto_001',
+ name: gateway.name,
+ authMode: 'IP',
+ host: gateway.host,
+ port: 5060,
+ transport: 'udp',
+ cpsLimit: 50,
+ concurrencyLimit: 500,
+ billingCycleSec: 60,
+ cycleRate: gateway.id === 'vgw_auto_primary' ? '0.035000' : '0.040000',
+ landingCalleePrefix: '86',
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.vendorGatewayCodec.upsert({
+ where: { vendorGatewayId_codec: { vendorGatewayId: gateway.id, codec: 'PCMA' } },
+ update: { priority: 10, updatedBy: ACTOR_ID },
+ create: { id: `${gateway.id}_pcma`, vendorGatewayId: gateway.id, codec: 'PCMA', priority: 10, createdBy: ACTOR_ID, updatedBy: ACTOR_ID },
+ });
+
+ await prisma.vendorGatewayPrefixRule.upsert({
+ where: { vendorGatewayId_direction_priority: { vendorGatewayId: gateway.id, direction: 'CALLEE', priority: 10 } },
+ update: { matchPrefix: '671', replacePrefix: '86', updatedBy: ACTOR_ID },
+ create: {
+ id: `${gateway.id}_callee`,
+ vendorGatewayId: gateway.id,
+ direction: 'CALLEE',
+ matchPrefix: '671',
+ replacePrefix: '86',
+ priority: 10,
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+ }
+
+ await prisma.vendorGatewayForbiddenPeriod.upsert({
+ where: { id: 'vgwfp_auto_001' },
+ update: {
+ vendorGatewayId: 'vgw_auto_backup',
+ weekdayMask: 127,
+ startTime: '00:00:00',
+ endTime: '00:10:00',
+ updatedBy: ACTOR_ID,
+ },
+ create: {
+ id: 'vgwfp_auto_001',
+ vendorGatewayId: 'vgw_auto_backup',
+ weekdayMask: 127,
+ startTime: '00:00:00',
+ endTime: '00:10:00',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.vendorGatewayCallerRewrite.upsert({
+ where: { id: 'vgwcr_auto_001' },
+ update: {
+ vendorGatewayId: 'vgw_auto_primary',
+ caller: '05510000001',
+ weight: 100,
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'vgwcr_auto_001',
+ vendorGatewayId: 'vgw_auto_primary',
+ caller: '05510000001',
+ weight: 100,
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.landingLineGroup.upsert({
+ where: { id: 'llg_auto_001' },
+ update: {
+ name: '自动化测试线路组',
+ status: 'ENABLED',
+ notes: '主备落地网关测试线路组',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'llg_auto_001',
+ name: '自动化测试线路组',
+ status: 'ENABLED',
+ notes: '主备落地网关测试线路组',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ for (const gateway of vendorGateways) {
+ await prisma.landingLineGroupItem.upsert({
+ where: { lineGroupId_vendorGatewayId: { lineGroupId: 'llg_auto_001', vendorGatewayId: gateway.id } },
+ update: {
+ priority: gateway.priority,
+ weight: gateway.weight,
+ concurrencyCap: gateway.id === 'vgw_auto_primary' ? 300 : 100,
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ },
+ create: {
+ id: gateway.id === 'vgw_auto_primary' ? 'llgi_auto_primary' : 'llgi_auto_backup',
+ lineGroupId: 'llg_auto_001',
+ vendorGatewayId: gateway.id,
+ priority: gateway.priority,
+ weight: gateway.weight,
+ concurrencyCap: gateway.id === 'vgw_auto_primary' ? 300 : 100,
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+ }
+
+ await prisma.customerGateway.upsert({
+ where: { id: 'cgw_auto_ip' },
+ update: {
+ customerId: 'cus_auto_001',
+ name: '自动化IP认证客户网关',
+ authMode: 'IP',
+ sourceIp: '10.66.0.10',
+ lineGroupId: 'llg_auto_001',
+ billingCycleSec: 60,
+ cycleRate: '0.080000',
+ callerMatchMode: 'PREFIXES',
+ calleeMatchMode: 'BUSINESS_PREFIXES',
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'cgw_auto_ip',
+ customerId: 'cus_auto_001',
+ name: '自动化IP认证客户网关',
+ authMode: 'IP',
+ sourceIp: '10.66.0.10',
+ lineGroupId: 'llg_auto_001',
+ billingCycleSec: 60,
+ cycleRate: '0.080000',
+ callerMatchMode: 'PREFIXES',
+ calleeMatchMode: 'BUSINESS_PREFIXES',
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.customerGateway.upsert({
+ where: { id: 'cgw_auto_sip' },
+ update: {
+ customerId: 'cus_auto_001',
+ name: '自动化SIP认证客户网关',
+ authMode: 'SIP_DIGEST',
+ sipUsername: 'auto_sip_user',
+ sipDomain: 'customer-a.example.test',
+ sipHa1: sipHa1('auto_sip_user', 'customer-a.example.test', TEST_PASSWORD),
+ lineGroupId: 'llg_auto_001',
+ billingCycleSec: 60,
+ cycleRate: '0.090000',
+ callerMatchMode: 'ANY',
+ calleeMatchMode: 'BUSINESS_PREFIXES',
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'cgw_auto_sip',
+ customerId: 'cus_auto_001',
+ name: '自动化SIP认证客户网关',
+ authMode: 'SIP_DIGEST',
+ sipUsername: 'auto_sip_user',
+ sipDomain: 'customer-a.example.test',
+ sipHa1: sipHa1('auto_sip_user', 'customer-a.example.test', TEST_PASSWORD),
+ lineGroupId: 'llg_auto_001',
+ billingCycleSec: 60,
+ cycleRate: '0.090000',
+ callerMatchMode: 'ANY',
+ calleeMatchMode: 'BUSINESS_PREFIXES',
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.customerGatewayIp.upsert({
+ where: { gatewayId_sourceIp: { gatewayId: 'cgw_auto_ip', sourceIp: '10.66.0.10' } },
+ update: { status: 'ENABLED', updatedBy: ACTOR_ID, deletedAt: null },
+ create: { id: 'cgip_auto_001', gatewayId: 'cgw_auto_ip', sourceIp: '10.66.0.10', status: 'ENABLED', createdBy: ACTOR_ID, updatedBy: ACTOR_ID },
+ });
+
+ await prisma.customerGatewayBusinessPrefix.upsert({
+ where: { gatewayId_businessPrefixId: { gatewayId: 'cgw_auto_ip', businessPrefixId: 'bp_auto_671' } },
+ update: {},
+ create: { id: 'cgbp_auto_ip_671', gatewayId: 'cgw_auto_ip', businessPrefixId: 'bp_auto_671', createdBy: ACTOR_ID },
+ });
+
+ await prisma.customerGatewayBusinessPrefix.upsert({
+ where: { gatewayId_businessPrefixId: { gatewayId: 'cgw_auto_sip', businessPrefixId: 'bp_auto_671' } },
+ update: {},
+ create: { id: 'cgbp_auto_sip_671', gatewayId: 'cgw_auto_sip', businessPrefixId: 'bp_auto_671', createdBy: ACTOR_ID },
+ });
+
+ await prisma.customerGatewayCallerPrefix.upsert({
+ where: { gatewayId_prefix: { gatewayId: 'cgw_auto_ip', prefix: '0551' } },
+ update: { priority: 10 },
+ create: { id: 'cgcp_auto_0551', gatewayId: 'cgw_auto_ip', prefix: '0551', priority: 10, createdBy: ACTOR_ID },
+ });
+
+ await prisma.customerGatewayPolicy.upsert({
+ where: { id: 'cgp_auto_001' },
+ update: {
+ customerId: 'cus_auto_001',
+ gatewayId: 'cgw_auto_ip',
+ lineGroupId: 'llg_auto_001',
+ name: '自动化客户网关策略',
+ priority: 10,
+ callerMode: 'PREFIX',
+ callerValue: '0551',
+ calleeMode: 'PREFIX',
+ calleeValue: '671',
+ status: 'ENABLED',
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'cgp_auto_001',
+ customerId: 'cus_auto_001',
+ gatewayId: 'cgw_auto_ip',
+ lineGroupId: 'llg_auto_001',
+ name: '自动化客户网关策略',
+ priority: 10,
+ callerMode: 'PREFIX',
+ callerValue: '0551',
+ calleeMode: 'PREFIX',
+ calleeValue: '671',
+ status: 'ENABLED',
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+}
+
+async function seedCdrAndQuality() {
+ const startedAt = new Date('2026-01-01T10:00:00.000Z');
+ const answeredAt = new Date('2026-01-01T10:00:06.000Z');
+ const endedAt = new Date('2026-01-01T10:01:06.000Z');
+
+ await prisma.rawCdr.upsert({
+ where: { id: 'raw_auto_001' },
+ update: {
+ eventId: 'evt_auto_001',
+ callId: 'call_auto_001',
+ customerId: 'cus_auto_001',
+ customerGatewayId: 'cgw_auto_ip',
+ customerGatewayPolicyId: 'cgp_auto_001',
+ sourceIp: '10.66.0.10',
+ caller: '05510000001',
+ callee: '67113800138000',
+ rawCallee: '67113800138000',
+ businessPrefixId: 'bp_auto_671',
+ businessPrefix: '671',
+ calleeCityCode: '340100',
+ calleeCityName: '合肥市',
+ calleeProvinceName: '安徽省',
+ calleeOperator: 'MOBILE',
+ calleeNumberType: 'MOBILE',
+ vendorId: 'ven_auto_001',
+ vendorGatewayId: 'vgw_auto_primary',
+ lineGroupId: 'llg_auto_001',
+ landingCaller: '05510000001',
+ landingCallee: '8613800138000',
+ startedAt,
+ answeredAt,
+ endedAt,
+ durationSec: 66,
+ sipCode: 200,
+ hangupReason: 'NORMAL_CLEARING',
+ recordingKey: 'seed/call_auto_001.wav',
+ configVersion: 1,
+ ratingStatus: 'RATED',
+ payload: { source: 'test-seed' },
+ },
+ create: {
+ id: 'raw_auto_001',
+ eventId: 'evt_auto_001',
+ callId: 'call_auto_001',
+ customerId: 'cus_auto_001',
+ customerGatewayId: 'cgw_auto_ip',
+ customerGatewayPolicyId: 'cgp_auto_001',
+ sourceIp: '10.66.0.10',
+ caller: '05510000001',
+ callee: '67113800138000',
+ rawCallee: '67113800138000',
+ businessPrefixId: 'bp_auto_671',
+ businessPrefix: '671',
+ calleeCityCode: '340100',
+ calleeCityName: '合肥市',
+ calleeProvinceName: '安徽省',
+ calleeOperator: 'MOBILE',
+ calleeNumberType: 'MOBILE',
+ vendorId: 'ven_auto_001',
+ vendorGatewayId: 'vgw_auto_primary',
+ lineGroupId: 'llg_auto_001',
+ landingCaller: '05510000001',
+ landingCallee: '8613800138000',
+ startedAt,
+ answeredAt,
+ endedAt,
+ durationSec: 66,
+ sipCode: 200,
+ hangupReason: 'NORMAL_CLEARING',
+ recordingKey: 'seed/call_auto_001.wav',
+ configVersion: 1,
+ ratingStatus: 'RATED',
+ payload: { source: 'test-seed' },
+ },
+ });
+
+ await prisma.ratedCdr.upsert({
+ where: { rawCdrId: 'raw_auto_001' },
+ update: {
+ billSec: 60,
+ customerFee: '0.080000',
+ vendorCost: '0.035000',
+ grossProfit: '0.045000',
+ customerRate: { cycleSec: 60, cycleRate: '0.080000' },
+ vendorRate: { cycleSec: 60, cycleRate: '0.035000' },
+ },
+ create: {
+ id: 'rated_auto_001',
+ rawCdrId: 'raw_auto_001',
+ billSec: 60,
+ customerFee: '0.080000',
+ vendorCost: '0.035000',
+ grossProfit: '0.045000',
+ customerRate: { cycleSec: 60, cycleRate: '0.080000' },
+ vendorRate: { cycleSec: 60, cycleRate: '0.035000' },
+ },
+ });
+
+ await prisma.recording.upsert({
+ where: { id: 'rec_auto_001' },
+ update: {
+ rawCdrId: 'raw_auto_001',
+ storageKey: 'seed/call_auto_001.wav',
+ storagePath: '/recordings/seed/call_auto_001.wav',
+ sha256: 'a'.repeat(64),
+ bytes: BigInt(55758),
+ durationSec: 66,
+ status: 'READY',
+ movedAt: endedAt,
+ },
+ create: {
+ id: 'rec_auto_001',
+ rawCdrId: 'raw_auto_001',
+ storageKey: 'seed/call_auto_001.wav',
+ storagePath: '/recordings/seed/call_auto_001.wav',
+ sha256: 'a'.repeat(64),
+ bytes: BigInt(55758),
+ durationSec: 66,
+ status: 'READY',
+ movedAt: endedAt,
+ },
+ });
+
+ await prisma.qualitySamplingRule.upsert({
+ where: { id: 'qsr_auto_001' },
+ update: {
+ name: '自动化抽检规则',
+ customerId: 'cus_auto_001',
+ lineGroupId: 'llg_auto_001',
+ ratio: '10.00',
+ status: 'ENABLED',
+ effectiveAt: startedAt,
+ expiresAt: null,
+ updatedBy: ACTOR_ID,
+ deletedAt: null,
+ },
+ create: {
+ id: 'qsr_auto_001',
+ name: '自动化抽检规则',
+ customerId: 'cus_auto_001',
+ lineGroupId: 'llg_auto_001',
+ ratio: '10.00',
+ status: 'ENABLED',
+ effectiveAt: startedAt,
+ createdBy: ACTOR_ID,
+ updatedBy: ACTOR_ID,
+ },
+ });
+
+ await prisma.qualityReview.upsert({
+ where: { id: 'qr_auto_001' },
+ update: {
+ recordingId: 'rec_auto_001',
+ reviewerId: 'usr_test_quality',
+ score: 88,
+ result: 'PASS',
+ issueTags: [],
+ notes: '自动化测试质检样本',
+ reviewedAt: endedAt,
+ },
+ create: {
+ id: 'qr_auto_001',
+ recordingId: 'rec_auto_001',
+ reviewerId: 'usr_test_quality',
+ score: 88,
+ result: 'PASS',
+ issueTags: [],
+ notes: '自动化测试质检样本',
+ reviewedAt: endedAt,
+ },
+ });
+}
+
+async function main() {
+ await seedAuth();
+ await seedNumberLibrary();
+ await seedBusinessData();
+ await seedCdrAndQuality();
+
+ console.log('Test seed completed.');
+ console.log(`Login users: ${users.map((user) => user.username).join(', ')}`);
+ console.log(`Default password: ${TEST_PASSWORD}`);
+}
+
+main()
+ .catch((error) => {
+ console.error('Test seed failed.', error);
+ process.exitCode = 1;
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/tests/README.md b/tests/README.md
new file mode 100644
index 0000000..548cdda
--- /dev/null
+++ b/tests/README.md
@@ -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.
diff --git a/tests/api/README.md b/tests/api/README.md
new file mode 100644
index 0000000..893eaf6
--- /dev/null
+++ b/tests/api/README.md
@@ -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 = '
'
+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
+```
diff --git a/tests/api/remote-auth-rbac.mjs b/tests/api/remote-auth-rbac.mjs
new file mode 100644
index 0000000..7c30dd3
--- /dev/null
+++ b/tests/api/remote-auth-rbac.mjs
@@ -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>/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;
+}
diff --git a/tests/api/remote-calls-cdr-billing.mjs b/tests/api/remote-calls-cdr-billing.mjs
new file mode 100644
index 0000000..f3836bd
--- /dev/null
+++ b/tests/api/remote-calls-cdr-billing.mjs
@@ -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>/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;
diff --git a/tests/api/remote-customer-gateways-prefixes.mjs b/tests/api/remote-customer-gateways-prefixes.mjs
new file mode 100644
index 0000000..686a867
--- /dev/null
+++ b/tests/api/remote-customer-gateways-prefixes.mjs
@@ -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>/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;
+}
diff --git a/tests/api/remote-customers-balance.mjs b/tests/api/remote-customers-balance.mjs
new file mode 100644
index 0000000..a54627e
--- /dev/null
+++ b/tests/api/remote-customers-balance.mjs
@@ -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>/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;
+}
diff --git a/tests/api/remote-dashboard-active-audit.mjs b/tests/api/remote-dashboard-active-audit.mjs
new file mode 100644
index 0000000..fb81fff
--- /dev/null
+++ b/tests/api/remote-dashboard-active-audit.mjs
@@ -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>/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;
+}
diff --git a/tests/api/remote-performance-security.mjs b/tests/api/remote-performance-security.mjs
new file mode 100644
index 0000000..eb36e8b
--- /dev/null
+++ b/tests/api/remote-performance-security.mjs
@@ -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>/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(''), `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;
+}
diff --git a/tests/api/remote-recordings-quality.mjs b/tests/api/remote-recordings-quality.mjs
new file mode 100644
index 0000000..d786079
--- /dev/null
+++ b/tests/api/remote-recordings-quality.mjs
@@ -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>/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;
diff --git a/tests/api/remote-vendors-line-groups.mjs b/tests/api/remote-vendors-line-groups.mjs
new file mode 100644
index 0000000..f334d0e
--- /dev/null
+++ b/tests/api/remote-vendors-line-groups.mjs
@@ -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>/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;
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
new file mode 100644
index 0000000..aff4916
--- /dev/null
+++ b/tests/fixtures/README.md
@@ -0,0 +1,3 @@
+# Fixtures
+
+Shared fixture payloads for automated tests.
diff --git a/tests/reports/AUTOMATION_STEP1_FOUNDATION_20260629.md b/tests/reports/AUTOMATION_STEP1_FOUNDATION_20260629.md
new file mode 100644
index 0000000..3d47afa
--- /dev/null
+++ b/tests/reports/AUTOMATION_STEP1_FOUNDATION_20260629.md
@@ -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
+```
diff --git a/tests/reports/README.md b/tests/reports/README.md
new file mode 100644
index 0000000..b6afd13
--- /dev/null
+++ b/tests/reports/README.md
@@ -0,0 +1,3 @@
+# Reports
+
+Generated reports and run summaries can be saved here. Keep committed reports small and intentional.
diff --git a/tests/reports/REMOTE_AUTH_RBAC_20260629T050554Z.md b/tests/reports/REMOTE_AUTH_RBAC_20260629T050554Z.md
new file mode 100644
index 0000000..56bc20f
--- /dev/null
+++ b/tests/reports/REMOTE_AUTH_RBAC_20260629T050554Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_AUTH_RBAC_20260629T050944Z.md b/tests/reports/REMOTE_AUTH_RBAC_20260629T050944Z.md
new file mode 100644
index 0000000..8c9448a
--- /dev/null
+++ b/tests/reports/REMOTE_AUTH_RBAC_20260629T050944Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_AUTH_RBAC_20260629T051008Z.md b/tests/reports/REMOTE_AUTH_RBAC_20260629T051008Z.md
new file mode 100644
index 0000000..bdeb4de
--- /dev/null
+++ b/tests/reports/REMOTE_AUTH_RBAC_20260629T051008Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_AUTH_RBAC_20260629T051104Z.md b/tests/reports/REMOTE_AUTH_RBAC_20260629T051104Z.md
new file mode 100644
index 0000000..e7132e8
--- /dev/null
+++ b/tests/reports/REMOTE_AUTH_RBAC_20260629T051104Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_CALLS_CDR_BILLING_20260629T053425Z.md b/tests/reports/REMOTE_CALLS_CDR_BILLING_20260629T053425Z.md
new file mode 100644
index 0000000..7229273
--- /dev/null
+++ b/tests/reports/REMOTE_CALLS_CDR_BILLING_20260629T053425Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_CUSTOMERS_BALANCE_20260629T051850Z.md b/tests/reports/REMOTE_CUSTOMERS_BALANCE_20260629T051850Z.md
new file mode 100644
index 0000000..f85a479
--- /dev/null
+++ b/tests/reports/REMOTE_CUSTOMERS_BALANCE_20260629T051850Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_CUSTOMER_GATEWAYS_PREFIXES_20260629T052245Z.md b/tests/reports/REMOTE_CUSTOMER_GATEWAYS_PREFIXES_20260629T052245Z.md
new file mode 100644
index 0000000..61b5654
--- /dev/null
+++ b/tests/reports/REMOTE_CUSTOMER_GATEWAYS_PREFIXES_20260629T052245Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_CUSTOMER_GATEWAYS_PREFIXES_20260629T052346Z.md b/tests/reports/REMOTE_CUSTOMER_GATEWAYS_PREFIXES_20260629T052346Z.md
new file mode 100644
index 0000000..07f54d3
--- /dev/null
+++ b/tests/reports/REMOTE_CUSTOMER_GATEWAYS_PREFIXES_20260629T052346Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_DASHBOARD_ACTIVE_AUDIT_20260629T055104Z.md b/tests/reports/REMOTE_DASHBOARD_ACTIVE_AUDIT_20260629T055104Z.md
new file mode 100644
index 0000000..1dd862f
--- /dev/null
+++ b/tests/reports/REMOTE_DASHBOARD_ACTIVE_AUDIT_20260629T055104Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_DASHBOARD_ACTIVE_AUDIT_20260629T055207Z.md b/tests/reports/REMOTE_DASHBOARD_ACTIVE_AUDIT_20260629T055207Z.md
new file mode 100644
index 0000000..d93108a
--- /dev/null
+++ b/tests/reports/REMOTE_DASHBOARD_ACTIVE_AUDIT_20260629T055207Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_OPS_RELEASE_BACKUP_ROLLBACK_20260629T064236Z.md b/tests/reports/REMOTE_OPS_RELEASE_BACKUP_ROLLBACK_20260629T064236Z.md
new file mode 100644
index 0000000..77b8584
--- /dev/null
+++ b/tests/reports/REMOTE_OPS_RELEASE_BACKUP_ROLLBACK_20260629T064236Z.md
@@ -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
+```
diff --git a/tests/reports/REMOTE_PERFORMANCE_SECURITY_20260629T061621Z.md b/tests/reports/REMOTE_PERFORMANCE_SECURITY_20260629T061621Z.md
new file mode 100644
index 0000000..c1cb092
--- /dev/null
+++ b/tests/reports/REMOTE_PERFORMANCE_SECURITY_20260629T061621Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_PERFORMANCE_SECURITY_DISRUPTIVE_20260629T062920Z.md b/tests/reports/REMOTE_PERFORMANCE_SECURITY_DISRUPTIVE_20260629T062920Z.md
new file mode 100644
index 0000000..357da0d
--- /dev/null
+++ b/tests/reports/REMOTE_PERFORMANCE_SECURITY_DISRUPTIVE_20260629T062920Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_RECORDINGS_QUALITY_20260629T054018Z.md b/tests/reports/REMOTE_RECORDINGS_QUALITY_20260629T054018Z.md
new file mode 100644
index 0000000..3efee24
--- /dev/null
+++ b/tests/reports/REMOTE_RECORDINGS_QUALITY_20260629T054018Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_SMOKE_20260629T045850Z.md b/tests/reports/REMOTE_SMOKE_20260629T045850Z.md
new file mode 100644
index 0000000..b4e3304
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T045850Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T045932Z.md b/tests/reports/REMOTE_SMOKE_20260629T045932Z.md
new file mode 100644
index 0000000..47442c6
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T045932Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T060357Z.md b/tests/reports/REMOTE_SMOKE_20260629T060357Z.md
new file mode 100644
index 0000000..764571b
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T060357Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T062920Z.md b/tests/reports/REMOTE_SMOKE_20260629T062920Z.md
new file mode 100644
index 0000000..10ce3c3
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T062920Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T064035Z.md b/tests/reports/REMOTE_SMOKE_20260629T064035Z.md
new file mode 100644
index 0000000..52ec15b
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T064035Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T071837Z.md b/tests/reports/REMOTE_SMOKE_20260629T071837Z.md
new file mode 100644
index 0000000..713d245
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T071837Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T074052Z.md b/tests/reports/REMOTE_SMOKE_20260629T074052Z.md
new file mode 100644
index 0000000..c587390
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T074052Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T075138Z.md b/tests/reports/REMOTE_SMOKE_20260629T075138Z.md
new file mode 100644
index 0000000..9799435
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T075138Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T075504Z.md b/tests/reports/REMOTE_SMOKE_20260629T075504Z.md
new file mode 100644
index 0000000..7ad971a
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T075504Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T080917Z.md b/tests/reports/REMOTE_SMOKE_20260629T080917Z.md
new file mode 100644
index 0000000..c5a3f2b
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T080917Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T094744Z.md b/tests/reports/REMOTE_SMOKE_20260629T094744Z.md
new file mode 100644
index 0000000..5568804
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T094744Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T101122Z.md b/tests/reports/REMOTE_SMOKE_20260629T101122Z.md
new file mode 100644
index 0000000..82870a1
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T101122Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260629T103420Z.md b/tests/reports/REMOTE_SMOKE_20260629T103420Z.md
new file mode 100644
index 0000000..73cc4c5
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260629T103420Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260630T012830Z.md b/tests/reports/REMOTE_SMOKE_20260630T012830Z.md
new file mode 100644
index 0000000..a75c302
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260630T012830Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260630T024412Z.md b/tests/reports/REMOTE_SMOKE_20260630T024412Z.md
new file mode 100644
index 0000000..59fa73f
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260630T024412Z.md
@@ -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
diff --git a/tests/reports/REMOTE_SMOKE_20260630T031146Z.md b/tests/reports/REMOTE_SMOKE_20260630T031146Z.md
new file mode 100644
index 0000000..57a1d58
--- /dev/null
+++ b/tests/reports/REMOTE_SMOKE_20260630T031146Z.md
@@ -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
diff --git a/tests/reports/REMOTE_VENDORS_LINE_GROUPS_20260629T052804Z.md b/tests/reports/REMOTE_VENDORS_LINE_GROUPS_20260629T052804Z.md
new file mode 100644
index 0000000..d802d10
--- /dev/null
+++ b/tests/reports/REMOTE_VENDORS_LINE_GROUPS_20260629T052804Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_VENDORS_LINE_GROUPS_20260629T052835Z.md b/tests/reports/REMOTE_VENDORS_LINE_GROUPS_20260629T052835Z.md
new file mode 100644
index 0000000..de054ed
--- /dev/null
+++ b/tests/reports/REMOTE_VENDORS_LINE_GROUPS_20260629T052835Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060046Z.md b/tests/reports/REMOTE_WEB_UI_20260629T060046Z.md
new file mode 100644
index 0000000..6551ad4
--- /dev/null
+++ b/tests/reports/REMOTE_WEB_UI_20260629T060046Z.md
@@ -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: [2m - waiting for getByRole('button', { name: '号码库' })[22m 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:
+[2m - waiting for getByRole('button', { name: '号码库' })[22m
+ |
+
+## 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.
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060046Z_admin-dashboard.png b/tests/reports/REMOTE_WEB_UI_20260629T060046Z_admin-dashboard.png
new file mode 100644
index 0000000..128e5fb
Binary files /dev/null and b/tests/reports/REMOTE_WEB_UI_20260629T060046Z_admin-dashboard.png differ
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060305Z.md b/tests/reports/REMOTE_WEB_UI_20260629T060305Z.md
new file mode 100644
index 0000000..ec3fec3
--- /dev/null
+++ b/tests/reports/REMOTE_WEB_UI_20260629T060305Z.md
@@ -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: [2m - navigating to "https://100.90.90.91/", waiting until "domcontentloaded"[22m 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:
+[2m - navigating to "https://100.90.90.91/", waiting until "domcontentloaded"[22m
+ |
+
+## 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.
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060555Z.md b/tests/reports/REMOTE_WEB_UI_20260629T060555Z.md
new file mode 100644
index 0000000..bfed070
--- /dev/null
+++ b/tests/reports/REMOTE_WEB_UI_20260629T060555Z.md
@@ -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: [2m - waiting for locator('input[autocomplete="username"]') to be visible[22m 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:
+[2m - waiting for locator('input[autocomplete="username"]') to be visible[22m
+ |
+
+## 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.
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060555Z_admin-dashboard.png b/tests/reports/REMOTE_WEB_UI_20260629T060555Z_admin-dashboard.png
new file mode 100644
index 0000000..92e670e
Binary files /dev/null and b/tests/reports/REMOTE_WEB_UI_20260629T060555Z_admin-dashboard.png differ
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060852Z.md b/tests/reports/REMOTE_WEB_UI_20260629T060852Z.md
new file mode 100644
index 0000000..eed7356
--- /dev/null
+++ b/tests/reports/REMOTE_WEB_UI_20260629T060852Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060852Z_admin-dashboard.png b/tests/reports/REMOTE_WEB_UI_20260629T060852Z_admin-dashboard.png
new file mode 100644
index 0000000..6339456
Binary files /dev/null and b/tests/reports/REMOTE_WEB_UI_20260629T060852Z_admin-dashboard.png differ
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060852Z_logout.png b/tests/reports/REMOTE_WEB_UI_20260629T060852Z_logout.png
new file mode 100644
index 0000000..89226b0
Binary files /dev/null and b/tests/reports/REMOTE_WEB_UI_20260629T060852Z_logout.png differ
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T060852Z_low-nav.png b/tests/reports/REMOTE_WEB_UI_20260629T060852Z_low-nav.png
new file mode 100644
index 0000000..86dbc1c
Binary files /dev/null and b/tests/reports/REMOTE_WEB_UI_20260629T060852Z_low-nav.png differ
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T061117Z.md b/tests/reports/REMOTE_WEB_UI_20260629T061117Z.md
new file mode 100644
index 0000000..7ccd272
--- /dev/null
+++ b/tests/reports/REMOTE_WEB_UI_20260629T061117Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T061117Z_admin-dashboard.png b/tests/reports/REMOTE_WEB_UI_20260629T061117Z_admin-dashboard.png
new file mode 100644
index 0000000..1ddb7cd
Binary files /dev/null and b/tests/reports/REMOTE_WEB_UI_20260629T061117Z_admin-dashboard.png differ
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T061117Z_logout.png b/tests/reports/REMOTE_WEB_UI_20260629T061117Z_logout.png
new file mode 100644
index 0000000..89226b0
Binary files /dev/null and b/tests/reports/REMOTE_WEB_UI_20260629T061117Z_logout.png differ
diff --git a/tests/reports/REMOTE_WEB_UI_20260629T061117Z_low-nav.png b/tests/reports/REMOTE_WEB_UI_20260629T061117Z_low-nav.png
new file mode 100644
index 0000000..b41ce6e
Binary files /dev/null and b/tests/reports/REMOTE_WEB_UI_20260629T061117Z_low-nav.png differ
diff --git a/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T100419Z.md b/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T100419Z.md
new file mode 100644
index 0000000..f6e7cbe
--- /dev/null
+++ b/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T100419Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T101911Z.md b/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T101911Z.md
new file mode 100644
index 0000000..dec73d0
--- /dev/null
+++ b/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T101911Z.md
@@ -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.
diff --git a/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T104040Z.md b/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T104040Z.md
new file mode 100644
index 0000000..ae13c86
--- /dev/null
+++ b/tests/reports/REMOTE_WEB_UI_SMOKE_20260629T104040Z.md
@@ -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.
diff --git a/tests/smoke/README.md b/tests/smoke/README.md
new file mode 100644
index 0000000..be00aa9
--- /dev/null
+++ b/tests/smoke/README.md
@@ -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
+```
diff --git a/tests/smoke/remote-smoke.mjs b/tests/smoke/remote-smoke.mjs
new file mode 100644
index 0000000..fef3d8c
--- /dev/null
+++ b/tests/smoke/remote-smoke.mjs
@@ -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(''),
+ (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;
+}
diff --git a/tests/web/README.md b/tests/web/README.md
new file mode 100644
index 0000000..442be19
--- /dev/null
+++ b/tests/web/README.md
@@ -0,0 +1,3 @@
+# Web Tests
+
+Browser automation will be added after API fixtures and E2E helpers are stable.
diff --git a/tests/web/remote-web-ui-smoke.mjs b/tests/web/remote-web-ui-smoke.mjs
new file mode 100644
index 0000000..3c799a7
--- /dev/null
+++ b/tests/web/remote-web-ui-smoke.mjs
@@ -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>/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;
+}