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

473 lines
22 KiB
Go

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))
}
// Админ создаёт модератора, модератор — пользователя,
// но не модератора.
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 TestAdminChangesUsername(t *testing.T) {
e := newEnv(t)
root := e.addUser("root", "root-password-1", auth.RoleAdmin)
listener := e.addUser("listener", "listener-password-1", auth.RoleUser)
mod := e.addUser("mod", "mod-password-1", auth.RoleModerator)
tok := e.signIn("root", "root-password-1")
patch := func(token string, id uuid.UUID, username string) resp {
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id.String(), map[string]any{"username": username}, bearer(token))
}
for _, target := range []auth.User{listener, mod, root} {
r := patch(tok, target.ID, " NEW."+target.Username+" ")
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, "new."+target.Username, r.body["username"])
assert.Equal(t, "user.username_change", e.store.Audit[len(e.store.Audit)-1].Action)
assert.Equal(t, http.StatusOK, e.login("new."+target.Username, map[uuid.UUID]string{
root.ID: "root-password-1", listener.ID: "listener-password-1", mod.ID: "mod-password-1",
}[target.ID], "web").Code)
}
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code, "own rename preserves session")
assert.Equal(t, auth.CodeUsernameTaken, patch(tok, listener.ID, "NEW.ROOT").code())
assert.Equal(t, auth.CodeUsernameInvalid, patch(tok, listener.ID, "invalid space").code())
assert.Equal(t, auth.CodeUsernameInvalid, patch(tok, listener.ID, "").code())
assert.Equal(t, "new.listener", e.store.Users[listener.ID].Username)
modTok := e.signIn("new.mod", "mod-password-1")
assert.Equal(t, auth.CodeForbidden, patch(modTok, listener.ID, "forbidden").code())
assert.Equal(t, auth.CodeForbidden, patch(modTok, mod.ID, "forbidden").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())
}
// Сброс пароля → обязательная смена при входе.
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 TestResetRecommendations(t *testing.T) {
e := newEnv(t)
e.addUser("mod", "moderator-pass-1", auth.RoleModerator)
peer := e.addUser("other", "moderator-pass-2", auth.RoleModerator)
target := e.addUser("listener", "listener-pass", auth.RoleUser)
mod := e.signIn("mod", "moderator-pass-1")
r := e.do(http.MethodPost, "/api/v1/admin/users/"+target.ID.String()+"/reset-recommendations", nil, bearer(mod))
require.Equal(t, http.StatusNoContent, r.Code, r.Body.String())
assert.Equal(t, []uuid.UUID{target.ID}, e.store.RecResets)
assert.Equal(t, "user.recommendations_reset", e.store.Audit[len(e.store.Audit)-1].Action)
// Модератор не трогает других модераторов.
assert.Equal(t, http.StatusForbidden,
e.do(http.MethodPost, "/api/v1/admin/users/"+peer.ID.String()+"/reset-recommendations", nil, bearer(mod)).Code)
assert.Len(t, e.store.RecResets, 1)
}
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())
}
// Аудит загрузок выдаёт только админ, и он не следует из роли.
func TestReviewUploadsGrant(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
admin := e.signIn("root", "root-password-1")
target := e.addUser("mod", "mod-password-1", auth.RoleModerator)
mod := e.signIn("mod", "mod-password-1")
path := "/api/v1/admin/users/" + target.ID.String()
grant := map[string]any{"reviews_uploads": true}
me := e.do(http.MethodGet, "/api/v1/me", nil, bearer(admin))
assert.Equal(t, false, me.body["reviews_uploads"])
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, path, grant, bearer(mod)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPost, "/api/v1/admin/users",
map[string]any{"username": "rev", "role": "user", "reviews_uploads": true}, bearer(mod)).code())
r := e.do(http.MethodPatch, path, grant, bearer(admin))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, true, r.body["reviews_uploads"])
assert.Equal(t, "moderator", r.body["role"])
me = e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod))
assert.Equal(t, true, me.body["reviews_uploads"])
}
func TestManageUpdatesGrantAndRevoke(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
admin := e.signIn("root", "root-password-1")
target := e.addUser("mod", "mod-password-1", auth.RoleModerator)
mod := e.signIn("mod", "mod-password-1")
path := "/api/v1/admin/users/" + target.ID.String()
grant := map[string]any{"manages_updates": true}
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(admin)).body["manages_updates"])
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, path, grant, bearer(mod)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(mod)).code())
r := e.do(http.MethodPatch, path, grant, bearer(admin))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, true, r.body["manages_updates"])
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
r = e.do(http.MethodPatch, path, map[string]any{"manages_updates": false}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
r = e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(admin))
require.Equal(t, http.StatusCreated, r.Code, r.Body.String())
}