| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210 |
- // 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) 索引)。
- type mockIdentities struct {
- byTypeIdent map[string]map[string]*model.Identity
- }
- func newMockIdentities() *mockIdentities {
- return &mockIdentities{byTypeIdent: map[string]map[string]*model.Identity{}}
- }
- func (m *mockIdentities) add(typ, identifier string, it *model.Identity) {
- if m.byTypeIdent[typ] == nil {
- m.byTypeIdent[typ] = map[string]*model.Identity{}
- }
- m.byTypeIdent[typ][identifier] = it
- }
- func (m *mockIdentities) GetByTypeIdentifier(_ context.Context, typ, identifier string) (*model.Identity, error) {
- it, ok := m.byTypeIdent[typ][identifier]
- 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 != "" {
- fx.identities.add(typ, identifier, &model.Identity{
- CanonicalUserID: uid, Type: typ, Identifier: identifier,
- })
- }
- }
|