Files
group_xinghuo_jinrong/scripts/dev/risk_gap_e2e_smoke.py
T

400 lines
21 KiB
Python
Raw Normal View History

"""风控线端到端补测(真实 HTTP · RK7–RK8)。
**为什么另起脚本而不扩 `risk_e2e_smoke.py`:** 那支是 Part C 的**回归基线**,其价值在于
「退出码仍为 0」。RK8 要做的是**修复前/后浏览器结果对照**,天然会产出差异记录;
把它塞进基线脚本会让「真回归退化」与「补测发现」不可区分。故基线脚本**一个字都不动**。
盘点的两个结论决定了本脚本只做**补漏**而非铺开:
1. 风控线**后端 4 条路由已被 `risk_e2e_smoke.py` 全覆盖**(R1 鉴权/R2 列表与分页/
R3 处置状态机/R4 适当性/R5 AML/R6 模拟交易),其中已含 `page_size=101→422`、
`handler_result 非法→422`、`重复处置→409` 等边界。→ 本脚本**只补它没测的面**,
不重复已有断言(重复不会增加证据,只会让报告变长)。
2. 本线真正的缺口在**跨接口自洽**与**未对账的边界**:
- `page=0` 下界(R2 只测了 `page_size` 上界)
- 分页「不重不漏」(page1 ∩ page2 = ∅)
- 同一响应内 `stats` 与 `total` 自洽
- 处置动作与 `stats` 的**前后差值**(跨接口一致性 —— 只测单接口的话,
「处置了但统计没动」这类缺陷永远测不出来)
- 未知资源在**归属校验阶段**与**判定阶段**的两种语义分裂
(`app/api/risk.py:134` 实测:未知**客户** → 404;未知**产品** → 200 结构化 not_found)
**RK8** 是文件级对照:把 `10-risk-pages.mjs` 的本轮产物与上一轮基线
`results-10-risk.json` 按**用例 id** 对齐,专盯 **PASS→FAIL** ——
那正是原来被 `03-risk.mjs:23` 恒真断言(F-01)掩盖的真缺陷。
用法:
python scripts/dev/risk_gap_e2e_smoke.py
python scripts/dev/risk_gap_e2e_smoke.py --only RK7
python scripts/dev/risk_gap_e2e_smoke.py --report docs/.../_raw/rk-gap.md
退出码:0 全过 / 1 有 FAIL / 2 前置自检失败。
"""
from __future__ import annotations
import argparse
import json
import sys
import traceback
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import customer_trade_e2e_smoke as e2e # noqa: E402
from customer_trade_e2e_smoke import ( # noqa: E402
FAIL,
INFO,
PASS,
SKIP,
Ctx,
bad,
body,
data_of,
db_query,
err_code,
expect_status,
expect_true,
info,
login,
ok,
skip,
)
OFFICER = "STAFF-30001" # risk_officer + risk_demo
MANAGER = "STAFF-31001" # risk_manager
COMPLIANCE = "STAFF-40001" # compliance
ADVISOR = "STAFF-10086" # advisor(需归属查询的对照角色)
CUSTOMER = "CUST-9527" # customer(self 域对照角色)
UNKNOWN_CUSTOMER = "CUST-000000" # 格式合法但库中不存在
KNOWN_CUSTOMER = "CUST-1001" # 库中确实存在
KNOWN_PRODUCT = "PROD-110022" # 库中确实存在
UNKNOWN_PRODUCT = "PROD-DOES-NOT-EXIST"
REQUIRED_PATHS = {
"/api/risk/alerts",
"/api/risk/alerts/{alert_id}/handle",
"/api/risk/suitability/check",
"/api/risk/aml/scan",
}
#: 上一轮的浏览器基线产物(仓库外,只读引用;本轮不复制进仓库,避免制造第二份事实源)
BASELINE_10 = Path("C:/Users/Windows/e2e-jinrong/results-10-risk.json")
#: 本轮 `10-risk-pages.mjs` 的产物(由 `_raw/` 下的 env 指定输出目录写出)
CURRENT_10 = (
Path(__file__).resolve().parents[2]
/ "docs" / "memory" / "tests" / "2026-09-13-risk-e2e" / "_raw" / "results-10-risk.json"
)
def risk_h(base: dict) -> dict:
"""风控线请求头:Bearer + **必需的** X-Agent-Type(`risk.py` 走 `get_auth_context`)。"""
return {**base, "X-Agent-Type": "risk"}
def preflight(ctx: Ctx, base_url: str) -> bool:
print(f"== 风控线端到端补测 @ {base_url} ==\n")
failures: list[str] = []
try:
r = ctx.client.get("/api/ready")
print(f" /api/ready -> HTTP {r.status_code}")
if r.status_code != 200:
failures.append(f"/api/ready HTTP {r.status_code}")
except Exception as exc: # noqa: BLE001
failures.append(f"后端不可达 /api/ready: {exc}")
try:
paths = set(ctx.client.get("/openapi.json").json().get("paths", {}))
missing = sorted(REQUIRED_PATHS - paths)
print(f" openapi 路径总数 {len(paths)}(基线 66)· 风控线必需路径缺 {len(missing)}")
failures.extend(f"openapi 缺路径 {p}(后端跑的是旧进程?)" for p in missing)
except Exception as exc: # noqa: BLE001
failures.append(f"取 openapi 失败: {exc}")
# ⚠️ 客户账号必须显式 `token_type="customer"`:默认的 `"staff"` 签不出 `customer_id`,
# self 域判定会失败(实测:客户查自己也得 403 NOT_OWNER)。与 GX 包同一陷阱。
for key, actor, tt in (("officer", OFFICER, "staff"),
("manager", MANAGER, "staff"),
("compliance", COMPLIANCE, "staff"),
("advisor", ADVISOR, "staff"),
("customer", CUSTOMER, "customer")):
try:
ctx.h[key] = login(ctx, actor, tt)
print(f" 登录 {actor}(token_type={tt})-> ok")
except Exception as exc: # noqa: BLE001
failures.append(f"登录 {actor} 失败: {exc}")
if failures:
print("\n前置自检未通过:")
for f in failures:
print(f" - {f}")
return not failures
# --------------------------------------------------------------------------
# RK7 · 后端补漏(只补 `risk_e2e_smoke.py` 未覆盖的面)
# --------------------------------------------------------------------------
def rk7(ctx: Ctx) -> None:
G = "RK7"
h = risk_h(ctx.h["officer"])
path = "/api/risk/alerts"
# ── 7-01 分页**下界**(R2 只测了 page_size 上界)──
r = ctx.client.get(path, headers=h, params={"page": 0})
expect_true(G, "7-01 page=0 → 422(Query(ge=1) 下界)",
r.status_code == 422, "HTTP 422 REQUEST_VALIDATION_FAILED",
f"HTTP {r.status_code} {err_code(r)}")
r = ctx.client.get(path, headers=h, params={"page": -3})
expect_true(G, "7-01b page=-3 → 422(负数同样越界)",
r.status_code == 422, "HTTP 422",
f"HTTP {r.status_code} {err_code(r)}")
# ── 7-02 远超末页:不得报错,且分页元数据要自洽 ──
r_first = ctx.client.get(path, headers=h, params={"page": 1, "page_size": 5})
d_first = body(r_first) if r_first.status_code == 200 else {}
total = d_first.get("total")
r_far = ctx.client.get(path, headers=h, params={"page": 999999, "page_size": 5})
if expect_status(G, "7-02 超末页 page=999999 → 200(不报错)", r_far, 200):
d_far = body(r_far)
expect_true(G, "7-02a 超末页 items 为空列表",
d_far.get("items") == [], "items=[]", f"items={d_far.get('items')!r}")
expect_true(G, "7-02b 超末页 total 与首页一致(分页元数据不随页码漂移)",
d_far.get("total") == total, f"total={total}", f"total={d_far.get('total')!r}")
expect_true(G, "7-02c 超末页回显 page 与请求一致",
d_far.get("page") == 999999, "page=999999", f"page={d_far.get('page')!r}")
# ── 7-03 不重不漏:相邻两页的 alert_id 交集必须为空 ──
r1 = ctx.client.get(path, headers=h, params={"page": 1, "page_size": 5})
r2 = ctx.client.get(path, headers=h, params={"page": 2, "page_size": 5})
if r1.status_code == 200 and r2.status_code == 200:
ids1 = {i.get("alert_id") for i in (body(r1).get("items") or [])}
ids2 = {i.get("alert_id") for i in (body(r2).get("items") or [])}
dup = ids1 & ids2
expect_true(G, "7-03 page1 ∩ page2 = ∅(分页不重复)",
not dup, "交集为空", f"重复 {sorted(x for x in dup if x)}")
expect_true(G, "7-03a 两页各自非空(否则上一条会因空集假过)",
len(ids1) > 0 and len(ids2) > 0, "两页均非空",
f"page1={len(ids1)} page2={len(ids2)}")
else:
skip(G, "7-03 分页不重不漏", f"取两页失败 {r1.status_code}/{r2.status_code}")
# ── 7-04 同一响应内自洽:按 status 过滤时,items 状态必须全等于该过滤值 ──
r = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
if expect_status(G, "7-04 status=pending_review 过滤生效", r, 200):
d = body(r)
items = d.get("items") or []
states = {i.get("status") for i in items}
expect_true(G, "7-04a 过滤后 items 状态集合 = {pending_review}",
states <= {"pending_review"} and len(items) > 0,
"items 全为 pending_review 且非空", f"n={len(items)} states={sorted(s for s in states if s)}")
# 同一响应内 stats 与 total 的自洽(不看库,纯接口内部一致性)
stats = d.get("stats") or {}
pr = stats.get("pending_review_count")
expect_true(G, "7-04b stats.pending_review_count 与 total 自洽(同响应内不矛盾)",
pr == d.get("total"), f"stats.pending_review_count == total == {d.get('total')}",
f"stats={pr!r} total={d.get('total')!r}")
# ── 7-05 只读对账:接口 total 与库计数一致(agent 库)──
rows = db_query("SELECT COUNT(*) FROM risk_alert WHERE status='pending_review'", "agent")
if rows and r.status_code == 200:
db_n = rows[0][0]
api_n = body(r).get("total")
expect_true(G, "7-05 接口 total 与库计数一致(pending_review)",
db_n == api_n, f"DB={db_n}", f"API total={api_n!r} / DB={db_n}")
else:
info(G, "7-05 接口 total 与库计数对账", "库查询不可用,跳过对账(不代表通过)")
# ── 7-06 处置动作 → stats 的**前后差值**(跨接口一致性)──
# 这是本组最有价值的一条:只测单接口时,「处置成功但统计没动」永远测不出来。
# 副作用说明:本用例会把 1 条 pending 预警推进为已处置;`risk_alert` 属追加型
# 审计表,按纪律**不还原**,故这是**有意的、记录在案的残留**(不新增行,只改状态)。
before = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
cand = (body(before).get("items") or []) if before.status_code == 200 else []
if not cand:
skip(G, "7-06 处置 → stats 前后差值", "取不到 pending_review 预警,无法构造")
else:
aid = cand[0].get("alert_id")
n_before = body(before).get("total")
rh = ctx.client.post(f"/api/risk/alerts/{aid}/handle", headers=h,
json={"handler_result": "confirmed_normal", "handler_comment": "E2E RK7-06 前后差值取证"})
if expect_status(G, f"7-06 处置 {aid}", rh, 200):
after = ctx.client.get(path, headers=h, params={"status": "pending_review", "page_size": 50})
n_after = body(after).get("total")
expect_true(G, "7-06a 处置后 pending_review 计数恰好 -1(前后差值,非绝对计数)",
isinstance(n_after, int) and isinstance(n_before, int) and n_before - n_after == 1,
f"{n_before} → {n_before - 1}", f"{n_before} → {n_after}")
info(G, "7-06b 处置残留登记", f"alert_id={aid} 已由 pending_review 推进为 confirmed_normal"
"(追加型审计表,按纪律不还原)")
# ── 7-07 未知资源的语义分裂(R10 边界)──
# 首跑我按源码 `risk.py:134-135`(LookupError → 404)**推断**「未知客户必 404」,
# 实测**该分支没被触发**:`assert_customer_access` 对全量读角色**先 return**、
# 根本不做存在性查询,于是未知客户直接进入判定阶段 → 200 结构化 not_found。
# 三个演示角色实测三分(详见 7-07a/b/c),**没有任何一条走到 404**。
r = ctx.client.post("/api/risk/suitability/check", headers=h,
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
if expect_status(G, "7-07 未知客户(risk_officer 全量读)→ 200 结构化返回", r, 200):
d = body(r)
expect_true(G, "7-07a officer 视图下未知客户被判 blocked(fail-closed,非静默放行)",
d.get("blocked") is True and d.get("mismatch_type") == "not_found",
"blocked=True + mismatch_type='not_found'",
f"blocked={d.get('blocked')!r} mismatch_type={d.get('mismatch_type')!r} "
f"code={d.get('block_response_code')!r}")
info(G, "7-07b `risk.py:134` 的 404 分支本轮**未被触发**",
"三个演示角色(officer/advisor/customer)打未知客户分别得 200/403/403,"
"无一走 404 —— 该分支的可达条件本轮未能构造,属**未覆盖路径**而非缺陷")
# ⚠️ `X-Agent-Type` 必须与**账号真实类型**一致,否则先在矩阵层被拦:
# advisor 账号带 `X-Agent-Type: risk` → `AUTH_403_AGENT_MISMATCH`(实测,见下条对照),
# 根本走不到归属校验。故这里用 advisor 通道,才能验证「归属查询分支」。
hr = {**ctx.h["advisor"], "X-Agent-Type": "advisor"}
r_bad_channel = ctx.client.post("/api/risk/suitability/check",
headers={**ctx.h["advisor"], "X-Agent-Type": "risk"},
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
expect_true(G, "7-07c 通道值与账号类型不符 → 矩阵层拦 AGENT_MISMATCH(早于归属校验)",
r_bad_channel.status_code == 403 and err_code(r_bad_channel) == "AUTH_403_AGENT_MISMATCH",
"HTTP 403 AUTH_403_AGENT_MISMATCH",
f"HTTP {r_bad_channel.status_code} {err_code(r_bad_channel)}")
r = ctx.client.post("/api/risk/suitability/check", headers=hr,
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
expect_true(G, "7-07d 未知客户(advisor 归属查询分支)→ 403 NOT_ASSIGNED(非 404)",
r.status_code == 403 and err_code(r) == "AUTH_403_NOT_ASSIGNED",
"HTTP 403 AUTH_403_NOT_ASSIGNED", f"HTTP {r.status_code} {err_code(r)}")
hc = {**ctx.h["customer"], "X-Agent-Type": "customer"}
r = ctx.client.post("/api/risk/suitability/check", headers=hc,
json={"customer_id": UNKNOWN_CUSTOMER, "product_id": KNOWN_PRODUCT})
expect_true(G, "7-07e 未知客户(customer self 域)→ 403 NOT_OWNER(非 404)",
r.status_code == 403 and err_code(r) == "AUTH_403_NOT_OWNER",
"HTTP 403 AUTH_403_NOT_OWNER", f"HTTP {r.status_code} {err_code(r)}")
r = ctx.client.post("/api/risk/suitability/check", headers=h,
json={"customer_id": KNOWN_CUSTOMER, "product_id": UNKNOWN_PRODUCT})
if expect_status(G, "7-08 已知客户 + 未知**产品** → 200(判定阶段结构化返回,不 404)", r, 200):
d = body(r)
expect_true(G, "7-08a 未知产品被判为 blocked 且 mismatch_type='not_found'",
d.get("blocked") is True and d.get("mismatch_type") == "not_found",
"blocked=True + mismatch_type='not_found'",
f"blocked={d.get('blocked')!r} mismatch_type={d.get('mismatch_type')!r} "
f"code={d.get('block_response_code')!r}")
info(G, "7-08b 同路由的「找不到」语义分裂(记录,非缺陷判定)",
"未知**产品**=200+blocked;未知**客户**则按角色三分(officer 200 / advisor 403 / customer 403),"
"**三者都不是 404** —— 见 7-07 组;`risk.py:134` 的 404 分支本轮未触发")
# ── 7-09 阻断类响应必须带 G-08 三要素(advice/notice)──
# `risk.py:157-161`:仅当 blocked 为真才补 advice/notice。
if r.status_code == 200:
d = body(r)
expect_true(G, "7-09 阻断响应补齐 advice/notice(G-08 三要素)",
bool(d.get("advice")) and bool(d.get("notice")),
"advice + notice 均非空",
f"advice={str(d.get('advice'))[:40]!r} notice={str(d.get('notice'))[:40]!r}")
# ── 7-10 时间字段口径(记录):接口返回的是 UTC naive 还是本地时间 ──
r = ctx.client.get(path, headers=h, params={"page_size": 1})
if r.status_code == 200:
items = body(r).get("items") or []
if items:
created = items[0].get("created_at")
info(G, "7-10 预警 created_at 口径", f"{created!r}"
"(与本地时区关系见报告;影响「近 N 分钟」类查询,R4 窗口陷阱的根源)")
# --------------------------------------------------------------------------
# RK8 · 浏览器结果对照(修复前后 PASS→FAIL 追踪)
# --------------------------------------------------------------------------
def rk8(ctx: Ctx) -> None:
"""把本轮 `10-risk-pages.mjs` 产物与上一轮基线按**用例 id** 对齐。
**专盯 PASS→FAIL**:`03-risk.mjs:23` 的恒真断言(F-01)被修掉之后,原本被它
掩盖的真缺陷会在此暴露。PASS→PASS 只计数,不逐条列(避免噪音淹没信号)。
"""
G = "RK8"
if not BASELINE_10.exists():
skip(G, "8-00 浏览器结果对照", f"基线产物不存在:{BASELINE_10}")
return
if not CURRENT_10.exists():
skip(G, "8-00 浏览器结果对照",
f"本轮产物不存在:{CURRENT_10}(须先跑 Part B 的 10-risk-pages.mjs 并指定 E2E_OUT_DIR)")
return
def load(p: Path) -> dict:
d = json.loads(p.read_text(encoding="utf-8"))
return {r.get("id"): r for r in d.get("results", []) if isinstance(r, dict)}
old, new = load(BASELINE_10), load(CURRENT_10)
info(G, "8-00 对照规模", f"基线 {len(old)} 条 / 本轮 {len(new)} 条 · 交集 {len(set(old) & set(new))} 条")
regressed = [k for k in set(old) & set(new)
if old[k].get("status") == "PASS" and new[k].get("status") != "PASS"]
fixed = [k for k in set(old) & set(new)
if old[k].get("status") != "PASS" and new[k].get("status") == "PASS"]
only_new = sorted(set(new) - set(old))
only_old = sorted(set(old) - set(new))
for k in sorted(regressed):
bad(G, f"8-01 ★PASS→FAIL★ {k} {new[k].get('name')}",
"修复恒真断言后仍应 PASS", f"{new[k].get('status')} · {str(new[k].get('actual'))[:160]}",
json.dumps({"before": old[k].get("actual"), "after": new[k].get("actual")},
ensure_ascii=False)[:400])
if not regressed:
ok(G, "8-01 无 PASS→FAIL 退化(恒真断言修复后原 PASS 项全部仍 PASS)",
"∅", f"比较 {len(set(old) & set(new))} 条交集")
info(G, "8-02 修复的旧 FAIL 条目", f"{sorted(fixed) if fixed else '无'}")
info(G, "8-03 仅本轮新增的用例", f"{only_new if only_new else '无'}")
info(G, "8-04 仅基线有的用例(本轮未复跑)", f"{only_old if only_old else '无'}")
GROUPS = {"RK7": rk7, "RK8": rk8}
ORDER = ["RK7", "RK8"]
def main() -> int:
parser = argparse.ArgumentParser(description="风控线端到端补测(RK7–RK8)")
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
parser.add_argument("--report", default=None)
parser.add_argument("--only", action="append", default=None)
parser.add_argument("--timeout", type=float, default=120.0)
args = parser.parse_args()
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass
ctx = Ctx(client=httpx.Client(base_url=args.base_url.rstrip("/"), timeout=args.timeout))
try:
if not preflight(ctx, args.base_url):
print("\n前置自检未通过 —— 未产生任何用例判定。")
return 2
selected = [g for g in ORDER if not args.only or g in args.only]
print(f"\n-- 执行分组: {', '.join(selected)} --\n")
for name in selected:
try:
GROUPS[name](ctx)
except Exception as exc: # noqa: BLE001
bad(name, f"{name}-00 分组执行异常", "分组正常跑完",
f"{type(exc).__name__}: {exc}", traceback.format_exc()[-800:])
finally:
ctx.client.close()
counts = {s: sum(1 for r in e2e.RESULTS if r.status == s) for s in (PASS, FAIL, SKIP, INFO)}
print(f"\n== 结果: {counts[PASS]} PASS / {counts[FAIL]} FAIL / "
f"{counts[SKIP]} SKIP / {counts[INFO]} INFO ==")
for r in e2e.RESULTS:
if r.status == FAIL:
print(f" - {r.group} · {r.name}: 期望 {r.expected} / 实际 {r.actual[:160]}")
if args.report:
e2e.write_report(Path(args.report))
print(f"\n报告已写入 {args.report}")
return 1 if counts[FAIL] else 0
if __name__ == "__main__":
sys.exit(main())