fix(review): harden payment, oauth, and migration paths
This commit is contained in:
@@ -361,7 +361,7 @@ var (
|
|||||||
Symbol: "auth_identities_users_auth_identities",
|
Symbol: "auth_identities_users_auth_identities",
|
||||||
Columns: []*schema.Column{AuthIdentitiesColumns[9]},
|
Columns: []*schema.Column{AuthIdentitiesColumns[9]},
|
||||||
RefColumns: []*schema.Column{UsersColumns[0]},
|
RefColumns: []*schema.Column{UsersColumns[0]},
|
||||||
OnDelete: schema.NoAction,
|
OnDelete: schema.Cascade,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Indexes: []*schema.Index{
|
Indexes: []*schema.Index{
|
||||||
@@ -405,7 +405,7 @@ var (
|
|||||||
Symbol: "auth_identity_channels_auth_identities_channels",
|
Symbol: "auth_identity_channels_auth_identities_channels",
|
||||||
Columns: []*schema.Column{AuthIdentityChannelsColumns[9]},
|
Columns: []*schema.Column{AuthIdentityChannelsColumns[9]},
|
||||||
RefColumns: []*schema.Column{AuthIdentitiesColumns[0]},
|
RefColumns: []*schema.Column{AuthIdentitiesColumns[0]},
|
||||||
OnDelete: schema.NoAction,
|
OnDelete: schema.Cascade,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Indexes: []*schema.Index{
|
Indexes: []*schema.Index{
|
||||||
@@ -595,7 +595,7 @@ var (
|
|||||||
Symbol: "identity_adoption_decisions_pending_auth_sessions_adoption_decision",
|
Symbol: "identity_adoption_decisions_pending_auth_sessions_adoption_decision",
|
||||||
Columns: []*schema.Column{IdentityAdoptionDecisionsColumns[7]},
|
Columns: []*schema.Column{IdentityAdoptionDecisionsColumns[7]},
|
||||||
RefColumns: []*schema.Column{PendingAuthSessionsColumns[0]},
|
RefColumns: []*schema.Column{PendingAuthSessionsColumns[0]},
|
||||||
OnDelete: schema.NoAction,
|
OnDelete: schema.Cascade,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Indexes: []*schema.Index{
|
Indexes: []*schema.Index{
|
||||||
@@ -692,8 +692,11 @@ var (
|
|||||||
Indexes: []*schema.Index{
|
Indexes: []*schema.Index{
|
||||||
{
|
{
|
||||||
Name: "paymentorder_out_trade_no",
|
Name: "paymentorder_out_trade_no",
|
||||||
Unique: false,
|
Unique: true,
|
||||||
Columns: []*schema.Column{PaymentOrdersColumns[8]},
|
Columns: []*schema.Column{PaymentOrdersColumns[8]},
|
||||||
|
Annotation: &entsql.IndexAnnotation{
|
||||||
|
Where: "out_trade_no <> ''",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "paymentorder_user_id",
|
Name: "paymentorder_user_id",
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ func (AuthIdentity) Edges() []ent.Edge {
|
|||||||
Field("user_id").
|
Field("user_id").
|
||||||
Required().
|
Required().
|
||||||
Unique(),
|
Unique(),
|
||||||
edge.To("channels", AuthIdentityChannel.Type),
|
edge.To("channels", AuthIdentityChannel.Type).
|
||||||
|
Annotations(entsql.OnDelete(entsql.Cascade)),
|
||||||
edge.To("adoption_decisions", IdentityAdoptionDecision.Type),
|
edge.To("adoption_decisions", IdentityAdoptionDecision.Type),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,9 @@ func (PaymentOrder) Edges() []ent.Edge {
|
|||||||
|
|
||||||
func (PaymentOrder) Indexes() []ent.Index {
|
func (PaymentOrder) Indexes() []ent.Index {
|
||||||
return []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("user_id"),
|
||||||
index.Fields("status"),
|
index.Fields("status"),
|
||||||
index.Fields("expires_at"),
|
index.Fields("expires_at"),
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ func (PendingAuthSession) Edges() []ent.Edge {
|
|||||||
Field("target_user_id").
|
Field("target_user_id").
|
||||||
Unique(),
|
Unique(),
|
||||||
edge.To("adoption_decision", IdentityAdoptionDecision.Type).
|
edge.To("adoption_decision", IdentityAdoptionDecision.Type).
|
||||||
|
Annotations(entsql.OnDelete(entsql.Cascade)).
|
||||||
Unique(),
|
Unique(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ func (User) Edges() []ent.Edge {
|
|||||||
edge.To("attribute_values", UserAttributeValue.Type),
|
edge.To("attribute_values", UserAttributeValue.Type),
|
||||||
edge.To("promo_code_usages", PromoCodeUsage.Type),
|
edge.To("promo_code_usages", PromoCodeUsage.Type),
|
||||||
edge.To("payment_orders", PaymentOrder.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),
|
edge.To("pending_auth_sessions", PendingAuthSession.Type),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1202,7 +1202,7 @@ func setDefaults() {
|
|||||||
viper.SetDefault("linuxdo_connect.redirect_url", "")
|
viper.SetDefault("linuxdo_connect.redirect_url", "")
|
||||||
viper.SetDefault("linuxdo_connect.frontend_redirect_url", "/auth/linuxdo/callback")
|
viper.SetDefault("linuxdo_connect.frontend_redirect_url", "/auth/linuxdo/callback")
|
||||||
viper.SetDefault("linuxdo_connect.token_auth_method", "client_secret_post")
|
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_email_path", "")
|
||||||
viper.SetDefault("linuxdo_connect.userinfo_id_path", "")
|
viper.SetDefault("linuxdo_connect.userinfo_id_path", "")
|
||||||
viper.SetDefault("linuxdo_connect.userinfo_username_path", "")
|
viper.SetDefault("linuxdo_connect.userinfo_username_path", "")
|
||||||
@@ -1222,7 +1222,7 @@ func setDefaults() {
|
|||||||
viper.SetDefault("oidc_connect.redirect_url", "")
|
viper.SetDefault("oidc_connect.redirect_url", "")
|
||||||
viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback")
|
viper.SetDefault("oidc_connect.frontend_redirect_url", "/auth/oidc/callback")
|
||||||
viper.SetDefault("oidc_connect.token_auth_method", "client_secret_post")
|
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.validate_id_token", true)
|
||||||
viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256")
|
viper.SetDefault("oidc_connect.allowed_signing_algs", "RS256,ES256,PS256")
|
||||||
viper.SetDefault("oidc_connect.clock_skew_seconds", 120)
|
viper.SetDefault("oidc_connect.clock_skew_seconds", 120)
|
||||||
|
|||||||
@@ -937,7 +937,19 @@ func clearOAuthBindAccessTokenCookie(c *gin.Context, secure bool) {
|
|||||||
Value: "",
|
Value: "",
|
||||||
Path: oauthBindAccessTokenCookiePath,
|
Path: oauthBindAccessTokenCookiePath,
|
||||||
MaxAge: -1,
|
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,
|
Secure: secure,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
@@ -1021,6 +1033,26 @@ func (h *AuthHandler) buildOAuthBindUserCookieFromContext(c *gin.Context) (strin
|
|||||||
return buildOAuthBindUserCookieValue(*userID, h.oauthBindCookieSecret())
|
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) {
|
func (h *AuthHandler) resolveOAuthBindTargetUserID(c *gin.Context) (*int64, error) {
|
||||||
if subject, ok := servermiddleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
if subject, ok := servermiddleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
|
||||||
return &subject.UserID, nil
|
return &subject.UserID, nil
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -226,6 +227,27 @@ func TestLinuxDoOAuthBindStartAcceptsAccessTokenCookie(t *testing.T) {
|
|||||||
require.Equal(t, -1, accessTokenCookie.MaxAge)
|
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) {
|
func TestLinuxDoOAuthCallbackCreatesLoginPendingSessionForExistingIdentityUser(t *testing.T) {
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.URL.Path {
|
switch r.URL.Path {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"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
|
// 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.
|
// to prevent startup with a misconfigured encryption key.
|
||||||
func ProvideEncryptionKey(cfg *config.Config) (EncryptionKey, error) {
|
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")
|
slog.Warn("payment encryption key not configured — encrypted payment config will be unavailable")
|
||||||
return nil, nil
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid payment encryption key (hex decode): %w", err)
|
return nil, fmt.Errorf("invalid payment encryption key (hex decode): %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
62
backend/internal/payment/wire_test.go
Normal file
62
backend/internal/payment/wire_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,7 @@ func RegisterAuthRoutes(
|
|||||||
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
|
authenticated.GET("/auth/me", h.Auth.GetCurrentUser)
|
||||||
// 撤销所有会话(需要认证)
|
// 撤销所有会话(需要认证)
|
||||||
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
|
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) {
|
authenticated.GET("/auth/oauth/linuxdo/bind/start", func(c *gin.Context) {
|
||||||
query := c.Request.URL.Query()
|
query := c.Request.URL.Query()
|
||||||
query.Set("intent", "bind_current_user")
|
query.Set("intent", "bind_current_user")
|
||||||
|
|||||||
@@ -80,21 +80,25 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo
|
|||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Skip amount check when paid=0 (e.g. QueryOrder doesn't return amount).
|
if !isValidProviderAmount(paid) {
|
||||||
// Also skip if paid is NaN/Inf (malformed provider data).
|
s.writeAuditLog(ctx, o.ID, "PAYMENT_INVALID_AMOUNT", pk, map[string]any{
|
||||||
if paid > 0 && !math.IsNaN(paid) && !math.IsInf(paid, 0) {
|
"expected": o.PayAmount,
|
||||||
|
"paid": paid,
|
||||||
|
"tradeNo": tradeNo,
|
||||||
|
})
|
||||||
|
return fmt.Errorf("invalid paid amount from provider: %v", paid)
|
||||||
|
}
|
||||||
if math.Abs(paid-o.PayAmount) > amountToleranceCNY {
|
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})
|
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 fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, 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
|
|
||||||
}
|
|
||||||
return s.toPaid(ctx, o, tradeNo, paid, pk)
|
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 {
|
func validateProviderNotificationMetadata(order *dbent.PaymentOrder, providerKey string, metadata map[string]string) error {
|
||||||
return validateProviderSnapshotMetadata(order, providerKey, metadata)
|
return validateProviderSnapshotMetadata(order, providerKey, metadata)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
@@ -322,6 +323,16 @@ func TestParseLegacyPaymentOrderID(t *testing.T) {
|
|||||||
assert.False(t, ok)
|
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) {
|
func TestValidateProviderNotificationMetadataRejectsAlipaySnapshotMismatch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
|
|||||||
tm = defaultOrderTimeoutMin
|
tm = defaultOrderTimeoutMin
|
||||||
}
|
}
|
||||||
exp := time.Now().Add(time.Duration(tm) * time.Minute)
|
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)
|
providerSnapshot := buildPaymentOrderProviderSnapshot(sel, req)
|
||||||
selectedInstanceID := ""
|
selectedInstanceID := ""
|
||||||
selectedProviderKey := ""
|
selectedProviderKey := ""
|
||||||
@@ -155,7 +159,7 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
|
|||||||
SetPayAmount(payAmount).
|
SetPayAmount(payAmount).
|
||||||
SetFeeRate(feeRate).
|
SetFeeRate(feeRate).
|
||||||
SetRechargeCode("").
|
SetRechargeCode("").
|
||||||
SetOutTradeNo(generateOutTradeNo()).
|
SetOutTradeNo(outTradeNo).
|
||||||
SetPaymentType(req.PaymentType).
|
SetPaymentType(req.PaymentType).
|
||||||
SetPaymentTradeNo("").
|
SetPaymentTradeNo("").
|
||||||
SetOrderType(req.OrderType).
|
SetOrderType(req.OrderType).
|
||||||
@@ -193,6 +197,21 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
|
|||||||
return order, nil
|
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 {
|
func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, userID int64, max int) error {
|
||||||
if max <= 0 {
|
if max <= 0 {
|
||||||
max = defaultMaxPendingOrders
|
max = defaultMaxPendingOrders
|
||||||
@@ -366,7 +385,10 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
|
|||||||
}
|
}
|
||||||
resumeToken := ""
|
resumeToken := ""
|
||||||
if resume := s.paymentResume(); resume != nil {
|
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{
|
resumeToken, err = resume.CreateToken(ResumeTokenClaims{
|
||||||
OrderID: order.ID,
|
OrderID: order.ID,
|
||||||
UserID: order.UserID,
|
UserID: order.UserID,
|
||||||
@@ -482,6 +504,9 @@ func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, r
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.paymentResume().ensureSigningKey(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
|
authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if resp.Status == payment.ProviderStatusPaid {
|
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
|
notificationTradeNo := o.PaymentTradeNo
|
||||||
if upstreamTradeNo := strings.TrimSpace(resp.TradeNo); paymentOrderShouldPersistUpstreamTradeNo(queryRef, upstreamTradeNo, notificationTradeNo) {
|
if upstreamTradeNo := strings.TrimSpace(resp.TradeNo); paymentOrderShouldPersistUpstreamTradeNo(queryRef, upstreamTradeNo, notificationTradeNo) {
|
||||||
if _, updateErr := s.entClient.PaymentOrder.Update().
|
if _, updateErr := s.entClient.PaymentOrder.Update().
|
||||||
|
|||||||
@@ -234,6 +234,97 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
|
|||||||
require.Equal(t, user.ID, redeemRepo.useCalls[0].userID)
|
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) {
|
func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
client := newPaymentOrderLifecycleTestClient(t)
|
client := newPaymentOrderLifecycleTestClient(t)
|
||||||
|
|||||||
@@ -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) {
|
func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t *testing.T) {
|
||||||
svc := newWeChatPaymentOAuthTestService(map[string]string{
|
svc := newWeChatPaymentOAuthTestService(map[string]string{
|
||||||
SettingKeyWeChatConnectEnabled: "true",
|
SettingKeyWeChatConnectEnabled: "true",
|
||||||
@@ -190,6 +229,7 @@ func newWeChatPaymentOAuthTestService(values map[string]string) *PaymentService
|
|||||||
return &PaymentService{
|
return &PaymentService{
|
||||||
configService: &PaymentConfigService{
|
configService: &PaymentConfigService{
|
||||||
settingRepo: &paymentConfigSettingRepoStub{values: values},
|
settingRepo: &paymentConfigSettingRepoStub{values: values},
|
||||||
|
encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
UPDATE payment_orders
|
||||||
SET provider_key = (
|
SET provider_key = (
|
||||||
|
|||||||
@@ -21,12 +21,3 @@ VALUES
|
|||||||
('auth_source_default_oidc_grant_on_signup', 'false'),
|
('auth_source_default_oidc_grant_on_signup', 'false'),
|
||||||
('auth_source_default_wechat_grant_on_signup', 'false')
|
('auth_source_default_wechat_grant_on_signup', 'false')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
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'
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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 <> '';
|
||||||
@@ -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 <> ''")
|
||||||
|
}
|
||||||
@@ -173,20 +173,12 @@ describe('oauth adoption auth api', () => {
|
|||||||
expect(hasPendingOAuthSuggestedProfile({})).toBe(false)
|
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')
|
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')
|
const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
|
||||||
|
|
||||||
prepareOAuthBindAccessTokenCookie()
|
await prepareOAuthBindAccessTokenCookie()
|
||||||
|
|
||||||
expect(setCookie).toHaveBeenCalledTimes(1)
|
expect(post).toHaveBeenCalledWith('/auth/oauth/bind-token')
|
||||||
expect(setCookie.mock.calls[0]?.[0]).toContain('oauth_bind_access_token=access-token-value')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -278,33 +278,11 @@ export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareOAuthBindAccessTokenCookie(): void {
|
export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
|
||||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
if (!getAuthToken()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await apiClient.post('/auth/oauth/bind-token')
|
||||||
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'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -153,10 +153,10 @@ export function buildOAuthBindingStartURL(
|
|||||||
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startOAuthBinding(
|
export async function startOAuthBinding(
|
||||||
provider: BindableOAuthProvider,
|
provider: BindableOAuthProvider,
|
||||||
options: BuildOAuthBindingStartURLOptions = {}
|
options: BuildOAuthBindingStartURLOptions = {}
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ export function startOAuthBinding(
|
|||||||
if (!startURL) {
|
if (!startURL) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
prepareOAuthBindAccessTokenCookie()
|
await prepareOAuthBindAccessTokenCookie()
|
||||||
window.location.href = startURL
|
window.location.href = startURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ function simulateGuard(
|
|||||||
'/auth/callback',
|
'/auth/callback',
|
||||||
'/auth/linuxdo/callback',
|
'/auth/linuxdo/callback',
|
||||||
'/auth/oidc/callback',
|
'/auth/oidc/callback',
|
||||||
'/auth/wechat/callback'
|
'/auth/wechat/callback',
|
||||||
|
'/auth/wechat/payment/callback',
|
||||||
]
|
]
|
||||||
const pendingAuthPaths = ['/register', '/email-verify']
|
const pendingAuthPaths = ['/register', '/email-verify']
|
||||||
const isAllowed =
|
const isAllowed =
|
||||||
@@ -131,7 +132,8 @@ function simulateGuard(
|
|||||||
'/auth/callback',
|
'/auth/callback',
|
||||||
'/auth/linuxdo/callback',
|
'/auth/linuxdo/callback',
|
||||||
'/auth/oidc/callback',
|
'/auth/oidc/callback',
|
||||||
'/auth/wechat/callback'
|
'/auth/wechat/callback',
|
||||||
|
'/auth/wechat/payment/callback',
|
||||||
]
|
]
|
||||||
const pendingAuthPaths = ['/register', '/email-verify']
|
const pendingAuthPaths = ['/register', '/email-verify']
|
||||||
const isAllowed =
|
const isAllowed =
|
||||||
@@ -448,6 +450,18 @@ describe('路由守卫逻辑', () => {
|
|||||||
expect(redirect).toBeNull()
|
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', () => {
|
it('unauthenticated: /register is allowed when a pending auth session exists', () => {
|
||||||
const authState: MockAuthState = {
|
const authState: MockAuthState = {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|||||||
@@ -52,4 +52,13 @@ describe('router WeChat OAuth route', () => {
|
|||||||
expect(route?.meta.requiresAuth).toBe(false)
|
expect(route?.meta.requiresAuth).toBe(false)
|
||||||
expect(route?.meta.title).toBe('WeChat OAuth Callback')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -547,7 +547,8 @@ const BACKEND_MODE_CALLBACK_PATHS = [
|
|||||||
'/auth/callback',
|
'/auth/callback',
|
||||||
'/auth/linuxdo/callback',
|
'/auth/linuxdo/callback',
|
||||||
'/auth/oidc/callback',
|
'/auth/oidc/callback',
|
||||||
'/auth/wechat/callback'
|
'/auth/wechat/callback',
|
||||||
|
'/auth/wechat/payment/callback',
|
||||||
]
|
]
|
||||||
const BACKEND_MODE_PENDING_AUTH_PATHS = ['/register', '/email-verify']
|
const BACKEND_MODE_PENDING_AUTH_PATHS = ['/register', '/email-verify']
|
||||||
|
|
||||||
|
|||||||
@@ -613,7 +613,7 @@ async function handleBindCurrentAccount() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareOAuthBindAccessTokenCookie()
|
await prepareOAuthBindAccessTokenCookie()
|
||||||
window.location.href = startURL
|
window.location.href = startURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,11 @@ import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
|
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 { usePaymentStore } from '@/stores/payment'
|
||||||
import { paymentAPI } from '@/api/payment'
|
import { paymentAPI } from '@/api/payment'
|
||||||
import type { PaymentOrder } from '@/types/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<PaymentOrder | null>) | null): void {
|
function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>) | null): void {
|
||||||
clearStatusRefreshTimer()
|
clearStatusRefreshTimer()
|
||||||
if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) {
|
if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) {
|
||||||
@@ -204,6 +220,7 @@ function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>
|
|||||||
const refreshedOrder = await refreshOrder()
|
const refreshedOrder = await refreshOrder()
|
||||||
if (refreshedOrder) {
|
if (refreshedOrder) {
|
||||||
order.value = refreshedOrder
|
order.value = refreshedOrder
|
||||||
|
clearRecoverySnapshotForTerminalStatus(refreshedOrder.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPendingStatus(order.value?.status)) {
|
if (isPendingStatus(order.value?.status)) {
|
||||||
@@ -285,6 +302,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (isPendingStatus(order.value?.status)) {
|
if (isPendingStatus(order.value?.status)) {
|
||||||
scheduleStatusRefresh(refreshOrder)
|
scheduleStatusRefresh(refreshOrder)
|
||||||
|
} else if (order.value) {
|
||||||
|
clearRecoverySnapshotForTerminalStatus(order.value.status)
|
||||||
|
} else if (returnInfo.value) {
|
||||||
|
clearRecoverySnapshot()
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -391,6 +391,20 @@ function resetPayment() {
|
|||||||
removeRecoverySnapshot()
|
removeRecoverySnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function redirectToPaymentResult(state: PaymentRecoverySnapshot): Promise<void> {
|
||||||
|
const query: Record<string, string | undefined> = {}
|
||||||
|
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() {
|
function onPaymentDone() {
|
||||||
const wasSubscription = paymentState.value.orderType === 'subscription'
|
const wasSubscription = paymentState.value.orderType === 'subscription'
|
||||||
resetPayment()
|
resetPayment()
|
||||||
@@ -684,8 +698,14 @@ async function createOrder(orderAmount: number, orderType: OrderType, planId?: n
|
|||||||
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
|
const errMsg = String(jsapiResult.err_msg || '').toLowerCase()
|
||||||
if (errMsg.includes('cancel')) {
|
if (errMsg.includes('cancel')) {
|
||||||
appStore.showInfo(t('payment.qr.cancelled'))
|
appStore.showInfo(t('payment.qr.cancelled'))
|
||||||
|
resetPayment()
|
||||||
} else if (errMsg && !errMsg.includes('ok')) {
|
} else if (errMsg && !errMsg.includes('ok')) {
|
||||||
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
|
applyScenarioError({ reason: 'WECHAT_JSAPI_FAILED', message: errMsg }, visibleMethod)
|
||||||
|
resetPayment()
|
||||||
|
} else {
|
||||||
|
const resultState = { ...decision.paymentState }
|
||||||
|
resetPayment()
|
||||||
|
await redirectToPaymentResult(resultState)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,21 @@ const orderFactory = (status: string) => ({
|
|||||||
refund_amount: 0,
|
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', () => {
|
describe('PaymentResultView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
routeState.query = {}
|
routeState.query = {}
|
||||||
@@ -162,6 +177,7 @@ describe('PaymentResultView', () => {
|
|||||||
expect(wrapper.text()).toContain('payment.result.success')
|
expect(wrapper.text()).toContain('payment.result.success')
|
||||||
expect(wrapper.text()).toContain('103.00')
|
expect(wrapper.text()).toContain('103.00')
|
||||||
expect(wrapper.text()).toContain('100.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 () => {
|
it('refreshes a pending resume-token result until the order becomes paid', async () => {
|
||||||
@@ -169,6 +185,10 @@ describe('PaymentResultView', () => {
|
|||||||
routeState.query = {
|
routeState.query = {
|
||||||
resume_token: 'resume-77',
|
resume_token: 'resume-77',
|
||||||
}
|
}
|
||||||
|
window.localStorage.setItem(
|
||||||
|
PAYMENT_RECOVERY_STORAGE_KEY,
|
||||||
|
JSON.stringify(recoverySnapshotFactory('resume-77')),
|
||||||
|
)
|
||||||
resolveOrderPublicByResumeToken
|
resolveOrderPublicByResumeToken
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
data: orderFactory('PENDING'),
|
data: orderFactory('PENDING'),
|
||||||
@@ -189,6 +209,7 @@ describe('PaymentResultView', () => {
|
|||||||
|
|
||||||
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1)
|
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1)
|
||||||
expect(wrapper.text()).toContain('payment.result.processing')
|
expect(wrapper.text()).toContain('payment.result.processing')
|
||||||
|
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull()
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(2000)
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -196,6 +217,7 @@ describe('PaymentResultView', () => {
|
|||||||
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2)
|
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2)
|
||||||
expect(wrapper.text()).toContain('payment.result.success')
|
expect(wrapper.text()).toContain('payment.result.success')
|
||||||
expect(wrapper.text()).not.toContain('payment.result.failed')
|
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 () => {
|
it('does not fall back to public out_trade_no verification when resume_token recovery fails', async () => {
|
||||||
|
|||||||
205
frontend/src/views/user/__tests__/PaymentView.spec.ts
Normal file
205
frontend/src/views/user/__tests__/PaymentView.spec.ts
Normal file
@@ -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<string, unknown>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
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<typeof import('vue-router')>('vue-router')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRoute: () => routeState,
|
||||||
|
useRouter: () => ({
|
||||||
|
replace: routerReplace,
|
||||||
|
push: routerPush,
|
||||||
|
resolve: routerResolve,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('vue-i18n')>('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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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', () => {
|
it('maps missing WeixinJSBridge to a JSAPI-specific prompt', () => {
|
||||||
expect(describePaymentScenarioError(
|
expect(describePaymentScenarioError(
|
||||||
new Error('WeixinJSBridge is unavailable'),
|
new Error('WeixinJSBridge is unavailable'),
|
||||||
|
|||||||
Reference in New Issue
Block a user