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()) }