Files
pipu-music/services/api/internal/auth/authtest/memstore.go
T
mrleo1nid a29a8b18ad
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
feat(admin): manage server updates and local configuration
2026-10-22 23:01:36 +03:00

461 lines
13 KiB
Go

// 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) {
if upd.Username != nil {
unlock := s.lock()
taken := s.usernameTaken(*upd.Username, id)
unlock()
if taken {
return auth.User{}, auth.ErrUsernameTaken
}
}
err := s.update(id, func(u *auth.User) {
if upd.Username != nil {
u.Username = *upd.Username
}
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.ReviewsUploads != nil {
u.ReviewsUploads = *upd.ReviewsUploads
}
if upd.ManagesUpdates != nil {
u.ManagesUpdates = *upd.ManagesUpdates
}
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
}