feat(api/v2): add admin actions on /api/v2
Port the admin action endpoints to the Huma-backed /api/v2:
- GET /admin/overview instance counts + license snapshot
- POST /admin/users create a user (201)
- PATCH /admin/users/{id}/admin promote/demote (*bool, nil = unchanged)
- PATCH /admin/users/{id}/status set status (*Status, nil = unchanged)
- DELETE /admin/users/{id} delete (mode=now|scheduled, 204)
- PATCH /admin/projects/{id}/owner reassign project owner
All sit behind the existing gateV2AdminRoutes path middleware (admin + license
gate, 404 on failure), so no per-handler permission checks are added. The
hand-registered PATCH routes carry genuine partial semantics, which AutoPatch
does not synthesise. The admin user response reuses the existing
pkg/routes/api/shared package.
This commit is contained in:
parent
e25f997281
commit
5579daa452
|
|
@ -14,8 +14,6 @@
|
|||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package shared holds route helpers used by both /api/v1 and /api/v2 so the two
|
||||
// versions render identical responses without one importing the other.
|
||||
package shared
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
|
|
@ -31,6 +32,16 @@ type adminProjectListBody struct {
|
|||
Body Paginated[*models.Project]
|
||||
}
|
||||
|
||||
type adminProjectBody struct {
|
||||
Body *models.Project
|
||||
}
|
||||
|
||||
// adminOwnerPatchBody reassigns a project's owner. owner_id is the only field;
|
||||
// the regular project-update endpoint refuses owner changes.
|
||||
type adminOwnerPatchBody struct {
|
||||
OwnerID int64 `json:"owner_id" minimum:"1" doc:"The numeric ID of the user who should become the project's owner."`
|
||||
}
|
||||
|
||||
// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler.
|
||||
func RegisterAdminProjectRoutes(api huma.API) {
|
||||
tags := []string{"admin"}
|
||||
|
|
@ -43,6 +54,15 @@ func RegisterAdminProjectRoutes(api huma.API) {
|
|||
Path: "/admin/projects",
|
||||
Tags: tags,
|
||||
}, adminProjectsList)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-projects-patch-owner",
|
||||
Summary: "Reassign a project's owner (admin)",
|
||||
Description: "Reassigns a project to a new owner — the admin-only escape hatch the regular update endpoint does not allow. The new owner must be an active account that is not scheduled for deletion. Restricted to instance admins on a licensed instance.",
|
||||
Method: http.MethodPatch,
|
||||
Path: "/admin/projects/{id}/owner",
|
||||
Tags: tags,
|
||||
}, adminProjectsPatchOwner)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterAdminProjectRoutes) }
|
||||
|
|
@ -62,3 +82,28 @@ func adminProjectsList(ctx context.Context, in *ListParams) (*adminProjectListBo
|
|||
}
|
||||
return &adminProjectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
|
||||
}
|
||||
|
||||
func adminProjectsPatchOwner(_ context.Context, in *struct {
|
||||
ID int64 `path:"id" doc:"The numeric ID of the project."`
|
||||
Body adminOwnerPatchBody
|
||||
}) (*adminProjectBody, error) {
|
||||
if in.ID < 1 {
|
||||
return nil, translateDomainError(models.ErrProjectDoesNotExist{ID: in.ID})
|
||||
}
|
||||
if in.Body.OwnerID < 1 {
|
||||
return nil, translateDomainError(models.ErrInvalidData{Message: "invalid body"})
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
p, err := models.ReassignProjectOwner(s, in.ID, in.Body.OwnerID)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &adminProjectBody{Body: p}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,274 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/auth/openid"
|
||||
"code.vikunja.io/api/pkg/routes/api/shared"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type adminOverviewBody struct {
|
||||
Body *models.Overview
|
||||
}
|
||||
|
||||
type adminUserBody struct {
|
||||
Body *shared.AdminUser
|
||||
}
|
||||
|
||||
// adminIsAdminPatchBody uses a pointer so an omitted is_admin leaves the flag unchanged
|
||||
// instead of silently demoting.
|
||||
type adminIsAdminPatchBody struct {
|
||||
IsAdmin *bool `json:"is_admin" doc:"New admin flag. Omitting it leaves the current value unchanged."`
|
||||
}
|
||||
|
||||
// adminStatusPatchBody uses a pointer so an omitted status leaves the account unchanged
|
||||
// instead of silently reactivating.
|
||||
type adminStatusPatchBody struct {
|
||||
Status *user.Status `json:"status" doc:"New account status (0=active, 1=email-confirmation required, 2=disabled, 3=locked). Omitting it leaves the current value unchanged."`
|
||||
}
|
||||
|
||||
// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler.
|
||||
func RegisterAdminUserRoutes(api huma.API) {
|
||||
tags := []string{"admin"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-overview",
|
||||
Summary: "Admin overview",
|
||||
Description: "Returns per-instance counts (users, projects, tasks, teams, shares) plus the current license snapshot. Restricted to instance admins on a licensed instance; unlicensed or non-admin callers get a 404, making the endpoint indistinguishable from one that is not registered.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/admin/overview",
|
||||
Tags: tags,
|
||||
}, adminOverview)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-users-create",
|
||||
Summary: "Create a user (admin)",
|
||||
Description: "Creates a local user account, bypassing the public-registration toggle. Honours the admin-only is_admin and skip_email_confirm fields. Restricted to instance admins on a licensed instance.",
|
||||
Method: http.MethodPost,
|
||||
Path: "/admin/users",
|
||||
Tags: tags,
|
||||
}, adminUsersCreate)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-users-patch-admin",
|
||||
Summary: "Promote or demote a user (admin)",
|
||||
Description: "Sets a user's instance-admin flag. The body field is a pointer: omitting is_admin leaves the flag unchanged. Demoting the last remaining admin is refused with 400.",
|
||||
Method: http.MethodPatch,
|
||||
Path: "/admin/users/{id}/admin",
|
||||
Tags: tags,
|
||||
}, adminUsersPatchAdmin)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-users-patch-status",
|
||||
Summary: "Set a user's status (admin)",
|
||||
Description: "Changes a user's account status without requiring them to log in. The body field is a pointer: omitting status leaves it unchanged. Moving the last remaining admin out of Active is refused with 400.",
|
||||
Method: http.MethodPatch,
|
||||
Path: "/admin/users/{id}/status",
|
||||
Tags: tags,
|
||||
}, adminUsersPatchStatus)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-users-delete",
|
||||
Summary: "Delete a user (admin)",
|
||||
Description: "Deletes a user. With mode=now the user is removed immediately. With mode=scheduled (the default) the user is scheduled for deletion through the email-confirmation self-deletion flow. Deleting the last remaining admin is refused with 400.",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/admin/users/{id}",
|
||||
Tags: tags,
|
||||
}, adminUsersDelete)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterAdminUserRoutes) }
|
||||
|
||||
func adminOverview(_ context.Context, _ *struct{}) (*adminOverviewBody, error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
overview, err := models.BuildOverview(s)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &adminOverviewBody{Body: overview}, nil
|
||||
}
|
||||
|
||||
func adminUsersCreate(_ context.Context, in *struct{ Body models.CreateUserBody }) (*adminUserBody, error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
newUser, err := models.CreateUserAsAdmin(s, &in.Body)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
providers, err := openid.GetAllProviders() //nolint:contextcheck // GetAllProviders reads a cached map; it takes no context, like the v1 admin handlers.
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &adminUserBody{Body: shared.NewAdminUser(newUser, providers)}, nil
|
||||
}
|
||||
|
||||
func adminUsersPatchAdmin(_ context.Context, in *struct {
|
||||
ID int64 `path:"id" doc:"The numeric ID of the user."`
|
||||
Body adminIsAdminPatchBody
|
||||
}) (*adminUserBody, error) {
|
||||
if in.Body.IsAdmin == nil {
|
||||
return nil, translateDomainError(models.ErrInvalidData{Message: "is_admin is required"})
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
target, err := adminLoadUser(s, in.ID)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if !*in.Body.IsAdmin {
|
||||
if err := user.GuardLastAdmin(s, target); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
}
|
||||
|
||||
target.IsAdmin = *in.Body.IsAdmin
|
||||
if _, err := s.ID(target.ID).Cols("is_admin").Update(target); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
return adminUserResponse(target) //nolint:contextcheck // see adminUserResponse.
|
||||
}
|
||||
|
||||
func adminUsersPatchStatus(_ context.Context, in *struct {
|
||||
ID int64 `path:"id" doc:"The numeric ID of the user."`
|
||||
Body adminStatusPatchBody
|
||||
}) (*adminUserBody, error) {
|
||||
if in.Body.Status == nil {
|
||||
return nil, translateDomainError(models.ErrInvalidData{Message: "status is required"})
|
||||
}
|
||||
newStatus := *in.Body.Status
|
||||
if newStatus < user.StatusActive || newStatus > user.StatusAccountLocked {
|
||||
return nil, translateDomainError(models.ErrInvalidData{Message: "invalid status"})
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
target, err := adminLoadUser(s, in.ID)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
// Any non-Active status blocks login, so moving an admin out of Active is equivalent to demotion.
|
||||
if target.IsAdmin && newStatus != user.StatusActive {
|
||||
if err := user.GuardLastAdmin(s, target); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := user.SetUserStatus(s, target, newStatus); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
// Refresh locally since GetUserByID refuses disabled accounts.
|
||||
target.Status = newStatus
|
||||
return adminUserResponse(target) //nolint:contextcheck // see adminUserResponse.
|
||||
}
|
||||
|
||||
func adminUsersDelete(_ context.Context, in *struct {
|
||||
ID int64 `path:"id" doc:"The numeric ID of the user."`
|
||||
Mode string `query:"mode" doc:"'now' deletes immediately; 'scheduled' (the default) triggers the email-confirmation self-deletion flow."`
|
||||
}) (*emptyBody, error) {
|
||||
mode := in.Mode
|
||||
if mode == "" {
|
||||
mode = "scheduled"
|
||||
}
|
||||
if mode != "now" && mode != "scheduled" {
|
||||
return nil, translateDomainError(models.ErrInvalidData{Message: "invalid mode, expected 'now' or 'scheduled'"})
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
target, err := adminLoadUser(s, in.ID)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := user.GuardLastAdmin(s, target); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if mode == "now" {
|
||||
err = models.DeleteUser(s, target)
|
||||
} else {
|
||||
err = user.RequestDeletion(s, target)
|
||||
}
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &emptyBody{}, nil
|
||||
}
|
||||
|
||||
// adminLoadUser fetches a user by ID, returning ErrUserDoesNotExist for an
|
||||
// invalid ID or a missing row (matching v1's 404).
|
||||
func adminLoadUser(s *xorm.Session, id int64) (*user.User, error) {
|
||||
if id < 1 {
|
||||
return nil, user.ErrUserDoesNotExist{UserID: id}
|
||||
}
|
||||
target := &user.User{ID: id}
|
||||
has, err := s.Get(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !has {
|
||||
return nil, user.ErrUserDoesNotExist{UserID: id}
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// adminUserResponse builds the admin user view from an already-mutated user.
|
||||
func adminUserResponse(target *user.User) (*adminUserBody, error) {
|
||||
providers, err := openid.GetAllProviders() //nolint:contextcheck // GetAllProviders reads a cached map; it takes no context, like the v1 admin handlers.
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &adminUserBody{Body: shared.NewAdminUser(target, providers)}, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,387 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/license"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Gate behaviour (404 on non-admin/unlicensed, 401 unauthenticated) is shared by
|
||||
// every /api/v2/admin route; covered once here against the overview endpoint.
|
||||
func TestHumaAdminOverview(t *testing.T) {
|
||||
t.Run("non-admin user gets 404", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u, err := user.GetUserByID(s, 1)
|
||||
require.NoError(t, err)
|
||||
require.False(t, u.IsAdmin, "fixture precondition: user1 is not an admin")
|
||||
|
||||
res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", u, "")
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
|
||||
t.Run("admin without the feature gets 404", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", admin, "")
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated caller gets 401", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", nil, "")
|
||||
assert.Equal(t, http.StatusUnauthorized, res.Code)
|
||||
})
|
||||
|
||||
t.Run("admin with the feature sees the overview", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
res := adminReq(t, e, http.MethodGet, "/api/v2/admin/overview", admin, "")
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
body := res.Body.String()
|
||||
assert.Contains(t, body, `"users"`)
|
||||
assert.Contains(t, body, `"projects"`)
|
||||
assert.Contains(t, body, `"tasks"`)
|
||||
assert.Contains(t, body, `"shares"`)
|
||||
assert.Contains(t, body, `"license"`)
|
||||
assert.Contains(t, body, `"licensed":true`)
|
||||
assert.Contains(t, body, `"instance_id"`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHumaAdminCreateUser(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
// Admin endpoint must bypass the public-registration toggle.
|
||||
prev := config.ServiceEnableRegistration.GetBool()
|
||||
config.ServiceEnableRegistration.Set(false)
|
||||
defer config.ServiceEnableRegistration.Set(prev)
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
t.Run("creates a plain user and returns 201", func(t *testing.T) {
|
||||
body := `{"username":"v2adm-create-1","password":"averyl0ngpassword","email":"v2adm-create-1@example.com"}`
|
||||
res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
|
||||
assert.Equal(t, http.StatusCreated, res.Code, res.Body.String())
|
||||
assert.Contains(t, res.Body.String(), `"username":"v2adm-create-1"`)
|
||||
})
|
||||
|
||||
t.Run("creates an is_admin user", func(t *testing.T) {
|
||||
body := `{"username":"v2adm-create-2","password":"averyl0ngpassword","email":"v2adm-create-2@example.com","is_admin":true}`
|
||||
res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
|
||||
require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u, err := user.GetUserByUsername(s, "v2adm-create-2")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, u.IsAdmin, "new user should have been promoted")
|
||||
})
|
||||
|
||||
t.Run("skip_email_confirm forces Status=Active", func(t *testing.T) {
|
||||
body := `{"username":"v2adm-create-3","password":"averyl0ngpassword","email":"v2adm-create-3@example.com","skip_email_confirm":true}`
|
||||
res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
|
||||
require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u, err := user.GetUserByUsername(s, "v2adm-create-3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, user.StatusActive, u.Status)
|
||||
})
|
||||
|
||||
t.Run("persists the name field", func(t *testing.T) {
|
||||
body := `{"username":"v2adm-create-4","password":"averyl0ngpassword","email":"v2adm-create-4@example.com","name":"Adm Create"}`
|
||||
res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
|
||||
require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u, err := user.GetUserByUsername(s, "v2adm-create-4")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Adm Create", u.Name)
|
||||
})
|
||||
|
||||
t.Run("rejects an invalid body with 422", func(t *testing.T) {
|
||||
// Password below the 8-char minimum fails govalidator before the create.
|
||||
body := `{"username":"v2adm-invalid","password":"short","email":"v2adm-invalid@example.com"}`
|
||||
res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", admin, body)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, res.Code, res.Body.String())
|
||||
})
|
||||
|
||||
t.Run("non-admin caller gets 404", func(t *testing.T) {
|
||||
s := db.NewSession()
|
||||
u2, err := user.GetUserByID(s, 2)
|
||||
require.NoError(t, err)
|
||||
require.False(t, u2.IsAdmin, "fixture precondition: user2 is not an admin")
|
||||
s.Close()
|
||||
|
||||
body := `{"username":"v2nonadmin","password":"averyl0ngpassword","email":"v2nonadmin@example.com"}`
|
||||
res := adminReq(t, e, http.MethodPost, "/api/v2/admin/users", u2, body)
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHumaAdminPatchAdmin(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
t.Run("promote a non-admin user", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":true}`)
|
||||
assert.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u, err := user.GetUserByID(s, 2)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, u.IsAdmin)
|
||||
})
|
||||
|
||||
t.Run("demote when another admin exists is allowed", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":false}`)
|
||||
assert.Equal(t, http.StatusOK, res.Code)
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u, err := user.GetUserByID(s, 2)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, u.IsAdmin)
|
||||
})
|
||||
|
||||
t.Run("last-admin guard refuses demotion with 400", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/1/admin", admin, `{"is_admin":false}`)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u, err := user.GetUserByID(s, 1)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, u.IsAdmin, "last admin must remain admin after refused demotion")
|
||||
})
|
||||
|
||||
t.Run("unknown user returns 404", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/9999999/admin", admin, `{"is_admin":true}`)
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
|
||||
t.Run("omitted is_admin is rejected rather than demoting", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{"is_admin":true}`)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
|
||||
res = adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/admin", admin, `{}`)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u, err := user.GetUserByID(s, 2)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, u.IsAdmin, "omitted is_admin must not silently demote")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHumaAdminPatchStatus(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{"status":2}`)
|
||||
assert.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
// GetUserByID refuses disabled accounts, so assert against the raw row.
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
var row struct {
|
||||
Status int `xorm:"status"`
|
||||
}
|
||||
_, err = s.Table("users").Where("id = ?", 2).Get(&row)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, row.Status)
|
||||
|
||||
t.Run("last-admin guard refuses self-disable with 400", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/1/status", admin, `{"status":2}`)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||
|
||||
var row struct {
|
||||
Status int `xorm:"status"`
|
||||
}
|
||||
_, err := s.Table("users").Where("id = ?", 1).Get(&row)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int(user.StatusActive), row.Status, "last admin must stay active after refused disable")
|
||||
})
|
||||
|
||||
t.Run("rejects invalid status value with 400", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{"status":99}`)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||
assert.Contains(t, res.Body.String(), "invalid status")
|
||||
})
|
||||
|
||||
t.Run("omitted status is rejected rather than reactivating", func(t *testing.T) {
|
||||
// User 2 was disabled above; an empty body must leave that intact.
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/users/2/status", admin, `{}`)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||
|
||||
var row struct {
|
||||
Status int `xorm:"status"`
|
||||
}
|
||||
_, err := s.Table("users").Where("id = ?", 2).Get(&row)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int(user.StatusDisabled), row.Status, "omitted status must not silently reactivate")
|
||||
})
|
||||
}
|
||||
|
||||
func TestHumaAdminDeleteUser(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
t.Run("mode=now deletes a regular user immediately with 204", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/15?mode=now", admin, "")
|
||||
assert.Equal(t, http.StatusNoContent, res.Code)
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
_, err := user.GetUserByID(s, 15)
|
||||
assert.Error(t, err, "deleted user must no longer be fetchable")
|
||||
})
|
||||
|
||||
t.Run("mode=scheduled keeps the user row", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/16?mode=scheduled", admin, "")
|
||||
assert.Equal(t, http.StatusNoContent, res.Code)
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u := &user.User{ID: 16}
|
||||
has, err := s.Get(u)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, has, "scheduled deletion must not remove the user row")
|
||||
})
|
||||
|
||||
t.Run("default (no mode) is scheduled", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/2", admin, "")
|
||||
assert.Equal(t, http.StatusNoContent, res.Code)
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
u := &user.User{ID: 2}
|
||||
has, err := s.Get(u)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, has, "default mode must not remove the user row")
|
||||
})
|
||||
|
||||
t.Run("rejects invalid mode with 400", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/3?mode=bogus", admin, "")
|
||||
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||
})
|
||||
|
||||
t.Run("mode=now last-admin guard refuses self-delete with 400", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/1?mode=now", admin, "")
|
||||
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||
})
|
||||
|
||||
t.Run("unknown user returns 404", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/9999999?mode=now", admin, "")
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHumaAdminReassignProjectOwner(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
t.Run("updates owner_id", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":2}`)
|
||||
assert.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
var row struct {
|
||||
OwnerID int64 `xorm:"owner_id"`
|
||||
}
|
||||
_, err := s.Table("projects").Where("id = ?", 2).Get(&row)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), row.OwnerID)
|
||||
})
|
||||
|
||||
t.Run("rejects nonexistent owner with 404", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":99999}`)
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
|
||||
t.Run("nonexistent project returns 404", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/99999/owner", admin, `{"owner_id":1}`)
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
|
||||
t.Run("rejects disabled user as new owner with 412", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":17}`)
|
||||
assert.Equal(t, http.StatusPreconditionFailed, res.Code)
|
||||
})
|
||||
|
||||
t.Run("rejects locked user as new owner with 412", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":18}`)
|
||||
assert.Equal(t, http.StatusPreconditionFailed, res.Code)
|
||||
})
|
||||
|
||||
t.Run("rejects deletion-scheduled user as new owner with 400", func(t *testing.T) {
|
||||
res := adminReq(t, e, http.MethodPatch, "/api/v2/admin/projects/2/owner", admin, `{"owner_id":20}`)
|
||||
assert.Equal(t, http.StatusBadRequest, res.Code)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue