外观
2026-09-27 迭代 — 验收用例(TC)
依据 PRD 原文 与 技术方案。PRD 定义产品行为,技术方案定义实现契约; 本文只把两者转成可重复执行、可留证据的判定条件,不另行扩大范围。 本文与 PRD、技术方案一同评审;预跑与证据归档由 T01(lamolabs-docs#87) 执行, 预跑完成后在 §10 回填结果与证据链接。
任务追踪:2026-09-27 父任务 lamolabs-docs#86 · 组织 Project
本批只产出本文档,不预跑。 预跑于 09-26 单独执行,结果与证据回填 §10。
1. 执行规则
- 编号含义:
G<门禁号>-<用例号>= 发布门禁用例(与 lamolabs-docs#87 的「门禁映射」表一一对应);S= PRD §8 点名、但父任务门禁表未单列门禁的补充覆盖用例;N= 非功能硬阈值;MTG= 上会用例子集。 编号一经发布不得复用或重排,后续任务的「测试追踪」按本编号引用。 - 分层:§4 为底线用例(门禁 #1—#5、#7,对应设计 §15 的 B01–B09 + B12);§5 为可顺延用例 (门禁 #6、#8、#9,对应 B10 / B11 / B13 / B15 / B16)。顺延则门禁 #6、#8、#9 转下期,底线用例不得顺延。 每条用例头部显式标注分层,§1.2 汇总表同口径。
- 会上只执行 §3 的 9 条,每条判定时间不超过 3 分钟;其余用例在 2026-09-26 预跑阶段完成,会上只复核证据。
- 一条用例的全部判定条件均满足才算通过;部分满足、无证据或只能口头说明都记为不通过。
- 因环境、资产或评审结论缺失而无法执行时记为阻塞,不允许把"未测"写成"通过"。
- 空集通过必须显式标注:当某条用例的被测面客观上为空集(如某服务根本不存在该类接口)时, 记为**
空集通过(vacuous pass)**而非普通"通过",并在证据与 §10 备注里写明「被测面为空的原因」。 空集通过与真实通过在一张结果表里长得一模一样,不标注会让后来者误以为某个接口真被加固过。 - 离线可重复:每条用例自建所需数据、自行清理,不依赖上一条用例的残留状态。 自建实体统一用
tc-<用例编号小写>-<6 位随机串>前缀命名,用例结束按前缀删除并断言零残留; 种子模板tpl_release_prod由发布步骤种入(design §16),用例只读引用、不建不改。 - 三路用例固定同一调用方身份:门禁 #1 的三次创建必须使用同一个 HMAC keyId(
namespace从已认证身份推导, 换调用方会让逐字段比对失败)。 - 并发用例脚本化:
S01由脚本发起并发请求,不靠人工手点。 - 证据不接受截图代替日志:接口类用例归档请求/响应 JSON,UI 类用例归档截图或录屏(截图只作 UI 用例的证据)。
- 涉及时间的用例以服务端时间为准;毫秒级阈值(
N01—N03)用服务端毫秒时间戳差计算,不用人工秒表。 - 三项硬阈值必须实测取数,不得估算(取数口径见 §7);数据由预跑阶段采集,会上只复核证据。
- 错误码断言必须比对
error.code具体值(design §10.1),只看 HTTP 状态不算通过。
1.1 本期冻结的验收基线
| 项目 | 验收值 |
|---|---|
| 模板绑定 | 三路统一存 template_snapshot(NOT NULL);template_id / resource_type 降级为溯源字段;source_type ∈ {resource_type, template_id, inline} |
| 三路允许差异字段 | 仅三个:source_type / template_id / resource_type。template_config 已被 template_snapshot 取代,不再作为比对维度(design §7.1) |
| 装配管线分叉点 | 只有 resolveTemplate() 一处分叉;分叉点之后不得出现任何 source_type 判断(code review 按此检查) |
| 第一阶段(创建) | 只预填 fill_by=system 字段;审批单 pending;不实例化节点 |
| 第二阶段(提交) | merged = { ...defaults, ...payload, ...submitterInput },整值替换槽位;校验不过即整体回滚,审批单停留 pending |
| 节点状态枚举 | pending / active / in_progress / approved / rejected / cancelled |
| 审批人状态枚举 | pending / approved / rejected / cancelled |
| 审批单状态枚举 | pending / in_progress / approved / rejected / withdrawn / cancelled / auto_approved / auto_rejected(后两者本期无代码路径产生) |
strategy 枚举 | single / countersign / any(无 multi_level) |
| 会签 mode | all / majority / ratio;ratio 必须带 threshold ∈ (0,1],判据为 通过比例 ≥ threshold |
| 判定优先级 | veto_users 一票否决优先于一切 mode 判定;cancelled 审批人不计入分母 |
| 或签终态 | 任一通过 → 节点 approved 且其余 pending 置 cancelled;全员拒绝 → 节点 rejected;单人拒绝不终止 |
| 条件 operator | == != > >= < <= in not_in 共八种;跨类型比较判不命中;槽位缺失判不命中并走 next.default |
| 幂等 | 重放判断先于终态检查与"是否当前节点"检查;同一审批人对同一节点同一决策不产生第二条 ApprovalAction |
| 并发 | 整单一把锁(approval_orders 行级 FOR UPDATE),同一单的并发操作串行化 |
| append-only | approval_actions / audit_logs 的 UPDATE / DELETE 一律被触发器拒绝,报 APPEND_ONLY_VIOLATION(design §7.3) |
| 快照冻结 | approval_orders.template_snapshot / source_type 变更被触发器拒绝,报 IMMUTABLE_FIELD |
| 节点与审批人上限 | APPROVAL_MAX_NODES_PER_ORDER=20、APPROVAL_MAX_APPROVERS_PER_NODE=20;列表分页上限 APPROVAL_LIST_PAGE_SIZE_MAX=100 |
| 鉴权 fail-closed | perm-api 不可达时管理接口 PERM_UNAVAILABLE(503)拒绝;审批操作(submit / actions / withdraw)不受影响 |
权限点(命名空间 approval) | approval.template.manage / approval.template.read / approval.resource-type.manage / approval.order.read.all / approval.audit.read |
| 读写矩阵 | editable 唯一实现在后端:system 恒 false;submitter 字段仅提交人且 status=pending 为 true;approver 字段仅本人待处理时为 true(design §10.4) |
| 硬阈值 | 审批单创建 API p99 < 1s;审批操作 API p99 < 1s;待办列表与「我发起的」列表首屏 p99 < 2s |
1.2 用例编号与门禁映射
| 门禁 | 门禁要点 | 主责任务 | 用例 | 分层 |
|---|---|---|---|---|
| #1 | 三种发起方式产出结构一致的审批单 | B04 | G1-01—G1-03 | 底线 |
| #2 | tpl_release_prod 两条分支各跑通 | B06 | G2-01—G2-03 | 底线 |
| #3 | 会签三 mode + 一票否决 | B08 | G3-01—G3-04 | 底线 |
| #4 | 或签两条终态 | B08 | G4-01—G4-02 | 底线 |
| #5 | 槽位校验失败不产生半实例化审批单 | B05 | G5-01—G5-03 | 底线 |
| #6 | 审计完整性 | B11 | G6-01—G6-02 | 可顺延 |
| #7 | 门户主流程全程不碰 API | F03 / F02 | G7-01—G7-05 | 底线 |
| #8 | 零硬编码管理员 | B13 | G8-01—G8-04 | 可顺延 |
| #9 | 存量接入债还清 | B15 / B16 | G9-01—G9-03 | 可顺延 |
| #10 | 09-26 预跑归档 + 09-27 验收会全通过 | 本任务 | G10-01—G10-02 | 底线 |
| — | PRD §8 点名补充(状态机并发 / 幂等 / 撤回与取消 / 读写矩阵) | B09 / B10 / F02 | S01—S03(读写矩阵并入 G7-02) | S01、S02 底线;S03 可顺延 |
| — | 三项硬阈值实测 | 本任务 | N01—N03 | 底线 |
2. 环境前置
nexo-approval-apitest 实例已部署并监听3003;nexo_approval库迁移(drizzle 生成 + 3 个手工触发器迁移 + 2 个部分唯一索引)与初始化脚本已执行。只读
psql访问nexo_approval(断言approval_orders/approval_nodes/approval_node_approvers/approval_actions/approval_comments/audit_logs/approval_templates/resource_types)。nexo-approval-console已部署并经壳(nexo-app)侧边栏入口可达;独立访问方式亦可登录。nexo-perm-api(3002)可用,命名空间approval与 5 个权限点、角色「审批平台管理员」已由初始化脚本幂等建好;nexo-perm-api#35的角色成员反查 internal 接口可用(供角色审批人展开)。nexo-account(仓库nexo-auth,3001)与nexo-im-api(3000)test 实例可用(供门禁 #9 与登录链路)。门禁 #9 的 SDK 版本前置(易漏,务必先核,且必须核到分支而非工作区):鉴权客户端
createPermissionCheckClient只存在于@lamolabs/nexo-backend-sdk0.3.0-alpha.1(v0.1.0 / v0.2.0 无src/permission-check/,v0.3.0-alpha.1 才有);更早版本的dist/index.d.ts只有 errors / internal-auth / jwks / logger / mask,无任何 permission-check 导出。 各仓基线分支(git show <ref>:package.json)实测如下:仓库 基线分支锁定版本 是否需升级 原因 nexo-auth(B15)0.1.0(main/dev及对应 origin 全为 0.1.0)需升级 要为 8 条管理接口挂判定,必须能编译鉴权客户端 nexo-im-api(B16)0.1.0(origin/main/origin/dev均为 0.1.0)需升级 B16 交付的是鉴权机制(中间件 + 客户端 + 权限点定义),机制本身要编译,故仍需升版 nexo-perm-api0.3.0-alpha.1已就绪 — nexo-approval-api0.3.0-alpha.1已就绪 — 两侧升级的语义不同,别混为一谈:B15 升版是为了给端点加门;B16 升版是为了交付机制本身—— 它把中间件挂到 0 条路由上(这是正确结果,见
G9-01被测面表)。 所以核验 B16 时的判据不是「有没有升 SDK」(升了是对的),而是 「中间件有没有被挂到任何自助端点上」:grep -rn authz src/routes/index.ts src/routes/*/routes.ts src/routes/*/index.ts应当零命中。若命中/users//conversations*//ws等任一条,即为往消息链路加耦合, 按净回归处理——这正是 nexo-im-api#85 明令禁止的。预跑提示:
package.json的版本因分支而异。核验一律用git show <基线分支>:package.json,不要读工作区——工作区可能已 checkout 到特性分支并带着未提交改动, 会得出与基线相反的结论(本条曾据此把nexo-im-api基线误读为已升到 0.3.0-alpha.1,实际其origin/main/origin/dev均为 0.1.0,是 B16 特性分支的工作区升了版)。@lamolabs/*走 GitHub Packages 私有 registry(各仓.npmrc声明@lamolabs:registry=https://npm.pkg.github.com), 需要read:packages权限的 token。新建仓库必须在 GitHub 仓库 Secrets 里配GH_PACKAGES_TOKEN(CI 用echo "//npm.pkg.github.com/:_authToken=${{ secrets.GH_PACKAGES_TOKEN }}" >> .npmrc注入), 否则pnpm install直接ERR_PNPM_FETCH_401。本用例的预跑依赖各仓可构建产物, 任一仓的该 Secret 缺失都会让 09-26 预跑无法开始——预跑前逐仓核对。写法提醒:VitePress 按 Vue 模板编译,行内代码里的美元符号加双花括号会被当作插值求值: 轻则构建时抛
TypeError并静默吞掉该段正文(构建仍打印 complete,页面却缺内容), 重则直接Error parsing JavaScript expression中断构建。凡需在正文里写出字面插值写法的, 用<span v-pre>包裹或放进围栏代码块——本行上方的 CI 命令即按此处理。种子模板
tpl_release_prod已由发布步骤种入(design §16),用例只读引用、不建不改。实测(B13 的 seed 实现):namespace = nexo-release,名称tpl_release_prod,同时种入绑定它的资源类型release(owner = nexo-release); 幂等靠(namespace, name)与type_key唯一约束,已存在即跳过且不覆盖。注意区分两个 namespace:模板的
nexo-release是模板归属;审批单的namespace由调用方 HMAC 身份推导, 二者是不同的字段。门禁 #1 只要求三次创建的namespace彼此相等(同一身份),不要求等于nexo-release。种子的审批人默认值刻意全为
type: 'user'(app_tl=u_101、sre_1=u_201、sre_2=u_202、oncall_lead=u_301), 而非 design §11 示例里的{ type: 'role', id: '7' }——若照抄示例,门禁 #2 的每次提交都会依赖 perm-api 里 恰好存在 id=7 的角色,引擎用例就变成了跨平台联调用例。故角色展开(G5-03)必须自建模板, 不能靠种子模板。调用方身份:内部通道 HMAC 凭据就绪(keyId 对应 app 主体,
namespace由此推导);门禁 #1 全程复用同一 keyId。账号矩阵:1 个发起方用户、≥4 个审批人(其中 ≥1 人经 perm-api 角色展开得到)、1 个平台管理员(持全部
approval.*权限点)、 1 个无任何approval.*权限的普通用户。 种子模板自带的 4 个审批人标识必须可用:u_101(node_tl)、u_201/u_202(node_sre 会签)、u_301(node_emergency)——它们由种子写死在defaults里,用例不建这些账号, 只要求 test 环境能以其身份登录门户(门禁 #2 / #3 的端到端提交与审批依赖此)。工具:
psql(只读断言)、浏览器 DevTools / HAR、并发脚本、压测脚本、服务端结构化日志检索。造数与压测脚本归档
evidence/scripts/,结构化取数(JSON)落evidence/logs/,UI 走查截图与录屏落evidence/ui/。敏感信息脱敏:HMAC 密钥不入库,Bearer Token 只留前 8 位,业务 payload 中的真实标识用测试值。
3. 会上用例子集(固定 9 条)
| 编号 | 覆盖用例 | 操作摘要 | 判定阈值 |
|---|---|---|---|
| MTG-01 | G1-01 | 同一模板、同一调用方身份分别走 resource_type / template_id / template_config 创建 | template_snapshot 三路 deep-equal;业务字段逐列相等;仅三个溯源字段允许不同 |
| MTG-02 | G2-01 | 用 tpl_release_prod 以 is_urgent=true 提交 | 节点序列 node_tl → node_emergency → node_sre,node_emergency 落行 |
| MTG-03 | G2-02 | 同一模板以 is_urgent=false 提交 | 节点序列 node_tl → node_sre,node_emergency 零行 |
| MTG-04 | G3-02 | 3 人会签 majority 节点两人通过 | 第 2 人通过即节点 approved,第 3 人未被要求处理 |
| MTG-05 | G4-01 | 或签节点一人通过 | 节点 approved,其余 pending 全部 cancelled |
| MTG-06 | G5-01 | 提交时必填字段缺失 / 审批人解析为空 | 两次均 400 具体错误码;审批单仍 pending;节点表零行 |
| MTG-07 | G7-01 | 从详情页链接进入 → 登录回跳 → 补充提交 → 审批 → 完成 | 全程浏览器内完成,录屏中无任何 API 工具调用 |
| MTG-08 | G7-02 | 三角色视角分别打开同一审批单详情页 | 九格 editable 与 design §10.4 完全一致 |
| MTG-09 | S01 | 脚本对同一会签节点两人同时通过 | 节点只流转一次;approval_actions 恰 2 条;下一节点 seq 唯一 |
N01—N03三项硬阈值在会上只复核预跑证据,不现场重测;证据缺失时该条记阻塞。
4. 底线用例(门禁 #1—#5、#7)
主责任务 B01–B09 + B12(design §15)。本节用例不得顺延;顺延即门禁失败。
4.1 门禁 #1 — 三路发起产出结构一致的审批单(B04)
G1-01 三路创建结构一致(同一模板 + 同一调用方身份)
分层:底线(门禁 #1 · B04)
前置条件:种子模板
tpl_release_prod可用;同一 HMAC keyId 可调/internal/*; 用例自建一个绑定该模板的resource_type(type_key = tc-g1-01-<rand>); 可只读查询approval_orders/approval_nodes/approval_node_approvers。操作步骤: ① 三路共用同一份
payload(含app_name/version/is_urgent/resource_url)与同一submittedBy, 分别调POST /internal/orders:resourceType = tc-g1-01-<rand>、templateId = <tpl_release_prod 主键>、templateConfig = <从GET /api/templates取到的该模板四份配置原样>; ② 对三个orderId分别只读查询approval_orders全列,逐列比对; ③ 三路各以同一份submitterInput调POST /api/orders/:id/submit,再次比对form_data/resolved_slots; ④ 比对三单的approval_nodes(seq/node_key/strategy/countersign_rule)与approval_node_approvers(approver_type/approver_id/from_role_id)。预期结果:三次创建均
201且status = pending、detailUrl形如${APPROVAL_PORTAL_BASE_URL}/orders/:id; 逐列比对结果:列 期望 template_snapshot三路 deep-equal namespace相等(同一 HMAC 身份推导) status相等 payload相等 form_data相等( system预填结果)resource_url相等 submitted_by相等 resolved_slots提交后相等(创建时为 NULL)source_type允许不同(依次为 resource_type/template_id/inline)template_id允许不同;方式三(inline)必为 NULL(design §7.2 列注释);方式一/二为所解析到的模板主键(实测核对)resource_type允许不同;方式二/三必为 NULL(design §7.2 列注释);方式一为所注册的type_keyid/created_at/updated_at允许不同(自增与时间戳) 允许不同的仅三个溯源字段(
source_type/template_id/resource_type)加上自增与时间戳列;template_config已被template_snapshot取代,不作为比对维度(design §7.1)。 步骤③后三单的form_data/resolved_slots仍相等,且approval_nodes的(seq, node_key, strategy, countersign_rule)三元组序列逐项相等、approval_node_approvers集合逐项相等。证据采集:三次创建与三次提交的请求/响应 JSON(
evidence/logs/g1-01-three-paths.json); 三单approval_orders全列的psql断言输出;节点与审批人表的对比输出; 代码检索结果(src/中source_type的出现位置仅限resolveTemplate与其后的落库赋值,无装配分支)。对应:PRD §8 第一条 · FR-4 / FR-5 · design §7.1 / §8.1 · 上会
MTG-01
G1-02 三路入参互斥与内联模板不落库
- 分层:底线(门禁 #1 · B04)
- 前置条件:同
G1-01;记录用例开始前approval_templates的行数。 - 操作步骤:① 同时传
resourceType+templateId;② 同时传templateId+templateConfig;③ 三者都传; ④ 三者都不传;⑤ 单独传templateConfig(合法内联配置);⑥ 断言approval_templates行数。 - 预期结果:①—④ 均
400 VALIDATION_FAILED,零新审批单、零新节点、零新审计行; ⑤201,source_type = 'inline'、template_id IS NULL、resource_type IS NULL、template_snapshot等于所传配置; ⑥ 模板表行数与用例开始时一致(内联模板不持久化为可复用模板); 另断言内联路径同样走 FR-2 全量校验:传一份违反 C01/C07 的内联配置应400 TEMPLATE_INVALID且detail.issues[]带path(PRD §10 Q5)。 - 证据采集:五次请求/响应 JSON;
approval_templates前后计数断言;非法内联配置的TEMPLATE_INVALID响应(含issues[])。 - 对应:PRD FR-4 / §10 Q5 · design §8.1 / §10.1
G1-03 第一阶段只预填、不建节点
- 分层:底线(门禁 #1 · B04)
- 前置条件:同
G1-01;tpl_release_prod的form_schema同时含fill_by=system与fill_by=submitter字段。 - 操作步骤:① 以
resourceType路创建,payload只带system字段值并额外塞一个submitter字段值; ② 查approval_orders该行;③ 查该order_id下的approval_nodes与approval_node_approvers行数。 - 预期结果:
status = pending;form_data只含fill_by=system字段的值(submitter字段被忽略);resolved_slots IS NULL;submitted_at IS NULL;节点表与审批人表零行; 此时GET /internal/orders/:id/status返回currentNode: null(未实例化节点)。 - 证据采集:创建请求/响应 JSON;该行全列
psql断言;节点与审批人表计数为 0 的输出;status接口响应。 - 对应:PRD FR-5 · design §8.1 · 上会并入
MTG-01
4.2 门禁 #2 — tpl_release_prod 两条分支(B06)
G2-01 is_urgent = true 走紧急分支
- 分层:底线(门禁 #2 · B06)
- 前置条件:
tpl_release_prod的flow_config含static_nodes(node_tl→node_sre)、dynamic_nodes(node_emergency→node_sre),node_tl.next.if的条件为is_urgent == true; 槽位app_tl/oncall_lead/sre_1/sre_2均可解析。 - 操作步骤:① 以
payload.is_urgent = true创建并提交;② 按seq升序查approval_nodes。 - 预期结果:节点序列为
seq=1 node_tl→seq=2 node_emergency→seq=3 node_sre;node_emergency有行且strategy = single;node_sre为countersign且countersign_rule.mode = 'all'; 首节点status = active且started_at非空,其余pending;审批单status = in_progress、submitted_at非空。 - 证据采集:创建与提交的请求/响应 JSON;
approval_nodes按seq的输出(evidence/logs/g2-branches.json)。 - 对应:PRD §8 第二条 · FR-7 · design §8.3 · 上会
MTG-02
G2-02 is_urgent = false 不走紧急分支
- 分层:底线(门禁 #2 · B06)
- 前置条件:同
G2-01。 - 操作步骤:① 以
payload.is_urgent = false创建并提交;② 按seq升序查approval_nodes; ③ 按node_key = 'node_emergency'精确查询。 - 预期结果:节点序列为
seq=1 node_tl→seq=2 node_sre;node_emergency零行(dynamic_nodes仅在条件命中时实例化); 其余断言同G2-01。 - 证据采集:创建与提交的请求/响应 JSON;
approval_nodes输出;node_emergency计数为 0 的断言。 - 对应:PRD §8 第二条 · FR-7 · design §8.3 · 上会
MTG-03
G2-03 八种 operator 求值矩阵与缺失槽位回落
分层:底线(门禁 #2 · B06)
前置条件:
flow模块为纯函数(无 DB 无 ctx),可直接以脚本调用;另可自建模板tc-g2-03-<rand>。操作步骤: ① 纯函数矩阵:脚本对
evalCondition({slot, operator, value})逐行输入下表,记录返回值; ② 端到端:自建模板,next.if分别使用in与not_in命中两个不同dynamic_nodes,各走一单; ③ 边界:槽位缺失、两侧类型不同(number对 ISO8601 串)各跑一行。operator 槽位值 value期望 ==truetrue命中 =="1"1不命中(跨类型严格相等) !="prod""staging"命中 >32命中 >=22命中 <"2026-09-01T00:00:00Z""2026-09-26T00:00:00Z"命中(ISO8601 日期比较) <="2026-09-26T00:00:00Z""2026-09-26T00:00:00Z"命中 in"staging"["staging","production"]命中 not_in"dev"["staging","production"]命中 >3"2026-09-26T00:00:00Z"不命中(跨类型) ==槽位缺失 true不命中 预期结果:纯函数矩阵逐行与期望一致;端到端两单各自只实例化被命中的那个
dynamic_node(另一个零行); 槽位缺失时该条件判为不命中并走next.default(PRD FR-6 允许条件引用的槽位缺失); 条件求值顺序按if数组顺序短路,命中即返回then。证据采集:矩阵输入/输出的 JSON(
evidence/logs/g2-03-operators.json);端到端两单的节点序列断言; 自建模板与两单数据的清理断言。对应:PRD FR-6 / FR-7 · design §8.3 · §11 C09
4.3 门禁 #3 — 会签三 mode 与一票否决(B08)
G3-01 会签 all:全部通过才通过
- 分层:底线(门禁 #3 · B08)
- 前置条件:可自建含
strategy = countersign、countersign_rule.mode = 'all'且 3 名审批人的模板 (tc-g3-01-<rand>);3 名审批人账号可登录门户。 - 操作步骤:① 走一单到该节点
active;② 第 1 人通过;③ 第 2 人通过;④ 查节点与审批单状态; ⑤ 第 3 人通过;⑥ 再查节点与审批单状态;另起一单重复 ①②,第 2 人改为拒绝。 - 预期结果:② 后节点
in_progress(非终态);③ 后仍in_progress(all需等全员); ⑤ 后节点approved、审批单流转到下一节点或approved; 拒绝分支:任一rejected→ 节点立即rejected,其余pending审批人置cancelled,后续pending节点全部cancelled,审批单rejected。 - 证据采集:每一步的请求/响应 JSON;
approval_nodes与approval_node_approvers的状态流转输出 (evidence/logs/g3-01-all.json)。 - 对应:PRD §8 第三条 · FR-9 · design §9.1
G3-02 会签 majority:3 人 2 通过即决
- 分层:底线(门禁 #3 · B08)
- 前置条件:自建模板
tc-g3-02-<rand>,节点mode = 'majority'、3 名审批人。 - 操作步骤:① 走一单到该节点
active;② 第 1 人通过(查状态);③ 第 2 人通过(查状态); ④ 查第 3 人的approval_node_approvers.status;⑤ 另起一单,前两人均拒绝(查状态)。 - 预期结果:② 后节点
in_progress(1 票不足半数);③ 后节点立即approved——approved 数 > 总数/2即决,无需等第 3 人; ④ 第 3 人状态为cancelled(节点已终态);⑤ 拒绝数达≥ 总数/2时提前终止为rejected,不再等待第 3 人。 - 证据采集:各步请求/响应 JSON;节点与审批人状态输出(
evidence/logs/g3-02-majority.json)。 - 对应:PRD §8 第三条 · FR-9 · design §9.1 · 上会
MTG-04
G3-03 会签 ratio(threshold = 0.66):等全员处理完再判
- 分层:底线(门禁 #3 · B08)
- 前置条件:自建模板
tc-g3-03-<rand>,节点mode = 'ratio'、threshold = 0.66、3 名审批人。 - 操作步骤:① 走一单到该节点
active;② 两人通过;③ 查节点状态;④ 第 3 人通过;⑤ 查节点状态; ⑥ 另起一单,两人通过、第 3 人拒绝(比例 2/3 ≈ 0.667)。 - 预期结果:③ 节点仍
in_progress(ratio必须等全员处理完,仍有 pending → pending); ④ 后节点approved;⑥ 同样approved(2/3 ≥ 0.66,判据为比例 ≥ threshold,PRD §10 Q4 不做取整策略); 另断言threshold ∉ (0,1]的模板被TEMPLATE_INVALID拒绝(design §11 C08)。 - 证据采集:各步请求/响应 JSON;节点状态输出;非法 threshold 的模板校验响应(
evidence/logs/g3-03-ratio.json)。 - 对应:PRD §8 第三条 / §10 Q4 · FR-9 · design §9.1 / §11 C08
G3-04 veto_users 一票否决优先于一切 mode
- 分层:底线(门禁 #3 · B08)
- 前置条件:自建模板
tc-g3-04-<rand>,节点mode = 'all'、3 名审批人,veto_users含其中 1 人。 - 操作步骤:① 走一单到该节点
active;② 其余 2 人先通过;③veto_users中的人拒绝;④ 查节点与审批单状态; ⑤ 另建模板,把veto_users指向不在该节点审批人内的用户,尝试提交。 - 预期结果:③ 后节点立即
rejected(否决优先于all的"全部通过"判定),审批单rejected、completed_at非空; ④ 其余pending审批人置cancelled; ⑤ 提交被拒400 VETO_NOT_APPROVER,审批单停留pending、节点表零行(展开后否决者必须是本节点审批人的子集)。 - 证据采集:各步请求/响应 JSON;节点与审批人状态输出;
VETO_NOT_APPROVER响应与零节点断言 (evidence/logs/g3-04-veto.json)。 - 对应:PRD §8 第三条 · FR-9 · design §8.4 / §9.1 / §10.1
4.4 门禁 #4 — 或签两条终态(B08)
G4-01 任一通过 → 节点 approved,其余 cancelled
- 分层:底线(门禁 #4 · B08)
- 前置条件:自建模板
tc-g4-01-<rand>,节点strategy = 'any'、3 名审批人。 - 操作步骤:① 走一单到该节点
active;② 第 1 人通过;③ 查节点与全部 3 名审批人的状态;④ 查审批单状态与completed_at。 - 预期结果:节点
approved;通过者approved,其余 2 人全部cancelled(不是pending); 审批单流转到下一节点(存在时)或approved(无下一节点时completed_at非空); 此后对已cancelled的审批人再提交操作,返回409 NODE_STALE(页面已过期)。 - 证据采集:各步请求/响应 JSON;节点与审批人状态输出;
NODE_STALE响应(evidence/logs/g4-any.json)。 - 对应:PRD §8 第四条 / §4.5 · FR-9 · design §9.1 / §9.3 · 上会
MTG-05
G4-02 单人拒绝不终止;全员拒绝 → 节点 rejected
- 分层:底线(门禁 #4 · B08)
- 前置条件:同
G4-01。 - 操作步骤:① 走一单到该节点
active;② 第 1 人拒绝;③ 查节点状态;④ 第 2 人拒绝;⑤ 查节点状态; ⑥ 第 3 人拒绝;⑦ 查节点、审批人、审批单与后续节点状态。 - 预期结果:③ 与 ⑤ 节点仍
in_progress(单人拒绝不终止或签,这是 PRD §4.5 补死的规则); ⑦ 节点rejected,其余pending审批人置cancelled,后续全部pending节点置cancelled, 审批单rejected、completed_at非空。 - 证据采集:各步请求/响应 JSON;节点/审批人/审批单状态输出(
evidence/logs/g4-any.json)。 - 对应:PRD §8 第四条 / §4.5 · FR-9 · design §9.1 / §9.2
4.5 门禁 #5 — 槽位校验失败不留半成品(B05)
G5-01 提交校验三类失败:必填缺失 / 槽位未解析 / 审批人为空
- 分层:底线(门禁 #5 · B05)
- 前置条件:自建模板
tc-g5-01-<rand>(含required字段、引用槽位approver_1); 另备一份模板使某节点审批人槽位解析后为空(perm-api 角色反查返回空成员)。 - 操作步骤: ① 创建审批单后,
submit时故意不传某个required字段; ②submit时defaults与payload均不含approver_1; ③ 用「角色反查返回空成员」的模板走一单并提交; ④ 每次失败后查approval_orders/approval_nodes/approval_node_approvers/approval_actions/audit_logs。 - 预期结果:①
400 REQUIRED_FIELD_MISSING且detail.fields[]列出缺失字段 key; ②400 SLOT_MISSING且detail.slots[]列出未解析槽位名; ③400 APPROVER_EMPTY且detail.nodeKey指明节点; ④ 三次失败后审批单status仍为pending、submitted_at IS NULL、resolved_slots IS NULL, 节点表与审批人表零行,approval_actions零行(无submit记录),audit_logs无submit_approval行—— 失败路径整体回滚,不留半实例化审批单; 失败后修正入参再次submit可成功(证明失败未污染审批单)。 - 证据采集:三类失败请求/响应 JSON;四张表的计数断言(
evidence/logs/g5-01-validation.json); 修正后成功提交的响应。 - 对应:PRD §8 第五条 · FR-6 · design §8.2 / §10.1 · 上会
MTG-06
G5-02 defaults 回落与条件引用槽位缺失走 default
- 分层:底线(门禁 #5 · B05)
- 前置条件:自建模板
tc-g5-02-<rand>,defaults提供app_tl;next.if条件引用槽位is_urgent。 - 操作步骤:① 提交时
payload与submitterInput均不含app_tl,查resolved_slots与审批人快照; ② 提交时payload与defaults均不含is_urgent,查节点序列; ③ 合并优先级验证:同一槽位在defaults/payload/submitterInput三处给不同值。 - 预期结果:①
app_tl由defaults回落解析成功,resolved_slots留档为回落值,节点审批人快照为展开后的用户; ② 条件引用槽位缺失时不报错,该条件判为不命中并走next.default(不产生SLOT_MISSING); ③ 生效值为submitterInput>payload>defaults。 - 证据采集:三次提交的请求/响应 JSON;
resolved_slots与节点序列断言(evidence/logs/g5-02-defaults.json)。 - 对应:PRD FR-6 · design §8.2
G5-03 perm-api 不可达时回滚,不降级为空审批人
- 分层:底线(门禁 #5 · B05)
- 前置条件:自建模板(
tc-g5-03-<rand>),其节点审批人为type: 'role',展开依赖 perm-api 角色反查; 不得使用种子模板tpl_release_prod——它的defaults刻意全为type: 'user'(见 §2), 用它验不出角色展开路径;perm-api 可临时停掉或改为不可达地址。 - 操作步骤:① 停掉 perm-api(或临时指向不可达地址);② 提交该审批单;③ 查四张表;④ 恢复 perm-api 后重新提交。
- 预期结果:② 返回
503 PERM_UNAVAILABLE(fail-closed,不降级为空审批人); ③ 审批单仍pending、节点表零行、无approval_actions行;④ 恢复后同一审批单可成功提交。 - 证据采集:失败与成功两次响应 JSON;四张表计数断言;服务端错误日志(脱敏) (
evidence/logs/g5-03-perm-unavailable.json)。 - 对应:PRD FR-8 · design §8.4 / §10.1 / §17
4.6 门禁 #7 — 门户主流程全程不碰 API(F03 / F02)
G7-01 收到链接 → 登录回跳 → 补充提交 → 审批 → 完成
- 分层:底线(门禁 #7 · F03)
- 前置条件:
nexo-approval-console已部署并经壳入口可达;发起方与审批人均有可用测试账号; 审批门户未登录(清空站点存储);已存在一张pending审批单(由用例自建,payload.resource_url指向可访问的业务页)。 - 操作步骤:① 以发起方身份把
detailUrl复制到浏览器新标签直接打开(模拟"业务方把链接放进自己的页面"); ② 完成登录(授权码流程),确认登录后回跳到原路由/orders/:id而非首页; ③ 在详情页填写fill_by=submitter字段并点提交; ④ 以审批人身份打开同一链接,点通过(必要时走完多个节点); ⑤ 回到业务方resource_url页面确认审批完成。 - 预期结果:全程在浏览器内完成,不使用 curl / Postman / 任何 API 工具; ② 回跳后直接落在详情页(F01 的
redirect参数生效),链接不因未登录而断链; ③ 提交后详情页状态变为in_progress,节点区出现当前节点与审批人; ④ 每一步操作后详情页即时反映节点状态、时间线与审批意见; ⑤ 审批单终态为approved,timeline按时间顺序完整呈现submit→approve记录。 - 证据采集:全程录屏(
evidence/ui/g7-01-portal-closed-loop.mp4或分步截图序列); 每一步的关键页面截图;浏览器 HAR 仅作辅助(不得替代录屏)。 - 对应:PRD §8 第八条 · FR-16 · design §14.3 · 上会
MTG-07
G7-02 三角色读写矩阵九格(fill_by × 角色视角)
分层:底线(门禁 #7 · F02/F03)
前置条件:一张
pending审批单(提交人未提交)与一张in_progress审批单; 三个账号:提交人、当前待处理审批人、持approval.order.read.all的观察者。操作步骤:① 以三个账号分别调
GET /api/orders/:id,逐字段记录editable; ② 以三个账号分别打开同一详情页,记录每个字段的可编辑态; ③ 在in_progress单上重复 ①②,并把审批单推到终态后再做一次观察者视角。fillByviewerRole = submitter且status = pendingviewerRole = approver且本人待处理其余一切情况 systemfalsefalsefalsesubmittertruefalsefalseapproverfalsetruefalse预期结果:九格的
editable与上表逐格一致;前端不自行推断,只消费后端返回的editable; 同一格在接口与页面两侧结论一致(后端单测 + 前端走查双侧覆盖,design §14.2); 终态单上一切字段editable = false;观察者视角无任何可写字段。证据采集:三账号 × 三种状态下的详情响应 JSON(
evidence/logs/g7-02-editable-matrix.json); 九格页面截图(evidence/ui/g7-02-*.png)。对应:PRD §8 · FR-15 · design §10.4 / §14.2 · 上会
MTG-08
G7-03 只读字段 DOM 改写后被提交前过滤
- 分层:底线(门禁 #7 · F02)
- 前置条件:一张
pending审批单,同时含fill_by=submitter与fill_by=system字段。 - 操作步骤:① 在详情页用 DevTools 移除某个
fill_by=system字段的readonly/disabled并改写其值; ② 点击提交;③ 查approval_orders.form_data与该次提交请求的 payload。 - 预期结果:提交请求的 payload 只含
editable === true的字段;被改写的system字段未进入form_data(前端按editable过滤,design §14.2);后端即便收到该字段也不采纳(后端是正确性底线); 页面在提交后回显的值等于服务端值而非被改写值。 - 证据采集:改写前后 DOM 截图;提交请求 payload(HAR 或 DevTools 复制);
form_data的psql断言(evidence/logs/g7-03-readonly-filter.json)。 - 对应:PRD FR-15 · design §14.2
G7-04 待办与我发起的列表 + 未处理计数
- 分层:底线(门禁 #7 · F03)
- 前置条件:当前审批人待办 ≥ 1 条;发起方「我发起的」≥ 1 条;另建一批
tc-g7-04-<rand>前缀数据用于分页与筛选。 - 操作步骤:① 打开「我的待审批」页,按状态筛选、翻页;② 打开「我发起的」页,按状态筛选、翻页; ③ 记录未处理计数(红点);④ 处理掉一条待办后刷新两页与计数。
- 预期结果:两个列表均只返回与当前登录身份相关的数据(他人数据不出现); 分页参数生效且
limit超过APPROVAL_LIST_PAGE_SIZE_MAX=100被拒或截断到上限; 状态筛选生效;未处理计数与待办列表中pending条数一致;④ 处理后计数减一、待办列表即时刷新。 - 证据采集:两个列表的接口响应 JSON(含
total)与页面截图;GET /api/orders/pending/count响应 (evidence/logs/g7-04-lists.json、evidence/ui/g7-04-*.png)。 - 对应:PRD FR-17 · design §10.3 · F04 / F05
G7-05 未知字段类型前端降级不白屏
- 分层:底线(门禁 #7 · F02)
- 前置条件:可自建一份
form_schema含type为未知值的模板(模拟"模板来自更新版本的后端")。 - 操作步骤:① 用该模板建单并打开详情页;② 观察该字段的渲染与页面其余部分。
- 预期结果:未知类型字段渲染为只读文本 + 提示,不白屏、不抛前端异常;页面其余字段与节点区正常渲染; 该字段
editable = false。 - 证据采集:页面截图与浏览器控制台(无未捕获异常);详情响应 JSON(
evidence/ui/g7-05-*.png)。 - 对应:PRD FR-15 · design §14.1
4.7 底线补充(PRD §8 点名、父任务门禁表未单列门禁)
S01 并发:同一会签节点两人同时通过,节点只流转一次
- 分层:底线(PRD §8 点名 · B09)
- 前置条件:自建模板
tc-s01-<rand>,单节点countersign(mode = 'all')或两节点串联; 两名审批人均为当前节点待处理人;并发脚本就绪(evidence/scripts/s01-concurrent-approve.ts)。 - 操作步骤:① 走一单到该节点
active;② 脚本在同一毫秒窗口内由两名审批人同时调POST /api/orders/:id/actions(action = 'approve',各自带自己的nodeId),重复 ≥20 轮; ③ 每轮记录两个响应的状态码与节点seq;④ 查approval_nodes(UNIQUE (order_id, seq))与approval_actions。 - 预期结果:每轮节点只流转一次——不存在同一
seq出现两行、也不存在跳节点;approval_actions中每轮恰 2 条(两名审批人各一条),无重复决策行; 两响应的语义为「一个生效 + 一个按重放或NODE_STALE收敛」,不出现两个请求都触发流转; 20 轮全部满足(整单一把锁FOR UPDATE串行化的直接结果,design §9.2)。 - 证据采集:脚本与原始输出(每轮两响应的状态码与耗时、
evidence/logs/s01-concurrency.json);approval_nodes/approval_actions断言输出。 - 对应:PRD §6 幂等与并发 · FR-10 · design §9.2 · 上会
MTG-09
S02 幂等:重复提交同一操作不产生第二条 ApprovalAction
- 分层:底线(PRD §8 点名 · B09)
- 前置条件:单节点或两节点审批单;同一审批人账号。
- 操作步骤:① 审批人对当前节点通过一次,记录响应; ② 用完全相同的请求体(同
nodeId、同action)连发 3 次; ③ 对最后一个节点的通过请求在审批单已approved后再重放一次; ④ 查approval_actions。 - 预期结果:② 三次均
200且返回当前详情(重放语义),不写任何新记录; ③ 终态单的重放同样200(重放判断先于终态检查,design §9.2),不是409 ORDER_FINALIZED; ④approval_actions中(node_id, actor, action)对该决策恰一行; 另断言「同一nodeId的不同审批人各自操作」不被误判为重放。 - 证据采集:四次请求/响应 JSON;
approval_actions计数与部分唯一索引断言 (evidence/logs/s02-idempotency.json)。 - 对应:PRD §6 幂等 · FR-10 · design §4.2 / §9.2 / §10.1
5. 可顺延用例(门禁 #6、#8、#9)
主责任务 B10 / B11 / B13 / B15 / B16(design §15)。允许顺延至验收会前一晚; 顺延则对应门禁转下期,本文档同步把该组标记为「下期」。
5.1 门禁 #6 — 审计完整性(B11)
G6-01 一单走完后 action / audit 条数与操作数一致
- 分层:可顺延(门禁 #6 · B11)
- 前置条件:自建模板
tc-g6-01-<rand>,两节点串联(首节点single,次节点countersign all两人); 记录该单创建前的approval_actions与audit_logs基线计数。 - 操作步骤:① 创建 → 提交 → 第 1 节点通过 → 第 2 节点两人通过(另起一单改为拒绝); ② 全程记录每一次状态类操作(不含评论);③ 按
order_id查两张表;④ 另发一条评论。 - 预期结果:通过路径
approval_actions条数 = 操作数(submit1 +approve3 = 4 条), 每条(node_id, actor, action)唯一、created_at单调;audit_logs中该单相关行覆盖create_order/submit_approval/ 每个approve/reject,条数与操作数一致; ④ 评论只落approval_comments,不写approval_actions、不写audit_logs的动作码(design §12 / §7.2 表 7 注释); 拒绝路径:reject同样一条approval_actions+ 一条audit_logs。 - 证据采集:全流程请求/响应 JSON;两张表按
order_id的psql输出与计数比对 (evidence/logs/g6-01-audit.json)。 - 对应:PRD §8 第七条 · FR-12 · design §7.2 / §12
G6-02 append-only 与快照冻结的 DB 触发器兜底
分层:可顺延(门禁 #6 · B11)
前置条件:只读
psql之外,验收环境允许执行受控的直连写语句(仅用于触发拒绝,不落任何数据); 已有一张完成态审批单。操作步骤:① 直连执行
UPDATE audit_logs SET detail = '{}'::jsonb WHERE id = <任一 id>; ② 直连执行DELETE FROM approval_actions WHERE id = <任一 id>; ③ 直连执行UPDATE approval_orders SET template_snapshot = '{}'::jsonb WHERE id = <任一 id>; ④ 直连执行UPDATE approval_orders SET source_type = 'inline' WHERE id = <任一 id>; ⑤ 检索接口面:对approval_actions/audit_logs尝试PATCH/PUT/DELETE路由; ⑥ 反证(触发器是否为承重件):在单事务内BEGIN→DROP TRIGGER trg_actions_append_only ON approval_actions→ 重跑一次①的同类UPDATE→ROLLBACK(Postgres 的 DDL 可回滚,不留任何残留)→ 事务外再跑一次同类UPDATE。预期结果(两半强度不同,不得并列为同等证据):
半 判定 强度 触发器半(①—④) ① ② 报 APPEND_ONLY_VIOLATION,③ ④ 报IMMUTABLE_FIELD;四者均两表行数与内容零变化真实通过——这是门禁 #6 的承重断言。它证明绕过应用层直连也改不动,即"写了 update 路径也会被库拒"(design §4.1 的原话) 路由半(⑤) 接口面不存在更新与删除路径( 404 NOT_FOUND),全仓检索无对应写语句空集通过——approval_actions/audit_logs按设计就没有PATCH/PUT/DELETE路由,该断言恒成立,无法区分"被加固过"与"从来没有过这条路径";它恰好就是 design §4.1 明确宣告不充分的那一层("不是'代码里没写 update 路径'")⑥ 反证:
DROP TRIGGER后同一UPDATE成功执行(证明拒绝确实来自触发器、断言是承重的,不是恒真);ROLLBACK后同一UPDATE再次被拒(触发器恢复、无残留);报告里给出DROP TRIGGER前后的成功/失败对照。证据采集:四条
psql语句的报错原文(evidence/logs/g6-02-append-only.txt); 拒绝前后两张表的行数与md5校验;⑥ 的DROP TRIGGER前后对照输出与ROLLBACK后的残留断言; 路由检索结果;代码检索结果。 §10 该行须分半记录:触发器半 =通过,路由半 =空集通过,不得合并成一条"通过"。对应:PRD §8 第七条 · FR-12 · design §4.1 / §7.3 / §10.1
5.2 门禁 #8 — 零硬编码管理员(B13)
G8-01 全仓检索无 requireAdmin、无管理员白名单
- 分层:可顺延(门禁 #8 · B13)
- 前置条件:
nexo-approval-api主分支工作区干净。 - 操作步骤:① 在
nexo-approval-api全仓(含src//drizzle// 脚本 / 配置)检索requireAdmin、isAdmin、ADMIN_USERS、ADMIN_IDS、adminIds、superAdmin、SUPERADMIN、白名单; ② 检索硬编码用户标识常量(形如u_admin/admin@的字面量); ③ 检索X-Nexo-*之外的自定义特权头; ④ 确认权限点常量的唯一真源:5 个approval.*code 的字面量在仓内只允许出现在权限点定义文件一处, 中间件、业务模块与初始化脚本一律引常量(拼错一个 code 的表现是「接口对所有人 403」或 「初始化时多建了一个没人用的点」,两者都不报错)。 - 预期结果:① 零命中(
PERM_SUPERADMIN_USER_ID一类仅存在于初始化脚本与 perm-api 侧的引导常量不算命中, 但需在报告中列出并说明用途);② 审批平台内零硬编码用户标识;③ 无绕过 perm-api 的特权通道; 初始化脚本只做幂等的权限点与角色建立,不授予任何用户特权——只建角色、不授人(design §13); ④ 权限点字面量仅一处定义,重复即不通过。 - 证据采集:检索命令与命中结果(
evidence/logs/g8-01-grep.txt);初始化脚本片段引用。 - 对应:PRD §8 第八条 · FR-14 · design §13
G8-02 无权限管理接口被拒,有权限放行
- 分层:可顺延(门禁 #8 · B13)
- 挂载依赖(必读):本条测的是真实端点,需 B02 / B03 / B12 把
authorizeCaller挂到各自的 模块index.ts上之后才能执行——B13 的交付面是「可被挂载的中间件 + 客户端 + 权限点注册」, 挂载由各业务模块自己做(B13 的任务契约禁止其改src/routes/index.ts)。 中间件行为本身由 B13 的演示性挂载集成测试覆盖(测试文件内起一条/api/_demo/*挂上中间件, 断言 403+denyReason/ 503 / 放行三态),该证据可在模块挂载落地前先引用; 但真端点级别的本用例须等挂载完成,未挂载时记为「阻塞」而非「失败」。 - 前置条件:1 个持合法 Bearer JWT 但无任何
approval.*权限点的普通用户;1 个平台管理员账号; 上述管理路由已完成挂载。 - 操作步骤:① 以普通用户调
POST /api/templates、PATCH /api/templates/:id、POST /api/resource-types、GET /api/audit-logs、POST /api/templates/validate、GET /api/orders/:id(非参与人的单); ② 以平台管理员调同一组接口;③ 以普通用户调自己的GET /api/orders/pending与自己的审批单详情。 - 预期结果:① 均
403 FORBIDDEN且带denyReason(不是 500、不是静默放行); ② 全部按权限点判定通过(approval.template.manage/approval.resource-type.manage/approval.audit.read/approval.template.read); ③ 自己的审批单与列表不走权限点、天然可见(design §13「不走 perm-api 的两类接口」); 「合法 JWT 即放行」在审批平台不成立(无权限的合法 JWT 被拒即为反证)。 ④ 中间件语义断言(按authorizeCaller实现核对,勿只断言 403):一次请求并发判完全部 5 个点, 缺哪个点就报哪个点的denyReason——须验证三种原因可区分: 主体失活 →subject_inactive;被显式拒绝 →deny_hit;压根没授过 →no_grant。 三者处置动作完全不同,压成一句「无权限」即不算通过。 ⑤denyReason原样透传 SDK 取值,不自行造枚举;且被拒时日志里的人身份标识已脱敏, 掩码格式为前 8 字符 +***(即id.slice(0,8) + '***'), 日志中出现完整external_id即为不通过。断言掩码时按「前 8 字符 + 三个星号」比对,不要按尖括号占位符
<前 8 位>***的字面形状断言—— 后者只是本文档的说明写法,不是实现输出。 - 证据采集:全部请求/响应 JSON(
evidence/logs/g8-02-authz.json)。 - 对应:PRD §8 第八条 · FR-14 · design §13 / §10.1
G8-03 perm-api 停掉时管理接口拒绝、审批操作正常
- 分层:可顺延(门禁 #8 · B13)
- 挂载依赖(必读):同
G8-02——管理接口一侧须待 B02 / B03 / B12 完成authorizeCaller挂载; 未挂载时记为「阻塞」。但审批操作一侧(步骤③)不依赖挂载,可先单独执行—— 它恰恰是要证明「审批链路不经过 perm-api」,与挂载与否无关。 - 前置条件:一张
in_progress审批单,当前节点审批人待处理;perm-api 可临时停掉(或指向不可达地址); 管理路由已完成挂载(仅 ② 需要)。 - 操作步骤:① 停掉 perm-api;② 调管理接口(
POST /api/templates、GET /api/audit-logs); ③ 调审批操作(POST /api/orders/:id/submit于另一张pending单、POST /api/orders/:id/actions、POST /api/orders/:id/withdraw);④ 恢复 perm-api 后重跑 ②。 - 预期结果:② 均
503 PERM_UNAVAILABLE(fail-closed); 必须是 503 而非 403——实现里「问不到」先于「有没有」判定:perm-api 不可达时 5 个点会同时返回denyReason: 'exception',若先走「有没有」分支,用户会看到 403「你没权限」而事实是权限平台挂了, 会照着提示去申请一个自己本就持有的权限。故断言503+PERM_UNAVAILABLE,断言到 403 即为不通过; 边界(易漏,必测):5 个点中任意一个是exception即整体 503, 即使本端点要求的那一个点恰好返回allow: true也不得放行——权限平台不可达时不许任何管理接口凭半份答案通过。 构造方式:让 perm-api 对其中 4 个点正常响应、对另 1 个点超时(或先全量正常取得 allow,再停服重放并断言仍 503); 若实现只在「被要求的点」为exception时才 503,本条即为不通过; ③ 审批操作全部正常(提交、通过/拒绝、撤回均按业务规则生效,状态正确流转)—— 这是把审批链路与权限平台解耦的直接收益(design §13 / §17); ④ 恢复后管理接口回归正常(反证 ② 不是恒拒)。 - 证据采集:停服前后各接口的请求/响应 JSON;服务端错误日志(脱敏); 审批单状态流转的
psql断言(evidence/logs/g8-03-perm-down.json)。 - 对应:PRD §8 第八条 · FR-14 · design §13 / §17
G8-04 详情越权:非参与人须持 approval.order.read.all
- 分层:可顺延(门禁 #8 · B13)
- 前置条件:一张与测试账号无关的审批单;一个无
approval.order.read.all的普通用户;一个持该权限的观察者。 - 操作步骤:① 以无关普通用户按 ID 遍历
GET /api/orders/:id; ② 以持approval.order.read.all的账号调同一接口; ③ 以该单的提交人与任一节点审批人(含已处理、待处理、未轮到)分别调用。 - 预期结果:①
403 FORBIDDEN(不设这条,任意登录用户按 ID 遍历就能读全部审批单 payload); ②200且viewerRole = 'observer';③ 参与人均200,viewerRole分别为submitter/approver;viewerRole由后端判定,前端不推断(design §10.4)。 - 证据采集:全部响应 JSON(
evidence/logs/g8-04-detail-scope.json)。 - 对应:PRD FR-14 · design §10.4 / §13
5.3 门禁 #9 — 存量接入债还清(B15 / B16)
G9-01 管理接口不再「合法 JWT 即放行」
分层:可顺延(门禁 #9 · B15 / B16)
前置条件:两个服务的 test 实例已部署接入 SDK 鉴权客户端; SDK 依赖已升到
0.3.0-alpha.1(nexo-auth/nexo-im-api当前锁在0.1.0,该版本无createPermissionCheckClient,不升级则本条无法执行,见 §2);准备 1 个持合法 Bearer JWT 但无对应管理权限的账号, 与 1 个持权限的账号。被测面(两侧不同,实测确认后固化,勿照抄对方):
服务 管理侧接口 是否需加判定 nexo-account(B15)POST /api/users(开户)、GET /api/users(花名册)、GET /api/users/{id}(详情)、PATCH /api/users/{id}(改资料)、POST /api/users/{id}/tickets(重发激活/重置链接)、POST /api/users/{id}/disable、POST /api/users/{id}/enable、POST /api/users/batch-import(CSV 导入)是,共 8 条 nexo-im-api(B16)不存在独立管理侧接口。全部 Bearer 面均为「本人/本团队」自服务: /me、/users(通讯录,普通用户页面)、/conversations*、/messages/{id}/recall(仅本人发送)、/rtc/devices(不含调用方自己)、/ws否,交付机制本身、挂 0 条路由,见下方判定 im-api 的口径必须写清:
/users是通讯录(nexo-im-pc的users()直接消费),不是管理接口; 会话、消息、撤回、/ws一律自服务且被 issue #85 的实现约束明确禁止改造。给其中任何一条加 perm-api 判定 都会打断通讯录或消息链路。因此 B16 的交付面是鉴权机制 + 回归证明,不是「给某组端点加门」: 机制(中间件 / 客户端 / 权限点定义)必须存在且有测试覆盖,但挂载点数为 0。 核验判据是挂载点而非「有没有写中间件」——写了但没挂是正确形态, 挂了任一条自助端点才是缺陷(grep -rn authz src/routes/index.ts src/routes/*/routes.ts src/routes/*/index.ts应零命中)。操作步骤:① 以无权限账号调用
nexo-account上表 8 条管理接口; ② 以持权限账号调用同一组接口;③ 以无权限账号调用nexo-im-api全部 Bearer 面,断言行为与改造前一致(不加判定); ④ 断言两侧的拒绝语义与 SDK 契约一致。预期结果:①
nexo-account8 条管理接口均403 FORBIDDEN(附denyReason)——该侧「合法 JWT 即放行」不再成立; ② 持权限账号全部成功(反证 ① 不是恒拒); ③nexo-im-api全部 Bearer 面不引入 perm-api 判定(无新增403、无新增延迟), perm-api 不可达时这些接口照常工作——im-api 侧的偿还判定是「管道就位且不误伤自服务面」,不是「新增拒绝」; ④ 两侧均经nexo-backend-sdk鉴权客户端;nexo-account侧 perm-api 不可达时 fail-closed(PERM_UNAVAILABLE)。 ⑤ 边界:nexo-account的用户自助接口(/api/users/me)与认证流程本身(/login/token/authorizeJWKS/internal/*) 不在判定面内,改造前后行为一致(见G9-02)。 ⑥ 本条 im-api 侧记为「空集通过」,不记为普通「通过」:该服务不存在管理侧接口,故"管理接口不再合法 JWT 即放行" 这一判定在 im-api 侧无被测对象——被满足的是「不新增耦合」而非「接口被加固」。 预跑时必须在evidence/logs/g9-01-im-api.json与 §10 该行备注里写明「被测面为空的原因」, 禁止把空集通过与nexo-account的真实通过合并成一行结论(两者在结果表里长得一样,不标注会被误读为 「im-api 也有一个管理接口被加固了」)。证据采集:两侧请求/响应 JSON(
evidence/logs/g9-01-account.json、g9-01-im-api.json); 两侧代码检索结果(无requireAdmin/ 无白名单常量);nexo-im-api全量路由清单与逐条「是否加判定」的判定表。对应:PRD §8 第九条 · FR-19 · nexo-auth#83 · nexo-im-api#85 · design §16 第 4 条
G9-02 登录链路回归无行为变化
- 分层:可顺延(门禁 #9 · B15)
- 前置条件:改造前的登录链路基线证据(0920 / 0913 归档)可取;
nexo-account与 IM 已就绪。 - 操作步骤:① 走完整登录链路:H1 派生 → 登录 → 授权码 + PKCE → 兑换令牌 → JWKS 本地验签; ② 用签发令牌访问 IM 与审批门户;③ 断言凭据与令牌载荷形状。
- 预期结果:链路各步响应形状与改造前一致;令牌可被资源服务本地验签通过;令牌载荷字段(
sub/exp/iss)无变化; 登录、刷新、登出行为与改造前逐项一致——管理接口的鉴权改造不得影响登录链路。 - 证据采集:链路各步请求/响应 JSON(脱敏);改造前后对照表(
evidence/logs/g9-02-login-regression.json)。 - 对应:PRD §8 第九条 · FR-19
G9-03 消息链路回归无行为变化
- 分层:可顺延(门禁 #9 · B16)
- 前置条件:≥2 个测试账号可登录 IM 桌面端或网页端;改造前的消息链路基线可取;perm-api 可临时停掉。
- 操作步骤:① 建立 WebSocket 连接;② 会话列表与历史消息拉取;③ 发送与接收文本消息;④ 撤回、未读计数、presence; ⑤ 断言按需拉取用户资料路径(
GET /internal/users?ids=...与通讯录/users); ⑥ 停掉 perm-api,重复 ①②③④。 - 预期结果:连接建立与重连正常;会话列表、历史消息、收发消息、撤回、未读数、presence 与改造前一致; 按需拉取用户资料与通讯录行为不变; ⑥ perm-api 停掉时消息收发与 WebSocket 全部正常(nexo-im-api#85 的验收标准点名项)—— 消息链路不因权限平台故障而中断,也不被其拖慢;改造前后逐项对照无行为变化。
- 证据采集:关键接口响应 JSON 与 WebSocket 帧记录;改造前后对照表;perm-api 停机期间的消息链路证据 (
evidence/logs/g9-03-im-regression.json)。 - 对应:PRD §8 第九条 · FR-19 · nexo-im-api#85
5.4 顺延补充(PRD §8 点名、父任务门禁表未单列门禁)
S03 撤回与业务方取消两条终态区分
- 分层:可顺延(PRD §8 点名 · B10)
- 前置条件:两张
in_progress审批单(多节点串联,当前节点非首节点),一张由发起方撤回、一张由业务方取消; HMAC 内部通道可用(取消走/internal/*)。 - 操作步骤:① 发起方调
POST /api/orders/:id/withdraw;② 业务方以 HMAC 身份调POST /internal/orders/:id/cancel; ③ 分别查审批单、当前节点、后续节点、全部审批人的状态;④ 对两张终态单重复调用撤回与取消。 - 预期结果:① 审批单
withdrawn、② 审批单cancelled——两条终态严格区分(PRD §4.6); 两者均执行同一段级联逻辑:当前节点与后续全部节点置cancelled,未处理审批人置cancelled;approval_actions分别落withdraw/cancel各一条(node_id为空,design §7.2); ④ 终态单的cancel幂等返回当前状态、withdraw返回409 ORDER_FINALIZED; 另断言「非发起方调withdraw」被拒403 FORBIDDEN。 - 证据采集:四次请求/响应 JSON;节点与审批人状态输出(
evidence/logs/s03-withdraw-cancel.json)。 - 对应:PRD §8 第六条 · FR-10 / FR-11 · design §9.2 / §10.2 / §10.3
6. 门禁 #10 — 预跑归档就绪
G10-01 证据目录齐备且与用例编号一一对应
- 分层:底线(门禁 #10 · 本任务)
- 前置条件:09-26 预跑已执行完毕。
- 操作步骤:① 列出
evidence/scripts/、evidence/logs/、evidence/ui/全部文件; ② 与 §1.2 用例清单逐条对账;③ 检查脱敏。 - 预期结果:§4—§6 的每条用例都有对应证据文件(命名含用例编号); 接口类用例的证据是请求/响应 JSON,UI 类用例的证据是截图或录屏;
evidence/scripts/中的脚本可重复执行(幂等、自建自清); 无 HMAC 密钥、无完整 Token、无真实业务数据泄漏;tc.md §10已回填每条用例的结果、证据链接与阻塞项解除条件。 - 证据采集:
evidence/目录树输出与对账表。 - 对应:门禁 #10 · 本文档 §9 / §10
G10-02 09-27 验收会上会用例全部通过
- 分层:底线(门禁 #10 · 本任务)
- 前置条件:09-26 预跑通过;test 环境在验收会当日保持可用;§3 的 9 条已准备就绪。
- 操作步骤:按 §3 顺序执行 9 条上会用例,逐条现场判定;
N01—N03复核预跑证据。 - 预期结果:9 条零失败;三项硬阈值证据齐备且达标; 预跑中标记为阻塞的用例已在会上给出解除结论或明确转入下期(不得静默跳过)。
- 证据采集:会上记录(时间、执行人、结论);现场产生的请求/响应 JSON 与截图。
- 对应:门禁 #10
7. 三项硬阈值取数口径(N01—N03,实测不估算)
本节三条必须实测取数,不得估算;数据由 09-26 预跑阶段采集,会上只复核证据,不现场重测。 采集脚本与原始样本随证据归档(
evidence/scripts/、evidence/logs/)。 计时一律取服务端毫秒时间戳差(handler 内performance.now()),不采客户端秒表; 列表首屏额外采集浏览器侧performance口径作为补充,两者都报。 采集前须确认共享库无并发写负载(pg_stat_activity中state='active'的非 psql 连接数为 0)。
N01 审批单创建 API p99 < 1s
- 取数口径:
POST /internal/orders服务端耗时;三路各采样 ≥200 次(合计 ≥600 次), 数据集固定(自建模板 + 自建resource_type+ 固定payload),预热 50 次不计入; 报告中位数 / p50 / p95 / p99 / max,并分路报告(resource_type/template_id/inline)。 - 阈值:p99 < 1s(PRD §6)。
- 证据采集:压测脚本与原始样本 JSON(
evidence/logs/n01-create-p99.json)。 - 对应:PRD §6 · design §17
N02 审批操作 API p99 < 1s
- 取数口径:
POST /api/orders/:id/actions服务端耗时;为每个样本预置一张待处理单(避免样本间相互影响), 采样 ≥200 次,覆盖approve与reject两类;报告中位数 / p50 / p95 / p99 / max。 该窗口包含整单锁获取、判定、approval_actions与audit_logs写入。 - 阈值:p99 < 1s(PRD §6)。
- 证据采集:压测脚本与原始样本 JSON(
evidence/logs/n02-action-p99.json)。 - 对应:PRD §6 · design §9.2 / §17
N03 两个列表首屏 p99 < 2s
- 取数口径:
GET /api/orders/pending与GET /api/orders/submitted各采样 ≥200 次(第一页,limit = 20); 数据集:单个测试用户 ≥500 条待办 + ≥200 条我发起的(脚本构造,前缀tc-n03-<rand>,采完清理); 同时记录两组口径:① 接口服务端耗时;② 浏览器侧首屏(导航开始 → 列表渲染完成,performance打点)。 报告中位数 / p50 / p95 / p99 / max,并断言查询走专用索引(idx_approver_pending/idx_orders_submitter),无 JSONB 扫描。 - 阈值:两个列表首屏 p99 < 2s(PRD §6)。
- 证据采集:压测脚本与原始样本 JSON;
EXPLAIN输出(evidence/logs/n03-lists-p99.json)。 - 对应:PRD §6 · design §7.2 / §17
- 附加观测(不计入门禁三项):详情页首屏 < 2s(PRD §6)随
G7-01录屏顺带记录首屏打点。
8. 需求追踪矩阵
| 需求 | 覆盖用例 |
|---|---|
| FR-1 模板管理 | G1-02、G8-02 |
| FR-2 模板配置强校验 | G1-02、G3-03(threshold 边界) |
| FR-3 资源类型注册与绑定 | G1-01、G8-02 |
| FR-4 三种发起方式同构装配 | G1-01、G1-02 |
| FR-5 第一阶段槽位预填 | G1-01、G1-03 |
| FR-6 第二阶段槽位解析与校验 | G2-03、G5-01、G5-02 |
| FR-7 条件分支求值与节点实例化 | G2-01、G2-02、G2-03 |
| FR-8 角色审批人解析与快照 | G1-01、G3-04、G5-03 |
| FR-9 判定引擎三策略 | G3-01—G3-04、G4-01、G4-02 |
| FR-10 审批操作与三层状态机 | G4-01、S01、S02、S03 |
| FR-11 业务方取消 | S03 |
| FR-12 审计日志 append-only | G6-01、G6-02 |
| FR-13 查询与列表 | G7-04、G8-04 |
| FR-14 平台自身接口鉴权 | G8-01—G8-04 |
| FR-15 动态表单渲染器与读写矩阵 | G7-02、G7-03、G7-05 |
| FR-16 审批详情页 | G7-01、G7-02 |
| FR-17 待办与我发起的 | G7-04 |
| FR-19 存量服务接入 SDK 鉴权 | G9-01—G9-03 |
| 非功能:性能硬阈值 | N01—N03 |
| 非功能:幂等 | S02 |
| 非功能:并发(整单一把锁) | S01 |
| 非功能:解耦(perm-api 停机) | G8-03、G5-03 |
9. 证据目录约定
evidence/scripts/:造数、并发(S01)、压测(N01—N03)、校验矩阵(G2-03)脚本,可重复执行、自建自清evidence/logs/:结构化取数(JSON / 纯文本),文件名按用例编号(如g1-01-three-paths.json、n01-create-p99.json)evidence/ui/:UI 类用例的截图与录屏(如g7-01-portal-closed-loop.mp4、g7-02-submitter.png)- 每条用例的证据文件命名必须包含用例编号,与 §1.2 一一对应(
G10-01按此对账) - 敏感信息脱敏:HMAC 密钥不入库,Bearer Token 只留前 8 位,业务 payload 用测试值
- 截图只作 UI 类用例的证据;接口类用例一律以请求/响应 JSON 为准,不接受截图代替
10. 预跑结果(T01 于 09-26 回填)
预跑报告见
tc-evidence-preflight.md(预跑时产出)。本节在预跑完成后回填逐条结论、证据链接与阻塞项解除条件; 预跑前保持空白,不得预先填写结论。
| 编号 | 分层 | 结果 | 证据 | 备注 |
|---|---|---|---|---|
G1-01 | 底线 | 通过(装配分叉点代码检索(tc.md §4.1 的证据采集项)、创建阶段 + 提交阶段(三路逐字段比对、节点与审批人表比对、装配分叉点)) | g1-01-assembly-branch.json、g1-01-three-paths.json | — |
G1-02 | 底线 | 通过 | g1-02-mutual-exclusion.json | — |
G1-03 | 底线 | 通过 | g1-01-three-paths.json | — |
G2-01 | 底线 | 通过(端到端节点序列落库(is_urgent=true)) | g2-branches.json | — |
G2-02 | 底线 | 通过(端到端节点序列落库(is_urgent=false,node_emergency 零行)) | g2-branches.json | — |
G2-03 | 底线 | 通过(步骤①八种 operator 矩阵 + 短路顺序(纯函数)、步骤①矩阵 + 步骤②③端到端两单) | g2-03-operators.json | — |
G3-01 | 底线 | 通过(判定逻辑(穷举 64 组合)、端到端状态流转(提交物化 + 逐步审批)) | g3-g4-decision-engine.json | — |
G3-02 | 底线 | 通过(判定逻辑(穷举 64 组合)) | g3-g4-decision-engine.json | — |
G3-03 | 底线 | 通过(判定逻辑(穷举 64 组合;C08 threshold 校验未验)、端到端 ratio 状态流转) | g3-g4-decision-engine.json | — |
G3-04 | 底线 | 通过(判定逻辑(否决优先于一切 mode)、端到端否决(其余人先通过、否决者拒绝 → 立即 rejected)) | g3-g4-decision-engine.json | — |
G4-01 | 底线 | 通过(判定逻辑(穷举 64 组合)) | g3-g4-decision-engine.json | — |
G4-02 | 底线 | 通过(判定逻辑(穷举 64 组合)) | g3-g4-decision-engine.json | — |
G5-01 | 底线 | 通过(仅步骤③审批人为空(APPROVER_EMPTY)、步骤①②③三条校验(错误码 + detail 载荷)+ 端到端四表零行回滚) | g5-slot-validation.json | — |
G5-02 | 底线 | 通过(纯函数层(resolveSlots 三层优先级 / 整值替换 / 类型保留)、端到端 resolved_slots 落库与 defaults 回落) | g5-slot-validation.json | — |
G5-03 | 底线 | 通过(perm-api 不可达 → PERM_UNAVAILABLE(fail-closed)、perm-api 不可达 fail-closed + 端到端四表零行) | g5-slot-validation.json | — |
G7-01 | 底线 | 阻塞(UI 全链路) | blocker-console-absent.json | 阻塞:nexo-approval-console 仓库为空(三个分支各仅 README) |
G7-02 | 底线 | 阻塞(UI 侧、UI 侧(页面截图 / 九格页面走查));通过(接口侧九格 editable 矩阵) | blocker-console-absent.json、g7-02-editable-matrix.json | 阻塞:nexo-approval-console 仓库为空 |
G7-03 | 底线 | 阻塞(UI 侧(DOM 改写后被过滤)) | blocker-console-absent.json | 阻塞:nexo-approval-console 仓库为空 |
G7-04 | 底线 | 阻塞(UI 侧(列表页与红点)) | blocker-console-absent.json | 阻塞:nexo-approval-console 仓库为空(接口侧已通过) |
G7-05 | 底线 | 阻塞(UI 侧(未知字段类型降级)) | blocker-console-absent.json | 阻塞:nexo-approval-console 仓库为空 |
S01 | 底线 | 通过(20 轮并发(前置状态由真实提交链路产生)) | s01-concurrency.json | — |
S02 | 底线 | 通过(幂等重放(前置状态由真实提交链路产生)) | s02-idempotency.json | — |
G6-01 | 可顺延 | 通过(审计完整性(整条动作序列含 submit 均由生产代码写入)) | g6-01-audit.json | — |
G6-02 | 可顺延 | 通过(触发器半(①②③④ + ⑥ 反证):直连 UPDATE/DELETE 被库拒绝);空集通过(路由半(⑤):无 PATCH/PUT/DELETE 路由,断言恒成立,不构成证据) | g6-02-append-only.json | 空集通过:路由半(⑤):无 PATCH/PUT/DELETE 路由,断言恒成立,不构成证据(须写明原因,勿与真实通过合并) |
G8-01 | 可顺延 | 通过(全仓静态检索(零硬编码管理员 / 权限点字面量唯一真源 / 初始化脚本不授人)) | g8-01-grep.json | — |
G8-02 | 可顺延 | 通过(真端点 403+denyReason / 管理员放行 / 三种 denyReason 可区分 / 掩码格式) | g8-02-authz.json | — |
G8-03 | 可顺延 | 通过(管理接口 503 非 403 / 边界半(进程内直测)/ 审批链路解耦(含真实审批执行)/ 恢复后回归) | g8-03-perm-down.json | — |
G8-04 | 可顺延 | 通过(非参与人 403 / 观察者 observer / 提交人 submitter / 节点审批人 approver) | g8-04-detail-scope.json | — |
G9-01 | 可顺延 | 通过(nexo-account 侧(B15):8 条管理接口无权限 403 / 有权限放行);空集通过(nexo-im-api 侧(B16):被测面为空——该服务不存在管理侧接口(会话管理已迁至认证中心),故「管理接口不再合法 JWT 即放行」无被测对象;被满足的是「不新增耦合」而非「接口被加固」) | g9-01-account.json、g9-01-im-api.json | 空集通过:nexo-im-api 侧(B16):被测面为空——该服务不存在管理侧接口(会话管理已迁至认证中心),故「管理接口不再合法 JWT 即放行」无被测对象;被满足的是「不新增耦合」而非「接口被加固」(须写明原因,勿与真实通过合并) |
G9-02 | 可顺延 | 通过(登录链路真实链路(H1 派生 → 登录 → 授权码 + PKCE → 换票 → JWKS 本地验签)) | g9-02-login-regression.json | — |
G9-03 | 可顺延 | 通过(消息链路回归(静态半段 + 动态半段;含 perm-api 不可达时的对照)) | g9-03-im-regression.json | — |
S03 | 可顺延 | 通过(两条终态级联(前置状态由真实提交链路产生)) | s03-withdraw-cancel.json | — |
N01 | 底线 | 通过(三路各 200 次采样(客户端观测墙钟;平台不暴露服务端计时,见文件头口径说明)) | n01-create-p99.json | — |
N02 | 底线 | 通过(200 次采样(approve/reject 各 100);前置单由真实提交链路产生,被测窗口为整单锁+判定+两表写入) | n02-action-p99.json | — |
N03 | 底线 | 通过(两列表各 200 次采样 + EXPLAIN 索引断言(走专用索引、无 JSONB 扫描)) | n03-lists-p99.json | — |
G10-01 | 底线 | 通过(证据目录齐备、命名含编号、格式合规、脱敏、脚本可重复执行) | g10-01-evidence-inventory.json | — |
G10-02 | 底线 | 阻塞(会上执行 9 条上会用例) | g10-01-evidence-inventory.json | 阻塞:MTG-07 / MTG-08 依赖的门户不存在(nexo-approval-console 仓库为空);MTG-01…MTG-06、MTG-09 的前置已具备 |