auth_service_test_helpers.go 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. // AuthService 单测公共设施(RSA 密钥生成 + 仓储 mock + 真 Issuer + miniredis 票据)。
  2. //
  3. // 设计:Service 依赖倒置(依赖 repository 接口 + token.Issuer + redis.LoginTicketStore),
  4. // 故单测用 **mock 仓储**(纯内存,不连 DB)+ **真 token.Issuer**(RS256 真签发/验签)+
  5. // **miniredis 票据存储**(真 Redis 语义,含 GETDEL 原子消费)—— 覆盖 Service 编排逻辑而不碰真实 DB/Redis。
  6. //
  7. // RSA 密钥:测试内即时生成 RS2048 私钥(PKCS1 PEM)+ 公钥(PKIX PEM),不依赖 config/dev.json
  8. // (测试须独立于环境配置;密钥仅内存,不落盘/不入库)。
  9. package service
  10. import (
  11. "context"
  12. "crypto/rand"
  13. "crypto/rsa"
  14. "crypto/x509"
  15. "encoding/pem"
  16. "testing"
  17. "time"
  18. miniredis "github.com/alicebob/miniredis/v2"
  19. "yuansiwei/unified-backend/internal/identity/model"
  20. "yuansiwei/unified-backend/internal/identity/token"
  21. "yuansiwei/unified-backend/internal/infra/config"
  22. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  23. appredis "yuansiwei/unified-backend/internal/infra/redis"
  24. orgmodel "yuansiwei/unified-backend/internal/org/model"
  25. )
  26. // genRSAKey 生成 RS2048 私钥 + 公钥 PEM(PKCS1 私有 / PKIX 公共)。
  27. // 出参:(privatePEM, []publicPEM);公钥列表单元素(密钥轮换场景可多元素,测试用单钥)。
  28. func genRSAKey(t *testing.T) (string, []string) {
  29. t.Helper()
  30. key, err := rsa.GenerateKey(rand.Reader, 2048)
  31. if err != nil {
  32. t.Fatalf("生成 RSA 私钥: %v", err)
  33. }
  34. privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
  35. pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
  36. if err != nil {
  37. t.Fatalf("序列化公钥: %v", err)
  38. }
  39. pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
  40. return privPEM, []string{pubPEM}
  41. }
  42. // ---- 仓储 mock(实现 repository 接口,纯内存)----
  43. // mockUsers 用户仓储 mock(按 canonical_user_id 索引;UpdatePasswordLazyUpgrade 原地改指针,
  44. // 保证"升级后取用户"拿到新值——惰性升级测试依赖此语义)。
  45. type mockUsers struct {
  46. byUID map[string]*model.User
  47. }
  48. func (m *mockUsers) GetByCanonicalUserID(_ context.Context, uid string) (*model.User, error) {
  49. u, ok := m.byUID[uid]
  50. if !ok {
  51. return nil, apperrors.ErrNotFound
  52. }
  53. return u, nil
  54. }
  55. func (m *mockUsers) UpdatePasswordLazyUpgrade(_ context.Context, uid, newHash string) error {
  56. u, ok := m.byUID[uid]
  57. if !ok {
  58. return apperrors.ErrNotFound
  59. }
  60. u.PasswordHash = &newHash
  61. u.PasswordSalt = nil
  62. hv := model.HashVersionArgon2
  63. u.HashVersion = &hv
  64. return nil
  65. }
  66. // mockIdentities 身份仓储 mock(按 (type, identifier) 索引)。
  67. type mockIdentities struct {
  68. byTypeIdent map[string]map[string]*model.Identity
  69. }
  70. func newMockIdentities() *mockIdentities {
  71. return &mockIdentities{byTypeIdent: map[string]map[string]*model.Identity{}}
  72. }
  73. func (m *mockIdentities) add(typ, identifier string, it *model.Identity) {
  74. if m.byTypeIdent[typ] == nil {
  75. m.byTypeIdent[typ] = map[string]*model.Identity{}
  76. }
  77. m.byTypeIdent[typ][identifier] = it
  78. }
  79. func (m *mockIdentities) GetByTypeIdentifier(_ context.Context, typ, identifier string) (*model.Identity, error) {
  80. it, ok := m.byTypeIdent[typ][identifier]
  81. if !ok {
  82. return nil, apperrors.ErrNotFound
  83. }
  84. return it, nil
  85. }
  86. // mockMemberships 成员关系仓储 mock(ListBy + GetBy 两个查询路径,取 created_at 最新对齐 repo 语义)。
  87. type mockMemberships struct {
  88. byUser map[string][]orgmodel.Membership
  89. }
  90. func (m *mockMemberships) add(uid string, ms ...orgmodel.Membership) {
  91. m.byUser[uid] = append(m.byUser[uid], ms...)
  92. }
  93. func (m *mockMemberships) ListByCanonicalUserID(_ context.Context, uid string) ([]orgmodel.Membership, error) {
  94. list := m.byUser[uid]
  95. if list == nil {
  96. return []orgmodel.Membership{}, nil
  97. }
  98. return list, nil
  99. }
  100. func (m *mockMemberships) GetByUserInstitutionCampus(_ context.Context, uid string, inst, campus int64) (*orgmodel.Membership, error) {
  101. var best *orgmodel.Membership
  102. for i := range m.byUser[uid] {
  103. mm := &m.byUser[uid][i]
  104. if mm.InstitutionID == inst && mm.CampusID == campus {
  105. if best == nil || mm.CreatedAt.After(best.CreatedAt) {
  106. best = mm
  107. }
  108. }
  109. }
  110. if best == nil {
  111. return nil, apperrors.ErrNotFound
  112. }
  113. return best, nil
  114. }
  115. // ---- fixture(一个场景的全套依赖)----
  116. // testFixture mock 仓储 + 真 Issuer + miniredis 票据。
  117. type testFixture struct {
  118. t *testing.T
  119. users *mockUsers
  120. identities *mockIdentities
  121. memberships *mockMemberships
  122. svc *AuthService
  123. pubKeys []string
  124. }
  125. // newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService。
  126. // 仓储 mock 已建空,调用方按需 addUser/addIdentity/addMembership(见 helper 方法)。
  127. func newFixture(t *testing.T) *testFixture {
  128. t.Helper()
  129. mr, err := miniredis.Run()
  130. if err != nil {
  131. t.Fatalf("miniredis.Run: %v", err)
  132. }
  133. t.Cleanup(mr.Close)
  134. // 单一共享 Client:Refresh/Revocation/Ticket 三个 store 都嵌入 *Client,共用一个连接池,
  135. // 只需 Close 一次(避免多 client 的 Close 顺序问题)。
  136. rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
  137. if err != nil {
  138. t.Fatalf("NewClient: %v", err)
  139. }
  140. t.Cleanup(func() { _ = rc.Close() })
  141. privPEM, pubKeys := genRSAKey(t)
  142. refreshStore := appredis.NewRefreshStore(rc)
  143. revocation := appredis.NewRevocationStore(rc, "revoked")
  144. tickets := appredis.NewLoginTicketStore(rc)
  145. issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
  146. if err != nil {
  147. t.Fatalf("NewIssuer: %v", err)
  148. }
  149. users := &mockUsers{byUID: map[string]*model.User{}}
  150. ids := newMockIdentities()
  151. mergs := &mockMemberships{byUser: map[string][]orgmodel.Membership{}}
  152. svc, err := NewAuthService(Config{
  153. Users: users,
  154. Identities: ids,
  155. Memberships: mergs,
  156. Issuer: issuer,
  157. Tickets: tickets,
  158. AccessTTL: 30 * time.Minute,
  159. PublicKeys: pubKeys,
  160. IssuerStr: "https://unified.test",
  161. Revocation: revocation,
  162. LoginTicketTTL: 5 * time.Minute,
  163. })
  164. if err != nil {
  165. t.Fatalf("NewAuthService: %v", err)
  166. }
  167. return &testFixture{t: t, users: users, identities: ids, memberships: mergs, svc: svc, pubKeys: pubKeys}
  168. }
  169. // addUser 造一个用户 + 可选身份 + 可选 membership(测试夹具快捷构造)。
  170. // 入参:uid ULID;hashVersion 密码版本;hash/salt 密码三列;typ/identifier 登录身份(可为空则不建)。
  171. func (fx *testFixture) addUser(uid, hashVersion, hash, salt, typ, identifier string) {
  172. fx.t.Helper()
  173. u := &model.User{
  174. CanonicalUserID: uid,
  175. PersonType: model.PersonTypeStaff,
  176. Status: model.UserStatusActive,
  177. }
  178. if hashVersion != "" {
  179. u.HashVersion = &hashVersion
  180. }
  181. if hash != "" {
  182. u.PasswordHash = &hash
  183. }
  184. if salt != "" {
  185. u.PasswordSalt = &salt
  186. }
  187. fx.users.byUID[uid] = u
  188. if typ != "" && identifier != "" {
  189. fx.identities.add(typ, identifier, &model.Identity{
  190. CanonicalUserID: uid, Type: typ, Identifier: identifier,
  191. })
  192. }
  193. }