test(api/v2): cover ported auth/token endpoints

Add webtests mirroring the v1 coverage for the v2 auth surface:
register (incl. registration-disabled 404), password reset request +
reset, email confirm, link-share auth (password matrix), the OAuth token
flow in both JSON and form-urlencoded encodings, oauth/authorize, the
token-test/check endpoints (200, not 418), /routes and link-share token
renewal (incl. user-token rejection).

Also make the link-share auth body optional so a passwordless share
authenticates with no request body, matching v1.
This commit is contained in:
kolaente 2026-06-11 21:51:40 +02:00
parent 38d7fa3c88
commit dab490376b
2 changed files with 302 additions and 2 deletions

View File

@ -163,11 +163,18 @@ func authConfirmEmail(_ context.Context, in *struct{ Body user.EmailConfirm }) (
func authLinkShare(_ context.Context, in *struct {
Share string `path:"share" doc:"The public hash of the link share."`
Body struct {
// Pointer so the body is optional: shares without a password are
// authenticated with no body at all.
Body *struct {
Password string `json:"password" doc:"The password for password-protected link shares. Ignored for shares without a password."`
}
}) (*linkShareTokenBody, error) {
token, err := shared.AuthenticateLinkShare(in.Share, in.Body.Password)
var password string
if in.Body != nil {
password = in.Body.Password
}
token, err := shared.AuthenticateLinkShare(in.Share, password)
if err != nil {
return nil, translateDomainError(err)
}

View File

@ -0,0 +1,293 @@
// 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 webtests
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/modules/auth/oauth2server"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestHumaAuthPublic ports the v1 coverage of the public local-account flows
// (register, password reset, email confirm) to /api/v2. These endpoints opt out
// of the global auth, so requests carry no token.
func TestHumaAuthPublic(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
post := func(path, body string) *httptest.ResponseRecorder {
return humaRequest(t, e, http.MethodPost, path, body, "", "")
}
t.Run("Register", func(t *testing.T) {
t.Run("normal", func(t *testing.T) {
rec := post("/api/v2/register", `{"username":"newhumauser","password":"12345678","email":"newhuma@example.com"}`)
require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), `"username":"newhumauser"`)
})
t.Run("already existing username", func(t *testing.T) {
rec := post("/api/v2/register", `{"username":"user1","password":"12345678","email":"x@example.com"}`)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
t.Run("empty username", func(t *testing.T) {
rec := post("/api/v2/register", `{"username":"","password":"12345678","email":"x@example.com"}`)
assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest)
})
})
t.Run("Request password reset token", func(t *testing.T) {
t.Run("normal", func(t *testing.T) {
rec := post("/api/v2/user/password/token", `{"email":"user1@example.com"}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "Token was sent.")
})
t.Run("no user with that email", func(t *testing.T) {
rec := post("/api/v2/user/password/token", `{"email":"user1000@example.com"}`)
assert.Equal(t, http.StatusNotFound, rec.Code)
})
})
t.Run("Reset password", func(t *testing.T) {
t.Run("normal", func(t *testing.T) {
rec := post("/api/v2/user/password/reset", `{"token":"passwordresettesttoken","new_password":"12345678"}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "The password was updated successfully.")
})
t.Run("invalid token", func(t *testing.T) {
rec := post("/api/v2/user/password/reset", `{"token":"invalidtoken","new_password":"12345678"}`)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
})
})
t.Run("Confirm email", func(t *testing.T) {
t.Run("normal", func(t *testing.T) {
rec := post("/api/v2/user/confirm", `{"token":"tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael"}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), "The email was confirmed successfully.")
})
t.Run("invalid token", func(t *testing.T) {
rec := post("/api/v2/user/confirm", `{"token":"invalidToken"}`)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
})
})
}
// TestHumaRegisterDisabled proves the registration endpoint 404s when
// registration is disabled, mirroring v1.
func TestHumaRegisterDisabled(t *testing.T) {
config.ServiceEnableRegistration.Set(false)
defer config.ServiceEnableRegistration.Set(true)
e, err := setupTestEnv()
require.NoError(t, err)
rec := humaRequest(t, e, http.MethodPost, "/api/v2/register",
`{"username":"nope","password":"12345678","email":"nope@example.com"}`, "", "")
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// TestHumaLinkShareAuth ports the v1 link-share auth coverage to /api/v2.
func TestHumaLinkShareAuth(t *testing.T) {
config.ServiceEnableLinkSharing.Set(true)
e, err := setupTestEnv()
require.NoError(t, err)
post := func(share, body string) *httptest.ResponseRecorder {
return humaRequest(t, e, http.MethodPost, "/api/v2/shares/"+share+"/auth", body, "", "")
}
t.Run("without password", func(t *testing.T) {
rec := post("test", ``)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), `"token":"`)
assert.Contains(t, rec.Body.String(), `"project_id":1`)
})
t.Run("with password, correct", func(t *testing.T) {
rec := post("testWithPassword", `{"password":"12345678"}`)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), `"token":"`)
})
t.Run("with password, missing", func(t *testing.T) {
rec := post("testWithPassword", ``)
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
assert.Equal(t, models.ErrCodeLinkSharePasswordRequired, problemCode(t, rec))
})
t.Run("with password, wrong", func(t *testing.T) {
rec := post("testWithPassword", `{"password":"wrong"}`)
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, models.ErrCodeLinkSharePasswordInvalid, problemCode(t, rec))
})
}
// TestHumaTokenMeta ports the token-introspection and link-share renew
// endpoints to /api/v2.
func TestHumaTokenMeta(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
userToken := humaTokenFor(t, &testuser1)
t.Run("token test (GET) returns ok", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", userToken, "")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), `"message":"ok"`)
})
t.Run("token check (POST) returns 200, not 418", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/token/test", "", userToken, "")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), `"message":"ok"`)
})
t.Run("token check unauthenticated", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodGet, "/api/v2/token/test", "", "", "")
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("routes lists token routes", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodGet, "/api/v2/routes", "", userToken, "")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var routes map[string]map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &routes))
assert.Contains(t, routes, "tasks")
})
t.Run("renew link-share token", func(t *testing.T) {
share := &models.LinkSharing{
ID: 1,
Hash: "test",
ProjectID: 1,
Permission: models.PermissionRead,
SharingType: models.SharingTypeWithoutPassword,
SharedByID: 1,
}
shareToken, err := auth.NewLinkShareJWTAuthtoken(share)
require.NoError(t, err)
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", shareToken, "")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Contains(t, rec.Body.String(), `"token":"`)
})
t.Run("renew rejects user token", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/token", "", userToken, "")
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
// TestHumaOAuth ports the OAuth 2.0 token and authorize flows to /api/v2 and
// exercises both the JSON and the spec-compliant form-urlencoded encodings of
// the token endpoint.
func TestHumaOAuth(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
t.Run("authorize requires authentication", func(t *testing.T) {
body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", "abc", "S256", "s")
rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), "", "")
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
t.Run("full code flow with PKCE (JSON token request)", func(t *testing.T) {
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := pkceChallenge(verifier)
code := authorizeV2(t, e, challenge, "xyz")
body, _ := json.Marshal(map[string]string{ //nolint:errchkjson
"grant_type": "authorization_code",
"code": code,
"client_id": "vikunja",
"redirect_uri": "vikunja-flutter://callback",
"code_verifier": verifier,
})
rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", string(body), "", "application/json")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
var resp oauth2server.TokenResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.NotEmpty(t, resp.AccessToken)
assert.Equal(t, "bearer", resp.TokenType)
assert.NotEmpty(t, resp.RefreshToken)
})
t.Run("full code flow with PKCE (form-urlencoded token request)", func(t *testing.T) {
verifier := "form-encoded-flow-verifier"
challenge := pkceChallenge(verifier)
code := authorizeV2(t, e, challenge, "")
form := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"client_id": {"vikunja"},
"redirect_uri": {"vikunja-flutter://callback"},
"code_verifier": {verifier},
}
rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp oauth2server.TokenResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.NotEmpty(t, resp.AccessToken)
assert.NotEmpty(t, resp.RefreshToken)
})
t.Run("invalid grant type", func(t *testing.T) {
form := url.Values{"grant_type": {"password"}, "client_id": {"vikunja"}}
rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/token", form.Encode(), "", "application/x-www-form-urlencoded")
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
func pkceChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
// authorizeV2 runs the v2 authorize step for testuser1 and returns the code.
func authorizeV2(t *testing.T, e *echo.Echo, challenge, state string) string {
t.Helper()
token := humaTokenFor(t, &testuser1)
body := authorizeRequestBody("code", "vikunja", "vikunja-flutter://callback", challenge, "S256", state)
rec := humaRequest(t, e, http.MethodPost, "/api/v2/oauth/authorize", string(body), token, "")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
var resp oauth2server.AuthorizeResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Code)
return resp.Code
}
// problemCode pulls the Vikunja numeric error code out of an RFC 9457 body.
func problemCode(t *testing.T, rec *httptest.ResponseRecorder) int {
t.Helper()
var body struct {
Code int `json:"code"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
return body.Code
}