Files
codex-app-server/cloud/tests/test_chv_muse_p10_payments.py
T
host b55b49b358 feat(chv): 对齐 Muse P10 支付安全——凭证隔离 + 结账检测 + 每次购买人工审批 + 一次性卡号
- 支付凭证隔离:PaymentCredentialVault(只活内存、白名单
  stripe-link/shop-pay、只存不透明 id + 掩码标签、原始卡号
  PAN 拒绝入库、按次释放绑定商家/金额/货币、单次 + TTL)
- 结账页检测:detect_checkout(URL + 无障碍树信号,确定性
  规则、不读原始 DOM、与 P4 detect_form_risk 同输入形状)
- 每次购买人工审批:PaymentGate 经 P5 对话外通道发审批,
  payload 含确切购买详情;完成时先验签、再验详情摘要
  (篡改即拒且审批保持待处理)、最后单次消费答复
- 一次性卡号:OneTimeCard 绑定商家/金额/货币/有效期,单次
  兑现;SimulatedWalletBackend 沙箱钱包(假 PAN 只在后端
  私有内存、charge 只记账不联网、取消即 void)
- 限额:SpendLimits 三档 minor units 比对,限额不可用时
  不编造上限(limits_checked=False)
- 审计:PaymentAudit 只留允许字段,秘密字段名丢弃 + redacted
- 审批通道最小扩展:ApprovalRequest 增加可选 details 字段,
  不传时 payload 与 P5 完全一致
- 真实支付与扣款不做:全部沙箱验证,真实钱包集成待用户
  另行批准;新测试 79 项,全量 cloud 套件 2037 OK

round-12 集成说明:本提交为 acf98f8 的 cherry-pick(只含 P10
自有 diff,基线 25bcd35 已含的 P3/P5 内容未重复合入;
approval_channel.py 仅加可选 details 字段)。
2026-11-07 00:09:55 +00:00

404 lines
16 KiB
Python

