diff --git a/backend/internal/service/auth/refresh_test.go b/backend/internal/service/auth/refresh_test.go new file mode 100644 index 0000000..dd113ad --- /dev/null +++ b/backend/internal/service/auth/refresh_test.go @@ -0,0 +1,170 @@ +package auth + +import ( + "backend/config" + "backend/internal/dto" + "backend/internal/model" + "backend/utils" + "path/filepath" + "strings" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// newTestService 构造基于临时 SQLite 的 AuthService(每次测试独立库,互不干扰) // Logan.Li +func newTestService(t *testing.T) (*AuthService, *gorm.DB) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.sqlite3") + db, err := gorm.Open(sqlite.Open(dbPath+"?_journal_mode=WAL&_busy_timeout=5000"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("打开测试库失败: %v", err) + } + if err := db.AutoMigrate(&model.User{}, &model.RefreshToken{}); err != nil { + t.Fatalf("迁移测试库失败: %v", err) + } + user := model.User{ + Username: "tester", + Password: utils.HashPassword("pass1234"), + Role: "admin", + IsActive: true, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("创建测试用户失败: %v", err) + } + return NewAuthService(db, &config.Config{JWTSecret: "test-secret"}), db +} + +// login 走一次真实登录,拿到令牌对 // Logan.Li +func login(t *testing.T, svc *AuthService) *LoginResult { + t.Helper() + res, err := svc.Login(dto.LoginRequest{Username: "tester", Password: "pass1234"}, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("登录失败: %v", err) + } + if res.AccessToken == "" || res.RefreshToken == "" { + t.Fatalf("登录未返回完整令牌对: %+v", res) + } + return res +} + +// TestLoginIssuesPair 登录必须同时返回访问令牌与刷新令牌,且库里不存明文 // Logan.Li +func TestLoginIssuesPair(t *testing.T) { + svc, db := newTestService(t) + res := login(t, svc) + + if !strings.HasPrefix(res.RefreshToken, refreshTokenPrefix) { + t.Fatalf("刷新令牌前缀不符: %s", res.RefreshToken) + } + + var row model.RefreshToken + if err := db.First(&row).Error; err != nil { + t.Fatalf("刷新令牌未入库: %v", err) + } + if row.TokenHash == res.RefreshToken || strings.Contains(row.TokenHash, res.RefreshToken) { + t.Fatalf("库内不应存明文令牌: %s", row.TokenHash) + } + if row.TokenHash != hashRefreshToken(res.RefreshToken) { + t.Fatalf("库内摘要与令牌不匹配") + } + if !row.ExpiresAt.After(row.CreatedAt) { + t.Fatalf("到期时间应晚于创建时间") + } +} + +// TestRefreshRotates 刷新必须换出新令牌对并撤销旧令牌,旧访问令牌仍可用至过期 // Logan.Li +func TestRefreshRotates(t *testing.T) { + svc, db := newTestService(t) + first := login(t, svc) + + second, err := svc.Refresh(first.RefreshToken, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("刷新失败: %v", err) + } + if second.RefreshToken == first.RefreshToken { + t.Fatalf("刷新后令牌未轮换") + } + + var old model.RefreshToken + if err := db.Where("token_hash = ?", hashRefreshToken(first.RefreshToken)).First(&old).Error; err != nil { + t.Fatalf("旧令牌记录丢失: %v", err) + } + if old.RevokedAt == nil { + t.Fatalf("旧令牌应被撤销") + } + if old.RotatedTo != hashRefreshToken(second.RefreshToken) { + t.Fatalf("轮换链未记录新令牌摘要") + } + + // 新令牌仍可继续刷新 // Logan.Li + if _, err := svc.Refresh(second.RefreshToken, "127.0.0.1", "go-test"); err != nil { + t.Fatalf("新令牌刷新失败: %v", err) + } +} + +// TestReplayRevokesAll 重放已撤销的令牌 → 撤销该用户全部会话(防令牌泄漏) // Logan.Li +func TestReplayRevokesAll(t *testing.T) { + svc, db := newTestService(t) + first := login(t, svc) + second, err := svc.Refresh(first.RefreshToken, "127.0.0.1", "go-test") + if err != nil { + t.Fatalf("刷新失败: %v", err) + } + + // 用已撤销的旧令牌再刷新一次 // Logan.Li + if _, err := svc.Refresh(first.RefreshToken, "127.0.0.1", "go-test"); err == nil { + t.Fatalf("重放旧令牌本应失败") + } + + var active int64 + db.Model(&model.RefreshToken{}).Where("revoked_at IS NULL").Count(&active) + if active != 0 { + t.Fatalf("重放后应撤销全部令牌,仍有 %d 条有效", active) + } + if _, err := svc.Refresh(second.RefreshToken, "127.0.0.1", "go-test"); err == nil { + t.Fatalf("重放后新令牌也应失效") + } +} + +// TestRevokeOnLogout 登出后刷新令牌立即失效,且重复登出不报错 // Logan.Li +func TestRevokeOnLogout(t *testing.T) { + svc, _ := newTestService(t) + res := login(t, svc) + + svc.Revoke(res.RefreshToken) + svc.Revoke(res.RefreshToken) // 幂等 + if _, err := svc.Refresh(res.RefreshToken, "127.0.0.1", "go-test"); err == nil { + t.Fatalf("登出后刷新令牌仍可用") + } +} + +// TestRefreshRejectsInactive 账号被禁用后刷新失败 // Logan.Li +func TestRefreshRejectsInactive(t *testing.T) { + svc, db := newTestService(t) + res := login(t, svc) + + db.Model(&model.User{}).Where("username = ?", "tester").Update("is_active", false) + if _, err := svc.Refresh(res.RefreshToken, "127.0.0.1", "go-test"); err == nil { + t.Fatalf("禁用账号本应刷新失败") + } +} + +// TestChangePasswordRevokesRefresh 改密后刷新令牌全部撤销,避免绕过失效基线 // Logan.Li +func TestChangePasswordRevokesRefresh(t *testing.T) { + svc, _ := newTestService(t) + res := login(t, svc) + + if err := svc.ChangePassword(res.User.ID, dto.ChangePasswordRequest{ + OldPassword: "pass1234", + NewPassword: "pass5678", + }); err != nil { + t.Fatalf("改密失败: %v", err) + } + if _, err := svc.Refresh(res.RefreshToken, "127.0.0.1", "go-test"); err == nil { + t.Fatalf("改密后刷新令牌本应失效") + } +}