feat(api/v2): add e2e testing-support endpoints on /api/v2

Port the testing fixture endpoints to /api/v2: PUT /test/{table} resets a
table to a posted fixture set and DELETE /test/all truncates everything.
Both authenticate with the configured testing token via a custom
Authorization header (not JWT/API-token) and only mount when that token is
set. Reuses the shared reset/truncate logic extracted from v1.
This commit is contained in:
kolaente 2026-06-12 10:11:00 +02:00 committed by kolaente
parent 5555950f03
commit 4737114b12
3 changed files with 357 additions and 0 deletions

View File

@ -0,0 +1,129 @@
// 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 apiv2
import (
"context"
"net/http"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/routes/api/shared"
"github.com/danielgtaylor/huma/v2"
)
// testingReplaceInput is the request for resetting a single table. The
// Authorization header carries the configured testing token (not a JWT or API
// token); the endpoint is public and checks it in-handler like v1.
type testingReplaceInput struct {
Table string `path:"table" doc:"The table to reset."`
// String (not bool) so absent is distinguishable from an explicit "false":
// like v1, an absent truncate parameter means truncate. Huma does not
// support *bool params, and a bool with default:"true" silently ignores an
// explicit ?truncate=false, so the parameter is read as a raw string and
// interpreted in the handler exactly like v1 does.
Truncate string `query:"truncate" enum:"true,false" doc:"Empty the table (and its dependents) before inserting the rows. Defaults to true; pass false to restore on top of existing data."`
Authorization string `header:"Authorization" doc:"The configured testing token."`
Body []map[string]any `doc:"The rows to write into the table. Free-form objects matching the table's columns."`
}
type testingReplaceBody struct {
Body []map[string]any `doc:"The table's contents after the reset."`
}
type testingTruncateAllInput struct {
Authorization string `header:"Authorization" doc:"The configured testing token."`
}
type testingTruncateAllBody struct {
Body struct {
Message string `json:"message" doc:"Always \"ok\" on success."`
}
}
// RegisterTestingRoutes wires the e2e testing-support endpoints onto the Huma
// API. They are only mounted when the testing token is configured, matching v1.
func RegisterTestingRoutes(api huma.API) {
if config.ServiceTestingtoken.GetString() == "" {
return
}
tags := []string{"testing"}
// Public: opt out of the globally-applied JWT/API-token auth — these
// authenticate with the testing token via the Authorization header
// instead. Their paths are also listed in unauthenticatedAPIPaths so the
// token middleware lets them through.
noAuth := []map[string][]string{}
Register(api, huma.Operation{
OperationID: "testing-truncate-all",
Summary: "Truncate all tables",
Description: "Removes all data from every Vikunja table. Used by e2e tests to ensure a clean state before each test. Authenticates with the configured testing token via the Authorization header, not a JWT or API token.",
Method: http.MethodDelete,
Path: "/test/all",
Tags: tags,
Security: noAuth,
// v1 returns 200 with a body rather than the 204 a DELETE would default to.
DefaultStatus: http.StatusOK,
}, testingTruncateAll)
Register(api, huma.Operation{
OperationID: "testing-replace-table",
Summary: "Reset a table to a defined state",
Description: "Replaces the contents of the named table with the rows in the payload and returns the resulting contents. Used by e2e tests to seed fixtures. Authenticates with the configured testing token via the Authorization header, not a JWT or API token.",
Method: http.MethodPut,
Path: "/test/{table}",
Tags: tags,
Security: noAuth,
// Mirror v1's 201 for a successful reset.
DefaultStatus: http.StatusCreated,
}, testingReplaceTable)
}
func init() { AddRouteRegistrar(RegisterTestingRoutes) }
func testingReplaceTable(_ context.Context, in *testingReplaceInput) (*testingReplaceBody, error) {
if in.Authorization != config.ServiceTestingtoken.GetString() {
return nil, huma.Error403Forbidden("forbidden")
}
// Mirror v1: absent or "true" truncates; only an explicit "false" appends.
truncate := in.Truncate == "true" || in.Truncate == ""
data, err := shared.ReplaceTableContents(in.Table, in.Body, truncate)
if err != nil {
log.Errorf("Error replacing table data: %v", err)
return nil, huma.Error500InternalServerError("could not replace table data")
}
return &testingReplaceBody{Body: data}, nil
}
func testingTruncateAll(_ context.Context, in *testingTruncateAllInput) (*testingTruncateAllBody, error) {
if in.Authorization != config.ServiceTestingtoken.GetString() {
return nil, huma.Error403Forbidden("forbidden")
}
if err := shared.TruncateAllTestingTables(); err != nil {
log.Errorf("Error truncating all tables: %v", err)
return nil, huma.Error500InternalServerError("could not truncate tables")
}
out := &testingTruncateAllBody{}
out.Body.Message = "ok"
return out, nil
}

View File

@ -360,6 +360,11 @@ var unauthenticatedAPIPaths = map[string]bool{
"/api/v2/user/confirm": true, "/api/v2/user/confirm": true,
"/api/v2/shares/:share/auth": true, "/api/v2/shares/:share/auth": true,
"/api/v2/oauth/token": true, "/api/v2/oauth/token": true,
// Testing endpoints authenticate with the testing token via a custom
// Authorization header, not a JWT; mounted only when that token is set.
"/api/v2/test/all": true,
"/api/v2/test/:table": true,
} }
// collectRoutesForAPITokens collects all routes for API token permission checking. // collectRoutesForAPITokens collects all routes for API token permission checking.

View File

