// Package authtest — тестовые реализации зависимостей auth: хранилище в памяти, часы, лимитер. package authtest import ( "bytes" "context" "encoding/json" "maps" "slices" "strings" "sync" "time" "github.com/google/uuid" "pipu/api/internal/auth" ) // MemStore — auth.Store в памяти. WithTx откатывает изменения, если fn вернула ошибку. type MemStore struct { mu *sync.Mutex inTx bool Users map[uuid.UUID]auth.User Tokens map[uuid.UUID]auth.RefreshToken Audit []auth.AuditEntry AuditIDs []uuid.UUID Settings map[string]any // RecResets — для кого сбрасывали рекомендации (таблиц рекомендатора в памяти нет). RecResets []uuid.UUID } // NewMemStore создаёт пустое хранилище. func NewMemStore() *MemStore { return &MemStore{ mu: &sync.Mutex{}, Users: map[uuid.UUID]auth.User{}, Tokens: map[uuid.UUID]auth.RefreshToken{}, Settings: map[string]any{}, } } func (s *MemStore) lock() func() { if s.inTx { return func() {} } s.mu.Lock() return s.mu.Unlock } // WithTx реализует auth.Store. func (s *MemStore) WithTx(_ context.Context, fn func(auth.Store) error) error { unlock := s.lock() defer unlock() users, tokens, settings := maps.Clone(s.Users), maps.Clone(s.Tokens), maps.Clone(s.Settings) audit, auditIDs, resets := slices.Clone(s.Audit), slices.Clone(s.AuditIDs), slices.Clone(s.RecResets) tx := &MemStore{ mu: s.mu, inTx: true, Users: s.Users, Tokens: s.Tokens, Audit: s.Audit, AuditIDs: s.AuditIDs, Settings: s.Settings, RecResets: s.RecResets, } if err := fn(tx); err != nil { s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings, s.RecResets = users, tokens, audit, auditIDs, settings, resets return err } s.Users, s.Tokens, s.Audit, s.AuditIDs, s.Settings, s.RecResets = tx.Users, tx.Tokens, tx.Audit, tx.AuditIDs, tx.Settings, tx.RecResets return nil } // CountAdmins реализует auth.Store. func (s *MemStore) CountAdmins(context.Context) (int, error) { defer s.lock()() n := 0 for _, u := range s.Users { if u.Role == auth.RoleAdmin && !u.IsBlocked { n++ } } return n, nil } func (s *MemStore) usernameTaken(username string, except uuid.UUID) bool { for _, u := range s.Users { if u.ID != except && strings.EqualFold(u.Username, username) { return true } } return false } // CreateUser реализует auth.Store. func (s *MemStore) CreateUser(_ context.Context, u auth.User, _ *uuid.UUID) error { defer s.lock()() if s.usernameTaken(u.Username, uuid.Nil) { return auth.ErrUsernameTaken } s.Users[u.ID] = cloneUser(u) return nil } // cloneUser копирует ui_prefs через JSON, чтобы тест не делил указатели с хранилищем. func cloneUser(u auth.User) auth.User { raw, _ := json.Marshal(u.UIPrefs) u.UIPrefs = auth.UIPrefs{} _ = json.Unmarshal(raw, &u.UIPrefs) return u } // GetUserByID реализует auth.Store. func (s *MemStore) GetUserByID(_ context.Context, id uuid.UUID) (auth.User, error) { defer s.lock()() u, ok := s.Users[id] if !ok { return auth.User{}, auth.ErrNotFound } return cloneUser(u), nil } // GetUserByUsername реализует auth.Store. func (s *MemStore) GetUserByUsername(_ context.Context, username string) (auth.User, error) { defer s.lock()() for _, u := range s.Users { if strings.EqualFold(u.Username, username) { return cloneUser(u), nil } } return auth.User{}, auth.ErrNotFound } func (s *MemStore) update(id uuid.UUID, fn func(*auth.User)) error { defer s.lock()() u, ok := s.Users[id] if !ok { return auth.ErrNotFound } fn(&u) s.Users[id] = u return nil } // RecordLoginFailure реализует auth.Store. func (s *MemStore) RecordLoginFailure(_ context.Context, id uuid.UUID, failed int, lockedUntil *time.Time) error { return s.update(id, func(u *auth.User) { u.FailedLoginCount, u.LockedUntil = failed, lockedUntil }) } // RecordLoginSuccess реализует auth.Store. func (s *MemStore) RecordLoginSuccess(_ context.Context, id uuid.UUID, at time.Time, newHash *string) error { return s.update(id, func(u *auth.User) { u.FailedLoginCount, u.LockedUntil, u.LastLoginAt = 0, nil, &at if newHash != nil { u.PasswordHash = *newHash } }) } // SetPassword реализует auth.Store. func (s *MemStore) SetPassword(_ context.Context, id uuid.UUID, hash string) error { return s.update(id, func(u *auth.User) { u.PasswordHash, u.MustChangePassword = hash, false u.TokenVersion++ }) } // SetCredentials реализует auth.Store. func (s *MemStore) SetCredentials(_ context.Context, id uuid.UUID, username, hash string) error { unlock := s.lock() taken := s.usernameTaken(username, id) unlock() if taken { return auth.ErrUsernameTaken } return s.update(id, func(u *auth.User) { u.Username, u.PasswordHash = username, hash u.MustChangeCredentials, u.MustChangePassword = false, false u.TokenVersion++ }) } // UpdateProfile реализует auth.Store. func (s *MemStore) UpdateProfile(ctx context.Context, id uuid.UUID, upd auth.ProfileUpdate) (auth.User, error) { err := s.update(id, func(u *auth.User) { if upd.DisplayName != nil { u.DisplayName = *upd.DisplayName } if upd.Locale != nil { u.Locale = *upd.Locale } if upd.UIPrefs != nil { u.UIPrefs = *upd.UIPrefs } }) if err != nil { return auth.User{}, err } return s.GetUserByID(ctx, id) } // InsertRefreshToken реализует auth.Store. func (s *MemStore) InsertRefreshToken(_ context.Context, t auth.RefreshToken) error { defer s.lock()() s.Tokens[t.ID] = t return nil } // GetRefreshTokenForUpdate реализует auth.Store. func (s *MemStore) GetRefreshTokenForUpdate(_ context.Context, hash []byte) (auth.RefreshToken, error) { defer s.lock()() for _, t := range s.Tokens { if bytes.Equal(t.TokenHash, hash) { return t, nil } } return auth.RefreshToken{}, auth.ErrNotFound } // MarkRefreshTokenUsed реализует auth.Store. func (s *MemStore) MarkRefreshTokenUsed(_ context.Context, id uuid.UUID, at time.Time) error { defer s.lock()() t := s.Tokens[id] t.UsedAt = &at s.Tokens[id] = t return nil } func (s *MemStore) revoke(at time.Time, match func(auth.RefreshToken) bool) { defer s.lock()() for id, t := range s.Tokens { if t.RevokedAt == nil && match(t) { t.RevokedAt = &at s.Tokens[id] = t } } } // RevokeFamily реализует auth.Store. func (s *MemStore) RevokeFamily(_ context.Context, familyID uuid.UUID, at time.Time) error { s.revoke(at, func(t auth.RefreshToken) bool { return t.FamilyID == familyID }) return nil } // RevokeUserSessions реализует auth.Store. func (s *MemStore) RevokeUserSessions(_ context.Context, userID uuid.UUID, at time.Time) error { s.revoke(at, func(t auth.RefreshToken) bool { return t.UserID == userID }) return nil } // ListSessions реализует auth.Store. func (s *MemStore) ListSessions(_ context.Context, userID uuid.UUID, now time.Time) ([]auth.Session, error) { defer s.lock()() var out []auth.Session for _, t := range s.Tokens { if t.UserID == userID && t.UsedAt == nil && t.RevokedAt == nil && t.ExpiresAt.After(now) { out = append(out, auth.Session{ ID: t.FamilyID, DeviceName: t.DeviceName, ClientType: t.ClientType, CreatedAt: t.FamilyStartedAt, LastUsedAt: t.CreatedAt, }) } } slices.SortFunc(out, func(a, b auth.Session) int { return b.LastUsedAt.Compare(a.LastUsedAt) }) return out, nil } // ListUsers реализует auth.Store. func (s *MemStore) ListUsers(_ context.Context, f auth.UserFilter) ([]auth.User, error) { defer s.lock()() q := strings.ToLower(f.Query) var out []auth.User for _, u := range s.Users { name := "" if u.DisplayName != nil { name = strings.ToLower(*u.DisplayName) } if q != "" && !strings.Contains(strings.ToLower(u.Username), q) && !strings.Contains(name, q) { continue } if f.Role != nil && u.Role != *f.Role { continue } if f.After != "" && strings.ToLower(u.Username) <= strings.ToLower(f.After) { continue } out = append(out, cloneUser(u)) } slices.SortFunc(out, func(a, b auth.User) int { return strings.Compare(strings.ToLower(a.Username), strings.ToLower(b.Username)) }) if len(out) > f.Limit { out = out[:f.Limit] } return out, nil } // LockActiveAdmins реализует auth.Store. func (s *MemStore) LockActiveAdmins(ctx context.Context) (int, error) { return s.CountAdmins(ctx) } // UpdateUser реализует auth.Store. func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUpdate) (auth.User, error) { err := s.update(id, func(u *auth.User) { if upd.DisplayName != nil { u.DisplayName = *upd.DisplayName } if upd.Role != nil { u.Role = *upd.Role } if upd.IsBlocked != nil { u.IsBlocked = *upd.IsBlocked } if upd.RevokeTokens { u.TokenVersion++ } }) if err != nil { return auth.User{}, err } return s.GetUserByID(ctx, id) } // ResetPassword реализует auth.Store. func (s *MemStore) ResetPassword(_ context.Context, id uuid.UUID, hash string) error { return s.update(id, func(u *auth.User) { u.PasswordHash, u.MustChangePassword = hash, true u.FailedLoginCount, u.LockedUntil = 0, nil u.TokenVersion++ }) } // ResetRecommendations реализует auth.Store. func (s *MemStore) ResetRecommendations(_ context.Context, id uuid.UUID) error { defer s.lock()() s.RecResets = append(s.RecResets, id) return nil } // DeleteUser реализует auth.Store: токены удаляются, ссылки в журнале обнуляются, как в PostgreSQL. func (s *MemStore) DeleteUser(_ context.Context, id uuid.UUID) error { defer s.lock()() delete(s.Users, id) for tid, t := range s.Tokens { if t.UserID == id { delete(s.Tokens, tid) } } for i := range s.Audit { if s.Audit[i].ActorID != nil && *s.Audit[i].ActorID == id { s.Audit[i].ActorID = nil } } return nil } // InsertAudit реализует auth.Store. ID сохраняется в AuditIDs параллельно Audit. func (s *MemStore) InsertAudit(_ context.Context, id uuid.UUID, e auth.AuditEntry) error { defer s.lock()() s.Audit = append(s.Audit, e) s.AuditIDs = append(s.AuditIDs, id) return nil } // ListAudit реализует auth.Store. func (s *MemStore) ListAudit(_ context.Context, before uuid.UUID, limit int) ([]auth.AuditRecord, error) { defer s.lock()() var out []auth.AuditRecord for i := len(s.Audit) - 1; i >= 0 && len(out) < limit; i-- { id, e := s.AuditIDs[i], s.Audit[i] if before != uuid.Nil && id.String() >= before.String() { continue } r := auth.AuditRecord{ID: id, ActorID: e.ActorID, Action: e.Action, TargetID: e.TargetID, Payload: e.Payload} if e.TargetType != "" { tt := e.TargetType r.TargetType = &tt } if e.ActorID != nil { if u, ok := s.Users[*e.ActorID]; ok { name := u.Username r.ActorUsername = &name } } out = append(out, r) } return out, nil } // GetSettingBool реализует auth.Store. func (s *MemStore) GetSettingBool(_ context.Context, key string) (bool, error) { defer s.lock()() v, _ := s.Settings[key].(bool) return v, nil } // SetSetting реализует auth.Store. func (s *MemStore) SetSetting(_ context.Context, key string, value any) error { defer s.lock()() s.Settings[key] = value return nil } var _ auth.Store = (*MemStore)(nil) // Clock — управляемые часы. type Clock struct { mu sync.Mutex T time.Time } // Now реализует auth.Clock. func (c *Clock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.T } // Advance сдвигает часы вперёд. func (c *Clock) Advance(d time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.T = c.T.Add(d) } // Limiter — счётчик попыток в памяти без учёта окна (окно сбрасывает Reset). type Limiter struct { mu sync.Mutex counts map[string]int } // Allow реализует auth.Limiter. func (l *Limiter) Allow(_ context.Context, key string, limit int, window time.Duration) (bool, int, error) { l.mu.Lock() defer l.mu.Unlock() if l.counts == nil { l.counts = map[string]int{} } l.counts[key]++ if l.counts[key] > limit { return false, int(window / time.Second), nil } return true, 0, nil } // Reset обнуляет счётчики (как истечение окна). func (l *Limiter) Reset() { l.mu.Lock() defer l.mu.Unlock() l.counts = nil }