Files
gaokao-volunteer-system/admin/tests/test_routes_orders.py
hermes 187b2ae634 fix(compliance): A-2 admin/外部渠道补录同意审计统一化
LEGAL_PRIVACY_BASELINE §4/§6 要求任何订单创建路径必须记录同意审计字段。
portal 路径已落 consent_channel=portal / consent_operator=guardian (见
web_public.py + intake_store.save), admin 路径 6/20 之前完全不入任何 consent
字段 (admin/routes/orders.py grep 'consent' 0 命中), 形成合规盲区。

落地:
- CreateOrderRequest 新增必填 consent: ConsentInfo
  - consent_method Literal: verbal_chat / phone_recording / screenshot /
    written_form / self_declared
  - consent_note Optional[str]
  - 缺失或非法 → HTTP 422
- Order 模型 + DAO _WRITABLE_COLUMNS 加 consent_method / consent_given_at
  (冗余落库避免每次列表 join order_intakes)
- schema 增量: ALTER TABLE orders ADD COLUMN consent_method / consent_given_at
  (幂等)
- create_order 同步写 order_intakes (独立 IntakeStore.for_db, 不复用 OrdersDAO
  conn — T12-D conn ownership 修复保驾)
  - consent_channel = payload.source (xianyu/wechat/school/web)
  - consent_operator 严格按基线白名单 self/guardian/admin_import:
    - web 渠道: 'guardian' (与 intake_store.save 默认值一致)
    - 其他渠道: 'admin_import' (后台代录, 同意来源是渠道商)
  - consent_method / consent_given_at / consent_note 落库

测试:
- test_create_order_rejects_missing_consent_block[xianyu|wechat|school|web] (4 个)
- test_create_order_writes_intake_record_with_consent_audit
- test_create_order_external_channel_marks_consent_operator_as_admin
- test_create_order_rejects_invalid_consent_method
- test_order_detail_returns_consent_method_and_given_at
- 更新 test_create_order_returns_masked_payload_with_history (加 consent)
- 更新 test_admin_orders_alias_list_and_detail (加 consent)

验证:
- 25/25 admin/tests/{test_routes_orders,test_admin_alias_routes} 通过
- ruff + mypy 通过
- 端到端 smoke: 4 笔订单 (2 terminal + 1 pending + 1 paid-in-window) 实测
  包含 consent 字段全部通过
- dev-verify: 1186 passed / 2 failed (失败的 2 个均为 worktree 环境限制:
  test_backup_restore_service_level 的 subprocess 路径假设 + 6/19 已知问题,
  与本改动无关; main 上单跑同样测试通过)
2026-06-20 15:29:06 +08:00

