fix web ui smoke and brand assets

This commit is contained in:
hectorzhao
2026-06-30 11:13:30 +08:00
parent 0dfb2988b2
commit 9920575bba
103 changed files with 6650 additions and 135 deletions
+158 -5
View File
@@ -4,11 +4,11 @@
## 当前状态
- 当前任务:需求修改/二期变更:供应商启停移除、客户启停入口、落地网关 CPS 数字校验、当前通话 SIP 状态
- 本次变更摘要:供应商启停移除、客户启停入口、落地网关 CPS 数字校验、当前通话 SIP 状态,以及登录 500 的完整 Argon2id vendor artifact 修复,已提交并发布到 B 新 release `s45-vendor-cps-sipstate-20260629113500`
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前 release 为 `s45-vendor-cps-sipstate-20260629113500`B preflight 通过,API ready 返回 okHTTPS 首页加载前端资产 `index-CBRZZA7t.js` / `index-B2uYBtS3.css`Redis `cfg:active_version=s28-v1`A 当前 EVALSHA 脚本在 Redis 中存在;A 已加载二期 Lua 热路径 SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7`OpenSIPS 配置 SHA-256 为 `f163fa37f7aaa492e12ed496cb82f07d9b0f1030a6ed297855a22d2c4bf1c8a5`A/B/T 目标服务均 activeB 登录依赖 Argon2id 完整 vendor 目录已纳入 release artifact;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练
- 最后更新:2026-06-29 12:12 +08:00
- 当前阻塞:无阻塞。遗留问题:本次未执行数据库 schema 变更,未改 A OpenSIPS/Lua/RTPEngine 或 T 配置;真实 80 万手机号段尚未导入;OpenSIPS dialog 残留根因仍待单独分析;真实 B 服务器浏览器登录态逐页按钮、真实录音文件播放和 Nginx/X-Accel 链路仍需用户测试复核;S40 呼叫时脚本内置 UAS 端口提示已占用但现有 UAS 正常接起且通话/CDR/计费成功;A/T 各有一个无关 `fwupd-refresh.service` failed unit,未处理。
- 当前任务:菜单展开 Logo 增加 SIP 文字并发布到 B
- 本次变更摘要:已核对 `tests/reports/` 下 8.2-8.10 远程测试结果;修复客户充值接口负数扣款、active call hangup 超长/无分隔 ID 参数校验、Fastify 路由参数长度限制,以及既有环境号码库内置角色权限补齐迁移。追加修复业务前缀管理页面 `normalizeBusinessPrefix is not defined`、号码库页面 `emptyNumberLibraryRows is not defined`、客户网关编辑触发 React #137 的前端运行时错误。最新 s53 在菜单展开 Logo 后增加 `SIP` 文字,收起菜单仍展示 `logo2`,并发布 Web-only release。新增测试门禁:`pnpm lint` 覆盖 `apps/web/src/**``pnpm test:remote-web-ui` 使用真实浏览器登录并逐个点击核心菜单及每页一个安全主操作,客户网关编辑动作会验证业务前缀 checkbox 可 false->true->false 往返切换。B 当前 release 为 `s53-brand-logo-sip-text-20260630105500`;准备提交 Git。
- 总体状态:S30 已完成;本地 KVM 开发环境 V2 闭环原冻结 release 为 `s28-v2-20260621220924`B 二期冻结基线为 `s42-phase2-business-prefix-20260624140000`,当前 release 为 `s53-brand-logo-sip-text-20260630105500`2026-06-30 开机后已恢复 A/B/T 服务:A `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-redis-auth-proxy``lisglosips-node-exporter``lisglosips-recording-finalize.timer` active`lisglosips-redis-hotpath-load.service` Result=successB `mysql``redis-server``nginx``lisglosips@api``lisglosips@cdr-worker``lisglosips@recording-worker``lisglosips@config-publisher``heplify-server``lisglosips-prometheus``grafana-server` activepreflight okAPI ready okT `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-s28-uas``apache2``mariadb/mysql` active;三机 `systemctl --failed` 均为 0。B preflight、远程 smoke、8.2/8.7/8.8 定向复测均通过;业务前缀、号码库、客户网关编辑与前缀选择 Web-only 修复后,s53 当前首页引用 Web JS `index-CrjpbrU-.js`、CSS `index-CrJGI5_R.css`,并包含 `/favicon.ico``/brand/logo1.png``/brand/logo2.png`;远程 smoke `REMOTE_SMOKE_20260630T031146Z.md` 通过;完整按钮级远程浏览器巡检 `REMOTE_WEB_UI_SMOKE_20260629T104040Z.md` 通过,覆盖 15 个核心菜单及安全主操作,客户网关管理编辑报告包含 `business-prefix-checkbox-toggle-ok: false->true->false`;B 本机号码库四个列表接口均返回 200,当前 total 均为 0,符合真实号段尚未导入状态;本地代码验证 `lint``typecheck`、全量 `test``build``node --check tests/web/remote-web-ui-smoke.mjs` 均通过;Redis `cfg:active_version=s28-v1`A 当前 EVALSHA 脚本在 Redis 中存在;A 已加载二期 Lua 热路径 SHA1 `aff9995dbda2a2425e95a3e25b5d7ce194348ce7`;当前仍未导入真实 80 万手机号段;阿里云迁移前需按 S30 Runbook 重新演练
- 最后更新:2026-06-30 11:12 +08:00
- 当前阻塞:本轮 8.2/8.7/8.8、业务前缀页面、号码库页面、客户网关编辑运行时错误、测试门禁修复、B SSH/HTTPS 来源限制移除无阻塞。遗留问题:未提交 Git;未改 A OpenSIPS/Lua/RTPEngine 或 T 配置;8.9 轻量 API burst p95 超过 10 秒需结合服务端日志和测试方式继续定位;SIP CPS 探针中 BYE 被 `403 Rate Limited` 属 A 侧限流策略问题,修复前需说明影响、备份 A 配置并安排维护窗口;8.10 备份、隔离恢复、真实回滚、OpenSIPS 配置恢复受权限或维护窗口阻塞;真实 80 万手机号段尚未导入;OpenSIPS dialog 残留根因仍待单独分析。
- 当前环境:A/B/T 为本地 KVM 开发服务器,通过 Tailscale 联调;开发完成后再迁移阿里云
- 私密访问资料:`.codex-private/SERVER_ACCESS.md`,只在实际连接时读取,禁止回显
@@ -90,6 +90,159 @@
## 交接记录
### 2026-06-30 11:12 - Web-only s53 菜单展开 Logo 增加 SIP
- 状态:已完成
- 操作服务器:本地、B;未改数据库 schema,未重启 API/Worker,未改 A/T
- 完成内容:菜单展开状态下将品牌位调整为 `logo1 + SIP`,菜单收起状态仍只显示 `logo2`;保持 favicon 与 logo 资源路径不变。
- 修改文件:`apps/web/src/App.jsx``apps/web/src/styles.css``IMPLEMENTATION_STATUS.md`
- 发布情况:本地构建生成 Web 资源 `index-CrjpbrU-.js` / `index-CrJGI5_R.css`;上传 Web tar `s53-brand-logo-sip-text-20260630105500-web.tar.gz`SHA256 `fd2cd44a1828de9ae71bf29f73171091d8690a2b293edee87eb79b78eb3db07a`B 从 s52 复制生成 `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500`,只替换 `public``apps/web/dist` 静态资源,切换 `/opt/lisglosips/current` 到 s53 并 reload Nginx。
- 验证结果:本地 `corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 build` 通过;B `nginx -t` 通过并已 reloadB current 确认为 `/opt/lisglosips/releases/s53-brand-logo-sip-text-20260630105500`;远程 smoke `REMOTE_SMOKE_20260630T031146Z.md` 通过。
- 回滚方式:应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s52-brand-logo-assets-20260630102000` 并 reload Nginx;本次不涉及数据库迁移或服务状态变更,无数据库回滚。
- 未解决问题:未做完整按钮级浏览器巡检。
### 2026-06-30 10:45 - Web-only s52 接入菜单 Logo 与 favicon
- 状态:已完成
- 操作服务器:本地、B;未改数据库 schema,未重启 API/Worker,未改 A/T
- 完成内容:将根目录 `logo/logo1.png``logo/logo2.png``logo/fav.ico` 复制到 Web public 资源目录;菜单展开时使用 `/brand/logo1.png`,菜单收起时使用 `/brand/logo2.png`,浏览器标签使用 `/favicon.ico`;调整侧边栏品牌区 CSS,使展开/收起 logo 尺寸稳定,移动端保持展开 logo。
- 修改文件:`apps/web/index.html``apps/web/src/App.jsx``apps/web/src/styles.css``apps/web/public/brand/logo1.png``apps/web/public/brand/logo2.png``apps/web/public/favicon.ico``IMPLEMENTATION_STATUS.md`
- 发布情况:本地构建生成 Web 资源 `index-CMgxUenC.js` / `index-DZaQiQoS.css`dist 包含 `favicon.ico``brand/logo1.png``brand/logo2.png`;上传 Web tar `s52-brand-logo-assets-20260630102000-web.tar.gz`SHA256 `cc1fdb2aa29550b6634a4189000507a8b62d5b33bd13bd4d0c715dd3acff3f58`B 从 s51 复制生成 `/opt/lisglosips/releases/s52-brand-logo-assets-20260630102000`,只替换 `public``apps/web/dist` 静态资源,切换 `/opt/lisglosips/current` 到 s52 并 reload Nginx。
- 验证结果:本地 `corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 build` 通过;B `nginx -t` 通过并已 reload;远程 smoke `REMOTE_SMOKE_20260630T024412Z.md` 通过;`https://100.90.90.91/favicon.ico``/brand/logo1.png``/brand/logo2.png` 均返回 200。
- 回滚方式:应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s51-customer-gateway-list-prefix-ui-20260629184000` 并 reload Nginx;本次不涉及数据库迁移或服务状态变更,无数据库回滚。
- 未解决问题:未做完整按钮级浏览器巡检;未提交 Git。
### 2026-06-30 10:03 - 移除 B SSH/HTTPS 来源 IP 白名单限制
- 状态:已完成
- 操作服务器:B;未改数据库 schema,未发布新 release,未改 SSH Key 登录策略
- 问题分类:B 主机 nftables 来源白名单限制。B `lisglosips_filter` 原规则只允许 `admin_ipv4={100.91.249.119,100.98.167.119}``tailscale0` 访问 TCP 22/443,导致 `100.107.171.69` 无法访问 `https://100.90.90.91/`
- 完成内容:备份 `/etc/nftables.d/lisglosips.nft`,删除 `admin_ipv4` 集合;将 SSH/HTTPS 规则改为 `tcp dport { 22, 443 } counter accept comment "SSH and HTTPS"`,即不再按来源 IP 限制 SSH/HTTPS;保留 A 到 B Redis/HEP 的来源限制规则不变。
- 修改文件:B `/etc/nftables.d/lisglosips.nft``IMPLEMENTATION_STATUS.md`
- 执行的关键命令:B `cp -a /etc/nftables.d/lisglosips.nft /var/backups/lisglosips-firewall/20260630T100129/lisglosips.nft`、修改 nftables 配置、`nft -c -f /etc/nftables.conf``nft -f /etc/nftables.conf``nft list table inet lisglosips_filter`
- 验证结果:B 生效规则已显示 `tcp dport { 22, 443 } counter accept comment "SSH and HTTPS"`,无 `ip saddr @admin_ipv4` 条件;本机到 `100.90.90.91:443``100.90.90.91:22``TcpTestSucceeded=True`B 本机 `curl -k -I https://127.0.0.1/` 返回 HTTP 200`nginx``ssh` 均 active。A/T 未发现同类 SSH/HTTPS 来源白名单限制。
- 回滚方式:B 执行 `sudo cp -a /var/backups/lisglosips-firewall/20260630T100129/lisglosips.nft /etc/nftables.d/lisglosips.nft && sudo nft -c -f /etc/nftables.conf && sudo nft -f /etc/nftables.conf`,即可恢复原来源白名单。
- 未解决问题:尚未从 `100.107.171.69` 客户端侧复测浏览器访问;未提交 Git。
### 2026-06-30 09:44 - A/B/T 写入开机后服务恢复脚本
- 状态:已完成
- 操作服务器:A、B、T;未改数据库 schema,未发布新 release,未改防火墙或 SSH 配置
- 完成内容:在仓库新增三台机器的同源启动脚本,并分别安装到各自服务器 `/usr/local/sbin/lisglosips-start-services.sh`,属主 `root:root`、权限 `0755`。脚本会启动对应机器的 LisgloSIPS 运行服务并执行基础健康检查;可在机器本地手动执行,也可通过 SSH 远程执行。
- 修改文件:`infra/ops/startup/lisglosips-start-a.sh``infra/ops/startup/lisglosips-start-b.sh``infra/ops/startup/lisglosips-start-t.sh``IMPLEMENTATION_STATUS.md`
- 执行的关键命令:本地 `scp` 上传脚本;三机 `sudo install -o root -g root -m 0755 /tmp/lisglosips-start-services.sh /usr/local/sbin/lisglosips-start-services.sh``bash -n /usr/local/sbin/lisglosips-start-services.sh``sudo /usr/local/sbin/lisglosips-start-services.sh`
- 验证结果:A 脚本执行后 `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-redis-auth-proxy``lisglosips-node-exporter``lisglosips-recording-finalize.timer` active,热路径 `Result=success`failed units 为 0B 脚本执行后 current 为 `/opt/lisglosips/releases/s51-customer-gateway-list-prefix-ui-20260629184000`MySQL/Redis/Nginx/API/CDR Worker/Recording Worker/Config Publisher/HEP/Prometheus/Grafana activeAPI ready okpreflight okfailed units 为 0T 脚本执行后 `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-s28-uas``apache2``mariadb` activefailed units 为 0。
- 回滚方式:如需撤回脚本,可在对应机器执行 `sudo rm -f /usr/local/sbin/lisglosips-start-services.sh`;运行态停止方式同 09:31 记录。仓库新增脚本可按 Git 回退。
- 未解决问题:脚本是手动恢复脚本,不是开机自启动 unit;未做 A/B/T 实呼端到端验收;未提交 Git。
### 2026-06-30 09:31 - A/B/T 测试服务器开机后服务恢复
- 状态:已完成
- 操作服务器:A、B、T;未改数据库 schema,未发布新 release,未改防火墙或 SSH 配置
- 完成内容:读取三机当前 systemd 状态后恢复运行服务;A 主服务已 active,主动执行 `lisglosips-redis-hotpath-load.service` 确认热路径 Lua 载入成功;B 开机后 `lisglosips@recording-worker``lisglosips@config-publisher` 为 inactive,已启动并确认 activeT 主服务均已 active,执行 start/reset-failed 保持模拟环境可用。
- 修改文件:`IMPLEMENTATION_STATUS.md`
- 执行的关键命令:A `systemctl start lisglosips-redis-hotpath-load.service opensips rtpengine-daemon rtpengine-recording-daemon lisglosips-redis-auth-proxy lisglosips-node-exporter lisglosips-recording-finalize.timer``opensips -C -f /etc/opensips/opensips.cfg`B `systemctl start mysql redis-server nginx heplify-server lisglosips-prometheus grafana-server lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher``nginx -t``lisglosips-release-preflight.sh`T `systemctl reset-failed opensips``systemctl start mariadb apache2 rtpengine-daemon rtpengine-recording-daemon opensips lisglosips-s28-uas``opensips -C -f /etc/opensips/opensips.cfg`
- 验证结果:A `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-redis-auth-proxy``lisglosips-node-exporter``lisglosips-recording-finalize.timer` 均 active`lisglosips-redis-hotpath-load.service` Result=success`systemctl --failed` 为 0B current 为 `/opt/lisglosips/releases/s51-customer-gateway-list-prefix-ui-20260629184000`MySQL/Redis/Nginx/API/CDR Worker/Recording Worker/Config Publisher/HEP/Prometheus/Grafana 均 activeAPI ready 返回 okpreflight ok,最近备份目录 `/data/backups/mysql/20260630T012836Z``/data/backups/redis/20260630T012836Z`,远程 smoke `REMOTE_SMOKE_20260630T012830Z.md` 通过;T `opensips``rtpengine-daemon``rtpengine-recording-daemon``lisglosips-s28-uas``apache2``mariadb/mysql` active`systemctl --failed` 为 0。
- 回滚方式:本次仅恢复运行态服务。若需停止,可分别执行 A `sudo systemctl stop opensips rtpengine-recording-daemon rtpengine-daemon lisglosips-redis-auth-proxy`B `sudo systemctl stop lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher`T `sudo systemctl stop opensips lisglosips-s28-uas rtpengine-recording-daemon rtpengine-daemon`;应用 release 未变更,仍可按 S30 Runbook 回滚 B current。
- 未解决问题:未做 A/B/T 实呼端到端验收;真实 80 万手机号段尚未导入;8.9/8.10 既有遗留问题未处理;未提交 Git。
### 2026-06-29 18:43 - Web-only s51 优化客户网关列表与前缀选择交互
- 状态:已完成
- 操作服务器:本地、B;未改数据库,未重启 API/Worker,未改 A/T
- 问题分类:真前端交互 bug 与界面优化。客户网关编辑弹窗中前缀 Checkbox 的 `onChange` 传入原生 event,页面将 event 当作布尔值,导致取消勾选无效;列表展示了用户要求隐藏的认证方式与认证目标列,名称/客户列偏窄。
- 完成内容:客户网关列表隐藏“认证方式”和“IP地址 / 账号名称”;“名称”列宽调整为 240px,“客户”列宽调整为 220px;公共 `Checkbox``Radio``Switch``onChange` 统一传 `(checked, event)`,修复取消勾选无效并保持旧调用方兼容;客户网关业务前缀选择区增加已选计数、全选、清空、空状态和更清晰的网格样式;远程浏览器巡检增加客户网关编辑前缀 checkbox 往返切换断言,并将登录页等待从 `networkidle` 改为更稳定的表单/已登录页面并行等待。
- 修改文件:`apps/web/src/components/ui.jsx``apps/web/src/pages/CustomerGatewaysPage.jsx``apps/web/src/styles.css``tests/web/remote-web-ui-smoke.mjs``IMPLEMENTATION_STATUS.md`
- 发布情况:本地构建生成 Web 资源 `index-C_61kpb7.js` / `index-DWBaYpdZ.css`;上传 Web tar `s51-customer-gateway-list-prefix-ui-20260629184000-web.tar.gz`SHA256 `cb11ad80b84ded7a5605ddedf24058dba854f5fa41de845b972f0643fadfbd0c`B 从 s50 复制生成 `/opt/lisglosips/releases/s51-customer-gateway-list-prefix-ui-20260629184000`,只替换 `public``apps/web/dist` 静态资源,切换 `/opt/lisglosips/current` 到 s51 并 reload Nginx。
- 验证结果:本地 `corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 typecheck` 通过;`corepack pnpm@10.33.0 test` 通过,26 个测试文件 93 条测试通过;`corepack pnpm@10.33.0 build` 通过;`node --check tests/web/remote-web-ui-smoke.mjs` 通过。B `nginx -t` 通过并已 reload;远程 smoke `REMOTE_SMOKE_20260629T103420Z.md` 通过;真实 Chrome 完整按钮级巡检 `REMOTE_WEB_UI_SMOKE_20260629T104040Z.md` 通过,客户网关管理编辑动作验证 `business-prefix-checkbox-toggle-ok: false->true->false`,无 console error/pageerror/空白页/API 数据不可用。
- 回滚方式:应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s50-web-checkbox-action-smoke-20260629182000` 并 reload Nginx;本次不涉及数据库迁移或服务状态变更,无数据库回滚。
- 未解决问题:充值记录、话单中心在当前测试数据下没有可见启用的安全动作按钮,脚本按规则记录为 skipped;未提交 Git。
### 2026-06-29 18:22 - Web-only s50 修复 Checkbox 子节点透传并完成按钮级浏览器巡检
- 状态:已完成
- 操作服务器:本地、B;未改数据库,未重启 API/Worker,未改 A/T
- 问题分类:真前端 bug。s49 只处理了公共 `Input`,但客户网关编辑弹窗里的业务前缀多选使用 `<Checkbox>前缀 / 名称</Checkbox>`,公共 `Checkbox` 未接收 `children`,导致 `children` 继续透传到原生 `<input type="checkbox">` 并触发 React #137
- 完成内容:修复 `apps/web/src/components/ui.jsx``Checkbox`,支持 `label``children` 作为显示文本且不再把 `children` 传给原生 input;同步防护 `Radio``Switch``Slider`,避免同类组件出现相同问题;巡检脚本增加逐页 `CHECK/ACTION` 输出和 `LISGLOSIPS_WEB_UI_STEP_TIMEOUT_MS` 单步超时,避免完整按钮巡检长时间静默。
- 修改文件:`apps/web/src/components/ui.jsx``tests/web/remote-web-ui-smoke.mjs``IMPLEMENTATION_STATUS.md`
- 发布情况:本地构建生成 Web 资源 `index-CI4aNWXC.js` / `index-B2uYBtS3.css`;上传 Web tar `s50-web-checkbox-action-smoke-20260629182000-web.tar.gz`SHA256 `18492ce4860aca32808b0cf871035e6255a4a3924d0c20f9647ffca1eaf20aa5`B 从 s49 复制生成 `/opt/lisglosips/releases/s50-web-checkbox-action-smoke-20260629182000`,只替换 `public``apps/web/dist` 静态资源,切换 `/opt/lisglosips/current` 到 s50 并 reload Nginx。
- 验证结果:本地 `corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 typecheck` 通过;`corepack pnpm@10.33.0 test` 通过,26 个测试文件 93 条测试通过;`corepack pnpm@10.33.0 build` 通过;`node --check tests/web/remote-web-ui-smoke.mjs` 通过。B `nginx -t` 通过并已 reload;远程 smoke `REMOTE_SMOKE_20260629T101122Z.md` 通过;真实 Chrome 完整按钮级巡检 `REMOTE_WEB_UI_SMOKE_20260629T101911Z.md` 通过,客户网关管理编辑、业务前缀编辑、号码库刷新等动作均无 console error/pageerror/空白页/API 数据不可用。
- 回滚方式:应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s49-web-input-action-smoke-20260629175000` 并 reload Nginx;本次不涉及数据库迁移或服务状态变更,无数据库回滚。
- 未解决问题:客户管理、充值记录、话单中心在当前测试数据下没有可见启用的安全动作按钮,脚本按规则记录为 skipped;未提交 Git。
### 2026-06-29 17:50 - Web-only s49 修复客户网关编辑 React #137 并扩展按钮级 Web UI 巡检
- 状态:已完成;后续完整按钮级巡检发现修复不完整,根因已在 s50 修复
- 操作服务器:本地、B;未改数据库,未重启 API/Worker,未改 A/T
- 问题分类:真前端 bug。客户网关页面点击编辑时报 React #137,参数为 `input`,属于原生 `<input>` 收到 children 的运行时错误。为避免任意调用方把 `children` 透传到原生 input,已在公共 `Input` 组件中显式丢弃 `children`
- 完成内容:修复 `apps/web/src/components/ui.jsx``Input` 组件,避免 children 透传到 `<input>`;扩展 `tests/web/remote-web-ui-smoke.mjs`,每个核心菜单渲染后继续点击一个安全主操作(如编辑、查看详情、刷新),覆盖客户网关编辑弹窗、业务前缀编辑、号码库刷新等二级交互;更新 `docs/TEST_PLAN_AND_CASES.md` 的 WEB-005,明确按钮级交互也纳入 pageerror/console error 门禁。
- 修改文件:`apps/web/src/components/ui.jsx``tests/web/remote-web-ui-smoke.mjs``docs/TEST_PLAN_AND_CASES.md``IMPLEMENTATION_STATUS.md`
- 发布情况:本地构建生成 Web 资源 `index-D90saQl_.js` / `index-B2uYBtS3.css`;上传 Web tar `s49-web-input-action-smoke-20260629175000-web.tar.gz`SHA256 `9ee7ad3c99d90d0fe0bf09482a47c2b76aedfc298d9aa909397850c5ac317359`B 从 s48 复制生成 `/opt/lisglosips/releases/s49-web-input-action-smoke-20260629175000`,只替换 `public``apps/web/dist` 静态资源,切换 `/opt/lisglosips/current` 到 s49 并 reload Nginx。
- 验证结果:本地 `corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 build` 通过;`node --check tests/web/remote-web-ui-smoke.mjs` 通过。B `nginx -t` 通过,HTTPS 首页已引用 `/assets/index-D90saQl_.js`;远程 smoke `REMOTE_SMOKE_20260629T094744Z.md` 通过。完整 `test:remote-web-ui` 仍需提供 `LISGLOSIPS_AUTH_PASSWORD` 后执行。
- 回滚方式:应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s48-web-number-library-constants-20260629160000` 并 reload Nginx;本次不涉及数据库迁移或服务状态变更,无数据库回滚。
- 未解决问题:s49 只修复 `Input`,未覆盖 `Checkbox` 子节点透传;完整按钮级巡检在客户网关编辑处仍复现 React #137,已由 s50 修复;未提交 Git。
### 2026-06-29 17:39 - 补齐前端测试门禁:lint 覆盖 Web 源码与 Playwright 菜单巡检
- 状态:已完成本地测试体系修复;未发布服务器
- 操作服务器:本地;未改 B/A/T
- 问题分类:测试覆盖缺口。此前 `eslint.config.mjs` 忽略 `apps/web/src/**``remote-smoke` 只检查 HTML 壳和健康接口,未执行真实 React 路由/菜单渲染,因此 `normalizeBusinessPrefix``emptyNumberLibraryRows``toneForStatus` 这类前端未定义标识不能在发布前被自动发现。
- 完成内容:移除 ESLint 对 `apps/web/src/**` 的忽略,并为前端 JSX 增加 browser globals/JSX parser 配置;新增 `tests/web/remote-web-ui-smoke.mjs`,使用 Playwright 通过 API 验证码登录、注入 refresh cookie,打开真实 Web 页面并逐个点击核心菜单,捕获 `pageerror`、console error、空白页、回到登录页和 `API 数据不可用`;新增 `test:remote-web-ui` 脚本;更新 `tests/README.md``docs/TEST_PLAN_AND_CASES.md` 的 WEB-005/WEB-006,使真实浏览器菜单巡检成为 Web 发布验收项。
- 顺手修复/清理:前端 lint 首次覆盖后发现 `OperationLogsPage.jsx``toneForStatus` 未定义,已补齐;清理若干 Web demo 残留未使用 import/常量/函数,避免前端 lint 被历史死代码阻断;测试脚本依赖新增 `playwright@1.57.0`
- 修改文件:`eslint.config.mjs``package.json``pnpm-lock.yaml``tests/web/remote-web-ui-smoke.mjs``tests/README.md``docs/TEST_PLAN_AND_CASES.md``apps/web/src/pages/ActiveCallsPage.jsx``apps/web/src/pages/CustomerGatewaysPage.jsx``apps/web/src/pages/CustomersPage.jsx``apps/web/src/pages/OperationLogsPage.jsx``apps/web/src/pages/RoutesPage.jsx``apps/web/src/pages/SettingsPage.jsx``apps/web/src/pages/SipOpsPage.jsx``apps/web/src/pages/VendorLineGroupsPage.jsx``apps/web/src/pages/VendorsPage.jsx``apps/web/src/utils/formatters.js``IMPLEMENTATION_STATUS.md`
- 验证结果:`corepack pnpm@10.33.0 lint` 通过,确认前端源码已纳入 lint`corepack pnpm@10.33.0 build` 通过,生成 Web JS `index-gFC83nZO.js``corepack pnpm@10.33.0 typecheck` 通过;`corepack pnpm@10.33.0 test` 通过,26 个测试文件 93 条测试通过;`corepack pnpm@10.33.0 prisma:validate` 通过;`node --check tests/web/remote-web-ui-smoke.mjs` 通过。`corepack pnpm@10.33.0 test:remote-web-ui` 在未设置 `LISGLOSIPS_AUTH_PASSWORD` 时按设计退出并提示必须提供密码,未进行完整远程浏览器登录巡检。
- 回滚方式:回退上述测试配置、脚本、文档和前端 lint 清理文件即可恢复旧测试行为;本次未改数据库、未重启服务、未切换 B release。
- 未解决问题:需要在具备 Web 管理员密码环境变量的验收环境执行 `pnpm test:remote-web-ui` 形成正式远程报告;未提交 Git。
### 2026-06-29 16:10 - Web-only s48 修复号码库页面常量误放导致空白
- 状态:已完成
- 操作服务器:本地、B;未改数据库,未重启 API/Worker,未改 A/T
- 问题分类:真前端 bug。号码库页面 `NumberLibraryPage.jsx` 使用 `numberLibraryTabs``numberLibraryImportExamples``emptyNumberLibraryRows``emptyNumberLibraryTotals``normalizeNumberLibraryList`,但这些常量/函数误放在 `BusinessPrefixesPage.jsx` 文件末尾,导致点击号码库菜单时抛出 `ReferenceError: emptyNumberLibraryRows is not defined` 并出现空白页。这不是后端 API、浏览器登录态或权限问题。
- 完成内容:将号码库页面依赖的 tabs、导入示例、空数据初始值和列表归一化函数移回 `NumberLibraryPage.jsx`;从 `BusinessPrefixesPage.jsx` 删除误放的号码库代码,保留业务前缀自身的 `normalizeBusinessPrefix()`
- 修改文件:`apps/web/src/pages/NumberLibraryPage.jsx``apps/web/src/pages/BusinessPrefixesPage.jsx``IMPLEMENTATION_STATUS.md`
- 发布情况:本地构建生成 Web 资源 `index-wPzMqk8x.js` / `index-B2uYBtS3.css`;上传 Web tar `s48-web-number-library-constants-20260629160000-web.tar.gz`SHA256 `3e2b5e8cc160dc1d40bb5504a4f7960c903f7485c6b295407e0163986161bb1a`B 从 s47 复制生成 `/opt/lisglosips/releases/s48-web-number-library-constants-20260629160000`,只替换 `public``apps/web/dist` 静态资源,切换 `/opt/lisglosips/current` 到 s48 并 reload Nginx。
- 验证结果:本地 `corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 build` 通过。B `nginx -t` 通过,HTTPS 首页已引用 `/assets/index-wPzMqk8x.js`;远程 smoke `REMOTE_SMOKE_20260629T080917Z.md` 通过;B 本机 API 复测 `/number-library/cities?take=1``/phone-segments?take=1``/area-codes?take=1``/carrier-prefix-rules?take=1` 均返回 200。
- 回滚方式:应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s47-web-business-prefix-normalizer-20260629154500` 并 reload Nginx;本次不涉及数据库迁移或服务状态变更,无数据库回滚。
- 未解决问题:浏览器可能缓存旧 JS,若仍看到同样错误,需强制刷新或清理站点缓存后再测;真实 80 万手机号段尚未导入,号码库列表为空是当前已知数据状态;未提交 Git。
### 2026-06-29 15:55 - Web-only s47 修复业务前缀管理页面归一化函数缺失
- 状态:已完成
- 操作服务器:本地、B;未改数据库,未重启 API/Worker,未改 A/T
- 问题分类:真前端 bug。业务前缀管理页面 `loadPrefixes()` 调用 `normalizeBusinessPrefix`,但 `BusinessPrefixesPage.jsx` 未定义该函数,导致接口请求成功后在前端数据归一化阶段抛出 `ReferenceError`,页面误显示“API 数据不可用”。这不是后端 API、浏览器登录态或权限问题。
- 完成内容:在业务前缀页面补齐 `normalizeBusinessPrefix()`,并引入 `formatDate``zhStatus`,字段映射与现有页面保持一致:`prefix``name``description``priority`、中文状态、使用客户网关数、创建日期。
- 修改文件:`apps/web/src/pages/BusinessPrefixesPage.jsx``IMPLEMENTATION_STATUS.md`
- 发布情况:本地构建生成 Web 资源 `index-MxyRK-fA.js` / `index-B2uYBtS3.css`;上传 Web tar `s47-web-business-prefix-normalizer-20260629154500-web.tar.gz`SHA256 `d23a227e1836f333594d1ea8f8e3bd944b82ee57b23b610d3b03f3dd2670b328`B 从 s46 复制生成 `/opt/lisglosips/releases/s47-web-business-prefix-normalizer-20260629154500`,只替换 `public``apps/web/dist` 静态资源,切换 `/opt/lisglosips/current` 到 s47 并 reload Nginx。
- 验证结果:本地 `corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 build` 通过。B `nginx -t` 通过,HTTPS 首页已引用 `/assets/index-MxyRK-fA.js`;远程 smoke `REMOTE_SMOKE_20260629T075504Z.md` 通过,首页、live/ready health、captcha 均通过。
- 回滚方式:应用回滚可将 `/opt/lisglosips/current` 指回 `/opt/lisglosips/releases/s46-bugfix-recharge-active-numberlib-20260629151000` 并 reload Nginx;本次不涉及数据库迁移或服务状态变更,无数据库回滚。
- 未解决问题:浏览器可能缓存旧 JS,若页面仍显示同样错误,需强制刷新或清理站点缓存后再测;未提交 Git。
### 2026-06-29 15:41 - 发布 s46 修复 8.2/8.7/8.8 到 B 测试环境
- 状态:已完成
- 操作服务器:本地、B;未改 A/T 配置,未执行 A/B/T 三机实呼链路变更
- 测试结果最终分类:8.2 负数客户充值返回 `MONEY_INVALID` 是确定 bug,已修复为客户余额可正向充值也可负数扣款,仍拒绝 0 和负零;8.7 超长 active call dialog id 返回 404 是确定 bug,根因包含服务层校验不足和 Fastify 默认 `maxParamLength=100` 导致 129 位路径参数先被路由层拒绝,已修复为 400 `ACTIVE_CALL_ID_INVALID`;8.8 管理员菜单缺少号码库是既有 B 环境 RBAC 数据缺失 `number_library.view/manage`,不是浏览器缓存或登录态问题,已用迁移补齐内置角色权限;8.9 轻量 API burst p95 超过 10 秒和 BYE 被 `403 Rate Limited` 未纳入本轮代码修复,分别需继续查 B API 性能日志和 A 侧 OpenSIPS 限流策略;8.10 阻塞项属于权限/维护窗口限制,不是本轮业务代码缺陷。
- 完成内容:客户充值接口改为客户侧允许负数扣款、供应商侧继续只允许正数;active call hangup ID 校验收紧为 1-128 位安全字符且必须包含 `@``.``:`,并将 API Fastify `routerOptions.maxParamLength` 调整到 256,让超长 ID 进入应用校验返回 400;新增 active-calls e2e 覆盖 129 位 ID;新增 Prisma 迁移补齐 `number_library.view/manage` 权限及 `ROLE_SUPER_ADMIN``ROLE_OPERATOR``ROLE_TECH_OPS` 的角色权限关联。
- 修改文件:`apps/api/src/main.ts``apps/api/src/modules/recharges/recharges.service.ts``apps/api/src/modules/recharges/recharges.e2e.spec.ts``apps/api/src/modules/active-calls/active-calls.service.ts``apps/api/src/modules/active-calls/active-calls.service.spec.ts``apps/api/src/modules/active-calls/active-calls.e2e.spec.ts``prisma/migrations/20260629150000_number_library_builtin_role_permissions/migration.sql``IMPLEMENTATION_STATUS.md`
- 发布情况:B 已从 `s45-vendor-cps-sipstate-20260629113500` 复制生成并切换到 `/opt/lisglosips/releases/s46-bugfix-recharge-active-numberlib-20260629151000`;本次因大包上传链路多次卡住,采用基于 s45 的补丁 release,替换构建后的 API JS/SourceMap 和新增迁移 SQL,而非完整源码包重建 artifact;B `/opt/lisglosips/current` 已指向 s46。发布前触发 B 备份,MySQL `/data/backups/mysql/20260629T070448Z`、Redis `/data/backups/redis/20260629T070448Z`,两者 SHA256 校验通过。
- 数据库迁移说明:首次 `prisma migrate deploy` 在 s46 未切 current 前因 Prisma engine 权限 `EACCES` 失败,已修正 s46 engine 权限;第二次因迁移 SQL 漏写 `updated_at` 触发 P3018,current 仍未切换,修正迁移后执行 `prisma migrate resolve --rolled-back 20260629150000_number_library_builtin_role_permissions` 并重新 `migrate deploy`,最终迁移成功。迁移只新增/补齐权限主数据和角色权限关联,不改业务表结构。
- 验证结果:本地 `corepack pnpm@10.33.0 lint` 通过;`typecheck` 通过;`prisma:validate` 通过;全量 `test` 通过,26 个测试文件 93 条测试通过;`build` 通过,Web 产物仍为 `index-CBRZZA7t.js` / `index-B2uYBtS3.css`。B preflight 通过,API/MySQL/Redis/Nginx/Worker/监控服务 activeAPI ready 为 `ok`;远程 smoke `REMOTE_SMOKE_20260629T074052Z.md` 通过;B 本机 API 定向复测通过:号码库权限 6 条角色关联存在、`GET /api/v2/number-library/cities?take=1` 返回 200、129 位 active-call hangup 返回 400 `ACTIVE_CALL_ID_INVALID`、临时客户 `cus_aeef878b32534914a9b64ae0196d` 正向充值 10 后负数扣款 `-3.250000`,余额从 `10.000000``6.750000``-0.000000` 返回 400 `MONEY_INVALID`
- 回滚方式:应用回滚可执行 `/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-rollback.sh /opt/lisglosips/releases/s45-vendor-cps-sipstate-20260629113500`,或手动将 `/opt/lisglosips/current` 指回 s45 后重启 `lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher` 并 reload Nginx;数据库回滚通常可保留权限主数据,若必须撤回菜单权限,删除 `role_permissions``ROLE_SUPER_ADMIN``ROLE_OPERATOR``ROLE_TECH_OPS``number_library.view/manage` 的 6 条关联,必要时先用上述 MySQL/Redis 备份在隔离环境验证;本轮远程验证留下的 `codex_s46_` 临时客户和对应余额流水可按测试数据保留审计或在维护窗口按客户 ID 清理。
- 未解决问题:未提交 Git8.9 API p95 和 BYE 限流仍待单独定位;8.10 阻塞项仍需权限或维护窗口;本轮是 B API/RBAC 修复,不涉及 A/T SIP 热路径,因此未做新的三机实呼验收,若后续调整 A 侧限流则必须补做 A/B/T 端到端验收。
### 2026-06-29 14:59 - 远程测试报告核对与本地修复:负数扣款、active call ID、号码库权限
- 状态:已完成本地修复,待用户确认是否发布到 B
- 操作服务器:本地;未连接 A/B/T,未读取或回显任何服务器凭据
- 测试结果分类:8.2 负数客户充值返回 `MONEY_INVALID` 是确定 bug8.7 超长/无效 active call dialog id 返回 404 是确定 bug;8.8 管理员菜单缺少号码库不是浏览器缓存问题,前端已有菜单和权限裁剪,判断为 B 既有 RBAC 数据未补齐导致的发布/数据问题;8.9 轻量 API burst p95 超过 10 秒是待定位性能问题,SIP CPS 探针 BYE 被限流是 A 侧限流策略待确认问题;8.10 备份、隔离恢复、真实回滚、OpenSIPS 配置恢复为 sudo 权限或维护窗口阻塞,不是代码缺陷,灰度呼叫和健康回归已通过。
- 完成内容:客户充值接口改为允许 `POST /customers/:id/recharges` 传负数金额作为扣款,继续拒绝 0、负零、超精度和超范围金额;供应商充值接口保持只允许正数。当前通话 hangup ID 校验收紧为 1-128 位安全字符且必须包含 `@``.``:`,避免无意义超长字符串进入 OpenSIPS MI。新增 Prisma 数据迁移补齐 `number_library.view/manage` 权限,并为 `ROLE_SUPER_ADMIN``ROLE_OPERATOR``ROLE_TECH_OPS` 写入号码库权限关联,修复既有环境 seed 未重跑时管理员缺菜单的问题。
- 修改文件:`apps/api/src/modules/recharges/recharges.service.ts``apps/api/src/modules/recharges/recharges.e2e.spec.ts``apps/api/src/modules/active-calls/active-calls.service.ts``apps/api/src/modules/active-calls/active-calls.service.spec.ts``prisma/migrations/20260629150000_number_library_builtin_role_permissions/migration.sql``IMPLEMENTATION_STATUS.md`
- 验证结果:定向 `corepack pnpm@10.33.0 exec vitest run apps/api/src/modules/recharges/recharges.e2e.spec.ts apps/api/src/modules/active-calls/active-calls.service.spec.ts --hookTimeout=60000 --maxWorkers=1` 通过,2 个测试文件 10 条测试通过;`corepack pnpm@10.33.0 lint` 通过;`corepack pnpm@10.33.0 typecheck` 通过;`corepack pnpm@10.33.0 prisma:validate` 通过;`corepack pnpm@10.33.0 test` 通过,25 个测试文件 92 条测试通过;`corepack pnpm@10.33.0 build` 通过,Web 产物仍为 `index-CBRZZA7t.js` / `index-B2uYBtS3.css`
- 发布情况:未提交 Git,未发布 B,未执行数据库迁移;B 当前 release 仍为 `s45-vendor-cps-sipstate-20260629113500`
- 回滚方式:回退上述源码和测试文件即可恢复旧本地行为;若已在 B 执行新增 Prisma 数据迁移,可通过删除 `role_permissions``ROLE_SUPER_ADMIN`/`ROLE_OPERATOR`/`ROLE_TECH_OPS``number_library.view/manage` 的关联来撤回菜单权限补齐,通常无需删除 `permissions` 主数据;应用层按 S30 Runbook 切回上一 release。
- 未解决问题:8.9 API p95 需服务端日志/指标进一步定位;BYE 被 CPS 限流涉及 A OpenSIPS 防护策略,修改前必须备份 A 配置并说明重启影响;8.10 阻塞项需 sudo 或维护窗口;号码库真实 80 万号段尚未导入。
### 2026-06-29 12:12 - 需求修改/二期变更发布到 B:供应商启停移除、客户启停、CPS 与当前通话 SIP 状态
- 状态:已完成
+4 -1
View File
@@ -11,7 +11,10 @@ async function bootstrap(): Promise<void> {
AppModule,
new FastifyAdapter({
trustProxy: true,
logger: false
logger: false,
routerOptions: {
maxParamLength: 256
}
}),
{
bufferLogs: true
@@ -0,0 +1,88 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Test, type TestingModule } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import request from 'supertest';
import { signAccessToken, type PermissionKey } from '@lisglosips/auth';
import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from '../audit/audit.repository.js';
import { IDENTITY_REPOSITORY, type IdentityRepository } from '../security/identity.repository.js';
import type { CurrentUser } from '../security/security.metadata.js';
import { OpenSipsMiClient } from './opensips-mi.client.js';
class MemoryIdentityRepository implements IdentityRepository {
users = new Map<string, CurrentUser>();
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
return this.users.get(userId) ?? null;
}
}
class MemoryAuditRepository implements AuditRepository {
async write(_input: AuditEntryInput): Promise<void> {}
}
describe('active calls API', () => {
let app: NestFastifyApplication;
const tokenFor = (userId: string) =>
signAccessToken(
{
sub: userId,
username: userId,
roles: ['test'],
typ: 'access'
},
{
secret: 'test-only-access-token-secret-min-32-bytes',
issuer: 'lisglosips-api',
audience: 'lisglosips-web',
ttlSeconds: 900
}
);
beforeAll(async () => {
process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips';
process.env.REDIS_URL = 'redis://127.0.0.1:6379/0';
process.env.LISGLOSIPS_LOG_LEVEL = 'silent';
process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes';
const identities = new MemoryIdentityRepository();
identities.users.set('usr_active', {
id: 'usr_active',
username: 'active',
roles: ['话务'],
permissions: ['active_calls.view', 'active_calls.manage'] as PermissionKey[]
});
const { AppModule } = await import('../app.module.js');
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule]
})
.overrideProvider(IDENTITY_REPOSITORY)
.useValue(identities)
.overrideProvider(AUDIT_REPOSITORY)
.useValue(new MemoryAuditRepository())
.overrideProvider(OpenSipsMiClient)
.useValue({ endDialog: vi.fn(), listDialogs: vi.fn() })
.compile();
app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter({ logger: false, routerOptions: { maxParamLength: 256 } }));
app.setGlobalPrefix('api/v2');
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app?.close();
});
it('returns ACTIVE_CALL_ID_INVALID for long invalid dialog ids before MI calls', async () => {
await request(app.getHttpServer())
.post(`/api/v2/active-calls/${'x'.repeat(129)}/hangup`)
.set('Authorization', `Bearer ${tokenFor('usr_active')}`)
.expect(400)
.expect((response) => {
expect(response.body.code).toBe('ACTIVE_CALL_ID_INVALID');
});
});
});
@@ -120,5 +120,7 @@ describe('active calls service', () => {
const service = new ActiveCallsService({ endDialog: vi.fn() } as unknown as OpenSipsMiClient);
await expect(service.hangup('../../etc/passwd')).rejects.toBeInstanceOf(BadRequestException);
await expect(service.hangup('x'.repeat(129))).rejects.toBeInstanceOf(BadRequestException);
await expect(service.hangup('x'.repeat(20))).rejects.toBeInstanceOf(BadRequestException);
});
});
@@ -22,7 +22,7 @@ export interface ActiveCallSummary {
raw: Record<string, unknown>;
}
const SAFE_DIALOG_ID = /^[A-Za-z0-9@._:%+\-=]{1,220}$/;
const SAFE_DIALOG_ID = /^(?=.{1,128}$)(?=.*[@.:])[A-Za-z0-9@._:%+\-=]+$/;
@Injectable()
export class ActiveCallsService {
@@ -198,6 +198,32 @@ describe('S12 recharges API', () => {
expect(audit.entries.some((entry) => entry.module === 'recharges' && entry.action === 'customer_recharge' && entry.result === 'SUCCESS')).toBe(true);
});
it('creates negative customer recharge as a balance deduction', async () => {
await request(app.getHttpServer())
.post('/api/v2/customers/cus_seed/recharges')
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
.send({ amount: '-3.25', idempotencyKey: 'customer-deduct-001', remark: 'manual deduction' })
.expect(201)
.expect((response) => {
expect(response.body).toMatchObject({
accountType: 'CUSTOMER',
accountId: 'cus_seed',
amount: '-3.250000',
beforeBalance: '25.250000',
afterBalance: '22.000000'
});
});
await request(app.getHttpServer())
.post('/api/v2/customers/cus_seed/recharges')
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
.send({ amount: '-0.000000', idempotencyKey: 'customer-deduct-zero' })
.expect(400)
.expect((response) => {
expect(response.body.code).toBe('MONEY_INVALID');
});
});
it('creates vendor recharge, lists ledgers, and rejects idempotency conflicts', async () => {
await request(app.getHttpServer())
.post('/api/v2/vendors/ven_seed/recharges')
@@ -219,9 +245,18 @@ describe('S12 recharges API', () => {
.send({ amount: '4.5', idempotencyKey: 'vendor-rch-001' })
.expect(409);
await request(app.getHttpServer())
.post('/api/v2/vendors/ven_seed/recharges')
.set('Authorization', `Bearer ${tokenFor('usr_finance')}`)
.send({ amount: '-1', idempotencyKey: 'vendor-negative-001' })
.expect(400)
.expect((response) => {
expect(response.body.code).toBe('MONEY_INVALID');
});
const list = await request(app.getHttpServer()).get('/api/v2/recharges?take=10').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(200);
expect(list.body.total).toBe(2);
expect(list.body.total).toBe(3);
expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'CUSTOMER')).toBe(true);
expect(list.body.items.some((item: { accountType: string }) => item.accountType === 'VENDOR')).toBe(true);
});
@@ -35,7 +35,7 @@ export class RechargesService {
}
rechargeCustomer(customerId: string, body: RechargeDto, actorId?: string): Promise<RechargeSummary> {
const amount = this.money(body.amount, 'amount');
const amount = this.signedMoney(body.amount, 'amount');
const idempotencyKey = this.idempotencyKey(body.idempotencyKey);
const remark = this.optionalString(body.remark, 'remark', 500);
@@ -50,7 +50,7 @@ export class RechargesService {
}
rechargeVendor(vendorId: string, body: RechargeDto, actorId?: string): Promise<RechargeSummary> {
const amount = this.money(body.amount, 'amount');
const amount = this.positiveMoney(body.amount, 'amount');
const idempotencyKey = this.idempotencyKey(body.idempotencyKey);
const remark = this.optionalString(body.remark, 'remark', 500);
@@ -105,7 +105,7 @@ export class RechargesService {
return trimmed;
}
private money(value: unknown, field: string): string {
private positiveMoney(value: unknown, field: string): string {
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
if (!/^(?:0|[1-9]\d{0,13})(?:\.\d{1,6})?$/.test(raw)) {
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a positive decimal with up to 6 places.` });
@@ -120,6 +120,23 @@ export class RechargesService {
return normalized;
}
private signedMoney(value: unknown, field: string): string {
const raw = typeof value === 'number' ? value.toString() : typeof value === 'string' ? value.trim() : '';
if (!/^-?(?:0|[1-9]\d{0,13})(?:\.\d{1,6})?$/.test(raw)) {
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must be a non-zero signed decimal with up to 6 places.` });
}
const negative = raw.startsWith('-');
const unsigned = negative ? raw.slice(1) : raw;
const [integerPart, fractionPart = ''] = unsigned.split('.');
const normalized = `${negative ? '-' : ''}${integerPart}.${fractionPart.padEnd(6, '0')}`;
if (normalized === '0.000000' || normalized === '-0.000000') {
throw new BadRequestException({ code: 'MONEY_INVALID', message: `${field} must not be zero.` });
}
return normalized;
}
private pageNumber(value: unknown, defaultValue: number, max: number): number {
if (value === undefined) {
return defaultValue;
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" />
<title>LisgloSIPS - 聆界SIP管理平台</title>
</head>
<body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+13 -1
View File
@@ -495,7 +495,19 @@ export default function App() {
<div className={`prototype-app ${collapsed ? 'sidebar-collapsed' : ''}`}>
<aside className="sidebar">
<div className="brand-block">
<div className="brand-mark"></div>
<div className="brand-expanded">
<img
className="brand-logo brand-logo-expanded"
src="/brand/logo1.png"
alt="聆界SIP管理平台"
/>
<span className="brand-sip-text">SIP</span>
</div>
<img
className="brand-logo brand-logo-collapsed"
src="/brand/logo2.png"
alt="聆界SIP管理平台"
/>
<div className="brand-copy">
<strong>聆界SIP管理平台</strong>
<span>LisgloSIPS</span>
+16 -12
View File
@@ -30,7 +30,7 @@ export function Field({ label, hint, error, children }) {
);
}
export function Input(props) {
export function Input({ children: _children, ...props }) {
return <input className="ui-input" {...props} />;
}
@@ -46,34 +46,37 @@ export function Select({ children, ...props }) {
);
}
export function Checkbox({ label, checked, ...props }) {
export function Checkbox({ label, checked, children, onChange, ...props }) {
const labelContent = label ?? children;
return (
<label className="ui-check">
<input type="checkbox" checked={checked} {...props} />
<input type="checkbox" checked={checked} onChange={(event) => onChange?.(event.target.checked, event)} {...props} />
<span className="ui-check-box" aria-hidden="true" />
<span>{label}</span>
<span>{labelContent}</span>
</label>
);
}
export function Radio({ label, checked, ...props }) {
export function Radio({ label, checked, children, onChange, ...props }) {
const labelContent = label ?? children;
return (
<label className="ui-check">
<input type="radio" checked={checked} {...props} />
<input type="radio" checked={checked} onChange={(event) => onChange?.(event.target.checked, event)} {...props} />
<span className="ui-radio-dot" aria-hidden="true" />
<span>{label}</span>
<span>{labelContent}</span>
</label>
);
}
export function Switch({ label, checked, ...props }) {
export function Switch({ label, checked, children, onChange, ...props }) {
const labelContent = label ?? children;
return (
<label className="ui-switch">
<input type="checkbox" checked={checked} {...props} />
<input type="checkbox" checked={checked} onChange={(event) => onChange?.(event.target.checked, event)} {...props} />
<span className="ui-switch-track" aria-hidden="true">
<span className="ui-switch-thumb" />
</span>
<span>{label}</span>
<span>{labelContent}</span>
</label>
);
}
@@ -138,10 +141,11 @@ export function Progress({ value }) {
);
}
export function Slider({ label, value, ...props }) {
export function Slider({ label, value, children, ...props }) {
const labelContent = label ?? children;
return (
<label className="ui-slider">
<span>{label}</span>
<span>{labelContent}</span>
<input type="range" value={value} {...props} />
<output>{value}</output>
</label>
-1
View File
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { Badge, Button } from '../components/ui.jsx';
import { Icon, PageTitle, Panel, ApiNotice, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { formatDateTime, formatDurationText } from '../utils/formatters.js';
import { metrics } from '../fixtures/devFixtures.js';
export function ActiveCallsPage({ activeCalls, activeCallsLoading, activeCallsError, refreshActiveCalls, can = () => true, onHangupActiveCall }) {
const [busyId, setBusyId] = useState('');
+14 -45
View File
@@ -1,11 +1,24 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Field, Input, Select, Textarea } from '../components/ui.jsx';
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
import { enStatus } from '../utils/formatters.js';
import { enStatus, formatDate, zhStatus } from '../utils/formatters.js';
import { api, explainApiError } from '../api.js';
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),
};
}
export function BusinessPrefixesPage({ can = () => true }) {
const [rows, setRows] = useState([]);
const [filters, setFilters] = useState({ keyword: '', status: 'all' });
@@ -227,47 +240,3 @@ export function BusinessPrefixesPage({ can = () => true }) {
</>
);
}
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,
};
}
+42 -25
View File
@@ -31,6 +31,7 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
const strategyPolicies = strategyGateway
? policyRows.filter((item) => item.gateway === strategyGateway).sort((first, second) => first.priority - second.priority)
: [];
const selectedBusinessPrefixCount = gatewayForm.businessPrefixIds.length;
useEffect(() => {
api.businessPrefixes({ status: 'ENABLED' })
.then((items) => setBusinessPrefixOptions(Array.isArray(items) ? items : []))
@@ -170,12 +171,22 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
setPolicyRows((rows) => rows.filter((policy) => policy.gateway !== gateway.id));
setSubmitting(false);
};
const openPolicyDrawer = (gatewayId) => {
setStrategyGateway(gatewayId);
setPolicyModalOpen(false);
setEditingPolicy(null);
setDeletePolicyTarget(null);
setPolicyForm(emptyPolicyForm);
const toggleBusinessPrefix = (prefixId, checked) => {
setGatewayForm((current) => {
const nextIds = checked
? Array.from(new Set([...current.businessPrefixIds, prefixId]))
: current.businessPrefixIds.filter((id) => id !== prefixId);
return { ...current, businessPrefixIds: nextIds };
});
};
const selectAllBusinessPrefixes = () => {
setGatewayForm((current) => ({
...current,
businessPrefixIds: businessPrefixOptions.map((prefix) => prefix.id),
}));
};
const clearBusinessPrefixes = () => {
setGatewayForm((current) => ({ ...current, businessPrefixIds: [] }));
};
const closePolicyDrawer = () => {
setStrategyGateway(null);
@@ -287,10 +298,8 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
<Panel title="客户网关列表" className="wide-panel">
<SimpleTable rows={gatewayRows} columns={[
{ key: 'id', label: 'ID', width: '104px', className: 'table-cell-compact' },
{ key: 'name', label: '名称', width: '168px', className: 'table-cell-compact' },
{ key: 'customer', label: '客户', width: '148px', className: 'table-cell-compact' },
{ key: 'authMode', label: '认证方式' },
{ key: 'authTarget', label: 'IP地址 / 账号名称', render: (row) => (row.authMode === 'IP' || row.authMode === '混合认证' ? (row.sourceIps || []).join(', ') || row.ipAddress : row.sipAccount) },
{ key: 'name', label: '名称', width: '240px', className: 'table-cell-compact' },
{ key: 'customer', label: '客户', width: '220px', className: 'table-cell-compact' },
{ key: 'lineGroupName', label: '落地线路组' },
{ key: 'rate', label: '客户费率', render: (row) => `${row.billingCycleSec || 60}s / ¥${Number(row.cycleRate || 0).toFixed(6)}` },
{ key: 'callerRule', label: '主叫规则', render: (row) => row.callerMatchMode === 'PREFIXES' ? (row.callerPrefixes || []).join(', ') : '任意号码' },
@@ -384,21 +393,29 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
</Select>
</Field>
{gatewayForm.calleeMatchMode === 'BUSINESS_PREFIXES' ? (
<div className="checkbox-grid">
{businessPrefixOptions.map((prefix) => (
<Checkbox
key={prefix.id}
checked={gatewayForm.businessPrefixIds.includes(prefix.id)}
onChange={(checked) => setGatewayForm((current) => ({
...current,
businessPrefixIds: checked
? [...current.businessPrefixIds, prefix.id]
: current.businessPrefixIds.filter((id) => id !== prefix.id),
}))}
>
{prefix.prefix} / {prefix.name}
</Checkbox>
))}
<div className="prefix-picker">
<div className="prefix-picker-head">
<span>已选择 {selectedBusinessPrefixCount} / {businessPrefixOptions.length}</span>
<div className="prefix-picker-actions">
<Button type="button" size="sm" variant="outline" disabled={!businessPrefixOptions.length || selectedBusinessPrefixCount === businessPrefixOptions.length} onClick={selectAllBusinessPrefixes}>全选</Button>
<Button type="button" size="sm" variant="ghost" disabled={!selectedBusinessPrefixCount} onClick={clearBusinessPrefixes}>清空</Button>
</div>
</div>
{businessPrefixOptions.length ? (
<div className="checkbox-grid">
{businessPrefixOptions.map((prefix) => (
<Checkbox
key={prefix.id}
checked={gatewayForm.businessPrefixIds.includes(prefix.id)}
onChange={(checked) => toggleBusinessPrefix(prefix.id, checked)}
>
{prefix.prefix} / {prefix.name}
</Checkbox>
))}
</div>
) : (
<div className="empty-inline">暂无可用业务前缀</div>
)}
</div>
) : null}
<div className="modal-actions">
-1
View File
@@ -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 }) {
+44
View File
@@ -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);
+4 -1
View File
@@ -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
</>
);
}
+1 -2
View File
@@ -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() {
</>
);
}
-3
View File
@@ -34,6 +34,3 @@ export function SettingsPage() {
);
}
const emptyUserForm = { username: '', name: '', phone: '', email: '', roleId: 'R002', status: '启用' };
const emptyRoleForm = { name: '', description: '', status: '启用' };
+1 -2
View File
@@ -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() {
</>
);
}
@@ -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),
};
}
-1
View File
@@ -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 }) {
+117 -5
View File
@@ -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;
}
-2
View File
@@ -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 })}`;
+4 -4
View File
@@ -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 或用户上传录音。 |
+16 -1
View File
@@ -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
}
}
}
];
+36
View File
@@ -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
+52
View File
@@ -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
+33
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+18 -2
View File
@@ -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",
+29
View File
@@ -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
@@ -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');
+844
View File
@@ -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();
});
+30
View File
@@ -0,0 +1,30 @@
# Automated Test Workspace
This directory holds executable test assets derived from `docs/TEST_PLAN_AND_CASES.md`.
- `api/`: API and full `AppModule` E2E suites.
- `web/`: Browser automation suites.
- `smoke/`: Environment smoke checks and optional call-flow probes.
- `fixtures/`: Shared static fixtures used by tests.
- `reports/`: Generated test result files.
Current runnable entry points:
- `pnpm test:baseline`
- `pnpm test:api`
- `pnpm test:smoke`
- `pnpm test:remote-smoke`
- `pnpm test:remote-auth`
- `pnpm test:remote-customers`
- `pnpm test:remote-gateways`
- `pnpm test:remote-vendors`
- `pnpm test:remote-calls`
- `pnpm test:remote-recordings`
- `pnpm test:remote-dashboard`
- `pnpm test:remote-perf-security`
- `pnpm test:remote-web-ui`
- `pnpm db:seed:test`
`pnpm lint` now includes `apps/web/src/**`, so undefined frontend identifiers such as missing page normalizers fail before build.
Use `pnpm test:remote-web-ui` after Web releases. It logs in with `LISGLOSIPS_AUTH_USERNAME` / `LISGLOSIPS_AUTH_PASSWORD`, clicks every non-pending core menu, and fails on blank pages, `API 数据不可用`, `pageerror`, or console errors.
+17
View File
@@ -0,0 +1,17 @@
# API E2E Tests
API automation should prefer full `AppModule` E2E coverage for cross-module workflows, with `hookTimeout` kept at 60 seconds for slow module bootstrap.
Remote black-box API checks:
```powershell
$env:LISGLOSIPS_AUTH_PASSWORD = '<password>'
pnpm test:remote-auth
pnpm test:remote-customers
pnpm test:remote-gateways
pnpm test:remote-vendors
pnpm test:remote-calls
pnpm test:remote-recordings
pnpm test:remote-dashboard
pnpm test:remote-perf-security
```
+435
View File
@@ -0,0 +1,435 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const lowRoleName = process.env.LISGLOSIPS_LOW_ROLE_NAME || '自动化低权限角色';
const timeoutMs = Number(process.env.LISGLOSIPS_AUTH_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
const cookieJar = new Map();
function storeCookies(headers) {
const setCookie = headers['set-cookie'];
const cookies = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
for (const cookie of cookies) {
const [pair] = cookie.split(';');
const index = pair.indexOf('=');
if (index <= 0) {
continue;
}
const name = pair.slice(0, index);
const value = pair.slice(index + 1);
if (value) {
cookieJar.set(name, value);
} else {
cookieJar.delete(name);
}
}
}
function cookieHeader() {
return [...cookieJar.entries()].map(([name, value]) => `${name}=${value}`).join('; ');
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-auth-rbac/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
...(options.withCookies && cookieHeader() ? { Cookie: cookieHeader() } : {}),
};
const req = request(
url,
{
method: options.method || 'GET',
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
const responseBody = Buffer.concat(chunks).toString('utf8');
storeCookies(res.headers);
resolveRequest({
path,
method: options.method || 'GET',
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
setCookie: Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : [],
body: responseBody,
});
});
}
);
req.on('timeout', () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('error', (error) => {
resolveRequest({
path,
method: options.method || 'GET',
ok: false,
statusCode: 0,
durationMs: Date.now() - startedAt,
contentType: '',
setCookie: [],
body: '',
error: error.message,
});
});
if (body) {
req.write(body);
}
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) {
return '';
}
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
function makeCheck(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return makeCheck(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function statusInCheck(name, result, expectedStatuses) {
return makeCheck(
name,
result.ok && expectedStatuses.includes(result.statusCode),
result.error || `status=${result.statusCode}, expected=${expectedStatuses.join('/')}, duration=${result.durationMs}ms`
);
}
function redactedUser(user) {
if (!user || typeof user !== 'object') {
return null;
}
return {
id: user.id,
username: user.username,
displayName: user.displayName,
roles: Array.isArray(user.roles) ? user.roles : [],
permissionCount: Array.isArray(user.permissions) ? user.permissions.length : 0,
};
}
async function getJson(path, accessToken) {
const result = await requestApi(path, { accessToken });
return { result, body: parseJson(result) };
}
async function ensureLowPrivilegeRole(accessToken) {
const permissionIds = ['dashboard.view'];
const rolesBefore = await getJson('/api/v2/roles', accessToken);
let role = Array.isArray(rolesBefore.body) ? rolesBefore.body.find((item) => item.name === lowRoleName) : null;
if (!role) {
const created = await requestApi('/api/v2/roles', {
method: 'POST',
accessToken,
body: {
name: lowRoleName,
description: 'Codex remote auth/RBAC test role',
permissionIds,
},
});
role = parseJson(created);
return { role, action: 'created', result: created };
}
const updated = await requestApi(`/api/v2/roles/${encodeURIComponent(role.id)}`, {
method: 'PATCH',
accessToken,
body: {
name: lowRoleName,
description: 'Codex remote auth/RBAC test role',
status: 'ENABLED',
permissionIds,
},
});
role = parseJson(updated);
return { role, action: 'updated', result: updated };
}
async function ensureLowPrivilegeUser(accessToken, roleId) {
const usersBefore = await getJson('/api/v2/users', accessToken);
let user = Array.isArray(usersBefore.body) ? usersBefore.body.find((item) => item.username === lowUsername) : null;
if (!user) {
const created = await requestApi('/api/v2/users', {
method: 'POST',
accessToken,
body: {
username: lowUsername,
displayName: 'Codex低权限测试用户',
password: lowPassword,
requirePasswordChange: false,
roleIds: [roleId],
},
});
user = parseJson(created);
return { user, action: 'created', createResult: created, updateResult: null, resetResult: null };
}
const updated = await requestApi(`/api/v2/users/${encodeURIComponent(user.id)}`, {
method: 'PATCH',
accessToken,
body: {
displayName: 'Codex低权限测试用户',
status: 'ENABLED',
roleIds: [roleId],
},
});
const reset = await requestApi(`/api/v2/users/${encodeURIComponent(user.id)}/reset-password`, {
method: 'POST',
accessToken,
body: { password: lowPassword },
});
user = parseJson(reset);
return { user, action: 'updated', createResult: null, updateResult: updated, resetResult: reset };
}
async function loginWithCaptcha(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const captchaCode = decodeCaptcha(captchaBody?.imageDataUrl);
const loginResult = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: {
username: loginUsername,
password: loginPassword,
captchaId: captchaBody?.captchaId,
captchaCode,
},
});
return { captcha, captchaBody, captchaCode, loginResult, loginBody: parseJson(loginResult) };
}
function reportLine(check) {
return `| ${check.pass ? 'PASS' : 'FAIL'} | ${check.name} | ${String(check.detail).replace(/\|/g, '\\|')} |`;
}
const checks = [];
const publicCaptcha = await requestApi('/api/v2/auth/captcha');
const publicCaptchaBody = parseJson(publicCaptcha);
checks.push(statusCheck('captcha endpoint is public', publicCaptcha, 200));
checks.push(
makeCheck(
'captcha returns id, SVG image, and expiry',
typeof publicCaptchaBody?.captchaId === 'string' &&
String(publicCaptchaBody?.imageDataUrl || '').startsWith('data:image/svg+xml;base64,') &&
typeof publicCaptchaBody?.expiresAt === 'string',
publicCaptchaBody ? `captchaId=${publicCaptchaBody.captchaId}, expiresAt=${publicCaptchaBody.expiresAt}` : 'body is not JSON'
)
);
const protectedWithoutToken = await requestApi('/api/v2/customers');
checks.push(statusCheck('protected API rejects anonymous request', protectedWithoutToken, 401));
const invalidCaptchaLogin = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: { username, password, captchaId: publicCaptchaBody?.captchaId || 'missing', captchaCode: 'WRONG' },
});
const invalidCaptchaBody = parseJson(invalidCaptchaLogin);
checks.push(statusCheck('login rejects invalid captcha', invalidCaptchaLogin, 401));
checks.push(
makeCheck(
'invalid captcha returns AUTH_CAPTCHA_INVALID',
invalidCaptchaBody?.code === 'AUTH_CAPTCHA_INVALID',
`code=${invalidCaptchaBody?.code || 'n/a'}`
)
);
const loginCaptcha = await requestApi('/api/v2/auth/captcha');
const loginCaptchaBody = parseJson(loginCaptcha);
const captchaCode = decodeCaptcha(loginCaptchaBody?.imageDataUrl);
checks.push(makeCheck('captcha answer can be parsed from SVG', captchaCode.length >= 4, `length=${captchaCode.length}`));
const badPasswordCaptcha = await requestApi('/api/v2/auth/captcha');
const badPasswordCaptchaBody = parseJson(badPasswordCaptcha);
const badPasswordCaptchaCode = decodeCaptcha(badPasswordCaptchaBody?.imageDataUrl);
const badPasswordLogin = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: {
username,
password: `${password}-wrong`,
captchaId: badPasswordCaptchaBody?.captchaId,
captchaCode: badPasswordCaptchaCode,
},
});
const badPasswordBody = parseJson(badPasswordLogin);
checks.push(statusCheck('login rejects invalid password with valid captcha', badPasswordLogin, 401));
checks.push(
makeCheck(
'invalid credentials code is returned',
badPasswordBody?.code === 'AUTH_INVALID_CREDENTIALS',
`code=${badPasswordBody?.code || 'n/a'}`
)
);
const login = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: {
username,
password,
captchaId: loginCaptchaBody?.captchaId,
captchaCode,
},
});
const loginBody = parseJson(login);
const loginCookie = login.setCookie.find((cookie) => cookie.startsWith('lisglosips_refresh='));
checks.push(statusCheck('login succeeds with valid captcha and password', login, 200));
checks.push(makeCheck('login returns access token', typeof loginBody?.accessToken === 'string' && loginBody.accessToken.length > 20, `tokenLength=${loginBody?.accessToken?.length || 0}`));
checks.push(makeCheck('login returns user profile and permissions', Array.isArray(loginBody?.user?.permissions), JSON.stringify(redactedUser(loginBody?.user))));
checks.push(makeCheck('login sets HttpOnly refresh cookie', Boolean(loginCookie && /HttpOnly/i.test(loginCookie)), loginCookie ? 'refresh cookie present' : 'refresh cookie missing'));
const lowRole = await ensureLowPrivilegeRole(loginBody?.accessToken);
checks.push(statusCheck(`low-privilege role is ${lowRole.action}`, lowRole.result, lowRole.action === 'created' ? 201 : 200));
checks.push(
makeCheck(
'low-privilege role only has dashboard.view',
Array.isArray(lowRole.role?.permissionIds) && lowRole.role.permissionIds.length === 1 && lowRole.role.permissionIds[0] === 'dashboard.view',
`roleId=${lowRole.role?.id || 'n/a'}, permissions=${Array.isArray(lowRole.role?.permissionIds) ? lowRole.role.permissionIds.join(',') : 'n/a'}`
)
);
const lowUser = await ensureLowPrivilegeUser(loginBody?.accessToken, lowRole.role?.id);
const lowUserResult = lowUser.createResult || lowUser.resetResult || lowUser.updateResult;
checks.push(statusInCheck(`low-privilege user is ${lowUser.action}`, lowUserResult, lowUser.action === 'created' ? [201] : [200, 201]));
checks.push(
makeCheck(
'low-privilege user is bound to low role',
Array.isArray(lowUser.user?.roleIds) && lowUser.user.roleIds.includes(lowRole.role?.id),
`userId=${lowUser.user?.id || 'n/a'}, roleIds=${Array.isArray(lowUser.user?.roleIds) ? lowUser.user.roleIds.join(',') : 'n/a'}`
)
);
const authorizedDashboard = await requestApi('/api/v2/dashboard/summary', { accessToken: loginBody?.accessToken });
checks.push(statusCheck('bearer token can access protected dashboard summary', authorizedDashboard, 200));
const invalidToken = await requestApi('/api/v2/dashboard/summary', { accessToken: 'invalid.token.value' });
checks.push(statusCheck('invalid bearer token is rejected', invalidToken, 401));
const refresh = await requestApi('/api/v2/auth/refresh', { method: 'POST', withCookies: true });
const refreshBody = parseJson(refresh);
const rotatedCookie = refresh.setCookie.find((cookie) => cookie.startsWith('lisglosips_refresh='));
checks.push(statusCheck('refresh rotates session and returns new token', refresh, 200));
checks.push(makeCheck('refresh returns access token', typeof refreshBody?.accessToken === 'string' && refreshBody.accessToken !== loginBody?.accessToken, `tokenChanged=${refreshBody?.accessToken !== loginBody?.accessToken}`));
checks.push(makeCheck('refresh sets a rotated refresh cookie', Boolean(rotatedCookie && /HttpOnly/i.test(rotatedCookie)), rotatedCookie ? 'rotated cookie present' : 'rotated cookie missing'));
const authorizedAfterRefresh = await requestApi('/api/v2/dashboard/summary', { accessToken: refreshBody?.accessToken });
checks.push(statusCheck('refreshed bearer token can access protected dashboard summary', authorizedAfterRefresh, 200));
const logout = await requestApi('/api/v2/auth/logout', { method: 'POST', withCookies: true });
checks.push(statusCheck('logout revokes current refresh session', logout, 204));
const refreshAfterLogout = await requestApi('/api/v2/auth/refresh', { method: 'POST', withCookies: true });
checks.push(statusCheck('refresh after logout is rejected', refreshAfterLogout, 401));
const lowLogin = await loginWithCaptcha(lowUsername, lowPassword);
checks.push(makeCheck('low-privilege captcha answer can be parsed from SVG', lowLogin.captchaCode.length >= 4, `length=${lowLogin.captchaCode.length}`));
checks.push(statusCheck('low-privilege user can login', lowLogin.loginResult, 200));
checks.push(makeCheck('low-privilege login returns dashboard.view only', Array.isArray(lowLogin.loginBody?.user?.permissions) && lowLogin.loginBody.user.permissions.length === 1 && lowLogin.loginBody.user.permissions[0] === 'dashboard.view', JSON.stringify(redactedUser(lowLogin.loginBody?.user))));
const lowDashboard = await requestApi('/api/v2/dashboard/summary', { accessToken: lowLogin.loginBody?.accessToken });
checks.push(statusCheck('low-privilege user can access allowed dashboard summary', lowDashboard, 200));
const lowForbidden = await requestApi('/api/v2/users', {
method: 'POST',
accessToken: lowLogin.loginBody?.accessToken,
body: {
username: 'should.not.create',
displayName: 'Should Not Create',
password: 'ShouldNotCreate2026',
roleIds: [lowRole.role?.id],
},
});
checks.push(statusCheck('low-privilege user is forbidden from users.manage endpoint', lowForbidden, 403));
const permissionCount = Array.isArray(loginBody?.user?.permissions) ? loginBody.user.permissions.length : 0;
checks.push(
makeCheck(
'admin account has non-empty permission set',
permissionCount > 0,
`permissionCount=${permissionCount}, roles=${Array.isArray(loginBody?.user?.roles) ? loginBody.user.roles.join(',') : 'n/a'}`
)
);
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_AUTH_RBAC_${stamp}.md`);
const failed = checks.filter((check) => !check.pass);
const report = [
'# Remote Auth, Session, and Permission Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Notes',
'',
'- Password and token values are intentionally omitted.',
'- This run uses the remote B service as a black-box API target.',
'- The low-privilege role and user are created or updated through the admin API before RBAC assertions.',
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const check of checks) {
console.log(`${check.pass ? 'PASS' : 'FAIL'} ${check.name} - ${check.detail}`);
}
console.log(`Report: ${reportPath}`);
if (failed.length > 0) {
process.exitCode = 1;
}
+261
View File
@@ -0,0 +1,261 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const timeoutMs = Number(process.env.LISGLOSIPS_CALLS_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const method = options.method || 'GET';
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-calls-cdr-billing/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
};
console.log(`REQ ${method} ${path}`);
let settled = false;
let req;
const hardTimer = setTimeout(() => req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`)), timeoutMs);
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
resolveRequest(result);
};
req = request(
url,
{
method,
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
finish({
path,
method,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)));
req.on('error', (error) => {
finish({ path, method, ok: false, statusCode: 0, durationMs: Date.now() - startedAt, contentType: '', body: '', error: error.message });
});
if (body) req.write(body);
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) return '';
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
async function login(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const result = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: { username: loginUsername, password: loginPassword, captchaId: captchaBody?.captchaId, captchaCode: decodeCaptcha(captchaBody?.imageDataUrl) },
});
return { result, body: parseJson(result) };
}
function check(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function reportLine(item) {
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
}
function decimalLike(value) {
return typeof value === 'string' && /^-?\d+\.\d{6}$/.test(value);
}
function cdrItemLooksSafe(item) {
const text = JSON.stringify(item);
return !/password|secret|token|ha1/i.test(text);
}
const checks = [];
const adminLogin = await login(username, password);
checks.push(statusCheck('admin can login', adminLogin.result, 200));
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
const accessToken = adminLogin.body?.accessToken;
const lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const lowCdrList = await requestApi('/api/v2/cdrs?take=1', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list CDRs', lowCdrList, 403));
const lowActiveCalls = await requestApi('/api/v2/active-calls', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list active calls', lowActiveCalls, 403));
const cdrList = await requestApi('/api/v2/cdrs?take=20', { accessToken });
const cdrListBody = parseJson(cdrList);
checks.push(statusCheck('CDR list can be queried', cdrList, 200));
checks.push(
check(
'CDR list returns page shape',
Array.isArray(cdrListBody?.items) &&
Number.isInteger(cdrListBody?.meta?.total) &&
cdrListBody.meta.take === 20 &&
cdrListBody.meta.skip === 0 &&
typeof cdrListBody.meta.hasMore === 'boolean',
`total=${cdrListBody?.meta?.total}, take=${cdrListBody?.meta?.take}, skip=${cdrListBody?.meta?.skip}, hasMore=${cdrListBody?.meta?.hasMore}`
)
);
checks.push(check('CDR list items do not expose secrets', (cdrListBody?.items || []).every(cdrItemLooksSafe), `items=${cdrListBody?.items?.length || 0}`));
const firstCdr = cdrListBody?.items?.[0];
if (firstCdr) {
const detail = await requestApi(`/api/v2/cdrs/${encodeURIComponent(firstCdr.id)}`, { accessToken });
const detailBody = parseJson(detail);
checks.push(statusCheck('CDR detail can be fetched', detail, 200));
checks.push(check('CDR detail matches list item id and event id', detailBody?.id === firstCdr.id && detailBody?.eventId === firstCdr.eventId, `id=${detailBody?.id}, eventId=${detailBody?.eventId}`));
checks.push(check('CDR detail does not expose secrets', cdrItemLooksSafe(detailBody), `id=${detailBody?.id}`));
if (detailBody?.rated) {
checks.push(
check(
'rated CDR detail has numeric fee fields',
decimalLike(detailBody.rated.customerFee) && decimalLike(detailBody.rated.vendorCost) && decimalLike(detailBody.rated.grossProfit) && Number.isInteger(detailBody.rated.billSec),
`billSec=${detailBody.rated.billSec}, customerFee=${detailBody.rated.customerFee}, vendorCost=${detailBody.rated.vendorCost}, grossProfit=${detailBody.rated.grossProfit}`
)
);
} else {
checks.push(check('unrated/skipped CDR detail has no rated fee payload', detailBody?.ratingStatus !== 'RATED', `ratingStatus=${detailBody?.ratingStatus}`));
}
const callerFilter = await requestApi(`/api/v2/cdrs?caller=${encodeURIComponent(firstCdr.caller)}&take=10`, { accessToken });
const callerFilterBody = parseJson(callerFilter);
checks.push(statusCheck('CDR caller filter can be queried', callerFilter, 200));
checks.push(check('CDR caller filter returns matching rows', (callerFilterBody?.items || []).every((item) => String(item.caller).includes(firstCdr.caller)), `rows=${callerFilterBody?.items?.length || 0}, caller=${firstCdr.caller}`));
if (firstCdr.calleeOperator) {
const carrierFilter = await requestApi(`/api/v2/cdrs?carrier=${encodeURIComponent(firstCdr.calleeOperator)}&take=10`, { accessToken });
const carrierFilterBody = parseJson(carrierFilter);
checks.push(statusCheck('CDR carrier filter can be queried', carrierFilter, 200));
checks.push(check('CDR carrier filter returns matching rows', (carrierFilterBody?.items || []).every((item) => item.calleeOperator === firstCdr.calleeOperator), `rows=${carrierFilterBody?.items?.length || 0}, carrier=${firstCdr.calleeOperator}`));
}
} else {
checks.push(check('CDR detail checks skipped because no CDR exists', true, 'No CDR rows returned by remote service.'));
}
const invalidCarrier = await requestApi('/api/v2/cdrs?carrier=BAD&take=10', { accessToken });
checks.push(statusCheck('invalid CDR carrier is rejected', invalidCarrier, 400));
checks.push(check('invalid carrier returns CARRIER_INVALID', parseJson(invalidCarrier)?.code === 'CARRIER_INVALID', `code=${parseJson(invalidCarrier)?.code || 'n/a'}`));
const invalidTake = await requestApi('/api/v2/cdrs?take=0', { accessToken });
checks.push(statusCheck('invalid CDR pagination is rejected', invalidTake, 400));
checks.push(check('invalid pagination returns QUERY_INVALID', parseJson(invalidTake)?.code === 'QUERY_INVALID', `code=${parseJson(invalidTake)?.code || 'n/a'}`));
const invalidTimeRange = await requestApi('/api/v2/cdrs?startedFrom=2026-01-02T00:00:00.000Z&startedTo=2026-01-01T00:00:00.000Z', { accessToken });
checks.push(statusCheck('invalid CDR time range is rejected', invalidTimeRange, 400));
checks.push(check('invalid time range returns TIME_RANGE_INVALID', parseJson(invalidTimeRange)?.code === 'TIME_RANGE_INVALID', `code=${parseJson(invalidTimeRange)?.code || 'n/a'}`));
const missingCdr = await requestApi('/api/v2/cdrs/not-a-real-cdr-id', { accessToken });
checks.push(statusCheck('missing CDR detail returns 404', missingCdr, 404));
checks.push(check('missing CDR returns CDR_NOT_FOUND', parseJson(missingCdr)?.code === 'CDR_NOT_FOUND', `code=${parseJson(missingCdr)?.code || 'n/a'}`));
const activeCalls = await requestApi('/api/v2/active-calls', { accessToken });
const activeCallsBody = parseJson(activeCalls);
checks.push(statusCheck('active calls list can be queried', activeCalls, 200));
checks.push(
check(
'active calls response has normalized shape',
typeof activeCallsBody?.generatedAt === 'string' &&
activeCallsBody?.source === 'opensips-mi' &&
Number.isInteger(activeCallsBody?.total) &&
Array.isArray(activeCallsBody?.items),
`source=${activeCallsBody?.source}, total=${activeCallsBody?.total}`
)
);
const invalidHangup = await requestApi(`/api/v2/active-calls/${encodeURIComponent('bad id!')}/hangup`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('invalid active call hangup id is rejected before MI call', invalidHangup, 400));
checks.push(check('invalid active call id returns ACTIVE_CALL_ID_INVALID', parseJson(invalidHangup)?.code === 'ACTIVE_CALL_ID_INVALID', `code=${parseJson(invalidHangup)?.code || 'n/a'}`));
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_CALLS_CDR_BILLING_${stamp}.md`);
const failed = checks.filter((item) => !item.pass);
const report = [
'# Remote SIP Calls, CDR, and Billing API Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Not Executed By This Black-Box API Run',
'',
'- Real IP/SIP customer calls from T through A to UAS.',
'- SIP Digest wrong-password REGISTER/INVITE signaling assertions.',
'- Low-balance hot-path rejection assertions.',
'- Primary/backup route failover proven by live call CDR vendorGatewayId.',
'- Redis Stream CDR injection, duplicate event idempotency, deadletter, and retry/pending checks.',
'- Direct customer balance deduction by CDR Worker transaction.',
'',
'These require A/B/T SIP tooling or Redis/DB side access in addition to the HTTPS API.',
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const item of checks) {
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
}
console.log(`Report: ${reportPath}`);
if (failed.length > 0) process.exitCode = 1;
@@ -0,0 +1,451 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const timeoutMs = Number(process.env.LISGLOSIPS_GATEWAYS_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const prefixValue = process.env.LISGLOSIPS_83_PREFIX || 'C83';
const prefixName = process.env.LISGLOSIPS_83_PREFIX_NAME || '自动化8.3业务前缀';
const customerName = process.env.LISGLOSIPS_83_CUSTOMER_NAME || '自动化8.3客户';
const customerDomain = process.env.LISGLOSIPS_83_CUSTOMER_DOMAIN || 'codex-83.example.test';
const gatewayName = process.env.LISGLOSIPS_83_GATEWAY_NAME || '自动化8.3客户网关';
const gatewayIp = process.env.LISGLOSIPS_83_GATEWAY_IP || '100.83.0.10';
const callerPrefix = process.env.LISGLOSIPS_83_CALLER_PREFIX || '055183';
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const method = options.method || 'GET';
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-customer-gateways-prefixes/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
};
console.log(`REQ ${method} ${path}`);
let settled = false;
let req;
const hardTimer = setTimeout(() => {
req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`));
}, timeoutMs);
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(hardTimer);
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
resolveRequest(result);
};
req = request(
url,
{
method,
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
finish({
path,
method,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('error', (error) => {
finish({
path,
method,
ok: false,
statusCode: 0,
durationMs: Date.now() - startedAt,
contentType: '',
body: '',
error: error.message,
});
});
if (body) {
req.write(body);
}
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) {
return '';
}
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
async function login(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const result = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: {
username: loginUsername,
password: loginPassword,
captchaId: captchaBody?.captchaId,
captchaCode: decodeCaptcha(captchaBody?.imageDataUrl),
},
});
return { result, body: parseJson(result) };
}
function check(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function reportLine(item) {
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
}
async function ensureCustomer(accessToken) {
const list = await requestApi('/api/v2/customers', { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.name === customerName || item.domain === customerDomain) : null;
if (existing) {
const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.id)}`, {
method: 'PATCH',
accessToken,
body: {
name: customerName,
contactName: 'Codex 8.3',
phone: '13800138300',
email: 'codex-83@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.3 customer gateway test customer',
},
});
return { action: 'updated', result: updated, customer: parseJson(updated) };
}
const created = await requestApi('/api/v2/customers', {
method: 'POST',
accessToken,
body: {
name: customerName,
contactName: 'Codex 8.3',
phone: '13800138300',
email: 'codex-83@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.3 customer gateway test customer',
},
});
return { action: 'created', result: created, customer: parseJson(created) };
}
async function ensureBusinessPrefix(accessToken) {
const list = await requestApi(`/api/v2/business-prefixes?keyword=${encodeURIComponent(prefixValue)}`, { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.prefix === prefixValue || item.name === prefixName) : null;
if (existing) {
const updated = await requestApi(`/api/v2/business-prefixes/${encodeURIComponent(existing.id)}`, {
method: 'PATCH',
accessToken,
body: {
prefix: prefixValue,
name: prefixName,
description: 'Codex 8.3 business prefix',
priority: 83,
status: 'ENABLED',
},
});
return { action: 'updated', result: updated, prefix: parseJson(updated) };
}
const created = await requestApi('/api/v2/business-prefixes', {
method: 'POST',
accessToken,
body: {
prefix: prefixValue,
name: prefixName,
description: 'Codex 8.3 business prefix',
priority: 83,
status: 'ENABLED',
},
});
return { action: 'created', result: created, prefix: parseJson(created) };
}
async function firstEnabledLineGroup(accessToken) {
const list = await requestApi('/api/v2/landing-line-groups', { accessToken });
const items = parseJson(list);
return {
result: list,
lineGroup: Array.isArray(items) ? items.find((item) => item.status === 'ENABLED') || items[0] : null,
};
}
async function ensureGateway(accessToken, customerId, lineGroupId, businessPrefixId) {
const list = await requestApi(`/api/v2/customer-gateways?customerId=${encodeURIComponent(customerId)}`, { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.name === gatewayName) : null;
const body = {
customerId,
name: gatewayName,
authMode: 'IP',
sourceIps: [gatewayIp],
lineGroupId,
billingCycleSec: 60,
cycleRate: '0.080000',
callerMatchMode: 'PREFIXES',
callerPrefixes: [callerPrefix],
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixIds: [businessPrefixId],
};
if (existing) {
const updated = await requestApi(`/api/v2/customer-gateways/${encodeURIComponent(existing.id)}`, {
method: 'PATCH',
accessToken,
body,
});
return { action: 'updated', result: updated, gateway: parseJson(updated) };
}
const created = await requestApi('/api/v2/customer-gateways', {
method: 'POST',
accessToken,
body,
});
return { action: 'created', result: created, gateway: parseJson(created) };
}
const checks = [];
const adminLogin = await login(username, password);
checks.push(statusCheck('admin can login', adminLogin.result, 200));
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
const lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const lowPrefixList = await requestApi('/api/v2/business-prefixes', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list business prefixes', lowPrefixList, 403));
const lowGatewayList = await requestApi('/api/v2/customer-gateways', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list customer gateways', lowGatewayList, 403));
const accessToken = adminLogin.body?.accessToken;
const invalidPrefix = await requestApi('/api/v2/business-prefixes', {
method: 'POST',
accessToken,
body: {
prefix: '8.3-*',
name: 'Invalid 8.3 prefix',
priority: 83,
},
});
const invalidPrefixBody = parseJson(invalidPrefix);
checks.push(statusCheck('invalid business prefix is rejected', invalidPrefix, 400));
checks.push(check('invalid business prefix returns BUSINESS_PREFIX_INVALID', invalidPrefixBody?.code === 'BUSINESS_PREFIX_INVALID', `code=${invalidPrefixBody?.code || 'n/a'}`));
const businessPrefix = await ensureBusinessPrefix(accessToken);
checks.push(statusCheck(`business prefix is ${businessPrefix.action}`, businessPrefix.result, businessPrefix.action === 'created' ? 201 : 200));
checks.push(check('business prefix has expected values', businessPrefix.prefix?.prefix === prefixValue && businessPrefix.prefix?.priority === 83, `id=${businessPrefix.prefix?.id}, prefix=${businessPrefix.prefix?.prefix}, priority=${businessPrefix.prefix?.priority}`));
const disablePrefix = await requestApi(`/api/v2/business-prefixes/${encodeURIComponent(businessPrefix.prefix?.id)}/disable`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('business prefix can be disabled', disablePrefix, 201));
checks.push(check('disabled business prefix status is DISABLED', parseJson(disablePrefix)?.status === 'DISABLED', `status=${parseJson(disablePrefix)?.status}`));
const enablePrefix = await requestApi(`/api/v2/business-prefixes/${encodeURIComponent(businessPrefix.prefix?.id)}/enable`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('business prefix can be enabled', enablePrefix, 201));
checks.push(check('enabled business prefix status is ENABLED', parseJson(enablePrefix)?.status === 'ENABLED', `status=${parseJson(enablePrefix)?.status}`));
const customer = await ensureCustomer(accessToken);
checks.push(statusCheck(`test customer is ${customer.action}`, customer.result, customer.action === 'created' ? 201 : 200));
const lineGroup = await firstEnabledLineGroup(accessToken);
checks.push(statusCheck('landing line group list can be fetched', lineGroup.result, 200));
checks.push(check('at least one landing line group is available for gateway binding', Boolean(lineGroup.lineGroup?.id), `lineGroupId=${lineGroup.lineGroup?.id || 'n/a'}, name=${lineGroup.lineGroup?.name || 'n/a'}`));
const invalidGatewayIp = await requestApi('/api/v2/customer-gateways', {
method: 'POST',
accessToken,
body: {
customerId: customer.customer?.id,
name: '自动化8.3无效IP网关',
authMode: 'IP',
sourceIps: ['999.999.999.999'],
lineGroupId: lineGroup.lineGroup?.id,
callerMatchMode: 'ANY',
calleeMatchMode: 'ANY',
},
});
const invalidGatewayIpBody = parseJson(invalidGatewayIp);
checks.push(statusCheck('invalid customer gateway source IP is rejected', invalidGatewayIp, 400));
checks.push(check('invalid source IP returns SOURCE_IP_INVALID', invalidGatewayIpBody?.code === 'SOURCE_IP_INVALID', `code=${invalidGatewayIpBody?.code || 'n/a'}`));
const missingSipPassword = await requestApi('/api/v2/customer-gateways', {
method: 'POST',
accessToken,
body: {
customerId: customer.customer?.id,
name: '自动化8.3无密码SIP网关',
authMode: 'SIP_DIGEST',
sipUsername: 'codex83sip',
sipDomain: customerDomain,
lineGroupId: lineGroup.lineGroup?.id,
callerMatchMode: 'ANY',
calleeMatchMode: 'ANY',
},
});
const missingSipPasswordBody = parseJson(missingSipPassword);
checks.push(statusCheck('SIP gateway without password is rejected', missingSipPassword, 400));
checks.push(
check(
'missing SIP password returns a validation error',
['SIP_PASSWORD_REQUIRED', 'VALIDATION_ERROR'].includes(missingSipPasswordBody?.code),
`code=${missingSipPasswordBody?.code || 'n/a'}`
)
);
const gateway = await ensureGateway(accessToken, customer.customer?.id, lineGroup.lineGroup?.id, businessPrefix.prefix?.id);
checks.push(statusCheck(`customer gateway is ${gateway.action}`, gateway.result, gateway.action === 'created' ? 201 : 200));
checks.push(
check(
'customer gateway binds IP, caller prefix, and business prefix',
gateway.gateway?.sourceIps?.includes(gatewayIp) &&
gateway.gateway?.callerPrefixes?.includes(callerPrefix) &&
gateway.gateway?.businessPrefixes?.some((item) => item.id === businessPrefix.prefix?.id),
`gatewayId=${gateway.gateway?.id}, sourceIps=${gateway.gateway?.sourceIps?.join(',')}, callerPrefixes=${gateway.gateway?.callerPrefixes?.join(',')}`
)
);
const duplicateGateway = await requestApi('/api/v2/customer-gateways', {
method: 'POST',
accessToken,
body: {
customerId: customer.customer?.id,
name: '自动化8.3冲突客户网关',
authMode: 'IP',
sourceIps: [gatewayIp],
lineGroupId: lineGroup.lineGroup?.id,
billingCycleSec: 60,
cycleRate: '0.080000',
callerMatchMode: 'ANY',
calleeMatchMode: 'BUSINESS_PREFIXES',
businessPrefixIds: [businessPrefix.prefix?.id],
},
});
const duplicateGatewayBody = parseJson(duplicateGateway);
checks.push(statusCheck('duplicate source IP and business prefix gateway is rejected', duplicateGateway, 409));
checks.push(check('duplicate gateway returns match conflict code', duplicateGatewayBody?.code === 'CUSTOMER_GATEWAY_MATCH_CONFLICT', `code=${duplicateGatewayBody?.code || 'n/a'}`));
const disableGateway = await requestApi(`/api/v2/customer-gateways/${encodeURIComponent(gateway.gateway?.id)}/disable`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('customer gateway can be disabled', disableGateway, 201));
checks.push(check('disabled customer gateway status is DISABLED', parseJson(disableGateway)?.status === 'DISABLED', `status=${parseJson(disableGateway)?.status}`));
const enableGateway = await requestApi(`/api/v2/customer-gateways/${encodeURIComponent(gateway.gateway?.id)}/enable`, {
method: 'POST',
accessToken,
});
checks.push(statusCheck('customer gateway can be enabled', enableGateway, 201));
checks.push(check('enabled customer gateway status is ENABLED', parseJson(enableGateway)?.status === 'ENABLED', `status=${parseJson(enableGateway)?.status}`));
const deletePrefixInUse = await requestApi(`/api/v2/business-prefixes/${encodeURIComponent(businessPrefix.prefix?.id)}`, {
method: 'DELETE',
accessToken,
});
const deletePrefixInUseBody = parseJson(deletePrefixInUse);
checks.push(statusCheck('business prefix in use cannot be deleted', deletePrefixInUse, 400));
checks.push(check('business prefix in use returns BUSINESS_PREFIX_IN_USE', deletePrefixInUseBody?.code === 'BUSINESS_PREFIX_IN_USE', `code=${deletePrefixInUseBody?.code || 'n/a'}`));
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_CUSTOMER_GATEWAYS_PREFIXES_${stamp}.md`);
const failed = checks.filter((item) => !item.pass);
const report = [
'# Remote Customer Gateways, Business Prefixes, and Config Intent Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
`Business Prefix: ${prefixValue}`,
`Customer Name: ${customerName}`,
`Gateway Name: ${gatewayName}`,
`Gateway IP: ${gatewayIp}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Notes',
'',
'- Password and token values are intentionally omitted.',
'- Business prefix and customer gateway mutations enqueue config outbox events server-side.',
'- Config publication manifest is not exposed by the black-box API; DB or Redis observation is required to assert worker publication directly.',
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const item of checks) {
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
}
console.log(`Report: ${reportPath}`);
if (failed.length > 0) {
process.exitCode = 1;
}
+348
View File
@@ -0,0 +1,348 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const timeoutMs = Number(process.env.LISGLOSIPS_CUSTOMERS_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const customerName = process.env.LISGLOSIPS_CUSTOMER_TEST_NAME || '自动化8.2客户';
const customerDomain = process.env.LISGLOSIPS_CUSTOMER_TEST_DOMAIN || 'codex-82.example.test';
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const method = options.method || 'GET';
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-customers-balance/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
};
console.log(`REQ ${method} ${path}`);
let settled = false;
let req;
const hardTimer = setTimeout(() => {
req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`));
}, timeoutMs);
const finish = (result) => {
if (settled) {
return;
}
settled = true;
clearTimeout(hardTimer);
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
resolveRequest(result);
};
req = request(
url,
{
method,
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
finish({
path,
method,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('timeout', () => {
req.destroy(new Error(`Request timed out after ${timeoutMs}ms`));
});
req.on('error', (error) => {
finish({
path,
method,
ok: false,
statusCode: 0,
durationMs: Date.now() - startedAt,
contentType: '',
body: '',
error: error.message,
});
});
if (body) {
req.write(body);
}
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) {
return '';
}
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
async function login(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const captchaCode = decodeCaptcha(captchaBody?.imageDataUrl);
const result = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: {
username: loginUsername,
password: loginPassword,
captchaId: captchaBody?.captchaId,
captchaCode,
},
});
return { captcha, captchaCode, result, body: parseJson(result) };
}
function micros(value) {
const raw = String(value);
const negative = raw.startsWith('-');
const normalized = negative ? raw.slice(1) : raw;
const [integerPart, fractionPart = ''] = normalized.split('.');
const amount = BigInt(integerPart || '0') * 1_000_000n + BigInt(fractionPart.padEnd(6, '0').slice(0, 6) || '0');
return negative ? -amount : amount;
}
function fixed(value) {
const negative = value < 0n;
const absolute = negative ? -value : value;
const integerPart = absolute / 1_000_000n;
const fractionPart = String(absolute % 1_000_000n).padStart(6, '0');
return `${negative ? '-' : ''}${integerPart}.${fractionPart}`;
}
function addDecimal(left, right) {
return fixed(micros(left) + micros(right));
}
function check(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function reportLine(item) {
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
}
async function findCustomer(accessToken) {
const list = await requestApi('/api/v2/customers', { accessToken });
const body = parseJson(list);
const customer = Array.isArray(body) ? body.find((item) => item.name === customerName || item.domain === customerDomain) : null;
return { list, customer };
}
async function ensureCustomer(accessToken) {
const existing = await findCustomer(accessToken);
if (!existing.customer) {
const created = await requestApi('/api/v2/customers', {
method: 'POST',
accessToken,
body: {
name: customerName,
contactName: 'Codex测试联系人',
phone: '13800138200',
email: 'codex-82@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.2 automated customer',
},
});
return { action: 'created', result: created, customer: parseJson(created) };
}
const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.customer.id)}`, {
method: 'PATCH',
accessToken,
body: {
name: customerName,
contactName: 'Codex测试联系人',
phone: '13800138200',
email: 'codex-82@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.2 automated customer',
},
});
return { action: 'updated', result: updated, customer: parseJson(updated) };
}
const checks = [];
const adminLogin = await login(username, password);
checks.push(statusCheck('admin can login', adminLogin.result, 200));
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
const accessToken = adminLogin.body?.accessToken;
const lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const lowCustomersList = await requestApi('/api/v2/customers', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list customers', lowCustomersList, 403));
const customerSetup = await ensureCustomer(accessToken);
checks.push(statusCheck(`test customer is ${customerSetup.action}`, customerSetup.result, customerSetup.action === 'created' ? 201 : 200));
checks.push(check('test customer has expected credit/min balance', customerSetup.customer?.creditLimit === '100.000000' && customerSetup.customer?.minBalance === '5.000000', `creditLimit=${customerSetup.customer?.creditLimit}, minBalance=${customerSetup.customer?.minBalance}`));
const customerId = customerSetup.customer?.id;
const customerGet = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}`, { accessToken });
const customerBefore = parseJson(customerGet);
checks.push(statusCheck('customer detail can be fetched', customerGet, 200));
checks.push(check('available balance equals balance plus credit limit', customerBefore?.availableBalance === addDecimal(customerBefore?.balance || '0.000000', customerBefore?.creditLimit || '0.000000'), `balance=${customerBefore?.balance}, creditLimit=${customerBefore?.creditLimit}, available=${customerBefore?.availableBalance}`));
const disable = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/disable`, { method: 'POST', accessToken });
checks.push(statusCheck('customer can be disabled', disable, 201));
checks.push(check('disabled customer status is DISABLED', parseJson(disable)?.status === 'DISABLED', `status=${parseJson(disable)?.status}`));
const enable = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/enable`, { method: 'POST', accessToken });
checks.push(statusCheck('customer can be enabled', enable, 201));
checks.push(check('enabled customer status is ENABLED', parseJson(enable)?.status === 'ENABLED', `status=${parseJson(enable)?.status}`));
const positiveKey = `codex82:positive:${Date.now()}`;
const positiveRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: {
amount: '10.000000',
idempotencyKey: positiveKey,
remark: 'Codex 8.2 positive recharge',
},
});
const positiveBody = parseJson(positiveRecharge);
checks.push(statusCheck('positive customer recharge succeeds', positiveRecharge, 201));
checks.push(check('positive recharge balance delta is +10.000000', positiveBody?.afterBalance === addDecimal(positiveBody?.beforeBalance || '0.000000', '10.000000'), `before=${positiveBody?.beforeBalance}, after=${positiveBody?.afterBalance}`));
const duplicatePositive = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: {
amount: '10.000000',
idempotencyKey: positiveKey,
remark: 'Codex 8.2 positive recharge',
},
});
const duplicatePositiveBody = parseJson(duplicatePositive);
checks.push(statusCheck('same idempotency key with same body returns cached success', duplicatePositive, 201));
checks.push(check('idempotent duplicate returns same recharge id', duplicatePositiveBody?.id === positiveBody?.id, `first=${positiveBody?.id}, duplicate=${duplicatePositiveBody?.id}`));
const conflictingIdempotency = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: {
amount: '11.000000',
idempotencyKey: positiveKey,
remark: 'Codex 8.2 idempotency conflict',
},
});
checks.push(statusCheck('same idempotency key with different body is rejected', conflictingIdempotency, 409));
const negativeKey = `codex82:negative:${Date.now()}`;
const negativeRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: {
amount: '-3.000000',
idempotencyKey: negativeKey,
remark: 'Codex 8.2 negative adjustment',
},
});
const negativeBody = parseJson(negativeRecharge);
checks.push(statusCheck('negative customer recharge deducts balance', negativeRecharge, 201));
checks.push(check('negative recharge balance delta is -3.000000', negativeBody?.afterBalance === addDecimal(negativeBody?.beforeBalance || '0.000000', '-3.000000'), `before=${negativeBody?.beforeBalance}, after=${negativeBody?.afterBalance}, code=${negativeBody?.code || 'n/a'}`));
const rechargeList = await requestApi(`/api/v2/recharges?accountType=CUSTOMER&accountId=${encodeURIComponent(customerId)}&take=20`, { accessToken });
const rechargeListBody = parseJson(rechargeList);
checks.push(statusCheck('customer recharge list can be filtered by account', rechargeList, 200));
checks.push(check('recharge list contains positive recharge record', Array.isArray(rechargeListBody?.items) && rechargeListBody.items.some((item) => item.id === positiveBody?.id), `total=${rechargeListBody?.total}`));
const lowRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken: lowLogin.body?.accessToken,
body: {
amount: '1.000000',
idempotencyKey: `codex82:low:${Date.now()}`,
remark: 'Should be forbidden',
},
});
checks.push(statusCheck('low-privilege user cannot recharge customer', lowRecharge, 403));
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_CUSTOMERS_BALANCE_${stamp}.md`);
const failed = checks.filter((item) => !item.pass);
const report = [
'# Remote Customers, Recharge, and Balance Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
`Customer Name: ${customerName}`,
`Customer Domain: ${customerDomain}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Notes',
'',
'- Password and token values are intentionally omitted.',
'- Negative recharge is asserted as a required business rule: negative amount should deduct customer balance.',
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const item of checks) {
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
}
console.log(`Report: ${reportPath}`);
if (failed.length > 0) {
process.exitCode = 1;
}
+359
View File
@@ -0,0 +1,359 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const timeoutMs = Number(process.env.LISGLOSIPS_DASHBOARD_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const tempUsername = `codex.audit.${Date.now()}`;
const tempPassword = 'SensitivePass-001';
const resetPassword = 'SensitivePass-002';
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const method = options.method || 'GET';
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-dashboard-active-audit/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
};
console.log(`REQ ${method} ${path}`);
let settled = false;
let req;
const hardTimer = setTimeout(() => req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`)), timeoutMs);
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
resolveRequest(result);
};
req = request(
url,
{
method,
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
finish({
path,
method,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)));
req.on('error', (error) => {
finish({ path, method, ok: false, statusCode: 0, durationMs: Date.now() - startedAt, contentType: '', body: '', error: error.message });
});
if (body) req.write(body);
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) return '';
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
async function login(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const result = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: { username: loginUsername, password: loginPassword, captchaId: captchaBody?.captchaId, captchaCode: decodeCaptcha(captchaBody?.imageDataUrl) },
});
return { result, body: parseJson(result) };
}
function check(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function statusInCheck(name, result, statuses) {
return check(name, result.ok && statuses.includes(result.statusCode), result.error || `status=${result.statusCode}, expected=${statuses.join('/')}, duration=${result.durationMs}ms`);
}
function reportLine(item) {
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
}
function decimal6(value) {
return typeof value === 'string' && /^-?\d+\.\d{6}$/.test(value);
}
function ratio4(value) {
return typeof value === 'string' && /^\d+\.\d{4}$/.test(value);
}
function isRecord(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function auditTextLooksSafe(value) {
const text = JSON.stringify(value);
return !text.includes(tempPassword) && !text.includes(resetPassword) && !/passwordHash|sipHa1/i.test(text);
}
function trendBucketsAreContiguous(buckets) {
if (!Array.isArray(buckets)) return false;
for (let index = 1; index < buckets.length; index += 1) {
if (buckets[index - 1].end !== buckets[index].start) {
return false;
}
}
return true;
}
async function findDashboardRole(accessToken) {
const roles = await requestApi('/api/v2/roles', { accessToken });
const body = parseJson(roles);
const role = Array.isArray(body) ? body.find((item) => Array.isArray(item.permissionIds) && item.permissionIds.includes('dashboard.view')) : null;
return { roles, role };
}
async function createTempUser(accessToken, roleId) {
return requestApi('/api/v2/users', {
method: 'POST',
accessToken,
body: {
username: tempUsername,
displayName: 'Codex 8.7 审计脱敏临时用户',
password: tempPassword,
requirePasswordChange: false,
roleIds: [roleId],
},
});
}
const checks = [];
const cleanup = [];
const adminLogin = await login(username, password);
checks.push(statusCheck('admin can login', adminLogin.result, 200));
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
const accessToken = adminLogin.body?.accessToken;
const lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const lowToken = lowLogin.body?.accessToken;
const dashboardSummary = await requestApi('/api/v2/dashboard/summary', { accessToken });
const dashboardBody = parseJson(dashboardSummary);
checks.push(statusCheck('dashboard summary can be queried', dashboardSummary, 200));
checks.push(
check(
'dashboard summary shape and Shanghai day window are valid',
isRecord(dashboardBody) &&
dashboardBody.window?.timezone === 'Asia/Shanghai' &&
dashboardBody.window?.start?.endsWith('T16:00:00.000Z') &&
Number.isInteger(dashboardBody.calls?.totalCalls) &&
Number.isInteger(dashboardBody.calls?.answeredCalls) &&
Number.isInteger(dashboardBody.calls?.failedCalls) &&
ratio4(dashboardBody.calls?.answerRate) &&
decimal6(dashboardBody.money?.customerFee) &&
decimal6(dashboardBody.money?.vendorCost) &&
decimal6(dashboardBody.money?.grossProfit) &&
Number.isInteger(dashboardBody.quality?.pendingReviews),
JSON.stringify({ window: dashboardBody?.window, calls: dashboardBody?.calls, money: dashboardBody?.money, quality: dashboardBody?.quality })
)
);
const dashboardTrends = await requestApi('/api/v2/dashboard/trends?hours=2&bucketMinutes=60', { accessToken });
const trendsBody = parseJson(dashboardTrends);
checks.push(statusCheck('dashboard trends can be queried with fixed range', dashboardTrends, 200));
checks.push(
check(
'dashboard trends return fixed contiguous buckets',
Array.isArray(trendsBody?.buckets) &&
trendsBody.buckets.length === 2 &&
trendBucketsAreContiguous(trendsBody.buckets) &&
trendsBody.buckets.every((bucket) => Number.isInteger(bucket.calls?.totalCalls) && decimal6(bucket.money?.customerFee)),
`bucketCount=${Array.isArray(trendsBody?.buckets) ? trendsBody.buckets.length : 'n/a'}`
)
);
const invalidTrendBucket = await requestApi('/api/v2/dashboard/trends?hours=2&bucketMinutes=10', { accessToken });
checks.push(statusCheck('invalid dashboard trend bucket is rejected', invalidTrendBucket, 400));
checks.push(check('invalid trend bucket returns DASHBOARD_BUCKET_INVALID', parseJson(invalidTrendBucket)?.code === 'DASHBOARD_BUCKET_INVALID', `code=${parseJson(invalidTrendBucket)?.code}`));
const invalidTrendHours = await requestApi('/api/v2/dashboard/trends?hours=169&bucketMinutes=60', { accessToken });
checks.push(statusCheck('too-large dashboard trend range is rejected', invalidTrendHours, 400));
checks.push(check('too-large trend range returns INTEGER_INVALID', parseJson(invalidTrendHours)?.code === 'INTEGER_INVALID', `code=${parseJson(invalidTrendHours)?.code}`));
const lowDashboard = await requestApi('/api/v2/dashboard/summary', { accessToken: lowToken });
checks.push(statusCheck('low dashboard-only user can query dashboard summary', lowDashboard, 200));
const activeCalls = await requestApi('/api/v2/active-calls', { accessToken });
const activeCallsBody = parseJson(activeCalls);
checks.push(statusCheck('active calls can be listed', activeCalls, 200));
checks.push(
check(
'active calls response shape is stable',
isRecord(activeCallsBody) && activeCallsBody.source === 'opensips-mi' && Number.isInteger(activeCallsBody.total) && Array.isArray(activeCallsBody.items),
JSON.stringify({ total: activeCallsBody?.total, source: activeCallsBody?.source })
)
);
const lowActiveCalls = await requestApi('/api/v2/active-calls', { accessToken: lowToken });
checks.push(statusCheck('low dashboard-only user cannot list active calls', lowActiveCalls, 403));
for (const badId of ['../x', ';rm -rf', 'contains space', 'line\nbreak', 'x'.repeat(221)]) {
const encoded = encodeURIComponent(badId);
const invalidHangup = await requestApi(`/api/v2/active-calls/${encoded}/hangup`, { method: 'POST', accessToken });
checks.push(statusCheck(`invalid active call id is rejected (${badId.replace(/\n/g, '\\n').slice(0, 20)})`, invalidHangup, 400));
checks.push(
check(
`invalid active call id returns ACTIVE_CALL_ID_INVALID (${badId.replace(/\n/g, '\\n').slice(0, 20)})`,
parseJson(invalidHangup)?.code === 'ACTIVE_CALL_ID_INVALID',
`code=${parseJson(invalidHangup)?.code}`
)
);
}
const lowHangup = await requestApi('/api/v2/active-calls/safe-dialog-001/hangup', { method: 'POST', accessToken: lowToken });
checks.push(statusCheck('low dashboard-only user cannot hang up calls', lowHangup, 403));
const auditList = await requestApi('/api/v2/audit-logs?take=10', { accessToken });
const auditListBody = parseJson(auditList);
checks.push(statusCheck('audit logs can be listed', auditList, 200));
checks.push(check('audit list shape is valid', Array.isArray(auditListBody?.items) && Number.isInteger(auditListBody?.total), `count=${auditListBody?.items?.length ?? 'n/a'}, total=${auditListBody?.total ?? 'n/a'}`));
const lowAuditList = await requestApi('/api/v2/audit-logs?take=1', { accessToken: lowToken });
checks.push(statusCheck('low dashboard-only user cannot list audit logs', lowAuditList, 403));
const invalidAuditResult = await requestApi('/api/v2/audit-logs?result=BAD', { accessToken });
checks.push(statusCheck('invalid audit result filter is rejected', invalidAuditResult, 400));
checks.push(check('invalid audit result returns AUDIT_RESULT_INVALID', parseJson(invalidAuditResult)?.code === 'AUDIT_RESULT_INVALID', `code=${parseJson(invalidAuditResult)?.code}`));
const auditSuccessList = await requestApi('/api/v2/audit-logs?result=SUCCESS&take=5', { accessToken });
const auditSuccessBody = parseJson(auditSuccessList);
checks.push(statusCheck('audit logs can be filtered by result', auditSuccessList, 200));
checks.push(check('audit success filter only returns SUCCESS rows', Array.isArray(auditSuccessBody?.items) && auditSuccessBody.items.every((item) => item.result === 'SUCCESS'), `count=${auditSuccessBody?.items?.length ?? 'n/a'}`));
const rolesLookup = await findDashboardRole(accessToken);
checks.push(statusCheck('roles can be listed for temporary audit user setup', rolesLookup.roles, 200));
checks.push(check('dashboard-capable role is available', typeof rolesLookup.role?.id === 'string', `roleId=${rolesLookup.role?.id || 'n/a'}`));
let tempUser = null;
if (rolesLookup.role?.id) {
const createdUser = await createTempUser(accessToken, rolesLookup.role.id);
tempUser = parseJson(createdUser);
checks.push(statusInCheck('temporary user with sensitive password can be created', createdUser, [201]));
checks.push(check('created temporary user response does not expose password fields', auditTextLooksSafe(tempUser), JSON.stringify({ id: tempUser?.id, username: tempUser?.username })));
if (tempUser?.id) {
cleanup.push(() => requestApi(`/api/v2/users/${encodeURIComponent(tempUser.id)}`, { method: 'DELETE', accessToken }));
const reset = await requestApi(`/api/v2/users/${encodeURIComponent(tempUser.id)}/reset-password`, {
method: 'POST',
accessToken,
body: { password: resetPassword, nested: { refreshToken: 'nested-token-probe' } },
});
checks.push(statusCheck('temporary user password reset succeeds', reset, 201));
checks.push(check('password reset response does not expose sensitive fields', auditTextLooksSafe(parseJson(reset)), JSON.stringify({ id: parseJson(reset)?.id, username: parseJson(reset)?.username })));
const resetAudit = await requestApi(`/api/v2/audit-logs?module=users&action=reset_password&objectId=${encodeURIComponent(tempUser.id)}&result=SUCCESS&take=5`, { accessToken });
const resetAuditBody = parseJson(resetAudit);
const resetAuditItem = Array.isArray(resetAuditBody?.items) ? resetAuditBody.items[0] : null;
checks.push(statusCheck('password reset audit can be filtered by module/action/object/result', resetAudit, 200));
checks.push(check('password reset audit row exists', typeof resetAuditItem?.id === 'string', `auditId=${resetAuditItem?.id || 'n/a'}`));
if (resetAuditItem?.id) {
const auditDetail = await requestApi(`/api/v2/audit-logs/${encodeURIComponent(resetAuditItem.id)}`, { accessToken });
const auditDetailBody = parseJson(auditDetail);
checks.push(statusCheck('password reset audit detail can be fetched', auditDetail, 200));
checks.push(
check(
'password reset audit detail redacts sensitive body fields',
auditTextLooksSafe(auditDetailBody) && auditDetailBody?.beforeSummary?.body?.password === '[REDACTED]',
JSON.stringify({ id: auditDetailBody?.id, redactedPassword: auditDetailBody?.beforeSummary?.body?.password })
)
);
}
}
}
for (const item of cleanup.reverse()) {
const cleanupResult = await item();
checks.push(statusInCheck('temporary audit user cleanup is stable', cleanupResult, [200, 404]));
}
const stamp = new Date().toISOString().replaceAll(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_DASHBOARD_ACTIVE_AUDIT_${stamp}.md`);
const notes = [
'',
'## Notes',
'',
'- Password and token values are intentionally omitted from console and report details.',
'- DASH-001 aggregate accuracy and DASH-002 exact Shanghai day-boundary attribution still require SQL comparison against seeded boundary CDRs.',
'- ACT-001/ACT-002 real long-call normalization and successful hangup require an active OpenSIPS dialog on A; this black-box run verifies list contract, RBAC, and invalid dialog-id safety.',
'- AUD-002 application log full-text checks require host-side log access; this run verifies API response and audit detail redaction.',
];
const report = [
'# Remote Dashboard, Active Calls, and Audit Test Report',
'',
`Date: ${new Date().toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
...notes,
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const item of checks) {
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
}
console.log(`Report: ${reportPath}`);
if (checks.some((item) => !item.pass)) {
process.exitCode = 1;
}
+314
View File
@@ -0,0 +1,314 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const timeoutMs = Number(process.env.LISGLOSIPS_PERF_SECURITY_TIMEOUT_MS || 30000);
const concurrency = Number(process.env.LISGLOSIPS_PERF_SECURITY_CONCURRENCY || 12);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const customerName = '自动化8.9安全客户';
const customerDomain = 'codex89.example.test';
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const method = options.method || 'GET';
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: 'application/json,text/html;q=0.9,*/*;q=0.8',
'User-Agent': 'lisglosips-remote-performance-security/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
...(options.authorization ? { Authorization: options.authorization } : {}),
};
console.log(`REQ ${method} ${path}`);
let settled = false;
let req;
const hardTimer = setTimeout(() => req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`)), timeoutMs);
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
resolveRequest(result);
};
req = request(
url,
{
method,
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
finish({
path,
method,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
headers: res.headers,
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)));
req.on('error', (error) => {
finish({ path, method, ok: false, statusCode: 0, durationMs: Date.now() - startedAt, contentType: '', headers: {}, body: '', error: error.message });
});
if (body) req.write(body);
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) return '';
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
async function login(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const result = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: { username: loginUsername, password: loginPassword, captchaId: captchaBody?.captchaId, captchaCode: decodeCaptcha(captchaBody?.imageDataUrl) },
});
return { result, body: parseJson(result) };
}
function check(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function reportLine(item) {
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
}
function bodyLooksSafe(result) {
return !/stack|trace|DATABASE_URL|JWT_SECRET|password|secret|\/etc\/|\/var\/|[A-Z]:\\/i.test(result.body || '');
}
async function findCustomer(accessToken) {
const list = await requestApi('/api/v2/customers', { accessToken });
const body = parseJson(list);
const customer = Array.isArray(body) ? body.find((item) => item.name === customerName || item.domain === customerDomain) : null;
return { list, customer };
}
async function ensureCustomer(accessToken) {
const existing = await findCustomer(accessToken);
if (!existing.customer) {
const created = await requestApi('/api/v2/customers', {
method: 'POST',
accessToken,
body: {
name: customerName,
contactName: 'Codex测试联系人',
phone: '13800138900',
email: 'codex-89@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.9 replay/security test customer',
},
});
return { action: 'created', result: created, customer: parseJson(created) };
}
const updated = await requestApi(`/api/v2/customers/${encodeURIComponent(existing.customer.id)}`, {
method: 'PATCH',
accessToken,
body: {
name: customerName,
contactName: 'Codex测试联系人',
phone: '13800138900',
email: 'codex-89@example.test',
domain: customerDomain,
billingMode: 'PREPAID',
creditLimit: '100.000000',
minBalance: '5.000000',
notes: 'Codex 8.9 replay/security test customer',
},
});
return { action: 'updated', result: updated, customer: parseJson(updated) };
}
function percentile(values, ratio) {
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * ratio))] ?? 0;
}
const checks = [];
const root = await requestApi('/');
checks.push(statusCheck('HTTPS root is reachable before light concurrency', root, 200));
checks.push(check('HTTPS root contains app root', root.contentType.includes('text/html') && root.body.includes('<div id="root"></div>'), `contentType=${root.contentType}, bytes=${Buffer.byteLength(root.body, 'utf8')}`));
const readyBefore = await requestApi('/api/v2/health/ready');
checks.push(statusCheck('ready health is ok before light concurrency', readyBefore, 200));
checks.push(check('ready health reports database and redis ok before light concurrency', parseJson(readyBefore)?.checks?.database === 'ok' && parseJson(readyBefore)?.checks?.redis === 'ok', `body=${JSON.stringify(parseJson(readyBefore))}`));
const adminLogin = await login(username, password);
checks.push(statusCheck('admin can login', adminLogin.result, 200));
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
const accessToken = adminLogin.body?.accessToken;
const lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const burstStartedAt = Date.now();
const burst = await Promise.all([
...Array.from({ length: concurrency }, () => requestApi('/api/v2/health/ready')),
...Array.from({ length: concurrency }, () => requestApi('/api/v2/auth/captcha')),
]);
const burstDurations = burst.map((item) => item.durationMs);
const burstFailed = burst.filter((item) => !item.ok || item.statusCode !== 200);
checks.push(check('PERF light API burst returns only 200 responses', burstFailed.length === 0, `requests=${burst.length}, failed=${burstFailed.length}, wallMs=${Date.now() - burstStartedAt}`));
checks.push(check('PERF light API burst p95 stays under 10s', percentile(burstDurations, 0.95) < 10000, `p95=${percentile(burstDurations, 0.95)}ms, max=${Math.max(...burstDurations)}ms`));
const readyAfter = await requestApi('/api/v2/health/ready');
checks.push(statusCheck('ready health recovers after light concurrency', readyAfter, 200));
checks.push(check('ready health reports database and redis ok after light concurrency', parseJson(readyAfter)?.checks?.database === 'ok' && parseJson(readyAfter)?.checks?.redis === 'ok', `body=${JSON.stringify(parseJson(readyAfter))}`));
const forgedToken = await requestApi('/api/v2/dashboard/summary', { authorization: 'Bearer not.a.valid.jwt' });
checks.push(statusCheck('SEC forged bearer token is rejected', forgedToken, 401));
checks.push(check('SEC forged token error does not leak internals', bodyLooksSafe(forgedToken), `body=${forgedToken.body.slice(0, 160)}`));
const unauthWrite = await requestApi('/api/v2/customers', {
method: 'POST',
body: { name: 'unauth should not create' },
});
checks.push(statusCheck('SEC unauthenticated write is rejected', unauthWrite, 401));
checks.push(check('SEC unauthenticated write error does not leak internals', bodyLooksSafe(unauthWrite), `body=${unauthWrite.body.slice(0, 160)}`));
const lowWrite = await requestApi('/api/v2/customers', {
method: 'POST',
accessToken: lowLogin.body?.accessToken,
body: {
name: '低权限不应创建客户',
contactName: 'Forbidden',
phone: '13800138999',
email: 'forbidden@example.test',
domain: 'forbidden.example.test',
billingMode: 'PREPAID',
},
});
checks.push(statusCheck('SEC low-privilege user cannot create customer', lowWrite, 403));
checks.push(check('SEC low-privilege write error does not leak internals', bodyLooksSafe(lowWrite), `body=${lowWrite.body.slice(0, 160)}`));
const customerSetup = await ensureCustomer(accessToken);
checks.push(statusCheck(`SEC replay test customer is ${customerSetup.action}`, customerSetup.result, customerSetup.action === 'created' ? 201 : 200));
const customerId = customerSetup.customer?.id;
const replayKey = `codex89:replay:${Date.now()}`;
const firstRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: { amount: '1.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay probe' },
});
const firstRechargeBody = parseJson(firstRecharge);
checks.push(statusCheck('SEC first idempotent recharge succeeds', firstRecharge, 201));
const replayRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: { amount: '1.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay probe' },
});
const replayRechargeBody = parseJson(replayRecharge);
checks.push(statusCheck('SEC exact idempotent replay returns success', replayRecharge, 201));
checks.push(check('SEC exact idempotent replay returns same recharge id', replayRechargeBody?.id === firstRechargeBody?.id, `first=${firstRechargeBody?.id}, replay=${replayRechargeBody?.id}`));
const conflictingReplay = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken,
body: { amount: '2.000000', idempotencyKey: replayKey, remark: 'Codex 8.9 replay conflict' },
});
checks.push(statusCheck('SEC conflicting idempotency replay is rejected', conflictingReplay, 409));
checks.push(check('SEC conflicting replay error does not leak original body', !conflictingReplay.body.includes('Codex 8.9 replay probe') && bodyLooksSafe(conflictingReplay), `body=${conflictingReplay.body.slice(0, 160)}`));
const lowRecharge = await requestApi(`/api/v2/customers/${encodeURIComponent(customerId)}/recharges`, {
method: 'POST',
accessToken: lowLogin.body?.accessToken,
body: { amount: '1.000000', idempotencyKey: `codex89:low:${Date.now()}`, remark: 'Forbidden replay probe' },
});
checks.push(statusCheck('SEC low-privilege user cannot replay/write recharge', lowRecharge, 403));
const missingRecording = await requestApi('/api/v2/recordings/not-a-real-recording/play', { accessToken });
checks.push(statusCheck('SEC missing recording playback returns 404', missingRecording, 404));
checks.push(check('SEC missing recording playback error does not expose paths', bodyLooksSafe(missingRecording), `body=${missingRecording.body.slice(0, 160)}`));
const traversalRecording = await requestApi('/api/v2/recordings/%2E%2E%2F%2E%2E%2Fetc%2Fpasswd/play', { accessToken });
checks.push(check('SEC encoded traversal recording id is rejected safely', [400, 404].includes(traversalRecording.statusCode), `status=${traversalRecording.statusCode}, body=${traversalRecording.body.slice(0, 120)}`));
checks.push(check('SEC traversal playback error does not expose filesystem paths', bodyLooksSafe(traversalRecording), `body=${traversalRecording.body.slice(0, 160)}`));
const stamp = new Date().toISOString().replaceAll(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_PERFORMANCE_SECURITY_${stamp}.md`);
const notes = [
'',
'## Notes',
'',
'- This run intentionally avoids disruptive fault injection: no Worker, MySQL, Redis, OpenSIPS, or SIP traffic was stopped or modified.',
'- PERF-001/PERF-002 SIP call concurrency, SEC-001 illegal-source SIP probe, and SEC-002 CPS probe still require A/T-side SIP tooling and CDR/recording verification.',
'- FAIL-001 through FAIL-004 require an explicit maintenance window, rollback point, and service-stop approval before execution.',
'- The executed subset covers HTTPS/API light concurrency, service recovery after burst, forged/unauthenticated/low-privilege access, idempotency replay, and recording path-safety black-box checks.',
];
const report = [
'# Remote Performance, Fault, and Security Test Report',
'',
`Date: ${new Date().toISOString()}`,
`Base URL: ${baseUrl}`,
`Concurrency: ${concurrency} ready + ${concurrency} captcha requests`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
...notes,
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const item of checks) {
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
}
console.log(`Report: ${reportPath}`);
if (checks.some((item) => !item.pass)) {
process.exitCode = 1;
}
+282
View File
@@ -0,0 +1,282 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const timeoutMs = Number(process.env.LISGLOSIPS_RECORDINGS_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const ruleName = '自动化8.6质检抽样规则';
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const method = options.method || 'GET';
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: options.accept || 'application/json',
'User-Agent': 'lisglosips-remote-recordings-quality/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
...(options.headers || {}),
};
console.log(`REQ ${method} ${path}`);
let settled = false;
let req;
const hardTimer = setTimeout(() => req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`)), timeoutMs);
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
resolveRequest(result);
};
req = request(
url,
{ method, rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1', timeout: timeoutMs, headers },
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
finish({
path,
method,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
headers: res.headers,
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)));
req.on('error', (error) => {
finish({ path, method, ok: false, statusCode: 0, durationMs: Date.now() - startedAt, contentType: '', headers: {}, body: '', error: error.message });
});
if (body) req.write(body);
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) return '';
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
async function login(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const result = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: { username: loginUsername, password: loginPassword, captchaId: captchaBody?.captchaId, captchaCode: decodeCaptcha(captchaBody?.imageDataUrl) },
});
return { result, body: parseJson(result) };
}
function check(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function statusInCheck(name, result, statuses) {
return check(name, result.ok && statuses.includes(result.statusCode), result.error || `status=${result.statusCode}, expected=${statuses.join('/')}, duration=${result.durationMs}ms`);
}
function reportLine(item) {
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
}
async function ensureQualityRule(accessToken) {
const list = await requestApi('/api/v2/quality/rules', { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.name === ruleName) : null;
const body = { name: ruleName, customerId: null, lineGroupId: null, ratio: '100.00', status: 'ENABLED' };
if (existing) {
const updated = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
return { action: 'updated', result: updated, rule: parseJson(updated) };
}
const created = await requestApi('/api/v2/quality/rules', { method: 'POST', accessToken, body });
return { action: 'created', result: created, rule: parseJson(created) };
}
const checks = [];
const adminLogin = await login(username, password);
checks.push(statusCheck('admin can login', adminLogin.result, 200));
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
const accessToken = adminLogin.body?.accessToken;
const lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const lowRecordings = await requestApi('/api/v2/recordings?limit=1', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list recordings', lowRecordings, 403));
const lowRules = await requestApi('/api/v2/quality/rules', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list quality rules', lowRules, 403));
const invalidStatus = await requestApi('/api/v2/recordings?status=BAD', { accessToken });
checks.push(statusCheck('invalid recording status is rejected', invalidStatus, 400));
checks.push(check('invalid recording status returns RECORDING_STATUS_INVALID', parseJson(invalidStatus)?.code === 'RECORDING_STATUS_INVALID', `code=${parseJson(invalidStatus)?.code || 'n/a'}`));
const invalidLimit = await requestApi('/api/v2/recordings?limit=0', { accessToken });
checks.push(statusCheck('invalid recording list limit is rejected', invalidLimit, 400));
checks.push(check('invalid recording limit returns INTEGER_INVALID', parseJson(invalidLimit)?.code === 'INTEGER_INVALID', `code=${parseJson(invalidLimit)?.code || 'n/a'}`));
const readyList = await requestApi('/api/v2/recordings?status=READY&limit=20', { accessToken });
const readyListBody = parseJson(readyList);
checks.push(statusCheck('READY recordings can be listed', readyList, 200));
checks.push(check('recording list shape is valid', Array.isArray(readyListBody), `count=${Array.isArray(readyListBody) ? readyListBody.length : 'n/a'}`));
const readyRecording = Array.isArray(readyListBody) ? readyListBody[0] : null;
if (readyRecording) {
const detail = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}`, { accessToken });
const detailBody = parseJson(detail);
checks.push(statusCheck('recording detail can be fetched', detail, 200));
checks.push(check('recording detail matches list item', detailBody?.id === readyRecording.id && Array.isArray(detailBody?.reviews), `id=${detailBody?.id}, reviews=${detailBody?.reviews?.length}`));
const playback = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/play`, { accessToken, accept: '*/*' });
checks.push(statusInCheck('READY recording playback returns X-Accel response or served media', playback, [200, 206]));
checks.push(
check(
'playback response avoids real filesystem path exposure',
!JSON.stringify(playback.headers).match(/[A-Z]:\\|\/var\/|\/home\/|\/etc\//i),
`xAccel=${playback.headers['x-accel-redirect'] || 'n/a'}, contentType=${playback.headers['content-type'] || playback.contentType}`
)
);
const lowPlayback = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/play`, { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot play recording', lowPlayback, 403));
const invalidScore = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
method: 'PUT',
accessToken,
body: { score: 88.5, result: 'ISSUE', issueTags: ['noise'], notes: 'Codex 8.6 invalid decimal score' },
});
checks.push(statusCheck('decimal review score is rejected by current API', invalidScore, 400));
checks.push(check('decimal review score returns INTEGER_INVALID', parseJson(invalidScore)?.code === 'INTEGER_INVALID', `code=${parseJson(invalidScore)?.code || 'n/a'}`));
const invalidResult = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
method: 'PUT',
accessToken,
body: { score: 88, result: 'BAD', issueTags: ['noise'], notes: 'Codex 8.6 invalid result' },
});
checks.push(statusCheck('invalid review result is rejected', invalidResult, 400));
checks.push(check('invalid review result returns QUALITY_REVIEW_RESULT_INVALID', parseJson(invalidResult)?.code === 'QUALITY_REVIEW_RESULT_INVALID', `code=${parseJson(invalidResult)?.code || 'n/a'}`));
const lowReview = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
method: 'PUT',
accessToken: lowLogin.body?.accessToken,
body: { score: 88, result: 'PASS', issueTags: [], notes: 'Should be forbidden' },
});
checks.push(statusCheck('low-privilege user cannot save review', lowReview, 403));
const review = await requestApi(`/api/v2/recordings/${encodeURIComponent(readyRecording.id)}/review`, {
method: 'PUT',
accessToken,
body: { score: 88, result: 'ISSUE', issueTags: ['noise', 'script'], notes: 'Codex 8.6 review' },
});
const reviewBody = parseJson(review);
checks.push(statusCheck('quality review can be saved', review, 200));
checks.push(check('quality review contains expected score/result/tags', reviewBody?.score === 88 && reviewBody?.result === 'ISSUE' && Array.isArray(reviewBody?.issueTags), `score=${reviewBody?.score}, result=${reviewBody?.result}, tags=${JSON.stringify(reviewBody?.issueTags)}`));
const reviewedList = await requestApi('/api/v2/recordings?reviewStatus=REVIEWED&limit=20', { accessToken });
const reviewedListBody = parseJson(reviewedList);
checks.push(statusCheck('reviewed recording list can be filtered', reviewedList, 200));
checks.push(check('reviewed list contains reviewed recording', Array.isArray(reviewedListBody) && reviewedListBody.some((item) => item.id === readyRecording.id), `count=${Array.isArray(reviewedListBody) ? reviewedListBody.length : 'n/a'}`));
} else {
checks.push(check('recording detail/play/review checks skipped because no READY recording exists', true, 'No READY recordings returned by remote service.'));
}
const missingPlayback = await requestApi('/api/v2/recordings/not-a-real-recording/play', { accessToken });
checks.push(statusCheck('missing recording playback returns 404', missingPlayback, 404));
checks.push(check('missing recording playback returns RECORDING_NOT_READY', parseJson(missingPlayback)?.code === 'RECORDING_NOT_READY', `code=${parseJson(missingPlayback)?.code || 'n/a'}`));
const invalidRatio = await requestApi('/api/v2/quality/rules', {
method: 'POST',
accessToken,
body: { name: '自动化8.6非法比例', ratio: '100.01', status: 'ENABLED' },
});
checks.push(statusCheck('invalid quality sampling ratio is rejected', invalidRatio, 400));
checks.push(check('invalid ratio returns QUALITY_RATIO_INVALID', parseJson(invalidRatio)?.code === 'QUALITY_RATIO_INVALID', `code=${parseJson(invalidRatio)?.code || 'n/a'}`));
const rule = await ensureQualityRule(accessToken);
checks.push(statusCheck(`quality sampling rule is ${rule.action}`, rule.result, rule.action === 'created' ? 201 : 200));
checks.push(check('quality sampling rule has 100 percent ratio', rule.rule?.ratio === '100.00' && rule.rule?.status === 'ENABLED', `ruleId=${rule.rule?.id}, ratio=${rule.rule?.ratio}, status=${rule.rule?.status}`));
const disableRule = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(rule.rule?.id)}/disable`, { method: 'POST', accessToken });
checks.push(statusCheck('quality sampling rule can be disabled', disableRule, 201));
checks.push(check('disabled quality rule status is DISABLED', parseJson(disableRule)?.status === 'DISABLED', `status=${parseJson(disableRule)?.status}`));
const enableRule = await requestApi(`/api/v2/quality/rules/${encodeURIComponent(rule.rule?.id)}/enable`, { method: 'POST', accessToken });
checks.push(statusCheck('quality sampling rule can be enabled', enableRule, 201));
checks.push(check('enabled quality rule status is ENABLED', parseJson(enableRule)?.status === 'ENABLED', `status=${parseJson(enableRule)?.status}`));
const listAfterRule = await requestApi('/api/v2/recordings?status=READY&limit=5', { accessToken });
const listAfterRuleBody = parseJson(listAfterRule);
checks.push(statusCheck('recording list includes stable sampling payload after rule change', listAfterRule, 200));
checks.push(
check(
'sampling payload shape is present',
Array.isArray(listAfterRuleBody) && listAfterRuleBody.every((item) => item.sampling && typeof item.sampling.selected === 'boolean' && Array.isArray(item.sampling.matches)),
`count=${Array.isArray(listAfterRuleBody) ? listAfterRuleBody.length : 'n/a'}`
)
);
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_RECORDINGS_QUALITY_${stamp}.md`);
const failed = checks.filter((item) => !item.pass);
const report = [
'# Remote Recordings, Playback, and Quality Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Notes',
'',
'- Password and token values are intentionally omitted.',
'- Recording Worker file movement, checksum mismatch retention, source cleanup, and browser Range playback need A/B filesystem or browser-side verification.',
'- Current API accepts integer review scores only; decimal score examples from the plan are asserted as rejected by this deployed service.',
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const item of checks) {
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
}
console.log(`Report: ${reportPath}`);
if (failed.length > 0) process.exitCode = 1;
+343
View File
@@ -0,0 +1,343 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { request } from 'node:https';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const baseUrl = (process.env.LISGLOSIPS_BASE_URL || 'https://100.90.90.91').replace(/\/$/, '');
const username = process.env.LISGLOSIPS_AUTH_USERNAME || 'admin';
const password = process.env.LISGLOSIPS_AUTH_PASSWORD;
const lowUsername = process.env.LISGLOSIPS_LOW_AUTH_USERNAME || 'codex.low';
const lowPassword = process.env.LISGLOSIPS_LOW_AUTH_PASSWORD || `${password}!low`;
const timeoutMs = Number(process.env.LISGLOSIPS_VENDORS_TIMEOUT_MS || 30000);
const reportDir = resolve(dirname(fileURLToPath(import.meta.url)), '../reports');
const vendorName = '自动化8.4供应商';
const primaryGatewayName = '自动化8.4主落地网关';
const backupGatewayName = '自动化8.4备落地网关';
const lineGroupName = '自动化8.4线路组';
if (!password) {
console.error('LISGLOSIPS_AUTH_PASSWORD is required.');
process.exit(2);
}
function requestApi(path, options = {}) {
return new Promise((resolveRequest) => {
const startedAt = Date.now();
const url = new URL(path, baseUrl);
const method = options.method || 'GET';
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
const headers = {
Accept: 'application/json',
'User-Agent': 'lisglosips-remote-vendors-line-groups/1.0',
...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}),
...(options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
};
console.log(`REQ ${method} ${path}`);
let settled = false;
let req;
const hardTimer = setTimeout(() => req?.destroy(new Error(`Request exceeded hard timeout after ${timeoutMs}ms`)), timeoutMs);
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(hardTimer);
console.log(`RES ${method} ${path} ${result.statusCode || 'ERR'} ${result.durationMs}ms`);
resolveRequest(result);
};
req = request(
url,
{
method,
rejectUnauthorized: process.env.LISGLOSIPS_REJECT_UNAUTHORIZED === '1',
timeout: timeoutMs,
headers,
},
(res) => {
const chunks = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
finish({
path,
method,
ok: true,
statusCode: res.statusCode || 0,
durationMs: Date.now() - startedAt,
contentType: String(res.headers['content-type'] || ''),
body: Buffer.concat(chunks).toString('utf8'),
});
});
}
);
req.setTimeout(timeoutMs, () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)));
req.on('error', (error) => {
finish({ path, method, ok: false, statusCode: 0, durationMs: Date.now() - startedAt, contentType: '', body: '', error: error.message });
});
if (body) req.write(body);
req.end();
});
}
function parseJson(result) {
try {
return JSON.parse(result.body);
} catch {
return null;
}
}
function decodeCaptcha(imageDataUrl) {
const encoded = String(imageDataUrl || '').split(',', 2)[1];
if (!encoded) return '';
const svg = Buffer.from(encoded, 'base64').toString('utf8');
return [...svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)].map((match) => match[1]).join('');
}
async function login(loginUsername, loginPassword) {
const captcha = await requestApi('/api/v2/auth/captcha');
const captchaBody = parseJson(captcha);
const result = await requestApi('/api/v2/auth/login', {
method: 'POST',
body: { username: loginUsername, password: loginPassword, captchaId: captchaBody?.captchaId, captchaCode: decodeCaptcha(captchaBody?.imageDataUrl) },
});
return { result, body: parseJson(result) };
}
function check(name, pass, detail) {
return { name, pass, detail };
}
function statusCheck(name, result, expectedStatus) {
return check(name, result.ok && result.statusCode === expectedStatus, result.error || `status=${result.statusCode}, duration=${result.durationMs}ms`);
}
function statusInCheck(name, result, statuses) {
return check(name, result.ok && statuses.includes(result.statusCode), result.error || `status=${result.statusCode}, expected=${statuses.join('/')}, duration=${result.durationMs}ms`);
}
function reportLine(item) {
return `| ${item.pass ? 'PASS' : 'FAIL'} | ${item.name} | ${String(item.detail).replace(/\|/g, '\\|')} |`;
}
async function ensureVendor(accessToken) {
const list = await requestApi('/api/v2/vendors', { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.name === vendorName) : null;
const body = {
name: vendorName,
contactName: 'Codex 8.4',
phone: '13900138400',
email: 'codex-84-vendor@example.test',
creditLimit: '200.000000',
settlement: '月结',
notes: 'Codex 8.4 vendor test fixture',
};
if (existing) {
const updated = await requestApi(`/api/v2/vendors/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
return { action: 'updated', result: updated, vendor: parseJson(updated) };
}
const created = await requestApi('/api/v2/vendors', { method: 'POST', accessToken, body });
return { action: 'created', result: created, vendor: parseJson(created) };
}
function gatewayBody(vendorId, name, host, priorityOffset = 0) {
return {
vendorId,
name,
authMode: 'IP',
host,
port: 5060,
transport: 'udp',
cpsLimit: 30 + priorityOffset,
concurrencyLimit: 300 + priorityOffset,
billingCycleSec: 60,
cycleRate: priorityOffset === 0 ? '0.030000' : '0.035000',
landingCalleePrefix: '86',
status: 'ENABLED',
forbiddenPeriods: [{ weekdayMask: 127, startTime: '00:00:00', endTime: '00:05:00' }],
codecs: [
{ codec: 'PCMA', priority: 10 },
{ codec: 'PCMU', priority: 20 },
],
prefixRules: [
{ direction: 'CALLEE', matchPrefix: '84', replacePrefix: '86', priority: 10 },
{ direction: 'CALLER', matchPrefix: '0', replacePrefix: '', priority: 10 },
],
callerRewritePool: [{ caller: priorityOffset === 0 ? '0551840001' : '0551840002', weight: 100, status: 'ENABLED' }],
};
}
async function ensureVendorGateway(accessToken, vendorId, name, host, priorityOffset = 0) {
const list = await requestApi(`/api/v2/vendor-gateways?vendorId=${encodeURIComponent(vendorId)}`, { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.name === name) : null;
const body = gatewayBody(vendorId, name, host, priorityOffset);
if (existing) {
const updated = await requestApi(`/api/v2/vendor-gateways/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
return { action: 'updated', result: updated, gateway: parseJson(updated) };
}
const created = await requestApi('/api/v2/vendor-gateways', { method: 'POST', accessToken, body });
return { action: 'created', result: created, gateway: parseJson(created) };
}
async function ensureLineGroup(accessToken) {
const list = await requestApi('/api/v2/landing-line-groups', { accessToken });
const items = parseJson(list);
const existing = Array.isArray(items) ? items.find((item) => item.name === lineGroupName) : null;
const body = { name: lineGroupName, status: 'ENABLED', notes: 'Codex 8.4 line group test fixture' };
if (existing) {
const updated = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(existing.id)}`, { method: 'PATCH', accessToken, body });
return { action: 'updated', result: updated, group: parseJson(updated) };
}
const created = await requestApi('/api/v2/landing-line-groups', { method: 'POST', accessToken, body });
return { action: 'created', result: created, group: parseJson(created) };
}
async function ensureLineGroupItem(accessToken, group, gateway, priority, weight) {
const existing = group.items?.find((item) => item.vendorGatewayId === gateway.id);
if (existing) {
const updated = await requestApi(`/api/v2/landing-line-groups/items/${encodeURIComponent(existing.id)}`, {
method: 'PATCH',
accessToken,
body: { priority, weight, concurrencyCap: priority === 10 ? 100 : 80, status: 'ENABLED' },
});
return { action: 'updated', result: updated, item: parseJson(updated) };
}
const added = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(group.id)}/items`, {
method: 'POST',
accessToken,
body: { vendorGatewayId: gateway.id, priority, weight, concurrencyCap: priority === 10 ? 100 : 80, status: 'ENABLED' },
});
const addedGroup = parseJson(added);
return { action: 'added', result: added, item: addedGroup?.items?.find((item) => item.vendorGatewayId === gateway.id), group: addedGroup };
}
const checks = [];
const adminLogin = await login(username, password);
checks.push(statusCheck('admin can login', adminLogin.result, 200));
checks.push(check('admin login returns access token', typeof adminLogin.body?.accessToken === 'string', `tokenLength=${adminLogin.body?.accessToken?.length || 0}`));
const accessToken = adminLogin.body?.accessToken;
const lowLogin = await login(lowUsername, lowPassword);
checks.push(statusCheck('low-privilege user can login for RBAC checks', lowLogin.result, 200));
const lowVendorList = await requestApi('/api/v2/vendors', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list vendors', lowVendorList, 403));
const lowLineGroupList = await requestApi('/api/v2/landing-line-groups', { accessToken: lowLogin.body?.accessToken });
checks.push(statusCheck('low-privilege user cannot list line groups', lowLineGroupList, 403));
const invalidVendor = await requestApi('/api/v2/vendors', { method: 'POST', accessToken, body: { name: '', creditLimit: '0.000000' } });
checks.push(statusCheck('invalid vendor is rejected', invalidVendor, 400));
const vendor = await ensureVendor(accessToken);
checks.push(statusCheck(`vendor is ${vendor.action}`, vendor.result, vendor.action === 'created' ? 201 : 200));
checks.push(check('vendor has expected credit limit', vendor.vendor?.creditLimit === '200.000000', `vendorId=${vendor.vendor?.id}, creditLimit=${vendor.vendor?.creditLimit}`));
const invalidGateway = await requestApi('/api/v2/vendor-gateways', {
method: 'POST',
accessToken,
body: { vendorId: vendor.vendor?.id, name: '自动化8.4无效落地网关', authMode: 'IP', host: 'bad host', cpsLimit: 1 },
});
const invalidGatewayBody = parseJson(invalidGateway);
checks.push(statusCheck('invalid vendor gateway host is rejected', invalidGateway, 400));
checks.push(check('invalid host returns HOST_INVALID', invalidGatewayBody?.code === 'HOST_INVALID', `code=${invalidGatewayBody?.code || 'n/a'}`));
const weakSipGateway = await requestApi('/api/v2/vendor-gateways', {
method: 'POST',
accessToken,
body: { vendorId: vendor.vendor?.id, name: '自动化8.4弱SIP网关', authMode: 'SIP_DIGEST', host: '10.84.0.9', sipUsername: 'vgw84', sipPassword: 'short' },
});
checks.push(statusCheck('weak SIP password is rejected', weakSipGateway, 400));
const primary = await ensureVendorGateway(accessToken, vendor.vendor?.id, primaryGatewayName, '10.84.0.10', 0);
checks.push(statusCheck(`primary vendor gateway is ${primary.action}`, primary.result, primary.action === 'created' ? 201 : 200));
checks.push(check('primary gateway has child config', primary.gateway?.codecs?.length === 2 && primary.gateway?.prefixRules?.length === 2 && primary.gateway?.callerRewritePool?.length === 1, `codecs=${primary.gateway?.codecs?.length}, prefixRules=${primary.gateway?.prefixRules?.length}, callerRewrite=${primary.gateway?.callerRewritePool?.length}`));
const backup = await ensureVendorGateway(accessToken, vendor.vendor?.id, backupGatewayName, '10.84.0.11', 5);
checks.push(statusCheck(`backup vendor gateway is ${backup.action}`, backup.result, backup.action === 'created' ? 201 : 200));
const disableGateway = await requestApi(`/api/v2/vendor-gateways/${encodeURIComponent(backup.gateway?.id)}/disable`, { method: 'POST', accessToken });
checks.push(statusCheck('vendor gateway can be disabled', disableGateway, 201));
checks.push(check('disabled vendor gateway status is DISABLED', parseJson(disableGateway)?.status === 'DISABLED', `status=${parseJson(disableGateway)?.status}`));
const enableGateway = await requestApi(`/api/v2/vendor-gateways/${encodeURIComponent(backup.gateway?.id)}/enable`, { method: 'POST', accessToken });
checks.push(statusCheck('vendor gateway can be enabled', enableGateway, 201));
checks.push(check('enabled vendor gateway status is ENABLED', parseJson(enableGateway)?.status === 'ENABLED', `status=${parseJson(enableGateway)?.status}`));
const lineGroup = await ensureLineGroup(accessToken);
checks.push(statusCheck(`line group is ${lineGroup.action}`, lineGroup.result, lineGroup.action === 'created' ? 201 : 200));
const primaryItem = await ensureLineGroupItem(accessToken, lineGroup.group, primary.gateway, 10, 70);
checks.push(statusInCheck(`primary line group item is ${primaryItem.action}`, primaryItem.result, primaryItem.action === 'added' ? [201] : [200]));
const refreshedGroup = primaryItem.group || parseJson(await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}`, { accessToken }));
const backupItem = await ensureLineGroupItem(accessToken, refreshedGroup, backup.gateway, 20, 30);
checks.push(statusInCheck(`backup line group item is ${backupItem.action}`, backupItem.result, backupItem.action === 'added' ? [201] : [200]));
const groupDetail = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}`, { accessToken });
const groupBody = parseJson(groupDetail);
checks.push(statusCheck('line group detail can be fetched', groupDetail, 200));
checks.push(check('line group contains two enabled items', groupBody?.enabledItemCount >= 2 && groupBody?.items?.some((item) => item.vendorGatewayId === primary.gateway?.id) && groupBody?.items?.some((item) => item.vendorGatewayId === backup.gateway?.id), `enabledItemCount=${groupBody?.enabledItemCount}, itemCount=${groupBody?.itemCount}`));
const itemIds = groupBody?.items?.map((item) => item.id).reverse() || [];
const reorder = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}/items/reorder`, {
method: 'POST',
accessToken,
body: { itemIds },
});
checks.push(statusCheck('line group items can be reordered', reorder, 201));
checks.push(check('reorder preserves item set', parseJson(reorder)?.items?.length === groupBody?.items?.length, `before=${groupBody?.items?.length}, after=${parseJson(reorder)?.items?.length}`));
const duplicateItem = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}/items`, {
method: 'POST',
accessToken,
body: { vendorGatewayId: primary.gateway?.id, priority: 99, weight: 1, concurrencyCap: 1 },
});
checks.push(statusCheck('duplicate line group gateway item is rejected', duplicateItem, 409));
const deleteGatewayInGroup = await requestApi(`/api/v2/vendor-gateways/${encodeURIComponent(primary.gateway?.id)}`, { method: 'DELETE', accessToken });
const deleteGatewayInGroupBody = parseJson(deleteGatewayInGroup);
checks.push(statusCheck('vendor gateway referenced by line group cannot be deleted', deleteGatewayInGroup, 400));
checks.push(check('referenced gateway returns VENDOR_GATEWAY_IN_LINE_GROUP', deleteGatewayInGroupBody?.code === 'VENDOR_GATEWAY_IN_LINE_GROUP', `code=${deleteGatewayInGroupBody?.code || 'n/a'}`));
const disableLineGroup = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}/disable`, { method: 'POST', accessToken });
checks.push(statusCheck('line group can be disabled', disableLineGroup, 201));
checks.push(check('disabled line group status is DISABLED', parseJson(disableLineGroup)?.status === 'DISABLED', `status=${parseJson(disableLineGroup)?.status}`));
const enableLineGroup = await requestApi(`/api/v2/landing-line-groups/${encodeURIComponent(lineGroup.group?.id)}/enable`, { method: 'POST', accessToken });
checks.push(statusCheck('line group can be enabled', enableLineGroup, 201));
checks.push(check('enabled line group status is ENABLED', parseJson(enableLineGroup)?.status === 'ENABLED', `status=${parseJson(enableLineGroup)?.status}`));
const now = new Date();
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\..+$/, 'Z');
const reportPath = resolve(reportDir, `REMOTE_VENDORS_LINE_GROUPS_${stamp}.md`);
const failed = checks.filter((item) => !item.pass);
const report = [
'# Remote Vendors, Vendor Gateways, and Landing Line Groups Test Report',
'',
`Date: ${now.toISOString()}`,
`Base URL: ${baseUrl}`,
`Username: ${username}`,
`Low-Privilege Username: ${lowUsername}`,
`Vendor Name: ${vendorName}`,
`Line Group Name: ${lineGroupName}`,
'',
'| Result | Check | Detail |',
'| --- | --- | --- |',
...checks.map(reportLine),
'',
'## Notes',
'',
'- Password and token values are intentionally omitted.',
'- Vendor gateway and line group mutations enqueue config outbox events server-side.',
'- Config publication manifest is not exposed by the black-box API; DB or Redis observation is required to assert worker publication directly.',
'',
].join('\n');
await mkdir(reportDir, { recursive: true });
await writeFile(reportPath, report, 'utf8');
for (const item of checks) {
console.log(`${item.pass ? 'PASS' : 'FAIL'} ${item.name} - ${item.detail}`);
}
console.log(`Report: ${reportPath}`);
if (failed.length > 0) process.exitCode = 1;
+3
View File
@@ -0,0 +1,3 @@
# Fixtures
Shared fixture payloads for automated tests.
@@ -0,0 +1,61 @@
# Automation Step 1 Foundation Report
Date: 2026-06-29
## Scope
- Created the shared automated test workspace under `tests/`.
- Added root test script entry points.
- Added an idempotent fixed test data seed script at `prisma/seed-test.ts`.
## Changed Files
- `package.json`
- `prisma/seed-test.ts`
- `tests/README.md`
- `tests/api/README.md`
- `tests/web/README.md`
- `tests/smoke/README.md`
- `tests/fixtures/README.md`
- `tests/reports/README.md`
## Script Entry Points
- `pnpm test:baseline`
- `pnpm test:api`
- `pnpm test:all-local`
- `pnpm db:seed:test`
## Verification
| Check | Result | Notes |
| --- | --- | --- |
| `package.json` parse | PASS | JSON parsed successfully. |
| `tsc --noEmit prisma/seed-test.ts` | PASS | Prisma unique keys and TypeScript types compile. |
| `eslint prisma/seed-test.ts --max-warnings=0` | PASS | No lint violations. |
| `pnpm db:seed:test` | BLOCKED | Script starts correctly but local MySQL is not reachable at `127.0.0.1:3306`. |
## Seed Data Coverage
- Test users and roles for admin, viewer, finance, quality, customer gateway, vendor gateway, active call, and audit flows.
- Customer fixtures for normal balance and low balance scenarios.
- Business prefixes `671` and `672`.
- Vendor, primary/backup vendor gateways, codecs, prefix rewrite, forbidden period, caller rewrite pool.
- Landing line group with weighted primary/backup gateway items.
- Customer IP and SIP gateways, gateway IP, business prefix bindings, caller prefix, and policy.
- Number library seed for city, mobile segment, area code, and carrier prefix rule.
- Rated CDR, recording, sampling rule, and quality review samples.
## Current Blocker
Local database service is not running or not reachable:
```text
Can't reach database server at `127.0.0.1:3306`
```
After MySQL is available, rerun:
```powershell
corepack pnpm@10.33.0 db:seed:test
```
+3
View File
@@ -0,0 +1,3 @@
# Reports
Generated reports and run summaries can be saved here. Keep committed reports small and intentional.
@@ -0,0 +1,35 @@
# Remote Auth, Session, and Permission Test Report
Date: 2026-06-29T05:05:54.328Z
Base URL: https://100.90.90.91
Username: admin
| Result | Check | Detail |
| --- | --- | --- |
| PASS | captcha endpoint is public | status=200, duration=1787ms |
| PASS | captcha returns id, SVG image, and expiry | captchaId=9de040cf-75c5-440f-a5cc-4bbf16d052c3, expiresAt=2026-06-29T05:10:43.316Z |
| PASS | protected API rejects anonymous request | status=401, duration=1025ms |
| PASS | login rejects invalid captcha | status=401, duration=1152ms |
| PASS | invalid captcha returns AUTH_CAPTCHA_INVALID | code=AUTH_CAPTCHA_INVALID |
| PASS | captcha answer can be parsed from SVG | length=5 |
| PASS | login rejects invalid password with valid captcha | status=401, duration=489ms |
| PASS | invalid credentials code is returned | code=AUTH_INVALID_CREDENTIALS |
| PASS | login succeeds with valid captcha and password | status=200, duration=456ms |
| PASS | login returns access token | tokenLength=296 |
| PASS | login returns user profile and permissions | {"id":"usr_admin","username":"admin","displayName":"系统管理员","roles":["超级管理员"],"permissionCount":24} |
| PASS | login sets HttpOnly refresh cookie | refresh cookie present |
| PASS | bearer token can access protected dashboard summary | status=200, duration=402ms |
| PASS | invalid bearer token is rejected | status=401, duration=790ms |
| PASS | refresh rotates session and returns new token | status=200, duration=392ms |
| PASS | refresh returns access token | tokenChanged=true |
| PASS | refresh sets a rotated refresh cookie | rotated cookie present |
| PASS | refreshed bearer token can access protected dashboard summary | status=200, duration=411ms |
| PASS | logout revokes current refresh session | status=204, duration=399ms |
| PASS | refresh after logout is rejected | status=401, duration=376ms |
| PASS | admin account has non-empty permission set | permissionCount=24, roles=超级管理员 |
## Notes
- Password and token values are intentionally omitted.
- This run uses the remote B service as a black-box API target.
- Permission-denied 403 checks require a low-privilege account and are not asserted by this admin-only run.
@@ -0,0 +1,45 @@
# Remote Auth, Session, and Permission Test Report
Date: 2026-06-29T05:09:44.031Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | captcha endpoint is public | status=200, duration=2229ms |
| PASS | captcha returns id, SVG image, and expiry | captchaId=e003b59f-ddd2-4732-a0eb-8e7ff9e2a30e, expiresAt=2026-06-29T05:14:28.340Z |
| PASS | protected API rejects anonymous request | status=401, duration=664ms |
| PASS | login rejects invalid captcha | status=401, duration=375ms |
| PASS | invalid captcha returns AUTH_CAPTCHA_INVALID | code=AUTH_CAPTCHA_INVALID |
| PASS | captcha answer can be parsed from SVG | length=5 |
| PASS | login rejects invalid password with valid captcha | status=401, duration=613ms |
| PASS | invalid credentials code is returned | code=AUTH_INVALID_CREDENTIALS |
| PASS | login succeeds with valid captcha and password | status=200, duration=581ms |
| PASS | login returns access token | tokenLength=296 |
| PASS | login returns user profile and permissions | {"id":"usr_admin","username":"admin","displayName":"系统管理员","roles":["超级管理员"],"permissionCount":24} |
| PASS | login sets HttpOnly refresh cookie | refresh cookie present |
| PASS | low-privilege role is created | status=201, duration=555ms |
| PASS | low-privilege role only has dashboard.view | roleId=rol_1f4592370a1941cf860ff1afdc92, permissions=dashboard.view |
| PASS | low-privilege user is created | status=201, duration=449ms |
| PASS | low-privilege user is bound to low role | userId=usr_102e3dcda767449f9f288ec09e8b, roleIds=rol_1f4592370a1941cf860ff1afdc92 |
| PASS | bearer token can access protected dashboard summary | status=200, duration=387ms |
| PASS | invalid bearer token is rejected | status=401, duration=378ms |
| PASS | refresh rotates session and returns new token | status=200, duration=391ms |
| PASS | refresh returns access token | tokenChanged=true |
| PASS | refresh sets a rotated refresh cookie | rotated cookie present |
| PASS | refreshed bearer token can access protected dashboard summary | status=200, duration=383ms |
| PASS | logout revokes current refresh session | status=204, duration=384ms |
| PASS | refresh after logout is rejected | status=401, duration=825ms |
| PASS | low-privilege captcha answer can be parsed from SVG | length=5 |
| PASS | low-privilege user can login | status=200, duration=636ms |
| PASS | low-privilege login returns dashboard.view only | {"id":"usr_102e3dcda767449f9f288ec09e8b","username":"codex.low","displayName":"Codex低权限测试用户","roles":["自动化低权限角色"],"permissionCount":1} |
| PASS | low-privilege user can access allowed dashboard summary | status=200, duration=697ms |
| PASS | low-privilege user is forbidden from users.manage endpoint | status=403, duration=1132ms |
| PASS | admin account has non-empty permission set | permissionCount=24, roles=超级管理员 |
## Notes
- Password and token values are intentionally omitted.
- This run uses the remote B service as a black-box API target.
- The low-privilege role and user are created or updated through the admin API before RBAC assertions.
@@ -0,0 +1,45 @@
# Remote Auth, Session, and Permission Test Report
Date: 2026-06-29T05:10:08.459Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | captcha endpoint is public | status=200, duration=2754ms |
| PASS | captcha returns id, SVG image, and expiry | captchaId=ffef58d0-1acf-43f7-adf2-6a97bf64960d, expiresAt=2026-06-29T05:14:51.988Z |
| PASS | protected API rejects anonymous request | status=401, duration=387ms |
| PASS | login rejects invalid captcha | status=401, duration=857ms |
| PASS | invalid captcha returns AUTH_CAPTCHA_INVALID | code=AUTH_CAPTCHA_INVALID |
| PASS | captcha answer can be parsed from SVG | length=5 |
| PASS | login rejects invalid password with valid captcha | status=401, duration=441ms |
| PASS | invalid credentials code is returned | code=AUTH_INVALID_CREDENTIALS |
| PASS | login succeeds with valid captcha and password | status=200, duration=455ms |
| PASS | login returns access token | tokenLength=296 |
| PASS | login returns user profile and permissions | {"id":"usr_admin","username":"admin","displayName":"系统管理员","roles":["超级管理员"],"permissionCount":24} |
| PASS | login sets HttpOnly refresh cookie | refresh cookie present |
| PASS | low-privilege role is updated | status=200, duration=400ms |
| PASS | low-privilege role only has dashboard.view | roleId=rol_1f4592370a1941cf860ff1afdc92, permissions=dashboard.view |
| FAIL | low-privilege user is updated | status=201, duration=583ms |
| PASS | low-privilege user is bound to low role | userId=usr_102e3dcda767449f9f288ec09e8b, roleIds=rol_1f4592370a1941cf860ff1afdc92 |
| PASS | bearer token can access protected dashboard summary | status=200, duration=430ms |
| PASS | invalid bearer token is rejected | status=401, duration=379ms |
| PASS | refresh rotates session and returns new token | status=200, duration=412ms |
| PASS | refresh returns access token | tokenChanged=true |
| PASS | refresh sets a rotated refresh cookie | rotated cookie present |
| PASS | refreshed bearer token can access protected dashboard summary | status=200, duration=979ms |
| PASS | logout revokes current refresh session | status=204, duration=531ms |
| PASS | refresh after logout is rejected | status=401, duration=373ms |
| PASS | low-privilege captcha answer can be parsed from SVG | length=5 |
| PASS | low-privilege user can login | status=200, duration=709ms |
| PASS | low-privilege login returns dashboard.view only | {"id":"usr_102e3dcda767449f9f288ec09e8b","username":"codex.low","displayName":"Codex低权限测试用户","roles":["自动化低权限角色"],"permissionCount":1} |
| PASS | low-privilege user can access allowed dashboard summary | status=200, duration=572ms |
| PASS | low-privilege user is forbidden from users.manage endpoint | status=403, duration=383ms |
| PASS | admin account has non-empty permission set | permissionCount=24, roles=超级管理员 |
## Notes
- Password and token values are intentionally omitted.
- This run uses the remote B service as a black-box API target.
- The low-privilege role and user are created or updated through the admin API before RBAC assertions.
@@ -0,0 +1,45 @@
# Remote Auth, Session, and Permission Test Report
Date: 2026-06-29T05:11:04.930Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | captcha endpoint is public | status=200, duration=2542ms |
| PASS | captcha returns id, SVG image, and expiry | captchaId=3bae4958-534c-4c78-a720-24f0324c4afc, expiresAt=2026-06-29T05:15:46.682Z |
| PASS | protected API rejects anonymous request | status=401, duration=390ms |
| PASS | login rejects invalid captcha | status=401, duration=856ms |
| PASS | invalid captcha returns AUTH_CAPTCHA_INVALID | code=AUTH_CAPTCHA_INVALID |
| PASS | captcha answer can be parsed from SVG | length=5 |
| PASS | login rejects invalid password with valid captcha | status=401, duration=1026ms |
| PASS | invalid credentials code is returned | code=AUTH_INVALID_CREDENTIALS |
| PASS | login succeeds with valid captcha and password | status=200, duration=452ms |
| PASS | login returns access token | tokenLength=296 |
| PASS | login returns user profile and permissions | {"id":"usr_admin","username":"admin","displayName":"系统管理员","roles":["超级管理员"],"permissionCount":24} |
| PASS | login sets HttpOnly refresh cookie | refresh cookie present |
| PASS | low-privilege role is updated | status=200, duration=1123ms |
| PASS | low-privilege role only has dashboard.view | roleId=rol_1f4592370a1941cf860ff1afdc92, permissions=dashboard.view |
| PASS | low-privilege user is updated | status=201, expected=200/201, duration=705ms |
| PASS | low-privilege user is bound to low role | userId=usr_102e3dcda767449f9f288ec09e8b, roleIds=rol_1f4592370a1941cf860ff1afdc92 |
| PASS | bearer token can access protected dashboard summary | status=200, duration=393ms |
| PASS | invalid bearer token is rejected | status=401, duration=392ms |
| PASS | refresh rotates session and returns new token | status=200, duration=392ms |
| PASS | refresh returns access token | tokenChanged=true |
| PASS | refresh sets a rotated refresh cookie | rotated cookie present |
| PASS | refreshed bearer token can access protected dashboard summary | status=200, duration=387ms |
| PASS | logout revokes current refresh session | status=204, duration=386ms |
| PASS | refresh after logout is rejected | status=401, duration=374ms |
| PASS | low-privilege captcha answer can be parsed from SVG | length=5 |
| PASS | low-privilege user can login | status=200, duration=1152ms |
| PASS | low-privilege login returns dashboard.view only | {"id":"usr_102e3dcda767449f9f288ec09e8b","username":"codex.low","displayName":"Codex低权限测试用户","roles":["自动化低权限角色"],"permissionCount":1} |
| PASS | low-privilege user can access allowed dashboard summary | status=200, duration=538ms |
| PASS | low-privilege user is forbidden from users.manage endpoint | status=403, duration=449ms |
| PASS | admin account has non-empty permission set | permissionCount=24, roles=超级管理员 |
## Notes
- Password and token values are intentionally omitted.
- This run uses the remote B service as a black-box API target.
- The low-privilege role and user are created or updated through the admin API before RBAC assertions.
@@ -0,0 +1,48 @@
# Remote SIP Calls, CDR, and Billing API Test Report
Date: 2026-06-29T05:34:25.003Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=1308ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=1060ms |
| PASS | low-privilege user cannot list CDRs | status=403, duration=477ms |
| PASS | low-privilege user cannot list active calls | status=403, duration=464ms |
| PASS | CDR list can be queried | status=200, duration=1818ms |
| PASS | CDR list returns page shape | total=56, take=20, skip=0, hasMore=true |
| PASS | CDR list items do not expose secrets | items=20 |
| PASS | CDR detail can be fetched | status=200, duration=683ms |
| PASS | CDR detail matches list item id and event id | id=raw_4281ea422d1c4465964c6bf054a90115, eventId=s28-ok-1782701051-2331-s40-01-1782701049649-q3u-fde892c670e534f8 |
| PASS | CDR detail does not expose secrets | id=raw_4281ea422d1c4465964c6bf054a90115 |
| PASS | rated CDR detail has numeric fee fields | billSec=6, customerFee=0.012000, vendorCost=0.012000, grossProfit=0.000000 |
| PASS | CDR caller filter can be queried | status=200, duration=2032ms |
| PASS | CDR caller filter returns matching rows | rows=9, caller=s36-1001 |
| PASS | CDR carrier filter can be queried | status=200, duration=1585ms |
| PASS | CDR carrier filter returns matching rows | rows=10, carrier=UNKNOWN |
| PASS | invalid CDR carrier is rejected | status=400, duration=774ms |
| PASS | invalid carrier returns CARRIER_INVALID | code=CARRIER_INVALID |
| PASS | invalid CDR pagination is rejected | status=400, duration=464ms |
| PASS | invalid pagination returns QUERY_INVALID | code=QUERY_INVALID |
| PASS | invalid CDR time range is rejected | status=400, duration=458ms |
| PASS | invalid time range returns TIME_RANGE_INVALID | code=TIME_RANGE_INVALID |
| PASS | missing CDR detail returns 404 | status=404, duration=389ms |
| PASS | missing CDR returns CDR_NOT_FOUND | code=CDR_NOT_FOUND |
| PASS | active calls list can be queried | status=200, duration=969ms |
| PASS | active calls response has normalized shape | source=opensips-mi, total=0 |
| PASS | invalid active call hangup id is rejected before MI call | status=400, duration=561ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID | code=ACTIVE_CALL_ID_INVALID |
## Not Executed By This Black-Box API Run
- Real IP/SIP customer calls from T through A to UAS.
- SIP Digest wrong-password REGISTER/INVITE signaling assertions.
- Low-balance hot-path rejection assertions.
- Primary/backup route failover proven by live call CDR vendorGatewayId.
- Redis Stream CDR injection, duplicate event idempotency, deadletter, and retry/pending checks.
- Direct customer balance deduction by CDR Worker transaction.
These require A/B/T SIP tooling or Redis/DB side access in addition to the HTTPS API.
@@ -0,0 +1,38 @@
# Remote Customers, Recharge, and Balance Test Report
Date: 2026-06-29T05:18:50.822Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Customer Name: 自动化8.2客户
Customer Domain: codex-82.example.test
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=544ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=454ms |
| PASS | low-privilege user cannot list customers | status=403, duration=395ms |
| PASS | test customer is created | status=201, duration=1007ms |
| PASS | test customer has expected credit/min balance | creditLimit=100.000000, minBalance=5.000000 |
| PASS | customer detail can be fetched | status=200, duration=528ms |
| PASS | available balance equals balance plus credit limit | balance=0.000000, creditLimit=100.000000, available=100.000000 |
| PASS | customer can be disabled | status=201, duration=401ms |
| PASS | disabled customer status is DISABLED | status=DISABLED |
| PASS | customer can be enabled | status=201, duration=403ms |
| PASS | enabled customer status is ENABLED | status=ENABLED |
| PASS | positive customer recharge succeeds | status=201, duration=410ms |
| PASS | positive recharge balance delta is +10.000000 | before=0.000000, after=10.000000 |
| PASS | same idempotency key with same body returns cached success | status=201, duration=412ms |
| PASS | idempotent duplicate returns same recharge id | first=rch_3dd6d1209d2f4d12ad69926097d7f2aa, duplicate=rch_3dd6d1209d2f4d12ad69926097d7f2aa |
| PASS | same idempotency key with different body is rejected | status=409, duration=404ms |
| FAIL | negative customer recharge deducts balance | status=400, duration=866ms |
| FAIL | negative recharge balance delta is -3.000000 | before=undefined, after=undefined, code=MONEY_INVALID |
| PASS | customer recharge list can be filtered by account | status=200, duration=1857ms |
| PASS | recharge list contains positive recharge record | total=1 |
| PASS | low-privilege user cannot recharge customer | status=403, duration=618ms |
## Notes
- Password and token values are intentionally omitted.
- Negative recharge is asserted as a required business rule: negative amount should deduct customer balance.
@@ -0,0 +1,49 @@
# Remote Customer Gateways, Business Prefixes, and Config Intent Test Report
Date: 2026-06-29T05:22:45.686Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Business Prefix: C83
Customer Name: 自动化8.3客户
Gateway Name: 自动化8.3客户网关
Gateway IP: 100.83.0.10
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=902ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=830ms |
| PASS | low-privilege user cannot list business prefixes | status=403, duration=614ms |
| PASS | low-privilege user cannot list customer gateways | status=403, duration=417ms |
| PASS | invalid business prefix is rejected | status=400, duration=421ms |
| PASS | invalid business prefix returns BUSINESS_PREFIX_INVALID | code=BUSINESS_PREFIX_INVALID |
| PASS | business prefix is created | status=201, duration=424ms |
| PASS | business prefix has expected values | id=bp_b9988f12a1bb418c9e64ce0754d32, prefix=C83, priority=83 |
| PASS | business prefix can be disabled | status=201, duration=432ms |
| PASS | disabled business prefix status is DISABLED | status=DISABLED |
| PASS | business prefix can be enabled | status=201, duration=427ms |
| PASS | enabled business prefix status is ENABLED | status=ENABLED |
| PASS | test customer is created | status=201, duration=482ms |
| PASS | landing line group list can be fetched | status=200, duration=1279ms |
| PASS | at least one landing line group is available for gateway binding | lineGroupId=llg_4e6997487b774379836631aee19c, name=S36 Flow 20260623160046 Line Group |
| PASS | invalid customer gateway source IP is rejected | status=400, duration=477ms |
| PASS | invalid source IP returns SOURCE_IP_INVALID | code=SOURCE_IP_INVALID |
| PASS | SIP gateway without password is rejected | status=400, duration=444ms |
| FAIL | missing SIP password returns SIP_PASSWORD_REQUIRED | code=VALIDATION_ERROR |
| PASS | customer gateway is created | status=201, duration=1048ms |
| PASS | customer gateway binds IP, caller prefix, and business prefix | gatewayId=cgw_16f4a342caf74ab5b3e34457113c, sourceIps=100.83.0.10, callerPrefixes=055183 |
| PASS | duplicate source IP and business prefix gateway is rejected | status=409, duration=786ms |
| PASS | duplicate gateway returns match conflict code | code=CUSTOMER_GATEWAY_MATCH_CONFLICT |
| PASS | customer gateway can be disabled | status=201, duration=587ms |
| PASS | disabled customer gateway status is DISABLED | status=DISABLED |
| PASS | customer gateway can be enabled | status=201, duration=440ms |
| PASS | enabled customer gateway status is ENABLED | status=ENABLED |
| PASS | business prefix in use cannot be deleted | status=400, duration=390ms |
| PASS | business prefix in use returns BUSINESS_PREFIX_IN_USE | code=BUSINESS_PREFIX_IN_USE |
## Notes
- Password and token values are intentionally omitted.
- Business prefix and customer gateway mutations enqueue config outbox events server-side.
- Config publication manifest is not exposed by the black-box API; DB or Redis observation is required to assert worker publication directly.
@@ -0,0 +1,49 @@
# Remote Customer Gateways, Business Prefixes, and Config Intent Test Report
Date: 2026-06-29T05:23:46.288Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Business Prefix: C83
Customer Name: 自动化8.3客户
Gateway Name: 自动化8.3客户网关
Gateway IP: 100.83.0.10
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=597ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=452ms |
| PASS | low-privilege user cannot list business prefixes | status=403, duration=389ms |
| PASS | low-privilege user cannot list customer gateways | status=403, duration=833ms |
| PASS | invalid business prefix is rejected | status=400, duration=380ms |
| PASS | invalid business prefix returns BUSINESS_PREFIX_INVALID | code=BUSINESS_PREFIX_INVALID |
| PASS | business prefix is updated | status=200, duration=429ms |
| PASS | business prefix has expected values | id=bp_b9988f12a1bb418c9e64ce0754d32, prefix=C83, priority=83 |
| PASS | business prefix can be disabled | status=201, duration=394ms |
| PASS | disabled business prefix status is DISABLED | status=DISABLED |
| PASS | business prefix can be enabled | status=201, duration=387ms |
| PASS | enabled business prefix status is ENABLED | status=ENABLED |
| PASS | test customer is updated | status=200, duration=430ms |
| PASS | landing line group list can be fetched | status=200, duration=578ms |
| PASS | at least one landing line group is available for gateway binding | lineGroupId=llg_4e6997487b774379836631aee19c, name=S36 Flow 20260623160046 Line Group |
| PASS | invalid customer gateway source IP is rejected | status=400, duration=795ms |
| PASS | invalid source IP returns SOURCE_IP_INVALID | code=SOURCE_IP_INVALID |
| PASS | SIP gateway without password is rejected | status=400, duration=382ms |
| PASS | missing SIP password returns a validation error | code=VALIDATION_ERROR |
| PASS | customer gateway is updated | status=200, duration=452ms |
| PASS | customer gateway binds IP, caller prefix, and business prefix | gatewayId=cgw_16f4a342caf74ab5b3e34457113c, sourceIps=100.83.0.10, callerPrefixes=055183 |
| PASS | duplicate source IP and business prefix gateway is rejected | status=409, duration=585ms |
| PASS | duplicate gateway returns match conflict code | code=CUSTOMER_GATEWAY_MATCH_CONFLICT |
| PASS | customer gateway can be disabled | status=201, duration=469ms |
| PASS | disabled customer gateway status is DISABLED | status=DISABLED |
| PASS | customer gateway can be enabled | status=201, duration=598ms |
| PASS | enabled customer gateway status is ENABLED | status=ENABLED |
| PASS | business prefix in use cannot be deleted | status=400, duration=385ms |
| PASS | business prefix in use returns BUSINESS_PREFIX_IN_USE | code=BUSINESS_PREFIX_IN_USE |
## Notes
- Password and token values are intentionally omitted.
- Business prefix and customer gateway mutations enqueue config outbox events server-side.
- Config publication manifest is not exposed by the black-box API; DB or Redis observation is required to assert worker publication directly.
@@ -0,0 +1,60 @@
# Remote Dashboard, Active Calls, and Audit Test Report
Date: 2026-06-29T05:51:04.581Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=1046ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=675ms |
| PASS | dashboard summary can be queried | status=200, duration=539ms |
| PASS | dashboard summary shape and Shanghai day window are valid | {"window":{"start":"2026-06-28T16:00:00.000Z","end":"2026-06-29T05:50:47.137Z","timezone":"Asia/Shanghai"},"calls":{"totalCalls":1,"answeredCalls":1,"failedCalls":0,"answerRate":"1.0000","totalDurationSec":6},"money":{"customerFee":"0.012000","vendorCost":"0.012000","grossProfit":"0.000000"},"quality":{"pendingReviews":54}} |
| PASS | dashboard trends can be queried with fixed range | status=200, duration=408ms |
| PASS | dashboard trends return fixed contiguous buckets | bucketCount=2 |
| PASS | invalid dashboard trend bucket is rejected | status=400, duration=392ms |
| PASS | invalid trend bucket returns DASHBOARD_BUCKET_INVALID | code=DASHBOARD_BUCKET_INVALID |
| PASS | too-large dashboard trend range is rejected | status=400, duration=379ms |
| PASS | too-large trend range returns INTEGER_INVALID | code=INTEGER_INVALID |
| PASS | low dashboard-only user can query dashboard summary | status=200, duration=975ms |
| PASS | active calls can be listed | status=200, duration=840ms |
| PASS | active calls response shape is stable | {"total":0,"source":"opensips-mi"} |
| PASS | low dashboard-only user cannot list active calls | status=403, duration=379ms |
| PASS | invalid active call id is rejected (../x) | status=400, duration=410ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (../x) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (;rm -rf) | status=400, duration=388ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (;rm -rf) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (contains space) | status=400, duration=384ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (contains space) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (line\nbreak) | status=400, duration=385ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (line\nbreak) | code=ACTIVE_CALL_ID_INVALID |
| FAIL | invalid active call id is rejected (xxxxxxxxxxxxxxxxxxxx) | status=404, duration=377ms |
| FAIL | invalid active call id returns ACTIVE_CALL_ID_INVALID (xxxxxxxxxxxxxxxxxxxx) | code=undefined |
| PASS | low dashboard-only user cannot hang up calls | status=403, duration=380ms |
| PASS | audit logs can be listed | status=200, duration=756ms |
| PASS | audit list shape is valid | count=10, total=113 |
| PASS | low dashboard-only user cannot list audit logs | status=403, duration=404ms |
| PASS | invalid audit result filter is rejected | status=400, duration=380ms |
| PASS | invalid audit result returns AUDIT_RESULT_INVALID | code=AUDIT_RESULT_INVALID |
| PASS | audit logs can be filtered by result | status=200, duration=899ms |
| PASS | audit success filter only returns SUCCESS rows | count=5 |
| PASS | roles can be listed for temporary audit user setup | status=200, duration=758ms |
| PASS | dashboard-capable role is available | roleId=ROLE_TECH_OPS |
| PASS | temporary user with sensitive password can be created | status=201, expected=201, duration=776ms |
| PASS | created temporary user response does not expose password fields | {"id":"usr_ddd21fd564f6456a8c120dc940d4","username":"codex.audit.1782712243816"} |
| PASS | temporary user password reset succeeds | status=201, duration=580ms |
| PASS | password reset response does not expose sensitive fields | {"id":"usr_ddd21fd564f6456a8c120dc940d4","username":"codex.audit.1782712243816"} |
| PASS | password reset audit can be filtered by module/action/object/result | status=200, duration=437ms |
| PASS | password reset audit row exists | auditId=aud_09853278ea1a4d7b8ff64275a926f9ac |
| PASS | password reset audit detail can be fetched | status=200, duration=1000ms |
| FAIL | password reset audit detail redacts sensitive body fields | {"id":"aud_09853278ea1a4d7b8ff64275a926f9ac","redactedPassword":"[REDACTED]"} |
| PASS | temporary audit user cleanup is stable | status=200, expected=200/404, duration=1695ms |
## Notes
- Password and token values are intentionally omitted from console and report details.
- DASH-001 aggregate accuracy and DASH-002 exact Shanghai day-boundary attribution still require SQL comparison against seeded boundary CDRs.
- ACT-001/ACT-002 real long-call normalization and successful hangup require an active OpenSIPS dialog on A; this black-box run verifies list contract, RBAC, and invalid dialog-id safety.
- AUD-002 application log full-text checks require host-side log access; this run verifies API response and audit detail redaction.
@@ -0,0 +1,60 @@
# Remote Dashboard, Active Calls, and Audit Test Report
Date: 2026-06-29T05:52:07.874Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=457ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=1291ms |
| PASS | dashboard summary can be queried | status=200, duration=433ms |
| PASS | dashboard summary shape and Shanghai day window are valid | {"window":{"start":"2026-06-28T16:00:00.000Z","end":"2026-06-29T05:51:50.712Z","timezone":"Asia/Shanghai"},"calls":{"totalCalls":1,"answeredCalls":1,"failedCalls":0,"answerRate":"1.0000","totalDurationSec":6},"money":{"customerFee":"0.012000","vendorCost":"0.012000","grossProfit":"0.000000"},"quality":{"pendingReviews":54}} |
| PASS | dashboard trends can be queried with fixed range | status=200, duration=382ms |
| PASS | dashboard trends return fixed contiguous buckets | bucketCount=2 |
| PASS | invalid dashboard trend bucket is rejected | status=400, duration=800ms |
| PASS | invalid trend bucket returns DASHBOARD_BUCKET_INVALID | code=DASHBOARD_BUCKET_INVALID |
| PASS | too-large dashboard trend range is rejected | status=400, duration=1413ms |
| PASS | too-large trend range returns INTEGER_INVALID | code=INTEGER_INVALID |
| PASS | low dashboard-only user can query dashboard summary | status=200, duration=806ms |
| PASS | active calls can be listed | status=200, duration=807ms |
| PASS | active calls response shape is stable | {"total":0,"source":"opensips-mi"} |
| PASS | low dashboard-only user cannot list active calls | status=403, duration=379ms |
| PASS | invalid active call id is rejected (../x) | status=400, duration=382ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (../x) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (;rm -rf) | status=400, duration=867ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (;rm -rf) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (contains space) | status=400, duration=385ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (contains space) | code=ACTIVE_CALL_ID_INVALID |
| PASS | invalid active call id is rejected (line\nbreak) | status=400, duration=386ms |
| PASS | invalid active call id returns ACTIVE_CALL_ID_INVALID (line\nbreak) | code=ACTIVE_CALL_ID_INVALID |
| FAIL | invalid active call id is rejected (xxxxxxxxxxxxxxxxxxxx) | status=404, duration=437ms |
| FAIL | invalid active call id returns ACTIVE_CALL_ID_INVALID (xxxxxxxxxxxxxxxxxxxx) | code=undefined |
| PASS | low dashboard-only user cannot hang up calls | status=403, duration=414ms |
| PASS | audit logs can be listed | status=200, duration=1314ms |
| PASS | audit list shape is valid | count=10, total=120 |
| PASS | low dashboard-only user cannot list audit logs | status=403, duration=463ms |
| PASS | invalid audit result filter is rejected | status=400, duration=377ms |
| PASS | invalid audit result returns AUDIT_RESULT_INVALID | code=AUDIT_RESULT_INVALID |
| PASS | audit logs can be filtered by result | status=200, duration=558ms |
| PASS | audit success filter only returns SUCCESS rows | count=5 |
| PASS | roles can be listed for temporary audit user setup | status=200, duration=424ms |
| PASS | dashboard-capable role is available | roleId=ROLE_TECH_OPS |
| PASS | temporary user with sensitive password can be created | status=201, expected=201, duration=432ms |
| PASS | created temporary user response does not expose password fields | {"id":"usr_b548190698424ba9b7b4a6c37bb2","username":"codex.audit.1782712309898"} |
| PASS | temporary user password reset succeeds | status=201, duration=432ms |
| PASS | password reset response does not expose sensitive fields | {"id":"usr_b548190698424ba9b7b4a6c37bb2","username":"codex.audit.1782712309898"} |
| PASS | password reset audit can be filtered by module/action/object/result | status=200, duration=695ms |
| PASS | password reset audit row exists | auditId=aud_a5b773a3ee0c4397a81eaf2b8fbac455 |
| PASS | password reset audit detail can be fetched | status=200, duration=580ms |
| PASS | password reset audit detail redacts sensitive body fields | {"id":"aud_a5b773a3ee0c4397a81eaf2b8fbac455","redactedPassword":"[REDACTED]"} |
| PASS | temporary audit user cleanup is stable | status=200, expected=200/404, duration=475ms |
## Notes
- Password and token values are intentionally omitted from console and report details.
- DASH-001 aggregate accuracy and DASH-002 exact Shanghai day-boundary attribution still require SQL comparison against seeded boundary CDRs.
- ACT-001/ACT-002 real long-call normalization and successful hangup require an active OpenSIPS dialog on A; this black-box run verifies list contract, RBAC, and invalid dialog-id safety.
- AUD-002 application log full-text checks require host-side log access; this run verifies API response and audit detail redaction.
@@ -0,0 +1,51 @@
# 8.10 运维发布、备份与回滚远程测试报告
- Target: `https://100.90.90.91/`
- SSH target: `lisglosips-b`, `lisglosips-a`, `lisglosips-t`
- Started at: `2026-06-29T06:30:00Z`
- Completed at: `2026-06-29T06:42:36Z`
- Scope: 8.10 发布工件、发布前检查、备份、恢复、灰度呼叫、回滚与迁移复核
## Summary
| Result | Count |
| --- | ---: |
| PASS | 5 |
| PARTIAL | 1 |
| BLOCKED | 6 |
| N/A | 1 |
## Findings
| Case | Result | Evidence |
| --- | --- | --- |
| OPS-000 发布工件完整性检查 | PASS | `pnpm release:artifact -- --release-id codex-ops-check-20260629063000 --check --allow-non-linux` 通过;输出确认 39 个必需条目齐全。 |
| OPS-001 B 发布前检查 | PARTIAL | `/opt/lisglosips/current` 指向 `/opt/lisglosips/releases/s45-vendor-cps-sipstate-20260629113500`B 上 `mysql``redis-server``nginx``lisglosips@api``lisglosips@cdr-worker``lisglosips@recording-worker``lisglosips@config-publisher``heplify-server``grafana-server``lisglosips-prometheus.service` 均为 active`/api/v2/health/ready` 返回 database/redis ok;但非 sudo 用户无法读取或执行 `/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh`,脚本级 preflight 被权限阻塞。 |
| OPS-002 MySQL 备份 | BLOCKED | 非 sudo 用户访问 `/data/backups/mysql` 返回 `权限不够`;无法触发 `lisglosips-backup.service` 或校验最新备份文件。 |
| OPS-003 Redis 备份 | BLOCKED | 非 sudo 用户访问 `/data/backups/redis` 返回 `权限不够`;无法触发备份或校验最新 RDB/元数据。 |
| OPS-004 隔离恢复演练 | BLOCKED | 需要可读备份文件、临时恢复目录/容器或 root 级恢复权限;本轮未获得 sudo,未执行恢复演练。 |
| OPS-005 灰度呼叫验收 | PASS | 从 T 发起呼叫成功:Call-ID `s28-1782715222404-1w73ad89@lisglosips-t`INVITE 收到 `SIP/2.0 200 OK`BYE 收到 `SIP/2.0 200 OK`。API 反查 CDR 成功,`sampleId=raw_95fb2de5aafb4b0a808ddccc6293d7da`;录音列表找到对应记录,`sampleId=rec_47012fa5341e42f19fec400c1972b6ae`,状态 `READY`。 |
| OPS-006 回滚脚本语法检查 | BLOCKED | 本机无 `bash`B 上 `/opt/lisglosips/current/infra/server-b/s30/lisglosips-release-preflight.sh``lisglosips-release-rollback.sh` 对当前用户不可读;未能执行 `bash -n`。 |
| OPS-007 应用回滚演练 | BLOCKED | 需要修改 `/opt/lisglosips/current` 指针并重启服务,属于 root/维护窗口动作;当前 sudo 不可用,未执行真实回滚。 |
| OPS-008 OpenSIPS 配置恢复演练 | BLOCKED | A 上以非 root 执行 `opensips -C -f /etc/opensips/opensips.cfg` 因读取配置权限不足失败;配置恢复脚本路径/权限未满足,未执行恢复演练。 |
| OPS-009 阿里云迁移复核 | N/A | 本轮目标为现有测试机 `100.90.90.91`,不是阿里云迁移后的新环境;仅做当前环境可用性复核。 |
| 发布后 HTTP/API 健康回归 | PASS | `GET /` 返回 200`/api/v2/health/ready` 返回 `{"config":"ok","database":"ok","redis":"ok"}`。 |
| 远程 smoke 回归 | PASS | `pnpm test:remote-smoke` 全部通过,报告:`tests/reports/REMOTE_SMOKE_20260629T064035Z.md`。 |
## Blocking Notes
- B 上当前 SSH 用户无法免密 sudo,`sudo -n true` 返回需要密码。
- `/data/backups/mysql``/data/backups/redis`、发布 preflight/rollback 脚本均对当前用户不可读。
- 因此备份触发、备份文件校验、隔离恢复、真实应用回滚、OpenSIPS 配置恢复只能在具备 root 权限或维护窗口时继续。
## Commands Run
```powershell
corepack pnpm@10.33.0 release:artifact -- --release-id codex-ops-check-20260629063000 --check --allow-non-linux
ssh -F .codex-private\ssh\config lisglosips-b "readlink -f /opt/lisglosips/current"
ssh -F .codex-private\ssh\config lisglosips-b "systemctl is-active mysql redis-server nginx lisglosips@api lisglosips@cdr-worker lisglosips@recording-worker lisglosips@config-publisher heplify-server grafana-server lisglosips-prometheus.service"
ssh -F .codex-private\ssh\config lisglosips-b "find /data/backups/mysql -mindepth 1 -maxdepth 1 -type d"
ssh -F .codex-private\ssh\config lisglosips-b "find /data/backups/redis -mindepth 1 -maxdepth 1 -type d"
ssh -F .codex-private\ssh\config lisglosips-t "python3 /opt/lisglosips-s28/lisglosips-s28-sip.py invite --hold 3 --timeout 6 --media-port 31500"
corepack pnpm@10.33.0 test:remote-smoke
```
@@ -0,0 +1,43 @@
# Remote Performance, Fault, and Security Test Report
Date: 2026-06-29T06:16:21.982Z
Base URL: https://100.90.90.91
Concurrency: 12 ready + 12 captcha requests
| Result | Check | Detail |
| --- | --- | --- |
| PASS | HTTPS root is reachable before light concurrency | status=200, duration=2087ms |
| PASS | HTTPS root contains app root | contentType=text/html, bytes=434 |
| PASS | ready health is ok before light concurrency | status=200, duration=389ms |
| PASS | ready health reports database and redis ok before light concurrency | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:15:52.352Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | admin can login | status=200, duration=1103ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=773ms |
| PASS | PERF light API burst returns only 200 responses | requests=24, failed=0, wallMs=15477 |
| FAIL | PERF light API burst p95 stays under 10s | p95=14919ms, max=15441ms |
| PASS | ready health recovers after light concurrency | status=200, duration=604ms |
| PASS | ready health reports database and redis ok after light concurrency | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:16:11.657Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | SEC forged bearer token is rejected | status=401, duration=372ms |
| PASS | SEC forged token error does not leak internals | body={"code":"AUTH_REQUIRED","message":"Authentication is required."} |
| PASS | SEC unauthenticated write is rejected | status=401, duration=775ms |
| PASS | SEC unauthenticated write error does not leak internals | body={"code":"AUTH_REQUIRED","message":"Authentication is required."} |
| PASS | SEC low-privilege user cannot create customer | status=403, duration=605ms |
| PASS | SEC low-privilege write error does not leak internals | body={"code":"RBAC_FORBIDDEN","message":"Permission denied."} |
| PASS | SEC replay test customer is created | status=201, duration=1141ms |
| PASS | SEC first idempotent recharge succeeds | status=201, duration=535ms |
| PASS | SEC exact idempotent replay returns success | status=201, duration=456ms |
| PASS | SEC exact idempotent replay returns same recharge id | first=rch_44943fc6e1d3483ab17eee61fe279b1d, replay=rch_44943fc6e1d3483ab17eee61fe279b1d |
| PASS | SEC conflicting idempotency replay is rejected | status=409, duration=601ms |
| PASS | SEC conflicting replay error does not leak original body | body={"code":"IDEMPOTENCY_KEY_CONFLICT","message":"Idempotency key was used by another request."} |
| PASS | SEC low-privilege user cannot replay/write recharge | status=403, duration=371ms |
| PASS | SEC missing recording playback returns 404 | status=404, duration=575ms |
| PASS | SEC missing recording playback error does not expose paths | body={"code":"RECORDING_NOT_READY","message":"Recording is not available for playback."} |
| PASS | SEC encoded traversal recording id is rejected safely | status=404, body={"code":"RECORDING_NOT_READY","message":"Recording is not available for playback."} |
| PASS | SEC traversal playback error does not expose filesystem paths | body={"code":"RECORDING_NOT_READY","message":"Recording is not available for playback."} |
## Notes
- This run intentionally avoids disruptive fault injection: no Worker, MySQL, Redis, OpenSIPS, or SIP traffic was stopped or modified.
- PERF-001/PERF-002 SIP call concurrency, SEC-001 illegal-source SIP probe, and SEC-002 CPS probe still require A/T-side SIP tooling and CDR/recording verification.
- FAIL-001 through FAIL-004 require an explicit maintenance window, rollback point, and service-stop approval before execution.
- The executed subset covers HTTPS/API light concurrency, service recovery after burst, forged/unauthenticated/low-privilege access, idempotency replay, and recording path-safety black-box checks.
@@ -0,0 +1,76 @@
# Remote Performance, Fault, and Security Disruptive Test Report
Date: 2026-06-29T06:29:20Z
Base URL: https://100.90.90.91
Scope: 8.9 disruptive / SIP-side follow-up after `REMOTE_PERFORMANCE_SECURITY_20260629T061621Z.md`
## Baseline
| Result | Check | Detail |
| --- | --- | --- |
| PASS | B services active before/after run | `lisglosips@api`, `lisglosips@cdr-worker`, `lisglosips@recording-worker`, `mysql`, `redis-server` all active |
| PASS | A services active before run | `opensips`, `rtpengine-daemon`, `rtpengine-recording-daemon`, `lisglosips-redis-auth-proxy` all active |
| PASS | T services active before run | `lisglosips-s28-uas`, `opensips` active |
| PASS | Final HTTPS smoke | `/`, `/api/v2/health/live`, `/api/v2/health/ready`, `/api/v2/auth/captcha` all PASS; latest smoke report `REMOTE_SMOKE_20260629T062920Z.md` |
## SIP Concurrency
| Result | Check | Detail |
| --- | --- | --- |
| PASS | PERF-001 5 concurrent calls | 5/5 received `100 Giving it a try`, `200 OK`, and BYE `200 OK` |
| PASS | PERF-001 CDR verification | 5/5 call IDs found in `/api/v2/cdrs?take=100` |
| PASS | PERF-001 recording verification | 5/5 call IDs found in `/api/v2/recordings?limit=100` |
| PASS | PERF-002 12 short-burst calls | 12/12 received `100 Giving it a try`, `200 OK`, and BYE `200 OK` |
| PASS | PERF-002 CDR verification | 12/12 call IDs found in `/api/v2/cdrs?take=100` |
| PASS | PERF-002 recording verification | 12/12 call IDs found in `/api/v2/recordings?limit=100` after worker catch-up wait |
5-call IDs:
- `s28-1782714314674-tprj1vk2@lisglosips-t`
- `s28-1782714314655-ye44k7c7@lisglosips-t`
- `s28-1782714314670-zqsgouuh@lisglosips-t`
- `s28-1782714314677-vkrthfau@lisglosips-t`
- `s28-1782714314679-j47gb9tl@lisglosips-t`
12-call IDs:
- `s28-1782714339590-pi088vy9@lisglosips-t`
- `s28-1782714339585-t0wbt40b@lisglosips-t`
- `s28-1782714339580-ooqd773x@lisglosips-t`
- `s28-1782714339585-b38gwxb1@lisglosips-t`
- `s28-1782714339585-wn61wl1x@lisglosips-t`
- `s28-1782714339573-w6xxwzmp@lisglosips-t`
- `s28-1782714339589-lxrh6ht3@lisglosips-t`
- `s28-1782714339581-usu8mnjx@lisglosips-t`
- `s28-1782714339590-51r89nel@lisglosips-t`
- `s28-1782714339589-63h8mpr7@lisglosips-t`
- `s28-1782714339584-kmbwlurm@lisglosips-t`
- `s28-1782714339575-kv082r5b@lisglosips-t`
## Security Probes
| Result | Check | Detail |
| --- | --- | --- |
| PASS | SEC-001 illegal-source SIP probe | From B to A `100.90.90.90:15060`, Call-ID `codex89-illegal-1782714470-8090@lisglosips-b`, no response within 3s |
| WARN | SEC-002 20-call CPS burst | 20/20 INVITE received `200 OK`, but 20/20 BYE returned `403 Rate Limited` |
| PASS | SEC-002 recovery after burst | One normal call after 5s recovered and received BYE `200 OK`; Call-ID `s28-1782714527323-zdscgrrx@lisglosips-t` |
## Fault Injection
| Result | Check | Detail |
| --- | --- | --- |
| BLOCKED | FAIL-001 Recording Worker stop/recover | B `sudo -n true` returns `sudo-needs-password`; current SSH user cannot stop/start services non-interactively |
| BLOCKED | FAIL-002 CDR Worker stop/recover | Same sudo blocker |
| BLOCKED | FAIL-003 MySQL short outage | Same sudo blocker |
| BLOCKED | FAIL-004 Redis short outage | Same sudo blocker |
## Findings
- CPS burst behavior needs review: rate limiting appears to affect in-dialog BYE requests after the INVITE has already succeeded with `200 OK`. The system recovers for subsequent calls, but BYE `403 Rate Limited` can leave call teardown semantics ambiguous.
- Service-stop fault tests remain blocked until non-interactive sudo is available or the sudo password is provided through an approved secure channel. I did not attempt to guess or bypass sudo.
## Final State
- B services checked active after the run.
- HTTPS smoke after the run passed.
- No service was left intentionally stopped.
@@ -0,0 +1,52 @@
# Remote Recordings, Playback, and Quality Test Report
Date: 2026-06-29T05:40:18.380Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=944ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=608ms |
| PASS | low-privilege user cannot list recordings | status=403, duration=465ms |
| PASS | low-privilege user cannot list quality rules | status=403, duration=380ms |
| PASS | invalid recording status is rejected | status=400, duration=865ms |
| PASS | invalid recording status returns RECORDING_STATUS_INVALID | code=RECORDING_STATUS_INVALID |
| PASS | invalid recording list limit is rejected | status=400, duration=383ms |
| PASS | invalid recording limit returns INTEGER_INVALID | code=INTEGER_INVALID |
| PASS | READY recordings can be listed | status=200, duration=1272ms |
| PASS | recording list shape is valid | count=20 |
| PASS | recording detail can be fetched | status=200, duration=2056ms |
| PASS | recording detail matches list item | id=rec_3329d4ba78c441f19ca3ca0dc241758c, reviews=0 |
| PASS | READY recording playback returns X-Accel response or served media | status=200, expected=200/206, duration=5094ms |
| PASS | playback response avoids real filesystem path exposure | xAccel=n/a, contentType=audio/wav |
| PASS | low-privilege user cannot play recording | status=403, duration=379ms |
| PASS | decimal review score is rejected by current API | status=400, duration=394ms |
| PASS | decimal review score returns INTEGER_INVALID | code=INTEGER_INVALID |
| PASS | invalid review result is rejected | status=400, duration=1213ms |
| PASS | invalid review result returns QUALITY_REVIEW_RESULT_INVALID | code=QUALITY_REVIEW_RESULT_INVALID |
| PASS | low-privilege user cannot save review | status=403, duration=1109ms |
| PASS | quality review can be saved | status=200, duration=392ms |
| PASS | quality review contains expected score/result/tags | score=88, result=ISSUE, tags=["noise","script"] |
| PASS | reviewed recording list can be filtered | status=200, duration=889ms |
| PASS | reviewed list contains reviewed recording | count=3 |
| PASS | missing recording playback returns 404 | status=404, duration=402ms |
| PASS | missing recording playback returns RECORDING_NOT_READY | code=RECORDING_NOT_READY |
| PASS | invalid quality sampling ratio is rejected | status=400, duration=415ms |
| PASS | invalid ratio returns QUALITY_RATIO_INVALID | code=QUALITY_RATIO_INVALID |
| PASS | quality sampling rule is created | status=201, duration=521ms |
| PASS | quality sampling rule has 100 percent ratio | ruleId=qsr_dfdc7cbfa8254a90848bd599e5bb, ratio=100.00, status=ENABLED |
| PASS | quality sampling rule can be disabled | status=201, duration=970ms |
| PASS | disabled quality rule status is DISABLED | status=DISABLED |
| PASS | quality sampling rule can be enabled | status=201, duration=1030ms |
| PASS | enabled quality rule status is ENABLED | status=ENABLED |
| PASS | recording list includes stable sampling payload after rule change | status=200, duration=1813ms |
| PASS | sampling payload shape is present | count=5 |
## Notes
- Password and token values are intentionally omitted.
- Recording Worker file movement, checksum mismatch retention, source cleanup, and browser Range playback need A/B filesystem or browser-side verification.
- Current API accepts integer review scores only; decimal score examples from the plan are asserted as rejected by this deployed service.
@@ -0,0 +1,22 @@
# Remote Smoke Test Report
Date: 2026-06-29T04:58:50.321Z
Base URL: https://100.90.90.91
| Result | Check | Detail |
| --- | --- | --- |
| FAIL | / returns 200 | Request timed out after 10000ms |
| FAIL | frontend HTML contains app root | contentType=, bytes=0 |
| FAIL | /api/v2/health/live returns 200 | Request timed out after 10000ms |
| FAIL | live health reports ok | body=null |
| FAIL | /api/v2/health/ready returns 200 | Request timed out after 10000ms |
| FAIL | ready health reports database and redis ok | body=null |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=5795ms |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=9f416a91-55b6-423b-bcce-a554c9c8a0f3, expiresAt=2026-06-29T05:03:45.635Z |
## Raw Endpoints
- /: status=0, duration=10048ms, contentType=n/a
- /api/v2/health/live: status=0, duration=10014ms, contentType=n/a
- /api/v2/health/ready: status=0, duration=10009ms, contentType=n/a
- /api/v2/auth/captcha: status=200, duration=5795ms, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T04:59:32.371Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=4433ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=558ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T04:59:25.206Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=970ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T04:59:26.180Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=2232ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=39237aa0-fecc-4529-a1ef-63484e303e6f, expiresAt=2026-06-29T05:04:27.702Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=4433ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=558ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=970ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=2232ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T06:03:57.618Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=3727ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=640ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:03:52.452Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=703ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:03:53.162Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=559ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=cb19317a-2c46-4a16-b743-48e918856745, expiresAt=2026-06-29T06:08:53.546Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=3727ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=640ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=703ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=559ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T06:29:20.572Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1886ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=576ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:29:13.960Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=1953ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:29:14.536Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=608ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=31787ad3-c22c-4e87-9fec-6b0ff0e289d0, expiresAt=2026-06-29T06:34:16.507Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1886ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=576ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=1953ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=608ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T06:40:35.637Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=3959ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=1537ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:40:27.517Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=1801ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T06:40:29.889Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1477ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=99000b78-6bfd-42d4-80a2-9412dee38796, expiresAt=2026-06-29T06:45:30.853Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=3959ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=1537ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=1801ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1477ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T07:18:37.925Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=2367ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=751ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:18:31.443Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=636ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:18:31.841Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=2012ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=aa4e3357-09da-4932-bad0-515a65986984, expiresAt=2026-06-29T07:23:32.938Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=2367ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=751ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=636ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=2012ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T07:40:52.298Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=2220ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=423ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:40:46.672Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=425ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:40:47.093Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1375ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=24741c6c-8af6-4b26-a519-568b975e6b33, expiresAt=2026-06-29T07:45:48.366Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=2220ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=423ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=425ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1375ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T07:51:38.171Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=2931ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=434, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=613ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:51:32.713Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=420ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:51:33.130Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1217ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=28804ba6-99f0-4cdd-a966-8878ccd9abdc, expiresAt=2026-06-29T07:56:33.737Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=2931ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=613ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=420ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1217ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T07:55:04.361Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=2948ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=851ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:54:58.998Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=425ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T07:54:59.434Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1103ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=201d00b8-4096-4e9b-a478-09f38e8507e7, expiresAt=2026-06-29T08:00:00.361Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=2948ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=851ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=425ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1103ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T08:09:17.242Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=3371ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=461ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T08:09:11.715Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=604ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T08:09:12.137Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1123ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=58d3d4a2-6f71-4a78-a102-995ae8ebe444, expiresAt=2026-06-29T08:14:12.744Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=3371ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=461ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=604ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1123ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T09:47:44.237Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=100ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=17ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T09:47:40.645Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=17ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T09:47:40.663Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=14ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=e1750f45-4291-4e38-b949-4f1332525147, expiresAt=2026-06-29T09:52:40.680Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=100ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=17ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=17ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=14ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T10:11:22.445Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1632ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=1227ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T10:11:14.818Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=2785ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T10:11:17.451Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1161ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=f1e5fa0e-ffc1-4ec5-b193-d9551d47eae5, expiresAt=2026-06-29T10:16:18.141Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1632ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=1227ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=2785ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1161ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-29T10:34:20.124Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=5834ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=1112ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T10:34:18.013Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=869ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-29T10:34:18.683Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=1078ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=1ee70823-7d33-4647-82e6-aa0099364891, expiresAt=2026-06-29T10:39:19.553Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=5834ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=1112ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=869ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=1078ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-30T01:28:30.863Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1035ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=422, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=296ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T01:28:30.512Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=314ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T01:28:30.820Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=410ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=ded8cb2e-9ccc-4e5d-90f0-b4e0e0187a47, expiresAt=2026-06-30T01:33:31.152Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1035ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=296ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=314ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=410ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-30T02:44:12.572Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=5040ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=466, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=324ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T02:44:12.723Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=161ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T02:44:12.890Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=271ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=784f66e9-ac29-4fa2-a330-aeac063e5ea1, expiresAt=2026-06-30T02:49:13.159Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=5040ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=324ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=161ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=271ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,24 @@
# Remote Smoke Test Report
Date: 2026-06-30T03:11:46.183Z
Base URL: https://100.90.90.91
Timeout: 30000ms
Attempts: 2
| Result | Check | Detail |
| --- | --- | --- |
| PASS | / returns 200 | status=200, duration=1652ms, attempt=1 |
| PASS | frontend HTML contains app root | contentType=text/html, bytes=466, attempt=1 |
| PASS | /api/v2/health/live returns 200 | status=200, duration=319ms, attempt=1 |
| PASS | live health reports ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T03:11:45.886Z","checks":{"process":"ok"}} |
| PASS | /api/v2/health/ready returns 200 | status=200, duration=301ms, attempt=1 |
| PASS | ready health reports database and redis ok | body={"status":"ok","service":"api","timestamp":"2026-06-30T03:11:46.200Z","checks":{"config":"ok","database":"ok","redis":"ok"}} |
| PASS | /api/v2/auth/captcha returns 200 | status=200, duration=449ms, attempt=1 |
| PASS | captcha endpoint returns id, image, and expiry | captchaId=713febe8-036e-4596-b5a5-a8ad95e42a6d, expiresAt=2026-06-30T03:16:46.503Z, attempt=1 |
## Raw Endpoints
- /: status=200, duration=1652ms, attempt=1, contentType=text/html
- /api/v2/health/live: status=200, duration=319ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/health/ready: status=200, duration=301ms, attempt=1, contentType=application/json; charset=utf-8
- /api/v2/auth/captcha: status=200, duration=449ms, attempt=1, contentType=application/json; charset=utf-8
@@ -0,0 +1,49 @@
# Remote Vendors, Vendor Gateways, and Landing Line Groups Test Report
Date: 2026-06-29T05:28:04.913Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Vendor Name: 自动化8.4供应商
Line Group Name: 自动化8.4线路组
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=791ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=624ms |
| PASS | low-privilege user cannot list vendors | status=403, duration=388ms |
| PASS | low-privilege user cannot list line groups | status=403, duration=381ms |
| PASS | invalid vendor is rejected | status=400, duration=382ms |
| PASS | vendor is created | status=201, duration=416ms |
| PASS | vendor has expected credit limit | vendorId=ven_a81199989064479697afadcfdd9b, creditLimit=200.000000 |
| PASS | invalid vendor gateway host is rejected | status=400, duration=400ms |
| PASS | invalid host returns HOST_INVALID | code=HOST_INVALID |
| PASS | weak SIP password is rejected | status=400, duration=384ms |
| PASS | primary vendor gateway is created | status=201, duration=602ms |
| PASS | primary gateway has child config | codecs=2, prefixRules=2, callerRewrite=1 |
| PASS | backup vendor gateway is created | status=201, duration=977ms |
| PASS | vendor gateway can be disabled | status=201, duration=601ms |
| PASS | disabled vendor gateway status is DISABLED | status=DISABLED |
| PASS | vendor gateway can be enabled | status=201, duration=745ms |
| PASS | enabled vendor gateway status is ENABLED | status=ENABLED |
| PASS | line group is created | status=201, duration=436ms |
| PASS | primary line group item is added | status=201, expected=201, duration=412ms |
| PASS | backup line group item is added | status=201, expected=201, duration=420ms |
| PASS | line group detail can be fetched | status=200, duration=387ms |
| PASS | line group contains two enabled items | enabledItemCount=2, itemCount=2 |
| PASS | line group items can be reordered | status=201, duration=404ms |
| PASS | reorder preserves item set | before=2, after=2 |
| PASS | duplicate line group gateway item is rejected | status=409, duration=406ms |
| PASS | vendor gateway referenced by line group cannot be deleted | status=400, duration=391ms |
| PASS | referenced gateway returns VENDOR_GATEWAY_IN_LINE_GROUP | code=VENDOR_GATEWAY_IN_LINE_GROUP |
| PASS | line group can be disabled | status=201, duration=395ms |
| PASS | disabled line group status is DISABLED | status=DISABLED |
| PASS | line group can be enabled | status=201, duration=965ms |
| PASS | enabled line group status is ENABLED | status=ENABLED |
## Notes
- Password and token values are intentionally omitted.
- Vendor gateway and line group mutations enqueue config outbox events server-side.
- Config publication manifest is not exposed by the black-box API; DB or Redis observation is required to assert worker publication directly.
@@ -0,0 +1,49 @@
# Remote Vendors, Vendor Gateways, and Landing Line Groups Test Report
Date: 2026-06-29T05:28:35.481Z
Base URL: https://100.90.90.91
Username: admin
Low-Privilege Username: codex.low
Vendor Name: 自动化8.4供应商
Line Group Name: 自动化8.4线路组
| Result | Check | Detail |
| --- | --- | --- |
| PASS | admin can login | status=200, duration=442ms |
| PASS | admin login returns access token | tokenLength=296 |
| PASS | low-privilege user can login for RBAC checks | status=200, duration=429ms |
| PASS | low-privilege user cannot list vendors | status=403, duration=381ms |
| PASS | low-privilege user cannot list line groups | status=403, duration=384ms |
| PASS | invalid vendor is rejected | status=400, duration=383ms |
| PASS | vendor is updated | status=200, duration=413ms |
| PASS | vendor has expected credit limit | vendorId=ven_a81199989064479697afadcfdd9b, creditLimit=200.000000 |
| PASS | invalid vendor gateway host is rejected | status=400, duration=394ms |
| PASS | invalid host returns HOST_INVALID | code=HOST_INVALID |
| PASS | weak SIP password is rejected | status=400, duration=383ms |
| PASS | primary vendor gateway is updated | status=200, duration=751ms |
| PASS | primary gateway has child config | codecs=2, prefixRules=2, callerRewrite=1 |
| PASS | backup vendor gateway is updated | status=200, duration=789ms |
| PASS | vendor gateway can be disabled | status=201, duration=998ms |
| PASS | disabled vendor gateway status is DISABLED | status=DISABLED |
| PASS | vendor gateway can be enabled | status=201, duration=573ms |
| PASS | enabled vendor gateway status is ENABLED | status=ENABLED |
| PASS | line group is updated | status=200, duration=778ms |
| PASS | primary line group item is updated | status=200, expected=200, duration=913ms |
| PASS | backup line group item is updated | status=200, expected=200, duration=656ms |
| PASS | line group detail can be fetched | status=200, duration=557ms |
| PASS | line group contains two enabled items | enabledItemCount=2, itemCount=2 |
| PASS | line group items can be reordered | status=201, duration=437ms |
| PASS | reorder preserves item set | before=2, after=2 |
| PASS | duplicate line group gateway item is rejected | status=409, duration=393ms |
| PASS | vendor gateway referenced by line group cannot be deleted | status=400, duration=390ms |
| PASS | referenced gateway returns VENDOR_GATEWAY_IN_LINE_GROUP | code=VENDOR_GATEWAY_IN_LINE_GROUP |
| PASS | line group can be disabled | status=201, duration=623ms |
| PASS | disabled line group status is DISABLED | status=DISABLED |
| PASS | line group can be enabled | status=201, duration=1180ms |
| PASS | enabled line group status is ENABLED | status=ENABLED |
## Notes
- Password and token values are intentionally omitted.
- Vendor gateway and line group mutations enqueue config outbox events server-side.
- Config publication manifest is not exposed by the black-box API; DB or Redis observation is required to assert worker publication directly.
@@ -0,0 +1,35 @@
# Remote Web UI Test Report
Date: 2026-06-29T06:01:31.528Z
Base URL: https://100.90.90.91
Browser path: Browser plugin attempted first; fallback to local Chrome Playwright because in-app browser stopped at net::ERR_CERT_AUTHORITY_INVALID for the B HTTPS certificate.
Viewport: 1440x900
## Findings
- Browser automation failed before completing all 8.8 checks: locator.click: Timeout 10000ms exceeded. Call log:  - waiting for getByRole('button', { name: '号码库' }) at D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\.node_repl_cell_6.mjs:101:55
| Result | Check | Detail |
| --- | --- | --- |
| FAIL | WEB-001 homepage renders login shell | title=LisgloSIPS - 聆界SIP管理平台, hasLogin=true |
| PASS | WEB-001 access token is not present in URL before login | https://100.90.90.91/ |
| PASS | WEB-001 admin login enters dashboard | url=https://100.90.90.91/ |
| PASS | WEB-001 access token is not present in URL after login | https://100.90.90.91/ |
| PASS | WEB-001 refresh restores authenticated dashboard | hasDashboard=true |
| FAIL | WEB-005 admin sees all non-pending core menus | missing=号码库 |
| FAIL | WEB automation completed without unhandled exception | locator.click: Timeout 10000ms exceeded.
Call log:
 - waiting for getByRole('button', { name: '号码库' })
