From f3c98f231569fc75c7d959d810efee7b60b963ef Mon Sep 17 00:00:00 2001 From: chengqian Date: Thu, 28 May 2026 20:36:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(test):=E6=B7=BB=E5=8A=A0=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E7=9A=84=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/logic/billing/wallet_test.go | 14 ++ internal/logic/common/jwt_test.go | 240 ++++++++++++++++++++ internal/logic/common/security_test.go | 286 ++++++++++++++++++++++++ internal/logic/payment/callback_test.go | 155 +++++++++++++ 4 files changed, 695 insertions(+) create mode 100644 internal/logic/common/jwt_test.go create mode 100644 internal/logic/common/security_test.go create mode 100644 internal/logic/payment/callback_test.go diff --git a/internal/logic/billing/wallet_test.go b/internal/logic/billing/wallet_test.go index 697b125..145883b 100644 --- a/internal/logic/billing/wallet_test.go +++ b/internal/logic/billing/wallet_test.go @@ -84,3 +84,17 @@ func TestWalletInfo_WarningThresholdZero(t *testing.T) { available := AvailableBalance(w) assertFloat(t, available, 50.0, "available balance") } + +func TestAvailableBalance_NegativeFrozen(t *testing.T) { + w := &WalletInfo{Balance: 100.0, FrozenBalance: -10.0} + got := AvailableBalance(w) + assertFloat(t, got, 110.0, "negative frozen produces inflated balance") +} + +func TestAvailableBalance_LargePrecision(t *testing.T) { + w := &WalletInfo{Balance: 0.0000000001, FrozenBalance: 0} + got := AvailableBalance(w) + if got <= 0 { + t.Fatalf("expected positive available balance for tiny balance, got %e", got) + } +} diff --git a/internal/logic/common/jwt_test.go b/internal/logic/common/jwt_test.go new file mode 100644 index 0000000..f7e1f27 --- /dev/null +++ b/internal/logic/common/jwt_test.go @@ -0,0 +1,240 @@ +package common + +import ( + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func init() { + jwtSecret = []byte("test-secret-for-unit-tests-only!!") +} + +// ─── HashRefreshToken ─────────────────────────────────────────────── + +func TestHashRefreshToken_Deterministic(t *testing.T) { + token := "abc123def456" + h1 := HashRefreshToken(token) + h2 := HashRefreshToken(token) + if h1 != h2 { + t.Fatalf("same input produced different hashes: %s vs %s", h1, h2) + } +} + +func TestHashRefreshToken_DifferentInputs(t *testing.T) { + h1 := HashRefreshToken("token-a") + h2 := HashRefreshToken("token-b") + if h1 == h2 { + t.Fatal("different inputs produced the same hash") + } +} + +func TestHashRefreshToken_Length(t *testing.T) { + h := HashRefreshToken("some-token") + if len(h) != 64 { + t.Fatalf("expected SHA-256 hex length 64, got %d", len(h)) + } +} + +func TestHashRefreshToken_Empty(t *testing.T) { + h := HashRefreshToken("") + if len(h) != 64 { + t.Fatalf("expected SHA-256 hex length 64 for empty input, got %d", len(h)) + } +} + +// ─── GenerateRefreshToken ─────────────────────────────────────────── + +func TestGenerateRefreshToken_Length(t *testing.T) { + token, err := GenerateRefreshToken() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(token) != 64 { + t.Fatalf("expected 64-char hex string, got %d chars", len(token)) + } +} + +func TestGenerateRefreshToken_Unique(t *testing.T) { + t1, _ := GenerateRefreshToken() + t2, _ := GenerateRefreshToken() + if t1 == t2 { + t.Fatal("two generated tokens should be different") + } +} + +// ─── GenerateTokenPair + ParseAccessToken round-trip ──────────────── + +func TestTokenPairRoundTrip(t *testing.T) { + pair, err := generateTestTokenPair(42, "admin", "super_admin", 100, 1) + if err != nil { + t.Fatalf("generate: %v", err) + } + if pair.AccessToken == "" { + t.Fatal("access token is empty") + } + if pair.RefreshToken == "" { + t.Fatal("refresh token is empty") + } + if pair.ExpiresAt.Before(time.Now()) { + t.Fatal("expires_at is in the past") + } + + claims, err := ParseAccessToken(pair.AccessToken) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.UserID != 42 { + t.Fatalf("UserID = %d, want 42", claims.UserID) + } + if claims.UserType != "admin" { + t.Fatalf("UserType = %q, want admin", claims.UserType) + } + if claims.Role != "super_admin" { + t.Fatalf("Role = %q, want super_admin", claims.Role) + } + if claims.TenantID != 100 { + t.Fatalf("TenantID = %d, want 100", claims.TenantID) + } + if claims.SessionID != 1 { + t.Fatalf("SessionID = %d, want 1", claims.SessionID) + } +} + +func TestTokenPairRoundTrip_TenantUser(t *testing.T) { + pair, err := generateTestTokenPair(99, "tenant", "member", 200, 5) + if err != nil { + t.Fatalf("generate: %v", err) + } + + claims, err := ParseAccessToken(pair.AccessToken) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.UserType != "tenant" { + t.Fatalf("UserType = %q, want tenant", claims.UserType) + } + if claims.TenantID != 200 { + t.Fatalf("TenantID = %d, want 200", claims.TenantID) + } +} + +// ─── ParseAccessToken error cases ─────────────────────────────────── + +func TestParseAccessToken_InvalidString(t *testing.T) { + _, err := ParseAccessToken("not-a-jwt") + if err == nil { + t.Fatal("expected error for invalid token") + } +} + +func TestParseAccessToken_Empty(t *testing.T) { + _, err := ParseAccessToken("") + if err == nil { + t.Fatal("expected error for empty token") + } +} + +func TestParseAccessToken_Expired(t *testing.T) { + now := time.Now().Add(-10 * time.Minute) + claims := JWTClaims{ + UserID: 1, + UserType: "admin", + Role: "admin", + SessionID: 1, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(-5 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, _ := token.SignedString(GetJWTSecret()) + + _, err := ParseAccessToken(tokenStr) + if err == nil { + t.Fatal("expected error for expired token") + } +} + +func TestParseAccessToken_WrongSecret(t *testing.T) { + claims := JWTClaims{ + UserID: 1, + UserType: "admin", + Role: "admin", + SessionID: 1, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, _ := token.SignedString([]byte("wrong-secret")) + + _, err := ParseAccessToken(tokenStr) + if err == nil { + t.Fatal("expected error for token signed with wrong secret") + } +} + +func TestParseAccessToken_RejectsNoneAlgorithm(t *testing.T) { + claims := JWTClaims{ + UserID: 1, + UserType: "admin", + Role: "admin", + } + token := jwt.NewWithClaims(jwt.SigningMethodNone, claims) + tokenStr, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType) + + _, err := ParseAccessToken(tokenStr) + if err == nil { + t.Fatal("expected error for 'none' signing method") + } +} + +// ─── RefreshToken hash consistency ────────────────────────────────── + +func TestRefreshTokenHashIsNotReversible(t *testing.T) { + token, _ := GenerateRefreshToken() + hash := HashRefreshToken(token) + if strings.Contains(hash, token) { + t.Fatal("hash should not contain the original token") + } +} + +// ─── helpers ──────────────────────────────────────────────────────── + +func generateTestTokenPair(userID int64, userType, role string, tenantID, sessionID int64) (*TokenPair, error) { + now := time.Now() + accessClaims := JWTClaims{ + UserID: userID, + UserType: userType, + Role: role, + TenantID: tenantID, + SessionID: sessionID, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "team-api-test", + Subject: "42", + ExpiresAt: jwt.NewNumericDate(now.Add(30 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + }, + } + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims) + accessTokenStr, err := accessToken.SignedString(GetJWTSecret()) + if err != nil { + return nil, err + } + + refreshTokenStr, err := GenerateRefreshToken() + if err != nil { + return nil, err + } + + return &TokenPair{ + AccessToken: accessTokenStr, + RefreshToken: refreshTokenStr, + ExpiresAt: now.Add(30 * time.Minute), + }, nil +} diff --git a/internal/logic/common/security_test.go b/internal/logic/common/security_test.go new file mode 100644 index 0000000..a53fe5f --- /dev/null +++ b/internal/logic/common/security_test.go @@ -0,0 +1,286 @@ +package common + +import ( + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// ─── ProvisionalToken round-trip ──────────────────────────────────── + +func TestProvisionalToken_RoundTrip(t *testing.T) { + token, err := generateTestProvisionalToken(42, "admin", "super_admin", 0) + if err != nil { + t.Fatalf("generate: %v", err) + } + + claims, err := ParseProvisionalToken(token) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.UserID != 42 { + t.Fatalf("UserID = %d, want 42", claims.UserID) + } + if claims.UserType != "admin" { + t.Fatalf("UserType = %q, want admin", claims.UserType) + } + if claims.Role != "super_admin" { + t.Fatalf("Role = %q, want super_admin", claims.Role) + } + if claims.Purpose != "totp_verify" { + t.Fatalf("Purpose = %q, want totp_verify", claims.Purpose) + } +} + +func TestProvisionalToken_TenantUser(t *testing.T) { + token, err := generateTestProvisionalToken(99, "tenant", "member", 200) + if err != nil { + t.Fatalf("generate: %v", err) + } + + claims, err := ParseProvisionalToken(token) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.TenantID != 200 { + t.Fatalf("TenantID = %d, want 200", claims.TenantID) + } +} + +func TestProvisionalToken_Expired(t *testing.T) { + now := time.Now().Add(-10 * time.Minute) + claims := ProvisionalClaims{ + UserID: 1, + UserType: "admin", + Role: "admin", + Purpose: "totp_verify", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "team-api", + ExpiresAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, _ := token.SignedString(GetJWTSecret()) + + _, err := ParseProvisionalToken(tokenStr) + if err == nil { + t.Fatal("expected error for expired provisional token") + } +} + +func TestProvisionalToken_WrongPurpose(t *testing.T) { + now := time.Now() + claims := ProvisionalClaims{ + UserID: 1, + UserType: "admin", + Role: "admin", + Purpose: "high_risk_confirm", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, _ := token.SignedString(GetJWTSecret()) + + _, err := ParseProvisionalToken(tokenStr) + if err == nil { + t.Fatal("expected error for wrong purpose in provisional token") + } +} + +func TestProvisionalToken_InvalidString(t *testing.T) { + _, err := ParseProvisionalToken("garbage") + if err == nil { + t.Fatal("expected error for invalid token string") + } +} + +// ─── ConfirmToken round-trip ──────────────────────────────────────── + +func TestConfirmToken_RoundTrip(t *testing.T) { + token, err := generateTestConfirmToken(42, "admin") + if err != nil { + t.Fatalf("generate: %v", err) + } + + claims, err := ParseConfirmToken(token) + if err != nil { + t.Fatalf("parse: %v", err) + } + if claims.UserID != 42 { + t.Fatalf("UserID = %d, want 42", claims.UserID) + } + if claims.UserType != "admin" { + t.Fatalf("UserType = %q, want admin", claims.UserType) + } + if claims.Purpose != "high_risk_confirm" { + t.Fatalf("Purpose = %q, want high_risk_confirm", claims.Purpose) + } +} + +func TestConfirmToken_Expired(t *testing.T) { + now := time.Now().Add(-10 * time.Minute) + claims := ConfirmClaims{ + UserID: 1, + UserType: "admin", + Purpose: "high_risk_confirm", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(-1 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, _ := token.SignedString(GetJWTSecret()) + + _, err := ParseConfirmToken(tokenStr) + if err == nil { + t.Fatal("expected error for expired confirm token") + } +} + +func TestConfirmToken_WrongPurpose(t *testing.T) { + now := time.Now() + claims := ConfirmClaims{ + UserID: 1, + UserType: "admin", + Purpose: "totp_verify", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenStr, _ := token.SignedString(GetJWTSecret()) + + _, err := ParseConfirmToken(tokenStr) + if err == nil { + t.Fatal("expected error for wrong purpose in confirm token") + } +} + +func TestConfirmToken_InvalidString(t *testing.T) { + _, err := ParseConfirmToken("") + if err == nil { + t.Fatal("expected error for empty confirm token") + } +} + +// ─── Cross-token type rejection ───────────────────────────────────── + +func TestProvisionalTokenCannotParseAsConfirm(t *testing.T) { + provToken, _ := generateTestProvisionalToken(1, "admin", "admin", 0) + _, err := ParseConfirmToken(provToken) + if err == nil { + t.Fatal("provisional token should not parse as confirm token") + } +} + +func TestConfirmTokenCannotParseAsProvisional(t *testing.T) { + confToken, _ := generateTestConfirmToken(1, "admin") + _, err := ParseProvisionalToken(confToken) + if err == nil { + t.Fatal("confirm token should not parse as provisional token") + } +} + +// ─── DeviceFingerprint ────────────────────────────────────────────── + +func TestDeviceFingerprint_Deterministic(t *testing.T) { + fp1 := DeviceFingerprint("Mozilla/5.0", "192.168.1.1") + fp2 := DeviceFingerprint("Mozilla/5.0", "192.168.1.1") + if fp1 != fp2 { + t.Fatalf("same input produced different fingerprints: %q vs %q", fp1, fp2) + } +} + +func TestDeviceFingerprint_DifferentUA(t *testing.T) { + fp1 := DeviceFingerprint("Chrome/120", "192.168.1.1") + fp2 := DeviceFingerprint("Firefox/115", "192.168.1.1") + if fp1 == fp2 { + t.Fatal("different UAs should produce different fingerprints") + } +} + +func TestDeviceFingerprint_DifferentIP(t *testing.T) { + fp1 := DeviceFingerprint("Chrome/120", "10.0.0.1") + fp2 := DeviceFingerprint("Chrome/120", "10.0.0.2") + if fp1 == fp2 { + t.Fatal("different IPs should produce different fingerprints") + } +} + +func TestDeviceFingerprint_Length(t *testing.T) { + fp := DeviceFingerprint("some-ua", "1.2.3.4") + if len(fp) != 32 { + t.Fatalf("expected length 32, got %d", len(fp)) + } +} + +func TestDeviceFingerprint_CaseInsensitive(t *testing.T) { + fp1 := DeviceFingerprint("Mozilla/5.0", "10.0.0.1") + fp2 := DeviceFingerprint("MOZILLA/5.0", "10.0.0.1") + if fp1 != fp2 { + t.Fatal("fingerprint should be case-insensitive on UA") + } +} + +func TestDeviceFingerprint_LongUA(t *testing.T) { + longUA := strings.Repeat("A", 500) + fp := DeviceFingerprint(longUA, "1.2.3.4") + if len(fp) != 32 { + t.Fatalf("expected length 32 for long UA, got %d", len(fp)) + } + + truncatedUA := strings.Repeat("A", 200) + fpTruncated := DeviceFingerprint(truncatedUA, "1.2.3.4") + if fp != fpTruncated { + t.Fatal("UA beyond 200 chars should not affect fingerprint") + } +} + +func TestDeviceFingerprint_Empty(t *testing.T) { + fp := DeviceFingerprint("", "") + if len(fp) != 32 { + t.Fatalf("expected length 32 for empty input, got %d", len(fp)) + } +} + +// ─── helpers ──────────────────────────────────────────────────────── + +func generateTestProvisionalToken(userID int64, userType, role string, tenantID int64) (string, error) { + now := time.Now() + claims := ProvisionalClaims{ + UserID: userID, + UserType: userType, + Role: role, + TenantID: tenantID, + Purpose: "totp_verify", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "team-api", + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(GetJWTSecret()) +} + +func generateTestConfirmToken(userID int64, userType string) (string, error) { + now := time.Now() + claims := ConfirmClaims{ + UserID: userID, + UserType: userType, + Purpose: "high_risk_confirm", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: "team-api", + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(GetJWTSecret()) +} diff --git a/internal/logic/payment/callback_test.go b/internal/logic/payment/callback_test.go new file mode 100644 index 0000000..1962592 --- /dev/null +++ b/internal/logic/payment/callback_test.go @@ -0,0 +1,155 @@ +package payment + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +// ─── LockOrder / UnlockOrder basic ────────────────────────────────── + +func TestLockUnlock_Basic(t *testing.T) { + orderNo := "test-order-basic" + LockOrder(orderNo) + UnlockOrder(orderNo) +} + +func TestLockUnlock_DifferentOrders(t *testing.T) { + done := make(chan struct{}) + go func() { + LockOrder("order-a") + LockOrder("order-b") + UnlockOrder("order-b") + UnlockOrder("order-a") + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("different orders should not block each other") + } +} + +func TestUnlockOrder_NonExistent(t *testing.T) { + UnlockOrder("never-locked-order") +} + +// ─── Mutual exclusion ─────────────────────────────────────────────── + +func TestLockOrder_MutualExclusion(t *testing.T) { + orderNo := "test-order-mutex" + var counter int64 + + LockOrder(orderNo) + + started := make(chan struct{}) + done := make(chan struct{}) + go func() { + close(started) + LockOrder(orderNo) + atomic.AddInt64(&counter, 1) + UnlockOrder(orderNo) + close(done) + }() + + <-started + time.Sleep(50 * time.Millisecond) + + if atomic.LoadInt64(&counter) != 0 { + t.Fatal("goroutine should be blocked by the lock") + } + + UnlockOrder(orderNo) + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("goroutine should have completed after unlock") + } + + if atomic.LoadInt64(&counter) != 1 { + t.Fatal("goroutine should have incremented counter") + } +} + +// ─── Ref-counting cleanup ─────────────────────────────────────────── + +func TestLockOrder_RefCounting(t *testing.T) { + orderNo := "test-order-refcount" + var sequence []int + var mu sync.Mutex + + LockOrder(orderNo) + + var wg sync.WaitGroup + for i := 1; i <= 3; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + LockOrder(orderNo) + mu.Lock() + sequence = append(sequence, id) + mu.Unlock() + UnlockOrder(orderNo) + }(i) + } + + time.Sleep(50 * time.Millisecond) + UnlockOrder(orderNo) + + wg.Wait() + + if len(sequence) != 3 { + t.Fatalf("expected 3 goroutines to complete, got %d", len(sequence)) + } + + _, exists := orderLocks.Load(orderNo) + if exists { + t.Fatal("lock entry should be cleaned up after all refs are released") + } +} + +// ─── Concurrent lock/unlock stress ────────────────────────────────── + +func TestLockOrder_ConcurrentStress(t *testing.T) { + orderNo := "test-order-stress" + var counter int64 + const goroutines = 50 + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + LockOrder(orderNo) + val := atomic.LoadInt64(&counter) + atomic.StoreInt64(&counter, val+1) + UnlockOrder(orderNo) + }() + } + + wg.Wait() + + if counter != goroutines { + t.Fatalf("expected counter=%d, got %d (race condition detected)", goroutines, counter) + } +} + +// ─── Re-lock after full release ───────────────────────────────────── + +func TestLockOrder_ReuseAfterRelease(t *testing.T) { + orderNo := "test-order-reuse" + + LockOrder(orderNo) + UnlockOrder(orderNo) + + _, exists := orderLocks.Load(orderNo) + if exists { + t.Fatal("lock should be cleaned up after release") + } + + LockOrder(orderNo) + UnlockOrder(orderNo) +}