510 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""订单管理路由测试 (T6.4).
覆盖:
- 手工录单 POST /api/orders
- 订单列表 / 详情
- PATCH 业务字段更新 + 状态流转
- refunded 退款流转
- CSV 导出
"""
from __future__ import annotations
import csv
from io import StringIO
import pytest
from data.orders.dao import OrdersDAO
from data.orders.intake_store import IntakeStore
from data.orders.models import Order
@pytest.fixture(autouse=True)
def _orders_fernet_key(monkeypatch):
monkeypatch.setenv("GAOKAO_ORDERS_FERNET_KEY", "test-secret-for-admin-orders")
def _seed_order(settings, **overrides) -> Order:
payload = {
"id": overrides.pop("id", "GKO-20260612-SEED"),
"source": "web",
"external_id": None,
"service_version": "basic",
"amount_cents": 4900,
"status": "pending",
"customer_name": "张三",
"customer_phone": "13800001234",
"customer_wechat": "zhangsanwx",
"candidate_name": "李同学",
"candidate_id_card": "430102200501011234",
"candidate_province": "湖南",
"candidate_score": 578,
"tags": ["首单"],
"notes": "待跟进",
}
payload.update(overrides)
order = Order(**payload)
with OrdersDAO.connect(settings.orders_db_path) as dao:
return dao.create(order, actor="test", reason="seed")
def test_create_order_returns_masked_payload_with_history(client, auth_headers):
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
"source": "wechat",
"external_id": "WX-001",
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "王家长",
"customer_phone": "13900001111",
"customer_wechat": "parentwx",
"candidate_name": "王同学",
"candidate_id_card": "110101200701011234",
"candidate_province": "北京",
"candidate_score": 640,
"candidate_subjects": ["物理", "化学"],
"notes": "手工补录",
"tags": ["渠道补单"],
"consent": {
"consent_method": "verbal_chat",
"consent_note": "微信沟通后家长口头同意",
},
},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["action"] == "created"
assert body["order"]["source"] == "wechat"
assert body["order"]["status"] == "pending"
assert body["order"]["customer_phone"] == "139****1111"
assert body["order"]["candidate_id_card"] == "110101********1234"
assert "customer_phone_hash" not in body["order"]
assert body["order"]["consent_method"] == "verbal_chat"
assert body["order"]["consent_given_at"] is not None
assert body["history"][0]["from_status"] is None
assert body["history"][0]["to_status"] == "pending"
# ---------------------------------------------------------------------------
# A-2: 后台/外部渠道补录同意审计统一化2026-06-20 落地)
#
# LEGAL_PRIVACY_BASELINE §6 要求所有订单创建路径必须记录同意审计字段。
# portal 路径已自动落 consent_channel=portal / consent_operator=guardian
# (见 web_public.py + intake_store.save
# admin 代录/外部渠道补录路径必须在请求体里显式带 consent 块, 否则拒绝
# (HTTP 422)。
# ---------------------------------------------------------------------------
_XIANYU_BASE = {
"source": "xianyu",
"external_id": "XY-A2-001",
"service_version": "standard",
"amount_cents": 9900,
"customer_name": "陈家长",
"customer_phone": "13900002222",
"candidate_name": "陈同学",
"candidate_province": "广东",
"candidate_score": 595,
}
@pytest.mark.parametrize("source", ["xianyu", "wechat", "school", "web"])
def test_create_order_rejects_missing_consent_block(client, auth_headers, source):
"""A-2 RED: 任何 source 补录都必须在请求体里带 consent 块, 否则 422。"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={**_XIANYU_BASE, "source": source, "external_id": f"TEST-{source}"},
)
assert resp.status_code == 422, resp.text
body_str = resp.text
# 项目对 422 走自定义 exception handler, detail 可能是 dict 或 str
# 只要 body 里出现 "consent" 字段名就算合规
assert "consent" in body_str, f"422 错误体未提及 consent 字段: {body_str}"
def test_create_order_writes_intake_record_with_consent_audit(
client, auth_headers, settings
):
"""A-2 RED: 合规补录成功后, 必须能在 order_intakes 表查到 consent 审计字段。"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "XY-A2-002",
"consent": {
"consent_method": "phone_recording",
"consent_note": "闲鱼沟通后电话确认",
},
},
)
assert resp.status_code == 201, resp.text
order_id = resp.json()["order"]["id"]
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
record = intake_store.get(order_id)
finally:
intake_store.close()
assert record is not None, f"order_intakes 表未创建 {order_id} 记录"
assert record.status == "submitted"
assert record.submitted_at is not None
# 同意审计字段必须落库, 与 portal 路径同口径
assert record.payload.get("consent_channel") == "xianyu"
assert record.payload.get("consent_operator") == "admin_import"
assert record.payload.get("consent_method") == "phone_recording"
assert record.payload.get("consent_given_at") == record.submitted_at
assert record.payload.get("consent_note") == "闲鱼沟通后电话确认"
# 旧 portal 字段保留 (admin 渠道下, guardian_confirmed 不适用, 不强制)
assert "privacy_accepted" in record.payload or "consent_note" in record.payload
def test_create_order_external_channel_marks_consent_operator_as_admin(
client, auth_headers, settings
):
"""A-2 RED: 外部渠道(xianyu/wechat/school)的 consent_operator 必须是 admin,
因为是后台代录而非用户自助 portal。"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "WECHAT-A2-003",
"source": "wechat",
"consent": {"consent_method": "screenshot", "consent_note": "微信截图"},
},
)
assert resp.status_code == 201, resp.text
order_id = resp.json()["order"]["id"]
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
record = intake_store.get(order_id)
finally:
intake_store.close()
assert record is not None
assert record.payload.get("consent_channel") == "wechat"
assert record.payload.get("consent_operator") == "admin_import"
def test_create_order_rejects_invalid_consent_method(client, auth_headers):
"""A-2 RED: consent_method 必须是白名单值。"""
resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "XY-A2-004",
"consent": {"consent_method": "telepathy", "consent_note": "非法方法"},
},
)
assert resp.status_code == 422, resp.text
def test_order_detail_returns_consent_method_and_given_at(client, auth_headers):
"""A-2 RED: Order 列表/详情接口直接返回 consent_method + consent_given_at,
不必后端 join order_intakes 表 (冗余字段落库, 性能 + 一致性)。"""
create_resp = client.post(
"/api/orders",
headers=auth_headers,
json={
**_XIANYU_BASE,
"external_id": "XY-A2-005",
"consent": {
"consent_method": "written_form",
"consent_note": "书面确认单已签字",
},
},
)
assert create_resp.status_code == 201, create_resp.text
order_id = create_resp.json()["order"]["id"]
detail_resp = client.get(f"/api/orders/{order_id}", headers=auth_headers)
assert detail_resp.status_code == 200, detail_resp.text
detail = detail_resp.json()["order"]
assert detail["consent_method"] == "written_form"
assert detail["consent_given_at"] is not None
def test_list_and_detail_return_real_orders_with_masking(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-LIST")
list_resp = client.get(
"/api/orders",
headers=auth_headers,
params={"status": "pending", "limit": 10, "offset": 0},
)
assert list_resp.status_code == 200, list_resp.text
payload = list_resp.json()
assert len(payload) == 1
assert payload[0]["id"] == created.id
assert payload[0]["customer_phone"] == "138****1234"
assert payload[0]["intake_status"] is None
assert payload[0]["intake_submitted_at"] is None
detail_resp = client.get(f"/api/orders/{created.id}", headers=auth_headers)
assert detail_resp.status_code == 200, detail_resp.text
detail = detail_resp.json()
assert detail["order"]["id"] == created.id
assert detail["order"]["customer_wechat"] == "zh******wx"
assert detail["order"]["intake_status"] is None
assert detail["order"]["intake_submitted_at"] is None
assert detail["history"][0]["to_status"] == "pending"
assert set(detail["available_next_statuses"]) == {"paid", "refunded"}
def test_detail_exposes_submitted_intake_state(client, auth_headers, settings):
created = _seed_order(settings, id="GKO-20260612-INTAKE-DETAIL")
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
record = intake_store.save(
order_id=created.id,
payload={"candidate_score": 578, "guardian_notes": "尽快处理"},
submit=True,
)
finally:
intake_store.close()
list_resp = client.get(
"/api/orders",
headers=auth_headers,
params={"status": "pending", "limit": 10, "offset": 0},
)
assert list_resp.status_code == 200, list_resp.text
payload = list_resp.json()
assert payload[0]["intake_status"] == "submitted"
assert payload[0]["intake_submitted_at"] == record.submitted_at
detail_resp = client.get(f"/api/orders/{created.id}", headers=auth_headers)
assert detail_resp.status_code == 200, detail_resp.text
detail = detail_resp.json()
assert detail["order"]["intake_status"] == "submitted"
assert detail["order"]["intake_submitted_at"] == record.submitted_at
def test_patch_updates_business_fields_and_status_transition(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-PATCH")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={
"updates": {
"assigned_consultant": "consultant-a",
"notes": "已联系家长",
"tags": ["已跟进", "VIP"],
},
"to_status": "paid",
"reason": "manual_pay",
},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["order"]["assigned_consultant"] == "consultant-a"
assert body["order"]["notes"] == "已联系家长"
assert body["order"]["tags"] == ["已跟进", "VIP"]
assert body["order"]["status"] == "paid"
assert body["order"]["paid_at"] is not None
assert [item["to_status"] for item in body["history"]] == ["pending", "paid"]
def test_patch_refund_flow_appends_history(client, auth_headers, settings):
created = _seed_order(settings, id="GKO-20260612-REFUND")
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.transition_status(created.id, "paid", actor="test", reason="seed_pay")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={"to_status": "refunded", "reason": "manual_refund"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["order"]["status"] == "refunded"
assert [item["to_status"] for item in body["history"]] == [
"pending",
"paid",
"refunded",
]
assert body["available_next_statuses"] == []
def test_patch_invalid_transition_returns_conflict(client, auth_headers, settings):
created = _seed_order(settings, id="GKO-20260612-CONFLICT")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={"to_status": "completed", "reason": "skip_steps"},
)
assert resp.status_code == 409
body = resp.json()
assert body["code"] == "E02301"
def test_patch_paid_to_serving_requires_submitted_intake(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-NEED-INTAKE")
with OrdersDAO.connect(settings.orders_db_path) as dao:
dao.transition_status(created.id, "paid", actor="test", reason="seed_pay")
blocked = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={"to_status": "serving", "reason": "begin_processing"},
)
assert blocked.status_code == 422
blocked_body = blocked.json()
assert blocked_body["code"] == "E03001"
assert blocked_body["detail"]["required_intake_status"] == "submitted"
intake_store = IntakeStore.for_db(settings.orders_db_path)
try:
intake_store.save(
order_id=created.id,
payload={"candidate_score": 588, "guardian_notes": "资料已确认"},
submit=True,
)
finally:
intake_store.close()
allowed = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={"to_status": "serving", "reason": "begin_processing"},
)
assert allowed.status_code == 200, allowed.text
allowed_body = allowed.json()
assert allowed_body["order"]["status"] == "serving"
assert allowed_body["order"]["intake_status"] == "submitted"
def test_patch_order_rejects_audit_report_outside_allowed_report_dir(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-UNTRUSTED-HTML")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={
"updates": {"audit_report": "/etc/hosts"},
"reason": "attach_report",
},
)
assert resp.status_code == 422
body = resp.json()
assert body["code"] == "E03001"
assert "untrusted artifact" in body["detail"]["reason"]
def test_patch_order_rejects_pdf_path_outside_allowed_report_dir(
client, auth_headers, settings
):
created = _seed_order(settings, id="GKO-20260612-UNTRUSTED-PDF")
resp = client.patch(
f"/api/orders/{created.id}",
headers=auth_headers,
json={
"updates": {"pdf_path": "/etc/hosts"},
"reason": "attach_report",
},
)
assert resp.status_code == 422
body = resp.json()
assert body["code"] == "E03001"
assert "untrusted artifact" in body["detail"]["reason"]
def test_viewer_cannot_list_orders(client, viewer_headers, settings):
_seed_order(settings, id="GKO-20260612-VIEWER-LIST")
resp = client.get("/api/orders", headers=viewer_headers)
assert resp.status_code == 403
body = resp.json()
assert body["code"] == "E01301"
def test_viewer_cannot_patch_order(client, viewer_headers, settings):
created = _seed_order(settings, id="GKO-20260612-VIEWER-PATCH")
resp = client.patch(
f"/api/orders/{created.id}",
headers=viewer_headers,
json={"updates": {"notes": "viewer should fail"}, "reason": "forbidden"},
)
assert resp.status_code == 403
body = resp.json()
assert body["code"] == "E01301"
def test_export_orders_csv_returns_masked_rows(client, auth_headers, settings):
_seed_order(
settings,
id="GKO-20260612-CSV",
source="school",
service_version="premium",
amount_cents=19900,
)
resp = client.get(
"/api/orders/export",
headers=auth_headers,
params={"status": "pending", "source": "school"},
)
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("text/csv")
assert "attachment; filename=" in resp.headers["content-disposition"]
rows = list(csv.DictReader(StringIO(resp.text)))
assert len(rows) == 1
assert rows[0]["id"] == "GKO-20260612-CSV"
assert rows[0]["customer_phone"] == "138****1234"
assert rows[0]["candidate_id_card"] == "430102********1234"
assert rows[0]["status"] == "pending"
def test_export_orders_csv_neutralizes_formula_injection_values(
client, auth_headers, settings
):
_seed_order(
settings,
id="GKO-20260612-CSV-FORMULA",
customer_name="=cmd|' /C calc'!A0",
candidate_name="+SUM(1,2)",
notes="@SUM(A1:A2)",
external_id="-10+20",
)
resp = client.get("/api/orders/export", headers=auth_headers)
assert resp.status_code == 200, resp.text
rows = list(csv.DictReader(StringIO(resp.text)))
assert len(rows) == 1
assert not rows[0]["customer_name"].startswith(("=", "+", "-", "@"))
assert not rows[0]["candidate_name"].startswith(("=", "+", "-", "@"))
assert rows[0]["notes"] == "'@SUM(A1:A2)"
assert rows[0]["external_id"] == "'-10+20"