ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m10s
ci / db / db (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / desktop / rust (desktop proxy) (push) Successful in 5m37s
ci / ts / ts (packages + web) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 11s
release / android (APK в релиз) (push) Successful in 4m22s
Serve desktop and Android builds from the latest Gitea release on /download, and fix the desktop compile and CI flakes that blocked v1.3.0. Co-authored-by: Cursor <cursoragent@cursor.com>
572 lines
24 KiB
Go
572 lines
24 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.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)
|
|
}
|