244 lines
8.2 KiB
Python
244 lines
8.2 KiB
Python
"""WP-HL-06 支付消息测试号门禁契约。
|
|
|
|
只验证 HTTP/BFF 调用进入无线 Frida RPC 前后的契约:不连接设备,
|
|
不发起任何真实资金动作。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
import pytest
|
|
|
|
APP = Path(__file__).resolve().parents[1] / "app"
|
|
if str(APP) not in sys.path:
|
|
sys.path.insert(0, str(APP))
|
|
|
|
from routers import unified
|
|
|
|
|
|
TEST_DEVICE = "payment-fixture-device"
|
|
TEST_TARGET = "payment-fixture-target"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def payment_test_gate(monkeypatch):
|
|
monkeypatch.setenv("WP_PAYMENT_TEST_TARGETS", TEST_TARGET)
|
|
monkeypatch.setenv("WP_PAYMENT_MAX_TEST_AMOUNT", "0.10")
|
|
monkeypatch.setenv("WP_PAYMENT_RATE_WINDOW_SECONDS", "60")
|
|
monkeypatch.setenv("WP_PAYMENT_RATE_MAX_CALLS", "2")
|
|
unified._payment_rate_events.clear()
|
|
|
|
|
|
def _request(action: str, *, real: bool = False, target: str = TEST_TARGET):
|
|
flags = {"confirm": real, "dry_run": not real}
|
|
if action in {"payment_receive", "receive_payment"}:
|
|
return unified.ReceivePaymentRequest(
|
|
device_id=TEST_DEVICE,
|
|
platform="wechat",
|
|
payer_id=target,
|
|
amount="0.01",
|
|
desc="测试收款",
|
|
**flags,
|
|
)
|
|
if action == "receive_red_packet":
|
|
return unified.RedPacketReceiveRequest(
|
|
device_id=TEST_DEVICE,
|
|
platform="wechat",
|
|
from_id=target,
|
|
msg_svr_id="fixture-red-packet-msg",
|
|
**flags,
|
|
)
|
|
if action == "receive_transfer":
|
|
return unified.TransferDecisionRequest(
|
|
device_id=TEST_DEVICE,
|
|
platform="wechat",
|
|
from_id=target,
|
|
msg_svr_id="fixture-transfer-msg",
|
|
**flags,
|
|
)
|
|
if action == "send_red_packet":
|
|
return unified.RedPacketRequest(
|
|
device_id=TEST_DEVICE,
|
|
platform="wechat",
|
|
to_id=target,
|
|
amount="0.01",
|
|
**flags,
|
|
)
|
|
if action in {"send_transfer", "transfer"}:
|
|
return unified.TransferRequest(
|
|
device_id=TEST_DEVICE,
|
|
platform="wechat",
|
|
to_id=target,
|
|
amount="0.01",
|
|
payment_password="fixture-password",
|
|
**flags,
|
|
)
|
|
raise AssertionError(f"missing fixture for {action}")
|
|
|
|
|
|
PAYMENT_VECTORS: list[tuple[str, Callable[[Any], Any], str]] = [
|
|
("payment_receive", unified.receive_payment, "receive_payment"),
|
|
("receive_payment", unified.receive_payment, "receive_payment"),
|
|
("receive_red_packet", unified.receive_red_packet, "receive_red_packet"),
|
|
("receive_transfer", unified.receive_transfer_payment, "receive_transfer"),
|
|
("send_red_packet", unified.send_red_packet, "send_red_packet"),
|
|
("send_transfer", unified.send_transfer, "send_transfer"),
|
|
("transfer", unified.send_transfer, "send_transfer"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("action", "handler", "rpc_action"), PAYMENT_VECTORS)
|
|
def test_all_payment_actions_default_to_confirmed_dry_run(action, handler, rpc_action):
|
|
response = asyncio.run(handler(_request(action)))
|
|
|
|
assert response["code"] == 200
|
|
assert response["success"] is True
|
|
assert response["channel_used"] == "dry_run"
|
|
assert response["data"]["dry_run"] is True
|
|
assert response["data"]["confirm_required"] is True
|
|
assert response["data"]["action"] == rpc_action
|
|
|
|
|
|
@pytest.mark.parametrize(("action", "handler", "rpc_action"), PAYMENT_VECTORS)
|
|
def test_all_payment_actions_reject_non_fixture_target_before_device_call(
|
|
monkeypatch, action, handler, rpc_action
|
|
):
|
|
monkeypatch.setattr(
|
|
unified,
|
|
"_check_device_online",
|
|
lambda *args, **kwargs: pytest.fail("测试对象门禁不应触达设备"),
|
|
)
|
|
response = asyncio.run(handler(_request(action, real=True, target="outside-fixture")))
|
|
|
|
assert response["code"] == 403
|
|
assert response["success"] is False
|
|
assert response["data"]["action"] == rpc_action
|
|
assert response["data"]["error_code"] == "payment_test_target_required"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("action", "handler"),
|
|
[
|
|
("payment_receive", unified.receive_payment),
|
|
("send_red_packet", unified.send_red_packet),
|
|
("send_transfer", unified.send_transfer),
|
|
],
|
|
)
|
|
def test_amount_guard_rejects_over_fixture_limit(action, handler):
|
|
request = _request(action, real=True)
|
|
request.amount = "0.11"
|
|
response = asyncio.run(handler(request))
|
|
|
|
assert response["code"] == 422
|
|
assert response["success"] is False
|
|
assert response["data"]["error_code"] == "payment_amount_out_of_range"
|
|
|
|
|
|
def test_payment_frequency_gate_returns_retry_after_seconds():
|
|
assert unified._payment_guard(
|
|
device_id=TEST_DEVICE,
|
|
action="receive_red_packet",
|
|
target_id=TEST_TARGET,
|
|
) is None
|
|
assert unified._payment_guard(
|
|
device_id=TEST_DEVICE,
|
|
action="receive_red_packet",
|
|
target_id=TEST_TARGET,
|
|
) is None
|
|
|
|
blocked = unified._payment_guard(
|
|
device_id=TEST_DEVICE,
|
|
action="receive_red_packet",
|
|
target_id=TEST_TARGET,
|
|
)
|
|
assert blocked is not None
|
|
assert blocked["code"] == 429
|
|
assert blocked["data"]["error_code"] == "rate_limited"
|
|
assert blocked["data"]["retry_after_seconds"] >= 1
|
|
|
|
|
|
@pytest.mark.parametrize(("action", "handler", "rpc_action"), PAYMENT_VECTORS)
|
|
def test_real_payment_path_requires_frida_receipt_trace_and_readback(
|
|
monkeypatch, action, handler, rpc_action
|
|
):
|
|
captured: dict[str, Any] = {}
|
|
monkeypatch.setattr(unified, "_check_device_online", lambda *args, **kwargs: True)
|
|
|
|
async def fake_execute(device_id, platform, hook_action, params, **kwargs):
|
|
captured.update(device_id=device_id, platform=platform, action=hook_action, params=params, kwargs=kwargs)
|
|
return {
|
|
"code": 200,
|
|
"success": True,
|
|
"verified": True,
|
|
"channel_used": "frida_rpc",
|
|
"trace_id": f"trace-{rpc_action}",
|
|
"raw_rpc_receipt": {"rpc_action": rpc_action, "ok": True},
|
|
"readback": {"found": True, "message_id": f"fixture-{rpc_action}"},
|
|
}
|
|
|
|
monkeypatch.setattr(unified, "_execute_skill", fake_execute)
|
|
response = asyncio.run(handler(_request(action, real=True)))
|
|
|
|
assert captured["action"] == rpc_action
|
|
assert response["code"] == 200
|
|
assert response["success"] is True
|
|
assert response["channel_used"] == "frida_rpc"
|
|
assert response["data"]["trace_id"] == f"trace-{rpc_action}"
|
|
assert response["data"]["raw_rpc_receipt"]["ok"] is True
|
|
assert response["data"]["readback"]["found"] is True
|
|
|
|
|
|
def test_transfer_secret_is_masked_and_missing_secret_blocks_rpc(monkeypatch):
|
|
dry_response = asyncio.run(unified.send_transfer(_request("transfer")))
|
|
assert dry_response["data"]["password_present"] is True
|
|
assert "fixture-password" not in str(dry_response)
|
|
|
|
monkeypatch.setattr(
|
|
unified,
|
|
"_check_device_online",
|
|
lambda *args, **kwargs: pytest.fail("缺少密码门禁不应触达设备"),
|
|
)
|
|
response = asyncio.run(
|
|
unified.send_transfer(
|
|
unified.TransferRequest(
|
|
device_id=TEST_DEVICE,
|
|
platform="wechat",
|
|
to_id=TEST_TARGET,
|
|
amount="0.01",
|
|
confirm=True,
|
|
dry_run=False,
|
|
)
|
|
)
|
|
)
|
|
assert response["code"] == 422
|
|
assert response["data"]["error_code"] == "payment_password_required"
|
|
|
|
|
|
def test_payment_actions_have_explicit_api_routes_and_no_u2_fallback():
|
|
from services.device_transport import WECHAT_U2_ONLY_ACTIONS
|
|
from main import app
|
|
|
|
paths = app.openapi()["paths"]
|
|
assert {
|
|
"/api/v3/payment/red-packet",
|
|
"/api/v3/payment/transfer",
|
|
"/api/v3/payment/transfer/receive",
|
|
"/api/v3/payment/receive-red-packet",
|
|
"/api/v3/payment/receive",
|
|
"/api/v3/payment/receive-payment",
|
|
"/api/v3/payment/payment-receive",
|
|
}.issubset(paths)
|
|
assert not {
|
|
"payment_receive",
|
|
"receive_payment",
|
|
"receive_red_packet",
|
|
"receive_transfer",
|
|
"send_red_packet",
|
|
"send_transfer",
|
|
"transfer",
|
|
}.intersection(WECHAT_U2_ONLY_ACTIONS)
|