Files
杭州明婳科技 9e11f8f007 refactor: 同义可失败 API 收敛(cache/jwt/storage/ratelimit breaking change)
按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 fail-closed"原则,
收敛历史双轨 API。自定义 CacheService/Storage 实现需同步更新签名(编译失败即迁移信号)。

cache:
- CacheService.Get/Exists 改为 (bool, error):命中 (true,nil)、未命中 (false,nil)、
  Redis 未就绪/命令错误/反序列化失败返回 (false,err)
- 删除 cache.GetE/cache.ExistsE/CacheGetter/CacheExistChecker/redisCache.GetE/redisCache.ExistsE
  (不保留 deprecated wrapper)
- 新增包级 cache.Get/cache.Exists(带 error);GetWithPrefix 改为 (bool,error)

jwt:
- TokenBlacklist.IsBlacklisted 改为 (bool, error),删除 IsBlacklistedE
- IsTokenRevoked 改为 (bool, error)
- ParseToken 默认从 fail-open 改为 fail-closed:黑名单后端不可检查时返回
  ErrBlacklistUnavailable 拒绝该 Token(无 Redis 部署不再支持可靠撤销)
- 删除 ParseTokenFailClosed(主 API 已 fail-closed)
- 保留 ParseTokenWithBlacklistPolicy + BlacklistPolicy/BlacklistFailOpen/BlacklistFailClosed
  供显式 fail-open(仅无 Redis 或低安全场景)

storage:
- Storage.Exists 改为 (bool, error):存在 (true,nil)、不存在 (false,nil)、
  未初始化/路径非法/穿越/后端错误返回 (false,err)
- LocalStorage.Exists 区分 os.IsNotExist 与其他 Stat 错误
- OSSStorage.Exists 区分 OSS 404/NoSuchKey 与鉴权/网络错误
- 包级 storage.Exists 同步签名,未初始化返回 ErrStorageNotInitialized
- 新增 ErrReadTooLarge:Local/OSSStorage.Get 读取超 maxReadBytes 上限原误用
  ErrInvalidPath(语义不贴切),改为 ErrReadTooLarge

ratelimit:
- NewRedisRateLimiter 加 opts ...RedisRateLimiterOption,新增 WithFailClosed(bool)
  替代原 NewRedisRateLimiterFailClosed
- RedisRateLimit/CustomRedisRateLimit/RedisRateLimitWithIdentifier 同步加 opts 参数
- 删除 NewRedisRateLimiterFailClosed/RedisRateLimitFailClosed/CustomRedisRateLimitFailClosed
- UploadRedisRateLimit 由 fail-open 改为 fail-closed(资源敏感操作,Redis 故障时拒绝)

测试与文档:
- cache_m10_internal_test.go 重写 4 个 M10 测试为 Get/Exists 契约测试,新增 mock 编译覆盖
- jwt_test.go 改 4 处;jwt_c9c_internal_test.go 改 IsTokenRevoked 2-value
- examples/full/main_test.go 加 miniredis 注入(ParseToken fail-closed 后认证需 Redis)
- middleware_test.go 改 5 处 FailClosed 调用为 WithFailClosed(true);auth_test.go 加 Redis
- storage 测试改 6 处 Exists/Get 断言为新签名/错误类型
- README/GUIDE 同步示例为新签名;CHANGELOG 新增 Breaking 升级说明与迁移示例

验证:go build / go vet / go test -race -count=1 ./... 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 22:22:10 +08:00

