mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-13 06:32:15 -05:00
Huma's AutoPatch synthesises a PATCH counterpart for every PUT, and both verbs collapse to the same "update" permission. PATCH is still skipped during collection (it would clobber PUT under the shared key), but the matcher now accepts it as an alias for the stored PUT route on the same path, so token holders aren't forced to use PUT exclusively.
203 lines
7.6 KiB
Go
203 lines
7.6 KiB
Go
// 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 models
|
|
|
|
import (
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/labstack/echo/v5"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestCanDoAPIRoute_BulkLabelTask(t *testing.T) {
|
|
// Reset apiTokenRoutes to isolate this test
|
|
apiTokenRoutes = make(map[string]APITokenRoute)
|
|
|
|
// Register the standard CRUD routes for tasks_labels first
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{
|
|
Method: "PUT",
|
|
Path: "/api/v1/tasks/:projecttask/labels",
|
|
}, true)
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{
|
|
Method: "DELETE",
|
|
Path: "/api/v1/tasks/:projecttask/labels/:label",
|
|
}, true)
|
|
|
|
// Now register the bulk route
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{
|
|
Method: "POST",
|
|
Path: "/api/v1/tasks/:projecttask/labels/bulk",
|
|
}, true)
|
|
|
|
// Verify that the tasks_labels route group exists
|
|
routes, has := apiTokenRoutes["tasks_labels"]
|
|
require.True(t, has, "tasks_labels route group should exist")
|
|
|
|
// The bulk route should be registered as "update_bulk" under tasks_labels
|
|
bulkRoute, has := routes["update_bulk"]
|
|
require.True(t, has, "update_bulk should exist in tasks_labels routes")
|
|
assert.Equal(t, "/api/v1/tasks/:projecttask/labels/bulk", bulkRoute.Path)
|
|
assert.Equal(t, "POST", bulkRoute.Method)
|
|
}
|
|
|
|
func TestIsV2Path(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"/api/v2": true,
|
|
"/api/v2/": true,
|
|
"/api/v2/labels": true,
|
|
"/api/v1/labels": false,
|
|
"/api/v1/api/v2": false, // prefix is authoritative
|
|
"": false,
|
|
"/api/v20/labels": false, // only exact /api/v2 prefix counts
|
|
"/api/v2labels": false,
|
|
}
|
|
for path, want := range cases {
|
|
t.Run(path, func(t *testing.T) {
|
|
assert.Equal(t, want, isV2Path(path))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStripAPIVersion(t *testing.T) {
|
|
cases := map[string]string{
|
|
"/api/v1/labels": "labels",
|
|
"/api/v2/labels": "labels",
|
|
"/api/v2/labels/42": "labels/42",
|
|
"/api/v1/tasks/bulk": "tasks/bulk",
|
|
"/api/v3/labels": "/api/v3/labels", // unknown versions pass through
|
|
"/labels": "/labels",
|
|
"": "",
|
|
}
|
|
for path, want := range cases {
|
|
t.Run(path, func(t *testing.T) {
|
|
assert.Equal(t, want, stripAPIVersion(path))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCollectRoutesV2 verifies that /api/v2 routes are stored in the v2
|
|
// shadow table under the same (group, permission) keys their v1 counterparts
|
|
// would use. This is what lets a token scoped on `labels.read_one` authorise
|
|
// both /api/v1/labels/{id} and /api/v2/labels/{id}.
|
|
func TestCollectRoutesV2(t *testing.T) {
|
|
apiTokenRoutes = make(map[string]APITokenRoute)
|
|
apiTokenRoutesV2 = make(map[string]APITokenRoute)
|
|
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/labels"}, true)
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/labels/:id"}, true)
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "POST", Path: "/api/v2/labels"}, true)
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/labels/:id"}, true)
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "DELETE", Path: "/api/v2/labels/:id"}, true)
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PATCH", Path: "/api/v2/labels/:id"}, true)
|
|
|
|
// v1 map stays untouched.
|
|
assert.Empty(t, apiTokenRoutes, "v2 routes must not land in the v1 table")
|
|
|
|
labels, has := apiTokenRoutesV2["labels"]
|
|
require.True(t, has, "labels group should exist in v2 table")
|
|
assert.Equal(t, "GET", labels["read_all"].Method)
|
|
assert.Equal(t, "/api/v2/labels", labels["read_all"].Path)
|
|
assert.Equal(t, "GET", labels["read_one"].Method)
|
|
assert.Equal(t, "POST", labels["create"].Method)
|
|
// PUT is the authoritative update verb for API tokens — PATCH is
|
|
// skipped during collection so it doesn't clobber PUT.
|
|
assert.Equal(t, "PUT", labels["update"].Method)
|
|
assert.Equal(t, "DELETE", labels["delete"].Method)
|
|
}
|
|
|
|
// TestGetRouteDetail_V2Verbs verifies the v2 verb mapping: POST→create,
|
|
// PUT/PATCH→update. v1 inverts POST and PUT so we need a separate mapping
|
|
// path.
|
|
func TestGetRouteDetail_V2Verbs(t *testing.T) {
|
|
cases := []struct {
|
|
method, path, wantPerm string
|
|
}{
|
|
{"GET", "/api/v2/labels", "read_all"},
|
|
{"GET", "/api/v2/labels/:id", "read_one"},
|
|
{"POST", "/api/v2/labels", "create"},
|
|
{"PUT", "/api/v2/labels/:id", "update"},
|
|
{"PATCH", "/api/v2/labels/:id", "update"},
|
|
{"DELETE", "/api/v2/labels/:id", "delete"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.method+" "+c.path, func(t *testing.T) {
|
|
perm, _ := getRouteDetail(echo.RouteInfo{Method: c.method, Path: c.path})
|
|
assert.Equal(t, c.wantPerm, perm)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCanDoAPIRoute_V2PatchAliasesPut verifies that a token granted the
|
|
// "update" permission on a v2 resource can issue PATCH requests against
|
|
// the same path as the stored PUT route. Huma's AutoPatch synthesises
|
|
// PATCH for every PUT — the matcher accepts it as an alias so token
|
|
// holders aren't forced to use PUT exclusively.
|
|
func TestCanDoAPIRoute_V2PatchAliasesPut(t *testing.T) {
|
|
apiTokenRoutes = make(map[string]APITokenRoute)
|
|
apiTokenRoutesV2 = make(map[string]APITokenRoute)
|
|
apiTokenRoutes["caldav"] = APITokenRoute{
|
|
"access": &RouteDetail{Path: "/dav/*", Method: "ANY"},
|
|
}
|
|
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PUT", Path: "/api/v2/labels/:id"}, true)
|
|
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "PATCH", Path: "/api/v2/labels/:id"}, true)
|
|
|
|
token := &APIToken{
|
|
APIPermissions: APIPermissions{"labels": []string{"update"}},
|
|
}
|
|
|
|
e := echo.New()
|
|
|
|
t.Run("PUT is allowed (stored verb)", func(t *testing.T) {
|
|
req := httptest.NewRequest("PUT", "/api/v2/labels/:id", nil)
|
|
c := e.NewContext(req, httptest.NewRecorder())
|
|
assert.True(t, CanDoAPIRoute(c, token))
|
|
})
|
|
|
|
t.Run("PATCH is allowed via alias", func(t *testing.T) {
|
|
req := httptest.NewRequest("PATCH", "/api/v2/labels/:id", nil)
|
|
c := e.NewContext(req, httptest.NewRecorder())
|
|
assert.True(t, CanDoAPIRoute(c, token))
|
|
})
|
|
|
|
t.Run("PATCH on a different path is rejected", func(t *testing.T) {
|
|
req := httptest.NewRequest("PATCH", "/api/v2/projects/:id", nil)
|
|
c := e.NewContext(req, httptest.NewRecorder())
|
|
assert.False(t, CanDoAPIRoute(c, token))
|
|
})
|
|
|
|
t.Run("v1 PATCH stays rejected", func(t *testing.T) {
|
|
// The alias must not bleed onto v1 — v1 has no AutoPatch and
|
|
// never registers PATCH on update routes.
|
|
apiTokenRoutes["labels"] = APITokenRoute{
|
|
"update": &RouteDetail{Path: "/api/v1/labels/:id", Method: "POST"},
|
|
}
|
|
v1Token := &APIToken{
|
|
APIPermissions: APIPermissions{"labels": []string{"update"}},
|
|
}
|
|
req := httptest.NewRequest("PATCH", "/api/v1/labels/:id", nil)
|
|
c := e.NewContext(req, httptest.NewRecorder())
|
|
assert.False(t, CanDoAPIRoute(c, v1Token))
|
|
})
|
|
}
|
|
|
|
// End-to-end CanDoAPIRoute coverage for /api/v2 is provided by the Label
|
|
// integration test in pkg/webtests/huma_label_test.go (see the token-auth
|
|
// scenarios in that file) which exercises the full auth pipeline.
|