store_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. // infra/redis store 单测(T4-1 验收):用 miniredis(纯 Go 嵌入式 Redis 替身,test-only)
  2. // 覆盖 RefreshStore / RevocationStore 的关键路径——往返、键不存在、TTL 过期、幂等删除、
  3. // 以及 fail-closed 故障语义(Redis 故障时 IsRevoked 必须返回 error 而非静默放行)。
  4. //
  5. // 为什么用 miniredis 而非连真实 Redis:
  6. // - CI/无 Redis 环境也能跑(对齐 infra/db 测试"未设 DSN 则 skip"的纪律,但 store 逻辑更宜用
  7. // 嵌入式替身常跑,不留集成盲区);
  8. // - TTL/FastForward 可精确控制时钟,真实 Redis 要 sleep 等待过期,慢且不稳;
  9. // - fail-closed 故障路径(关 miniredis 模拟连接故障)在真实 Redis 上难以稳定复现。
  10. package redis
  11. import (
  12. "context"
  13. "testing"
  14. "time"
  15. miniredis "github.com/alicebob/miniredis/v2"
  16. "yuansiwei/unified-backend/internal/infra/config"
  17. )
  18. // newMiniredis 起一个嵌入式 Redis(miniredis),测试结束自动 Close。
  19. // v2.34.0 的 Run() 返回 (*Miniredis, error),不接 testing.T,故此处统一封装判错 + cleanup。
  20. func newMiniredis(t *testing.T) *miniredis.Miniredis {
  21. t.Helper()
  22. mr, err := miniredis.Run()
  23. if err != nil {
  24. t.Fatalf("miniredis.Run: %v", err)
  25. }
  26. t.Cleanup(mr.Close)
  27. return mr
  28. }
  29. // newTestClient 起一个 miniredis 并构造 *Client(探活走 miniredis,毫秒级通过)。
  30. func newTestClient(t *testing.T, mr *miniredis.Miniredis) *Client {
  31. t.Helper()
  32. rc, err := NewClient(config.RedisCfg{Addr: mr.Addr()})
  33. if err != nil {
  34. t.Fatalf("NewClient: %v", err)
  35. }
  36. t.Cleanup(func() { _ = rc.Close() })
  37. return rc
  38. }
  39. // ---- NewClient ----
  40. // TestNewClient_PingOK 探活成功路径:miniredis 在监听,NewClient 应返回可用客户端。
  41. func TestNewClient_PingOK(t *testing.T) {
  42. rc := newTestClient(t, newMiniredis(t))
  43. if rc == nil || rc.Client == nil {
  44. t.Fatal("NewClient 应返回非 nil 客户端")
  45. }
  46. }
  47. // TestNewClient_EmptyAddr Addr 为空必须 fail-fast(配置缺失,不允许带病启动)。
  48. func TestNewClient_EmptyAddr(t *testing.T) {
  49. if _, err := NewClient(config.RedisCfg{Addr: ""}); err == nil {
  50. t.Fatal("Addr 为空时 NewClient 必须返回 error(fail-fast)")
  51. }
  52. }
  53. // TestNewClient_Unreachable 连接被拒必须 fail-fast(Redis 不可用 = token 链路强依赖失效)。
  54. func TestNewClient_Unreachable(t *testing.T) {
  55. // 指向一个本机未监听的端口(127.0.0.1:1 几乎必然被拒)
  56. if _, err := NewClient(config.RedisCfg{Addr: "127.0.0.1:1"}); err == nil {
  57. t.Fatal("连接被拒时 NewClient 必须返回 error(fail-fast)")
  58. }
  59. }
  60. // ---- RefreshStore ----
  61. // TestRefreshStore_RoundTrip 写-读往返:SetRefresh 后 GetRefresh 应取回原 payload。
  62. func TestRefreshStore_RoundTrip(t *testing.T) {
  63. mr := newMiniredis(t)
  64. rc := newTestClient(t, mr)
  65. rs := NewRefreshStore(rc)
  66. ctx := context.Background()
  67. payload := []byte(`{"sub":"ABC","uid":1,"roles":["student"],"institution_id":11,"campus_id":3}`)
  68. if err := rs.SetRefresh(ctx, "jti-rt", payload, time.Hour); err != nil {
  69. t.Fatalf("SetRefresh: %v", err)
  70. }
  71. got, err := rs.GetRefresh(ctx, "jti-rt")
  72. if err != nil {
  73. t.Fatalf("GetRefresh: %v", err)
  74. }
  75. if string(got) != string(payload) {
  76. t.Fatalf("往返 payload 不符:期望 %q,实际 %q", payload, got)
  77. }
  78. }
  79. // TestRefreshStore_GetMiss 键不存在返回 (nil, nil)——不区分"未签发/已轮换/过期"(防枚举,token 包契约)。
  80. func TestRefreshStore_GetMiss(t *testing.T) {
  81. mr := newMiniredis(t)
  82. rc := newTestClient(t, mr)
  83. rs := NewRefreshStore(rc)
  84. ctx := context.Background()
  85. v, err := rs.GetRefresh(ctx, "jti-not-exist")
  86. if err != nil {
  87. t.Fatalf("键不存在时 GetRefresh 不应报错,实际: %v", err)
  88. }
  89. if v != nil {
  90. t.Fatalf("键不存在时 GetRefresh 应返回 nil payload,实际: %q", v)
  91. }
  92. }
  93. // TestRefreshStore_DeleteIdempotent DeleteRefresh 幂等:删不存在的键不报错(DEL 天然幂等)。
  94. func TestRefreshStore_DeleteIdempotent(t *testing.T) {
  95. mr := newMiniredis(t)
  96. rc := newTestClient(t, mr)
  97. rs := NewRefreshStore(rc)
  98. ctx := context.Background()
  99. if err := rs.DeleteRefresh(ctx, "jti-never-existed"); err != nil {
  100. t.Fatalf("删不存在的键不应报错(幂等),实际: %v", err)
  101. }
  102. }
  103. // TestRefreshStore_TTL TTL 生效:写入后 FastForward 超过 TTL,GetRefresh 应返回 nil。
  104. func TestRefreshStore_TTL(t *testing.T) {
  105. mr := newMiniredis(t)
  106. rc := newTestClient(t, mr)
  107. rs := NewRefreshStore(rc)
  108. ctx := context.Background()
  109. if err := rs.SetRefresh(ctx, "jti-ttl", []byte("payload"), 5*time.Second); err != nil {
  110. t.Fatalf("SetRefresh: %v", err)
  111. }
  112. // 未过期:可读
  113. if v, _ := rs.GetRefresh(ctx, "jti-ttl"); v == nil {
  114. t.Fatal("TTL 内 GetRefresh 应可读")
  115. }
  116. // 快进超过 TTL:键过期,读回 nil
  117. mr.FastForward(6 * time.Second)
  118. v2, err := rs.GetRefresh(ctx, "jti-ttl")
  119. if err != nil {
  120. t.Fatalf("GetRefresh: %v", err)
  121. }
  122. if v2 != nil {
  123. t.Fatalf("TTL 过期后 GetRefresh 应返回 nil,实际: %q", v2)
  124. }
  125. }
  126. // ---- RevocationStore ----
  127. // TestRevocationStore_Hit 写后查命中:AddRevoked 后 IsRevoked 应返回 true。
  128. func TestRevocationStore_Hit(t *testing.T) {
  129. mr := newMiniredis(t)
  130. rc := newTestClient(t, mr)
  131. rs := NewRevocationStore(rc, "revoked")
  132. ctx := context.Background()
  133. if err := rs.AddRevoked(ctx, "jti-h", time.Minute); err != nil {
  134. t.Fatalf("AddRevoked: %v", err)
  135. }
  136. revoked, err := rs.IsRevoked(ctx, "jti-h")
  137. if err != nil {
  138. t.Fatalf("IsRevoked: %v", err)
  139. }
  140. if !revoked {
  141. t.Fatal("AddRevoked 后 IsRevoked 应返回 true(命中黑名单)")
  142. }
  143. }
  144. // TestRevocationStore_Miss 未写入的 jti:IsRevoked 返回 (false, nil)。
  145. func TestRevocationStore_Miss(t *testing.T) {
  146. mr := newMiniredis(t)
  147. rc := newTestClient(t, mr)
  148. rs := NewRevocationStore(rc, "revoked")
  149. ctx := context.Background()
  150. revoked, err := rs.IsRevoked(ctx, "jti-never")
  151. if err != nil {
  152. t.Fatalf("IsRevoked: %v", err)
  153. }
  154. if revoked {
  155. t.Fatal("未写入的 jti IsRevoked 应返回 false(未吊销)")
  156. }
  157. }
  158. // TestRevocationStore_TTLZero ttl ≤ 0 不写键(token 本已过期,无需进黑名单)。
  159. func TestRevocationStore_TTLZero(t *testing.T) {
  160. mr := newMiniredis(t)
  161. rc := newTestClient(t, mr)
  162. rs := NewRevocationStore(rc, "revoked")
  163. ctx := context.Background()
  164. if err := rs.AddRevoked(ctx, "jti-zero", 0); err != nil {
  165. t.Fatalf("AddRevoked(ttl=0): %v", err)
  166. }
  167. // ttl≤0 不写键 → IsRevoked 应 false
  168. revoked, err := rs.IsRevoked(ctx, "jti-zero")
  169. if err != nil {
  170. t.Fatalf("IsRevoked: %v", err)
  171. }
  172. if revoked {
  173. t.Fatal("ttl=0 时 AddRevoked 不写键,IsRevoked 应 false")
  174. }
  175. }
  176. // TestRevocationStore_FailClosed 关键安全语义:Redis 故障时 IsRevoked 必须返回 error(fail-closed),
  177. // 绝不可把"查不到"当作"未吊销"静默放行——吊销失效 = 改密后旧 token 仍可用,安全红线。
  178. func TestRevocationStore_FailClosed(t *testing.T) {
  179. mr := newMiniredis(t)
  180. rc := newTestClient(t, mr)
  181. rs := NewRevocationStore(rc, "revoked")
  182. ctx := context.Background()
  183. // 正常态:IsRevoked 返回 (false, nil)
  184. if _, err := rs.IsRevoked(ctx, "jti-fc"); err != nil {
  185. t.Fatalf("正常态 IsRevoked 不应报错: %v", err)
  186. }
  187. // 关停 miniredis 模拟连接故障 → IsRevoked 必须 fail-closed 返回 error
  188. mr.Close()
  189. if _, err := rs.IsRevoked(ctx, "jti-fc"); err == nil {
  190. t.Fatal("Redis 故障后 IsRevoked 必须返回 error(fail-closed),实际返回 nil(= 静默放行,安全漏洞)")
  191. }
  192. }
  193. // TestRevocationStore_DefaultPrefix 前缀为空时用默认 "revoked"(防御,装配方应显式传配置值)。
  194. func TestRevocationStore_DefaultPrefix(t *testing.T) {
  195. mr := newMiniredis(t)
  196. rc := newTestClient(t, mr)
  197. rs := NewRevocationStore(rc, "") // 空前缀 → 默认 "revoked"
  198. ctx := context.Background()
  199. if err := rs.AddRevoked(ctx, "jti-dp", time.Minute); err != nil {
  200. t.Fatalf("AddRevoked: %v", err)
  201. }
  202. // 直接用底层 client 确认键落在 "revoked:jti-dp"
  203. exists := mr.Exists("revoked:jti-dp")
  204. if !exists {
  205. t.Fatal("空前缀应回落默认前缀 revoked,键 revoked:jti-dp 应存在")
  206. }
  207. }
  208. // ---- LoginTicketStore ----
  209. // TestLoginTicketStore_RoundTrip 写-消费往返:SetTicket 后 GetAndDeleteTicket 应取回 ULID 且键已删(一次性)。
  210. func TestLoginTicketStore_RoundTrip(t *testing.T) {
  211. mr := newMiniredis(t)
  212. rc := newTestClient(t, mr)
  213. lt := NewLoginTicketStore(rc)
  214. ctx := context.Background()
  215. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  216. if err := lt.SetTicket(ctx, "tk-rt", uid, time.Minute); err != nil {
  217. t.Fatalf("SetTicket: %v", err)
  218. }
  219. // 第一次消费:取到 ULID
  220. got, err := lt.GetAndDeleteTicket(ctx, "tk-rt")
  221. if err != nil {
  222. t.Fatalf("GetAndDeleteTicket: %v", err)
  223. }
  224. if got != uid {
  225. t.Fatalf("往返 ULID 不符:期望 %q,实际 %q", uid, got)
  226. }
  227. // 键应已被 GETDEL 原子删除(一次性):再消费取回 ""
  228. again, err := lt.GetAndDeleteTicket(ctx, "tk-rt")
  229. if err != nil {
  230. t.Fatalf("二次消费不应报错(未命中非故障): %v", err)
  231. }
  232. if again != "" {
  233. t.Fatalf("一次性票据二次消费应返回空串(已消费),实际 %q(= 重放漏洞)", again)
  234. }
  235. }
  236. // TestLoginTicketStore_Miss 未签发的票据:GetAndDeleteTicket 返回 ("", nil)(非故障)。
  237. func TestLoginTicketStore_Miss(t *testing.T) {
  238. mr := newMiniredis(t)
  239. rc := newTestClient(t, mr)
  240. lt := NewLoginTicketStore(rc)
  241. ctx := context.Background()
  242. got, err := lt.GetAndDeleteTicket(ctx, "tk-never")
  243. if err != nil {
  244. t.Fatalf("未命中时不应报错: %v", err)
  245. }
  246. if got != "" {
  247. t.Fatalf("未签发的票据应返回空串,实际 %q", got)
  248. }
  249. }
  250. // TestLoginTicketStore_TTL TTL 生效:写入后 FastForward 超过 TTL,消费应返回 ""(键过期自动删)。
  251. func TestLoginTicketStore_TTL(t *testing.T) {
  252. mr := newMiniredis(t)
  253. rc := newTestClient(t, mr)
  254. lt := NewLoginTicketStore(rc)
  255. ctx := context.Background()
  256. if err := lt.SetTicket(ctx, "tk-ttl", "01GAQF0Z6C1V9Y3E5H7K2M8B4T", 5*time.Second); err != nil {
  257. t.Fatalf("SetTicket: %v", err)
  258. }
  259. mr.FastForward(6 * time.Second) // 超过 TTL:键过期
  260. got, err := lt.GetAndDeleteTicket(ctx, "tk-ttl")
  261. if err != nil {
  262. t.Fatalf("GetAndDeleteTicket: %v", err)
  263. }
  264. if got != "" {
  265. t.Fatalf("TTL 过期后票据应失效(返回空串),实际 %q", got)
  266. }
  267. }
  268. // TestLoginTicketStore_FailClosed 关键安全语义:Redis 故障时 GetAndDeleteTicket 必须返回 error,
  269. // 不可降级为"无票据"(= 放行任意用户签 token,安全红线)。
  270. func TestLoginTicketStore_FailClosed(t *testing.T) {
  271. mr := newMiniredis(t)
  272. rc := newTestClient(t, mr)
  273. lt := NewLoginTicketStore(rc)
  274. ctx := context.Background()
  275. mr.Close() // 模拟连接故障
  276. if _, err := lt.GetAndDeleteTicket(ctx, "tk-fc"); err == nil {
  277. t.Fatal("Redis 故障后 GetAndDeleteTicket 必须返回 error(fail-closed),实际返回 nil(= 静默放行,安全漏洞)")
  278. }
  279. }