一、T-9 本体:HTTP 层 convert 端到端走通 - api/simulate.py:TradeRequest 三型字段分池(subscribe/redeem → product_id+amount; convert → from/to_product_id + qty + 可选 client_request_id)+ model_validator 分支校验; 未知类型放行给网关抛 400(保住既有 purchase → 400 断言);model_dump(exclude_none=True); PROCESSING → 202;异常捕获由 except LookupError 收窄为 except NotFoundError (原写法把 KeyError 这类编程错误静默转成 404,实测掩盖 convert 分支真实诊断)。 - gateway/trade_gateway.py:移除 convert 显式拒绝,新增 _submit_convert 分派 (只做参数映射 + 仓储装配);convert 不写 trade_request 审计(审计归 convert_service)。 - utils/response.py:错误体合入 exc.extra(TOO_MANY_LOTS 的 batch_count/max_lots); 既有 ApiError 无 extra 属性 → 老错误体逐字节不变。 - utils/trace.py + main.py:正则收敛单点定义。执行期发现 trace.py 与 main.py 各有一份 内容完全相同的白名单副本 —— S4 要防的「漂移」其实已经发生,现将常量上移 trace.py 成公开 HEADER_ID_PATTERN(同时解决 main→simulate 反向导入成环)。 二、展示位数口径修复(执行期发现 → 联网核验 → 修复 → 文档订正) 发现:同一逻辑响应两种写法 —— 首次 "53456.95" vs 幂等重放 "53456.9500",数值相等、字符串不等。 根因不是 T-7 写错,是契约缺位:§2.5 只规定「金额/份额 2 位」,净值、费率、申请份额的 回显位数根本没定义 → 实现只能 str(Decimal) 原样出网 → 位数随数据来源漂移。 修复:convert_service 新增 _q(value, unit) + _D2/_D4 规格常量作对外唯一出口 —— 金额/份额 2 位、净值/费率/份额尾差 4 位;响应 + 审计 summary + 异常日志共用该出口; 原 _s() 全部替换。首次路径幂等(除 requested_qty/actual_qty/lot[].qty 由 4 位补齐 2 位外不变)。 依据(2026-09-10 联网核验 7 家管理人公告):金额/份额「四舍五入保留至小数点后两位」; 「申请转换份额精确到小数点后两位」;净值保留 4 位第 5 位四舍五入(中欧/国泰公告由 3 位提高至 4 位); 费率以百分比 2 位表示。已知不统一:易方达 ETF 场外份额取整数位、南方基金取截断 → 取主流口径 并记入 PRD 已知差异(未来接真实 TA 需按基金合同配置化)。 三、文档订正 - PRD → v0.9.2:§2.5 拆 2.5.1 计算精度 / 2.5.2 展示位数(新增按字段分类的规格表 + 外部依据); §5.3 示例 requested_qty/actual_qty/lot_breakdown[].qty 4 位 → 2 位(原示例与 §2.5 「计算与对外展示按 2 位」自相矛盾,属漏改);字段类型约定补「位数不自由 + 两条路径须逐字节一致」。 - 架构 → v1.0.1:§1 原则 11 补「str() 前必须按 §2.5.2 量化」,无结构变更。 四、验证 - 新增 tests/test_convert_integration.py(8 条真 MySQL 端到端,CNV-TEST-/TRD-TEST- 前缀隔离): 折算与 PRD §5.3 逐项吻合、两条流水同组、持仓与批次如实变动、明细 completed + 审计、 幂等重试不产生第二组、跨主体 400、未知类型 400,以及 「首次与重放逐字段逐字节相等」+「展示位数规格」两条新闸门。 - test_trade_gateway.py +17(11 条错误码映射全表参数化 · 202 · 200 透传 · 不写 trade_request 审计)。 - test_integration_risk.py:R15 处置 —— 端到端已迁入新文件,原槽位改造为 test_invalid_type_400_and_no_new_trade_audit(改用 purchase 触发),保住「校验失败不落审计」不变量。 - pytest -q → 697 passed / 3 skipped(基线 672 +25,零回归)。 - 真库复跑:T-6 24/24 · T-7 35/35 · T-8 31/31;calc_convert_demo.py 15/15。 - 突变验证 4 组:关掉 convert 分派 → 21 条红;关掉错误体 extra 展开 → 精准 1 条; 关掉 client_request_id 正则 → 精准 1 条;关掉 _q() 展示量化 → 2 条红 (assert '50000.0000' == '50000' 直接复现原缺陷)。均已恢复。
436 lines
17 KiB
Python
436 lines
17 KiB
Python
"""trade_gateway 集成测试(B5 · FR-1:convert 400 / 阻断不落 trade / 放行贯通引擎)。
|
||
|
||
服务层直测三路径 + TestClient 验 HTTP 语义(sqlite 全套表,驱动差异由引擎层
|
||
_normalize_trades 兜底)。API 层经 monkeypatch 注入 sqlite 仓储。
|
||
"""
|
||
|
||
from datetime import datetime, timedelta
|
||
from decimal import Decimal
|
||
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import text
|
||
|
||
from _ddl import create_sqlite_engine, seed_suitability_matrix
|
||
from app.api.simulate import router as simulate_router
|
||
from app.gateway import trade_gateway as tg
|
||
from app.gateway.gateway_repository import GatewayRepository
|
||
from app.gateway.trade_gateway import UnsupportedTradeType, submit_trade
|
||
from app.repository.core_ro import CoreReadOnlyRepository
|
||
from app.repository.risk_repository import RiskRepository
|
||
from app.service.convert.convert_service import PROCESSING
|
||
from app.service.convert.errors import (
|
||
BelowMinQty,
|
||
CrossEntityNotSupported,
|
||
FeeRuleMissing,
|
||
IdempotencyUnavailable,
|
||
InsufficientShares,
|
||
LotConflict,
|
||
NavNotReady,
|
||
ProductNotRedeemable,
|
||
ProductNotSubscribable,
|
||
SameProduct,
|
||
TooManyLots,
|
||
)
|
||
from app.service.risk import alert_service
|
||
from app.service.risk.profile_l3 import AML_PENDING_TAG
|
||
from app.utils.response import register_error_handlers
|
||
|
||
|
||
class FakePublisher:
|
||
def __init__(self):
|
||
self.messages = []
|
||
self.deletes = []
|
||
|
||
def publish(self, channel, payload):
|
||
self.messages.append((channel, payload))
|
||
|
||
def delete(self, *keys):
|
||
self.deletes.append(keys)
|
||
|
||
|
||
@pytest.fixture()
|
||
def env():
|
||
engine = create_sqlite_engine() # DDL 单一事实源(B4 评审 P3-12)
|
||
seed_suitability_matrix(engine) # AL-05:check_suitability 以矩阵表为 L0 权威
|
||
with engine.begin() as conn:
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer (customer_id, display_name, age, is_active) VALUES"
|
||
" ('CUST-1001', '客户·王**', 28, 1), ('CUST-3001', '客户·孙**', 45, 1)"
|
||
)
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_customer_risk (customer_id, risk_code, evaluated_at, expires_at) VALUES"
|
||
" ('CUST-1001', 'C1', :t, :exp), ('CUST-3001', 'C3', :t, :exp)"
|
||
),
|
||
{"t": datetime.now() - timedelta(days=30), "exp": datetime.now() + timedelta(days=300)},
|
||
)
|
||
conn.execute(
|
||
text(
|
||
"INSERT INTO core_product (product_id, product_name, min_risk_code, product_type) VALUES"
|
||
" ('PROD-161725', '科技成长主题', 'R4', 'mixed'),"
|
||
" ('PROD-510300', '沪深300指数', 'R3', 'index')"
|
||
)
|
||
)
|
||
core = CoreReadOnlyRepository(engine=engine)
|
||
repo = RiskRepository(engine=engine)
|
||
writer = GatewayRepository(engine=engine)
|
||
pub = FakePublisher()
|
||
alert_service.set_publisher(pub)
|
||
yield core, repo, writer, pub, engine
|
||
alert_service.set_publisher(None)
|
||
engine.dispose()
|
||
|
||
|
||
def _req(customer="CUST-1001", product="PROD-161725", ttype="subscribe", amount="100000"):
|
||
return {
|
||
"customer_id": customer,
|
||
"product_id": product,
|
||
"trade_type": ttype,
|
||
"amount": amount,
|
||
}
|
||
|
||
|
||
def _convert_req(
|
||
customer="CUST-3001",
|
||
frm="PROD-510300",
|
||
to="PROD-161725",
|
||
qty="1000",
|
||
**extra,
|
||
):
|
||
"""convert 请求体(T-9 字段池):from/to/qty 三件套 + 可选幂等键。"""
|
||
return {
|
||
"customer_id": customer,
|
||
"trade_type": "convert",
|
||
"from_product_id": frm,
|
||
"to_product_id": to,
|
||
"qty": qty,
|
||
**extra,
|
||
}
|
||
|
||
|
||
def _counts(engine, table, where="1=1"):
|
||
with engine.connect() as conn:
|
||
return conn.execute(text(f"SELECT COUNT(*) FROM {table} WHERE {where}")).scalar_one()
|
||
|
||
|
||
# ---------- 服务层 ----------
|
||
|
||
|
||
def test_unknown_trade_type_rejected(env):
|
||
"""§12 **R3 拆分后**:未知类型仍 400;convert 不再拒绝(改由下方 API 层覆盖)。
|
||
|
||
保留原 `test_convert_rejected` 的两条断言:不落 `core_trade`、
|
||
不落 `invalid_type` 审计(PRD 审计口径仅阻断/放行)。
|
||
"""
|
||
core, repo, writer, _, _ = env
|
||
with pytest.raises(UnsupportedTradeType, match="不支持的交易类型"):
|
||
submit_trade(_req(ttype="purchase"), core_ro=core, risk_repo=repo, gateway_repo=writer)
|
||
assert _counts(writer._engine, "core_trade") == 0
|
||
assert _counts(writer._engine, "audit_log", "decision='invalid_type'") == 0
|
||
|
||
|
||
def test_blocked_does_not_touch_core_trade(env):
|
||
"""A-1:C1 客户买 R4 → blocked=true(SUIT_RISK_MISMATCH)、不落 core_trade、日志/预警/审计齐全。"""
|
||
core, repo, writer, pub, engine = env
|
||
resp = submit_trade(_req(), core_ro=core, risk_repo=repo, gateway_repo=writer,
|
||
now=datetime(2026, 9, 6, 14, 0, 0))
|
||
assert resp["blocked"] is True
|
||
assert resp["block_response_code"] == "SUIT_RISK_MISMATCH" # main 契约机器码(SUIT-001 退役)
|
||
assert resp["advice"] == "请联系持证投资顾问" and resp["notice"] == "本次请求已记录"
|
||
assert _counts(engine, "core_trade") == 0 # 阻断不落交易
|
||
assert _counts(engine, "risk_suitability_log", "is_blocked=1 AND request_ref='" + resp["trade_id"] + "'") == 1
|
||
assert _counts(engine, "risk_alert", "alert_type='suitability'") == 1
|
||
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='suitability_blocked'") == 1
|
||
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='trade_accepted'") == 0
|
||
assert len(pub.messages) == 1 # suitability 预警推送
|
||
|
||
|
||
def test_accepted_trades_and_engine_fires(env):
|
||
"""A-3:C3 客户 60 万买 R3 → 落库 confirmed + 事件预警单(RISK-001/002) + 审计放行。"""
|
||
core, repo, writer, pub, engine = env
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer, now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
assert resp["blocked"] is False
|
||
assert resp["trade_id"].startswith("TRD-20260906-")
|
||
assert resp["triggered_rules"] == ["RISK-001", "RISK-002"] # 含本笔累计
|
||
assert len(resp["alert_ids"]) == 1
|
||
with engine.connect() as conn:
|
||
row = conn.execute(
|
||
text("SELECT trade_status, amount FROM core_trade WHERE trade_id=:t"),
|
||
{"t": resp["trade_id"]},
|
||
).mappings().one()
|
||
assert row["trade_status"] == "confirmed" and Decimal(str(row["amount"])) == Decimal("600000")
|
||
alert = repo.get_alert(resp["alert_ids"][0])
|
||
assert alert["alert_type"] == "large_amount" and alert["risk_score"] == 70
|
||
assert alert["status"] == "pending_review" # 评审 P3-3 加固
|
||
assert _counts(engine, "risk_suitability_log", "is_blocked=0") == 1
|
||
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='trade_accepted'") == 1
|
||
assert _counts(engine, "customer_profile_l3", "monitor_tier='watch'") == 1
|
||
(channel, payload), = pub.messages
|
||
assert channel == "risk:pub:alert" and payload["risk_score"] == 70
|
||
|
||
|
||
def test_redeem_accepted_without_alert(env):
|
||
"""redeem 正向路径(评审 P3-3):小额赎回放行,无预警。"""
|
||
core, repo, writer, pub, engine = env
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", ttype="redeem", amount="1000"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer, now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
assert resp["blocked"] is False and resp["triggered_rules"] == []
|
||
assert _counts(engine, "core_trade", "trade_type='redeem'") == 1
|
||
assert _counts(engine, "risk_alert") == 0
|
||
|
||
|
||
def test_engine_failure_is_audited_and_degraded(env):
|
||
"""评审 P1-1:引擎异常 → 审计 risk_engine_error + 响应 engine_error=true(交易已成立)。"""
|
||
core, repo, writer, pub, engine = env
|
||
|
||
class Boom(Exception):
|
||
pass
|
||
|
||
monkey_patch = lambda *a, **k: (_ for _ in ()).throw(Boom())
|
||
saved = tg.process_trade_event
|
||
tg.process_trade_event = monkey_patch
|
||
try:
|
||
resp = submit_trade(
|
||
_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
|
||
core_ro=core, risk_repo=repo, gateway_repo=writer,
|
||
now=datetime(2026, 9, 6, 14, 0, 0),
|
||
)
|
||
finally:
|
||
tg.process_trade_event = saved
|
||
assert resp["blocked"] is False and resp["engine_error"] is True
|
||
assert _counts(engine, "core_trade") == 1 # 交易已成立
|
||
assert _counts(engine, "audit_log", "agent_type='platform' AND decision='risk_engine_error'") == 1
|
||
assert _counts(engine, "risk_alert") == 0 # 引擎未跑,无预警
|
||
assert pub.messages == []
|
||
|
||
|
||
def test_missing_customer_returns_lookup_error(env):
|
||
"""AL-05 换核:NotFound 不再抛 LookupError,返回 forbidden/not_found 结构(main 契约)。"""
|
||
core, repo, writer, _, _ = env
|
||
resp = submit_trade(_req(customer="CUST-9999"), core_ro=core, risk_repo=repo, gateway_repo=writer)
|
||
assert resp["blocked"] is True
|
||
assert resp["block_response_code"] == "SUIT_NOT_FOUND"
|
||
|
||
|
||
# ---------- API 层(TestClient;仓储注入 sqlite) ----------
|
||
|
||
|
||
@pytest.fixture()
|
||
def client(env, monkeypatch):
|
||
core, repo, writer, pub, engine = env
|
||
monkeypatch.setattr(tg, "CoreReadOnlyRepository", lambda: core)
|
||
monkeypatch.setattr(tg, "RiskRepository", lambda: repo)
|
||
monkeypatch.setattr(tg, "GatewayRepository", lambda: writer)
|
||
# 401/越权审计经 deps/simulate 内仓储构造点,统一注入 sqlite(B6 评审 P3-4)
|
||
from app.api import deps as deps_mod
|
||
from app.api import simulate as simulate_mod
|
||
|
||
monkeypatch.setattr(deps_mod, "RiskRepository", lambda: repo)
|
||
monkeypatch.setattr(simulate_mod, "_repo", lambda: repo)
|
||
app = FastAPI()
|
||
register_error_handlers(app) # 统一错误体(手册 §10,与 main 同一 handler 集)
|
||
app.include_router(simulate_router)
|
||
with TestClient(app) as c:
|
||
yield c
|
||
|
||
|
||
DEMO = {"X-Debug-Role": "risk_demo", "X-Debug-Actor": "STAFF-90001"}
|
||
|
||
|
||
# ---------- convert 请求模型与错误码映射(T-9 · 架构 §8.1/§8.3) ----------
|
||
|
||
|
||
def test_api_convert_missing_leg_fields_returns_422(client):
|
||
"""§12 **R4 改写**:convert 缺 from/to/qty → 422(模型分支校验)。
|
||
|
||
完整「走通 → 200」路径下放到 `test_convert_integration.py`(真 MySQL)——
|
||
单测层造齐产品/持仓/费率/净值成本高且与集成测试重复(§12 R4 降级方案)。
|
||
"""
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json={"customer_id": "CUST-3001", "trade_type": "convert", "qty": "1000"},
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 422
|
||
assert r.json()["error_code"] == "REQUEST_VALIDATION_FAILED"
|
||
|
||
|
||
def test_api_convert_bad_client_request_id_returns_422(client):
|
||
"""幂等键白名单与 X-Trace-Id **共用同一份正则**(S4)→ 非法字符 422。"""
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json=_convert_req(client_request_id="bad id!"),
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_api_subscribe_missing_amount_returns_422(client):
|
||
"""反向验证字段池互斥:subscribe 仅给 product_id(缺 amount)→ 422。"""
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json={
|
||
"customer_id": "CUST-3001",
|
||
"trade_type": "subscribe",
|
||
"product_id": "PROD-510300",
|
||
},
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 422
|
||
|
||
|
||
#: 架构 §8.3 全量映射(含验收 14 的 `CROSS_ENTITY_NOT_SUPPORTED`)
|
||
CONVERT_ERROR_MATRIX = [
|
||
(ProductNotRedeemable(), 400, "PRODUCT_NOT_REDEEMABLE"),
|
||
(ProductNotSubscribable(), 400, "PRODUCT_NOT_SUBSCRIBABLE"),
|
||
(InsufficientShares(), 400, "INSUFFICIENT_SHARES"),
|
||
(BelowMinQty(), 400, "BELOW_MIN_QTY"),
|
||
(SameProduct(), 400, "SAME_PRODUCT"),
|
||
(CrossEntityNotSupported(), 400, "CROSS_ENTITY_NOT_SUPPORTED"),
|
||
(TooManyLots(3, 200), 400, "TOO_MANY_LOTS"),
|
||
(NavNotReady(), 503, "NAV_NOT_READY"),
|
||
(LotConflict(), 409, "LOT_CONFLICT"),
|
||
(IdempotencyUnavailable(), 503, "IDEMPOTENCY_UNAVAILABLE"),
|
||
(FeeRuleMissing(), 500, "FEE_RULE_MISSING"),
|
||
]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"exc,status,code",
|
||
CONVERT_ERROR_MATRIX,
|
||
ids=[e.error_code for e, _, _ in CONVERT_ERROR_MATRIX],
|
||
)
|
||
def test_convert_error_code_mapping(client, monkeypatch, exc, status, code):
|
||
"""每条 convert 异常 → HTTP 状态 + `error_code` 逐项对齐(架构 §8.3 表)。
|
||
|
||
做法:让 `convert_fund` 抛该异常,验统一错误体出口 —— convert 异常继承
|
||
`ApiError`,经 `register_error_handlers` 自动出体,**路由层不逐个 except**。
|
||
"""
|
||
def _boom(*_a, **_k):
|
||
raise exc
|
||
|
||
monkeypatch.setattr(tg, "convert_fund", _boom)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == status
|
||
body = r.json()
|
||
assert body["error_code"] == code
|
||
assert body["trace_id"] and body["request_id"] # 统一错误体四要素仍在
|
||
|
||
|
||
def test_too_many_lots_body_carries_batch_count_and_max_lots(client, monkeypatch):
|
||
"""架构 §8.3:`TOO_MANY_LOTS` 错误体必须带 `batch_count`/`max_lots`。
|
||
|
||
本条同时锁住 `response.py` 的 `extra` 展开能力 —— 缺了它前端拿不到
|
||
「需跨 N 个批次、上限 200」的提示依据(执行期风险 #5 三重约束之一)。
|
||
"""
|
||
def _boom(*_a, **_k):
|
||
raise TooManyLots(250, 200)
|
||
|
||
monkeypatch.setattr(tg, "convert_fund", _boom)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == 400
|
||
body = r.json()
|
||
assert body["error_code"] == "TOO_MANY_LOTS"
|
||
assert body["batch_count"] == 250
|
||
assert body["max_lots"] == 200
|
||
|
||
|
||
def test_api_convert_processing_returns_202(client, monkeypatch):
|
||
"""架构 §8.3:未抢到执行权 → **202** + `{convert_group_id, status}`。"""
|
||
monkeypatch.setattr(
|
||
tg,
|
||
"convert_fund",
|
||
lambda *_a, **_k: {"status": PROCESSING, "convert_group_id": None},
|
||
)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == 202
|
||
assert r.json()["status"] == "processing"
|
||
|
||
|
||
def test_api_convert_accepted_returns_200(client, monkeypatch):
|
||
"""convert 走通 → 200 且**路由层原样透传**(完整折算数值见真库集成测试)。"""
|
||
fake = {"blocked": False, "convert_group_id": "CNV-TEST-1", "in_qty": "1000.00"}
|
||
monkeypatch.setattr(tg, "convert_fund", lambda *_a, **_k: fake)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == 200
|
||
assert r.json() == fake
|
||
|
||
|
||
def test_convert_does_not_write_trade_request_audit(client, env, monkeypatch):
|
||
"""convert **不落 `trade_request` 审计** —— 审计由 convert_service 记 `convert_request`。
|
||
|
||
防的是「网关 + convert_service 双重审计」:一次转换被记成两条审计事件。
|
||
"""
|
||
_, _, _, _, engine = env
|
||
monkeypatch.setattr(
|
||
tg,
|
||
"convert_fund",
|
||
lambda *_a, **_k: {"blocked": False, "convert_group_id": "CNV-TEST-2"},
|
||
)
|
||
r = client.post("/api/simulate/trade", json=_convert_req(), headers=DEMO)
|
||
assert r.status_code == 200
|
||
assert _counts(engine, "audit_log", "event_type='trade_request'") == 0
|
||
|
||
|
||
def test_api_blocked_returns_200_with_blocked_true(client):
|
||
r = client.post("/api/simulate/trade", json=_req(), headers=DEMO) # C1 买 R4
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is True and body["block_response_code"] == "SUIT_RISK_MISMATCH"
|
||
|
||
|
||
def test_api_accepted_returns_200_with_trade_id(client):
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json=_req(customer="CUST-3001", product="PROD-510300", amount="600000"),
|
||
headers=DEMO,
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is False and body["trade_id"].startswith("TRD-")
|
||
assert body["triggered_rules"] == ["RISK-001", "RISK-002"]
|
||
|
||
|
||
def test_api_customer_owner_allowed_other_denied(client, env):
|
||
"""B6 评审 P2-2:客户本人放行进业务(适当性阻断与否由业务层决定),查他人 403+审计。"""
|
||
_, repo, _, _, engine = env
|
||
# CUST-1001(C1)买 R3:鉴权通过进入业务,业务层适当性阻断(200 blocked=true)
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json=_req(customer="CUST-1001", product="PROD-510300", amount="1000"),
|
||
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"},
|
||
)
|
||
assert r.status_code == 200 and r.json()["blocked"] is True # 业务响应,非 403
|
||
r = client.post(
|
||
"/api/simulate/trade",
|
||
json=_req(customer="CUST-3001", product="PROD-510300", amount="1000"),
|
||
headers={"X-Debug-Role": "customer", "X-Debug-Actor": "CUST-1001"},
|
||
)
|
||
assert r.status_code == 403
|
||
# 网关越权审计与放行同口径 agent_type='platform'(复审 P3)
|
||
assert _counts(engine, "audit_log", "event_type='authz' AND decision='forbidden' AND agent_type='platform'") == 1
|
||
|
||
|
||
def test_api_non_positive_amount_returns_422(client):
|
||
r = client.post("/api/simulate/trade", json=_req(amount="0"), headers=DEMO)
|
||
assert r.status_code == 422
|
||
|
||
|
||
def test_api_unknown_customer_returns_404(client):
|
||
"""AL-05 换核:未知客户走 main 契约 → 200 + blocked + SUIT_NOT_FOUND(不再 404)。"""
|
||
r = client.post("/api/simulate/trade", json=_req(customer="CUST-9999"), headers=DEMO)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["blocked"] is True
|
||
assert body["block_response_code"] == "SUIT_NOT_FOUND"
|