9e11f8f007
按"同一能力只保留一个主入口、框架不替上层吞错、安全路径默认 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>
161 lines
5.0 KiB
Go
161 lines
5.0 KiB
Go
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 黑名单走 Redis(ParseToken 默认 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 状态应为 200,got %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)
|
||
}
|