From 4a558fc57abe2c713cfad098d50f38685af19995 Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 8 Jun 2026 15:14:34 +0200 Subject: [PATCH] fix(api/v2): expose v2-only token route groups via the routes endpoint --- pkg/models/api_routes.go | 30 +++++++++++++++++++++++++----- pkg/models/api_routes_test.go | 23 +++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/pkg/models/api_routes.go b/pkg/models/api_routes.go index d6300c51c..24a3c747b 100644 --- a/pkg/models/api_routes.go +++ b/pkg/models/api_routes.go @@ -29,8 +29,8 @@ var apiTokenRoutes = map[string]APITokenRoute{} // apiTokenRoutesV2 holds /api/v2 routes under the same (group, permission) // keys as v1, so a token granted e.g. labels.read_one authorises both -// versions. The frontend token UI still reads only apiTokenRoutes; -// CanDoAPIRoute consults both tables. +// versions. CanDoAPIRoute consults both tables; GetAPITokenRoutes (the /routes +// exposure the frontend reads) merges v2-only groups so they're discoverable. var apiTokenRoutesV2 = map[string]APITokenRoute{} func init() { @@ -346,10 +346,30 @@ func CollectRoutesForAPITokenUsage(route echo.RouteInfo, requiresJWT bool) { } -// GetAPITokenRoutes exposes the registered scoped-token routes so tests -// and the /api/v1/routes handler share a single source of truth. +// GetAPITokenRoutes exposes the registered scoped-token routes for the /routes +// handler and tests. v1 is the base; v2-only groups and permissions (a v2-only +// resource like time-entries has no v1 counterpart) are merged in so tokens can +// discover and grant them. Shared (group, permission) keys keep their v1 entry — +// CanDoAPIRoute authorises both versions off the same key regardless. func GetAPITokenRoutes() map[string]APITokenRoute { - return apiTokenRoutes + merged := make(map[string]APITokenRoute, len(apiTokenRoutes)) + for group, perms := range apiTokenRoutes { + merged[group] = make(APITokenRoute, len(perms)) + for perm, rd := range perms { + merged[group][perm] = rd + } + } + for group, perms := range apiTokenRoutesV2 { + if merged[group] == nil { + merged[group] = make(APITokenRoute) + } + for perm, rd := range perms { + if merged[group][perm] == nil { + merged[group][perm] = rd + } + } + } + return merged } // GetAvailableAPIRoutesForToken returns a list of all API routes which are available for token usage. diff --git a/pkg/models/api_routes_test.go b/pkg/models/api_routes_test.go index 5537c90b5..5cc510a98 100644 --- a/pkg/models/api_routes_test.go +++ b/pkg/models/api_routes_test.go @@ -147,6 +147,29 @@ func TestCollectRoutes_TimeEntriesV2(t *testing.T) { assert.Equal(t, "DELETE", te["delete"].Method) } +// TestGetAPITokenRoutes_ExposesV2Only verifies the /routes payload merges +// v2-only groups (time-entries has no v1 counterpart) so token clients can +// discover and grant them, without mutating the v1 table itself. +func TestGetAPITokenRoutes_ExposesV2Only(t *testing.T) { + apiTokenRoutes = make(map[string]APITokenRoute) + apiTokenRoutesV2 = make(map[string]APITokenRoute) + + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v1/labels"}, true) + CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/time-entries"}, true) + + routes := GetAPITokenRoutes() + + _, hasLabels := routes["labels"] + assert.True(t, hasLabels, "v1 groups stay exposed") + + te, hasTE := routes["time-entries"] + require.True(t, hasTE, "v2-only time-entries must be exposed via /routes") + assert.Equal(t, "GET", te["read_all"].Method) + + _, v1HasTE := apiTokenRoutes["time-entries"] + assert.False(t, v1HasTE, "the merge must not mutate the v1 table") +} + // TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create, // PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping // path.