diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ff29621 --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +# Paliad — developer entrypoints. +# +# Targets here are the gate tier from the test-strategy design +# (docs/design-paliad-test-strategy-2026-05-19.md). Slice 1 lands: +# +# make verify-migrations — dry-run every pending migration (BEGIN..ROLLBACK) +# plus the full boot smoke (apply + tracker +# advances + /healthz returns 200). +# make verify-mig — alias for verify-migrations. +# make test — short test pass: go test ./internal/... -short +# plus the cmd/server package. Includes the +# live-DB tests when TEST_DATABASE_URL is set, +# skips them otherwise. +# make test-go — go test ./... -race (full Go suite). +# +# Future slices will extend this with: +# make test-frontend — bun test (Slice 3 / Slice 6) +# make e2e — Playwright golden-path suite (Slice 4) +# +# All targets are idempotent. None of them write to the filesystem outside +# the test runner's working dirs. None of them touch internal/db/migrations/ +# files. + +.PHONY: help verify-migrations verify-mig test test-go + +help: + @echo "Paliad — developer targets" + @echo "" + @echo " verify-migrations Dry-run pending migrations + boot smoke (needs TEST_DATABASE_URL)" + @echo " verify-mig Alias for verify-migrations" + @echo " test Short test pass — covers gate tier" + @echo " test-go Full Go suite with race detector" + @echo "" + @echo "Set TEST_DATABASE_URL to enable live-DB tests. Example:" + @echo " export TEST_DATABASE_URL=postgres://paliad:...@localhost:11833/paliad_test" + +# Gate target — the test that would have caught mig 098 / mig 099 before +# deploy. Combines: +# - TestMigrations_DryRun (internal/db): per-migration BEGIN..ROLLBACK +# - TestBootSmoke (cmd/server): apply-end-to-end + tracker advances +# + /healthz 200 +# +# Requires TEST_DATABASE_URL. Without it, both tests skip and the target +# is effectively a no-op — guard against that explicitly so CI doesn't +# silently green a missing env var. +verify-migrations: + @if [ -z "$$TEST_DATABASE_URL" ]; then \ + echo "ERROR: TEST_DATABASE_URL is not set."; \ + echo " The migration gate cannot run without a scratch DB."; \ + echo " Set TEST_DATABASE_URL to a Postgres URL the test can"; \ + echo " open transactions against, e.g."; \ + echo " export TEST_DATABASE_URL=postgres://paliad:PW@localhost:11833/paliad_test"; \ + exit 2; \ + fi + @echo "==> migration dry-run (per-mig BEGIN..ROLLBACK)" + go test -count=1 -run TestMigrations_DryRun ./internal/db/ + @echo "==> boot smoke (apply + tracker + /healthz)" + go test -count=1 -run TestBootSmoke ./cmd/server/ + +verify-mig: verify-migrations + +# Gate-tier test pass. -short skips the slow live-DB tests when the +# author opts out via `if testing.Short() { t.Skip(...) }`; today most of +# paliad's live-DB tests gate on TEST_DATABASE_URL instead, so -short is +# forward-compatible rather than load-bearing. +test: + go test -short ./internal/... ./cmd/... + +# Full Go suite with race detection. Slower but catches concurrent-map +# regressions that -short would skip; intended for the merge-to-main gate +# (full suite, not per-PR). +test-go: + go test -race ./... diff --git a/cmd/server/main_smoke_test.go b/cmd/server/main_smoke_test.go new file mode 100644 index 0000000..061fdb0 --- /dev/null +++ b/cmd/server/main_smoke_test.go @@ -0,0 +1,170 @@ +// Boot smoke test — assert paliad reaches a serving state. +// +// Three checks against TEST_DATABASE_URL: +// +// 1. db.ApplyMigrations does not panic and returns nil. +// 2. The migration tracker (public.paliad_schema_migrations) advances to +// the highest *.up.sql version on disk — no migrations were silently +// skipped, no "dirty=true" stragglers left behind. +// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz. +// +// This is the lightweight cousin of the migration dry-run gate +// (internal/db/migrate_test.go): the dry-run catches per-migration syntax +// errors before merge; this smoke confirms the apply+bind path the +// container actually runs at boot. Together they cover the mig-098 / +// mig-099 class of crash-loops end-to-end. +// +// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests. +// +// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1. + +package main + +import ( + "database/sql" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" + + _ "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/internal/auth" + "mgit.msbls.de/m/paliad/internal/db" + "mgit.msbls.de/m/paliad/internal/handlers" +) + +func TestBootSmoke(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set — skipping boot smoke") + } + + // (1) Apply migrations end-to-end. The same code path the prod + // container runs at boot before `http.ListenAndServe`. A regression + // like mig-098's digit-regex would surface here as a non-nil error. + if err := db.ApplyMigrations(url); err != nil { + t.Fatalf("db.ApplyMigrations: %v", err) + } + + // (2) Assert the tracker advanced to the highest *.up.sql version we + // embed. If a migration was silently skipped or the tracker is dirty, + // the prod container would crash-loop — this turns that into a test + // failure with a precise reason. + expected := highestEmbeddedMigrationVersion(t) + got, dirty := readTrackerVersion(t, url) + if dirty { + t.Errorf("tracker reports dirty=true at version %d — investigate before deploying", got) + } + if got != expected { + t.Errorf("tracker at version %d; expected %d (highest *.up.sql on disk). "+ + "A migration was skipped or applied out of order.", + got, expected) + } + + // (3) Mount the public handlers (the same Register call main() makes, + // minus the DB-backed Services bundle which the /healthz route doesn't + // need) and assert /healthz returns 200. This is the bind-and-serve + // half of the smoke: catches a regression that would make /healthz + // 404 or break the mux registration order. + // + // We deliberately do not boot the full main() — that would require + // SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_JWT_SECRET, an open + // listening socket and a real auth client. The /healthz handler is + // auth-independent by design, and Register registers it on the outer + // mux before any DB-backed route, so this minimal setup exercises the + // exact code path main() takes. + mux := http.NewServeMux() + authClient := auth.NewClient("https://test.invalid", "anon-key", []byte("test-secret")) + handlers.Register(mux, authClient, "", nil) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("GET /healthz: status=%d, body=%q; want 200 OK", rec.Code, rec.Body.String()) + } + if body := strings.TrimSpace(rec.Body.String()); body != "ok" { + t.Errorf("GET /healthz: body=%q; want \"ok\"", body) + } +} + +// highestEmbeddedMigrationVersion finds max(N) over every NNN_*.up.sql +// file in internal/db/migrations/ on disk. Used as the expected tracker +// version after a clean apply. We read from disk (not the embed.FS in +// the db package — it's unexported) since the test runs from the repo. +func highestEmbeddedMigrationVersion(t *testing.T) int { + t.Helper() + root, err := repoRoot() + if err != nil { + t.Fatalf("locate repo root: %v", err) + } + dir := filepath.Join(root, "internal", "db", "migrations") + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read migrations dir %s: %v", dir, err) + } + var versions []int + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".up.sql") { + continue + } + base := strings.TrimSuffix(name, ".up.sql") + underscore := strings.IndexByte(base, '_') + if underscore <= 0 { + continue + } + v, err := strconv.Atoi(base[:underscore]) + if err != nil { + continue + } + versions = append(versions, v) + } + if len(versions) == 0 { + t.Fatalf("no *.up.sql files found in %s", dir) + } + sort.Ints(versions) + return versions[len(versions)-1] +} + +// readTrackerVersion fetches the lone row from the tracker. golang-migrate +// keeps exactly one row; if we ever see zero or more, that's the dirty-state +// the test is designed to flag. +func readTrackerVersion(t *testing.T, url string) (version int, dirty bool) { + t.Helper() + conn, err := sql.Open("postgres", url) + if err != nil { + t.Fatalf("open: %v", err) + } + defer conn.Close() + row := conn.QueryRow(`SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`) + if err := row.Scan(&version, &dirty); err != nil { + t.Fatalf("read tracker: %v", err) + } + return version, dirty +} + +// repoRoot walks upward from the test binary's working directory until it +// finds a go.mod. `go test` runs in the package dir, so we typically have +// to climb a couple of levels. +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return "", os.ErrNotExist + } + dir = parent + } +} diff --git a/internal/db/migrate_test.go b/internal/db/migrate_test.go new file mode 100644 index 0000000..6b7b774 --- /dev/null +++ b/internal/db/migrate_test.go @@ -0,0 +1,198 @@ +// Package db tests — migration dry-run gate. +// +// This is the test that catches mig-N crash-loops before they reach prod. +// The convention since t-paliad-098/099 is that paliad migrations land in +// numeric order on a single trunk; the next deploy runs whichever ones are +// pending against the live `public.paliad_schema_migrations` tracker. A +// migration that compiles cleanly but fails on apply (typo, missing column, +// wrong CHECK shape) crashes the Dokploy container loop before paliad.de +// finishes binding :8080, and the only way to learn about it today is to +// watch the deploy log. +// +// TestMigrations_DryRun closes that gap: for every *.up.sql in this +// directory whose version is greater than the scratch DB's current tracker +// version, it opens a transaction, runs the SQL, and ROLLBACKs. Any error +// fails the test with the file name + Postgres error. Always non-destructive +// — the ROLLBACK runs even on success, so the scratch DB stays at its +// starting version. +// +// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB +// tests). Skipped without it. +// +// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1. + +package db + +import ( + "database/sql" + "errors" + "fmt" + "os" + "sort" + "strconv" + "strings" + "testing" + + _ "github.com/lib/pq" +) + +// migration is one *.up.sql file from the embedded migrations FS. +type migration struct { + version int + name string + filename string +} + +// TestMigrations_DryRun walks every pending *.up.sql in numeric order, +// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and +// fails the test on the first SQL error. Reports per-file as a sub-test so +// `go test -v` shows which migration failed. +// +// What "pending" means: greater than the scratch DB's current tracker +// version (or 0 if the tracker doesn't exist yet). In CI against a fresh +// scratch DB, every migration is pending and gets verified. On a developer +// laptop whose scratch DB is already at HEAD, no migrations are pending and +// the test logs the start version and passes — the protection only kicks in +// the moment a new *.up.sql lands in the tree before the developer runs +// `db.ApplyMigrations` against the same scratch DB. +func TestMigrations_DryRun(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set — skipping migration dry-run") + } + + conn, err := sql.Open("postgres", url) + if err != nil { + t.Fatalf("open: %v", err) + } + defer conn.Close() + if err := conn.Ping(); err != nil { + t.Fatalf("ping: %v", err) + } + + // The paliad schema must exist before migration 001 runs against it, + // mirroring the bootstrap step in ApplyMigrations. Without this, a + // fresh scratch DB would fail migration 001's CREATE TABLE paliad.* + // statements inside the BEGIN/ROLLBACK probe with "schema paliad does + // not exist" — a false negative that distracts from real errors. + if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil { + t.Fatalf("ensure paliad schema: %v", err) + } + + startVersion, dirty, err := currentTrackerVersion(conn) + if err != nil { + t.Fatalf("read tracker: %v", err) + } + if dirty { + t.Fatalf("tracker is dirty at version %d — fix that first (DROP the tracker row "+ + "or restore from backup); the dry-run cannot trust a dirty starting state", + startVersion) + } + t.Logf("scratch DB tracker at version %d; walking pending migrations from %d upward", + startVersion, startVersion+1) + + migs, err := loadPendingMigrations(startVersion) + if err != nil { + t.Fatalf("load migrations: %v", err) + } + if len(migs) == 0 { + t.Logf("no pending migrations — scratch DB is at HEAD (%d)", startVersion) + return + } + + for _, m := range migs { + t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) { + body, err := migrationFS.ReadFile("migrations/" + m.filename) + if err != nil { + t.Fatalf("read %s: %v", m.filename, err) + } + tx, err := conn.Begin() + if err != nil { + t.Fatalf("begin: %v", err) + } + // Always rollback; the dry-run must not leave the scratch DB + // at a different version than where it started. Rollback is + // safe to call even after a failed Exec — Postgres aborts the + // transaction internally on the first error. + defer func() { _ = tx.Rollback() }() + + if _, err := tx.Exec(string(body)); err != nil { + t.Fatalf("migration %s failed dry-run: %v", m.filename, err) + } + }) + } +} + +// currentTrackerVersion reads the latest version + dirty flag from the +// `public.paliad_schema_migrations` tracker. Returns (0, false, nil) when the +// tracker doesn't exist yet — that's the "fresh scratch DB" path. +// +// We don't use golang-migrate's API to read this because golang-migrate's +// driver locks the tracker row on read; a test runner that calls this while +// the developer has paliad running locally would race. A plain SELECT is +// race-safe and matches what `psql` would show. +func currentTrackerVersion(conn *sql.DB) (version int, dirty bool, err error) { + const q = `SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1` + row := conn.QueryRow(q) + if scanErr := row.Scan(&version, &dirty); scanErr != nil { + // Missing table → fresh DB → start at 0. lib/pq surfaces this + // as `pq.Error.Code = "42P01"` (undefined_table); the simpler + // sql.ErrNoRows fires if the table exists but is empty (also + // fresh-DB-shaped). + if errors.Is(scanErr, sql.ErrNoRows) { + return 0, false, nil + } + if strings.Contains(scanErr.Error(), "does not exist") { + return 0, false, nil + } + return 0, false, scanErr + } + return version, dirty, nil +} + +// loadPendingMigrations returns every *.up.sql in the embedded FS whose +// version is greater than startVersion, sorted by version ascending. A +// filename like "098_submission_codes_prefix_and_rename.up.sql" yields +// version=98, name="submission_codes_prefix_and_rename". +func loadPendingMigrations(startVersion int) ([]migration, error) { + entries, err := migrationFS.ReadDir("migrations") + if err != nil { + return nil, fmt.Errorf("read migrations dir: %w", err) + } + var out []migration + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".up.sql") { + continue + } + v, n, ok := parseMigrationName(name) + if !ok { + return nil, fmt.Errorf("unparseable migration filename: %s "+ + "(expected NNN_description.up.sql)", name) + } + if v <= startVersion { + continue + } + out = append(out, migration{version: v, name: n, filename: name}) + } + sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version }) + return out, nil +} + +// parseMigrationName splits "NNN_description.up.sql" into (NNN, description). +// Returns ok=false on any deviation from that shape. +func parseMigrationName(filename string) (version int, name string, ok bool) { + base := strings.TrimSuffix(filename, ".up.sql") + if base == filename { // suffix wasn't present + return 0, "", false + } + underscore := strings.IndexByte(base, '_') + if underscore <= 0 { + return 0, "", false + } + v, err := strconv.Atoi(base[:underscore]) + if err != nil { + return 0, "", false + } + return v, base[underscore+1:], true +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 060fe01..2386fea 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -128,6 +128,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc } } + // Liveness probe. Public, no auth, no DB touch — just confirms the + // process bound the listener and the goroutine is alive. Used by the + // boot-smoke test (cmd/server/main_smoke_test.go) to assert the server + // reaches a serving state after migrations apply; also safe for any + // future container orchestrator or uptime check. + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) + }) + // API endpoints (JSON, public) mux.HandleFunc("POST /api/login", handleAPILogin) mux.HandleFunc("POST /api/register", handleAPIRegister)