diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index e608aad..edd3cbc 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -221,3 +221,63 @@ func TestCreateOrderCurrencyFromChannelCapability(t *testing.T) { t.Fatalf("order = %s/%d want USDT/29990000", o.Currency, o.AmountMinor) } } + +// cnyOnlyProvider 是本测试文件专用的最小 stub provider(结算币种固定 CNY), +// 陪 fake(结算 USDT)凑出"两个结算币种不同的方法"场景,用来直测 RetryOrder 换渠道 +// 触发 gateway.ErrCurrencyMismatch 的分支(gateway.go:177 附近)。不复用 +// internal/provider/provider_test.go 的 stubProvider——那个类型未导出且在 +// package provider_test 里,gateway_test 包拿不到。 +type cnyOnlyProvider struct{} + +func (cnyOnlyProvider) Method() string { return "cny-only" } + +func (cnyOnlyProvider) Capabilities() provider.Capabilities { + return provider.Capabilities{ + RenderTypes: []provider.RenderType{provider.RenderQR}, + SettleCurrencies: []string{"CNY"}, + } +} + +func (cnyOnlyProvider) Create(_ context.Context, req provider.CreateRequest) (*provider.Session, error) { + return &provider.Session{ProviderRef: "CNYONLY-" + req.OutTradeNo, RenderType: provider.RenderQR, Payload: map[string]any{}}, nil +} + +func (cnyOnlyProvider) VerifyCallback(context.Context, provider.CallbackInput) (*provider.PaidEvent, error) { + return nil, errors.New("cnyOnlyProvider: not implemented") +} + +func (cnyOnlyProvider) Query(context.Context, provider.QueryRequest) (*provider.PaidEvent, error) { + return &provider.PaidEvent{Status: provider.PaidPending}, nil +} + +// TestRetryOrderCurrencyMismatch 直测 gateway.go:177 附近的触发条件:换到一个结算 +// 币种与订单不同的渠道 retry,必须报 ErrCurrencyMismatch(而不是静默换币重定价—— +// P3 范围之外,见 RetryOrder 注释)。此前只在 handler 层经 HTTP 间接测到 409。 +func TestRetryOrderCurrencyMismatch(t *testing.T) { + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + subs := store.NewSubscriptionStore(db) + chargebacks := store.NewChargebackStore(db) + preg := provider.NewRegistry() + preg.Register(fake.New()) // "fake" 结算 USDT + preg.Register(cnyOnlyProvider{}) // "cny-only" 结算 CNY + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + {AccountID: "cny-a1", Channel: "cny-only", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + g := gateway.New(orders, refunds, preg, picker, stubResolver{}, &spyEnqueuer{}, "global", subs, chargebacks) + ctx := context.Background() + + // 用 cny-only 下单(结算 CNY),再 retry 到 fake(结算 USDT)→ 币种不符。 + res, err := g.CreateOrder(ctx, gateway.CreateOrderInput{SKU: "pro_year", Method: "cny-only"}) + if err != nil { + t.Fatalf("create: %v", err) + } + + _, err = g.RetryOrder(ctx, res.OrderNo, "fake", nil) + if !errors.Is(err, gateway.ErrCurrencyMismatch) { + t.Fatalf("RetryOrder 换到结算币种不同的渠道应报 ErrCurrencyMismatch, got %v", err) + } +} diff --git a/internal/handler/devmock_test.go b/internal/handler/devmock_test.go new file mode 100644 index 0000000..550fe9e --- /dev/null +++ b/internal/handler/devmock_test.go @@ -0,0 +1,113 @@ +package handler_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/wangjia/pay/config" + "github.com/wangjia/pay/internal/accounts" + "github.com/wangjia/pay/internal/gateway" + "github.com/wangjia/pay/internal/handler" + "github.com/wangjia/pay/internal/model" + "github.com/wangjia/pay/internal/provider" + "github.com/wangjia/pay/internal/provider/fake" + "github.com/wangjia/pay/internal/store" + "github.com/wangjia/pay/internal/webhook" +) + +// buildDevMockEngine 装配一个带 DevMockHandler 的最小 gin engine:与 main.go 的 +// MOCK_CHANNEL_ENABLED 挂载点(POST /api/v2/dev/orders/:order_no/mark-paid)同形, +// 但走真实 webhook.Notifier + store.WebhookStore(而非 nopEnqueuer),这样才能断言 +// mark-paid 触发的 webhook 出站 outbox 真的入队了一行——handler 包既有的 +// buildEngine()/buildEngineWithStore() 都用 nopEnqueuer,查不到 outbox。 +func buildDevMockEngine(t *testing.T) (*gin.Engine, *gateway.Gateway, *store.OrderStore, *store.WebhookStore) { + t.Helper() + gin.SetMode(gin.TestMode) + db := model.OpenTestDB(t) + orders := store.NewOrderStore(db) + refunds := store.NewRefundStore(db) + subs := store.NewSubscriptionStore(db) + chargebacks := store.NewChargebackStore(db) + webhookStore := store.NewWebhookStore(db) + + preg := provider.NewRegistry() + preg.Register(fake.New()) + areg := accounts.New([]config.AccountConfig{ + {AccountID: "fake-a1", Channel: "fake", Region: "global", Enabled: true, Weight: 1}, + }) + picker := accounts.NewRouter(areg, nil, nil) + + // bizConfig/orderPaid 只是 Notifier 投递(DeliverPending)才用得到;本测试只验证 + // Enqueue 落 outbox 一行,不跑投递循环,给最简单的桩即可。 + notifier := webhook.NewNotifier(webhookStore, + func(string) (config.BizSystemConfig, bool) { return config.BizSystemConfig{}, true }, + func(string) (bool, error) { return true, nil }, + ) + g := gateway.New(orders, refunds, preg, picker, oneResolver{}, notifier, "global", subs, chargebacks) + + devH := handler.NewDevMockHandler(db, g) + r := gin.New() + r.POST("/api/v2/dev/orders/:order_no/mark-paid", devH.MarkPaid) + return r, g, orders, webhookStore +} + +// TestDevMockMarkPaid 覆盖 dev-only「模拟付款成功」端点的最小闭环:fake 渠道下单 +// (带 biz_system,否则 Settle 不会入队 webhook,见 gateway/settle.go +// enqueuePaymentSucceeded)→ POST mark-paid → 200 + 订单翻 paid + webhook outbox +// 有一行 payment.succeeded 待投递。 +func TestDevMockMarkPaid(t *testing.T) { + r, g, orders, webhookStore := buildDevMockEngine(t) + ctx := t.Context() + + res, err := g.CreateOrder(ctx, gateway.CreateOrderInput{ + SKU: "pro_year", Method: "fake", BizSystem: "pangolin", BizRef: "u-1", + }) + if err != nil { + t.Fatalf("CreateOrder: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/v2/dev/orders/"+res.OrderNo+"/mark-paid", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("mark-paid code=%d body=%s", w.Code, w.Body.String()) + } + + o, err := orders.GetOrder(res.OrderNo) + if err != nil { + t.Fatalf("GetOrder: %v", err) + } + if o.Status != model.OrderPaidV2 { + t.Fatalf("订单状态 = %v, want paid", o.Status) + } + + rows, err := webhookStore.ListUndelivered(10) + if err != nil { + t.Fatalf("ListUndelivered: %v", err) + } + found := false + for _, row := range rows { + if row.OutTradeNo == res.OrderNo && row.EventType == "payment.succeeded" { + found = true + } + } + if !found { + t.Fatalf("webhook outbox 应有一行 %s/payment.succeeded 待投递, rows=%+v", res.OrderNo, rows) + } +} + +// TestDevMockMarkPaidOrderNotFound 覆盖找不到订单(未走 fake 渠道下单/order_no 错) +// 时的 404 分支,不误判成 500。 +func TestDevMockMarkPaidOrderNotFound(t *testing.T) { + r, _, _, _ := buildDevMockEngine(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v2/dev/orders/GHOST-ORDER/mark-paid", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("未知订单 mark-paid code=%d, want 404, body=%s", w.Code, w.Body.String()) + } +} diff --git a/internal/middleware/ratelimit_internal_test.go b/internal/middleware/ratelimit_internal_test.go index 6c7dcbb..6ffdfa6 100644 --- a/internal/middleware/ratelimit_internal_test.go +++ b/internal/middleware/ratelimit_internal_test.go @@ -3,6 +3,7 @@ package middleware import ( "fmt" "testing" + "time" ) // jiu 反馈波安全 review item 2:攻击者可用无限伪造 IP(哪怕已按 SetTrustedProxies(nil) @@ -38,3 +39,83 @@ func TestIPRateLimiterBucketMapBounded(t *testing.T) { t.Fatalf("超限请求后 buckets map 不应继续增长: size=%d max=%d", sizeAfterOverflow, maxBuckets) } } + +// TestIPRateLimiterSweepLocked_EvictsStaleKeepsFresh 白盒直测 sweepLocked 本身的 +// 回收语义(补 TestIPRateLimiterBucketMapBounded 没覆盖到的部分——那条只验证 +// map 有硬上限,没验证"过期 bucket 真的会被回收"这件事):>10min 无请求的桶应被 +// 回收,近期活跃的桶应保留。updated 字段用白盒测试直接拨表,不必真等 10 分钟。 +func TestIPRateLimiterSweepLocked_EvictsStaleKeepsFresh(t *testing.T) { + rl := NewIPRateLimiter(1) + now := time.Now() + + rl.allow("stale-ip") + rl.allow("fresh-ip") + + rl.mu.Lock() + stale := rl.buckets["stale-ip"] + fresh := rl.buckets["fresh-ip"] + rl.mu.Unlock() + + stale.mu.Lock() + stale.updated = now.Add(-11 * time.Minute) // 超过 10min 阈值 + stale.mu.Unlock() + + fresh.mu.Lock() + fresh.updated = now.Add(-1 * time.Minute) // 未超阈值 + fresh.mu.Unlock() + + rl.mu.Lock() + rl.sweepLocked(now) + _, staleStillThere := rl.buckets["stale-ip"] + _, freshStillThere := rl.buckets["fresh-ip"] + rl.mu.Unlock() + + if staleStillThere { + t.Fatalf("超过 10min 不活跃的桶应被 sweepLocked 回收,仍在 map 中") + } + if !freshStillThere { + t.Fatalf("近期活跃(<10min)的桶不应被误回收") + } +} + +// TestIPRateLimiterOverflowRecoversViaStaleSweep 端到端验证:硬上限已满时,一个 +// 早已沉寂(>10min 不活跃)的桶会在下一次 allow() 触发的 sweep 里被回收腾出位置, +// 新 IP 能正常建桶并放行——而不是被永久拒绝。这是 maxBuckets backstop 之外真实 +// 存在的自愈路径(allow() 里 len(buckets)>=maxBuckets 分支先 sweepLocked 再判定, +// 见 ratelimit.go)。 +func TestIPRateLimiterOverflowRecoversViaStaleSweep(t *testing.T) { + rl := NewIPRateLimiter(1) + + for i := 0; i < maxBuckets; i++ { + rl.allow(fmt.Sprintf("filler-ip-%d", i)) + } + + // 把其中一个桶拨成 11 分钟前不活跃,模拟"曾经的来源已沉寂"。 + rl.mu.Lock() + b := rl.buckets["filler-ip-0"] + rl.mu.Unlock() + b.mu.Lock() + b.updated = time.Now().Add(-11 * time.Minute) + b.mu.Unlock() + + // map 已满,新 IP 进来触发 sweep:应回收 filler-ip-0 的位置,新 IP 首次请求 + // (满 burst)应放行,而不是被当成"硬上限已满"直接拒绝。 + if !rl.allow("new-ip-after-sweep") { + t.Fatalf("stale 桶应被 sweep 回收腾出位置,新 IP 首次请求应放行,却被拒绝") + } + + rl.mu.Lock() + _, staleStillThere := rl.buckets["filler-ip-0"] + _, newThere := rl.buckets["new-ip-after-sweep"] + size := len(rl.buckets) + rl.mu.Unlock() + if staleStillThere { + t.Fatalf("stale 桶 filler-ip-0 应已被回收") + } + if !newThere { + t.Fatalf("新 IP 应已建桶") + } + if size > maxBuckets { + t.Fatalf("回收后总数不应超过硬上限: size=%d max=%d", size, maxBuckets) + } +} diff --git a/internal/provider/fake/fake_test.go b/internal/provider/fake/fake_test.go index b3fbefc..e0f53e3 100644 --- a/internal/provider/fake/fake_test.go +++ b/internal/provider/fake/fake_test.go @@ -34,6 +34,54 @@ func TestFakeCreateAndVerify(t *testing.T) { } } +// TestFakeCreateRedirect 覆盖 Create 按 metadata["render"]="redirect" 出的 redirect +// 三态分支(pangolin /buy 联调门控,见 fake.go Create 注释)。 +func TestFakeCreateRedirect(t *testing.T) { + p := fake.New() + ctx := context.Background() + + sess, err := p.Create(ctx, provider.CreateRequest{ + OutTradeNo: "PAY-REDIRECT-1", AmountMinor: 100, Currency: "USDT", + Metadata: map[string]string{"render": "redirect"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if sess.RenderType != provider.RenderRedirect { + t.Fatalf("render_type = %v, want redirect", sess.RenderType) + } + url, _ := sess.Payload["url"].(string) + if !strings.Contains(url, "mock-cashier") || !strings.Contains(url, "PAY-REDIRECT-1") { + t.Fatalf("payload.url = %q, want 含 mock-cashier 与 out_trade_no", url) + } +} + +// TestFakeCreateQR 覆盖 metadata["render"]="qr" 分支。 +func TestFakeCreateQR(t *testing.T) { + p := fake.New() + ctx := context.Background() + + sess, err := p.Create(ctx, provider.CreateRequest{ + OutTradeNo: "PAY-QR-1", AmountMinor: 12345, Currency: "CNY", + Metadata: map[string]string{"render": "qr"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if sess.RenderType != provider.RenderQR { + t.Fatalf("render_type = %v, want qr", sess.RenderType) + } + if _, ok := sess.Payload["qr_content"]; !ok { + t.Fatalf("payload 缺 qr_content: %+v", sess.Payload) + } + if got := sess.Payload["display_amount"]; got != "12345" { + t.Fatalf("payload.display_amount = %v, want 12345", got) + } + if got := sess.Payload["currency"]; got != "CNY" { + t.Fatalf("payload.currency = %v, want CNY", got) + } +} + func TestFakeQueryPreset(t *testing.T) { p := fake.New() ctx := context.Background() diff --git a/internal/providerbuild/registry_build_test.go b/internal/providerbuild/registry_build_test.go index 2fab2ef..ef15886 100644 --- a/internal/providerbuild/registry_build_test.go +++ b/internal/providerbuild/registry_build_test.go @@ -96,3 +96,54 @@ func TestBuildRegistry(t *testing.T) { }) } } + +// TestBuildRegistryMockChannelEnabled 覆盖 config.C.MockChannelEnabled=true 时的 +// fake 装配分支:注册表含 "fake",且其结算币种被设成 ["CNY","USDT"](CNY 排首位, +// 对齐 pangolin 下单实际结算币种,见 registry.go 顶部注释)。config.C 是包级全局, +// 测试内直接改字段 + defer 复原,避免污染同包/其它包测试的默认值(与 +// handler 包 ratelimit_test.go / refund_test.go 的 config.C 约定一致)。 +func TestBuildRegistryMockChannelEnabled(t *testing.T) { + prev := config.C.MockChannelEnabled + config.C.MockChannelEnabled = true + defer func() { config.C.MockChannelEnabled = prev }() + + acctReg := accounts.New(nil) + reg := providerbuild.BuildRegistry(acctReg) + + methods := reg.Methods() + found := false + for _, m := range methods { + if m == "fake" { + found = true + } + } + if !found { + t.Fatalf("MockChannelEnabled=true 时 Methods() 应含 fake, got %v", methods) + } + + fp, err := reg.Get("fake") + if err != nil { + t.Fatalf("Get(fake): %v", err) + } + caps := fp.Capabilities() + if len(caps.SettleCurrencies) == 0 || caps.SettleCurrencies[0] != "CNY" { + t.Fatalf("mock 装配应把 fake 结算币种设成 CNY 优先, got %v", caps.SettleCurrencies) + } +} + +// TestBuildRegistryMockChannelDisabledByDefault 反向覆盖:MockChannelEnabled=false +// (生产默认值)时注册表不含 "fake",证明 mock 渠道对生产零影响。 +func TestBuildRegistryMockChannelDisabledByDefault(t *testing.T) { + prev := config.C.MockChannelEnabled + config.C.MockChannelEnabled = false + defer func() { config.C.MockChannelEnabled = prev }() + + acctReg := accounts.New(nil) + reg := providerbuild.BuildRegistry(acctReg) + + for _, m := range reg.Methods() { + if m == "fake" { + t.Fatalf("MockChannelEnabled=false(默认)时 Methods() 不应含 fake, got %v", reg.Methods()) + } + } +}