From 5579daa4529c4e24c226963362425d22d4b36096 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 09:41:47 +0200 Subject: [PATCH] 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. --- pkg/routes/api/shared/admin_user.go | 2 - pkg/routes/api/v2/admin_projects.go | 45 +++ pkg/routes/api/v2/admin_users.go | 274 +++++++++++++++++ pkg/webtests/huma_admin_actions_test.go | 387 ++++++++++++++++++++++++ 4 files changed, 706 insertions(+), 2 deletions(-) create mode 100644 pkg/routes/api/v2/admin_users.go create mode 100644 pkg/webtests/huma_admin_actions_test.go diff --git a/pkg/routes/api/shared/admin_user.go b/pkg/routes/api/shared/admin_user.go index eeae1c794..4459f2698 100644 --- a/pkg/routes/api/shared/admin_user.go +++ b/pkg/routes/api/shared/admin_user.go @@ -14,8 +14,6 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -// 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 ( diff --git a/pkg/routes/api/v2/admin_projects.go b/pkg/routes/api/v2/admin_projects.go index 2203ab01d..9d424eb6e 100644 --- a/pkg/routes/api/v2/admin_projects.go +++ b/pkg/routes/api/v2/admin_projects.go @@ -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 +} diff --git a/pkg/routes/api/v2/admin_users.go b/pkg/routes/api/v2/admin_users.go new file mode 100644 index 000000000..347636444 --- /dev/null +++ b/pkg/routes/api/v2/admin_users.go @@ -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 . + +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 +} diff --git a/pkg/webtests/huma_admin_actions_test.go b/pkg/webtests/huma_admin_actions_test.go new file mode 100644 index 000000000..806036a32 --- /dev/null +++ b/pkg/webtests/huma_admin_actions_test.go @@ -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 . + +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) + }) +}