From 9cad4f388ce300f1d15fc3f54e3045db48e67d3c Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 17 Jun 2026 21:38:27 +0200 Subject: [PATCH] feat(api/v2): expose websocket endpoint under /api/v2 Adds GET /api/v2/ws as a raw echo route reusing the v1 upgrade handler. WebSockets can't be modeled in OpenAPI and Huma has no WS support, so it stays outside the Huma spec; it authenticates via its first message, so unauthenticatedAPIPaths exempts it from the group's JWT middleware. Also adds webtests covering all three /api/v2 non-CRUD endpoints: health returns OK, ws is reachable without a JWT, and the atom feed is basic-auth-gated. A spec test asserts /health and /notifications.atom appear in the generated OpenAPI paths (atom with its application/atom+xml response and BasicAuth security) while /ws is absent. --- pkg/routes/routes.go | 11 ++ pkg/webtests/huma_non_crud_aliases_test.go | 151 +++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 pkg/webtests/huma_non_crud_aliases_test.go diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 3cdc47f1b..bcdea1bdb 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -375,6 +375,10 @@ var unauthenticatedAPIPaths = map[string]bool{ // Atom feed (a Huma op) authenticates itself with HTTP Basic auth (a // feeds-scoped API token), like its /feeds counterpart, not a JWT. "/api/v2/notifications.atom": true, + + // WebSocket upgrade (a raw echo route — OpenAPI can't model WebSockets); + // it authenticates via its first message, so the upgrade needs no JWT. + "/api/v2/ws": true, } // collectRoutesForAPITokens collects all routes for API token permission checking. @@ -447,6 +451,13 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) { a.GET("/docs", apiv2.ScalarUI) a.GET("/docs/scalar.standalone.js", apiv2.ScalarJS) + // WebSockets can't be modeled in OpenAPI and Huma has no WS support, so the + // upgrade endpoint stays a raw echo route (outside the Huma spec). It + // authenticates via its first message, so unauthenticatedAPIPaths exempts it + // from the group's JWT middleware. Health and the Atom feed are Huma ops and + // self-register via init()/RegisterAll. + a.GET("/ws", ws.UpgradeHandler) + // Resources self-register via init(); RegisterAll runs them all + AutoPatch. apiv2.RegisterAll(api) } diff --git a/pkg/webtests/huma_non_crud_aliases_test.go b/pkg/webtests/huma_non_crud_aliases_test.go new file mode 100644 index 000000000..1377507cd --- /dev/null +++ b/pkg/webtests/huma_non_crud_aliases_test.go @@ -0,0 +1,151 @@ +// 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 ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// feedsTokenUser13 is a feeds-scoped API token for user 13 (see the feeds +// fixtures); it authenticates the v2 notifications Atom feed via HTTP Basic. +const feedsTokenUser13 = "tk_feeds_access_token_user_0013_feed0013" + +// TestHumaNonCRUDAliases covers the three non-REST endpoints mounted under +// /api/v2. Health and the Atom feed are Huma operations (so they appear in the +// OpenAPI spec); the WebSocket upgrade stays a raw echo route (OpenAPI can't +// model WebSockets). Each authenticates itself, so the group's JWT middleware +// must let them through. +func TestHumaNonCRUDAliases(t *testing.T) { + t.Run("health is public and returns OK", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/health", "", "", "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "OK") + }) + + t.Run("ws is reachable without a JWT", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + // A plain GET without the upgrade headers makes websocket.Accept reject + // the request (typically 400). The point is that it reaches the handler + // at all — not a 401 from the JWT middleware nor a 404 for an unmounted + // route. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/ws", "", "", "") + assert.NotEqual(t, http.StatusUnauthorized, rec.Code, "ws must not be blocked by v2 JWT auth") + assert.NotEqual(t, http.StatusNotFound, rec.Code, "ws must be mounted under /api/v2") + }) + + t.Run("atom feed is basic-auth-gated, not JWT-gated", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + t.Run("without credentials returns a basic-auth challenge", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/notifications.atom", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + // The JWT middleware skips this path, so the handler's own HTTP Basic + // auth gates it instead: a 401 carrying a Basic challenge, not the JWT + // middleware's JSON error. + require.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, strings.ToLower(rec.Header().Get(echo.HeaderWWWAuthenticate)), "basic", + "expected a Basic auth challenge, got %q", rec.Header().Get(echo.HeaderWWWAuthenticate)) + }) + + t.Run("with a feeds API token returns an atom feed", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v2/notifications.atom", nil) + req.SetBasicAuth("user13", feedsTokenUser13) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.True(t, strings.HasPrefix(rec.Header().Get(echo.HeaderContentType), "application/atom+xml"), + "expected atom content type, got %q", rec.Header().Get(echo.HeaderContentType)) + assert.Contains(t, rec.Body.String(), "