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(), "