diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 81f6a664..40b326a9 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -361,7 +361,7 @@ var ( Symbol: "auth_identities_users_auth_identities", Columns: []*schema.Column{AuthIdentitiesColumns[9]}, RefColumns: []*schema.Column{UsersColumns[0]}, - OnDelete: schema.NoAction, + OnDelete: schema.Cascade, }, }, Indexes: []*schema.Index{ @@ -405,7 +405,7 @@ var ( Symbol: "auth_identity_channels_auth_identities_channels", Columns: []*schema.Column{AuthIdentityChannelsColumns[9]}, RefColumns: []*schema.Column{AuthIdentitiesColumns[0]}, - OnDelete: schema.NoAction, + OnDelete: schema.Cascade, }, }, Indexes: []*schema.Index{ @@ -595,7 +595,7 @@ var ( Symbol: "identity_adoption_decisions_pending_auth_sessions_adoption_decision", Columns: []*schema.Column{IdentityAdoptionDecisionsColumns[7]}, RefColumns: []*schema.Column{PendingAuthSessionsColumns[0]}, - OnDelete: schema.NoAction, + OnDelete: schema.Cascade, }, }, Indexes: []*schema.Index{ @@ -692,8 +692,11 @@ var ( Indexes: []*schema.Index{ { Name: "paymentorder_out_trade_no", - Unique: false, + Unique: true, Columns: []*schema.Column{PaymentOrdersColumns[8]}, + Annotation: &entsql.IndexAnnotation{ + Where: "out_trade_no <> ''", + }, }, { Name: "paymentorder_user_id", diff --git a/backend/ent/schema/auth_identity.go b/backend/ent/schema/auth_identity.go index e4b9ac90..0b1b56ab 100644 --- a/backend/ent/schema/auth_identity.go +++ b/backend/ent/schema/auth_identity.go @@ -79,7 +79,8 @@ func (AuthIdentity) Edges() []ent.Edge { Field("user_id"). Required(). Unique(), - edge.To("channels", AuthIdentityChannel.Type), + edge.To("channels", AuthIdentityChannel.Type). + Annotations(entsql.OnDelete(entsql.Cascade)), edge.To("adoption_decisions", IdentityAdoptionDecision.Type), } } diff --git a/backend/ent/schema/payment_order.go b/backend/ent/schema/payment_order.go index 5815d032..d25d1e5e 100644 --- a/backend/ent/schema/payment_order.go +++ b/backend/ent/schema/payment_order.go @@ -185,7 +185,9 @@ func (PaymentOrder) Edges() []ent.Edge { func (PaymentOrder) Indexes() []ent.Index { return []ent.Index{ - index.Fields("out_trade_no"), + index.Fields("out_trade_no"). + Unique(). + Annotations(entsql.IndexWhere("out_trade_no <> ''")), index.Fields("user_id"), index.Fields("status"), index.Fields("expires_at"), diff --git a/backend/ent/schema/pending_auth_session.go b/backend/ent/schema/pending_auth_session.go index 91341d49..7e95f085 100644 --- a/backend/ent/schema/pending_auth_session.go +++ b/backend/ent/schema/pending_auth_session.go @@ -119,6 +119,7 @@ func (PendingAuthSession) Edges() []ent.Edge { Field("target_user_id"). Unique(), edge.To("adoption_decision", IdentityAdoptionDecision.Type). + Annotations(entsql.OnDelete(entsql.Cascade)). Unique(), } } diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index bb58d9e3..f307bda8 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -115,7 +115,8 @@ func (User) Edges() []ent.Edge { edge.To("attribute_values", UserAttributeValue.Type), edge.To("promo_code_usages", PromoCodeUsage.Type), edge.To("payment_orders", PaymentOrder.Type), - edge.To("auth_identities", AuthIdentity.Type), + edge.To("auth_identities", AuthIdentity.Type). + Annotations(entsql.OnDelete(entsql.Cascade)), edge.To("pending_auth_sessions", PendingAuthSession.Type), } } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 44bc5c9f..f355a15d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1202,7 +1202,7 @@ func setDefaults() { viper.SetDefault("linuxdo_connect.redirect_url", "") viper.SetDefault("linuxdo_connect.frontend_redirect_url", "/auth/linuxdo/callback") viper.SetDefault("linuxdo_connect.token_auth_method", "client_secret_post") - viper.SetDefault("linuxdo_connect.use_pkce", false) + viper.SetDefault("linuxdo_connect.use_pkce", true) viper.SetDefault("linuxdo_connect.userinfo_email_path", "") viper.SetDefault("linuxdo_connect.userinfo_id_path", "") viper.SetDefault("linuxdo_connect.userinfo_username_path", "") @@ -1222,7 +1222,7 @@ func setDefaults() { viper.SetDefault("oidc_connect.redirect_url", "") viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback") viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post") - viper.SetDefault("oidc_connect.use_pkce", false) + viper.SetDefault("oidc_connect.use_pkce", true) viper.SetDefault("oidc_connect.validate_id_token", true) viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256") viper.SetDefault("oidc_connect.clock_skew_seconds", 120) diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index e0bee2f5..2bd44e78 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -937,7 +937,19 @@ func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) { Value: "", Path: oauthBindAccessTokenCookiePath, MaxAge: -1, - HttpOnly: false, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func setOAuthBindAccessTokenCookie(c *gin.Context, token string, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: oauthBindAccessTokenCookieName, + Value: url.QueryEscape(strings.TrimSpace(token)), + Path: oauthBindAccessTokenCookiePath, + MaxAge: linuxDoOAuthCookieMaxAgeSec, + HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, }) @@ -1021,6 +1033,26 @@ func (h *AuthHandler) buildOAuthBindUserCookieFromContext(c *gin.Context) (strin return buildOAuthBindUserCookieValue(*userID, h.oauthBindCookieSecret()) } +func (h *AuthHandler) PrepareOAuthBindAccessTokenCookie(c *gin.Context) { + const bearerPrefix = "Bearer " + + authHeader := strings.TrimSpace(c.GetHeader("Authorization")) + if !strings.HasPrefix(strings.ToLower(authHeader), strings.ToLower(bearerPrefix)) { + response.ErrorFrom(c, infraerrors.Unauthorized("UNAUTHORIZED", "authentication required")) + return + } + + token := strings.TrimSpace(authHeader[len(bearerPrefix):]) + if token == "" { + response.ErrorFrom(c, infraerrors.Unauthorized("UNAUTHORIZED", "authentication required")) + return + } + + setOAuthBindAccessTokenCookie(c, token, isRequestHTTPS(c)) + c.Status(http.StatusNoContent) + c.Writer.WriteHeaderNow() +} + func (h *AuthHandler) resolveOAuthBindTargetUserID(c *gin.Context) (*int64, error) { if subject, ok := servermiddleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 { return &subject.UserID, nil diff --git a/backend/internal/handler/auth_linuxdo_oauth_test.go b/backend/internal/handler/auth_linuxdo_oauth_test.go index 0c760ee9..a3d87dfb 100644 --- a/backend/internal/handler/auth_linuxdo_oauth_test.go +++ b/backend/internal/handler/auth_linuxdo_oauth_test.go @@ -5,6 +5,7 @@ import ( "context" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -226,6 +227,27 @@ func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) { require.Equal(t, -1, accessTokenCookie.MaxAge) } +func TestPrepareOAuthBindAccessTokenCookieSetsHttpOnlyCookie(t *testing.T) { + handler, client := newLinuxDoOAuthHandlerAndClient(t, false, config.LinuxDoConnectConfig{}) + t.Cleanup(func() { _ = client.Close() }) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/bind-token", nil) + req.Header.Set("Authorization", "Bearer access-token-value") + c.Request = req + + handler.PrepareOAuthBindAccessTokenCookie(c) + + require.Equal(t, http.StatusNoContent, recorder.Code) + accessTokenCookie := findCookie(recorder.Result().Cookies(), oauthBindAccessTokenCookieName) + require.NotNil(t, accessTokenCookie) + require.Equal(t, oauthBindAccessTokenCookiePath, accessTokenCookie.Path) + require.Equal(t, linuxDoOAuthCookieMaxAgeSec, accessTokenCookie.MaxAge) + require.True(t, accessTokenCookie.HttpOnly) + require.Equal(t, url.QueryEscape("access-token-value"), accessTokenCookie.Value) +} + func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { diff --git a/backend/internal/payment/wire.go b/backend/internal/payment/wire.go index 9717465d..4b7f422d 100644 --- a/backend/internal/payment/wire.go +++ b/backend/internal/payment/wire.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "fmt" "log/slog" + "strings" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/config" @@ -19,11 +20,22 @@ type EncryptionKey []byte // When the key is non-empty but invalid (bad hex or wrong length), an error is returned // to prevent startup with a misconfigured encryption key. func ProvideEncryptionKey(cfg *config.Config) (EncryptionKey, error) { - if cfg.Totp.EncryptionKey == "" { + if cfg == nil { + slog.Warn("payment encryption key not configured — encrypted payment config and resume signing will be unavailable") + return nil, nil + } + keyHex := strings.TrimSpace(cfg.Totp.EncryptionKey) + if keyHex == "" { slog.Warn("payment encryption key not configured — encrypted payment config will be unavailable") return nil, nil } - key, err := hex.DecodeString(cfg.Totp.EncryptionKey) + // Reject auto-generated TOTP keys for payment signing. + // They change across restarts/instances and can silently break resume-token flows. + if !cfg.Totp.EncryptionKeyConfigured { + slog.Warn("payment encryption/signing key is not explicitly configured; set TOTP_ENCRYPTION_KEY to enable payment resume tokens") + return nil, nil + } + key, err := hex.DecodeString(keyHex) if err != nil { return nil, fmt.Errorf("invalid payment encryption key (hex decode): %w", err) } diff --git a/backend/internal/payment/wire_test.go b/backend/internal/payment/wire_test.go new file mode 100644 index 00000000..1b360f89 --- /dev/null +++ b/backend/internal/payment/wire_test.go @@ -0,0 +1,62 @@ +package payment + +import ( + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +func TestProvideEncryptionKeySkipsAutoGeneratedTotpKey(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Totp: config.TotpConfig{ + EncryptionKey: strings.Repeat("a", 64), + EncryptionKeyConfigured: false, + }, + } + + key, err := ProvideEncryptionKey(cfg) + if err != nil { + t.Fatalf("ProvideEncryptionKey returned error: %v", err) + } + if len(key) != 0 { + t.Fatalf("encryption key len = %d, want 0", len(key)) + } +} + +func TestProvideEncryptionKeyUsesConfiguredTotpKey(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Totp: config.TotpConfig{ + EncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + EncryptionKeyConfigured: true, + }, + } + + key, err := ProvideEncryptionKey(cfg) + if err != nil { + t.Fatalf("ProvideEncryptionKey returned error: %v", err) + } + if len(key) != 32 { + t.Fatalf("encryption key len = %d, want 32", len(key)) + } +} + +func TestProvideEncryptionKeyRejectsConfiguredInvalidLength(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Totp: config.TotpConfig{ + EncryptionKey: "abcd", + EncryptionKeyConfigured: true, + }, + } + + _, err := ProvideEncryptionKey(cfg) + if err == nil { + t.Fatal("expected error for invalid key length") + } +} diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index f1032eb5..b4b75795 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -164,6 +164,7 @@ func RegisterAuthRoutes( authenticated.GET("/auth/me", h.Auth.GetCurrentUser) // 撤销所有会话(需要认证) authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions) + authenticated.POST("/auth/oauth/bind-token", h.Auth.PrepareOAuthBindAccessTokenCookie) authenticated.GET("/auth/oauth/linuxdo/bind/start", func(c *gin.Context) { query := c.Request.URL.Query() query.Set("intent", "bind_current_user") diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 904960ee..71f1eb2f 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -80,21 +80,25 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo }) return err } - // Skip amount check when paid=0 (e.g. QueryOrder doesn't return amount). - // Also skip if paid is NaN/Inf (malformed provider data). - if paid > 0 && !math.IsNaN(paid) && !math.IsInf(paid, 0) { - if math.Abs(paid-o.PayAmount) > amountToleranceCNY { - s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo}) - return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid) - } + if !isValidProviderAmount(paid) { + s.writeAuditLog(ctx, o.ID, "PAYMENT_INVALID_AMOUNT", pk, map[string]any{ + "expected": o.PayAmount, + "paid": paid, + "tradeNo": tradeNo, + }) + return fmt.Errorf("invalid paid amount from provider: %v", paid) } - // Use order's expected amount when provider didn't report one - if paid <= 0 || math.IsNaN(paid) || math.IsInf(paid, 0) { - paid = o.PayAmount + if math.Abs(paid-o.PayAmount) > amountToleranceCNY { + s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo}) + return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid) } return s.toPaid(ctx, o, tradeNo, paid, pk) } +func isValidProviderAmount(amount float64) bool { + return amount > 0 && !math.IsNaN(amount) && !math.IsInf(amount, 0) +} + func validateProviderNotificationMetadata(order *dbent.PaymentOrder, providerKey string, metadata map[string]string) error { return validateProviderSnapshotMetadata(order, providerKey, metadata) } diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index 6aed19f8..abdb59de 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -5,6 +5,7 @@ package service import ( "context" "errors" + "math" "testing" dbent "github.com/Wei-Shaw/sub2api/ent" @@ -322,6 +323,16 @@ func TestParseLegacyPaymentOrderID(t *testing.T) { assert.False(t, ok) } +func TestIsValidProviderAmount(t *testing.T) { + t.Parallel() + + assert.True(t, isValidProviderAmount(0.01)) + assert.False(t, isValidProviderAmount(0)) + assert.False(t, isValidProviderAmount(-1)) + assert.False(t, isValidProviderAmount(math.NaN())) + assert.False(t, isValidProviderAmount(math.Inf(1))) +} + func TestValidateProviderNotificationMetadataRejectsAlipaySnapshotMismatch(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 6554526e..3fdcecb5 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -139,6 +139,10 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq tm = defaultOrderTimeoutMin } exp := time.Now().Add(time.Duration(tm) * time.Minute) + outTradeNo, err := s.allocateOutTradeNo(ctx, tx) + if err != nil { + return nil, err + } providerSnapshot := buildPaymentOrderProviderSnapshot(sel, req) selectedInstanceID := "" selectedProviderKey := "" @@ -155,7 +159,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq SetPayAmount(payAmount). SetFeeRate(feeRate). SetRechargeCode(""). - SetOutTradeNo(generateOutTradeNo()). + SetOutTradeNo(outTradeNo). SetPaymentType(req.PaymentType). SetPaymentTradeNo(""). SetOrderType(req.OrderType). @@ -193,6 +197,21 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq return order, nil } +func (s *PaymentService) allocateOutTradeNo(ctx context.Context, tx *dbent.Tx) (string, error) { + const maxAttempts = 5 + for attempt := 0; attempt < maxAttempts; attempt++ { + candidate := generateOutTradeNo() + exists, err := tx.PaymentOrder.Query().Where(paymentorder.OutTradeNo(candidate)).Exist(ctx) + if err != nil { + return "", fmt.Errorf("check out_trade_no uniqueness: %w", err) + } + if !exists { + return candidate, nil + } + } + return "", fmt.Errorf("generate unique out_trade_no: exhausted %d attempts", maxAttempts) +} + func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, userID int64, max int) error { if max <= 0 { max = defaultMaxPendingOrders @@ -366,7 +385,10 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen } resumeToken := "" if resume := s.paymentResume(); resume != nil { - if resume.isSigningConfigured() { + if canonicalReturnURL != "" { + if err := resume.ensureSigningKey(); err != nil { + return nil, err + } resumeToken, err = resume.CreateToken(ResumeTokenClaims{ OrderID: order.ID, UserID: order.UserID, @@ -482,6 +504,9 @@ func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, r if err != nil { return nil, err } + if err := s.paymentResume().ensureSigningKey(); err != nil { + return nil, err + } authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base") if err != nil { diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go index ccab7c11..ffb63066 100644 --- a/backend/internal/service/payment_order_lifecycle.go +++ b/backend/internal/service/payment_order_lifecycle.go @@ -150,6 +150,16 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s return "" } if resp.Status == payment.ProviderStatusPaid { + if !isValidProviderAmount(resp.Amount) { + s.writeAuditLog(ctx, o.ID, "PAYMENT_INVALID_AMOUNT", prov.ProviderKey(), map[string]any{ + "expected": o.PayAmount, + "paid": resp.Amount, + "tradeNo": resp.TradeNo, + "queryRef": queryRef, + }) + slog.Warn("query upstream returned invalid paid amount", "orderID", o.ID, "queryRef", queryRef, "paid", resp.Amount) + return "" + } notificationTradeNo := o.PaymentTradeNo if upstreamTradeNo := strings.TrimSpace(resp.TradeNo); paymentOrderShouldPersistUpstreamTradeNo(queryRef, upstreamTradeNo, notificationTradeNo) { if _, updateErr := s.entClient.PaymentOrder.Update(). diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index 39993a2f..cabdb445 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -234,6 +234,97 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) { require.Equal(t, user.ID, redeemRepo.useCalls[0].userID) } +func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + user, err := client.User.Create(). + SetEmail("checkpaid-zero-amount@example.com"). + SetPasswordHash("hash"). + SetUsername("checkpaid-zero-amount-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(88). + SetPayAmount(88). + SetFeeRate(0). + SetRechargeCode("CHECKPAID-ZERO-AMOUNT"). + SetOutTradeNo("sub2_checkpaid_zero_amount"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + userRepo := &mockUserRepo{ + getByIDUser: &User{ + ID: user.ID, + Email: user.Email, + Username: user.Username, + Balance: 0, + }, + } + redeemRepo := &paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{ + order.RechargeCode: { + ID: 1, + Code: order.RechargeCode, + Type: RedeemTypeBalance, + Value: order.Amount, + Status: StatusUnused, + }, + }, + } + redeemService := NewRedeemService( + redeemRepo, + userRepo, + nil, + nil, + nil, + client, + nil, + ) + registry := payment.NewRegistry() + provider := &paymentOrderLifecycleQueryProvider{ + resp: &payment.QueryOrderResponse{ + TradeNo: "upstream-trade-zero", + Status: payment.ProviderStatusPaid, + Amount: 0, + }, + } + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + redeemService: redeemService, + userRepo: userRepo, + providersLoaded: true, + } + + got, err := svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID) + require.NoError(t, err) + require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) + require.Equal(t, OrderStatusPending, got.Status) + require.Empty(t, got.PaymentTradeNo) + + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusPending, reloaded.Status) + require.Empty(t, reloaded.PaymentTradeNo) + + require.Equal(t, 0.0, userRepo.getByIDUser.Balance) + require.Empty(t, redeemRepo.useCalls) +} + func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go index 16757323..23371cfd 100644 --- a/backend/internal/service/payment_order_result_test.go +++ b/backend/internal/service/payment_order_result_test.go @@ -159,6 +159,45 @@ func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testin } } +func TestMaybeBuildWeChatOAuthRequiredResponseRequiresResumeSigningKey(t *testing.T) { + t.Parallel() + + svc := &PaymentService{ + configService: &PaymentConfigService{ + settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{ + SettingKeyWeChatConnectEnabled: "true", + SettingKeyWeChatConnectAppID: "wx123456", + SettingKeyWeChatConnectAppSecret: "wechat-secret", + SettingKeyWeChatConnectMode: "mp", + SettingKeyWeChatConnectScopes: "snsapi_base", + SettingKeyWeChatConnectRedirectURL: "https://api.example.com/api/v1/auth/oauth/wechat/callback", + SettingKeyWeChatConnectFrontendRedirectURL: "/auth/wechat/callback", + }}, + // Intentionally missing payment resume signing key. + encryptionKey: nil, + }, + } + + resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{ + Amount: 12.5, + PaymentType: payment.TypeWxpay, + IsWeChatBrowser: true, + SrcURL: "https://merchant.example/payment?from=wechat", + OrderType: payment.OrderTypeBalance, + }, 12.5, 12.88, 0.03) + if resp != nil { + t.Fatalf("expected nil response, got %+v", resp) + } + if err == nil { + t.Fatal("expected error, got nil") + } + + appErr := infraerrors.FromError(err) + if appErr.Reason != "PAYMENT_RESUME_NOT_CONFIGURED" { + t.Fatalf("reason = %q, want %q", appErr.Reason, "PAYMENT_RESUME_NOT_CONFIGURED") + } +} + func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t *testing.T) { svc := newWeChatPaymentOAuthTestService(map[string]string{ SettingKeyWeChatConnectEnabled: "true", @@ -189,7 +228,8 @@ func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t func newWeChatPaymentOAuthTestService(values map[string]string) *PaymentService { return &PaymentService{ configService: &PaymentConfigService{ - settingRepo: &paymentConfigSettingRepoStub{values: values}, + settingRepo: &paymentConfigSettingRepoStub{values: values}, + encryptionKey: []byte("0123456789abcdef0123456789abcdef"), }, } } diff --git a/backend/migrations/112_add_payment_order_provider_key_snapshot.sql b/backend/migrations/112_add_payment_order_provider_key_snapshot.sql index 7ec19ae3..d331b824 100644 --- a/backend/migrations/112_add_payment_order_provider_key_snapshot.sql +++ b/backend/migrations/112_add_payment_order_provider_key_snapshot.sql @@ -1,4 +1,4 @@ -ALTER TABLE payment_orders ADD COLUMN provider_key VARCHAR(30); +ALTER TABLE payment_orders ADD COLUMN IF NOT EXISTS provider_key VARCHAR(30); UPDATE payment_orders SET provider_key = ( diff --git a/backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql b/backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql index 6eef59e2..9b037984 100644 --- a/backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql +++ b/backend/migrations/118_wechat_dual_mode_and_auth_source_defaults.sql @@ -21,12 +21,3 @@ VALUES ('auth_source_default_oidc_grant_on_signup', 'false'), ('auth_source_default_wechat_grant_on_signup', 'false') ON CONFLICT (key) DO NOTHING; - -UPDATE settings -SET value = 'false' -WHERE key IN ( - 'auth_source_default_email_grant_on_signup', - 'auth_source_default_linuxdo_grant_on_signup', - 'auth_source_default_oidc_grant_on_signup', - 'auth_source_default_wechat_grant_on_signup' -); diff --git a/backend/migrations/119_enforce_payment_orders_out_trade_no_unique.sql b/backend/migrations/119_enforce_payment_orders_out_trade_no_unique.sql new file mode 100644 index 00000000..4e256562 --- /dev/null +++ b/backend/migrations/119_enforce_payment_orders_out_trade_no_unique.sql @@ -0,0 +1,7 @@ +-- Replace the legacy non-unique index with a partial unique index. +-- Keep empty-string legacy rows compatible while enforcing uniqueness for real order IDs. +DROP INDEX IF EXISTS paymentorder_out_trade_no; + +CREATE UNIQUE INDEX IF NOT EXISTS paymentorder_out_trade_no + ON payment_orders (out_trade_no) + WHERE out_trade_no <> ''; diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go new file mode 100644 index 00000000..1c4a51fa --- /dev/null +++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMigration112UsesIdempotentAddColumn(t *testing.T) { + content, err := FS.ReadFile("112_add_payment_order_provider_key_snapshot.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS provider_key VARCHAR(30)") + require.NotContains(t, sql, "ADD COLUMN provider_key VARCHAR(30);") +} + +func TestMigration118DoesNotForceOverwriteAuthSourceGrantDefaults(t *testing.T) { + content, err := FS.ReadFile("118_wechat_dual_mode_and_auth_source_defaults.sql") + require.NoError(t, err) + + sql := string(content) + require.NotContains(t, sql, "UPDATE settings") + require.NotContains(t, sql, "SET value = 'false'") + require.True(t, strings.Contains(sql, "ON CONFLICT (key) DO NOTHING")) +} + +func TestMigration119EnforcesOutTradeNoPartialUniqueIndex(t *testing.T) { + content, err := FS.ReadFile("119_enforce_payment_orders_out_trade_no_unique.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "DROP INDEX IF EXISTS paymentorder_out_trade_no") + require.Contains(t, sql, "CREATE UNIQUE INDEX IF NOT EXISTS paymentorder_out_trade_no") + require.Contains(t, sql, "WHERE out_trade_no <> ''") +} diff --git a/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts b/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts index f95332fb..a484d7ed 100644 --- a/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts +++ b/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts @@ -173,20 +173,12 @@ describe('oauth adoption auth api', () => { expect(hasPendingOAuthSuggestedProfile({})).toBe(false) }) - it('prepares an oauth bind access token cookie before redirect binding', async () => { + it('requests an HttpOnly oauth bind cookie before redirect binding', async () => { localStorage.setItem('auth_token', 'access-token-value') - const setCookie = vi.fn() - Object.defineProperty(document, 'cookie', { - configurable: true, - get: () => '', - set: setCookie - }) - const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth') - prepareOAuthBindAccessTokenCookie() + await prepareOAuthBindAccessTokenCookie() - expect(setCookie).toHaveBeenCalledTimes(1) - expect(setCookie.mock.calls[0]?.[0]).toContain('oauth_bind_access_token=access-token-value') + expect(post).toHaveBeenCalledWith('/auth/oauth/bind-token') }) }) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 9244489c..9621c26e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -278,33 +278,11 @@ export function persistOAuthTokenContext(tokens: Partial): v } } -export function prepareOAuthBindAccessTokenCookie(): void { - if (typeof document === 'undefined' || typeof window === 'undefined') { +export async function prepareOAuthBindAccessTokenCookie(): Promise { + if (!getAuthToken()) { return } - - const token = getAuthToken() - if (!token) { - return - } - - const secure = window.location.protocol === 'https:' ? '; Secure' : '' - const path = resolveOAuthBindCookiePath() - document.cookie = - `oauth_bind_access_token=${encodeURIComponent(token)}; Path=${path}/auth/oauth; Max-Age=600; SameSite=Lax${secure}` -} - -function resolveOAuthBindCookiePath(): string { - const apiBase = ((import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1').replace(/\/$/, '') - - try { - return new URL(apiBase, window.location.origin).pathname.replace(/\/$/, '') || '/api/v1' - } catch { - if (apiBase.startsWith('/')) { - return apiBase - } - return '/api/v1' - } + await apiClient.post('/auth/oauth/bind-token') } /** diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index 32ef07e0..f6baf49d 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -153,10 +153,10 @@ export function buildOAuthBindingStartURL( return `${normalized}/auth/oauth/${provider}/start?${params.toString()}` } -export function startOAuthBinding( +export async function startOAuthBinding( provider: BindableOAuthProvider, options: BuildOAuthBindingStartURLOptions = {} -): void { +): Promise { if (typeof window === 'undefined') { return } @@ -164,7 +164,7 @@ export function startOAuthBinding( if (!startURL) { return } - prepareOAuthBindAccessTokenCookie() + await prepareOAuthBindAccessTokenCookie() window.location.href = startURL } diff --git a/frontend/src/router/__tests__/guards.spec.ts b/frontend/src/router/__tests__/guards.spec.ts index 11636139..bdf07b18 100644 --- a/frontend/src/router/__tests__/guards.spec.ts +++ b/frontend/src/router/__tests__/guards.spec.ts @@ -83,7 +83,8 @@ function simulateGuard( '/auth/callback', '/auth/linuxdo/callback', '/auth/oidc/callback', - '/auth/wechat/callback' + '/auth/wechat/callback', + '/auth/wechat/payment/callback', ] const pendingAuthPaths = ['/register', '/email-verify'] const isAllowed = @@ -131,7 +132,8 @@ function simulateGuard( '/auth/callback', '/auth/linuxdo/callback', '/auth/oidc/callback', - '/auth/wechat/callback' + '/auth/wechat/callback', + '/auth/wechat/payment/callback', ] const pendingAuthPaths = ['/register', '/email-verify'] const isAllowed = @@ -448,6 +450,18 @@ describe('路由守卫逻辑', () => { expect(redirect).toBeNull() }) + it('unauthenticated: WeChat payment callback route is allowed', () => { + const authState: MockAuthState = { + isAuthenticated: false, + isAdmin: false, + isSimpleMode: false, + backendModeEnabled: true, + hasPendingAuthSession: false, + } + const redirect = simulateGuard('/auth/wechat/payment/callback', { requiresAuth: false }, authState) + expect(redirect).toBeNull() + }) + it('unauthenticated: /register is allowed when a pending auth session exists', () => { const authState: MockAuthState = { isAuthenticated: false, diff --git a/frontend/src/router/__tests__/wechat-route.spec.ts b/frontend/src/router/__tests__/wechat-route.spec.ts index 84b20452..f85a732d 100644 --- a/frontend/src/router/__tests__/wechat-route.spec.ts +++ b/frontend/src/router/__tests__/wechat-route.spec.ts @@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => { expect(route?.meta.requiresAuth).toBe(false) expect(route?.meta.title).toBe('WeChat OAuth Callback') }) + + it('registers the WeChat payment callback route as a public route', async () => { + const { default: router } = await import('@/router') + const route = router.getRoutes().find((record) => record.name === 'WeChatPaymentOAuthCallback') + + expect(route?.path).toBe('/auth/wechat/payment/callback') + expect(route?.meta.requiresAuth).toBe(false) + expect(route?.meta.title).toBe('WeChat Payment Callback') + }) }) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1a73e8aa..b7fcf475 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -547,7 +547,8 @@ const BACKEND_MODE_CALLBACK_PATHS = [ '/auth/callback', '/auth/linuxdo/callback', '/auth/oidc/callback', - '/auth/wechat/callback' + '/auth/wechat/callback', + '/auth/wechat/payment/callback', ] const BACKEND_MODE_PENDING_AUTH_PATHS = ['/register', '/email-verify'] diff --git a/frontend/src/views/auth/WechatCallbackView.vue b/frontend/src/views/auth/WechatCallbackView.vue index 2bcc1c3d..9a71f62b 100644 --- a/frontend/src/views/auth/WechatCallbackView.vue +++ b/frontend/src/views/auth/WechatCallbackView.vue @@ -613,7 +613,7 @@ async function handleBindCurrentAccount() { return } - prepareOAuthBindAccessTokenCookie() + await prepareOAuthBindAccessTokenCookie() window.location.href = startURL } diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index 57e81f40..1af34540 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -101,7 +101,11 @@ import { ref, computed, onBeforeUnmount, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue' -import { PAYMENT_RECOVERY_STORAGE_KEY, readPaymentRecoverySnapshot } from '@/components/payment/paymentFlow' +import { + PAYMENT_RECOVERY_STORAGE_KEY, + clearPaymentRecoverySnapshot, + readPaymentRecoverySnapshot, +} from '@/components/payment/paymentFlow' import { usePaymentStore } from '@/stores/payment' import { paymentAPI } from '@/api/payment' import type { PaymentOrder } from '@/types/payment' @@ -193,6 +197,18 @@ function clearStatusRefreshTimer(): void { } } +function clearRecoverySnapshot(): void { + if (typeof window === 'undefined') return + clearPaymentRecoverySnapshot(window.localStorage, PAYMENT_RECOVERY_STORAGE_KEY) +} + +function clearRecoverySnapshotForTerminalStatus(status: string | null | undefined): void { + if (!status) return + if (!isPendingStatus(status)) { + clearRecoverySnapshot() + } +} + function scheduleStatusRefresh(refreshOrder: (() => Promise) | null): void { clearStatusRefreshTimer() if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) { @@ -204,6 +220,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise const refreshedOrder = await refreshOrder() if (refreshedOrder) { order.value = refreshedOrder + clearRecoverySnapshotForTerminalStatus(refreshedOrder.status) } if (isPendingStatus(order.value?.status)) { @@ -285,6 +302,10 @@ onMounted(async () => { if (isPendingStatus(order.value?.status)) { scheduleStatusRefresh(refreshOrder) + } else if (order.value) { + clearRecoverySnapshotForTerminalStatus(order.value.status) + } else if (returnInfo.value) { + clearRecoverySnapshot() } loading.value = false }) diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 7d037917..10aa7019 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -391,6 +391,20 @@ function resetPayment() { removeRecoverySnapshot() } +async function redirectToPaymentResult(state: PaymentRecoverySnapshot): Promise { + const query: Record = {} + if (state.orderId > 0) { + query.order_id = String(state.orderId) + } + if (state.resumeToken) { + query.resume_token = state.resumeToken + } + await router.push({ + path: '/payment/result', + query, + }) +} + function onPaymentDone() { const wasSubscription = paymentState.value.orderType === 'subscription' resetPayment() @@ -684,8 +698,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n const errMsg = String(jsapiResult.err_msg || '').toLowerCase() if (errMsg.includes('cancel')) { appStore.showInfo(t('payment.qr.cancelled')) + resetPayment() } else if (errMsg && !errMsg.includes('ok')) { applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod) + resetPayment() + } else { + const resultState = { ...decision.paymentState } + resetPayment() + await redirectToPaymentResult(resultState) } return } diff --git a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts index 34ced07a..94ae6ef8 100644 --- a/frontend/src/views/user/__tests__/PaymentResultView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentResultView.spec.ts @@ -60,6 +60,21 @@ const orderFactory = (status: string) => ({ refund_amount: 0, }) +const recoverySnapshotFactory = (resumeToken: string) => ({ + orderId: 42, + amount: 88, + qrCode: '', + expiresAt: '2099-01-01T00:10:00.000Z', + paymentType: 'alipay', + payUrl: 'https://pay.example.com/session/42', + clientSecret: '', + payAmount: 88, + orderType: 'balance', + paymentMode: 'popup', + resumeToken, + createdAt: Date.UTC(2099, 0, 1, 0, 0, 0), +}) + describe('PaymentResultView', () => { beforeEach(() => { routeState.query = {} @@ -162,6 +177,7 @@ describe('PaymentResultView', () => { expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).toContain('103.00') expect(wrapper.text()).toContain('100.00') + expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) it('refreshes a pending resume-token result until the order becomes paid', async () => { @@ -169,6 +185,10 @@ describe('PaymentResultView', () => { routeState.query = { resume_token: 'resume-77', } + window.localStorage.setItem( + PAYMENT_RECOVERY_STORAGE_KEY, + JSON.stringify(recoverySnapshotFactory('resume-77')), + ) resolveOrderPublicByResumeToken .mockResolvedValueOnce({ data: orderFactory('PENDING'), @@ -189,6 +209,7 @@ describe('PaymentResultView', () => { expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('payment.result.processing') + expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull() await vi.advanceTimersByTimeAsync(2000) await flushPromises() @@ -196,6 +217,7 @@ describe('PaymentResultView', () => { expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2) expect(wrapper.text()).toContain('payment.result.success') expect(wrapper.text()).not.toContain('payment.result.failed') + expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() }) it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => { diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts new file mode 100644 index 00000000..f60ea962 --- /dev/null +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -0,0 +1,205 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, shallowMount } from '@vue/test-utils' +import PaymentView from '../PaymentView.vue' +import { PAYMENT_RECOVERY_STORAGE_KEY } from '@/components/payment/paymentFlow' + +const routeState = vi.hoisted(() => ({ + path: '/purchase', + query: {} as Record, +})) + +const routerReplace = vi.hoisted(() => vi.fn()) +const routerPush = vi.hoisted(() => vi.fn()) +const routerResolve = vi.hoisted(() => vi.fn(() => ({ href: '/payment/stripe?mock=1' }))) +const createOrder = vi.hoisted(() => vi.fn()) +const refreshUser = vi.hoisted(() => vi.fn()) +const fetchActiveSubscriptions = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +const showError = vi.hoisted(() => vi.fn()) +const showInfo = vi.hoisted(() => vi.fn()) +const getCheckoutInfo = vi.hoisted(() => vi.fn()) +const bridgeInvoke = vi.hoisted(() => vi.fn()) + +vi.mock('vue-router', async () => { + const actual = await vi.importActual('vue-router') + return { + ...actual, + useRoute: () => routeState, + useRouter: () => ({ + replace: routerReplace, + push: routerPush, + resolve: routerResolve, + }), + } +}) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + user: { + username: 'demo-user', + balance: 0, + }, + refreshUser, + }), +})) + +vi.mock('@/stores/payment', () => ({ + usePaymentStore: () => ({ + createOrder, + }), +})) + +vi.mock('@/stores/subscriptions', () => ({ + useSubscriptionStore: () => ({ + activeSubscriptions: [], + fetchActiveSubscriptions, + }), +})) + +vi.mock('@/stores', () => ({ + useAppStore: () => ({ + showError, + showInfo, + }), +})) + +vi.mock('@/api/payment', () => ({ + paymentAPI: { + getCheckoutInfo, + }, +})) + +vi.mock('@/utils/device', () => ({ + isMobileDevice: () => true, +})) + +function checkoutInfoFixture() { + return { + data: { + methods: { + wxpay: { + daily_limit: 0, + daily_used: 0, + daily_remaining: 0, + single_min: 0, + single_max: 0, + fee_rate: 0, + available: true, + }, + }, + global_min: 0, + global_max: 0, + plans: [], + balance_disabled: false, + balance_recharge_multiplier: 1, + recharge_fee_rate: 0, + help_text: '', + help_image_url: '', + stripe_publishable_key: '', + }, + } +} + +function jsapiOrderFixture(resumeToken: string) { + return { + order_id: 123, + amount: 88, + pay_amount: 88, + fee_rate: 0, + expires_at: '2099-01-01T00:10:00.000Z', + payment_type: 'wxpay', + result_type: 'jsapi_ready' as const, + resume_token: resumeToken, + jsapi: { + appId: 'wx123', + timeStamp: '1712345678', + nonceStr: 'nonce', + package: 'prepay_id=wx123', + signType: 'RSA', + paySign: 'signed', + }, + } +} + +describe('PaymentView WeChat JSAPI flow', () => { + beforeEach(() => { + routeState.path = '/purchase' + routeState.query = { + wechat_resume: '1', + wechat_resume_token: 'resume-token-123', + } + routerReplace.mockReset().mockResolvedValue(undefined) + routerPush.mockReset().mockResolvedValue(undefined) + routerResolve.mockClear() + createOrder.mockReset() + refreshUser.mockReset() + fetchActiveSubscriptions.mockReset().mockResolvedValue(undefined) + showError.mockReset() + showInfo.mockReset() + getCheckoutInfo.mockReset().mockResolvedValue(checkoutInfoFixture()) + bridgeInvoke.mockReset() + window.localStorage.clear() + ;(window as Window & { WeixinJSBridge?: { invoke: typeof bridgeInvoke } }).WeixinJSBridge = { + invoke: bridgeInvoke, + } + }) + + it('resets payment state and redirects to /payment/result after JSAPI reports success', async () => { + createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-123')) + bridgeInvoke.mockImplementation((_action, _payload, callback) => { + callback({ err_msg: 'get_brand_wcpay_request:ok' }) + }) + + shallowMount(PaymentView, { + global: { + stubs: { + Teleport: true, + Transition: false, + }, + }, + }) + await flushPromises() + await flushPromises() + + expect(routerReplace).toHaveBeenCalledWith({ path: '/purchase', query: {} }) + expect(routerPush).toHaveBeenCalledWith({ + path: '/payment/result', + query: { + order_id: '123', + resume_token: 'resume-token-123', + }, + }) + expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() + }) + + it('resets payment state when JSAPI reports cancellation', async () => { + createOrder.mockResolvedValue(jsapiOrderFixture('resume-token-cancel')) + bridgeInvoke.mockImplementation((_action, _payload, callback) => { + callback({ err_msg: 'get_brand_wcpay_request:cancel' }) + }) + + shallowMount(PaymentView, { + global: { + stubs: { + Teleport: true, + Transition: false, + }, + }, + }) + await flushPromises() + await flushPromises() + + expect(showInfo).toHaveBeenCalledWith('payment.qr.cancelled') + expect(routerPush).not.toHaveBeenCalled() + expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/frontend/src/views/user/__tests__/paymentUx.spec.ts b/frontend/src/views/user/__tests__/paymentUx.spec.ts index c2a4ac59..8d73d1fa 100644 --- a/frontend/src/views/user/__tests__/paymentUx.spec.ts +++ b/frontend/src/views/user/__tests__/paymentUx.spec.ts @@ -28,6 +28,16 @@ describe('describePaymentScenarioError', () => { }) }) + it('maps WeChat H5 authorization errors when provider aliases use wxpay_direct', () => { + expect(describePaymentScenarioError( + { reason: 'WECHAT_H5_NOT_AUTHORIZED' }, + { paymentMethod: 'wxpay_direct', isMobile: true, isWechatBrowser: false }, + )).toEqual({ + messageKey: 'payment.errors.wechatH5NotAuthorized', + hintKey: 'payment.errors.wechatOpenInWeChatHint', + }) + }) + it('maps missing WeixinJSBridge to a JSAPI-specific prompt', () => { expect(describePaymentScenarioError( new Error('WeixinJSBridge is unavailable'),