vikunja/veans/e2e/helpers.go

327 lines
10 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 e2e is the integration suite for veans. It assumes a running
// Vikunja API at VEANS_E2E_API_URL with VIKUNJA_SERVICE_TESTINGTOKEN set
// (passed in via VEANS_E2E_TESTING_TOKEN) so the suite can seed its own
// admin via PATCH /api/v1/test/users — the same `/test/{table}` endpoint
// the frontend playwright suite uses.
//
// The alternative path — VEANS_E2E_ADMIN_TOKEN — is a JWT against a
// long-lived Vikunja the user wants to drive without touching its data;
// in that mode the suite skips the seed.
//
// The suite never provisions Vikunja itself.
package e2e
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"code.vikunja.io/veans/internal/client"
)
// Hard-coded seed credentials. The hash is the bcrypt of "1234" and
// matches frontend/tests/support/constants.ts so the whole e2e infra
// shares one well-known password — tests themselves never need to read
// these from env.
const (
seedAdminUsername = "e2eadmin"
seedAdminPassword = "1234"
seedAdminBcrypt = "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To." //nolint:gosec // G101: deterministic test fixture, not a credential
)
// Harness bundles a built veans binary and an authenticated admin client
// for verifying side effects on the server.
type Harness struct {
Binary string
APIURL string
AdminToken string
AdminClient *client.Client
}
// New builds (or reuses) the veans binary, seeds the admin user via
// PATCH /api/v1/test/users (using VEANS_E2E_TESTING_TOKEN), logs in as
// that admin, and returns a Harness ready to drive tests.
//
// If VEANS_E2E_ADMIN_TOKEN is set, the seed is skipped and that token
// is used directly — useful for running against a long-lived Vikunja
// the caller doesn't want this suite to mutate user rows on.
//
// Tests rely on the `-short` skip in TestMain to opt out when a Vikunja
// instance isn't available; if `-short` is *not* set and env is missing,
// we fail loudly with a "configure or pass -short" hint.
func New(t *testing.T) *Harness {
t.Helper()
apiURL := strings.TrimRight(os.Getenv("VEANS_E2E_API_URL"), "/")
if apiURL == "" {
t.Fatal("VEANS_E2E_API_URL is not set — point it at a Vikunja instance, or pass -short to skip the e2e suite")
}
binary, err := buildOrLocate()
if err != nil {
t.Fatalf("locate veans binary: %v", err)
}
tok := os.Getenv("VEANS_E2E_ADMIN_TOKEN")
if tok == "" {
testingToken := os.Getenv("VEANS_E2E_TESTING_TOKEN")
if testingToken == "" {
t.Fatal("set VEANS_E2E_ADMIN_TOKEN, or VEANS_E2E_TESTING_TOKEN (matching the API's VIKUNJA_SERVICE_TESTINGTOKEN) so the suite can seed its own admin")
}
seedAdmin(t, apiURL, testingToken)
c := client.New(apiURL, "")
resp, err := c.Login(t.Context(), &client.LoginRequest{
Username: seedAdminUsername,
Password: seedAdminPassword,
LongToken: true,
})
if err != nil {
t.Fatalf("admin login: %v", err)
}
tok = resp.Token
}
return &Harness{
Binary: binary,
APIURL: apiURL,
AdminToken: tok,
AdminClient: client.New(apiURL, tok),
}
}
// seedAdmin PATCHes a single admin user row into the users table via
// the testing endpoint. truncate=true wipes any prior users from
// previous tests so each New(t) starts from a known state.
func seedAdmin(t *testing.T, apiURL, testingToken string) {
t.Helper()
now := time.Now().UTC().Format(time.RFC3339)
body, err := json.Marshal([]map[string]any{{
"id": 1,
"username": seedAdminUsername,
"password": seedAdminBcrypt,
"email": "e2e@example.com",
"status": 0,
"issuer": "local",
"language": "en",
"created": now,
"updated": now,
}})
if err != nil {
t.Fatalf("marshal seed payload: %v", err)
}
req, err := http.NewRequestWithContext(t.Context(), http.MethodPatch,
apiURL+"/api/v1/test/users?truncate=true", bytes.NewReader(body))
if err != nil {
t.Fatalf("build seed request: %v", err)
}
req.Header.Set("Authorization", testingToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("seed admin: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
buf, _ := io.ReadAll(resp.Body)
t.Fatalf("seed admin: HTTP %d: %s", resp.StatusCode, string(buf))
}
}
// Workspace creates a per-test git repo in a TempDir with HOME pointed at
// a TempDir so the credential store writes under the test's own directory
// rather than touching the developer's keychain.
type Workspace struct {
Dir string
Home string
ConfigPath string
BotUsername string
envOverrides map[string]string
}
// NewWorkspace initializes a fresh repo with `git init` + a single commit so
// `git rev-parse --abbrev-ref HEAD` returns a real branch name.
func (h *Harness) NewWorkspace(t *testing.T) *Workspace {
t.Helper()
dir := t.TempDir()
home := t.TempDir()
for _, c := range [][]string{
{"git", "init", "-q", "-b", "main"},
{"git", "config", "user.email", "veans-e2e@example.com"},
{"git", "config", "user.name", "veans-e2e"},
// Disable any inherited commit signing; the test commit doesn't
// need provenance and signing brokers can fail in dev containers.
{"git", "config", "commit.gpgsign", "false"},
{"git", "config", "tag.gpgsign", "false"},
} {
cmd := exec.CommandContext(t.Context(), c[0], c[1:]...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out)
}
}
if err := os.WriteFile(filepath.Join(dir, "README"), []byte("test"), 0o644); err != nil {
t.Fatal(err)
}
for _, c := range [][]string{
{"git", "add", "."},
{"git", "commit", "-q", "-m", "initial"},
} {
cmd := exec.CommandContext(t.Context(), c[0], c[1:]...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%s: %v\n%s", strings.Join(c, " "), err, out)
}
}
return &Workspace{
Dir: dir,
Home: home,
ConfigPath: filepath.Join(dir, ".veans.yml"),
envOverrides: map[string]string{
"HOME": home,
},
}
}
// Run executes the veans binary against this workspace, returning stdout,
// stderr, and exit code.
func (h *Harness) Run(t *testing.T, ws *Workspace, args ...string) (stdout, stderr string, exitCode int) {
t.Helper()
cmd := exec.CommandContext(t.Context(), h.Binary, args...)
cmd.Dir = ws.Dir
// Filter VEANS_* out of the inherited env before applying our
// overrides — a developer's VEANS_TOKEN would otherwise mask the
// per-test bot token via the env backend.
cmd.Env = append(filterEnv(os.Environ(), "VEANS_"), envSlice(ws.envOverrides)...)
var so, se bytes.Buffer
cmd.Stdout = &so
cmd.Stderr = &se
err := cmd.Run()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return so.String(), se.String(), ee.ExitCode()
}
t.Fatalf("run veans %v: %v", args, err)
}
return so.String(), se.String(), 0
}
// CreateProject creates a fresh project owned by the admin user and returns
// it. Tests use a unique title to keep results isolated across parallel runs.
func (h *Harness) CreateProject(t *testing.T, title, identifier string) *client.Project {
t.Helper()
out, err := h.AdminClient.CreateProject(t.Context(),
&client.Project{Title: title, Identifier: identifier})
if err != nil {
t.Fatalf("create project %q: %v", title, err)
}
return out
}
// FindKanbanView returns the first Kanban view of the project (Vikunja
// auto-creates one).
func (h *Harness) FindKanbanView(t *testing.T, projectID int64) *client.ProjectView {
t.Helper()
views, err := h.AdminClient.ListProjectViews(t.Context(), projectID)
if err != nil {
t.Fatalf("list views: %v", err)
}
for _, v := range views {
if v.ViewKind == client.ViewKindKanban {
return v
}
}
t.Fatalf("no Kanban view on project %d", projectID)
return nil
}
// GetTask fetches a task by ID for verification.
func (h *Harness) GetTask(t *testing.T, id int64) *client.Task {
t.Helper()
task, err := h.AdminClient.GetTask(t.Context(), id)
if err != nil {
t.Fatalf("get task %d: %v", id, err)
}
return task
}
func buildOrLocate() (string, error) {
if env := os.Getenv("VEANS_BINARY"); env != "" {
if abs, err := filepath.Abs(env); err == nil {
if _, err := os.Stat(abs); err == nil {
return abs, nil
}
}
}
tmp, err := os.MkdirTemp("", "veans-bin-*")
if err != nil {
return "", err
}
bin := filepath.Join(tmp, "veans")
if runtime.GOOS == "windows" {
bin += ".exe"
}
cmd := exec.CommandContext(context.Background(), "go", "build", "-o", bin, "./cmd/veans")
cmd.Dir = repoRoot()
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("build veans: %w (output: %s)", err, out)
}
return bin, nil
}
func repoRoot() string {
// e2e/helpers.go lives at <repo>/veans/e2e/helpers.go, so go up two.
_, file, _, _ := runtime.Caller(0)
return filepath.Clean(filepath.Join(filepath.Dir(file), ".."))
}
func envSlice(overrides map[string]string) []string {
out := make([]string, 0, len(overrides))
for k, v := range overrides {
out = append(out, k+"="+v)
}
return out
}
// filterEnv returns env entries whose keys do NOT start with prefix.
func filterEnv(env []string, prefix string) []string {
out := make([]string, 0, len(env))
for _, kv := range env {
if !strings.HasPrefix(kv, prefix) {
out = append(out, kv)
}
}
return out
}