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:
2026-07-07 14:44:12 +08:00
parent d4ef996f49
commit aeddedf2a3
1348 changed files with 176 additions and 57 deletions
@@ -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()
}