import hashlib import hmac import json import os import time from unittest.mock import patch from fastapi.testclient import TestClient from database import SessionLocal from main import app, seed from models import InvoiceApplication, PaymentRefund, TokenAccount, TokenPaymentOrder, TokenPlan, User from services.token_billing import DEFAULT_TOKEN_GRANT client = TestClient(app) def _huihui_virtual_headers(secret, body, timestamp=None): timestamp = str(timestamp if timestamp is not None else int(time.time())) signature = hmac.new(secret.encode(), timestamp.encode() + b"\n" + body, hashlib.sha256).hexdigest() return {"X-Huihui-Timestamp": timestamp, "X-Huihui-Signature": signature} def test_huihui_virtual_callback_requires_signature_and_exact_order_details(authorization_context): seed() context = authorization_context db = SessionLocal() try: user = db.query(User).filter(User.id == context["owner"].id).one() user.wechat_mp_openid = "openid-huihui-callback" user.wechat_mp_session_key = "session-huihui-callback" plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one() plan.virtual_product_id = "points_plan_1" db.commit() finally: db.close() secret = "huihui-virtual-callback-test-secret-123456" env = { "WECHAT_VIRTUAL_ENV": "sandbox", "WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key", "WECHAT_VIRTUAL_OFFER_ID": "offer-1", "HUIHUI_VIRTUAL_CALLBACK_SECRET": secret, } with patch.dict(os.environ, env): created = client.post( "/api/token/charge", headers=context["owner_headers"], json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"}, ).json()["data"] payload = { "orderNo": created["orderNo"], "paymentNo": f"huihui-virtual-payment-{context['suffix']}", "status": "SUCCESS", "amountCents": 1000, "productId": "points_plan_1", "openId": "openid-huihui-callback", "env": 1, } path = "/api/token/payment/huihui/virtual/notify" body = json.dumps(payload, separators=(",", ":")).encode() assert client.post(path, content=body, headers=_huihui_virtual_headers(secret, body, 1)).status_code == 401 assert client.post(path, content=body, headers={"X-Huihui-Timestamp": str(int(time.time())), "X-Huihui-Signature": "bad"}).status_code == 401 wrong = json.dumps({**payload, "amountCents": 999}, separators=(",", ":")).encode() assert client.post(path, content=wrong, headers=_huihui_virtual_headers(secret, wrong)).status_code == 422 first = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body)) second = client.post(path, content=body, headers=_huihui_virtual_headers(secret, body)) assert first.json()["data"] == {"received": True, "paid": True} assert second.json()["data"] == {"received": True, "duplicate": True} db = SessionLocal() try: order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one() account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one() assert order.status == "paid" assert account.balance == DEFAULT_TOKEN_GRANT + 2_000_000 finally: db.close() def _signature(token, timestamp, nonce): return hashlib.sha1("".join(sorted([token, timestamp, nonce])).encode()).hexdigest() def test_virtual_payment_callback_and_refund_are_idempotent(authorization_context): seed() context = authorization_context db = SessionLocal() try: user = db.query(User).filter(User.id == context["owner"].id).one() user.wechat_mp_openid = "openid-flow" user.wechat_mp_session_key = "session-flow" plan = db.query(TokenPlan).filter(TokenPlan.id == "1").one() plan.virtual_product_id = "points_plan_1" db.commit() finally: db.close() env = { "WECHAT_VIRTUAL_ENV": "sandbox", "WECHAT_VIRTUAL_SANDBOX_APP_KEY": "sandbox-key", "WECHAT_VIRTUAL_OFFER_ID": "offer-1", "WECHAT_VIRTUAL_CALLBACK_TOKEN": "callback-token", "AVATAR_FINANCE_ADMIN_SECRET": "finance-admin-secret-123", } with patch.dict(os.environ, env): created = client.post( "/api/token/charge", headers=context["owner_headers"], json={"planId": "1", "paymentMethod": "wechat", "payScene": "LITE"}, ).json()["data"] assert created["provider"] == "wechat_virtual" params = json.loads(created["payMessage"]) assert params["mode"] == "short_series_goods" assert "session-flow" not in created["payMessage"] notify = { "Event": "xpay_goods_deliver_notify", "OutTradeNo": created["orderNo"], "OpenId": "openid-flow", "Env": 1, "GoodsInfo": json.dumps({"ProductId": "points_plan_1", "ActualPrice": 1000}), "WeChatPayInfo": json.dumps({"TransactionId": "wx-transaction-1"}), } query = {"timestamp": "100", "nonce": "nonce", "signature": _signature("callback-token", "100", "nonce")} assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0 assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0 invoice = client.post( f"/api/token/orders/{created['orderNo']}/invoice", headers=context["owner_headers"], json={"title": "测试用户", "invoiceType": "personal", "email": "test@example.com"}, ).json()["data"] assert invoice["status"] == "pending" with patch("routers.tokens.request_wechat_virtual_refund", return_value={"errcode": 0}): refund_response = client.post( f"/api/token/admin/orders/{created['orderNo']}/refund", headers={"X-Avatar-Finance-Key": "finance-admin-secret-123"}, json={"reason": "用户申请退款", "operator": "tester"}, ) assert refund_response.json()["data"]["status"] == "processing" refund_no = refund_response.json()["data"]["refundNo"] refund_notify = { "Event": "xpay_refund_notify", "MchOrderId": created["orderNo"], "MchRefundId": refund_no, "WxRefundId": "wx-refund-1", "RefundFee": 1000, "RetCode": 0, } assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0 assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=refund_notify).json()["ErrCode"] == 0 assert client.post("/api/token/payment/wechat/virtual/notify", params=query, json=notify).json()["ErrCode"] == 0 db = SessionLocal() try: order = db.query(TokenPaymentOrder).filter(TokenPaymentOrder.order_no == created["orderNo"]).one() account = db.query(TokenAccount).filter(TokenAccount.user_id == context["owner"].id).one() refund = db.query(PaymentRefund).filter(PaymentRefund.refund_no == refund_no).one() invoice = db.query(InvoiceApplication).filter(InvoiceApplication.order_no == created["orderNo"]).one() assert order.status == "refunded" assert refund.status == "succeeded" assert invoice.status == "cancelled" assert account.balance == DEFAULT_TOKEN_GRANT finally: db.close()