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.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public") assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/clients", nil).Code, "download page is public") assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code) }