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 字段)。
This commit is contained in:
1 parent
90c42f6e1d
commit
b55b49b358
7 files changed
+1939
-8
No files matched your search
@@ -0,0 +1,133 @@
|
||||
"""对齐 Muse P10 结账检测测试:detect_checkout 纯函数。
|
||||
|
||||
只用标准库,不启动浏览器、不联网。覆盖:
|
||||
- URL 信号(结账路径 / 购物车 / 普通页 / 坏 URL);
|
||||
- 无障碍树信号(支付字段 / 密码字段 / 下单按钮 / 金额文本);
|
||||
- 中英文特征;节点为空;页面金额只作展示信号。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.chv.checkout_detect import detect_checkout
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.chv.checkout_detect import detect_checkout
|
||||
|
||||
|
||||
def _node(role: str, name: str) -> dict:
|
||||
return {"role": role, "name": name}
|
||||
|
||||
|
||||
class CheckoutUrlTest(unittest.TestCase):
|
||||
def test_checkout_path_is_checkout_high(self) -> None:
|
||||
verdict = detect_checkout("https://shop.example.com/checkout")
|
||||
self.assertTrue(verdict.is_checkout)
|
||||
self.assertEqual(verdict.confidence, "medium")
|
||||
self.assertIn("checkout_url", verdict.reasons)
|
||||
|
||||
def test_payment_path_is_checkout(self) -> None:
|
||||
verdict = detect_checkout(
|
||||
"https://shop.example.com/order/payment?step=2")
|
||||
self.assertTrue(verdict.is_checkout)
|
||||
self.assertIn("checkout_url", verdict.reasons)
|
||||
|
||||
def test_cart_path_is_not_checkout(self) -> None:
|
||||
verdict = detect_checkout("https://shop.example.com/cart")
|
||||
self.assertFalse(verdict.is_checkout)
|
||||
self.assertEqual(verdict.confidence, "low")
|
||||
self.assertIn("cart_url", verdict.reasons)
|
||||
|
||||
def test_plain_page_is_not_checkout(self) -> None:
|
||||
verdict = detect_checkout("https://shop.example.com/products/1")
|
||||
self.assertFalse(verdict.is_checkout)
|
||||
self.assertEqual(verdict.confidence, "low")
|
||||
self.assertEqual(verdict.reasons, [])
|
||||
|
||||
def test_bad_url_does_not_crash(self) -> None:
|
||||
verdict = detect_checkout("::::not a url::::")
|
||||
self.assertFalse(verdict.is_checkout)
|
||||
|
||||
def test_no_nodes_url_only_decision(self) -> None:
|
||||
verdict = detect_checkout(
|
||||
"https://shop.example.com/checkout", None)
|
||||
self.assertTrue(verdict.is_checkout)
|
||||
|
||||
|
||||
class CheckoutNodesTest(unittest.TestCase):
|
||||
def test_payment_fields_make_checkout_high(self) -> None:
|
||||
nodes = [_node("textbox", "Card number"),
|
||||
_node("textbox", "Expiry date"),
|
||||
_node("textbox", "Security code (CVC)")]
|
||||
verdict = detect_checkout("https://shop.example.com/buy",
|
||||
nodes)
|
||||
self.assertTrue(verdict.is_checkout)
|
||||
self.assertEqual(verdict.confidence, "high")
|
||||
self.assertEqual(verdict.form_risk, "payment")
|
||||
self.assertIn("payment_fields", verdict.reasons)
|
||||
|
||||
def test_password_field_marks_form_risk(self) -> None:
|
||||
nodes = [_node("textbox", "Password")]
|
||||
verdict = detect_checkout("https://shop.example.com/login",
|
||||
nodes)
|
||||
self.assertFalse(verdict.is_checkout)
|
||||
self.assertEqual(verdict.form_risk, "password")
|
||||
|
||||
def test_order_button_plus_amount_is_checkout(self) -> None:
|
||||
nodes = [_node("button", "Place order"),
|
||||
_node("text", "Total: $50.00")]
|
||||
verdict = detect_checkout("https://shop.example.com/review",
|
||||
nodes)
|
||||
self.assertTrue(verdict.is_checkout)
|
||||
self.assertEqual(verdict.confidence, "medium")
|
||||
self.assertEqual(verdict.amount_text, "$50.00")
|
||||
|
||||
def test_order_button_alone_is_not_checkout(self) -> None:
|
||||
nodes = [_node("button", "Place order")]
|
||||
verdict = detect_checkout("https://shop.example.com/review",
|
||||
nodes)
|
||||
self.assertFalse(verdict.is_checkout)
|
||||
|
||||
def test_amount_only_is_not_checkout(self) -> None:
|
||||
nodes = [_node("text", "Total: $50.00")]
|
||||
verdict = detect_checkout("https://shop.example.com/review",
|
||||
nodes)
|
||||
self.assertFalse(verdict.is_checkout)
|
||||
self.assertEqual(verdict.amount_text, "$50.00")
|
||||
|
||||
def test_chinese_payment_hints(self) -> None:
|
||||
nodes = [_node("textbox", "卡号"),
|
||||
_node("button", "立即支付")]
|
||||
verdict = detect_checkout("https://shop.example.com/buy",
|
||||
nodes)
|
||||
self.assertTrue(verdict.is_checkout)
|
||||
self.assertEqual(verdict.form_risk, "payment")
|
||||
|
||||
def test_checkout_url_plus_payment_fields_high(self) -> None:
|
||||
nodes = [_node("textbox", "Card number"),
|
||||
_node("button", "Pay now")]
|
||||
verdict = detect_checkout("https://shop.example.com/checkout",
|
||||
nodes)
|
||||
self.assertTrue(verdict.is_checkout)
|
||||
self.assertEqual(verdict.confidence, "high")
|
||||
self.assertIn("checkout_url", verdict.reasons)
|
||||
self.assertIn("payment_fields", verdict.reasons)
|
||||
|
||||
def test_yen_and_currency_code_amounts(self) -> None:
|
||||
nodes = [_node("text", "合計:¥1,200"),
|
||||
_node("text", "Total 25.00 USD")]
|
||||
verdict = detect_checkout("https://shop.example.com/review",
|
||||
nodes)
|
||||
self.assertEqual(verdict.amount_text, "¥1,200")
|
||||
|
||||
def test_empty_nodes_plain_page(self) -> None:
|
||||
verdict = detect_checkout("https://shop.example.com/", [])
|
||||
self.assertFalse(verdict.is_checkout)
|
||||
self.assertIsNone(verdict.form_risk)
|
||||
self.assertIsNone(verdict.amount_text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,346 @@
|
||||
"""对齐 Muse P10 支付安全门端到端测试:PaymentGate 编排。
|
||||
|
||||
只用标准库、假时钟与沙箱钱包,绝不启动真实 VM、不联网、
|
||||
不产生真实扣款。覆盖:
|
||||
- 完整购买链(检测 → 路由 → 限额 → 审批 → 铸卡 → 记账);
|
||||
- 审批 payload 含确切购买详情;无 details 时 payload 与 P5 一致;
|
||||
- 无审批不支付、对话内伪造批准无效、答复过期/重放无效;
|
||||
- 详情被篡改即拒且审批保持待处理(同一答复换正确详情仍可用);
|
||||
- 审批拒绝即停、不扣款;超限额审批前即拒;限额不可用时不编造;
|
||||
- 无钱包路由即拒;已保存凭证路由;取消待支付;扣款失败 void 卡;
|
||||
- 审计里绝无卡号。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cloud.chv.approval_channel import (
|
||||
ApprovalRequest,
|
||||
make_answer,
|
||||
request_approval,
|
||||
)
|
||||
from cloud.chv.checkout_detect import CheckoutVerdict
|
||||
from cloud.chv.payments import (
|
||||
PaymentAudit,
|
||||
PaymentCredentialVault,
|
||||
PaymentGate,
|
||||
PurchaseDetails,
|
||||
SimulatedWalletBackend,
|
||||
SpendLimits,
|
||||
)
|
||||
from cloud.chv.sentinel import ApprovalQueue
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.chv.approval_channel import (
|
||||
ApprovalRequest,
|
||||
make_answer,
|
||||
request_approval,
|
||||
)
|
||||
from cloud.chv.checkout_detect import CheckoutVerdict
|
||||
from cloud.chv.payments import (
|
||||
PaymentAudit,
|
||||
PaymentCredentialVault,
|
||||
PaymentGate,
|
||||
PurchaseDetails,
|
||||
SimulatedWalletBackend,
|
||||
SpendLimits,
|
||||
)
|
||||
from cloud.chv.sentinel import ApprovalQueue
|
||||
|
||||
_NOW = 1_700_000_000.0
|
||||
_SECRET = "chan-secret"
|
||||
|
||||
|
||||
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,
|
||||
merchant: str = "Example Store") -> PurchaseDetails:
|
||||
return PurchaseDetails(
|
||||
merchant=merchant, merchant_host="shop.example.com",
|
||||
items=(("Widget", 2, 2500),), amount_minor=amount,
|
||||
currency="USD", wallet_provider="stripe-link")
|
||||
|
||||
|
||||
def _checkout() -> CheckoutVerdict:
|
||||
return CheckoutVerdict(True, "high", ["checkout_url"], "payment",
|
||||
"$50.00")
|
||||
|
||||
|
||||
def _not_checkout() -> CheckoutVerdict:
|
||||
return CheckoutVerdict(False, "low", ["cart_url"], None, None)
|
||||
|
||||
|
||||
def _answer_for(payload: dict, verdict: str, secret: str = _SECRET):
|
||||
"""按 payload 重建等价请求对象并签发答复(expires_at
|
||||
按默认 900 秒 TTL 反推,nonce 与 pending_id 取自 payload)。"""
|
||||
req = ApprovalRequest(
|
||||
payload["pending_id"], payload["target"],
|
||||
payload["reasons"], nonce=payload["nonce"],
|
||||
now=payload["expires_at"] - 900)
|
||||
return make_answer(req, verdict, secret)
|
||||
|
||||
|
||||
class GateTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.clock = _Clock()
|
||||
self.queue = ApprovalQueue(clock=self.clock)
|
||||
self.wallet = SimulatedWalletBackend(clock=self.clock)
|
||||
self.vault = PaymentCredentialVault(clock=self.clock)
|
||||
self.audit = PaymentAudit(clock=self.clock)
|
||||
self.gate = PaymentGate(
|
||||
self.queue, self.wallet, self.vault, _SECRET,
|
||||
clock=self.clock, audit=self.audit)
|
||||
|
||||
def _begin(self, details: PurchaseDetails | None = None,
|
||||
limits: SpendLimits | None = None) -> dict:
|
||||
return self.gate.begin_purchase(
|
||||
details or _details(), _checkout(), limits=limits,
|
||||
now=self.clock.now)
|
||||
|
||||
def test_begin_ok_payload_has_exact_details(self) -> None:
|
||||
result = self._begin()
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["route"], "wallet")
|
||||
self.assertEqual(result["provider"], "stripe-link")
|
||||
payload = result["payload"]
|
||||
details = payload["details"]
|
||||
self.assertEqual(details["merchant"], "Example Store")
|
||||
self.assertEqual(details["amount_minor"], 5000)
|
||||
self.assertEqual(details["currency"], "USD")
|
||||
self.assertEqual(details["items"][0]["name"], "Widget")
|
||||
self.assertEqual(details["items"][0]["qty"], 2)
|
||||
self.assertEqual(len(self.queue.list_pending()), 1)
|
||||
self.assertIn(result["pending_id"], self.gate.pending_ids())
|
||||
|
||||
def test_begin_not_checkout_refused(self) -> None:
|
||||
result = self.gate.begin_purchase(
|
||||
_details(), _not_checkout(), now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "not_checkout")
|
||||
self.assertEqual(self.queue.list_pending(), [])
|
||||
|
||||
def test_begin_no_wallet_route_refused(self) -> None:
|
||||
wallet = SimulatedWalletBackend(providers=(), clock=self.clock)
|
||||
gate = PaymentGate(self.queue, wallet, self.vault, _SECRET,
|
||||
clock=self.clock, audit=self.audit)
|
||||
result = gate.begin_purchase(_details(), _checkout(),
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "no_wallet_route")
|
||||
|
||||
def test_begin_saved_credential_route(self) -> None:
|
||||
self.vault.store("shop-pay", "pm_saved", "MC ....5555",
|
||||
"shop.example.com")
|
||||
result = self._begin()
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["route"], "saved")
|
||||
self.assertEqual(result["provider"], "shop-pay")
|
||||
|
||||
def test_begin_over_limit_refused_before_approval(self) -> None:
|
||||
limits = SpendLimits(per_purchase_minor=4999)
|
||||
result = self._begin(limits=limits)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "over_per_purchase_limit")
|
||||
self.assertEqual(self.queue.list_pending(), [])
|
||||
|
||||
def test_begin_limits_unavailable_still_approves(self) -> None:
|
||||
result = self._begin(limits=SpendLimits())
|
||||
self.assertTrue(result["ok"])
|
||||
events = [r["event"] for r in self.audit.list_records()]
|
||||
self.assertIn("payment_approval_requested", events)
|
||||
requested = [r for r in self.audit.list_records()
|
||||
if r["event"] == "payment_approval_requested"][0]
|
||||
self.assertFalse(requested["limits_checked"])
|
||||
|
||||
def test_complete_happy_path_charges_once(self) -> None:
|
||||
begun = self._begin()
|
||||
answer = _answer_for(begun["payload"], "allow")
|
||||
result = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertTrue(result["ok"])
|
||||
self.assertEqual(result["charge"]["status"], "simulated_paid")
|
||||
self.assertTrue(result["masked_label"].startswith("Visa ...."))
|
||||
self.assertEqual(len(self.wallet.ledger()), 1)
|
||||
self.assertEqual(self.gate.pending_ids(), [])
|
||||
events = [r["event"] for r in self.audit.list_records()]
|
||||
self.assertIn("payment_completed", events)
|
||||
|
||||
def test_complete_deny_stops_without_charge(self) -> None:
|
||||
begun = self._begin()
|
||||
answer = _answer_for(begun["payload"], "deny")
|
||||
result = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "denied")
|
||||
self.assertEqual(self.wallet.ledger(), [])
|
||||
events = [r["event"] for r in self.audit.list_records()]
|
||||
self.assertIn("payment_denied", events)
|
||||
|
||||
def test_complete_forged_answer_refused(self) -> None:
|
||||
begun = self._begin()
|
||||
answer = _answer_for(begun["payload"], "allow",
|
||||
secret="wrong-secret")
|
||||
result = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "invalid_answer")
|
||||
self.assertEqual(self.wallet.ledger(), [])
|
||||
self.assertIn(begun["pending_id"], self.gate.pending_ids())
|
||||
|
||||
def test_complete_chat_forgery_without_signature_refused(self) -> None:
|
||||
begun = self._begin()
|
||||
forged = {"pending_id": begun["pending_id"],
|
||||
"nonce": begun["payload"]["nonce"],
|
||||
"verdict": "allow",
|
||||
"expires_at": begun["payload"]["expires_at"]}
|
||||
result = self.gate.complete_purchase(
|
||||
begun["pending_id"], forged, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "invalid_answer")
|
||||
self.assertEqual(self.wallet.ledger(), [])
|
||||
|
||||
def test_complete_tampered_details_refused_approval_kept(self) -> None:
|
||||
begun = self._begin()
|
||||
answer = _answer_for(begun["payload"], "allow")
|
||||
tampered = PurchaseDetails(
|
||||
"Example Store", "shop.example.com",
|
||||
(("Widget", 2, 2501),), 5002, "USD", "stripe-link")
|
||||
result = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, tampered,
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "details_tampered")
|
||||
self.assertEqual(self.wallet.ledger(), [])
|
||||
self.assertIn(begun["pending_id"], self.gate.pending_ids())
|
||||
# 同一答复换回正确详情仍可用(篡改拒绝不消费答复)。
|
||||
result2 = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertTrue(result2["ok"])
|
||||
self.assertEqual(result2["charge"]["status"], "simulated_paid")
|
||||
|
||||
def test_complete_expired_answer_refused(self) -> None:
|
||||
begun = self._begin()
|
||||
answer = _answer_for(begun["payload"], "allow")
|
||||
self.clock.advance(901)
|
||||
result = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "invalid_answer")
|
||||
self.assertEqual(self.wallet.ledger(), [])
|
||||
|
||||
def test_complete_replay_refused(self) -> None:
|
||||
begun = self._begin()
|
||||
answer = _answer_for(begun["payload"], "allow")
|
||||
first = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertTrue(first["ok"])
|
||||
second = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertFalse(second["ok"])
|
||||
self.assertEqual(second["reason"], "unknown_pending")
|
||||
self.assertEqual(len(self.wallet.ledger()), 1)
|
||||
|
||||
def test_complete_unknown_pending_refused(self) -> None:
|
||||
begun = self._begin()
|
||||
answer = _answer_for(begun["payload"], "allow")
|
||||
result = self.gate.complete_purchase(
|
||||
"pending_nope", answer, _details(), now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "unknown_pending")
|
||||
|
||||
def test_cancel_pending_then_complete_fails(self) -> None:
|
||||
begun = self._begin()
|
||||
cancelled = self.gate.cancel_purchase(
|
||||
begun["pending_id"], now=self.clock.now)
|
||||
self.assertTrue(cancelled["ok"])
|
||||
answer = _answer_for(begun["payload"], "allow")
|
||||
result = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "unknown_pending")
|
||||
events = [r["event"] for r in self.audit.list_records()]
|
||||
self.assertIn("payment_cancelled", events)
|
||||
|
||||
def test_cancel_unknown_pending_refused(self) -> None:
|
||||
result = self.gate.cancel_purchase("pending_nope",
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "unknown_pending")
|
||||
|
||||
def test_charge_failure_voids_card_no_retry(self) -> None:
|
||||
begun = self._begin()
|
||||
self.wallet.fail_next_charge = True
|
||||
answer = _answer_for(begun["payload"], "allow")
|
||||
result = self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["reason"], "wallet_declined")
|
||||
ledger = self.wallet.ledger()
|
||||
self.assertEqual(len(ledger), 1)
|
||||
self.assertEqual(ledger[0]["status"], "simulated_failed")
|
||||
|
||||
def test_audit_never_contains_card_numbers(self) -> None:
|
||||
begun = self._begin()
|
||||
answer = _answer_for(begun["payload"], "allow")
|
||||
self.gate.complete_purchase(
|
||||
begun["pending_id"], answer, _details(),
|
||||
now=self.clock.now)
|
||||
blob = str(self.audit.list_records())
|
||||
self.assertNotIn("400000000000", blob)
|
||||
self.assertNotIn("4242424242424242", blob)
|
||||
|
||||
|
||||
class ApprovalChannelDetailsTest(unittest.TestCase):
|
||||
def test_payload_without_details_unchanged_from_p5(self) -> None:
|
||||
queue = ApprovalQueue(clock=_Clock())
|
||||
record = {"pending_id": "p1",
|
||||
"connector": "payment", "method": "purchase",
|
||||
"scheme": "https", "host": "shop.example.com",
|
||||
"port": 443, "path": "/checkout",
|
||||
"reasons": ["payment_approval"]}
|
||||
req = request_approval(record, _SECRET, now=_NOW)
|
||||
self.assertNotIn("details", req.payload())
|
||||
self.assertEqual(req.payload()["target"]["host"],
|
||||
"shop.example.com")
|
||||
|
||||
def test_payload_with_details_carries_exact_purchase(self) -> None:
|
||||
queue = ApprovalQueue(clock=_Clock())
|
||||
record = {"pending_id": "p2",
|
||||
"connector": "payment", "method": "purchase",
|
||||
"scheme": "https", "host": "shop.example.com",
|
||||
"port": 443, "path": "/checkout",
|
||||
"reasons": ["payment_approval"]}
|
||||
req = request_approval(record, _SECRET, now=_NOW,
|
||||
details=_details().describe())
|
||||
payload = req.payload()
|
||||
self.assertEqual(payload["details"]["amount_minor"], 5000)
|
||||
self.assertEqual(payload["details"]["merchant"],
|
||||
"Example Store")
|
||||
self.assertNotIn("card_number", str(payload))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,403 @@
|
||||
"""对齐 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()
|
||||
Reference in new issue
Block a user