auth_service_test_helpers.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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, appid) 索引·v1.2;非微信类型 appid 恒 "")。
  67. type mockIdentities struct {
  68. byTypeIdentAppID map[string]map[string]map[string]*model.Identity
  69. }
  70. func newMockIdentities() *mockIdentities {
  71. return &mockIdentities{byTypeIdentAppID: map[string]map[string]map[string]*model.Identity{}}
  72. }
  73. // add 按 (typ, identifier, appid) 索引;非微信类型 appid 恒 "",微信类型传实际 appid。
  74. func (m *mockIdentities) add(typ, identifier, appid string, it *model.Identity) {
  75. if m.byTypeIdentAppID[typ] == nil {
  76. m.byTypeIdentAppID[typ] = map[string]map[string]*model.Identity{}
  77. }
  78. if m.byTypeIdentAppID[typ][identifier] == nil {
  79. m.byTypeIdentAppID[typ][identifier] = map[string]*model.Identity{}
  80. }
  81. m.byTypeIdentAppID[typ][identifier][appid] = it
  82. }
  83. func (m *mockIdentities) GetByTypeIdentifierAppID(_ context.Context, typ, identifier, appid string) (*model.Identity, error) {
  84. it, ok := m.byTypeIdentAppID[typ][identifier][appid]
  85. if !ok {
  86. return nil, apperrors.ErrNotFound
  87. }
  88. return it, nil
  89. }
  90. // mockMemberships 成员关系仓储 mock(ListBy + GetBy 两个查询路径,取 created_at 最新对齐 repo 语义)。
  91. type mockMemberships struct {
  92. byUser map[string][]orgmodel.Membership
  93. }
  94. func (m *mockMemberships) add(uid string, ms ...orgmodel.Membership) {
  95. m.byUser[uid] = append(m.byUser[uid], ms...)
  96. }
  97. func (m *mockMemberships) ListByCanonicalUserID(_ context.Context, uid string) ([]orgmodel.Membership, error) {
  98. list := m.byUser[uid]
  99. if list == nil {
  100. return []orgmodel.Membership{}, nil
  101. }
  102. return list, nil
  103. }
  104. func (m *mockMemberships) GetByUserInstitutionCampus(_ context.Context, uid string, inst, campus int64) (*orgmodel.Membership, error) {
  105. var best *orgmodel.Membership
  106. for i := range m.byUser[uid] {
  107. mm := &m.byUser[uid][i]
  108. if mm.InstitutionID == inst && mm.CampusID == campus {
  109. if best == nil || mm.CreatedAt.After(best.CreatedAt) {
  110. best = mm
  111. }
  112. }
  113. }
  114. if best == nil {
  115. return nil, apperrors.ErrNotFound
  116. }
  117. return best, nil
  118. }
  119. // ---- fixture(一个场景的全套依赖)----
  120. // testFixture mock 仓储 + 真 Issuer + miniredis 票据。
  121. type testFixture struct {
  122. t *testing.T
  123. users *mockUsers
  124. identities *mockIdentities
  125. memberships *mockMemberships
  126. svc *AuthService
  127. pubKeys []string
  128. }
  129. // newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService。
  130. // 仓储 mock 已建空,调用方按需 addUser/addIdentity/addMembership(见 helper 方法)。
  131. func newFixture(t *testing.T) *testFixture {
  132. t.Helper()
  133. mr, err := miniredis.Run()
  134. if err != nil {
  135. t.Fatalf("miniredis.Run: %v", err)
  136. }
  137. t.Cleanup(mr.Close)
  138. // 单一共享 Client:Refresh/Revocation/Ticket 三个 store 都嵌入 *Client,共用一个连接池,
  139. // 只需 Close 一次(避免多 client 的 Close 顺序问题)。
  140. rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
  141. if err != nil {
  142. t.Fatalf("NewClient: %v", err)
  143. }
  144. t.Cleanup(func() { _ = rc.Close() })
  145. privPEM, pubKeys := genRSAKey(t)
  146. refreshStore := appredis.NewRefreshStore(rc)
  147. revocation := appredis.NewRevocationStore(rc, "revoked")
  148. tickets := appredis.NewLoginTicketStore(rc)
  149. issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
  150. if err != nil {
  151. t.Fatalf("NewIssuer: %v", err)
  152. }
  153. users := &mockUsers{byUID: map[string]*model.User{}}
  154. ids := newMockIdentities()
  155. mergs := &mockMemberships{byUser: map[string][]orgmodel.Membership{}}
  156. svc, err := NewAuthService(Config{
  157. Users: users,
  158. Identities: ids,
  159. Memberships: mergs,
  160. Issuer: issuer,
  161. Tickets: tickets,
  162. AccessTTL: 30 * time.Minute,
  163. PublicKeys: pubKeys,
  164. IssuerStr: "https://unified.test",
  165. Revocation: revocation,
  166. LoginTicketTTL: 5 * time.Minute,
  167. })
  168. if err != nil {
  169. t.Fatalf("NewAuthService: %v", err)
  170. }
  171. return &testFixture{t: t, users: users, identities: ids, memberships: mergs, svc: svc, pubKeys: pubKeys}
  172. }
  173. // addUser 造一个用户 + 可选身份 + 可选 membership(测试夹具快捷构造)。
  174. // 入参:uid ULID;hashVersion 密码版本;hash/salt 密码三列;typ/identifier 登录身份(可为空则不建)。
  175. func (fx *testFixture) addUser(uid, hashVersion, hash, salt, typ, identifier string) {
  176. fx.t.Helper()
  177. u := &model.User{
  178. CanonicalUserID: uid,
  179. PersonType: model.PersonTypeStaff,
  180. Status: model.UserStatusActive,
  181. }
  182. if hashVersion != "" {
  183. u.HashVersion = &hashVersion
  184. }
  185. if hash != "" {
  186. u.PasswordHash = &hash
  187. }
  188. if salt != "" {
  189. u.PasswordSalt = &salt
  190. }
  191. fx.users.byUID[uid] = u
  192. if typ != "" && identifier != "" {
  193. // 非微信类型 appid 恒 ""(v1.2 唯一键 (type,identifier,appid);账密/手机/邮箱登录测试用)
  194. fx.identities.add(typ, identifier, "", &model.Identity{
  195. CanonicalUserID: uid, Type: typ, Identifier: identifier,
  196. })
  197. }
  198. }