Files
pipu-music/services/api/internal/auth/auth_test.go
T
mrleo1nidandClaude Opus 5.5 ed4a492dba
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
feat(api): sign-in, refresh rotation and bootstrap admin (M1)
- users, refresh_tokens and audit_log schema (migration 00002)
- argon2id passwords with rehash on login, password policy, lockout
  with exponential back-off and a per-IP rate limit in Redis
- Ed25519 JWT access tokens with kid-based key rotation; rotating
  refresh tokens with reuse detection (cookie for web, body for Android)
- bootstrap admin on first start; must_change_credentials and
  must_change_password gate every other endpoint
- /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password,
  /me/bootstrap-credentials, /me/sessions; server-info reports
  bootstrap_required
- role policy from docs/auth-and-roles.md with table tests
- problem+json errors with field errors; ru/en translations of the codes
- install.sh and update.sh generate JWT_PRIVATE_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:12:25 +03:00

570 lines
23 KiB
Go

package auth_test
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/auth/authtest"
"pipu/api/internal/server"
"pipu/api/internal/system"
)
// Быстрые параметры argon2: тестам важна логика, а не стойкость.
var testArgon = auth.Argon2Params{MemoryKiB: 64, Time: 1, Threads: 1, SaltLen: 16, KeyLen: 32}
type env struct {
t *testing.T
store *authtest.MemStore
clock *authtest.Clock
limiter *authtest.Limiter
hasher *auth.Hasher
svc *auth.Service
h http.Handler
}
func newEnv(t *testing.T, mutate ...func(*auth.Config)) *env {
t.Helper()
e := &env{
t: t,
store: authtest.NewMemStore(),
clock: &authtest.Clock{T: time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)},
limiter: &authtest.Limiter{},
hasher: auth.NewHasher(testArgon),
}
key, err := auth.GenerateKey()
require.NoError(t, err)
cfg := auth.DefaultConfig()
cfg.BootstrapPassword = ""
for _, m := range mutate {
m(&cfg)
}
e.svc, err = auth.NewService(e.store, e.hasher, auth.NewTokenIssuer(key, nil, 15*time.Minute, e.clock.Now),
e.limiter, e.clock, auth.UUIDv7{}, nil, cfg)
require.NoError(t, err)
sys := system.NewHandler(system.Info{Version: "test", BootstrapRequired: e.svc.BootstrapRequired}, nil, nil)
e.h = server.NewRouter(server.Deps{System: sys, Auth: auth.NewHandler(e.svc, true), AuthService: e.svc})
return e
}
// addUser создаёт пользователя напрямую в хранилище.
func (e *env) addUser(username, password string, role auth.Role, mutate ...func(*auth.User)) auth.User {
e.t.Helper()
hash, err := e.hasher.Hash(password)
require.NoError(e.t, err)
u := auth.User{ID: uuid.New(), Username: username, PasswordHash: hash, Role: role, Locale: "ru", CreatedAt: e.clock.Now()}
for _, m := range mutate {
m(&u)
}
require.NoError(e.t, e.store.CreateUser(context.Background(), u, nil))
return u
}
type resp struct {
*httptest.ResponseRecorder
body map[string]any
}
func (r resp) code() string { s, _ := r.body["code"].(string); return s }
func (r resp) cookie(name string) *http.Cookie {
for _, c := range r.Result().Cookies() {
if c.Name == name {
return c
}
}
return nil
}
type reqOpt func(*http.Request)
func bearer(token string) reqOpt {
return func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) }
}
func withCookie(c *http.Cookie) reqOpt { return func(r *http.Request) { r.AddCookie(c) } }
func (e *env) do(method, path string, body any, opts ...reqOpt) resp {
e.t.Helper()
var rd *bytes.Reader
if body != nil {
raw, err := json.Marshal(body)
require.NoError(e.t, err)
rd = bytes.NewReader(raw)
} else {
rd = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rd)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.RemoteAddr = "192.0.2.10:5555"
for _, o := range opts {
o(req)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
out := resp{ResponseRecorder: rec}
if rec.Body.Len() > 0 {
require.NoError(e.t, json.Unmarshal(rec.Body.Bytes(), &out.body), rec.Body.String())
}
return out
}
func (e *env) login(username, password, clientType string) resp {
e.t.Helper()
return e.do(http.MethodPost, "/api/v1/auth/login", map[string]any{
"username": username, "password": password, "client_type": clientType, "device_name": "Test device",
})
}
func (e *env) mustLogin(username, password, clientType string) resp {
e.t.Helper()
r := e.login(username, password, clientType)
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
return r
}
func access(r resp) string { return r.body["access_token"].(string) }
func user(r resp) map[string]any { return r.body["user"].(map[string]any) }
func TestBootstrapFlow(t *testing.T) {
e := newEnv(t)
ctx := context.Background()
require.NoError(t, e.svc.Bootstrap(ctx))
require.NoError(t, e.svc.Bootstrap(ctx), "second bootstrap is a no-op")
require.Len(t, e.store.Users, 1)
info := e.do(http.MethodGet, "/api/v1/server-info", nil)
assert.Equal(t, true, info.body["bootstrap_required"])
// Сгенерированный пароль неизвестен тесту — подменяем хэш известным паролем.
var admin auth.User
for _, u := range e.store.Users {
admin = u
}
assert.Equal(t, "admin", admin.Username)
assert.Equal(t, auth.RoleAdmin, admin.Role)
assert.True(t, admin.MustChangeCredentials)
admin.PasswordHash, _ = e.hasher.Hash("initial-secret-pass")
e.store.Users[admin.ID] = admin
r := e.mustLogin("admin", "initial-secret-pass", "web")
assert.Equal(t, true, user(r)["must_change_credentials"])
tok := access(r)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
blocked := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
assert.Equal(t, http.StatusForbidden, blocked.Code)
assert.Equal(t, auth.CodeCredentialsChangeRequired, blocked.code())
assert.Equal(t, auth.CodeCredentialsChangeRequired,
e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "whatever-long-pass"}, bearer(tok)).code())
short := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": "owner", "new_password": "short"}, bearer(tok))
assert.Equal(t, http.StatusUnprocessableEntity, short.Code)
assert.Equal(t, auth.CodePasswordTooShort, short.code())
assert.EqualValues(t, 12, short.body["params"].(map[string]any)["min"])
same := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": "owner", "new_password": "initial-secret-pass"}, bearer(tok))
assert.Equal(t, auth.CodePasswordUnchanged, same.code())
badName := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": "x", "new_password": "a-much-better-secret"}, bearer(tok))
assert.Equal(t, auth.CodeUsernameInvalid, badName.code())
ok := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": " Owner ", "new_password": "a-much-better-secret"}, bearer(tok))
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
assert.Equal(t, "owner", user(ok)["username"])
assert.Equal(t, false, user(ok)["must_change_credentials"])
assert.NotNil(t, ok.cookie(auth.RefreshCookie))
old := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok))
assert.Equal(t, http.StatusUnauthorized, old.Code)
assert.Equal(t, auth.CodeTokenRevoked, old.code())
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code)
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/server-info", nil).body["bootstrap_required"])
again := e.do(http.MethodPost, "/api/v1/me/bootstrap-credentials",
map[string]any{"new_username": "owner", "new_password": "another-better-secret"}, bearer(access(ok)))
assert.Equal(t, http.StatusConflict, again.Code)
actions := []string{}
for _, a := range e.store.Audit {
actions = append(actions, a.Action)
}
assert.Equal(t, []string{"user.bootstrap_create", "user.bootstrap_credentials"}, actions)
// После завершения первого запуска удаление всех админов не создаёт нового автоматически.
delete(e.store.Users, admin.ID)
require.NoError(t, e.svc.Bootstrap(ctx))
assert.Empty(t, e.store.Users)
}
func TestBootstrapWithConfiguredPassword(t *testing.T) {
e := newEnv(t, func(c *auth.Config) {
c.BootstrapUsername = "Root"
c.BootstrapPassword = "configured-admin-pass"
})
require.NoError(t, e.svc.Bootstrap(context.Background()))
r := e.mustLogin("root", "configured-admin-pass", "android")
assert.Equal(t, "root", user(r)["username"])
}
func TestBootstrapSkippedWhenAdminExists(t *testing.T) {
e := newEnv(t)
e.addUser("boss", "boss-password-123", auth.RoleAdmin)
require.NoError(t, e.svc.Bootstrap(context.Background()))
assert.Len(t, e.store.Users, 1)
}
func TestLoginErrorsAreIndistinguishable(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
wrong := e.login("alice", "nope-nope-nope", "web")
unknown := e.login("bob", "nope-nope-nope", "web")
for _, r := range []resp{wrong, unknown} {
assert.Equal(t, http.StatusUnauthorized, r.Code)
assert.Equal(t, auth.CodeInvalidCredentials, r.code())
}
assert.Equal(t, http.StatusOK, e.login("ALICE", "alice-password", "web").Code, "username is case-insensitive")
bad := e.do(http.MethodPost, "/api/v1/auth/login",
map[string]any{"username": "alice", "password": "alice-password", "client_type": "ios"})
assert.Equal(t, http.StatusUnprocessableEntity, bad.Code)
}
func TestLoginLockout(t *testing.T) {
e := newEnv(t)
u := e.addUser("alice", "alice-password", auth.RoleUser)
for range 5 {
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code())
e.limiter.Reset()
}
locked := e.store.Users[u.ID]
require.NotNil(t, locked.LockedUntil)
assert.Equal(t, e.clock.Now().Add(5*time.Minute), *locked.LockedUntil)
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code(),
"correct password is rejected while locked")
assert.Equal(t, 5, e.store.Users[u.ID].FailedLoginCount, "attempts while locked are not counted")
e.clock.Advance(5 * time.Minute)
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code())
assert.Equal(t, e.clock.Now().Add(10*time.Minute), *e.store.Users[u.ID].LockedUntil, "lock doubles")
e.clock.Advance(10 * time.Minute)
e.limiter.Reset()
e.mustLogin("alice", "alice-password", "web")
assert.Zero(t, e.store.Users[u.ID].FailedLoginCount)
assert.Nil(t, e.store.Users[u.ID].LockedUntil)
}
func TestLockDurationIsCapped(t *testing.T) {
e := newEnv(t)
u := e.addUser("alice", "alice-password", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 40 })
e.login("alice", "wrong-password", "web")
assert.Equal(t, e.clock.Now().Add(time.Hour), *e.store.Users[u.ID].LockedUntil)
}
func TestLoginRateLimit(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
for range 5 {
e.login("alice", "wrong-password", "web")
}
r := e.login("alice", "alice-password", "web")
assert.Equal(t, http.StatusTooManyRequests, r.Code)
assert.Equal(t, auth.CodeTooManyAttempts, r.code())
assert.Equal(t, "60", r.Header().Get("Retry-After"))
}
func TestBlockedUserCannotLoginOrUseTokens(t *testing.T) {
e := newEnv(t)
u := e.addUser("alice", "alice-password", auth.RoleUser)
tok := access(e.mustLogin("alice", "alice-password", "web"))
blocked := e.store.Users[u.ID]
blocked.IsBlocked = true
e.store.Users[u.ID] = blocked
assert.Equal(t, auth.CodeUserBlocked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
r := e.login("alice", "alice-password", "web")
assert.Equal(t, http.StatusForbidden, r.Code)
assert.Equal(t, auth.CodeUserBlocked, r.code())
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "wrong-password", "web").code(),
"blocked status is not revealed without the password")
}
func TestWebRefreshRotation(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
r := e.mustLogin("alice", "alice-password", "web")
assert.Nil(t, r.body["refresh_token"], "web never gets refresh token in the body")
c1 := r.cookie(auth.RefreshCookie)
require.NotNil(t, c1)
assert.True(t, c1.HttpOnly)
assert.True(t, c1.Secure)
assert.Equal(t, http.SameSiteStrictMode, c1.SameSite)
assert.Equal(t, "/api/v1/auth", c1.Path)
e.clock.Advance(time.Minute)
r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
require.Equal(t, http.StatusOK, r2.Code, r2.Body.String())
c2 := r2.cookie(auth.RefreshCookie)
require.NotNil(t, c2)
assert.NotEqual(t, c1.Value, c2.Value)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(r2))).Code)
// Вторая вкладка прислала старую cookie сразу после ротации — это гонка, а не кража.
race := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
assert.Equal(t, auth.CodeRefreshRace, race.code())
assert.Nil(t, race.cookie(auth.RefreshCookie), "race keeps the fresh cookie")
// Позже старый токен — признак кражи: отзывается вся семья, в том числе новый токен.
e.clock.Advance(time.Minute)
reuse := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c1))
assert.Equal(t, http.StatusUnauthorized, reuse.Code)
assert.Equal(t, auth.CodeRefreshReused, reuse.code())
cleared := reuse.cookie(auth.RefreshCookie)
require.NotNil(t, cleared)
assert.Equal(t, -1, cleared.MaxAge)
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(c2)).code())
}
func TestAndroidRefreshInBody(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
r := e.mustLogin("alice", "alice-password", "android")
assert.Nil(t, r.cookie(auth.RefreshCookie))
rt, ok := r.body["refresh_token"].(string)
require.True(t, ok)
r2 := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
require.Equal(t, http.StatusOK, r2.Code, r2.Body.String())
assert.NotEqual(t, rt, r2.body["refresh_token"])
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": "garbage"}).code())
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh", nil).code())
}
func TestRefreshExpiry(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
rt := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string)
// Скользящий срок: каждые 29 дней ротация продлевает, но не дальше 180 дней от входа.
for range 6 {
e.clock.Advance(29 * 24 * time.Hour)
r := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
rt = r.body["refresh_token"].(string)
}
e.clock.Advance(7 * 24 * time.Hour) // 181 день от входа
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt}).code())
rt2 := e.mustLogin("alice", "alice-password", "android").body["refresh_token"].(string)
e.clock.Advance(31 * 24 * time.Hour)
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt2}).code())
}
func TestAccessTokenErrors(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
tok := access(e.mustLogin("alice", "alice-password", "web"))
assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil).code())
assert.Equal(t, auth.CodeTokenInvalid, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok+"x")).code())
assert.Equal(t, auth.CodeUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil,
func(r *http.Request) { r.Header.Set("Authorization", "Basic abc") }).code())
e.clock.Advance(16 * time.Minute)
expired := e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok))
assert.Equal(t, http.StatusUnauthorized, expired.Code)
assert.Equal(t, auth.CodeTokenExpired, expired.code())
}
func TestTokenSignedByPreviousKey(t *testing.T) {
clock := &authtest.Clock{T: time.Now()}
oldKey, _ := auth.GenerateKey()
newKey, _ := auth.GenerateKey()
old := auth.NewTokenIssuer(oldKey, nil, time.Minute, clock.Now)
rotated := auth.NewTokenIssuer(newKey, []ed25519.PrivateKey{oldKey}, time.Minute, clock.Now)
other := auth.NewTokenIssuer(newKey, nil, time.Minute, clock.Now)
tok, err := old.Issue(uuid.New(), auth.RoleUser, uuid.New(), 3)
require.NoError(t, err)
c, err := rotated.Parse(tok)
require.NoError(t, err)
assert.Equal(t, 3, c.TokenVersion)
_, err = other.Parse(tok)
assert.ErrorIs(t, err, auth.ErrTokenInvalid)
}
func TestChangePassword(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
web := e.mustLogin("alice", "alice-password", "web")
phone := e.mustLogin("alice", "alice-password", "android")
tok := access(web)
missing := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "brand-new-pass"}, bearer(tok))
assert.Equal(t, auth.CodeCurrentPasswordRequired, missing.code())
wrong := e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"current_password": "nope", "new_password": "brand-new-pass"}, bearer(tok))
assert.Equal(t, auth.CodeCurrentPasswordInvalid, wrong.code())
common := e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"current_password": "alice-password", "new_password": "Password123"}, bearer(tok))
assert.Equal(t, auth.CodePasswordTooCommon, common.code())
ok := e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"current_password": "alice-password", "new_password": "brand-new-pass"}, bearer(tok))
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
assert.NotNil(t, ok.cookie(auth.RefreshCookie), "current web device keeps a session")
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
map[string]any{"refresh_token": phone.body["refresh_token"]}).code(), "other devices are signed out")
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(ok))).Code)
assert.Equal(t, auth.CodeInvalidCredentials, e.login("alice", "alice-password", "web").code())
e.mustLogin("alice", "brand-new-pass", "web")
}
func TestTemporaryPasswordMustBeChanged(t *testing.T) {
e := newEnv(t)
e.addUser("bob", "temp-pass-123", auth.RoleUser, func(u *auth.User) { u.MustChangePassword = true })
r := e.mustLogin("bob", "temp-pass-123", "android")
tok := access(r)
assert.Equal(t, true, user(r)["must_change_password"])
denied := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
assert.Equal(t, http.StatusForbidden, denied.Code)
assert.Equal(t, auth.CodePasswordChangeRequired, denied.code())
ok := e.do(http.MethodPost, "/api/v1/me/password", map[string]any{"new_password": "bobs-own-pass"}, bearer(tok))
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
assert.Equal(t, false, user(ok)["must_change_password"])
assert.NotEmpty(t, ok.body["refresh_token"], "android device gets refresh token in the body")
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(ok))).Code)
}
func TestPrivilegedPasswordLength(t *testing.T) {
e := newEnv(t)
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
tok := access(e.mustLogin("mod", "moderator-pass-1", "web"))
r := e.do(http.MethodPost, "/api/v1/me/password",
map[string]any{"current_password": "moderator-pass-1", "new_password": "eleven-char"}, bearer(tok))
assert.Equal(t, auth.CodePasswordTooShort, r.code())
assert.EqualValues(t, 12, r.body["params"].(map[string]any)["min"])
assert.Equal(t, []any{map[string]any{"field": "new_password", "code": auth.CodePasswordTooShort}}, r.body["errors"])
}
func TestUpdateMe(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
tok := access(e.mustLogin("alice", "alice-password", "web"))
r := e.do(http.MethodPatch, "/api/v1/me", map[string]any{
"display_name": " Alice ", "locale": "en", "ui_prefs": map[string]any{"theme": "light", "crossfade_s": 3},
}, bearer(tok))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, "Alice", r.body["display_name"])
assert.Equal(t, "en", r.body["locale"])
r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"wave_variant": "liquid"}}, bearer(tok))
assert.Equal(t, map[string]any{"theme": "light", "crossfade_s": float64(3), "wave_variant": "liquid"}, r.body["ui_prefs"],
"ui_prefs are merged")
assert.Equal(t, "Alice", r.body["display_name"], "absent fields are unchanged")
r = e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": ""}, bearer(tok))
assert.Nil(t, r.body["display_name"])
assert.Equal(t, auth.CodeLocaleUnsupported,
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"locale": "de"}, bearer(tok)).code())
assert.Equal(t, auth.CodeUIPrefsInvalid,
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"ui_prefs": map[string]any{"crossfade_s": 9}}, bearer(tok)).code())
assert.Equal(t, auth.CodeDisplayNameTooLong,
e.do(http.MethodPatch, "/api/v1/me", map[string]any{"display_name": strings.Repeat("я", 65)}, bearer(tok)).code())
}
func TestSessions(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
web := e.mustLogin("alice", "alice-password", "web")
e.clock.Advance(time.Second)
phone := e.mustLogin("alice", "alice-password", "android")
tok := access(web)
list := e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok))
items := list.body["items"].([]any)
require.Len(t, items, 2)
first, second := items[0].(map[string]any), items[1].(map[string]any)
assert.Equal(t, "android", first["client_type"], "newest first")
assert.Equal(t, false, first["current"])
assert.Equal(t, true, second["current"])
assert.Equal(t, "Test device", second["device_name"])
assert.Equal(t, auth.CodeSessionNotFound,
e.do(http.MethodDelete, "/api/v1/me/sessions/"+uuid.NewString(), nil, bearer(tok)).code())
del := e.do(http.MethodDelete, "/api/v1/me/sessions/"+first["id"].(string), nil, bearer(tok))
assert.Equal(t, http.StatusNoContent, del.Code)
assert.Equal(t, auth.CodeRefreshInvalid, e.do(http.MethodPost, "/api/v1/auth/refresh",
map[string]any{"refresh_token": phone.body["refresh_token"]}).code())
assert.Len(t, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(tok)).body["items"], 1)
// Чужую сессию завершить нельзя.
e.addUser("bob", "bob-password", auth.RoleUser)
bob := access(e.mustLogin("bob", "bob-password", "web"))
assert.Equal(t, auth.CodeSessionNotFound,
e.do(http.MethodDelete, "/api/v1/me/sessions/"+second["id"].(string), nil, bearer(bob)).code())
}
func TestLogout(t *testing.T) {
e := newEnv(t)
e.addUser("alice", "alice-password", auth.RoleUser)
web := e.mustLogin("alice", "alice-password", "web")
phone := e.mustLogin("alice", "alice-password", "android")
out := e.do(http.MethodPost, "/api/v1/auth/logout", nil, bearer(access(web)))
assert.Equal(t, http.StatusNoContent, out.Code)
require.NotNil(t, out.cookie(auth.RefreshCookie))
assert.Equal(t, -1, out.cookie(auth.RefreshCookie).MaxAge)
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(web.cookie(auth.RefreshCookie))).code())
rt := phone.body["refresh_token"]
ok := e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": rt})
require.Equal(t, http.StatusOK, ok.Code, "logout touches only the current session")
assert.Equal(t, http.StatusNoContent, e.do(http.MethodPost, "/api/v1/auth/logout-all", nil, bearer(access(ok))).Code)
assert.Equal(t, auth.CodeRefreshInvalid,
e.do(http.MethodPost, "/api/v1/auth/refresh", map[string]any{"refresh_token": ok.body["refresh_token"]}).code())
}
func TestPublicRoutes(t *testing.T) {
e := newEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
}