From c4fc962db2a97b7cce36396adb4cac7e91844ad2 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Fri, 10 Jul 2026 15:32:10 +0800 Subject: [PATCH] =?UTF-8?q?chore(v2):=20P3=20=E7=BB=88=E5=AE=A1=E6=94=B6?= =?UTF-8?q?=E5=B0=BE=E2=80=94=E2=80=94GetReserved=20=E7=A7=BB=20export=5Ft?= =?UTF-8?q?est=20/=20BuildRegistry=20=E8=A1=A8=E6=B5=8B=E8=AF=95=20/=20?= =?UTF-8?q?=E5=9D=8F=E7=AD=BE=E5=90=8D=E8=B4=9F=E8=B7=AF=E5=BE=84=20/=20?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=9B=B4=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gateway/e2e_crypto_test.go | 5 +- internal/provider/alipay/alipay_test.go | 25 +++++ internal/provider/crypto/crypto.go | 11 --- internal/provider/crypto/export_test.go | 19 ++++ internal/provider/stripe/stripe_test.go | 22 +++++ internal/providerbuild/registry_build_test.go | 98 +++++++++++++++++++ 6 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 internal/provider/crypto/export_test.go create mode 100644 internal/providerbuild/registry_build_test.go diff --git a/internal/gateway/e2e_crypto_test.go b/internal/gateway/e2e_crypto_test.go index 13089e7..8eeca47 100644 --- a/internal/gateway/e2e_crypto_test.go +++ b/internal/gateway/e2e_crypto_test.go @@ -20,8 +20,9 @@ import ( // TestE2ECryptoQuerySettles 验证真 crypto adapter(非 fake)跑通 P2 全链: // 下单(单地址+唯一金额)→ 查单兜底(假 TronGrid only_confirmed 精确匹配)→ 入账 → webhook。 -// 不经 provider.BuildRegistry(那是 main 装配期用的),直接用 crypto.New 组 gateway—— -// 本用例验证的是「真 adapter 跑通管线」,BuildRegistry 由 registry_build_test.go 覆盖装配决策。 +// 不经 providerbuild.BuildRegistry(那是 main 装配期用的),直接用 crypto.New 组 gateway—— +// 本用例验证的是「真 adapter 跑通管线」,BuildRegistry 本身的装配/跳过决策由 +// internal/providerbuild/registry_build_test.go 的表驱动用例覆盖。 func TestE2ECryptoQuerySettles(t *testing.T) { const addr = "TWe2eADDRESS00000000000000000000000" t.Setenv("E2E_ADDRESS", addr) diff --git a/internal/provider/alipay/alipay_test.go b/internal/provider/alipay/alipay_test.go index c61924d..52b8672 100644 --- a/internal/provider/alipay/alipay_test.go +++ b/internal/provider/alipay/alipay_test.go @@ -130,6 +130,31 @@ func TestVerifyCallbackRSA_PaidAtFromGmtPayment(t *testing.T) { } } +// 攻击者拿一把与装配时加载的支付宝公钥不匹配的私钥伪造回调(冒充支付宝) → 验签 +// 必须失败,且绝不能返回 PaidEvent(哪怕金额/状态字段看起来"正常"也不能被当真入账)。 +func TestVerifyCallbackRSA_WrongKeyFails(t *testing.T) { + appPriv, _, aliPub := genKeys(t) + p := ali.New(buildClient(t, appPriv, aliPub)) + + _, wrongAliPriv, _ := genKeys(t) // 另一把毫不相干的"支付宝私钥" + + form := url.Values{} + form.Set("out_trade_no", "PAY-EVIL") + form.Set("trade_no", "2021EVIL") + form.Set("trade_status", "TRADE_SUCCESS") + form.Set("total_amount", "999999.00") + form.Set("sign_type", "RSA2") + form.Set("sign", signRSA2(t, wrongAliPriv, form)) + + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) + if err == nil { + t.Fatalf("want 验签失败(签名私钥与装配公钥不匹配), got event = %+v", ev) + } + if ev != nil { + t.Fatalf("验签失败时不应返回 PaidEvent, got %+v", ev) + } +} + // TRADE_CLOSED(超时未付关闭/全额退款后关闭)须归一化为 failed 终态,不能停在 pending // 让 SyncPendingAttempts 空转到订单永远"待处理"。 func TestVerifyCallbackRSA_ClosedIsFailed(t *testing.T) { diff --git a/internal/provider/crypto/crypto.go b/internal/provider/crypto/crypto.go index 39132b1..a6551cb 100644 --- a/internal/provider/crypto/crypto.go +++ b/internal/provider/crypto/crypto.go @@ -99,17 +99,6 @@ func (p *Provider) apiKey(accountID string) string { return os.Getenv("TRONGRID_API_KEY") } -// GetReserved 仅用于测试,返回预留表副本。 -func (p *Provider) GetReserved() map[string]time.Time { - p.mu.Lock() - defer p.mu.Unlock() - m := make(map[string]time.Time, len(p.reserved)) - for k, v := range p.reserved { - m[k] = v - } - return m -} - // allocateAmount 移植 canonical pay/service.go:随机尾数 [1,tailMax] + 冷却预留, // 保证同(地址,金额)在冷却窗内唯一——迟到付款绝不可能匹配到新单。64 次重试。 // 预留键基于地址(链上匹配维度),防止共享地址的不同账户产生同金额碰撞。 diff --git a/internal/provider/crypto/export_test.go b/internal/provider/crypto/export_test.go new file mode 100644 index 0000000..4905e06 --- /dev/null +++ b/internal/provider/crypto/export_test.go @@ -0,0 +1,19 @@ +package crypto + +import "time" + +// GetReserved 仅用于测试,返回预留表副本。 +// +// 这是标准 Go export_test.go 手法:本文件属 package crypto(内部包), +// 但文件名以 _test.go 结尾只在 `go test` 时编译,不进生产二进制/生产 API 面; +// crypto_test.go(package crypto_test,黑盒测试)通过它访问私有字段 +// p.reserved,而无需在 crypto.go 上暴露一个只为测试存在的导出方法。 +func (p *Provider) GetReserved() map[string]time.Time { + p.mu.Lock() + defer p.mu.Unlock() + m := make(map[string]time.Time, len(p.reserved)) + for k, v := range p.reserved { + m[k] = v + } + return m +} diff --git a/internal/provider/stripe/stripe_test.go b/internal/provider/stripe/stripe_test.go index 1ec09e5..09510db 100644 --- a/internal/provider/stripe/stripe_test.go +++ b/internal/provider/stripe/stripe_test.go @@ -101,6 +101,28 @@ func TestVerifyWebhook(t *testing.T) { } } +// 用错误的签名密钥(冒充攻击者伪造 webhook)→ ConstructEventWithOptions 内部 HMAC 校验 +// 必失败,VerifyCallback 必须返回 error,绝不能返回 PaidEvent(哪怕 payload 里状态是 paid)。 +func TestVerifyWebhookWrongSecretFails(t *testing.T) { + ts := fakeStripeAPI(t) + defer ts.Close() + p := newStripe(t, ts) + + payload := `{"id":"evt_evil","object":"event","type":"checkout.session.completed","data":{"object":{"id":"cs_test_123","object":"checkout.session","amount_total":2999,"currency":"usd","payment_status":"paid"}}}` + sig := signStripe(payload, "whsec_completely_different_secret", time.Now().Unix()) + + ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{ + Raw: []byte(payload), + Headers: map[string]string{"Stripe-Signature": sig}, + }) + if err == nil { + t.Fatalf("want 验签失败(签名密钥不匹配), got event = %+v", ev) + } + if ev != nil { + t.Fatalf("验签失败时不应返回 PaidEvent, got %+v", ev) + } +} + // signStripe 复刻 Stripe webhook 签名头: t=,v1=hex(HMAC-SHA256(secret, ".")) func signStripe(payload, secret string, ts int64) string { mac := hmac.New(sha256.New, []byte(secret)) diff --git a/internal/providerbuild/registry_build_test.go b/internal/providerbuild/registry_build_test.go new file mode 100644 index 0000000..2fab2ef --- /dev/null +++ b/internal/providerbuild/registry_build_test.go @@ -0,0 +1,98 @@ +package providerbuild_test + +import ( + "reflect" + "sort" + "testing" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/providerbuild" +) + +// TestBuildRegistry 表驱动覆盖 BuildRegistry 的装配/跳过决策:据 accounts.Registry +// 的 enabled 账户 + 对应 env 凭证是否齐备,决定各渠道是否 Register——缺凭证只 +// log+skip,绝不 fatal(允许只上线部分渠道)。 +// +// alipay 的"凭证齐备→真注册"分支这里不覆盖(需要构造能通过 +// x509.MarshalPKIXPublicKey/LoadAliPayPublicKey 的 RSA 密钥对,详见 +// internal/provider/alipay/alipay_test.go 的 genKeys——挪来此处会让本测试文件 +// 显著变重,且该构造逻辑本身已被 alipay 包自己的单测覆盖)。这里只验证 alipay +// 的"缺凭证→跳过、不 fatal、注册表里没有它"分支,以及它在混合场景里不会误伤 +// 同批次里凭证齐备的其它渠道——这正是 BuildRegistry 装配决策本身要保证的行为。 +func TestBuildRegistry(t *testing.T) { + cases := []struct { + name string + accounts []config.AccountConfig + env map[string]string + want []string // 期望注册的 Method() 集合 + }{ + { + name: "无任何 enabled 账户_空注册表", + accounts: nil, + want: nil, + }, + { + name: "crypto_enabled_且凭证齐备_注册crypto", + accounts: []config.AccountConfig{ + {AccountID: "cry-1", Channel: "crypto", Enabled: true, CredentialEnvPrefix: "t_cry"}, + }, + env: map[string]string{ + "T_CRY_ADDRESS": "TWtest0000000000000000000000000000", + "T_CRY_TRONGRID_KEY": "k", + }, + want: []string{"crypto"}, + }, + { + name: "alipay_enabled_但凭证不全_跳过不fatal_注册表无alipay", + accounts: []config.AccountConfig{ + {AccountID: "ali-1", Channel: "alipay", Enabled: true, CredentialEnvPrefix: "t_ali"}, + }, + // 故意不设 T_ALI_APP_ID / _APP_PRIVATE_KEY / _ALIPAY_PUBLIC_KEY: + // 若 BuildRegistry 对此 fatal/panic,本测试直接挂掉,已是断言的一部分。 + want: nil, + }, + { + name: "stripe_enabled_且凭证齐备_注册stripe", + accounts: []config.AccountConfig{ + {AccountID: "st-1", Channel: "stripe", Enabled: true, CredentialEnvPrefix: "t_st"}, + }, + env: map[string]string{ + "T_ST_SECRET_KEY": "sk_test_x", + "T_ST_WEBHOOK_SECRET": "whsec_x", + }, + want: []string{"stripe"}, + }, + { + name: "混合_一个凭证齐备一个不全_只注册齐备的那个", + accounts: []config.AccountConfig{ + {AccountID: "st-2", Channel: "stripe", Enabled: true, CredentialEnvPrefix: "t_st2"}, + {AccountID: "ali-2", Channel: "alipay", Enabled: true, CredentialEnvPrefix: "t_ali2"}, + }, + env: map[string]string{ + "T_ST2_SECRET_KEY": "sk_test_y", + "T_ST2_WEBHOOK_SECRET": "whsec_y", + // ali-2 故意留白凭证。 + }, + want: []string{"stripe"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for k, v := range tc.env { + t.Setenv(k, v) + } + acctReg := accounts.New(tc.accounts) + reg := providerbuild.BuildRegistry(acctReg) + + got := reg.Methods() + sort.Strings(got) + want := append([]string(nil), tc.want...) + sort.Strings(want) + if !reflect.DeepEqual(got, want) && !(len(got) == 0 && len(want) == 0) { + t.Fatalf("Methods() = %v, want %v", got, want) + } + }) + } +}