package jwt_test import ( "errors" "testing" "time" "github.com/EthanCodeCraft/xlgo-core/config" "github.com/EthanCodeCraft/xlgo-core/jwt" "github.com/alicebob/miniredis/v2" gojwt "github.com/golang-jwt/jwt/v5" "github.com/redis/go-redis/v9" ) func setupTestConfig() { // 设置测试配置 cfg := &config.Config{ JWT: config.JWTConfig{ Secret: "test-secret-key-1234567890123456789012", // ≥32 字节 Expire: time.Hour, // 1小时 }, } config.Set(cfg) } func TestGenerateToken(t *testing.T) { setupTestConfig() token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin") if err != nil { t.Fatalf("GenerateToken error: %v", err) } if token == "" { t.Error("GenerateToken should return non-empty token") } // Token 应包含三部分(用 . 分隔) parts := splitToken(token) if len(parts) != 3 { t.Errorf("Token should have 3 parts, got %d", len(parts)) } } func TestParseToken(t *testing.T) { setupTestConfig() setupMiniRedis(t) // 先生成 token token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") // 解析 token claims, err := jwt.ParseToken(token) if err != nil { t.Fatalf("ParseToken error: %v", err) } if claims.UserID != 1 { t.Errorf("UserID = %d, want 1", claims.UserID) } if claims.Username != "testuser" { t.Errorf("Username = %s, want testuser", claims.Username) } if claims.Role != "admin" { t.Errorf("Role = %s, want admin", claims.Role) } if claims.UserType != "super_admin" { t.Errorf("UserType = %s, want super_admin", claims.UserType) } } func TestParseTokenInvalid(t *testing.T) { setupTestConfig() // 无效 token _, err := jwt.ParseToken("invalid-token") if err == nil { t.Error("ParseToken should fail with invalid token") } // 空 token _, err = jwt.ParseToken("") if err == nil { t.Error("ParseToken should fail with empty token") } } func TestParseTokenWrongSecret(t *testing.T) { setupTestConfig() // 用不同 secret 生成的 token token, _ := jwt.GenerateToken(1, "test", "admin", "admin") // 修改 secret cfg := &config.Config{ JWT: config.JWTConfig{ Secret: "different-secret-key-12345678901234567890", Expire: 3600, }, } if err := config.Set(cfg); err != nil { t.Fatalf("Set wrong secret config: %v", err) } // 应该解析失败 _, err := jwt.ParseToken(token) if err == nil { t.Error("ParseToken should fail with wrong secret") } } func TestRefreshToken(t *testing.T) { setupTestConfig() // 生成 token token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") // 无 Redis 时 RefreshToken 必须 fail-closed(C9b 修复:旧 token 撤销失败不签发新 token) _, err := jwt.RefreshToken(token) if !errors.Is(err, jwt.ErrBlacklistUnavailable) { t.Errorf("RefreshToken without Redis should fail with ErrBlacklistUnavailable, got %v", err) } } // setupMiniRedis 启动 miniredis 并注入到 jwt 包级 tokenBlacklist(经 SetDefaultJWTManager)。 // 测试结束 cleanup 还原为无 Redis 的默认 Manager,避免污染后续测试(-shuffle 下尤其关键)。 func setupMiniRedis(t *testing.T) *miniredis.Miniredis { t.Helper() mr := miniredis.RunT(t) client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) t.Cleanup(func() { _ = client.Close() // 还原包级 tokenBlacklist 为无 Redis 默认 Manager,避免残留指向已关闭 miniredis 的 client。 jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) // 用注入 Redis 的 Manager 替换默认,使包级 tokenBlacklist 指向 miniredis。 jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(client)) return mr } func TestClaimsStructure(t *testing.T) { claims := jwt.Claims{ UserID: 1, Username: "test", Role: "admin", UserType: "super_admin", } if claims.UserID != 1 { t.Error("Claims UserID failed") } if claims.Username != "test" { t.Error("Claims Username failed") } if claims.Role != "admin" { t.Error("Claims Username failed") } if claims.UserType != "super_admin" { t.Error("Claims Username failed") } } func TestErrorDefinitions(t *testing.T) { if jwt.ErrTokenExpired == nil { t.Error("ErrTokenExpired should be defined") } if jwt.ErrTokenInvalid == nil { t.Error("ErrTokenInvalid should be defined") } if jwt.ErrTokenMalformed == nil { t.Error("ErrTokenMalformed should be defined") } if jwt.ErrTokenNotValidYet == nil { t.Error("ErrTokenNotValidYet should be defined") } } func TestTokenBlacklist(t *testing.T) { tb := jwt.TokenBlacklist{} // 无 Redis 时,Add 应返回 ErrBlacklistUnavailable(C9a 修复:fail-closed) err := tb.Add("test-token", time.Now().Add(time.Hour)) if !errors.Is(err, jwt.ErrBlacklistUnavailable) { t.Errorf("TokenBlacklist.Add without Redis should return ErrBlacklistUnavailable, got %v", err) } // 无 Redis 时,IsBlacklisted 返回 (false, ErrBlacklistUnavailable)(fail-closed:错误上抛) revoked, err := tb.IsBlacklisted("test-token") if revoked || !errors.Is(err, jwt.ErrBlacklistUnavailable) { t.Errorf("TokenBlacklist.IsBlacklisted without Redis = (%v, %v), want (false, ErrBlacklistUnavailable)", revoked, err) } } func TestInvalidateToken(t *testing.T) { setupTestConfig() token, _ := jwt.GenerateToken(1, "test", "admin", "admin") // 无 Redis 时应返回 ErrBlacklistUnavailable(C9a 修复:fail-closed,不再静默成功) err := jwt.InvalidateToken(token) if !errors.Is(err, jwt.ErrBlacklistUnavailable) { t.Errorf("InvalidateToken without Redis should return ErrBlacklistUnavailable, got %v", err) } } func splitToken(token string) []string { count := 0 for _, c := range token { if c == '.' { count++ } } if count != 2 { return []string{} } start := 0 result := make([]string, 0, 3) for i, c := range token { if c == '.' { result = append(result, token[start:i]) start = i + 1 } } result = append(result, token[start:]) return result } // ===== C9b 回归:刷新令牌撤销闭环 ===== // 回归 C9b:RefreshToken 成功后,旧 token 必须被拉黑(ParseToken 返 ErrTokenRevoked), // 新 token 可用。旧实现丢弃 Add 错误仍签发新 token,旧 token 仍有效(双有效)。 func TestRefreshTokenRevokesOldToken(t *testing.T) { setupTestConfig() setupMiniRedis(t) token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") // 刷新 newToken, err := jwt.RefreshToken(token) if err != nil { t.Fatalf("RefreshToken: %v", err) } if newToken == "" { t.Fatal("RefreshToken should return non-empty token") } // 新 token 可解析 newClaims, err := jwt.ParseToken(newToken) if err != nil { t.Fatalf("ParseToken new token: %v", err) } if newClaims.Username != "testuser" { t.Error("new token claims should match original") } // 旧 token 必须已被拉黑(C9b 核心) _, err = jwt.ParseToken(token) if !errors.Is(err, jwt.ErrTokenRevoked) { t.Errorf("old token after refresh err = %v, want ErrTokenRevoked (C9b: old must be blacklisted)", err) } } // 回归 C9b:Redis 抖动(Add 失败)时 RefreshToken 必须 fail-closed,不签发新 token。 // 旧实现丢弃 Add 错误仍签发新 token → 旧 token 未拉黑、新旧双有效。 func TestRefreshTokenFailsOnRedisError(t *testing.T) { setupTestConfig() mr := setupMiniRedis(t) token, _ := jwt.GenerateToken(1, "testuser", "admin", "super_admin") // 模拟 Redis 抖动:关闭 miniredis,使 Add 的 Set 失败。 mr.Close() _, err := jwt.RefreshToken(token) if err == nil { t.Fatal("RefreshToken should fail when Redis unavailable (C9b: must not issue new token)") } // 不应是 ErrBlacklistUnavailable(那是无 Redis 路径),而是 Add 的 Set 错误包装。 // 关键:未签发新 token,旧 token 未被拉黑(因 Add 失败)。 } // 回归 C9a/b:InvalidateToken 闭环——登出后 token 被拉黑、ParseToken 返 ErrTokenRevoked。 func TestInvalidateTokenRevokesToken(t *testing.T) { setupTestConfig() setupMiniRedis(t) token, _ := jwt.GenerateToken(1, "test", "admin", "admin") // 登出前可解析 if _, err := jwt.ParseToken(token); err != nil { t.Fatalf("ParseToken before invalidate: %v", err) } // 登出(拉黑) if err := jwt.InvalidateToken(token); err != nil { t.Fatalf("InvalidateToken: %v", err) } // 登出后必须被拉黑 _, err := jwt.ParseToken(token) if !errors.Is(err, jwt.ErrTokenRevoked) { t.Errorf("ParseToken after invalidate err = %v, want ErrTokenRevoked", err) } } // 回归 C9a:无 Redis 时 InvalidateTokenByID 返 ErrBlacklistUnavailable(fail-closed)。 func TestInvalidateTokenByIDNoRedis(t *testing.T) { setupTestConfig() // 不 setupMiniRedis → tokenBlacklist 指向无 Redis 的 Manager jwt.SetDefaultJWTManager(jwt.NewJWTManager()) t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) // 还原基线 err := jwt.InvalidateTokenByID("some-jti", time.Now().Add(time.Hour)) if !errors.Is(err, jwt.ErrBlacklistUnavailable) { t.Errorf("InvalidateTokenByID without Redis err = %v, want ErrBlacklistUnavailable", err) } } // ===== P0 回归:算法混淆 / 空密钥 / 不支持算法 ===== // 回归 P0:alg=none 的 token 必须被拒(jwt.WithValidMethods 固定 HMAC 族)。 // 这是算法混淆攻击的一种典型形态——攻击者去掉签名并将 alg 置为 none。 func TestParseTokenRejectsAlgNone(t *testing.T) { setupTestConfig() claims := jwt.Claims{UserID: 1, Username: "attacker", Role: "admin", UserType: "super_admin"} // 构造 alg=none 的未签名 token。 noneToken := gojwt.NewWithClaims(gojwt.SigningMethodNone, claims) signed, err := noneToken.SignedString(gojwt.UnsafeAllowNoneSignatureType) if err != nil { t.Fatalf("构造 none token 失败: %v", err) } if _, err := jwt.ParseToken(signed); err == nil { t.Error("ParseToken 必须拒绝 alg=none 的 token(算法混淆防护)") } } // 回归 P0:空密钥时 GenerateToken/ParseToken 必须 fail-closed 返 ErrEmptySecret, // 杜绝以零长度 HMAC 密钥签发/校验导致任意 token 通过。 func TestEmptySecretFailsClosed(t *testing.T) { config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}}) t.Cleanup(setupTestConfig) // 还原基线,避免污染其它测试 _, err := jwt.GenerateToken(1, "u", "admin", "admin") if !errors.Is(err, jwt.ErrEmptySecret) { t.Errorf("空密钥 GenerateToken err = %v, want ErrEmptySecret", err) } // 校验侧:任意 token 在空密钥下都不得通过。 if _, err := jwt.ParseToken("a.b.c"); err == nil { t.Error("空密钥 ParseToken 不得通过任何 token") } } // 回归 P0:配置不支持的算法(如 RS256)时 GenerateToken 必须返 ErrUnsupportedAlgorithm, // 不再静默回退 HS256。 func TestUnsupportedAlgorithmFailsClosed(t *testing.T) { config.Set(&config.Config{JWT: config.JWTConfig{ Secret: "test-secret-key-1234567890123456789012", Algorithm: "RS256", Expire: time.Hour, }}) t.Cleanup(setupTestConfig) _, err := jwt.GenerateToken(1, "u", "admin", "admin") if !errors.Is(err, jwt.ErrUnsupportedAlgorithm) { t.Errorf("RS256 GenerateToken err = %v, want ErrUnsupportedAlgorithm", err) } } // 回归 P0:显式支持的 HS384 仍可正常签发与校验(确认算法固定未误伤合法 HMAC 族)。 func TestSupportedAlgorithmHS384(t *testing.T) { config.Set(&config.Config{JWT: config.JWTConfig{ Secret: "test-secret-key-1234567890123456789012", Algorithm: "HS384", Expire: time.Hour, }}) t.Cleanup(setupTestConfig) setupMiniRedis(t) token, err := jwt.GenerateToken(1, "u", "admin", "admin") if err != nil { t.Fatalf("HS384 GenerateToken: %v", err) } if _, err := jwt.ParseToken(token); err != nil { t.Errorf("HS384 ParseToken: %v", err) } } func TestParseTokenRejectsWrongIssuer(t *testing.T) { config.Set(&config.Config{JWT: config.JWTConfig{ Secret: "test-secret-key-1234567890123456789012", Expire: time.Hour, Issuer: "trusted-issuer", }}) t.Cleanup(setupTestConfig) claims := jwt.Claims{ UserID: 1, Username: "attacker", Role: "admin", UserType: "super_admin", RegisteredClaims: gojwt.RegisteredClaims{ ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)), IssuedAt: gojwt.NewNumericDate(time.Now()), NotBefore: gojwt.NewNumericDate(time.Now()), Issuer: "evil-issuer", }, } token := signTokenForTest(t, claims) if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrTokenInvalid) { t.Errorf("ParseToken wrong issuer err = %v, want ErrTokenInvalid", err) } if _, err := jwt.GetClaimsFromToken(token); !errors.Is(err, jwt.ErrTokenInvalid) { t.Errorf("GetClaimsFromToken wrong issuer err = %v, want ErrTokenInvalid", err) } } func TestRefreshTokenUsesRefreshExpire(t *testing.T) { config.Set(&config.Config{JWT: config.JWTConfig{ Secret: "test-secret-key-1234567890123456789012", Expire: time.Hour, RefreshExpire: 4 * time.Hour, }}) t.Cleanup(setupTestConfig) setupMiniRedis(t) token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin") if err != nil { t.Fatalf("GenerateToken: %v", err) } newToken, err := jwt.RefreshToken(token) if err != nil { t.Fatalf("RefreshToken: %v", err) } claims, err := jwt.ParseToken(newToken) if err != nil { t.Fatalf("ParseToken refreshed token: %v", err) } ttl := time.Until(claims.ExpiresAt.Time) if ttl < 3*time.Hour || ttl > 4*time.Hour+time.Minute { t.Errorf("refreshed token ttl = %v, want about 4h", ttl) } } func TestGenerateTokenWithCustomExpiryRejectsNonPositive(t *testing.T) { setupTestConfig() for _, seconds := range []int{0, -1} { if _, err := jwt.GenerateTokenWithCustomExpiry(1, "u", "admin", "admin", seconds); !errors.Is(err, jwt.ErrInvalidExpiry) { t.Errorf("GenerateTokenWithCustomExpiry(%d) err = %v, want ErrInvalidExpiry", seconds, err) } } } func TestInvalidateTokenByIDRejectsEmptyJTI(t *testing.T) { setupTestConfig() if err := jwt.InvalidateTokenByID("", time.Now().Add(time.Hour)); !errors.Is(err, jwt.ErrEmptyJTI) { t.Errorf("InvalidateTokenByID empty err = %v, want ErrEmptyJTI", err) } } func TestRefreshTokenRejectsEmptyJTI(t *testing.T) { setupTestConfig() setupMiniRedis(t) token := signTokenForTest(t, jwt.Claims{ UserID: 1, Username: "legacy", Role: "admin", UserType: "admin", RegisteredClaims: gojwt.RegisteredClaims{ ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)), IssuedAt: gojwt.NewNumericDate(time.Now()), NotBefore: gojwt.NewNumericDate(time.Now()), Issuer: "xlgo", }, }) if _, err := jwt.RefreshToken(token); !errors.Is(err, jwt.ErrEmptyJTI) { t.Errorf("RefreshToken empty JTI err = %v, want ErrEmptyJTI", err) } } func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) { setupTestConfig() setupMiniRedis(t) token := signTokenForTest(t, jwt.Claims{ UserID: 1, Username: "legacy", Role: "admin", UserType: "admin", RegisteredClaims: gojwt.RegisteredClaims{ ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)), IssuedAt: gojwt.NewNumericDate(time.Now()), NotBefore: gojwt.NewNumericDate(time.Now()), Issuer: "xlgo", }, }) if err := jwt.InvalidateToken(token); !errors.Is(err, jwt.ErrEmptyJTI) { t.Errorf("InvalidateToken empty JTI err = %v, want ErrEmptyJTI", err) } } func TestParseTokenEmptySecretReturnsSentinel(t *testing.T) { setupTestConfig() token, err := jwt.GenerateToken(1, "u", "admin", "admin") if err != nil { t.Fatalf("GenerateToken before empty secret: %v", err) } config.Set(&config.Config{JWT: config.JWTConfig{Secret: "", Expire: time.Hour}}) t.Cleanup(setupTestConfig) if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrEmptySecret) { t.Fatalf("ParseToken empty secret err = %v, want ErrEmptySecret", err) } } func TestParseTokenBlacklistPolicy(t *testing.T) { setupTestConfig() jwt.SetDefaultJWTManager(jwt.NewJWTManager()) t.Cleanup(func() { jwt.SetDefaultJWTManager(jwt.NewJWTManager()) }) token, err := jwt.GenerateToken(1, "test", "admin", "admin") if err != nil { t.Fatalf("GenerateToken: %v", err) } if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrBlacklistUnavailable) { t.Fatalf("ParseToken default fail-closed should reject without Redis, err = %v, want ErrBlacklistUnavailable", err) } if _, err := jwt.ParseTokenWithBlacklistPolicy(token, jwt.BlacklistFailOpen); err != nil { t.Fatalf("ParseTokenWithBlacklistPolicy fail-open should pass without Redis: %v", err) } } func TestInvalidateTokenAllowsFutureNotBeforeToken(t *testing.T) { setupTestConfig() mr := setupMiniRedis(t) claims := jwt.Claims{ UserID: 1, Username: "external", Role: "admin", UserType: "admin", JTI: "future-jti", RegisteredClaims: gojwt.RegisteredClaims{ ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)), IssuedAt: gojwt.NewNumericDate(time.Now()), NotBefore: gojwt.NewNumericDate(time.Now().Add(10 * time.Minute)), Issuer: "xlgo", ID: "future-jti", }, } token := signTokenForTest(t, claims) if err := jwt.InvalidateToken(token); err != nil { t.Fatalf("InvalidateToken future nbf: %v", err) } if !mr.Exists("jwt_bl:future-jti") { t.Fatal("future nbf token JTI should be blacklisted") } } func signTokenForTest(t *testing.T, claims jwt.Claims) string { t.Helper() token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012")) if err != nil { t.Fatalf("sign token: %v", err) } return token }