352 lines
8.6 KiB
Plaintext
352 lines
8.6 KiB
Plaintext
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()
|
||
} |