| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- // 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(= 静默放行,安全漏洞)")
- }
- }
|