"""对齐 Muse P10 支付单元测试:凭证保险库、一次性卡号、
购买详情摘要、限额、审计、沙箱钱包。
只用标准库与假时钟,绝不启动真实 VM、不联网、不产生真实
扣款。覆盖:
- 凭证隔离(PAN 拒绝入库、未知提供方拒绝、按次释放绑定、
单次兑现、过期、忘记作废、repr 无凭证值);
- 一次性卡号(换商家/超额/换货币/过期/复用一律拒);
- 购买详情(自洽校验、摘要防篡改);
- 限额(三档、边界、限额不可用时不编造上限);
- 审计(秘密字段名丢弃 + redacted);
- 沙箱钱包(铸卡、记账、失败 void、账本无卡号)。
"""
import sys
import unittest
from pathlib import Path
try:
from cloud.chv.payments import (
OneTimeCard,
PaymentAudit,
PaymentCredentialVault,
PaymentRefusal,
PurchaseDetails,
SimulatedWalletBackend,
SpendLimits,
luhn_valid,
)
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.chv.payments import (
OneTimeCard,
PaymentAudit,
PaymentCredentialVault,
PaymentRefusal,
PurchaseDetails,
SimulatedWalletBackend,
SpendLimits,
luhn_valid,
)
_NOW = 1_700_000_000.0
_TEST_PAN = "4242424242424242" # Luhn 有效的测试卡号,永不真扣款
class _Clock:
"""可推进的假时钟(测过期用)。"""
def __init__(self, now: float = _NOW) -> None:
self.now = now
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += seconds
def _details(amount: int = 5000) -> PurchaseDetails:
return PurchaseDetails(
merchant="Example Store", merchant_host="shop.example.com",
items=(("Widget", 2, 2500),), amount_minor=amount,
currency="USD", wallet_provider="stripe-link")
class LuhnTest(unittest.TestCase):
def test_valid_test_pan_passes_luhn(self) -> None:
self.assertTrue(luhn_valid(_TEST_PAN))
def test_short_or_bad_number_fails(self) -> None:
self.assertFalse(luhn_valid("123"))
self.assertFalse(luhn_valid("4242424242424241"))
class VaultTest(unittest.TestCase):
def setUp(self) -> None:
self.clock = _Clock()
self.vault = PaymentCredentialVault(clock=self.clock)
def test_store_release_consume_roundtrip(self) -> None:
self.vault.store("stripe-link", "pm_abc", "Visa ....4242",
"shop.example.com")
self.assertTrue(self.vault.has("stripe-link", "pm_abc"))
rel = self.vault.release("stripe-link", "pm_abc",
"shop.example.com", 5000, "USD")
method = self.vault.consume(rel.release_id)
self.assertEqual(method.method_id, "pm_abc")
self.assertEqual(method.masked_label, "Visa ....4242")
def test_raw_pan_as_method_id_refused(self) -> None:
with self.assertRaises(PaymentRefusal) as ctx:
self.vault.store("stripe-link", _TEST_PAN, "Visa ....4242")
self.assertEqual(ctx.exception.kind, "raw_pan_refused")
def test_raw_pan_as_label_refused(self) -> None:
with self.assertRaises(PaymentRefusal):
self.vault.store("stripe-link", "pm_abc", _TEST_PAN)
def test_unknown_provider_refused(self) -> None:
with self.assertRaises(PaymentRefusal) as ctx:
self.vault.store("sketchy-pay", "pm_abc", "Visa ....4242")
self.assertEqual(ctx.exception.kind, "unknown_provider")
def test_empty_method_id_refused(self) -> None:
with self.assertRaises(PaymentRefusal):
self.vault.store("stripe-link", " ", "Visa ....4242")
def test_release_scope_mismatch_refused(self) -> None:
self.vault.store("stripe-link", "pm_abc", "Visa ....4242",
"shop.example.com")
with self.assertRaises(PaymentRefusal) as ctx:
self.vault.release("stripe-link", "pm_abc",
"evil.example.com", 5000, "USD")
self.assertEqual(ctx.exception.kind, "scope_mismatch")
def test_release_unknown_method_refused(self) -> None:
with self.assertRaises(PaymentRefusal):
self.vault.release("stripe-link", "pm_nope",
"shop.example.com", 5000, "USD")
def test_release_bad_amount_refused(self) -> None:
self.vault.store("stripe-link", "pm_abc", "Visa ....4242")
with self.assertRaises(PaymentRefusal):
self.vault.release("stripe-link", "pm_abc",
"shop.example.com", 0, "USD")
def test_consume_twice_second_fails(self) -> None:
self.vault.store("stripe-link", "pm_abc", "Visa ....4242")
rel = self.vault.release("stripe-link", "pm_abc",
"shop.example.com", 5000, "USD")
self.vault.consume(rel.release_id)
with self.assertRaises(PaymentRefusal) as ctx:
self.vault.consume(rel.release_id)
self.assertEqual(ctx.exception.kind, "release_unknown")
def test_consume_expired_release_fails(self) -> None:
self.vault.store("stripe-link", "pm_abc", "Visa ....4242")
rel = self.vault.release("stripe-link", "pm_abc",
"shop.example.com", 5000, "USD",
ttl_seconds=300)
self.clock.advance(301)
with self.assertRaises(PaymentRefusal) as ctx:
self.vault.consume(rel.release_id)
self.assertEqual(ctx.exception.kind, "release_expired")
def test_forget_removes_and_voids_releases(self) -> None:
self.vault.store("stripe-link", "pm_abc", "Visa ....4242")
rel = self.vault.release("stripe-link", "pm_abc",
"shop.example.com", 5000, "USD")
self.assertTrue(self.vault.forget("stripe-link", "pm_abc"))
self.assertFalse(self.vault.has("stripe-link", "pm_abc"))
with self.assertRaises(PaymentRefusal):
self.vault.consume(rel.release_id)
self.assertFalse(self.vault.forget("stripe-link", "pm_abc"))
def test_repr_contains_no_credential_value(self) -> None:
self.vault.store("stripe-link", "pm_abc", "Visa ....4242")
self.assertNotIn(_TEST_PAN, repr(self.vault))
self.assertNotIn("pm_abc", repr(self.vault))
def test_methods_for_merchant_scoped(self) -> None:
self.vault.store("stripe-link", "pm_a", "Visa ....4242",
"shop.example.com")
self.vault.store("shop-pay", "pm_b", "MC ....5555",
"other.example.com")
found = self.vault.methods_for_merchant("shop.example.com")
self.assertEqual([m.method_id for m in found], ["pm_a"])
class OneTimeCardTest(unittest.TestCase):
def _card(self) -> OneTimeCard:
return OneTimeCard(
card_id="otc_test", merchant="Example Store",
amount_minor=5000, currency="USD",
expires_at=_NOW + 900, masked_label="Visa ....4242")
def test_authorize_ok_consumes_once(self) -> None:
card = self._card()
ok, reason = card.authorize("Example Store", 5000, "USD", _NOW)
self.assertTrue(ok)
self.assertEqual(reason, "ok")
ok2, reason2 = card.authorize("Example Store", 5000, "USD",
_NOW)
self.assertFalse(ok2)
self.assertEqual(reason2, "card_exhausted")
def test_partial_amount_ok(self) -> None:
card = self._card()
ok, _ = card.authorize("Example Store", 4999, "USD", _NOW)
self.assertTrue(ok)
def test_wrong_merchant_refused(self) -> None:
card = self._card()
ok, reason = card.authorize("Evil Store", 5000, "USD", _NOW)
self.assertFalse(ok)
self.assertEqual(reason, "wrong_merchant")
def test_amount_exceeded_refused(self) -> None:
card = self._card()
ok, reason = card.authorize("Example Store", 5001, "USD", _NOW)
self.assertFalse(ok)
self.assertEqual(reason, "amount_exceeded")
def test_wrong_currency_refused(self) -> None:
card = self._card()
ok, reason = card.authorize("Example Store", 5000, "EUR", _NOW)
self.assertFalse(ok)
self.assertEqual(reason, "wrong_currency")
def test_expired_refused(self) -> None:
card = self._card()
ok, reason = card.authorize("Example Store", 5000, "USD",
_NOW + 901)
self.assertFalse(ok)
self.assertEqual(reason, "card_expired")
def test_describe_and_repr_have_no_pan(self) -> None:
card = self._card()
self.assertNotIn(_TEST_PAN, str(card.describe()))
self.assertNotIn(_TEST_PAN, repr(card))
class PurchaseDetailsTest(unittest.TestCase):
def test_validate_ok_and_total(self) -> None:
details = _details()
details.validate()
self.assertEqual(details.total_minor(), 5000)
def test_total_mismatch_rejected(self) -> None:
details = _details(amount=5001)
with self.assertRaises(ValueError):
details.validate()
def test_bad_currency_rejected(self) -> None:
details = PurchaseDetails(
"Example Store", "shop.example.com",
(("Widget", 1, 100),), 100, "US", "stripe-link")
with self.assertRaises(ValueError):
details.validate()
def test_empty_items_rejected(self) -> None:
details = PurchaseDetails(
"Example Store", "shop.example.com", (), 100, "USD",
"stripe-link")
with self.assertRaises(ValueError):
details.validate()
def test_unknown_provider_rejected(self) -> None:
details = PurchaseDetails(
"Example Store", "shop.example.com",
(("Widget", 1, 100),), 100, "USD", "sketchy-pay")
with self.assertRaises(ValueError):
details.validate()
def test_digest_stable_and_tamper_sensitive(self) -> None:
first = _details().digest()
self.assertEqual(first, _details().digest())
tampered_price = PurchaseDetails(
"Example Store", "shop.example.com",
(("Widget", 2, 2501),), 5002, "USD", "stripe-link")
self.assertNotEqual(first, tampered_price.digest())
other_merchant = PurchaseDetails(
"Other Store", "shop.example.com",
(("Widget", 2, 2500),), 5000, "USD", "stripe-link")
self.assertNotEqual(first, other_merchant.digest())
class SpendLimitsTest(unittest.TestCase):
def test_within_limits_ok(self) -> None:
limits = SpendLimits(per_purchase_minor=10000,
daily_minor=20000,
thirty_day_minor=100000,
daily_spent_minor=5000)
ok, reason = limits.check(5000)
self.assertTrue(ok)
self.assertEqual(reason, "ok")
def test_over_per_purchase_limit(self) -> None:
limits = SpendLimits(per_purchase_minor=4999)
ok, reason = limits.check(5000)
self.assertFalse(ok)
self.assertEqual(reason, "over_per_purchase_limit")
def test_over_daily_limit(self) -> None:
limits = SpendLimits(daily_minor=9000, daily_spent_minor=5000)
ok, reason = limits.check(5000)
self.assertFalse(ok)
self.assertEqual(reason, "over_daily_limit")
def test_over_thirty_day_limit(self) -> None:
limits = SpendLimits(thirty_day_minor=9000,
thirty_day_spent_minor=5000)
ok, reason = limits.check(5000)
self.assertFalse(ok)
self.assertEqual(reason, "over_thirty_day_limit")
def test_exact_limit_boundary_ok(self) -> None:
limits = SpendLimits(per_purchase_minor=5000)
ok, _ = limits.check(5000)
self.assertTrue(ok)
def test_limits_unavailable_never_invents_cap(self) -> None:
limits = SpendLimits()
ok, reason = limits.check(10_000_000)
self.assertTrue(ok)
self.assertEqual(reason, "limits_unavailable")
class PaymentAuditTest(unittest.TestCase):
def test_record_keeps_allowed_fields(self) -> None:
audit = PaymentAudit(clock=_Clock())
rec = audit.record("payment_completed",
{"merchant": "Example Store",
"amount_minor": 5000,
"masked_label": "Visa ....4242",
"pending_id": "abc123"})
self.assertEqual(rec["event"], "payment_completed")
self.assertEqual(rec["amount_minor"], 5000)
self.assertEqual(rec["masked_label"], "Visa ....4242")
def test_secret_field_names_dropped_and_flagged(self) -> None:
audit = PaymentAudit(clock=_Clock())
rec = audit.record("payment_completed",
{"merchant": "Example Store",
"card_number": _TEST_PAN,
"token": "tok_secret",
"amount_minor": 5000})
self.assertNotIn("card_number", rec)
self.assertNotIn("token", rec)
self.assertTrue(rec["redacted"])
self.assertNotIn(_TEST_PAN, str(rec))
def test_unknown_fields_dropped_silently(self) -> None:
audit = PaymentAudit(clock=_Clock())
rec = audit.record("payment_completed",
{"merchant": "Example Store",
"buyer_email": "a@example.com"})
self.assertNotIn("buyer_email", rec)
self.assertEqual(len(audit.list_records()), 1)
class SimulatedWalletTest(unittest.TestCase):
def setUp(self) -> None:
self.clock = _Clock()
self.wallet = SimulatedWalletBackend(clock=self.clock)
def test_supports_known_providers_only(self) -> None:
self.assertTrue(self.wallet.supports("stripe-link"))
self.assertTrue(self.wallet.supports("shop-pay"))
self.assertFalse(self.wallet.supports("sketchy-pay"))
def test_mint_and_charge_roundtrip(self) -> None:
card = self.wallet.mint_card(_details(), now=_NOW)
self.assertTrue(card.masked_label.startswith("Visa ...."))
result = self.wallet.charge(card, _details(), now=_NOW)
self.assertEqual(result["status"], "simulated_paid")
self.assertEqual(result["amount_minor"], 5000)
self.assertEqual(len(self.wallet.ledger()), 1)
def test_no_pan_leaks_into_ledger_or_repr(self) -> None:
card = self.wallet.mint_card(_details(), now=_NOW)
self.wallet.charge(card, _details(), now=_NOW)
blob = str(self.wallet.ledger()) + repr(self.wallet)
self.assertNotIn("400000000000", blob)
def test_charge_wrong_merchant_refused(self) -> None:
card = self.wallet.mint_card(_details(), now=_NOW)
other = PurchaseDetails(
"Other Store", "shop.example.com",
(("Widget", 2, 2500),), 5000, "USD", "stripe-link")
result = self.wallet.charge(card, other, now=_NOW)
self.assertEqual(result["status"], "refused")
self.assertEqual(result["reason"], "wrong_merchant")
def test_fail_next_charge_voids_card(self) -> None:
card = self.wallet.mint_card(_details(), now=_NOW)
self.wallet.fail_next_charge = True
result = self.wallet.charge(card, _details(), now=_NOW)
self.assertEqual(result["status"], "simulated_failed")
self.assertEqual(result["reason"], "wallet_declined")
ok, reason = card.authorize("Example Store", 5000, "USD",
_NOW)
self.assertFalse(ok)
self.assertEqual(reason, "card_exhausted")
def test_void_card_unknown_and_twice(self) -> None:
self.assertFalse(self.wallet.void_card("otc_nope"))
card = self.wallet.mint_card(_details(), now=_NOW)
self.assertTrue(self.wallet.void_card(card.card_id))
self.assertFalse(self.wallet.void_card(card.card_id))
def test_add_and_list_methods(self) -> None:
self.wallet.add_method("stripe-link", "pm_abc",
"Visa ....4242", "shop.example.com")
methods = self.wallet.list_methods("shop.example.com")
self.assertEqual(len(methods), 1)
self.assertEqual(methods[0]["method_id"], "pm_abc")
self.assertNotIn(_TEST_PAN, str(methods))
if __name__ == "__main__":
unittest.main()