From ef53a3415ad458bb285b909bbf72f610ab49323b Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 31 May 2026 18:00:29 +0200 Subject: [PATCH 1/5] feat(api/v2): gate admin routes by feature + instance admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the admin + license gate for /api/v2 and ship the first gated resource, GET /api/v2/admin/projects (AdminProjectList). The gate reuses the existing v1 middleware functions unchanged — RequireFeature(license.FeatureAdminPanel) and RequireInstanceAdmin(), both of which serve 404 on failure. Rather than splitting the single v2 Huma API into a separate gated sub-group (which would split the OpenAPI spec and drop admin operations from /api/v2/openapi.json), the gate is applied as a path-scoped Echo middleware on the shared /api/v2 group, firing only for /api/v2/admin/* and after the token middleware. This preserves v1's 404-not-403 semantics and keeps admin routes in the unified v2 spec and Scalar docs. AdminProjectList lists every project on the instance (archived included), behind the gate. Adds doc:/readOnly: tags to the shared Project model so it documents correctly as a v2 schema. Tests in pkg/webtests/huma_admin_test.go (TestHumaAdminProjects) cover all three personas: non-admin -> 404, admin without feature -> 404, admin with feature -> 200 list, plus unauthenticated -> 401. --- pkg/models/project.go | 34 +++++----- pkg/routes/api/v2/admin_projects.go | 67 ++++++++++++++++++++ pkg/routes/routes.go | 36 +++++++++++ pkg/webtests/huma_admin_test.go | 96 +++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 pkg/routes/api/v2/admin_projects.go create mode 100644 pkg/webtests/huma_admin_test.go diff --git a/pkg/models/project.go b/pkg/models/project.go index c860ed0621..022f673a5f 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -38,52 +38,52 @@ import ( // Project represents a project of tasks type Project struct { // The unique, numeric id of this project. - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"project" readOnly:"true" doc:"The unique, numeric id of this project."` // The title of the project. You'll see this in the overview. - Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250"` + Title string `xorm:"varchar(250) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of the project. You'll see this in the overview."` // The description of the project. - Description string `xorm:"longtext null" json:"description"` + Description string `xorm:"longtext null" json:"description" doc:"The description of the project."` // The unique project short identifier. Used to build task identifiers. - Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10"` + Identifier string `xorm:"varchar(10) null" json:"identifier" valid:"runelength(0|10)" minLength:"0" maxLength:"10" doc:"The unique project short identifier. Used to build task identifiers (e.g. PROJ-123)."` // The hex color of this project - HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"` + HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7" doc:"The hex color of this project, without the leading #."` OwnerID int64 `xorm:"bigint INDEX not null" json:"-"` - ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id"` + ParentProjectID int64 `xorm:"bigint INDEX null" json:"parent_project_id" doc:"The id of the parent project. 0 if this is a top-level project."` ParentProject *Project `xorm:"-" json:"-"` // The user who created this project. - Owner *user.User `xorm:"-" json:"owner" valid:"-"` + Owner *user.User `xorm:"-" json:"owner" valid:"-" readOnly:"true" doc:"The user who owns this project. Set by the server; ignored on write."` // Whether a project is archived. - IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived"` + IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived" doc:"Whether the project is archived. Archived projects are read-only."` // The id of the file this project has set as background BackgroundFileID int64 `xorm:"null" json:"-"` // Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /projects/{projectID}/background - BackgroundInformation interface{} `xorm:"-" json:"background_information"` + BackgroundInformation interface{} `xorm:"-" json:"background_information" readOnly:"true" doc:"Extra information about the background (e.g. attribution). When not null, the background is available at /projects/{projectID}/background."` // Contains a very small version of the project background to use as a blurry preview until the actual background is loaded. Check out https://blurha.sh/ to learn how it works. - BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash"` + BackgroundBlurHash string `xorm:"varchar(50) null" json:"background_blur_hash" readOnly:"true" doc:"A small BlurHash preview of the project background, shown until the real background loads. See https://blurha.sh/."` // True if a project is a favorite. Favorite projects show up in a separate parent project. This value depends on the user making the call to the api. - IsFavorite bool `xorm:"-" json:"is_favorite"` + IsFavorite bool `xorm:"-" json:"is_favorite" doc:"Whether the project is a favorite of the requesting user. This value is per-user and depends on who makes the call."` // The subscription status for the user reading this project. You can only read this property, use the subscription endpoints to modify it. // Will only returned when retreiving one project. - Subscription *Subscription `xorm:"-" json:"subscription,omitempty"` + Subscription *Subscription `xorm:"-" json:"subscription,omitempty" readOnly:"true" doc:"The requesting user's subscription status for this project. Read-only here; use the subscription endpoints to change it. Only returned when retrieving a single project."` // The position this project has when querying all projects. See the tasks.position property on how to use this. - Position float64 `xorm:"double null" json:"position"` + Position float64 `xorm:"double null" json:"position" doc:"The position of this project when listing all projects. See the tasks.position property for how positions work."` - Views []*ProjectView `xorm:"-" json:"views"` + Views []*ProjectView `xorm:"-" json:"views" readOnly:"true" doc:"The views configured for this project. Managed through the project view endpoints."` Expand ProjectExpandable `xorm:"-" json:"-" query:"expand"` - MaxPermission Permission `xorm:"-" json:"max_permission"` + MaxPermission Permission `xorm:"-" json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this project (0 = read, 1 = read/write, 2 = admin)."` // A timestamp when this project was created. You cannot change this value. - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this project was created. You cannot change this value."` // A timestamp when this project was last updated. You cannot change this value. - Updated time.Time `xorm:"updated not null" json:"updated"` + Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this project was last updated. You cannot change this value."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` diff --git a/pkg/routes/api/v2/admin_projects.go b/pkg/routes/api/v2/admin_projects.go new file mode 100644 index 0000000000..2e5f3e93ff --- /dev/null +++ b/pkg/routes/api/v2/admin_projects.go @@ -0,0 +1,67 @@ +// 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" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// AdminProjectList.ReadAll returns []*models.Project, so the wire shape is a +// plain paginated list of projects. +type adminProjectListBody struct { + Body Paginated[*models.Project] +} + +// RegisterAdminProjectRoutes wires the admin project list onto the Huma API. +// The instance-admin + admin-panel-license gate is applied by the Echo +// middleware on the /api/v2/admin path prefix (see gateV2AdminRoutes in +// pkg/routes/routes.go), not here — there is no per-handler permission check. +func RegisterAdminProjectRoutes(api huma.API) { + tags := []string{"admin"} + + Register(api, huma.Operation{ + OperationID: "admin-projects-list", + Summary: "List all projects (admin)", + Description: "Returns every project on the instance, including archived ones and projects the caller does not own. 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/projects", + Tags: tags, + }, adminProjectsList) +} + +func adminProjectsList(ctx context.Context, in *ListParams) (*adminProjectListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.AdminProjectList{}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + items, ok := result.([]*models.Project) + if !ok { + return nil, fmt.Errorf("AdminProjectList.ReadAll returned unexpected type %T (expected []*models.Project)", result) + } + return &adminProjectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 5b00b13620..730d96f29f 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -376,12 +376,47 @@ func noStoreCacheControl() echo.MiddlewareFunc { } } +// v2AdminPathPrefix is the URL prefix every gated admin operation lives under. +const v2AdminPathPrefix = "/api/v2/admin" + +// gateV2AdminRoutes applies the existing v1 admin gate (license feature + +// instance admin, both 404-on-failure) to every /api/v2/admin request and +// passes everything else through untouched. +// +// v2 is a single Huma API mounted on the /api/v2 Echo group, so unlike v1 — +// which builds a dedicated `/admin` Echo sub-group and attaches the gate as +// group middleware — we can't simply construct a gated sub-group without +// splitting the Huma API (which would split the OpenAPI spec, dropping admin +// operations out of /api/v2/openapi.json). Instead we reuse the exact same +// RequireFeature/RequireInstanceAdmin functions as a path-scoped middleware on +// the shared group: the checks run before Huma's handler, in the same order as +// v1 (feature first, then admin), and return the identical 404. Keeping one +// Huma API means admin routes stay in the unified v2 spec and docs. +func gateV2AdminRoutes() echo.MiddlewareFunc { + feature := RequireFeature(license.FeatureAdminPanel) + admin := RequireInstanceAdmin() + return func(next echo.HandlerFunc) echo.HandlerFunc { + // Compose feature → admin → next, evaluated once at setup. + gated := feature(admin(next)) + return func(c *echo.Context) error { + if strings.HasPrefix(c.Request().URL.Path, v2AdminPathPrefix) { + return gated(c) + } + return next(c) + } + } +} + // registerAPIRoutesV2 wires the /api/v2 Echo group. Token middleware is // attached before any route so Huma's spec and Scalar docs share the // resource handlers' stack; unauthenticatedAPIPaths keeps them public. func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { a.Use(noStoreCacheControl()) a.Use(SetupTokenMiddleware()) + // The admin gate must run after the token middleware (it reads the + // authenticated user from the JWT claims) and is scoped by path so only + // /api/v2/admin/* is gated. + a.Use(gateV2AdminRoutes()) // Match the authenticated v1 group: rate limiting and route metrics // apply to v2 resource endpoints too. setupRateLimit(a, config.RateLimitKind.GetString()) @@ -396,6 +431,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { // Resource registrations. apiv2.RegisterLabelRoutes(api) apiv2.RegisterTaskDuplicateRoutes(api) + apiv2.RegisterAdminProjectRoutes(api) // AutoPatch must run AFTER all GET/PUT pairs are registered so it can // synthesize their PATCH counterparts. diff --git a/pkg/webtests/huma_admin_test.go b/pkg/webtests/huma_admin_test.go new file mode 100644 index 0000000000..507b655d94 --- /dev/null +++ b/pkg/webtests/huma_admin_test.go @@ -0,0 +1,96 @@ +// 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/db" + "code.vikunja.io/api/pkg/license" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaAdminProjects exercises the v2 admin gate via GET /api/v2/admin/projects. +// It mirrors v1's TestAdmin_ListProjects but additionally asserts that the same +// two-stage gate (license feature + instance admin) v1 uses on /admin carries +// through to the Huma-backed /api/v2/admin group, returning 404 (not 403) on +// failure. The RFC 9457 error body is asserted once globally in +// TestHuma_ErrorShapeIsRFC9457, so here we only assert the status codes. +func TestHumaAdminProjects(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() + u, err := user.GetUserByID(s, 1) + require.NoError(t, err) + require.False(t, u.IsAdmin, "fixture precondition: user1 is not an admin") + s.Close() + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", 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.ResetForTests() + + admin := promoteToAdmin(t, 1) + + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", admin, "") + assert.Equal(t, http.StatusNotFound, res.Code) + }) + + t.Run("admin with the feature sees every project", 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/projects", admin, "") + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + body := res.Body.String() + // v2 wraps lists in the Paginated envelope. + assert.Contains(t, body, `"items":`) + assert.Contains(t, body, `"total":`) + // Project 6 is owned by user6, not shared with user1 — the admin list + // surfaces it regardless of ownership. + assert.Contains(t, body, `"id":6`) + // Project 22 is archived; the admin list includes archived projects. + assert.Contains(t, body, `"id":22`) + }) + + 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() + + // The token middleware rejects with 401 before the gate runs, matching v1. + res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", nil, "") + assert.Equal(t, http.StatusUnauthorized, res.Code) + }) +} From 53bb5f8aff86898d3520f6a1b781c451be53634d Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 31 May 2026 21:05:19 +0200 Subject: [PATCH 2/5] fix(api/v2): apply rate limit before the admin gate --- pkg/routes/routes.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 730d96f29f..a8159e63ab 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -413,14 +413,17 @@ func gateV2AdminRoutes() echo.MiddlewareFunc { func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { a.Use(noStoreCacheControl()) a.Use(SetupTokenMiddleware()) - // The admin gate must run after the token middleware (it reads the - // authenticated user from the JWT claims) and is scoped by path so only - // /api/v2/admin/* is gated. - a.Use(gateV2AdminRoutes()) // Match the authenticated v1 group: rate limiting and route metrics // apply to v2 resource endpoints too. setupRateLimit(a, config.RateLimitKind.GetString()) setupMetricsMiddleware(a) + // The admin gate must run after the token middleware (it reads the + // authenticated user from the JWT claims) and after the rate limit and + // metrics middleware so requests rejected by the gate are still rate + // limited and measured — RequireInstanceAdmin does a DB read per request, + // so an unauthenticated flood to /api/v2/admin/* would otherwise hit the + // DB unbounded. It is scoped by path so only /api/v2/admin/* is gated. + a.Use(gateV2AdminRoutes()) api := apiv2.NewAPI(e, a) From 7333a410296138f33124d6a833058e8a431dfc03 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 31 May 2026 21:06:26 +0200 Subject: [PATCH 3/5] test(api/v2): defer session close in admin webtest --- pkg/webtests/huma_admin_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/webtests/huma_admin_test.go b/pkg/webtests/huma_admin_test.go index 507b655d94..18b348cad2 100644 --- a/pkg/webtests/huma_admin_test.go +++ b/pkg/webtests/huma_admin_test.go @@ -42,10 +42,10 @@ func TestHumaAdminProjects(t *testing.T) { 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") - s.Close() res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", u, "") assert.Equal(t, http.StatusNotFound, res.Code) From c2eb4b698b65284377db84dbe95130e72ceb5c64 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 31 May 2026 21:06:34 +0200 Subject: [PATCH 4/5] test(api/v2): defer license reset in admin webtest --- pkg/webtests/huma_admin_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/webtests/huma_admin_test.go b/pkg/webtests/huma_admin_test.go index 18b348cad2..c241d35056 100644 --- a/pkg/webtests/huma_admin_test.go +++ b/pkg/webtests/huma_admin_test.go @@ -54,7 +54,11 @@ func TestHumaAdminProjects(t *testing.T) { t.Run("admin without the feature gets 404", func(t *testing.T) { e, err := setupTestEnv() require.NoError(t, err) - license.ResetForTests() + // A valid license that lacks the admin panel feature still gates the + // route. Match the sibling subtests' set/defer-reset pattern so the + // license state never bleeds into other tests. + license.SetForTests([]license.Feature{}) + defer license.ResetForTests() admin := promoteToAdmin(t, 1) From 78e38d95281259ceacccb01415300020b97be146 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 1 Jun 2026 14:33:50 +0200 Subject: [PATCH 5/5] test(api/v2): assert admin project id via structured json --- pkg/routes/api/v2/admin_projects.go | 7 +----- pkg/routes/routes.go | 26 ++++--------------- pkg/webtests/huma_admin_test.go | 39 ++++++++++++++++------------- 3 files changed, 27 insertions(+), 45 deletions(-) diff --git a/pkg/routes/api/v2/admin_projects.go b/pkg/routes/api/v2/admin_projects.go index 2e5f3e93ff..8e7b51d3d2 100644 --- a/pkg/routes/api/v2/admin_projects.go +++ b/pkg/routes/api/v2/admin_projects.go @@ -27,16 +27,11 @@ import ( "github.com/danielgtaylor/huma/v2" ) -// AdminProjectList.ReadAll returns []*models.Project, so the wire shape is a -// plain paginated list of projects. type adminProjectListBody struct { Body Paginated[*models.Project] } -// RegisterAdminProjectRoutes wires the admin project list onto the Huma API. -// The instance-admin + admin-panel-license gate is applied by the Echo -// middleware on the /api/v2/admin path prefix (see gateV2AdminRoutes in -// pkg/routes/routes.go), not here — there is no per-handler permission check. +// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler. func RegisterAdminProjectRoutes(api huma.API) { tags := []string{"admin"} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index a8159e63ab..d95f7e7eff 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -376,27 +376,15 @@ func noStoreCacheControl() echo.MiddlewareFunc { } } -// v2AdminPathPrefix is the URL prefix every gated admin operation lives under. const v2AdminPathPrefix = "/api/v2/admin" -// gateV2AdminRoutes applies the existing v1 admin gate (license feature + -// instance admin, both 404-on-failure) to every /api/v2/admin request and -// passes everything else through untouched. -// -// v2 is a single Huma API mounted on the /api/v2 Echo group, so unlike v1 — -// which builds a dedicated `/admin` Echo sub-group and attaches the gate as -// group middleware — we can't simply construct a gated sub-group without -// splitting the Huma API (which would split the OpenAPI spec, dropping admin -// operations out of /api/v2/openapi.json). Instead we reuse the exact same -// RequireFeature/RequireInstanceAdmin functions as a path-scoped middleware on -// the shared group: the checks run before Huma's handler, in the same order as -// v1 (feature first, then admin), and return the identical 404. Keeping one -// Huma API means admin routes stay in the unified v2 spec and docs. +// gateV2AdminRoutes reuses v1's RequireFeature/RequireInstanceAdmin gate (both +// 404-on-failure) as path-scoped middleware: splitting v2 into a gated Echo +// sub-group would split the Huma API and drop admin ops from the OpenAPI spec. func gateV2AdminRoutes() echo.MiddlewareFunc { feature := RequireFeature(license.FeatureAdminPanel) admin := RequireInstanceAdmin() return func(next echo.HandlerFunc) echo.HandlerFunc { - // Compose feature → admin → next, evaluated once at setup. gated := feature(admin(next)) return func(c *echo.Context) error { if strings.HasPrefix(c.Request().URL.Path, v2AdminPathPrefix) { @@ -417,12 +405,8 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { // apply to v2 resource endpoints too. setupRateLimit(a, config.RateLimitKind.GetString()) setupMetricsMiddleware(a) - // The admin gate must run after the token middleware (it reads the - // authenticated user from the JWT claims) and after the rate limit and - // metrics middleware so requests rejected by the gate are still rate - // limited and measured — RequireInstanceAdmin does a DB read per request, - // so an unauthenticated flood to /api/v2/admin/* would otherwise hit the - // DB unbounded. It is scoped by path so only /api/v2/admin/* is gated. + // Must come after rate limiting: the gate does a per-request admin DB read, + // so an unauthenticated flood to /api/v2/admin/* would otherwise be unbounded. a.Use(gateV2AdminRoutes()) api := apiv2.NewAPI(e, a) diff --git a/pkg/webtests/huma_admin_test.go b/pkg/webtests/huma_admin_test.go index c241d35056..36f25de154 100644 --- a/pkg/webtests/huma_admin_test.go +++ b/pkg/webtests/huma_admin_test.go @@ -17,6 +17,7 @@ package webtests import ( + "encoding/json" "net/http" "testing" @@ -28,12 +29,8 @@ import ( "github.com/stretchr/testify/require" ) -// TestHumaAdminProjects exercises the v2 admin gate via GET /api/v2/admin/projects. -// It mirrors v1's TestAdmin_ListProjects but additionally asserts that the same -// two-stage gate (license feature + instance admin) v1 uses on /admin carries -// through to the Huma-backed /api/v2/admin group, returning 404 (not 403) on -// failure. The RFC 9457 error body is asserted once globally in -// TestHuma_ErrorShapeIsRFC9457, so here we only assert the status codes. +// The error body shape is covered by TestHuma_ErrorShapeIsRFC9457; this test +// only asserts gate status codes (404 on failure, matching v1). func TestHumaAdminProjects(t *testing.T) { t.Run("non-admin user gets 404", func(t *testing.T) { e, err := setupTestEnv() @@ -54,9 +51,7 @@ func TestHumaAdminProjects(t *testing.T) { t.Run("admin without the feature gets 404", func(t *testing.T) { e, err := setupTestEnv() require.NoError(t, err) - // A valid license that lacks the admin panel feature still gates the - // route. Match the sibling subtests' set/defer-reset pattern so the - // license state never bleeds into other tests. + // Empty feature set = licensed instance without the admin feature. license.SetForTests([]license.Feature{}) defer license.ResetForTests() @@ -76,15 +71,23 @@ func TestHumaAdminProjects(t *testing.T) { res := adminReq(t, e, http.MethodGet, "/api/v2/admin/projects", admin, "") require.Equal(t, http.StatusOK, res.Code, res.Body.String()) - body := res.Body.String() - // v2 wraps lists in the Paginated envelope. - assert.Contains(t, body, `"items":`) - assert.Contains(t, body, `"total":`) - // Project 6 is owned by user6, not shared with user1 — the admin list - // surfaces it regardless of ownership. - assert.Contains(t, body, `"id":6`) - // Project 22 is archived; the admin list includes archived projects. - assert.Contains(t, body, `"id":22`) + + var envelope struct { + Items []struct { + ID int64 `json:"id"` + } `json:"items"` + Total int64 `json:"total"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &envelope)) + + ids := make(map[int64]bool, len(envelope.Items)) + for _, item := range envelope.Items { + ids[item.ID] = true + } + // Project 6 (owned by user6, not shared with user1) proves the list ignores ownership. + assert.True(t, ids[6], "expected project 6 in the admin list, got items %v", ids) + // Project 22 is archived, proving the list includes archived projects. + assert.True(t, ids[22], "expected archived project 22 in the admin list, got items %v", ids) }) t.Run("unauthenticated caller gets 401", func(t *testing.T) {