chore(v2): P3 终审收尾——GetReserved 移 export_test / BuildRegistry 表测试 / 坏签名负路径 / 注释更正
This commit is contained in:
@@ -20,8 +20,9 @@ import (
|
|||||||
|
|
||||||
// TestE2ECryptoQuerySettles 验证真 crypto adapter(非 fake)跑通 P2 全链:
|
// TestE2ECryptoQuerySettles 验证真 crypto adapter(非 fake)跑通 P2 全链:
|
||||||
// 下单(单地址+唯一金额)→ 查单兜底(假 TronGrid only_confirmed 精确匹配)→ 入账 → webhook。
|
// 下单(单地址+唯一金额)→ 查单兜底(假 TronGrid only_confirmed 精确匹配)→ 入账 → webhook。
|
||||||
// 不经 provider.BuildRegistry(那是 main 装配期用的),直接用 crypto.New 组 gateway——
|
// 不经 providerbuild.BuildRegistry(那是 main 装配期用的),直接用 crypto.New 组 gateway——
|
||||||
// 本用例验证的是「真 adapter 跑通管线」,BuildRegistry 由 registry_build_test.go 覆盖装配决策。
|
// 本用例验证的是「真 adapter 跑通管线」,BuildRegistry 本身的装配/跳过决策由
|
||||||
|
// internal/providerbuild/registry_build_test.go 的表驱动用例覆盖。
|
||||||
func TestE2ECryptoQuerySettles(t *testing.T) {
|
func TestE2ECryptoQuerySettles(t *testing.T) {
|
||||||
const addr = "TWe2eADDRESS00000000000000000000000"
|
const addr = "TWe2eADDRESS00000000000000000000000"
|
||||||
t.Setenv("E2E_ADDRESS", addr)
|
t.Setenv("E2E_ADDRESS", addr)
|
||||||
|
|||||||
@@ -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
|
// TRADE_CLOSED(超时未付关闭/全额退款后关闭)须归一化为 failed 终态,不能停在 pending
|
||||||
// 让 SyncPendingAttempts 空转到订单永远"待处理"。
|
// 让 SyncPendingAttempts 空转到订单永远"待处理"。
|
||||||
func TestVerifyCallbackRSA_ClosedIsFailed(t *testing.T) {
|
func TestVerifyCallbackRSA_ClosedIsFailed(t *testing.T) {
|
||||||
|
|||||||
@@ -99,17 +99,6 @@ func (p *Provider) apiKey(accountID string) string {
|
|||||||
return os.Getenv("TRONGRID_API_KEY")
|
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] + 冷却预留,
|
// allocateAmount 移植 canonical pay/service.go:随机尾数 [1,tailMax] + 冷却预留,
|
||||||
// 保证同(地址,金额)在冷却窗内唯一——迟到付款绝不可能匹配到新单。64 次重试。
|
// 保证同(地址,金额)在冷却窗内唯一——迟到付款绝不可能匹配到新单。64 次重试。
|
||||||
// 预留键基于地址(链上匹配维度),防止共享地址的不同账户产生同金额碰撞。
|
// 预留键基于地址(链上匹配维度),防止共享地址的不同账户产生同金额碰撞。
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
|
// signStripe 复刻 Stripe webhook 签名头: t=<ts>,v1=hex(HMAC-SHA256(secret, "<ts>.<payload>"))
|
||||||
func signStripe(payload, secret string, ts int64) string {
|
func signStripe(payload, secret string, ts int64) string {
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user