// infra/redis store 单测(T4-1 验收):用 miniredis(纯 Go 嵌入式 Redis 替身,test-only) // 覆盖 RefreshStore / RevocationStore 的关键路径——往返、键不存在、TTL 过期、幂等删除、 // 以及 fail-closed 故障语义(Redis 故障时 IsRevoked 必须返回 error 而非静默放行)。 // // 为什么用 miniredis 而非连真实 Redis: // - CI/无 Redis 环境也能跑(对齐 infra/db 测试"未设 DSN 则 skip"的纪律,但 store 逻辑更宜用 // 嵌入式替身常跑,不留集成盲区); // - TTL/FastForward 可精确控制时钟,真实 Redis 要 sleep 等待过期,慢且不稳; // - fail-closed 故障路径(关 miniredis 模拟连接故障)在真实 Redis 上难以稳定复现。 package redis import ( "context" "testing" "time" miniredis "github.com/alicebob/miniredis/v2" "yuansiwei/unified-backend/internal/infra/config" ) // newMiniredis 起一个嵌入式 Redis(miniredis),测试结束自动 Close。 // v2.34.0 的 Run() 返回 (*Miniredis, error),不接 testing.T,故此处统一封装判错 + cleanup。 func newMiniredis(t *testing.T) *miniredis.Miniredis { t.Helper() mr, err := miniredis.Run() if err != nil { t.Fatalf("miniredis.Run: %v", err) } t.Cleanup(mr.Close) return mr } // newTestClient 起一个 miniredis 并构造 *Client(探活走 miniredis,毫秒级通过)。 func newTestClient(t *testing.T, mr *miniredis.Miniredis) *Client { t.Helper() rc, err := NewClient(config.RedisCfg{Addr: mr.Addr()}) if err != nil { t.Fatalf("NewClient: %v", err) } t.Cleanup(func() { _ = rc.Close() }) return rc } // ---- NewClient ---- // TestNewClient_PingOK 探活成功路径:miniredis 在监听,NewClient 应返回可用客户端。 func TestNewClient_PingOK(t *testing.T) { rc := newTestClient(t, newMiniredis(t)) if rc == nil || rc.Client == nil { t.Fatal("NewClient 应返回非 nil 客户端") } } // TestNewClient_EmptyAddr Addr 为空必须 fail-fast(配置缺失,不允许带病启动)。 func TestNewClient_EmptyAddr(t *testing.T) { if _, err := NewClient(config.RedisCfg{Addr: ""}); err == nil { t.Fatal("Addr 为空时 NewClient 必须返回 error(fail-fast)") } } // TestNewClient_Unreachable 连接被拒必须 fail-fast(Redis 不可用 = token 链路强依赖失效)。 func TestNewClient_Unreachable(t *testing.T) { // 指向一个本机未监听的端口(127.0.0.1:1 几乎必然被拒) if _, err := NewClient(config.RedisCfg{Addr: "127.0.0.1:1"}); err == nil { t.Fatal("连接被拒时 NewClient 必须返回 error(fail-fast)") } } // ---- RefreshStore ---- // TestRefreshStore_RoundTrip 写-读往返:SetRefresh 后 GetRefresh 应取回原 payload。 func TestRefreshStore_RoundTrip(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRefreshStore(rc) ctx := context.Background() payload := []byte(`{"sub":"ABC","uid":1,"roles":["student"],"institution_id":11,"campus_id":3}`) if err := rs.SetRefresh(ctx, "jti-rt", payload, time.Hour); err != nil { t.Fatalf("SetRefresh: %v", err) } got, err := rs.GetRefresh(ctx, "jti-rt") if err != nil { t.Fatalf("GetRefresh: %v", err) } if string(got) != string(payload) { t.Fatalf("往返 payload 不符:期望 %q,实际 %q", payload, got) } } // TestRefreshStore_GetMiss 键不存在返回 (nil, nil)——不区分"未签发/已轮换/过期"(防枚举,token 包契约)。 func TestRefreshStore_GetMiss(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRefreshStore(rc) ctx := context.Background() v, err := rs.GetRefresh(ctx, "jti-not-exist") if err != nil { t.Fatalf("键不存在时 GetRefresh 不应报错,实际: %v", err) } if v != nil { t.Fatalf("键不存在时 GetRefresh 应返回 nil payload,实际: %q", v) } } // TestRefreshStore_DeleteIdempotent DeleteRefresh 幂等:删不存在的键不报错(DEL 天然幂等)。 func TestRefreshStore_DeleteIdempotent(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRefreshStore(rc) ctx := context.Background() if err := rs.DeleteRefresh(ctx, "jti-never-existed"); err != nil { t.Fatalf("删不存在的键不应报错(幂等),实际: %v", err) } } // TestRefreshStore_TTL TTL 生效:写入后 FastForward 超过 TTL,GetRefresh 应返回 nil。 func TestRefreshStore_TTL(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRefreshStore(rc) ctx := context.Background() if err := rs.SetRefresh(ctx, "jti-ttl", []byte("payload"), 5*time.Second); err != nil { t.Fatalf("SetRefresh: %v", err) } // 未过期:可读 if v, _ := rs.GetRefresh(ctx, "jti-ttl"); v == nil { t.Fatal("TTL 内 GetRefresh 应可读") } // 快进超过 TTL:键过期,读回 nil mr.FastForward(6 * time.Second) v2, err := rs.GetRefresh(ctx, "jti-ttl") if err != nil { t.Fatalf("GetRefresh: %v", err) } if v2 != nil { t.Fatalf("TTL 过期后 GetRefresh 应返回 nil,实际: %q", v2) } } // ---- RevocationStore ---- // TestRevocationStore_Hit 写后查命中:AddRevoked 后 IsRevoked 应返回 true。 func TestRevocationStore_Hit(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRevocationStore(rc, "revoked") ctx := context.Background() if err := rs.AddRevoked(ctx, "jti-h", time.Minute); err != nil { t.Fatalf("AddRevoked: %v", err) } revoked, err := rs.IsRevoked(ctx, "jti-h") if err != nil { t.Fatalf("IsRevoked: %v", err) } if !revoked { t.Fatal("AddRevoked 后 IsRevoked 应返回 true(命中黑名单)") } } // TestRevocationStore_Miss 未写入的 jti:IsRevoked 返回 (false, nil)。 func TestRevocationStore_Miss(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRevocationStore(rc, "revoked") ctx := context.Background() revoked, err := rs.IsRevoked(ctx, "jti-never") if err != nil { t.Fatalf("IsRevoked: %v", err) } if revoked { t.Fatal("未写入的 jti IsRevoked 应返回 false(未吊销)") } } // TestRevocationStore_TTLZero ttl ≤ 0 不写键(token 本已过期,无需进黑名单)。 func TestRevocationStore_TTLZero(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRevocationStore(rc, "revoked") ctx := context.Background() if err := rs.AddRevoked(ctx, "jti-zero", 0); err != nil { t.Fatalf("AddRevoked(ttl=0): %v", err) } // ttl≤0 不写键 → IsRevoked 应 false revoked, err := rs.IsRevoked(ctx, "jti-zero") if err != nil { t.Fatalf("IsRevoked: %v", err) } if revoked { t.Fatal("ttl=0 时 AddRevoked 不写键,IsRevoked 应 false") } } // TestRevocationStore_FailClosed 关键安全语义:Redis 故障时 IsRevoked 必须返回 error(fail-closed), // 绝不可把"查不到"当作"未吊销"静默放行——吊销失效 = 改密后旧 token 仍可用,安全红线。 func TestRevocationStore_FailClosed(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRevocationStore(rc, "revoked") ctx := context.Background() // 正常态:IsRevoked 返回 (false, nil) if _, err := rs.IsRevoked(ctx, "jti-fc"); err != nil { t.Fatalf("正常态 IsRevoked 不应报错: %v", err) } // 关停 miniredis 模拟连接故障 → IsRevoked 必须 fail-closed 返回 error mr.Close() if _, err := rs.IsRevoked(ctx, "jti-fc"); err == nil { t.Fatal("Redis 故障后 IsRevoked 必须返回 error(fail-closed),实际返回 nil(= 静默放行,安全漏洞)") } } // TestRevocationStore_DefaultPrefix 前缀为空时用默认 "revoked"(防御,装配方应显式传配置值)。 func TestRevocationStore_DefaultPrefix(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) rs := NewRevocationStore(rc, "") // 空前缀 → 默认 "revoked" ctx := context.Background() if err := rs.AddRevoked(ctx, "jti-dp", time.Minute); err != nil { t.Fatalf("AddRevoked: %v", err) } // 直接用底层 client 确认键落在 "revoked:jti-dp" exists := mr.Exists("revoked:jti-dp") if !exists { t.Fatal("空前缀应回落默认前缀 revoked,键 revoked:jti-dp 应存在") } } // ---- LoginTicketStore ---- // TestLoginTicketStore_RoundTrip 写-消费往返:SetTicket 后 GetAndDeleteTicket 应取回 ULID 且键已删(一次性)。 func TestLoginTicketStore_RoundTrip(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) lt := NewLoginTicketStore(rc) ctx := context.Background() uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" if err := lt.SetTicket(ctx, "tk-rt", uid, time.Minute); err != nil { t.Fatalf("SetTicket: %v", err) } // 第一次消费:取到 ULID got, err := lt.GetAndDeleteTicket(ctx, "tk-rt") if err != nil { t.Fatalf("GetAndDeleteTicket: %v", err) } if got != uid { t.Fatalf("往返 ULID 不符:期望 %q,实际 %q", uid, got) } // 键应已被 GETDEL 原子删除(一次性):再消费取回 "" again, err := lt.GetAndDeleteTicket(ctx, "tk-rt") if err != nil { t.Fatalf("二次消费不应报错(未命中非故障): %v", err) } if again != "" { t.Fatalf("一次性票据二次消费应返回空串(已消费),实际 %q(= 重放漏洞)", again) } } // TestLoginTicketStore_Miss 未签发的票据:GetAndDeleteTicket 返回 ("", nil)(非故障)。 func TestLoginTicketStore_Miss(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) lt := NewLoginTicketStore(rc) ctx := context.Background() got, err := lt.GetAndDeleteTicket(ctx, "tk-never") if err != nil { t.Fatalf("未命中时不应报错: %v", err) } if got != "" { t.Fatalf("未签发的票据应返回空串,实际 %q", got) } } // TestLoginTicketStore_TTL TTL 生效:写入后 FastForward 超过 TTL,消费应返回 ""(键过期自动删)。 func TestLoginTicketStore_TTL(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) lt := NewLoginTicketStore(rc) ctx := context.Background() if err := lt.SetTicket(ctx, "tk-ttl", "01GAQF0Z6C1V9Y3E5H7K2M8B4T", 5*time.Second); err != nil { t.Fatalf("SetTicket: %v", err) } mr.FastForward(6 * time.Second) // 超过 TTL:键过期 got, err := lt.GetAndDeleteTicket(ctx, "tk-ttl") if err != nil { t.Fatalf("GetAndDeleteTicket: %v", err) } if got != "" { t.Fatalf("TTL 过期后票据应失效(返回空串),实际 %q", got) } } // TestLoginTicketStore_FailClosed 关键安全语义:Redis 故障时 GetAndDeleteTicket 必须返回 error, // 不可降级为"无票据"(= 放行任意用户签 token,安全红线)。 func TestLoginTicketStore_FailClosed(t *testing.T) { mr := newMiniredis(t) rc := newTestClient(t, mr) lt := NewLoginTicketStore(rc) ctx := context.Background() mr.Close() // 模拟连接故障 if _, err := lt.GetAndDeleteTicket(ctx, "tk-fc"); err == nil { t.Fatal("Redis 故障后 GetAndDeleteTicket 必须返回 error(fail-closed),实际返回 nil(= 静默放行,安全漏洞)") } }