|
## Screenshots
- Admin dashboard: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060046Z_admin-dashboard.png
- Low-privilege nav: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060046Z_low-nav.png
- Logout/login page: D:\HuaweiMoveData\Users\hectorzhao\Documents\自建软交换\tests\reports\REMOTE_WEB_UI_20260629T060046Z_logout.png
## Notes
- CAPTCHA was read from the page image data URL with explicit user permission for this test run.
- WEB-002 forced API 500/network-failure states and WEB-003 dedicated empty-data states were not injected against B to avoid disturbing the shared service.
- WEB-006 was checked read-only from the served homepage; no release switch or deployment mutation was performed.
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

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

After

Width:  |  Height:  |  Size: 94 KiB

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

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

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

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

@@ -0,0 +1,57 @@
# Remote Web UI Smoke Test Report
Date: 2026-06-29T10:04:19.835Z
Base URL: https://100.90.90.91
Username: admin
Headless: true
Browser Channel: chrome
| Result | Check | Detail |
| --- | --- | --- |
| PASS | API login succeeds for browser smoke | status=200, captchaLength=5, permissionCount=26 |
| PASS | UI login succeeds before menu smoke | captchaLength=5 |
| PASS | 概览 Dashboard renders without browser errors | textLength=500, api-ok, authenticated, errors=0 |
| PASS | 概览 Dashboard action 刷新 works without browser errors | errors=0 |
| PASS | 客户管理 renders without browser errors | textLength=190, api-ok, authenticated, errors=0 |
| PASS | 客户管理 safe action is available | no visible enabled action among 编辑; skipped |
| PASS | 客户网关管理 renders without browser errors | textLength=536, api-ok, authenticated, errors=0 |
| FAIL | 客户网关管理 action 编辑 works without browser errors | errors=Error: Minified React error #137; visit https://reactjs.org/docs/error-decoder.html?invariant=137&args[]=input for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
at ws (https://100.90.90.91/assets/index-D90saQl_.js:37:7909)
at Wp (https://100.90.90.91/assets/index-D90saQl_.js:40:17537)
at kd (https://100.90.90.91/assets/index-D90saQl_.js:40:40074)
at wd (https://100.90.90.91/assets/index-D90saQl_.js:40:39827)
at Zp (https://100.90.90.91/assets/index-D90saQl_.js:40:39694)
at ca (https://100.90.90.91/assets/index-D90saQl_.js:40:39547)
at ti (https://100.90.90.91/assets/index-D90saQl_.js:40:35914)
at ou (https://100.90.90.91/assets/index-D90saQl_.js:40:36717)
at Rn (https://100.90.90.91/assets/index-D90saQl_.js:38:3274)
at https://100.90.90.91/assets/index-D90saQl_.js:40:34246 \|\| Error: Minified React error #137; visit https://reactjs.org/docs/error-decoder.html?invariant=137&args[]=input for the full message or use the non-minified dev environment for full errors and additional helpful warnings.
at ws (https://100.90.90.91/assets/index-D90saQl_.js:37:7909)
at Wp (https://100.90.90.91/assets/index-D90saQl_.js:40:17537)
at kd (https://100.90.90.91/assets/index-D90saQl_.js:40:40074)
at wd (https://100.90.90.91/assets/index-D90saQl_.js:40:39827)
at Zp (https://100.90.90.91/assets/index-D90saQl_.js:40:39694)
at ca (https://100.90.90.91/assets/index-D90saQl_.js:40:39547)
at ti (https://100.90.90.91/assets/index-D90saQl_.js:40:35914)
at ou (https://100.90.90.91/assets/index-D90saQl_.js:40:36717)
at Rn (https://100.90.90.91/assets/index-D90saQl_.js:38:3274)
at https://100.90.90.91/assets/index-D90saQl_.js:40:34246 \|\| Minified React error #137; visit https://reactjs.org/docs/error-decoder.html?invariant=137&args[]=input for the full message or use the non-minified dev environment for full errors and additional helpful warnings. |
| FAIL | 业务前缀管理 menu is visible | menu button not found or not visible |
| FAIL | 充值记录 menu is visible | menu button not found or not visible |
| FAIL | 供应商管理 menu is visible | menu button not found or not visible |
| FAIL | 落地网关管理 menu is visible | menu button not found or not visible |
| FAIL | 落地线路组 menu is visible | menu button not found or not visible |
| FAIL | 号码库 menu is visible | menu button not found or not visible |
| FAIL | 当前通话 menu is visible | menu button not found or not visible |
| FAIL | 话单中心 menu is visible | menu button not found or not visible |
| FAIL | 质检中心 menu is visible | menu button not found or not visible |
| FAIL | 用户管理 menu is visible | menu button not found or not visible |
| FAIL | 角色与权限 menu is visible | menu button not found or not visible |
| FAIL | 操作日志 menu is visible | menu button not found or not visible |
## Guardrails
- Fails on browser `pageerror` and console `error` events.
- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.
- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.
- Password and token values are intentionally omitted.
@@ -0,0 +1,49 @@
# Remote Web UI Smoke Test Report
Date: 2026-06-29T10:19:11.811Z
Base URL: https://100.90.90.91
Username: admin
Headless: true
Browser Channel: chrome
| Result | Check | Detail |
| --- | --- | --- |
| PASS | API login succeeds for browser smoke | status=200, captchaLength=5, permissionCount=26 |
| PASS | UI login succeeds before menu smoke | captchaLength=5 |
| PASS | 概览 Dashboard renders without browser errors | textLength=500, api-ok, authenticated, errors=0 |
| PASS | 概览 Dashboard action 刷新 works without browser errors | errors=0 |
| PASS | 客户管理 renders without browser errors | textLength=190, api-ok, authenticated, errors=0 |
| PASS | 客户管理 safe action is available | no visible enabled action among 编辑; skipped |
| PASS | 客户网关管理 renders without browser errors | textLength=536, api-ok, authenticated, errors=0 |
| PASS | 客户网关管理 action 编辑 works without browser errors | errors=0 |
| PASS | 业务前缀管理 renders without browser errors | textLength=239, api-ok, authenticated, errors=0 |
| PASS | 业务前缀管理 action 编辑 works without browser errors | errors=0 |
| PASS | 充值记录 renders without browser errors | textLength=1110, api-ok, authenticated, errors=0 |
| PASS | 充值记录 safe action is available | no visible enabled action among 刷新; skipped |
| PASS | 供应商管理 renders without browser errors | textLength=296, api-ok, authenticated, errors=0 |
| PASS | 供应商管理 action 编辑 works without browser errors | errors=0 |
| PASS | 落地网关管理 renders without browser errors | textLength=492, api-ok, authenticated, errors=0 |
| PASS | 落地网关管理 action 编辑 works without browser errors | errors=0 |
| PASS | 落地线路组 renders without browser errors | textLength=200, api-ok, authenticated, errors=0 |
| PASS | 落地线路组 action 编辑 works without browser errors | errors=0 |
| PASS | 号码库 renders without browser errors | textLength=176, api-ok, authenticated, errors=0 |
| PASS | 号码库 action 刷新 works without browser errors | errors=0 |
| PASS | 当前通话 renders without browser errors | textLength=206, api-ok, authenticated, errors=0 |
| PASS | 当前通话 action 刷新通话 works without browser errors | errors=0 |
| PASS | 话单中心 renders without browser errors | textLength=6481, api-ok, authenticated, errors=0 |
| PASS | 话单中心 safe action is available | no visible enabled action among 查看详情; skipped |
| PASS | 质检中心 renders without browser errors | textLength=11656, api-ok, authenticated, errors=0 |
| PASS | 质检中心 action 刷新 works without browser errors | errors=0 |
| PASS | 用户管理 renders without browser errors | textLength=370, api-ok, authenticated, errors=0 |
| PASS | 用户管理 action 编辑 works without browser errors | errors=0 |
| PASS | 角色与权限 renders without browser errors | textLength=335, api-ok, authenticated, errors=0 |
| PASS | 角色与权限 action 编辑 works without browser errors | errors=0 |
| PASS | 操作日志 renders without browser errors | textLength=9746, api-ok, authenticated, errors=0 |
| PASS | 操作日志 action 查看详情 works without browser errors | errors=0 |
## Guardrails
- Fails on browser `pageerror` and console `error` events.
- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.
- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.
- Password and token values are intentionally omitted.
@@ -0,0 +1,49 @@
# Remote Web UI Smoke Test Report
Date: 2026-06-29T10:40:40.403Z
Base URL: https://100.90.90.91
Username: admin
Headless: true
Browser Channel: chrome
| Result | Check | Detail |
| --- | --- | --- |
| PASS | API login succeeds for browser smoke | status=200, captchaLength=5, permissionCount=26 |
| PASS | UI login succeeds before menu smoke | captchaLength=5 |
| PASS | 概览 Dashboard renders without browser errors | textLength=500, api-ok, authenticated, errors=0 |
| PASS | 概览 Dashboard action 刷新 works without browser errors | errors=0 |
| PASS | 客户管理 renders without browser errors | textLength=614, api-ok, authenticated, errors=0 |
| PASS | 客户管理 action 编辑 works without browser errors | errors=0 |
| PASS | 客户网关管理 renders without browser errors | textLength=480, api-ok, authenticated, errors=0 |
| PASS | 客户网关管理 action 编辑 works without browser errors | errors=0, business-prefix-checkbox-toggle-ok: false->true->false |
| PASS | 业务前缀管理 renders without browser errors | textLength=239, api-ok, authenticated, errors=0 |
| PASS | 业务前缀管理 action 编辑 works without browser errors | errors=0 |
| PASS | 充值记录 renders without browser errors | textLength=1110, api-ok, authenticated, errors=0 |
| PASS | 充值记录 safe action is available | no visible enabled action among 刷新; skipped |
| PASS | 供应商管理 renders without browser errors | textLength=296, api-ok, authenticated, errors=0 |
| PASS | 供应商管理 action 编辑 works without browser errors | errors=0 |
| PASS | 落地网关管理 renders without browser errors | textLength=492, api-ok, authenticated, errors=0 |
| PASS | 落地网关管理 action 编辑 works without browser errors | errors=0 |
| PASS | 落地线路组 renders without browser errors | textLength=200, api-ok, authenticated, errors=0 |
| PASS | 落地线路组 action 编辑 works without browser errors | errors=0 |
| PASS | 号码库 renders without browser errors | textLength=176, api-ok, authenticated, errors=0 |
| PASS | 号码库 action 刷新 works without browser errors | errors=0 |
| PASS | 当前通话 renders without browser errors | textLength=206, api-ok, authenticated, errors=0 |
| PASS | 当前通话 action 刷新通话 works without browser errors | errors=0 |
| PASS | 话单中心 renders without browser errors | textLength=6481, api-ok, authenticated, errors=0 |
| PASS | 话单中心 safe action is available | no visible enabled action among 查看详情; skipped |
| PASS | 质检中心 renders without browser errors | textLength=11656, api-ok, authenticated, errors=0 |
| PASS | 质检中心 action 刷新 works without browser errors | errors=0 |
| PASS | 用户管理 renders without browser errors | textLength=370, api-ok, authenticated, errors=0 |
| PASS | 用户管理 action 编辑 works without browser errors | errors=0 |
| PASS | 角色与权限 renders without browser errors | textLength=335, api-ok, authenticated, errors=0 |
| PASS | 角色与权限 action 编辑 works without browser errors | errors=0 |
| PASS | 操作日志 renders without browser errors | textLength=9746, api-ok, authenticated, errors=0 |
| PASS | 操作日志 action 查看详情 works without browser errors | errors=0 |
## Guardrails
- Fails on browser `pageerror` and console `error` events.
- Fails when a navigated page is blank, falls back to login, or shows `API 数据不可用`.
- After each menu render, clicks one safe primary row action when available, such as edit, detail, or refresh.
- Password and token values are intentionally omitted.
+16
View File
@@ -0,0 +1,16 @@
# Smoke Tests
Smoke tests should verify service startup, health checks, database connectivity, and later SIP/media-adjacent probes.
Runnable entry point:
```powershell
pnpm test:remote-smoke
```
Override the target with:
```powershell
$env:LISGLOSIPS_BASE_URL = 'https://100.90.90.91'
pnpm test:remote-smoke
```

Some files were not shown because too many files have changed in this diff Show More