package store import ( "database/sql" "errors" "path/filepath" "testing" _ "modernc.org/sqlite" ) // openRawDB opens a SQLite DB at a fresh tempfile without running any // migrations, so tests can observe migration-runner behaviour directly. func openRawDB(t *testing.T) *sql.DB { t.Helper() path := filepath.Join(t.TempDir(), "state.db") dsn, err := sqliteDSN(path) if err != nil { t.Fatalf("sqliteDSN: %v", err) } db, err := sql.Open("sqlite", dsn) if err != nil { t.Fatalf("sql.Open: %v", err) } t.Cleanup(func() { _ = db.Close() }) return db } func TestRunMigrationsAppliesBaselineOnFreshDB(t *testing.T) { db := openRawDB(t) if err := runMigrations(db); err != nil { t.Fatalf("runMigrations: %v", err) } // All declared migrations must be recorded. for _, m := range migrations { var got string if err := db.QueryRow("SELECT name FROM schema_migrations WHERE id = ?", m.id).Scan(&got); err != nil { t.Fatalf("migration %d not recorded: %v", m.id, err) } if got != m.name { t.Errorf("migration %d name = %q, want %q", m.id, got, m.name) } } // Baseline must have created the real tables. for _, table := range []string{"images", "vms"} { var name string if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name); err != nil { t.Fatalf("table %s missing: %v", table, err) } } } func TestRunMigrationsIsIdempotent(t *testing.T) { db := openRawDB(t) if err := runMigrations(db); err != nil { t.Fatalf("runMigrations first pass: %v", err) } if err := runMigrations(db); err != nil { t.Fatalf("runMigrations second pass: %v", err) } // One row per migration, no duplicates. var count int if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { t.Fatalf("count: %v", err) } if count != len(migrations) { t.Errorf("schema_migrations rows = %d, want %d", count, len(migrations)) } } func TestRunMigrationsSkipsAlreadyApplied(t *testing.T) { db := openRawDB(t) // Swap in a test-only migration whose body would error if invoked, // pre-insert its id into schema_migrations, and confirm the runner // recognises the marker and skips the body entirely. orig := migrations t.Cleanup(func() { migrations = orig }) migrations = []migration{ {id: 1, name: "baseline", up: migrateBaseline}, {id: 99, name: "explodes-if-run", up: func(*sql.Tx) error { return errors.New("must not execute") }}, } if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL )`); err != nil { t.Fatalf("seed schema_migrations table: %v", err) } if _, err := db.Exec( "INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)", 99, "explodes-if-run", "2026-04-20T00:00:00Z", ); err != nil { t.Fatalf("seed applied row: %v", err) } if err := runMigrations(db); err != nil { t.Fatalf("runMigrations: %v", err) } } func TestApplyMigrationRollsBackOnBodyError(t *testing.T) { db := openRawDB(t) if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL )`); err != nil { t.Fatalf("seed schema_migrations: %v", err) } err := applyMigration(db, migration{ id: 7, name: "creates-then-fails", up: func(tx *sql.Tx) error { if _, err := tx.Exec("CREATE TABLE transient (x INTEGER)"); err != nil { return err } return errors.New("synthetic failure") }, }) if err == nil { t.Fatal("expected applyMigration to surface body error") } // The transient table must NOT survive the failed migration. var name string if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='transient'").Scan(&name); err == nil { t.Fatal("transient table survived rollback") } // And no schema_migrations row for id=7. var count int if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE id=7").Scan(&count); err != nil { t.Fatalf("count: %v", err) } if count != 0 { t.Fatalf("schema_migrations recorded failed migration: count=%d", count) } } // TestMigrateDropDeadImageColumns_AcrossInstallPaths verifies the // drop-column migration is correct on both paths it can land on: // a fresh install (baseline created the column, migration 2 drops // it) and a legacy DB that somehow lost or never had the column // (migration 2 is a no-op). Runs migrations end-to-end so the // invariant-check is the real system, not the helper in isolation. func TestMigrateDropDeadImageColumns_AcrossInstallPaths(t *testing.T) { hasColumn := func(t *testing.T, db *sql.DB, table, column string) bool { t.Helper() rows, err := db.Query("PRAGMA table_info(" + table + ")") if err != nil { t.Fatalf("PRAGMA table_info: %v", err) } defer rows.Close() for rows.Next() { var ( cid int name string valueType string notNull int defaultV sql.NullString pk int ) if err := rows.Scan(&cid, &name, &valueType, ¬Null, &defaultV, &pk); err != nil { t.Fatalf("scan table_info row: %v", err) } if name == column { return true } } if err := rows.Err(); err != nil { t.Fatalf("rows.Err: %v", err) } return false } t.Run("fresh install drops packages_path", func(t *testing.T) { db := openRawDB(t) if err := runMigrations(db); err != nil { t.Fatalf("runMigrations: %v", err) } if hasColumn(t, db, "images", "packages_path") { t.Fatal("packages_path column survived migration 2 on fresh install") } }) t.Run("legacy DB without column is a no-op", func(t *testing.T) { db := openRawDB(t) // Simulate a DB whose baseline was applied against a modified // schema that never had packages_path: seed schema_migrations, // run baseline, drop the column out-of-band, then run // runMigrations and expect migration 2 to succeed regardless. if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL )`); err != nil { t.Fatalf("seed schema_migrations: %v", err) } if err := applyMigration(db, migrations[0]); err != nil { t.Fatalf("apply baseline: %v", err) } if _, err := db.Exec("ALTER TABLE images DROP COLUMN packages_path"); err != nil { t.Fatalf("pre-drop packages_path: %v", err) } if err := runMigrations(db); err != nil { t.Fatalf("runMigrations after manual pre-drop: %v", err) } if hasColumn(t, db, "images", "packages_path") { t.Fatal("packages_path reappeared after runMigrations") } }) } // TestOpenReadOnlyDoesNotRunMigrations pins the doctor contract: // OpenReadOnly must not mutate the DB. We create a DB without the // schema_migrations row for migration 2 present (simulating a // daemon-not-yet-run state), open it read-only, and confirm no row // was added and no column dropped. func TestOpenReadOnlyDoesNotRunMigrations(t *testing.T) { path := filepath.Join(t.TempDir(), "state.db") // Seed the file by running full Open once, then roll migration 2 // backwards manually so the DB is "behind" current code. full, err := Open(path) if err != nil { t.Fatalf("Open: %v", err) } if _, err := full.db.Exec("ALTER TABLE images ADD COLUMN packages_path TEXT"); err != nil { t.Fatalf("re-add packages_path: %v", err) } if _, err := full.db.Exec("DELETE FROM schema_migrations WHERE id = 2"); err != nil { t.Fatalf("remove migration 2 marker: %v", err) } _ = full.Close() ro, err := OpenReadOnly(path) if err != nil { t.Fatalf("OpenReadOnly: %v", err) } defer ro.Close() // Migration 2 marker must still be absent; packages_path must // still exist. var migCount int if err := ro.db.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE id = 2").Scan(&migCount); err != nil { t.Fatalf("query schema_migrations: %v", err) } if migCount != 0 { t.Fatal("OpenReadOnly recorded migration 2 — the open path mutated the DB") } rows, err := ro.db.Query("PRAGMA table_info(images)") if err != nil { t.Fatalf("PRAGMA table_info: %v", err) } defer rows.Close() var sawColumn bool for rows.Next() { var ( cid int name string valueType string notNull int defaultV sql.NullString pk int ) if err := rows.Scan(&cid, &name, &valueType, ¬Null, &defaultV, &pk); err != nil { t.Fatalf("scan: %v", err) } if name == "packages_path" { sawColumn = true } } if !sawColumn { t.Fatal("packages_path disappeared — OpenReadOnly ran the drop migration") } } // TestOpenReadOnlyRefusesWrites confirms SQLite's mode=ro is in effect // — no matter what a caller tries, writes are rejected at the driver // level. Belt-and-braces guard against a future refactor that might // plumb a write method through. func TestOpenReadOnlyRefusesWrites(t *testing.T) { path := filepath.Join(t.TempDir(), "state.db") if s, err := Open(path); err != nil { t.Fatalf("seed Open: %v", err) } else { _ = s.Close() } ro, err := OpenReadOnly(path) if err != nil { t.Fatalf("OpenReadOnly: %v", err) } defer ro.Close() if _, err := ro.db.Exec("INSERT INTO schema_migrations (id, name, applied_at) VALUES (999, 'x', 'x')"); err == nil { t.Fatal("write succeeded against a read-only store") } } func TestRunMigrationsRejectsDuplicateID(t *testing.T) { db := openRawDB(t) orig := migrations t.Cleanup(func() { migrations = orig }) migrations = []migration{ {id: 1, name: "first", up: func(*sql.Tx) error { return nil }}, {id: 1, name: "dupe", up: func(*sql.Tx) error { return nil }}, } err := runMigrations(db) if err == nil { t.Fatal("expected error for duplicate migration id") } }