- 支付凭证隔离: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 字段)。
404 lines
16 KiB
Python
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()
|