fault_catalog.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. """第二阶段异常场景目录(任务书 §12-§15)。
  2. 纯数据 + 应用/恢复指令:副作用由 app.py 的 fault_apply / fault_recover 执行,
  3. 本模块不连接数据库、不改任何状态,保证场景定义可单测、可审计。
  4. kind 语义:
  5. - auto 模拟器一键预置(apply 指令由模拟器执行)
  6. - guide 需测试人员在 Ai-DOP 侧人工操作(页面展示指引,模拟器只控制第三方一侧)
  7. 被 Ai-DOP 正确拒绝属于测试通过,不属于工具失败。
  8. """
  9. from __future__ import annotations
  10. # 场景报告八列(任务书 §15)
  11. FAULT_REPORT_FIELDS: list[tuple[str, str]] = [
  12. ("scenarioId", "场景"),
  13. ("simBehavior", "模拟端行为"),
  14. ("expected", "预期结果"),
  15. ("actualResponse", "实际响应"),
  16. ("aidopFlowStatus", "Ai-DOP 流水状态"),
  17. ("targetChanged", "目标数据是否变化"),
  18. ("pass", "是否符合预期"),
  19. ("note", "备注"),
  20. ]
  21. # ---------------------------------------------------------------------------
  22. # N1 方式甲 DB_SYNC(10 场景)
  23. # ---------------------------------------------------------------------------
  24. N1_SCENARIOS: list[dict] = [
  25. {"id": "N1-01", "channel": "DB_SYNC", "kind": "guide", "title": "数据库地址/端口错误",
  26. "sim": "测试人员在 Ai-DOP 侧把来源连接串改为未监听端口(如 3307)。模拟器不修改 Ai-DOP 配置。",
  27. "expect": "Ai-DOP 测连或同步失败,提示连接被拒绝/超时。",
  28. "recover": "把 Ai-DOP 侧连接串改回正确地址。"},
  29. {"id": "N1-02", "channel": "DB_SYNC", "kind": "guide", "title": "数据库用户名/密码错误",
  30. "sim": "测试人员在 Ai-DOP 侧填写错误的测试账号/密码(如 sim_user/wrong)。",
  31. "expect": "认证失败(Access denied),同步任务失败。",
  32. "recover": "改回正确测试账号。"},
  33. {"id": "N1-03", "channel": "DB_SYNC", "kind": "guide", "title": "无表权限",
  34. "sim": "测试人员在模拟库上创建受限账号(仅 SELECT 部分表或完全无权限),Ai-DOP 侧配置该账号。",
  35. "expect": "查询被拒绝(SELECT command denied)。",
  36. "recover": "Ai-DOP 侧换回有权限账号。"},
  37. {"id": "N1-04", "channel": "DB_SYNC", "kind": "auto", "title": "源表不存在",
  38. "sim": "模拟器删除源表(DROP TABLE)。测试人员在 Ai-DOP 侧保持配置不变并触发同步。",
  39. "expect": "明确报表不存在(Table ... doesn't exist),同步失败不静默。",
  40. "recover": "点击恢复:按目录样例重建表并装载样例行。",
  41. "apply": {"type": "db_drop_table"}},
  42. {"id": "N1-05", "channel": "DB_SYNC", "kind": "guide", "title": "字段不存在(错误增量列)",
  43. "sim": "测试人员在 Ai-DOP 侧把增量列配置改为不存在的列名(如 sourceUpdatedAt2)。",
  44. "expect": "明确报列不存在(Unknown column),同步失败。",
  45. "recover": "改回正确增量列名。"},
  46. {"id": "N1-06", "channel": "DB_SYNC", "kind": "auto", "title": "空业务键",
  47. "sim": "模拟器装载 bizKey 为 NULL 的行(额外插入,不清空原数据)。",
  48. "expect": "被拒绝或隔离,不静默覆盖正常数据。",
  49. "recover": "点击恢复:删除空键行并重建样例行。",
  50. "apply": {"type": "db_load_null_bizkey"}},
  51. {"id": "N1-07", "channel": "DB_SYNC", "kind": "auto", "title": "重复业务键",
  52. "sim": "模拟器重建无唯一键的表并装载两条相同 bizKey 的行。",
  53. "expect": "行为可解释(Ai-DOP 侧去重/告警),不产生脏数据。",
  54. "recover": "点击恢复:重建带唯一键的表并装载样例行。",
  55. "apply": {"type": "db_load_duplicate_bizkey"}},
  56. {"id": "N1-08", "channel": "DB_SYNC", "kind": "auto", "title": "增量列不推进",
  57. "sim": "模拟器更新一行数据但保持增量列时间不变(advance_increment=false)。",
  58. "expect": "该行不被误判为新增量;下一次正常推进后可同步。",
  59. "recover": "点击恢复:重建样例行。",
  60. "apply": {"type": "db_update_no_advance"}},
  61. {"id": "N1-09", "channel": "DB_SYNC", "kind": "auto", "title": "慢查询",
  62. "sim": "模拟器创建受控延迟视图 v_slow_{table}(每行 SLEEP 0.5 秒),并提示测试人员在 Ai-DOP 侧把 source_table_name 改为该视图名。",
  63. "expect": "Ai-DOP 读取耗时显著变长,超时行为可解释。",
  64. "recover": "Ai-DOP 侧改回原表名;点击恢复删除视图。",
  65. "apply": {"type": "db_slow_view", "sleepSeconds": 0.5}},
  66. {"id": "N1-10", "channel": "DB_SYNC", "kind": "guide", "title": "连接中断",
  67. "sim": "测试人员在同步过程中停止模拟库服务(或断开网络),Ai-DOP 侧触发同步。",
  68. "expect": "失败不记成功,重连后状态可恢复。",
  69. "recover": "重新启动模拟库服务。"},
  70. ]
  71. # ---------------------------------------------------------------------------
  72. # N2 方式乙 API_PULL(16 场景)
  73. # ---------------------------------------------------------------------------
  74. N2_SCENARIOS: list[dict] = [
  75. {"id": "N2-01", "channel": "API_PULL", "kind": "auto", "title": "401 未授权",
  76. "sim": "Mock API 返回 401(force_status=401)。",
  77. "expect": "Ai-DOP 拉取失败,流水记录失败状态,不写入数据。",
  78. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 401}}},
  79. {"id": "N2-02", "channel": "API_PULL", "kind": "auto", "title": "403 禁止",
  80. "sim": "Mock API 返回 403(force_status=403)。",
  81. "expect": "Ai-DOP 拉取失败,流水记录失败状态。",
  82. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 403}}},
  83. {"id": "N2-03", "channel": "API_PULL", "kind": "auto", "title": "404 路径不存在",
  84. "sim": "Mock API 返回 404(force_status=404)。",
  85. "expect": "Ai-DOP 拉取失败,明确提示路径不存在。",
  86. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 404}}},
  87. {"id": "N2-04", "channel": "API_PULL", "kind": "auto", "title": "429 限流",
  88. "sim": "Mock API 返回 429(force_status=429)。",
  89. "expect": "Ai-DOP 拉取失败或按限流策略重试。",
  90. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 429}}},
  91. {"id": "N2-05", "channel": "API_PULL", "kind": "auto", "title": "500 服务器错误",
  92. "sim": "Mock API 返回 500(force_status=500)。",
  93. "expect": "Ai-DOP 拉取失败,错误信息明确。",
  94. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 500}}},
  95. {"id": "N2-06", "channel": "API_PULL", "kind": "auto", "title": "503 服务不可用",
  96. "sim": "Mock API 返回 503(force_status=503)。",
  97. "expect": "Ai-DOP 拉取失败,错误信息明确。",
  98. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"force_status": 503}}},
  99. {"id": "N2-07", "channel": "API_PULL", "kind": "auto", "title": "固定延迟",
  100. "sim": "Mock API 每次响应前延迟 3 秒(delay_seconds=3)。",
  101. "expect": "Ai-DOP 响应耗时约 3 秒,请求成功。",
  102. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"delay_seconds": 3}}},
  103. {"id": "N2-08", "channel": "API_PULL", "kind": "auto", "title": "超时",
  104. "sim": "Mock API 睡眠 120 秒(timeout_seconds=120),超过 Ai-DOP 读取超时。",
  105. "expect": "Ai-DOP 侧超时终止,流水记录失败。",
  106. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"timeout_seconds": 120}}},
  107. {"id": "N2-09", "channel": "API_PULL", "kind": "auto", "title": "空响应",
  108. "sim": "Mock API 返回 200 空 body(empty_response=true)。",
  109. "expect": "Ai-DOP 解析失败并明确报错,不写入数据。",
  110. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"empty_response": True}}},
  111. {"id": "N2-10", "channel": "API_PULL", "kind": "auto", "title": "非法 JSON",
  112. "sim": "Mock API 返回截断的非法 JSON 文本(invalid_json=true)。",
  113. "expect": "Ai-DOP 解析失败并明确报错。",
  114. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"invalid_json": True}}},
  115. {"id": "N2-11", "channel": "API_PULL", "kind": "auto", "title": "错误 Content-Type",
  116. "sim": "Mock API 返回 text/html 的 Content-Type(content_type=text/html)。",
  117. "expect": "Ai-DOP 按 Content-Type 校验失败或仍按 JSON 解析,行为可解释。",
  118. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"content_type": "text/html"}}},
  119. {"id": "N2-12", "channel": "API_PULL", "kind": "auto", "title": "错误 response path",
  120. "sim": "Mock API 把数据挂到不存在的路径 data.notExist(response_path=data.notExist)。",
  121. "expect": "Ai-DOP 取不到列表,明确报错。",
  122. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"response_path": "data.notExist"}}},
  123. {"id": "N2-13", "channel": "API_PULL", "kind": "auto", "title": "缺 bizKey",
  124. "sim": "Mock API 挂载去掉 bizKey 字段的覆盖行(/api/item)。",
  125. "expect": "Ai-DOP 去重键缺失,报错或隔离,不静默写入。",
  126. "recover": "恢复默认(清除覆盖行)。", "apply": {"type": "mock_api_override", "mode": "missing_bizkey"}},
  127. {"id": "N2-14", "channel": "API_PULL", "kind": "auto", "title": "重复 bizKey",
  128. "sim": "Mock API 挂载两条相同 bizKey 的覆盖行(/api/item)。",
  129. "expect": "Ai-DOP 去重行为可解释,不产生脏数据。",
  130. "recover": "恢复默认(清除覆盖行)。", "apply": {"type": "mock_api_override", "mode": "duplicate_bizkey"}},
  131. {"id": "N2-15", "channel": "API_PULL", "kind": "auto", "title": "cursor 不推进",
  132. "sim": "Mock API 无视 cursor 返回全部行(ignore_cursor=true)。",
  133. "expect": "Ai-DOP 每次拿到同样数据,去重后无新增,行为可解释。",
  134. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"ignore_cursor": True}}},
  135. {"id": "N2-16", "channel": "API_PULL", "kind": "auto", "title": "cursor 乱序",
  136. "sim": "Mock API 不按 bizKey 排序返回(no_sort=true)。",
  137. "expect": "Ai-DOP 分页去重行为可解释,不产生脏数据。",
  138. "recover": "恢复默认。", "apply": {"type": "mock_api", "settings": {"no_sort": True}}},
  139. ]
  140. # ---------------------------------------------------------------------------
  141. # N3 方式丙 API_INBOUND(17 场景,任务书 §14 原文)
  142. # ---------------------------------------------------------------------------
  143. N3_SCENARIOS: list[dict] = [
  144. {"id": "N3-01", "channel": "API_INBOUND", "kind": "auto", "title": "缺 AccessKey",
  145. "sim": "推数请求不发送 X-Access-Key 头。",
  146. "expect": "Ai-DOP 拒绝(401/403 授权缺失),不产生流水数据。",
  147. "recover": "无需恢复(无状态注入)。", "apply": {"type": "inbound_case", "case": "missing-access-key"}},
  148. {"id": "N3-02", "channel": "API_INBOUND", "kind": "auto", "title": "错误 AccessKey",
  149. "sim": "发送未授权的 AccessKey(SIM-UNKNOWN-ACCESS-KEY,签名自洽)。",
  150. "expect": "Ai-DOP 拒绝(授权缺失/未知 AccessKey)。",
  151. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "wrong-access-key"}},
  152. {"id": "N3-03", "channel": "API_INBOUND", "kind": "auto", "title": "错误 Secret/签名",
  153. "sim": "用错误 Secret 计算签名(bad_signature=true)。",
  154. "expect": "Ai-DOP 验签失败(401/403)。",
  155. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "bad-signature"}},
  156. {"id": "N3-04", "channel": "API_INBOUND", "kind": "auto", "title": "body 摘要不一致",
  157. "sim": "发送后篡改 body(X-Content-SHA256 与实际不符)。",
  158. "expect": "Ai-DOP 摘要校验失败,明确拒绝。",
  159. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "tamper-body"}},
  160. {"id": "N3-05", "channel": "API_INBOUND", "kind": "auto", "title": "缺幂等键",
  161. "sim": "请求不发送 Idempotency-Key 头。",
  162. "expect": "Ai-DOP 拒绝(幂等键必填)。",
  163. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "missing-idem"}},
  164. {"id": "N3-06", "channel": "API_INBOUND", "kind": "auto", "title": "过期 timestamp",
  165. "sim": "X-Timestamp 设为 1 小时前。",
  166. "expect": "Ai-DOP 拒绝(时间戳超出允许窗口)。",
  167. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "expired-ts"}},
  168. {"id": "N3-07", "channel": "API_INBOUND", "kind": "auto", "title": "nonce replay",
  169. "sim": "相同 nonce 连发两个请求。",
  170. "expect": "第二个被拒绝(防重放)。",
  171. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "nonce-replay"}},
  172. {"id": "N3-08", "channel": "API_INBOUND", "kind": "auto", "title": "同幂等键同 body",
  173. "sim": "相同 Idempotency-Key 与 body 连发两次。",
  174. "expect": "第二次命中幂等返回相同结果,不重复落库。",
  175. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "same-key-replay"}},
  176. {"id": "N3-09", "channel": "API_INBOUND", "kind": "auto", "title": "同幂等键不同 body",
  177. "sim": "相同 Idempotency-Key 但 body 不同。",
  178. "expect": "Ai-DOP 拒绝(幂等键冲突)。",
  179. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "same-key-diff-body"}},
  180. {"id": "N3-10", "channel": "API_INBOUND", "kind": "auto", "title": "缺必填字段",
  181. "sim": "推数行删除契约必填字段(如 MDM_ITEM 的 Descr)。",
  182. "expect": "Ai-DOP 拒绝或隔离该行(映射/校验失败)。",
  183. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "missing-required-field"}},
  184. {"id": "N3-11", "channel": "API_INBOUND", "kind": "auto", "title": "字段类型错误",
  185. "sim": "推数行把必填字段替换为结构敌对值(嵌套对象,实测比数字更能逼近类型校验边界)。",
  186. "expect": "Ai-DOP 拒绝或隔离该行(类型校验失败);若宽容转换则在报告中记录实证。",
  187. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "wrong-field-type"}},
  188. {"id": "N3-12", "channel": "API_INBOUND", "kind": "auto", "title": "部分行拒绝",
  189. "sim": "push 信封多行中一行缺必填字段。",
  190. "expect": "坏行被拒绝,好行正常入站(或整体拒绝),行为可解释。",
  191. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "partial-reject"}},
  192. {"id": "N3-13", "channel": "API_INBOUND", "kind": "auto", "title": "stale 数据",
  193. "sim": "推数行时间字段退到 2000 年(快照场景下模拟旧数据)。",
  194. "expect": "Ai-DOP 拒绝过期数据或按 stale 策略处理。",
  195. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "stale"}},
  196. {"id": "N3-14", "channel": "API_INBOUND", "kind": "auto", "title": "未授权 entityCode",
  197. "sim": "推送到不存在/未授权的实体码 SIM_UNKNOWN_ENTITY_XX。",
  198. "expect": "Ai-DOP 返回 404 实体未注册(或 403 未授权)。",
  199. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "unknown-entity"}},
  200. {"id": "N3-15", "channel": "API_INBOUND", "kind": "auto", "title": "未启用 entity",
  201. "sim": "测试人员先在 Ai-DOP 侧关闭某实体 inbound 开关,模拟器再向该实体推送(默认 S5_INVENTORY_TXN,可填其他已注册实体码)。",
  202. "expect": "Ai-DOP 拒绝(实体未启用)。",
  203. "recover": "Ai-DOP 侧重新启用该实体。",
  204. "apply": {"type": "inbound_case", "case": "disabled-entity"}},
  205. {"id": "N3-16", "channel": "API_INBOUND", "kind": "auto", "title": "bulk 部分失败",
  206. "sim": "bulk 端点 ndjson 多行中一行缺必填字段。",
  207. "expect": "Ai-DOP 返回逐行结果,坏行计数准确。",
  208. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "bulk-partial-failure"}},
  209. {"id": "N3-17", "channel": "API_INBOUND", "kind": "auto", "title": "snapshot commit 失败",
  210. "sim": "提交不存在的 snapshotId(SNAP-NOT-EXIST-0001)。",
  211. "expect": "Ai-DOP 拒绝 commit,明确报错。",
  212. "recover": "无需恢复。", "apply": {"type": "inbound_case", "case": "snapshot-commit-failure"}},
  213. ]
  214. FAULT_SCENARIOS: list[dict] = N1_SCENARIOS + N2_SCENARIOS + N3_SCENARIOS
  215. FAULT_CHANNEL_LABELS = {
  216. "DB_SYNC": "方式甲 DB_SYNC(数据库同步)",
  217. "API_PULL": "方式乙 API_PULL(接口拉取)",
  218. "API_INBOUND": "方式丙 API_INBOUND(接口推送)",
  219. }
  220. def scenario_counts() -> dict:
  221. counts = {"DB_SYNC": 0, "API_PULL": 0, "API_INBOUND": 0, "total": 0}
  222. for s in FAULT_SCENARIOS:
  223. counts[s["channel"]] += 1
  224. counts["total"] += 1
  225. return counts
  226. def find_scenario(scenario_id: str) -> dict | None:
  227. sid = scenario_id.upper()
  228. return next((s for s in FAULT_SCENARIOS if s["id"].upper() == sid), None)