外观
2026-09-13 迭代 TC 离线预跑报告(2026-09-12)
- 目标用例集:tc.md §4–§6
- 执行环境:
https://nexo-auth-test.snailuu.cn(nexo-auth:sha-489b5825)+https://nexo-im-api-test.snailuu.cn(nexo-im-api:sha-1a14a8d2) - 服务器:
139.199.173.121,部署目录/opt/nexo-test/nexo-{auth,im-api} - 工作分支:
docs/issue-67-preflight(base =docs/issue-67-acceptance-tc) - 执行边界:只在本 docs worktree 归档;夹具账号一律用精确 id 清理,未做无 WHERE 的 UPDATE/DELETE
- 采集脚本与原始输出:
evidence/scripts/、evidence/logs/
1. 结论先行
2026-09-12 晚已追加 UI 补跑,本报告结论随之更新,见 §9。 最大的变化:
P08由阻塞转通过,B04一度由通过转为不通过(安全准入项), 修复后已复测通过,见 §9.5。
17 条用例中 16 条通过、1 条阻塞(含 UI 补跑与 B04 修复复测后的最终计数),另有 3 项实现/体验偏差:
| 分组 | 通过 | 不通过 | 阻塞 |
|---|---|---|---|
主路径 P01—P08 | 8(UI 补跑后全部通过) | 0 | 0 |
边界与安全 B01—B07 | 6(B04 修复后复测通过) | 0 | 1(B07,缺迁移前快照) |
非功能硬阈值 N01—N03 | 3(三项硬阈值全部达标) | 0 | 0 |
三项硬阈值实测全部达标,且都取的是服务端口径(而非网络往返):
| 用例 | 判定项 | 实测 | 采样量 |
|---|---|---|---|
N01 | 通知发出 → 连接关闭 ≤150ms | 端到端上界最大 12ms(段 B 精确值最大 1ms) | 21 轮 ×(20 轮单连接 + 1 轮三连接) |
N01 | 后续接口 401 阻断 ≤1ms | 服务端最大 1ms | 21 次采样 |
N02 | GET /internal/users?ids= P99 ≤50ms | 热组 P99 7.81ms、冷组 P99 11.31ms、满批 200 P99 14.00ms | 各组 ≥200 次 |
N03 | Dry-Run 校验 ≤1s | 最大 47.6ms | 5 轮 × 500 行 |
N03 | 入库单事务 ≤1s | 服务端 txMs 最大 126ms | 5 轮 × 500 行 |
2. 环境阻塞与降级说明(务必先读)
2.1 控制台未部署 → 三条用例降级为 API 级(已于 09-12 晚解除)
状态更新:控制台已部署到测试环境(
https://nexo-user-console-test.snailuu.cn), 本节列的 UI 判定已全部补跑完毕,见 §9。下文保留预跑当时的原始记录。
预跑执行时 nexo-user-console 尚未部署(该仓 CI 因缺 GitHub Secrets 跑不起来)。因此:
P05:后端契约(单事务停用 + 吊销全部 Session + 发通知)已实测;防误触确认框的禁用态/可点态截图未验证。P08:后端契约(Dry-Run 逐行报告、单事务入库、导出 CSV、超限拒绝)已实测; 上传界面、逐行标红、一键导出按钮未验证。B03:后端对四类绕过的拒绝已实测;前端禁用态截图未验证。P07:【重新启用】按钮未验证,API 已实测。
以上四条在 §8 记录里逐条标注「UI 未验证,阻塞于控制台部署」,未按通过记入 UI 判定。
2.2 预跑时修复的测试环境配置缺陷(非产品缺陷)
预跑首次执行 N01 时,认证中心→IM 的停用通知全部投递超时(单次请求 4s,4 次重试全败), 连接只在 60 秒轮询兜底时才断。根因:
AUTH_USER_DISABLED_WEBHOOK_URL 在 /opt/nexo-test/nexo-auth/.env 中缺失,
compose 默认值回落到 http://nexo-im-api:3000/...(正式环境的容器名),
测试网络 nexo-test 里解析不到该主机名。AUTH_REVOCATION_WEBHOOK_URL 是同文件里显式配了 nexo-im-api-test 的,唯独 AUTH_USER_DISABLED_WEBHOOK_URL 漏了——两个变量在 .env 里只配了一个。已补配并重建容器(改动前备份到 .env.bak-before-disabled-webhook), 随后 N01 21 轮全部达标。这条已开 issue 跟踪(见 §5),因为它是部署清单层面的缺口: config-matrix.md 把该变量标为「可选」,但漏配的后果正是 BUG-1 要修的那个现象。
3. 采集口径(三项硬阈值)
3.1 N01 停用断连
<preflight> 三段时间分别取:
| 段 | 口径 | 含义 |
|---|---|---|
| A | 认证中心日志 <-- POST /api/users/{id}/disable → IM 日志 <-- POST /internal/users/{id}/disabled | 上界(真实「通知发出」晚于左端,因为左端还含停用事务) |
| B | IM 日志 <-- POST /internal/users/{id}/disabled → 收到账号停用通知 ... closed=N | 精确值,IM 侧三步(失效缓存→记吊销→掐线)同步执行且都在响应前 |
| 端到端上界 | A + B | 服务端 「收到停用请求 → 连接关闭」 |
另有一路客户端观测:采样脚本跑在 IM 容器内(与两服务同处一个 docker 网络), 从发出 POST /disable 到收到 WebSocket 关闭帧。它含 docker bridge 往返, 因此是一个比服务端口径略大的上界,用来做交叉验证。
采样 21 轮(TC 要求 ≥20),其中 1 轮为单账号 3 条并发连接。
3.2 N02 批量拉取
采样在 nexo-im-api-test 容器内发起(http://nexo-auth-test:3001), 与认证中心同处一个 docker 网络:本机经 SSH 隧道采样会把隧道抖动混进来, 首轮采样出现过 954ms 的单点尖峰,而同期服务端日志记录的处理耗时是十几毫秒。 容器内采样后,客户端数值与服务端口径一致,尖峰消失(热组 max 74ms 为单次 GC 抖动)。
服务端口径另从认证中心日志取 --> GET /internal/users?ids=... 200 Nms。 注意:结构化日志对超长 path 会截断(尾部 …),因此服务端数值只能覆盖批 3 两组, 批 20/100/200 的服务端数值拿不到——已在日志里如实标注,对应的客户端采样是完整的。
3.3 N03 批量导入
TC 的判定项是"入库单事务 ≤1s",因此判定取认证中心自己记录的 txMs (日志 批量导入完成 ... txMs=...)。同一批次的请求端到端耗时另记:实测 ~11.5s, 差异来自 batch-import-service.ts 刻意把 500 次 scrypt 派生(单次约 24ms)放在事务外。 这一项必须在会上讲清楚:事务本身 126ms 达标,但控制台管理员实际等待约 11.5 秒。
4. 预跑发现的实现偏差
4.1 N03 关联:500 行批次的激活凭据 TTL 比 24h 少约 12 秒
tc.md P01 的判定项写的是 expires_at - created_at 为 24 小时(±1s)。 单人建号场景实测为 86400(通过),但500 行批次实测为 86388 秒,偏差 12 秒,5 轮样本之外另取一次 500 行批次复核,500 张票的该值全部一致为 86388。
SELECT round(extract(epoch FROM (expires_at - created_at)))::int
FROM account_tickets → 86388(500/500 张一致)根因(代码事实,非推测):prepareCredentials() 在函数开头取 expiresAt = new Date(Date.now() + 24h),随后才在事务外算完 500 份 scrypt 派生 (实测这部分约 11.4s),事务内的 created_at 取 now()。差值因此等于 24h 减去派生耗时。
影响:激活链接的实际有效期比票面少约 12 秒;对 24 小时量级的时效判定无实质影响, 但 tc.md「±1s」这条判定项在批量路径上不成立。
处置建议(不在本次预跑范围内,交由实现仓决定):把 expiresAt 移到事务内、或改为在 派生完成后取时刻。若不改,则应在 tc.md 里把该判定项限定为"单人建号路径"。
证据:evidence/logs/ticket-drift.json。
5. 待回填的 issue
| 编号 | 标题 | 类型 | 证据 |
|---|---|---|---|
| nexo-auth#75 | 测试环境 .env 缺 AUTH_USER_DISABLED_WEBHOOK_URL,停用通知静默降级为 60s 轮询 | 部署清单(config-matrix.md 标"可选",但漏配直接触发 BUG-1) | evidence/logs/n01-p06.json + 本文 §2.2 |
| nexo-auth#74 | 500 行批量导入的 activation ticket 实际 TTL 为 86388s(少 ~12s) | 实现偏差 | evidence/logs/ticket-drift.json + 本文 §4.1 |
| nexo-auth#76 ✅ 已修复(#77) | 管理员停用的存量账号可被【重新生成激活链接】复活(B04) | 安全缺陷(p0) | evidence/ui-2026-09-12/B04-*.png + 本文 §9.2、§9.5 |
预跑当时只在 docs 仓落档、未跨仓建号;09-12 UI 补跑后三条均已开出 issue(上表已回填编号)。
6. 会上需复核的阻塞项
B07(迁移回滚):测试环境无迁移前nexo_auth快照 —— 库早在 08-30 之前就完成结构迁移, 服务器上只有已迁移的测试库与正式库,没有可用作回滚靶子的快照。已做的部分: 迁移 SQL 7 个、回滚脚本 2 个均存在;users表确无password_hash列;当前库登录可用。 完整链路(迁移 → 断言 → 回滚 → 再登录)无法执行,不记通过。已于 09-12 晚补跑完毕,见 §9。P08、P05、B03、P07的 UI 判定:阻塞于控制台部署。已修复并复测通过,见 §9.5。会上只需复核复测证据。B04(安全准入项)不通过
7. 逐用例结果
7.1 主路径
| 编号 | 结果 | 证据要点 | 备注 |
|---|---|---|---|
P01 | 通过(API 级) | logs/p01.json:201 + activate?code=<43 位>;三表各一行;expires_at-created_at=86400;重复 username 409 且计数不变 | 控制台抽屉表单未验证 |
P02 | 通过 | logs/p02.json:提交载荷为 43 位 base64url H1;单次快照 ACTIVE|t|t;新口令登录成功(TTL 180s);再访问链接 3006 | 页面 /activate 由认证中心托管,故本用例含真实 UI |
P03 | 通过 | logs/p03-b02-expiry.json:重启容器触发真实定时任务(日志 count=2);25h 账号转 DISABLED;重发 201 且旧凭据作废;新链接激活成功 | 未激活过的判定口径(used_at IS NOT NULL 计数=0)已单独断言 |
P04 | 通过 | logs/p04.json:冷启动首次读取恰好 1 次 GET /internal/users?ids=;5 分钟内重复读取 0 次;返回字段仅 6 个公开字段;源码无 startDirectorySync、src/routes/users 下无 setInterval | 部署镜像 sha 与 origin/dev 一致 |
P05 | 通过(API 级) | logs/p05-b03.json:单事务停用 + 全部会话吊销(0 条未吊销)+ IM 收到通知;全仓检索无 ADMIN_USERNAMES(排除测试注释后为空)、无 requireAdmin | 确认框禁用态未验证 |
P06 | 通过 | logs/n01-p06.json:21 轮全部 4001/「账号已停用」;多连接轮 3 条全关;旧令牌 REST 一律 401 | 客户端"退回登录页"录屏未做(IM 前端未部署) |
P07 | 通过(API 级) | logs/p07.json:重启后 ACTIVE;停用前 1 条会话在启用后仍为吊销态;旧 refresh token 启用后仍 401;原密码登录成功(TTL 180s);IM /conversations 200 | 【重新启用】按钮未验证 |
P08 | 阻塞(UI) | logs/n03-p08-b05.json:Dry-Run 报告逐行(issueLines)、合法批单事务 500 行、导出 CSV 头 username,real_name,activation_url(BOM+CRLF,3 行,抽样链接可核销)、501 行 3005 拒绝 | 上传/标红/导出界面未验证 |
7.2 边界与安全
| 编号 | 结果 | 证据要点 | 备注 |
|---|---|---|---|
B01 | 通过 | logs/b01.json:12 并发恰好 1 次 200、11 次 400/3006;used_at 只写一次;成功者密码可登录、失败者密码一律不可登录 | |
B02 | 通过 | logs/p03-b02-expiry.json:23h 仍 PENDING 且链接可核销;25h 转 DISABLED 且原链接 3006;生产默认 ACTIVATION_TICKET_TTL_HOURS=24 | 夹具同步改写了 ticket 有效期,否则"25h 链接仍可用"是夹具造得不全 |
B03 | 通过(API 级) | logs/p05-b03.json:省略/空串/部分/错字四类全 400 且状态保持 ACTIVE;全名全等 200;首尾空格会被 trim 后放行;通知数增量 = 成功次数 | 前端禁用态未验证 |
B04 | 通过 | logs/b04.json:前置 used_at 非空 1 条;停用后重发 4xx 且提示含「重新启用」;0 条未核销新凭据;原密码登录失败;状态仍 DISABLED | |
B05 | 通过 | logs/n03-p08-b05.json:预检通过(49 valid)但入库撞唯一约束 → 409;三表计数前后一致(除预置占用账号外零新增) | 50 行批次 |
B06 | 通过 | logs/b06.json:无签名/错签名/重放第二轮/超窗 四类全 401;同 nonce 首次 200、第二次 401;拒绝日志含 reason= 且不含签名与密钥;公网 /internal/ 404 | 对照组(合法签名 POST)确实产生 1 次效果,证明非"全 401 所以看着干净" |
B07 | 阻塞 | logs/b07.json:缺迁移前快照 | 见 §6 |
7.3 非功能硬阈值
| 编号 | 结果 | 采样与口径 | 实测 |
|---|---|---|---|
N01 | 通过 | 21 轮;服务端日志分段 + 容器内客户端交叉验证 | 段 B 最大 1ms;端到端上界最大 12ms;客户端最大 14.7ms;401 阻断服务端最大 1ms |
N02 | 通过 | 热/冷各 210 次,批 20/100/200 各 200/200/120 次;容器内采样 + 服务端日志 | 热 P99 7.81ms / 冷 P99 11.31ms / 批 200 P99 14.00ms;IM 210 次会话列表读取仅 1 次内部拉取 |
N03 | 通过(含 1 项偏差记录) | 5 轮 × 500 行;客户端 + 服务端 txMs | Dry-Run 最大 47.6ms;事务 txMs 最大 126ms;请求端到端最大 11.7s(含事务外 scrypt 派生) |
N02的"冷""热"说明:端点本身无状态,冷热只落在 PG 缓冲与批形状上 (热 = 固定批重复请求;冷 = 2 个真实 id + 每次不同的无效 UUID)。 这一点在tc.md里未界定,本报告如实标注,供评审确认口径。
8. 复现方式
bash
# 0. 建立到服务器内部端口的隧道(/internal/* 被反代 404 拦掉,只能走内网口)
ssh -f -N -L 13011:127.0.0.1:3011 -L 13010:127.0.0.1:3010 139.199.173.121
cd products/nexo-im/iterations/2026-09-13/evidence/scripts
node p01-create-user.mjs
node p02-activate.mjs
node p03-b02-expiry.mjs
node p04-on-demand-fetch.mjs
node p05-b03-disable-guard.mjs
node p07-enable.mjs
node b01-concurrent-redeem.mjs
node b04-disabled-cannot-reactivate.mjs
node b06-internal-hmac.mjs
node b07-migration-rollback.mjs
node n01-p06-disable-disconnect.mjs
node n02-batch-fetch.mjs
node n03-p08-b05-batch-import.mjs
node measure-ticket-drift.mjs # N03 TTL 偏差单点复核
node cleanup-fixtures.mjs # 清理全部 preflight- 夹具脚本内的环境常量(端点、AUTH_INTERNAL_SHARED_SECRET、HMAC 签名实现)与测试环境一致; lib.mjs 的签名实现与 @lamolabs/nexo-backend-sdk 的 signInternalRequest 逐字节等价。
每个脚本都在结束时按精确 id 清理夹具;
cleanup-fixtures.mjs作为兜底。 预跑结束后测试库已回到 5 个种子账号的初始状态(users=5、credentials=5、account_tickets=0)。
9. UI 补跑(2026-09-12 晚)
控制台 nexo-user-console 合入 dev 并部署到测试环境后,把 §2.1 列的 UI 判定在真实浏览器里补跑完毕。
- 环境:
https://nexo-user-console-test.snailuu.cn(Cloudflare Pages,dev 分支产物); 认证中心nexo-auth-test镜像sha-a3c55fa3(含 PR #73 的回调白名单修复) - 账号:
user1(种子账号,ACTIVE);临时夹具统一用tc0912-前缀 - 证据:
evidence/ui-2026-09-12/(截图、上传用的 CSV、导出的激活链接 CSV 已对code=打码) - 清理:夹具按精确用户名删除,补跑后测试库回到
users=5、credentials=5、account_tickets=0,5 账号全ACTIVE
9.1 补跑通过的用例
| 用例 | 补跑内容 | 结果 |
|---|---|---|
P08 | 错误 CSV 预检、501 行超限、合法批入库、一键导出 | 阻塞 → 通过。错误行红字 rgb(200,58,74) + 浅红底 rgb(252,235,237);逐行给物理行号与原因(真实姓名不能为空 / 用户名与第 3 行重复 / 用户名已被占用);未通过时【确认导入】disabled=true;501 行文件被前端就地拒绝(「单次最多导入 500 行,当前文件有 501 行,请拆分后再上传」)且未发起上传;合法批 3 行入库后三表 5/5/0 → 8/8/3,三个账号全 PENDING,票据 TTL 恰为 86400s(小批次无 §4.1 的 12s 偏差);导出 CSV 带 BOM + CRLF、表头 username,real_name,activation_url,抽样链接打开托管设密页正常 |
P01 | 新建用户页 | 通过。页面 input[type=password] 计数 0,仅 3 个 text 输入(用户名/真实姓名/昵称);"密码"只出现在说明文案「管理员全程不接触任何明文密码」;建号后出现激活链接面板 + 一键复制 + 「仅本次可见」「24 小时内有效」两条提醒 |
P05 / B03 | 停用防误触确认框 | 通过。空输入、「用户」(部分)、「用户 五」(中间空格)、「用户5」(错字)四档,【确认停用】均 disabled=true;仅「用户五」逐字符全等时点亮;确认后 users.status='DISABLED'、活跃会话数 0 |
P07 | 重新启用 | 通过。用户列表操作列对 DISABLED 账号显示【重新启用】;二次确认弹窗明示「停用期间被吊销的登录态不会恢复,本人需要重新登录」;确认后状态回 ACTIVE |
P03 | 重新生成激活链接 | 通过。PENDING 账号点【重新生成激活链接】→ 二次确认 → 新链接展示;票据总数 2、未核销 1,旧链接已作废 |
9.2 补跑推翻的结论:B04 不通过
预跑把 B04 记为通过,前置账号存在 used_at IS NOT NULL 的 activation ticket。 补跑改用没有票据历史的种子账号 user5,结论反转:
| 断言项 | 期望 | 实际 |
|---|---|---|
| 重发接口响应 | 4xx,提示含「重新启用」 | 200,签发新 24h 链接 |
users.status | 保持 DISABLED | DISABLED → PENDING |
| 未核销 activation ticket | 0 条 | 1 条 |
credentials.password_hash | — | 未轮换(updated_at 保持 2026-08-27) |
判定依据疑似是「是否存在已核销的 activation ticket」,用来区分管理员主动停用与 24h 超时未激活自动停用(T08)。种子账号与迁移而来的存量账号没有这段历史, 被当作「超时停用」放行重发。
影响:正式库里的存量账号正是这一类。任一存量账号被管理员停用后可被拉回 PENDING, 持有新链接者可自行设密接管该账号 —— 正是 B04 想堵的路径。 B04 属安全准入项,按 tc.md §8.5 直接构成迭代不通过条件。
已开 nexo-auth#76(p0)。 建议在 users 上显式记录停用来源(ADMIN / TIMEOUT),重发时 fail-closed。
9.3 补跑新增的两项体验偏差(非阻塞)
- 控制台顶栏当前登录人显示的是**用户 ID(UUID)**而非真实姓名 —— 用户列表里同一账号显示为「用户一」,顶栏却是
9670161b-…。 - 对管理员主动停用的账号,详情页「凭据与链接」区仍提示「该账号正在等待激活,重发会作废旧链接」,文案与实际状态不符(与 §9.2 是同一处判定逻辑的两个表现面)。
9.4 仍未覆盖的部分
B07(迁移回滚):与控制台无关,仍缺迁移前nexo_auth快照,维持阻塞。P06的客户端录屏(「退回登录页并禁用自动重连」):缺的是 IM 桌面端nexo-im-pc,未部署到测试环境,与控制台无关。P08/P02的「抽样链接可完成激活」最后一步(在设密页真实提交口令):补跑只验证到链接能正确打开托管设密页; 提交环节沿用预跑的 API 级结论(logs/n03-p08-b05.json、logs/p02.json)。
9.5 B04 修复复测
nexo-auth#77 合入 dev(491faad)后, 测试环境自动部署到镜像 sha-d013a330,迁移 0007 生效(drizzle.__drizzle_migrations 共 8 条、 users.disabled_reason 列存在)。按 §9.2 的同一路径复测:
| 步骤 | 结果 |
|---|---|
控制台停用 user5(种子账号,无任何票据历史 —— 修复前正是这种账号被复活),输全名确认 | status='DISABLED'、disabled_reason='ADMIN'、未核销票据 0 条 |
| 点【重新生成激活链接】→【确认生成新链接】 | 被拒:「该账号已被管理员停用,请先重新启用后再重发凭据」;status 仍 DISABLED、disabled_reason 仍 ADMIN、票据仍 0 条 |
把 disabled_reason 改为 'TIMEOUT' 后重试(验证放行路径未被误伤) | 放行:拉回 PENDING、签发 24h 新链接、disabled_reason 清为 null |
还原为 DISABLED/ADMIN 后走控制台【重新启用】 | status='ACTIVE'、disabled_reason 清为 null |
两个方向都验到了:该拒的拒、该放的放。只验拒绝那一侧的话,一个把所有 DISABLED 都拒掉的实现同样会绿,而那会让超时未激活账号失去唯一的自助补救路径。
证据:evidence/ui-2026-09-12/B04-03-fixed-rejected.png、B04-04-enable-clears-reason.png。
复测后测试库已还原:5 个种子账号全 ACTIVE、disabled_reason 全 null、account_tickets 0 条。
9.6 复测中发现的文案瑕疵(非阻塞)
被拒时页面显示「该账号已被管理员停用,请先重新启用后再重发凭据(本页无法补发凭据。)」—— 服务端文案后面又被前端追加了一句括注,读起来是两句重复的话。功能无影响,归入 §9.3 同类。