feat(api): user management and audit log for admins (M1)
- /admin/users: list with search, role filter and cursor pages; create with a one-time temporary password; rename, change role, block; reset password; delete - every change is checked against the role policy, a forbidden field rejects the whole patch; the last active admin is locked in the transaction and cannot be demoted, blocked or deleted - role change and blocking revoke access tokens at once; blocking and password reset also end all sessions - /admin/audit: newest-first journal with actor names - path and query parameter errors are problem+json now Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed4a492dba
commit
f075863f15
13 files changed
+2578
-70
No files matched your search
@@ -0,0 +1,379 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"pipu/api/internal/auth"
|
||||
"pipu/api/internal/platform/apperr"
|
||||
)
|
||||
|
||||
// signIn входит под пользователем, у которого может стоять must_change_password, и сразу меняет пароль.
|
||||
func (e *env) signIn(username, password string) string {
|
||||
e.t.Helper()
|
||||
r := e.mustLogin(username, password, "web")
|
||||
if user(r)["must_change_password"] == true {
|
||||
r = e.do(http.MethodPost, "/api/v1/me/password",
|
||||
map[string]any{"new_password": username + "-own-password"}, bearer(access(r)))
|
||||
require.Equal(e.t, http.StatusOK, r.Code, r.Body.String())
|
||||
}
|
||||
return access(r)
|
||||
}
|
||||
|
||||
func (e *env) createUser(token, username, role string) resp {
|
||||
e.t.Helper()
|
||||
return e.do(http.MethodPost, "/api/v1/admin/users",
|
||||
map[string]any{"username": username, "role": role, "display_name": " " + username + " "}, bearer(token))
|
||||
}
|
||||
|
||||
// Сценарий 2 из docs/testing.md: админ создаёт модератора, модератор — пользователя,
|
||||
// но не модератора.
|
||||
func TestAdminCreatesModeratorWhoCreatesUser(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
|
||||
created := e.createUser(admin, "Mod.One", "moderator")
|
||||
require.Equal(t, http.StatusCreated, created.Code, created.Body.String())
|
||||
modUser := created.body["user"].(map[string]any)
|
||||
assert.Equal(t, "mod.one", modUser["username"])
|
||||
assert.Equal(t, "Mod.One", modUser["display_name"])
|
||||
assert.Equal(t, true, modUser["must_change_password"])
|
||||
temp := created.body["temporary_password"].(string)
|
||||
assert.Len(t, temp, 12)
|
||||
|
||||
first := e.mustLogin("mod.one", temp, "web")
|
||||
assert.Equal(t, auth.CodePasswordChangeRequired,
|
||||
e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(access(first))).code())
|
||||
mod := e.signIn("mod.one", temp)
|
||||
|
||||
listenerUser := e.createUser(mod, "listener", "user")
|
||||
require.Equal(t, http.StatusCreated, listenerUser.Code)
|
||||
for _, role := range []string{"moderator", "admin"} {
|
||||
r := e.createUser(mod, "sneaky-"+role, role)
|
||||
assert.Equal(t, http.StatusForbidden, r.Code, role)
|
||||
assert.Equal(t, auth.CodeForbidden, r.code())
|
||||
}
|
||||
|
||||
list := e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(mod))
|
||||
require.Equal(t, http.StatusOK, list.Code)
|
||||
var names []string
|
||||
for _, it := range list.body["items"].([]any) {
|
||||
names = append(names, it.(map[string]any)["username"].(string))
|
||||
}
|
||||
assert.Equal(t, []string{"listener", "mod.one", "root"}, names)
|
||||
assert.Nil(t, list.body["next_cursor"])
|
||||
|
||||
listener := e.signIn("listener", listenerUser.body["temporary_password"].(string))
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/users", nil, bearer(listener)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.createUser(listener, "another", "user").code())
|
||||
}
|
||||
|
||||
func TestCreateUserValidation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
|
||||
assert.Equal(t, auth.CodeUsernameTaken, e.createUser(admin, "ROOT", "user").code())
|
||||
assert.Equal(t, auth.CodeUsernameInvalid, e.createUser(admin, "no spaces", "user").code())
|
||||
assert.Equal(t, auth.CodeRoleInvalid, e.createUser(admin, "valid", "root").code())
|
||||
}
|
||||
|
||||
// Матрица «кто кого может» через API: сброс пароля, блокировка, смена роли, удаление.
|
||||
func TestManageUsersMatrix(t *testing.T) {
|
||||
type actorCase struct {
|
||||
role auth.Role
|
||||
// разрешено ли действие над целью с ролью user / moderator / admin
|
||||
reset, block, role2, del [3]bool
|
||||
}
|
||||
cases := []actorCase{
|
||||
{auth.RoleModerator, [3]bool{true, false, false}, [3]bool{true, false, false}, [3]bool{false, false, false}, [3]bool{false, false, false}},
|
||||
{auth.RoleAdmin, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}, [3]bool{true, true, true}},
|
||||
}
|
||||
targets := [3]auth.Role{auth.RoleUser, auth.RoleModerator, auth.RoleAdmin}
|
||||
for _, ac := range cases {
|
||||
for ti, tr := range targets {
|
||||
actions := []struct {
|
||||
name string
|
||||
allowed bool
|
||||
call func(e *env, tok, id string) resp
|
||||
}{
|
||||
{"reset", ac.reset[ti], func(e *env, tok, id string) resp {
|
||||
return e.do(http.MethodPost, "/api/v1/admin/users/"+id+"/reset-password", nil, bearer(tok))
|
||||
}},
|
||||
{"block", ac.block[ti], func(e *env, tok, id string) resp {
|
||||
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok))
|
||||
}},
|
||||
{"role", ac.role2[ti], func(e *env, tok, id string) resp {
|
||||
next := "user"
|
||||
if tr == auth.RoleUser {
|
||||
next = "moderator"
|
||||
}
|
||||
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": next}, bearer(tok))
|
||||
}},
|
||||
{"delete", ac.del[ti], func(e *env, tok, id string) resp {
|
||||
return e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok))
|
||||
}},
|
||||
}
|
||||
for _, a := range actions {
|
||||
t.Run(fmt.Sprintf("%s %s %s", ac.role, a.name, tr), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("actor", "actor-password-1", ac.role)
|
||||
e.addUser("spare-admin", "spare-password-1", auth.RoleAdmin) // чтобы не упереться в «последнего админа»
|
||||
target := e.addUser("target", "target-password", tr)
|
||||
tok := e.signIn("actor", "actor-password-1")
|
||||
r := a.call(e, tok, target.ID.String())
|
||||
if a.allowed {
|
||||
assert.Less(t, r.Code, 300, r.Body.String())
|
||||
} else {
|
||||
assert.Equal(t, http.StatusForbidden, r.Code, r.Body.String())
|
||||
assert.Equal(t, auth.CodeForbidden, r.code())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeratorCannotTouchSelfOrRoles(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
mod := e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||
u := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
tok := e.signIn("mod", "moderator-pass-1")
|
||||
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+mod.ID.String(),
|
||||
map[string]any{"display_name": "Boss"}, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
|
||||
map[string]any{"display_name": "ok", "role": "moderator"}, bearer(tok)).code(), "one forbidden change rejects the patch")
|
||||
assert.Nil(t, e.store.Users[u.ID].DisplayName, "rejected patch changes nothing")
|
||||
|
||||
ok := e.do(http.MethodPatch, "/api/v1/admin/users/"+u.ID.String(),
|
||||
map[string]any{"display_name": "Listener", "role": "user"}, bearer(tok))
|
||||
require.Equal(t, http.StatusOK, ok.Code, ok.Body.String())
|
||||
assert.Equal(t, "Listener", ok.body["display_name"])
|
||||
}
|
||||
|
||||
func TestAdminCannotDemoteBlockOrDeleteSelf(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
root := e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
e.addUser("other", "other-password-1", auth.RoleAdmin)
|
||||
tok := e.signIn("root", "root-password-1")
|
||||
id := root.ID.String()
|
||||
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"role": "user"}, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"is_blocked": true}, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodDelete, "/api/v1/admin/users/"+id, nil, bearer(tok)).code())
|
||||
rename := e.do(http.MethodPatch, "/api/v1/admin/users/"+id, map[string]any{"display_name": "Root"}, bearer(tok))
|
||||
assert.Equal(t, http.StatusOK, rename.Code)
|
||||
}
|
||||
|
||||
func TestLastAdminIsProtected(t *testing.T) {
|
||||
// Через API последнего админа не затронуть: себя менять нельзя, а действующий админ — второй.
|
||||
// Остаётся гонка (админ удалён, пока его запрос в пути) — её и закрывает проверка в сервисе.
|
||||
e := newEnv(t)
|
||||
only := e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
ghost := auth.Principal{UserID: uuid.New(), Role: auth.RoleAdmin}
|
||||
ctx := context.Background()
|
||||
|
||||
role := auth.RoleUser
|
||||
blocked := true
|
||||
for name, call := range map[string]func() error{
|
||||
"demote": func() error { _, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role}); return err },
|
||||
"block": func() error {
|
||||
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{IsBlocked: &blocked})
|
||||
return err
|
||||
},
|
||||
"delete": func() error { return e.svc.DeleteUser(ctx, ghost, only.ID) },
|
||||
} {
|
||||
err := call()
|
||||
var ae *apperr.Error
|
||||
require.ErrorAs(t, err, &ae, name)
|
||||
assert.Equal(t, auth.CodeLastAdmin, ae.Code, name)
|
||||
assert.Equal(t, http.StatusConflict, ae.Status, name)
|
||||
}
|
||||
assert.Equal(t, auth.RoleAdmin, e.store.Users[only.ID].Role)
|
||||
assert.False(t, e.store.Users[only.ID].IsBlocked)
|
||||
assert.Empty(t, e.store.Audit, "rolled back changes leave no audit")
|
||||
|
||||
// Заблокированный админ не считается: второго активного нет.
|
||||
e.addUser("sleeping", "sleeping-pass-1", auth.RoleAdmin, func(u *auth.User) { u.IsBlocked = true })
|
||||
_, err := e.svc.UpdateUser(ctx, ghost, only.ID, auth.UserPatch{Role: &role})
|
||||
assert.ErrorIs(t, err, apperr.Conflict(auth.CodeLastAdmin, ""))
|
||||
}
|
||||
|
||||
func TestBlockAndRoleChangeRevokeAccess(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
web := e.mustLogin("listener", "listener-pass", "web")
|
||||
cookie := web.cookie(auth.RefreshCookie)
|
||||
|
||||
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"role": "moderator"}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
assert.Equal(t, "moderator", r.body["role"])
|
||||
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(web))).code())
|
||||
|
||||
refreshed := e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(cookie))
|
||||
require.Equal(t, http.StatusOK, refreshed.Code, "role change keeps sessions")
|
||||
assert.Equal(t, "moderator", user(refreshed)["role"])
|
||||
tok := access(refreshed)
|
||||
|
||||
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": true}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code)
|
||||
assert.Equal(t, true, r.body["is_blocked"])
|
||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code)
|
||||
assert.Equal(t, auth.CodeRefreshInvalid,
|
||||
e.do(http.MethodPost, "/api/v1/auth/refresh", nil, withCookie(refreshed.cookie(auth.RefreshCookie))).code(),
|
||||
"blocking ends sessions")
|
||||
|
||||
r = e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(), map[string]any{"is_blocked": false}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code)
|
||||
e.mustLogin("listener", "listener-pass", "web")
|
||||
|
||||
var actions []string
|
||||
for _, a := range e.store.Audit {
|
||||
actions = append(actions, a.Action)
|
||||
}
|
||||
assert.Equal(t, []string{"user.role_change", "user.block", "user.unblock"}, actions)
|
||||
assert.Equal(t, map[string]any{"from": "user", "to": "moderator"}, e.store.Audit[0].Payload)
|
||||
}
|
||||
|
||||
func TestNoOpPatch(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
r := e.do(http.MethodPatch, "/api/v1/admin/users/"+target.ID.String(),
|
||||
map[string]any{"role": "user", "is_blocked": false}, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code)
|
||||
assert.Empty(t, e.store.Audit)
|
||||
assert.Zero(t, e.store.Users[target.ID].TokenVersion)
|
||||
|
||||
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodPatch, "/api/v1/admin/users/"+uuid.NewString(),
|
||||
map[string]any{"display_name": "x"}, bearer(admin)).code())
|
||||
}
|
||||
|
||||
// Сценарий 10 из docs/testing.md: сброс пароля → обязательная смена при входе.
|
||||
func TestResetPassword(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser, func(u *auth.User) { u.FailedLoginCount = 9 })
|
||||
mod := e.signIn("mod", "moderator-pass-1")
|
||||
phone := e.mustLogin("listener", "listener-pass", "android")
|
||||
|
||||
r := e.do(http.MethodPost, "/api/v1/admin/users/"+target.ID.String()+"/reset-password", nil, bearer(mod))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
temp := r.body["temporary_password"].(string)
|
||||
|
||||
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(access(phone))).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.Equal(t, auth.CodeInvalidCredentials, e.login("listener", "listener-pass", "web").code())
|
||||
|
||||
again := e.mustLogin("listener", temp, "web")
|
||||
assert.Equal(t, true, user(again)["must_change_password"])
|
||||
assert.Equal(t, auth.CodePasswordChangeRequired, e.do(http.MethodGet, "/api/v1/me/sessions", nil, bearer(access(again))).code())
|
||||
assert.Equal(t, "user.password_reset", e.store.Audit[len(e.store.Audit)-1].Action)
|
||||
}
|
||||
|
||||
func TestDeleteUser(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
target := e.addUser("listener", "listener-pass", auth.RoleUser)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
tok := access(e.mustLogin("listener", "listener-pass", "web"))
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).Code)
|
||||
assert.NotContains(t, e.store.Users, target.ID)
|
||||
assert.Equal(t, auth.CodeTokenRevoked, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).code())
|
||||
assert.Equal(t, auth.CodeUserNotFound, e.do(http.MethodDelete, "/api/v1/admin/users/"+target.ID.String(), nil, bearer(admin)).code())
|
||||
last := e.store.Audit[len(e.store.Audit)-1]
|
||||
assert.Equal(t, "user.delete", last.Action)
|
||||
assert.Equal(t, "listener", last.Payload["username"])
|
||||
}
|
||||
|
||||
func TestListUsersFiltersAndPages(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
for _, n := range []string{"anna", "boris", "vera", "gleb", "dina"} {
|
||||
e.addUser(n, n+"-password", auth.RoleUser)
|
||||
}
|
||||
name := "Анна Каренина"
|
||||
e.addUser("kate", "kate-password", auth.RoleModerator, func(u *auth.User) { u.DisplayName = &name })
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
list := func(q url.Values) resp {
|
||||
return e.do(http.MethodGet, "/api/v1/admin/users?"+q.Encode(), nil, bearer(admin))
|
||||
}
|
||||
usernames := func(r resp) []string {
|
||||
var out []string
|
||||
for _, it := range r.body["items"].([]any) {
|
||||
out = append(out, it.(map[string]any)["username"].(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
all []string
|
||||
cursor string
|
||||
)
|
||||
for page := 0; ; page++ {
|
||||
q := url.Values{"limit": {"3"}}
|
||||
if cursor != "" {
|
||||
q.Set("cursor", cursor)
|
||||
}
|
||||
r := list(q)
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
all = append(all, usernames(r)...)
|
||||
next, _ := r.body["next_cursor"].(string)
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
require.Less(t, page, 5)
|
||||
}
|
||||
assert.Equal(t, []string{"anna", "boris", "dina", "gleb", "kate", "root", "vera"}, all)
|
||||
|
||||
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"q": {"анна"}})), "matches display name, case-insensitive")
|
||||
assert.Equal(t, []string{"anna"}, usernames(list(url.Values{"q": {"AN"}})))
|
||||
assert.Equal(t, []string{"kate"}, usernames(list(url.Values{"role": {"moderator"}})))
|
||||
assert.Equal(t, auth.CodeCursorBad, list(url.Values{"cursor": {"%%%"}}).code())
|
||||
assert.Equal(t, http.StatusBadRequest, list(url.Values{"limit": {"lots"}}).Code)
|
||||
}
|
||||
|
||||
func TestAudit(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
|
||||
admin := e.signIn("root", "root-password-1")
|
||||
for _, n := range []string{"u1", "u2", "u3"} {
|
||||
require.Equal(t, http.StatusCreated, e.createUser(admin, n+"-user", "user").Code)
|
||||
}
|
||||
|
||||
r := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2", nil, bearer(admin))
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
items := r.body["items"].([]any)
|
||||
require.Len(t, items, 2)
|
||||
newest := items[0].(map[string]any)
|
||||
assert.Equal(t, "user.create", newest["action"])
|
||||
assert.Equal(t, "u3-user", newest["payload"].(map[string]any)["username"])
|
||||
assert.Equal(t, "root", newest["actor"].(map[string]any)["username"])
|
||||
assert.Equal(t, "user", newest["target_type"])
|
||||
|
||||
next := r.body["next_cursor"].(string)
|
||||
r2 := e.do(http.MethodGet, "/api/v1/admin/audit?limit=2&cursor="+next, nil, bearer(admin))
|
||||
rest := r2.body["items"].([]any)
|
||||
require.Len(t, rest, 1)
|
||||
assert.Equal(t, "u1-user", rest[0].(map[string]any)["payload"].(map[string]any)["username"])
|
||||
assert.Nil(t, r2.body["next_cursor"])
|
||||
|
||||
mod := e.signIn("mod", "moderator-pass-1")
|
||||
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodGet, "/api/v1/admin/audit", nil, bearer(mod)).code())
|
||||
}
|
||||
Reference in new issue
Block a user