Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
)
|
||||
|
||||
// setupTestStore 创建 miniredis 服务器和 RefreshTokenStore,用于测试。
|
||||
// 返回 store、miniredis server 和 redis.Client,测试结束后需调用 cleanup。
|
||||
func setupTestStore(t *testing.T) (*RefreshTokenStore, *miniredis.Miniredis, *redis.Client) {
|
||||
t.Helper()
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
|
||||
cfg := &config.JWTConfig{
|
||||
Secret: "test-secret-key-min-32-chars!!",
|
||||
ExpiryHours: 24,
|
||||
RefreshExpiryHours: 7 * 24, // 7天
|
||||
AccessExpiryMinutes: 15,
|
||||
}
|
||||
|
||||
store := NewRefreshTokenStore(rdb, cfg)
|
||||
|
||||
t.Cleanup(func() {
|
||||
rdb.Close()
|
||||
mr.Close()
|
||||
})
|
||||
|
||||
return store, mr, rdb
|
||||
}
|
||||
|
||||
// ========== Store 测试 ==========
|
||||
|
||||
// TestRefreshTokenStore_Store 正常存储 refresh token
|
||||
func TestRefreshTokenStore_Store(t *testing.T) {
|
||||
store, mr, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uint(100)
|
||||
token := "refresh-token-abc123"
|
||||
|
||||
err := store.Store(ctx, userID, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Store 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 验证 Redis 中确实存储了正确的值
|
||||
key := "gochat:refresh_token:100"
|
||||
val, err := mr.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("miniredis.Get 返回错误: %v", err)
|
||||
}
|
||||
if val != token {
|
||||
t.Errorf("存储的值不匹配: got %q, want %q", val, token)
|
||||
}
|
||||
|
||||
// 验证 TTL 设置正确(7天 = 168小时)
|
||||
ttl := mr.TTL(key)
|
||||
expectedTTL := time.Duration(7*24) * time.Hour
|
||||
// TTL 允许一定误差(因为 miniredis 模拟时间可能有微小偏差)
|
||||
if ttl < expectedTTL-time.Minute || ttl > expectedTTL+time.Minute {
|
||||
t.Errorf("TTL 不在预期范围内: got %v, want 约 %v", ttl, expectedTTL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshTokenStore_Store_覆盖旧token Store 同一 userID 会覆盖旧值
|
||||
func TestRefreshTokenStore_Store_覆盖旧token(t *testing.T) {
|
||||
store, _, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uint(200)
|
||||
|
||||
// 第一次存储
|
||||
err := store.Store(ctx, userID, "old-token")
|
||||
if err != nil {
|
||||
t.Fatalf("第一次 Store 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 第二次存储(覆盖)
|
||||
err = store.Store(ctx, userID, "new-token")
|
||||
if err != nil {
|
||||
t.Fatalf("第二次 Store 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 验证 Validate 只匹配新 token
|
||||
ok, err := store.Validate(ctx, userID, "new-token")
|
||||
if err != nil {
|
||||
t.Fatalf("Validate 返回意外错误: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("新 token 应验证成功")
|
||||
}
|
||||
|
||||
ok, err = store.Validate(ctx, userID, "old-token")
|
||||
if err != nil {
|
||||
t.Fatalf("Validate 返回意外错误: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("旧 token 应验证失败")
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Validate 测试 ==========
|
||||
|
||||
// TestRefreshTokenStore Validate_匹配 验证匹配的 refresh token
|
||||
func TestRefreshTokenStore_Validate_匹配(t *testing.T) {
|
||||
store, _, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uint(300)
|
||||
token := "valid-refresh-token"
|
||||
|
||||
err := store.Store(ctx, userID, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Store 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
ok, err := store.Validate(ctx, userID, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate 返回意外错误: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("token 匹配时应返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshTokenStore Validate_不匹配 验证不匹配的 refresh token
|
||||
func TestRefreshTokenStore_Validate_不匹配(t *testing.T) {
|
||||
store, _, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uint(400)
|
||||
err := store.Store(ctx, userID, "correct-token")
|
||||
if err != nil {
|
||||
t.Fatalf("Store 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
ok, err := store.Validate(ctx, userID, "wrong-token")
|
||||
if err != nil {
|
||||
t.Fatalf("Validate 返回意外错误: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("不匹配的 token 应返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshTokenStore Validate_不存在 验证不存在(已过期/已撤销)的 token
|
||||
func TestRefreshTokenStore_Validate_不存在(t *testing.T) {
|
||||
store, _, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 没有存储任何 token,直接验证
|
||||
userID := uint(500)
|
||||
ok, err := store.Validate(ctx, userID, "any-token")
|
||||
if err != nil {
|
||||
t.Fatalf("Validate 返回意外错误: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("不存在的 token 应返回 false(而非错误)")
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Revoke 测试 ==========
|
||||
|
||||
// TestRefreshTokenStore_Revoke 正常撤销 refresh token
|
||||
func TestRefreshTokenStore_Revoke(t *testing.T) {
|
||||
store, mr, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uint(600)
|
||||
token := "to-be-revoked"
|
||||
|
||||
err := store.Store(ctx, userID, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Store 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
err = store.Revoke(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("Revoke 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 验证 Redis 中 key 已被删除
|
||||
key := "gochat:refresh_token:600"
|
||||
exists := mr.Exists(key)
|
||||
if exists {
|
||||
t.Error("Revoke 后 key 应不存在")
|
||||
}
|
||||
|
||||
// 验证 Validate 返回 false
|
||||
ok, err := store.Validate(ctx, userID, token)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate 返回意外错误: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("撤销后 Validate 应返回 false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshTokenStore_Revoke_不存在key 撤销不存在的 key 不会报错
|
||||
func TestRefreshTokenStore_Revoke_不存在key(t *testing.T) {
|
||||
store, _, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uint(700)
|
||||
err := store.Revoke(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("撤销不存在的 key 不应返回错误: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Rotate 测试 ==========
|
||||
|
||||
// TestRefreshTokenStore_Rotate 正常旋转 token(旧 token → 新 token)
|
||||
func TestRefreshTokenStore_Rotate(t *testing.T) {
|
||||
store, _, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uint(800)
|
||||
oldToken := "old-refresh-token"
|
||||
newToken := "new-refresh-token"
|
||||
|
||||
// 先存储旧 token
|
||||
err := store.Store(ctx, userID, oldToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Store 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 旋转:替换为新 token
|
||||
err = store.Rotate(ctx, userID, newToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 旧 token 应不再有效
|
||||
ok, err := store.Validate(ctx, userID, oldToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate 返回意外错误: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("旋转后旧 token 应验证失败")
|
||||
}
|
||||
|
||||
// 新 token 应有效
|
||||
ok, err = store.Validate(ctx, userID, newToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate 返回意外错误: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("旋转后新 token 应验证成功")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshTokenStore_Rotate_连续旋转 多次连续旋转 token
|
||||
func TestRefreshTokenStore_Rotate_连续旋转(t *testing.T) {
|
||||
store, _, _ := setupTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uint(900)
|
||||
|
||||
tokens := []string{"token-v1", "token-v2", "token-v3"}
|
||||
|
||||
// 存储初始 token
|
||||
err := store.Store(ctx, userID, tokens[0])
|
||||
if err != nil {
|
||||
t.Fatalf("Store 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 第一次旋转
|
||||
err = store.Rotate(ctx, userID, tokens[1])
|
||||
if err != nil {
|
||||
t.Fatalf("第一次 Rotate 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 第二次旋转
|
||||
err = store.Rotate(ctx, userID, tokens[2])
|
||||
if err != nil {
|
||||
t.Fatalf("第二次 Rotate 返回意外错误: %v", err)
|
||||
}
|
||||
|
||||
// 只有最新 token 有效
|
||||
for i, tok := range tokens {
|
||||
ok, err := store.Validate(ctx, userID, tok)
|
||||
if err != nil {
|
||||
t.Fatalf("Validate(token-%d) 返回意外错误: %v", i+1, err)
|
||||
}
|
||||
if i == 2 && !ok {
|
||||
t.Error("最新 token (token-v3) 应验证成功")
|
||||
}
|
||||
if i < 2 && ok {
|
||||
t.Errorf("旧 token (token-v%d) 应验证失败", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 异常路径测试 ==========
|
||||
|
||||
// TestRefreshTokenStore_Redis断连 Redis 连接断开时的错误处理
|
||||
func TestRefreshTokenStore_Redis断连(t *testing.T) {
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
cfg := &config.JWTConfig{
|
||||
Secret: "test-secret-key-min-32-chars!!",
|
||||
RefreshExpiryHours: 168,
|
||||
}
|
||||
|
||||
store := NewRefreshTokenStore(rdb, cfg)
|
||||
ctx := context.Background()
|
||||
|
||||
// 先关闭 miniredis 模拟断连
|
||||
mr.Close()
|
||||
|
||||
userID := uint(1000)
|
||||
|
||||
// Store 应返回错误
|
||||
err := store.Store(ctx, userID, "some-token")
|
||||
if err == nil {
|
||||
t.Error("Redis 断连时 Store 应返回错误")
|
||||
}
|
||||
|
||||
// Validate 应返回错误
|
||||
_, err = store.Validate(ctx, userID, "some-token")
|
||||
if err == nil {
|
||||
t.Error("Redis 断连时 Validate 应返回错误")
|
||||
}
|
||||
|
||||
// Revoke 应返回错误
|
||||
err = store.Revoke(ctx, userID)
|
||||
if err == nil {
|
||||
t.Error("Redis 断连时 Revoke 应返回错误")
|
||||
}
|
||||
|
||||
// Rotate 应返回错误
|
||||
err = store.Rotate(ctx, userID, "new-token")
|
||||
if err == nil {
|
||||
t.Error("Redis 断连时 Rotate 应返回错误")
|
||||
}
|
||||
|
||||
rdb.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user