vikunja/pkg/models/api_routes_test.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.