|
@@ -0,0 +1,236 @@
|
|
|
|
|
+// 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 应存在")
|
|
|
|
|
+ }
|
|
|
|
|
+}
|