// AuthService 单测公共设施(RSA 密钥生成 + 仓储 mock + 真 Issuer + miniredis 票据)。 // // 设计:Service 依赖倒置(依赖 repository 接口 + token.Issuer + redis.LoginTicketStore), // 故单测用 **mock 仓储**(纯内存,不连 DB)+ **真 token.Issuer**(RS256 真签发/验签)+ // **miniredis 票据存储**(真 Redis 语义,含 GETDEL 原子消费)—— 覆盖 Service 编排逻辑而不碰真实 DB/Redis。 // // RSA 密钥:测试内即时生成 RS2048 私钥(PKCS1 PEM)+ 公钥(PKIX PEM),不依赖 config/dev.json // (测试须独立于环境配置;密钥仅内存,不落盘/不入库)。 package service import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "testing" "time" miniredis "github.com/alicebob/miniredis/v2" "yuansiwei/unified-backend/internal/identity/model" "yuansiwei/unified-backend/internal/identity/token" "yuansiwei/unified-backend/internal/infra/config" apperrors "yuansiwei/unified-backend/internal/infra/errors" appredis "yuansiwei/unified-backend/internal/infra/redis" orgmodel "yuansiwei/unified-backend/internal/org/model" ) // genRSAKey 生成 RS2048 私钥 + 公钥 PEM(PKCS1 私有 / PKIX 公共)。 // 出参:(privatePEM, []publicPEM);公钥列表单元素(密钥轮换场景可多元素,测试用单钥)。 func genRSAKey(t *testing.T) (string, []string) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("生成 RSA 私钥: %v", err) } privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) if err != nil { t.Fatalf("序列化公钥: %v", err) } pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})) return privPEM, []string{pubPEM} } // ---- 仓储 mock(实现 repository 接口,纯内存)---- // mockUsers 用户仓储 mock(按 canonical_user_id 索引;UpdatePasswordLazyUpgrade 原地改指针, // 保证"升级后取用户"拿到新值——惰性升级测试依赖此语义)。 type mockUsers struct { byUID map[string]*model.User } func (m *mockUsers) GetByCanonicalUserID(_ context.Context, uid string) (*model.User, error) { u, ok := m.byUID[uid] if !ok { return nil, apperrors.ErrNotFound } return u, nil } func (m *mockUsers) UpdatePasswordLazyUpgrade(_ context.Context, uid, newHash string) error { u, ok := m.byUID[uid] if !ok { return apperrors.ErrNotFound } u.PasswordHash = &newHash u.PasswordSalt = nil hv := model.HashVersionArgon2 u.HashVersion = &hv return nil } // mockIdentities 身份仓储 mock(按 (type, identifier, appid) 索引·v1.2;非微信类型 appid 恒 "")。 type mockIdentities struct { byTypeIdentAppID map[string]map[string]map[string]*model.Identity } func newMockIdentities() *mockIdentities { return &mockIdentities{byTypeIdentAppID: map[string]map[string]map[string]*model.Identity{}} } // add 按 (typ, identifier, appid) 索引;非微信类型 appid 恒 "",微信类型传实际 appid。 func (m *mockIdentities) add(typ, identifier, appid string, it *model.Identity) { if m.byTypeIdentAppID[typ] == nil { m.byTypeIdentAppID[typ] = map[string]map[string]*model.Identity{} } if m.byTypeIdentAppID[typ][identifier] == nil { m.byTypeIdentAppID[typ][identifier] = map[string]*model.Identity{} } m.byTypeIdentAppID[typ][identifier][appid] = it } func (m *mockIdentities) GetByTypeIdentifierAppID(_ context.Context, typ, identifier, appid string) (*model.Identity, error) { it, ok := m.byTypeIdentAppID[typ][identifier][appid] if !ok { return nil, apperrors.ErrNotFound } return it, nil } // mockMemberships 成员关系仓储 mock(ListBy + GetBy 两个查询路径,取 created_at 最新对齐 repo 语义)。 type mockMemberships struct { byUser map[string][]orgmodel.Membership } func (m *mockMemberships) add(uid string, ms ...orgmodel.Membership) { m.byUser[uid] = append(m.byUser[uid], ms...) } func (m *mockMemberships) ListByCanonicalUserID(_ context.Context, uid string) ([]orgmodel.Membership, error) { list := m.byUser[uid] if list == nil { return []orgmodel.Membership{}, nil } return list, nil } func (m *mockMemberships) GetByUserInstitutionCampus(_ context.Context, uid string, inst, campus int64) (*orgmodel.Membership, error) { var best *orgmodel.Membership for i := range m.byUser[uid] { mm := &m.byUser[uid][i] if mm.InstitutionID == inst && mm.CampusID == campus { if best == nil || mm.CreatedAt.After(best.CreatedAt) { best = mm } } } if best == nil { return nil, apperrors.ErrNotFound } return best, nil } // ---- fixture(一个场景的全套依赖)---- // testFixture mock 仓储 + 真 Issuer + miniredis 票据。 type testFixture struct { t *testing.T users *mockUsers identities *mockIdentities memberships *mockMemberships svc *AuthService pubKeys []string } // newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService。 // 仓储 mock 已建空,调用方按需 addUser/addIdentity/addMembership(见 helper 方法)。 func newFixture(t *testing.T) *testFixture { t.Helper() mr, err := miniredis.Run() if err != nil { t.Fatalf("miniredis.Run: %v", err) } t.Cleanup(mr.Close) // 单一共享 Client:Refresh/Revocation/Ticket 三个 store 都嵌入 *Client,共用一个连接池, // 只需 Close 一次(避免多 client 的 Close 顺序问题)。 rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()}) if err != nil { t.Fatalf("NewClient: %v", err) } t.Cleanup(func() { _ = rc.Close() }) privPEM, pubKeys := genRSAKey(t) refreshStore := appredis.NewRefreshStore(rc) revocation := appredis.NewRevocationStore(rc, "revoked") tickets := appredis.NewLoginTicketStore(rc) issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation) if err != nil { t.Fatalf("NewIssuer: %v", err) } users := &mockUsers{byUID: map[string]*model.User{}} ids := newMockIdentities() mergs := &mockMemberships{byUser: map[string][]orgmodel.Membership{}} svc, err := NewAuthService(Config{ Users: users, Identities: ids, Memberships: mergs, Issuer: issuer, Tickets: tickets, AccessTTL: 30 * time.Minute, PublicKeys: pubKeys, IssuerStr: "https://unified.test", Revocation: revocation, LoginTicketTTL: 5 * time.Minute, }) if err != nil { t.Fatalf("NewAuthService: %v", err) } return &testFixture{t: t, users: users, identities: ids, memberships: mergs, svc: svc, pubKeys: pubKeys} } // addUser 造一个用户 + 可选身份 + 可选 membership(测试夹具快捷构造)。 // 入参:uid ULID;hashVersion 密码版本;hash/salt 密码三列;typ/identifier 登录身份(可为空则不建)。 func (fx *testFixture) addUser(uid, hashVersion, hash, salt, typ, identifier string) { fx.t.Helper() u := &model.User{ CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive, } if hashVersion != "" { u.HashVersion = &hashVersion } if hash != "" { u.PasswordHash = &hash } if salt != "" { u.PasswordSalt = &salt } fx.users.byUID[uid] = u if typ != "" && identifier != "" { // 非微信类型 appid 恒 ""(v1.2 唯一键 (type,identifier,appid);账密/手机/邮箱登录测试用) fx.identities.add(typ, identifier, "", &model.Identity{ CanonicalUserID: uid, Type: typ, Identifier: identifier, }) } }