Files
vikunja/pkg/webtests/error_responses_test.go
kolaente 8ee069a2a3 feat: add session-based auth with refresh token rotation
- Login creates a server-side session and sets an HttpOnly refresh
  token cookie alongside the short-lived JWT
- POST /user/token/refresh exchanges the cookie for a new JWT and
  rotates the refresh token atomically
- POST /user/logout destroys the session and clears the cookie
- POST /user/token restricted to link share tokens only
- Session list (GET) and delete (DELETE) routes for /user/sessions
- All user sessions invalidated on password change and reset
- CORS configured to allow credentials for cross-origin cookies
- JWT 401 responses use structured error code 11 for client detection
- Refresh token cookie name constants annotated for gosec G101
2026-02-25 10:30:25 +01:00

151 lines
5.5 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 webtests
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"code.vikunja.io/api/pkg/modules/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ErrorResponse represents the expected JSON error structure for standard errors
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ValidationErrorResponse represents the expected JSON error structure for validation errors
type ValidationErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
InvalidFields []string `json:"invalid_fields"`
}
// TestErrorResponseFormats tests that error responses are correctly serialized to JSON
// This is critical because the error response format is part of the API contract
func TestErrorResponseFormats(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
// Get auth token for testuser1
token, err := auth.NewUserJWTAuthtoken(&testuser1, "test-session-id")
require.NoError(t, err)
t.Run("validation error returns invalid_fields in JSON body", func(t *testing.T) {
// Update a project with empty title - this should trigger validation error
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1", strings.NewReader(`{"title":""}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// Should be 412 Precondition Failed for validation errors
assert.Equal(t, http.StatusPreconditionFailed, rec.Code)
var errResp ValidationErrorResponse
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
require.NoError(t, err, "Response body: %s", rec.Body.String())
// Verify the error structure includes invalid_fields
assert.Equal(t, 2002, errResp.Code, "Expected error code 2002 (ErrCodeInvalidData)")
require.NotEmpty(t, errResp.InvalidFields, "invalid_fields should not be empty")
require.GreaterOrEqual(t, len(errResp.InvalidFields), 1, "invalid_fields should have at least one element")
assert.Contains(t, errResp.InvalidFields[0], "title", "invalid_fields should mention 'title'")
})
t.Run("bind error returns 400 with message", func(t *testing.T) {
// Send malformed JSON
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/1", strings.NewReader(`{invalid json`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
t.Run("not found error returns 404 with correct structure", func(t *testing.T) {
// Try to get a project that doesn't exist
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/99999", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code)
var errResp ErrorResponse
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
require.NoError(t, err, "Response body: %s", rec.Body.String())
// Should have a proper error code
assert.NotZero(t, errResp.Code, "Error code should be non-zero")
assert.NotEmpty(t, errResp.Message, "Error message should not be empty")
})
t.Run("forbidden error returns 403", func(t *testing.T) {
// Try to access a project owned by user13 (project 20)
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/20", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
})
t.Run("domain error returns correct code and message", func(t *testing.T) {
// Try to create a project with a nonexistent parent
req := httptest.NewRequest(http.MethodPut, "/api/v1/projects", strings.NewReader(`{"title":"Test","parent_project_id":99999}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
// Should be 404 for nonexistent parent project
assert.Equal(t, http.StatusNotFound, rec.Code)
var errResp ErrorResponse
err := json.Unmarshal(rec.Body.Bytes(), &errResp)
require.NoError(t, err, "Response body: %s", rec.Body.String())
// Verify the error has proper structure
assert.NotZero(t, errResp.Code, "Error code should be non-zero")
assert.NotEmpty(t, errResp.Message, "Error message should not be empty")
})
t.Run("unauthorized request returns 401", func(t *testing.T) {
// Make request without auth token
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects/1", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
}