vikunja/pkg/webtests/huma_testing_test.go

224 lines
8.1 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/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-01 00:00:00","updated":"2020-01-01 00:00:00"}]`
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-01 00:00:00","updated":"2020-01-01 00:00:00"}]`
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
}
}