Vellum/internal/core/migrate_test.go
mithral 35e81400f2 Refuse to start when the database is newer than the binary
Migrate only skipped migrations whose number was already applied (n <=
current). Point an older binary at a database a newer binary had already
migrated and it started happily, then read columns that had moved. For a
self-hosted app that is the worst failure mode: silent schema confusion
rather than a clean refusal.

Migrate now errors when PRAGMA user_version exceeds the highest embedded
migration, naming both numbers. Both callers already propagate the error,
so the binary exits at startup instead.

Verified red-green: removing the guard fails
TestMigrateRefusesDatabaseNewerThanBinary.
2026-10-12 00:45:27 -04:00

191 lines
5 KiB
Go

package core
import (
"context"
"database/sql"
"fmt"
"io"
"io/fs"
"log/slog"
"path/filepath"
"strings"
"testing"
"github.com/mithral/vellum/migrations"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// highestMigrationNumber reads the embedded migrations so the version
// assertion does not need editing every time a migration is added.
func highestMigrationNumber(t *testing.T) int {
t.Helper()
names, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
highest := 0
for _, name := range names {
n, err := migrationNumber(name)
if err != nil {
t.Fatal(err)
}
if n > highest {
highest = n
}
}
return highest
}
func openTempDB(t *testing.T) *sql.DB {
t.Helper()
db, err := OpenDB(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func TestMigrateAppliesAndIsIdempotent(t *testing.T) {
ctx := context.Background()
db := openTempDB(t)
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
t.Fatalf("first Migrate: %v", err)
}
// Running again must be a no-op, not an error.
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
t.Fatalf("second Migrate (should be no-op): %v", err)
}
var version int
if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil {
t.Fatalf("read user_version: %v", err)
}
want := highestMigrationNumber(t)
if version != want {
t.Errorf("user_version = %d, want %d", version, want)
}
var tables int
if err := db.QueryRowContext(ctx,
"SELECT count(*) FROM sqlite_master WHERE type='table'").Scan(&tables); err != nil {
t.Fatalf("count tables: %v", err)
}
if tables < 16 {
t.Errorf("table count = %d, want >= 16", tables)
}
// WAL must be active: progress writes during a scan depend on it.
var mode string
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatalf("read journal_mode: %v", err)
}
if !strings.EqualFold(mode, "wal") {
t.Errorf("journal_mode = %q, want wal", mode)
}
}
func TestForeignKeyEnforcementIsOn(t *testing.T) {
ctx := context.Background()
db := openTempDB(t)
if err := Migrate(ctx, db, migrations.FS, discardLogger()); err != nil {
t.Fatalf("Migrate: %v", err)
}
// library_grants references users and libraries; neither 999 exists.
_, err := db.ExecContext(ctx,
"INSERT INTO library_grants(user_id, library_id) VALUES (999, 999)")
if err == nil {
t.Fatal("expected foreign key violation, got nil")
}
}
func TestMigrateRefusesDatabaseNewerThanBinary(t *testing.T) {
ctx := context.Background()
db := openTempDB(t)
// A database migrated by a newer binary.
newer := highestMigrationNumber(t) + 1
if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", newer)); err != nil {
t.Fatalf("set user_version: %v", err)
}
err := Migrate(ctx, db, migrations.FS, discardLogger())
if err == nil {
t.Fatal("expected error when user_version is above the highest embedded migration, got nil")
}
if !strings.Contains(err.Error(), fmt.Sprintf("version %d", newer)) {
t.Errorf("error should name the database version, got: %v", err)
}
}
func TestMigrationNumber(t *testing.T) {
cases := map[string]int{
"0001_core.sql": 1,
"0042_audiobooks.sql": 42,
"7_shorthand.sql": 7,
"0010_no_ext": 10,
}
for name, want := range cases {
got, err := migrationNumber(name)
if err != nil {
t.Errorf("migrationNumber(%q): %v", name, err)
continue
}
if got != want {
t.Errorf("migrationNumber(%q) = %d, want %d", name, got, want)
}
}
if _, err := migrationNumber("core.sql"); err == nil {
t.Error("expected error for filename without a numeric prefix")
}
}
func TestSplitStatementsIgnoresComments(t *testing.T) {
in := `
-- a comment with a semicolon; which must not split
CREATE TABLE a (id INTEGER); -- trailing comment;
/* block; comment; here */
CREATE TABLE b (id INTEGER);
`
got := splitStatements(in)
if len(got) != 2 {
t.Fatalf("got %d statements, want 2: %#v", len(got), got)
}
if !strings.Contains(got[0], "CREATE TABLE a") {
t.Errorf("statement 0 = %q", got[0])
}
if !strings.Contains(got[1], "CREATE TABLE b") {
t.Errorf("statement 1 = %q", got[1])
}
}
func TestSplitStatementsKeepsTriggerBodiesWhole(t *testing.T) {
in := `
CREATE TABLE t (id INTEGER, name TEXT);
CREATE TRIGGER t_ai AFTER INSERT ON t BEGIN
INSERT INTO t(name) VALUES (new.name);
UPDATE t SET name = 'x' WHERE id = new.id;
END;
CREATE TABLE after_trigger (id INTEGER);
`
got := splitStatements(in)
if len(got) != 3 {
t.Fatalf("got %d statements, want 3:\n%#v", len(got), got)
}
if !strings.Contains(got[1], "CREATE TRIGGER") || !strings.Contains(got[1], "END") {
t.Errorf("trigger was split apart: %q", got[1])
}
if n := strings.Count(got[1], ";"); n != 2 {
t.Errorf("trigger body has %d semicolons, want 2 (the internal ones)", n)
}
if !strings.Contains(got[2], "after_trigger") {
t.Errorf("statement after the trigger = %q", got[2])
}
}