@ -0,0 +1,223 @@
// 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/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/license"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/modules/migration"
"code.vikunja.io/api/pkg/routes"
"code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"src.techknowlogick.com/xormigrate"
)
const testingToken = "test-testing-token"
// setupTestingEnv mirrors setupTestEnv but sets the testing token before
// registering routes, so the config-gated /api/v2/test/* endpoints mount.
// When token is empty the endpoints stay unmounted (the disabled case).
func setupTestingEnv(t *testing.T, token string) *echo.Echo {
t.Helper()
config.InitDefaultConfig()
config.ServicePublicURL.Set("https://localhost")
config.ServiceTestingtoken.Set(token)
t.Cleanup(func() { config.ServiceTestingtoken.Set("") })
log.InitLogger()
files.InitTests()
user.InitTests()
models.SetupTests()
events.Fake()
keyvalue.InitStorage()
// models.SetupTests only syncs models + notifications tables, but
// TruncateAllTables walks *every* registered table — including ones created
// by migration in production (license_status, migration_status) plus
// xormigrate's "migration" tracking table. Create them here so truncate-all
// doesn't hit "no such table" (the same gap that kept v1 from testing it).
engine, err := db.CreateTestEngine()
require.NoError(t, err)
extraTables := append(append([]any{new(xormigrate.Migration)}, license.GetTables()...), migration.GetTables()...)
require.NoError(t, engine.Sync2(extraTables...))
require.NoError(t, db.LoadFixtures())
e := routes.NewEcho()
routes.RegisterRoutes(e)
return e
}
// testingRequest dispatches a request to a /api/v2/test/* endpoint, sending the
// raw token in the Authorization header (not a Bearer JWT).
func testingRequest(e *echo.Echo, method, path, body, token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", token)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}
func countRows(t *testing.T, table string) int {
t.Helper()
s := db.NewSession()
defer s.Close()
rows := []map[string]interface{}{}
require.NoError(t, s.Table(table).Find(&rows))
return len(rows)
}
func TestTesting(t *testing.T) {
t.Run("replace table contents", func(t *testing.T) {
e := setupTestingEnv(t, testingToken)
t.Cleanup(func() { _ = db.LoadFixtures() })
body := `[{"id":1,"title":"only label","created_by_id":1,"created":"2020-01-01T00:00:00Z","updated":"2020-01-01T00:00:00Z"}]`
rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", body, testingToken)
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
var data []map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &data))
require.Len(t, data, 1)
assert.EqualValues(t, "only label", data[0]["title"])
assert.Equal(t, 1, countRows(t, "labels"), "table should hold exactly the seeded rows")
})
t.Run("replace without truncate keeps existing rows", func(t *testing.T) {
e := setupTestingEnv(t, testingToken)
t.Cleanup(func() { _ = db.LoadFixtures() })
before := countRows(t, "labels")
require.Positive(t, before, "fixtures should seed some labels")
body := `[{"id":9999,"title":"added label","created_by_id":1,"created":"2020-01-01T00:00:00Z","updated":"2020-01-01T00:00:00Z"}]`
rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels?truncate=false", body, testingToken)
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
assert.Equal(t, before+1, countRows(t, "labels"), "row should be added on top of existing data")
})
t.Run("truncate all tables", func(t *testing.T) {
e := setupTestingEnv(t, testingToken)
t.Cleanup(func() { _ = db.LoadFixtures() })
require.Positive(t, countRows(t, "labels"))
rec := testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", testingToken)
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resp struct {
Message string `json:"message"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, "ok", resp.Message)
assert.Equal(t, 0, countRows(t, "labels"), "every table should be empty after truncate")
})
t.Run("wrong token is forbidden", func(t *testing.T) {
e := setupTestingEnv(t, testingToken)
rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "wrong-token")
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "wrong-token")
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
t.Run("missing token is forbidden", func(t *testing.T) {
e := setupTestingEnv(t, testingToken)
rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "")
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "")
assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
}
func TestTesting_DisabledConfig(t *testing.T) {
e := setupTestingEnv(t, "")
rec := testingRequest(e, http.MethodPut, "/api/v2/test/labels", `[]`, "")
assert.Equal(t, http.StatusNotFound, rec.Code, "endpoint must be absent when no testing token is configured")
rec = testingRequest(e, http.MethodDelete, "/api/v2/test/all", "", "")
assert.Equal(t, http.StatusNotFound, rec.Code, "endpoint must be absent when no testing token is configured")
}
func TestTesting_BodySchemaIsArrayOfObjects(t *testing.T) {
e := setupTestingEnv(t, testingToken)
req := httptest.NewRequest(http.MethodGet, "/api/v2/openapi.json", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var spec map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec))
paths, _ := spec["paths"].(map[string]any)
op, _ := paths["/test/{table}"].(map[string]any)
put, ok := op["put"].(map[string]any)
require.True(t, ok, "PUT /test/{table} must be in the spec")
reqBody, _ := put["requestBody"].(map[string]any)
content, _ := reqBody["content"].(map[string]any)
appJSON, _ := content["application/json"].(map[string]any)
schema, _ := appJSON["schema"].(map[string]any)
// FieldsOptionalByDefault makes the array nullable, so `type` may be the
// string "array" or the list ["array","null"]. Either is honest; assert it
// describes an array (not, say, a base64 string as json.RawMessage would).
assert.Contains(t, schemaTypes(schema["type"]), "array", "request body must be modeled as an array")
}
// schemaTypes normalises an OpenAPI `type` value (a string or a list of
// strings when nullable) into a slice for assertion.
func schemaTypes(v any) []string {
switch t := v.(type) {
case string:
return []string{t}
case []any:
out := make([]string, 0, len(t))
for _, e := range t {
if s, ok := e.(string); ok {
out = append(out, s)
}
}
return out
default:
return nil
}
}