161 lines
5.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/EthanCodeCraft/xlgo-core/config"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/jwt"
"github.com/EthanCodeCraft/xlgo-core/validation"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
func setupExampleRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
t.Helper()
database.RegisterDialect(database.DialectSpec{
Name: "sqlite_full_example_test",
Dialector: func(dsn string) gorm.Dialector { return sqlite.Open(dsn) },
DSN: func(c *config.DatabaseConfig) string {
return c.CustomDSN
},
})
cfg := &config.Config{
Database: config.DatabaseConfig{
Driver: "sqlite_full_example_test",
CustomDSN: filepath.Join(t.TempDir(), "full-example.db"),
},
JWT: config.JWTConfig{
Secret: "test-secret-for-full-example-at-least-32-bytes",
Expire: time.Hour,
RefreshExpire: 24 * time.Hour,
Issuer: "xlgo",
Algorithm: "HS256",
},
}
if err := config.Set(cfg); err != nil {
t.Fatalf("设置测试配置失败: %v", err)
}
mgr := database.NewManager(cfg)
if err := mgr.InitDB(context.Background(), cfg); err != nil {
t.Fatalf("初始化测试数据库失败: %v", err)
}
db := mgr.Master()
if err := db.AutoMigrate(&User{}); err != nil {
t.Fatalf("迁移示例用户表失败: %v", err)
}
prev := database.SwapDefaultManager(mgr)
t.Cleanup(func() {
current := database.SwapDefaultManager(prev)
if current != nil && current != prev {
_ = current.Close()
}
})
// JWT 黑名单走 RedisParseToken 默认 fail-closed,无 Redis 会拒绝所有 token)。
// 测试用 miniredis 注入,避免依赖外部 Redis。
mr := miniredis.RunT(t)
redisClient := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = redisClient.Close() })
prevJWTMgr := jwt.GetDefaultJWT()
jwt.SetDefaultJWTManager(jwt.NewJWTManagerWithRedis(redisClient))
t.Cleanup(func() { jwt.SetDefaultJWTManager(prevJWTMgr) })
gin.SetMode(gin.TestMode)
r := gin.New()
registerRoutes(r.Group(""))
if err := ensureExampleUser(context.Background()); err != nil {
t.Fatalf("初始化示例用户失败: %v", err)
}
return r, db
}
func postJSON(t *testing.T, r http.Handler, path string, body string, token string) (int, map[string]any) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var payload map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("解析响应 JSON 失败: %v, body=%s", err, rec.Body.String())
}
return rec.Code, payload
}
func tokenFromResponse(t *testing.T, payload map[string]any) string {
t.Helper()
data, ok := payload["data"].(map[string]any)
if !ok {
t.Fatalf("响应缺少 data 对象: %#v", payload)
}
token, ok := data["token"].(string)
if !ok || strings.TrimSpace(token) == "" {
t.Fatalf("响应缺少 token: %#v", payload)
}
return token
}
func TestFullExampleLoginAndPasswordFlow(t *testing.T) {
r, db := setupExampleRouter(t)
var seeded User
if err := db.Where("username = ?", "alice").First(&seeded).Error; err != nil {
t.Fatalf("示例用户 alice 应在启动时初始化: %v", err)
}
if seeded.Password == "" || seeded.Password == "secret" {
t.Fatal("示例用户密码必须保存为 bcrypt 哈希,不能保存明文")
}
if !validation.CheckPassword(seeded.Password, "secret") {
t.Fatal("示例用户 alice/secret 应能通过密码校验")
}
status, wrong := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"wrong"}`, "")
if status != http.StatusOK {
t.Fatalf("业务失败模式下 HTTP 状态应为 200got %d", status)
}
if code, _ := wrong["code"].(float64); code == 0 {
t.Fatalf("错误密码应返回业务失败: %#v", wrong)
}
_, loginPayload := postJSON(t, r, "/api/v1/login", `{"username":"alice","password":"secret"}`, "")
token := tokenFromResponse(t, loginPayload)
_, createPayload := postJSON(t, r, "/api/v1/users", `{"username":"bob","password":"s3cret"}`, token)
if code, _ := createPayload["code"].(float64); code != 0 {
t.Fatalf("带 token 创建用户应成功: %#v", createPayload)
}
var bob User
if err := db.Where("username = ?", "bob").First(&bob).Error; err != nil {
t.Fatalf("创建后的用户应写入数据库: %v", err)
}
if bob.Password == "" || bob.Password == "s3cret" {
t.Fatal("创建用户时必须保存 bcrypt 哈希,不能保存明文")
}
if !validation.CheckPassword(bob.Password, "s3cret") {
t.Fatal("创建用户的密码哈希应能通过校验")
}
_, bobLogin := postJSON(t, r, "/api/v1/login", `{"username":"bob","password":"s3cret"}`, "")
_ = tokenFromResponse(t, bobLogin)
}