banger/internal/store/migrations_test.go
Thales Maciel 7820960706
store: edge-path tests for migrations and Open
Three gaps from the coverage plan, none of which were covered before.

internal/store/migrations_test.go:

  TestRunMigrationsIgnoresUnknownAppliedIDs — simulates a DB
  written by a newer banger opened by an older one: schema_migrations
  carries an id (9001) the current binary doesn't know about. The
  runner must leave the alien row alone AND still apply its own
  known migrations. Without this, forward-then-backward upgrades or
  running two daemon versions against the same state dir would
  either fail or start destructively reinterpreting rows.

  TestDropColumnIfExistsIsIdempotent — pins the "run twice, no harm"
  property. A daemon restart after migration 2 succeeded on a fresh
  install must not fail because the column is already gone.
  dropColumnIfExists is what makes that idempotent.

internal/store/store_test.go:

  TestOpenRejectsCorruptDB — writes garbage to state.db, Open must
  error cleanly (not panic, not silently overwrite). Also verifies
  the garbage bytes are untouched so the operator can hand the
  file to a recovery tool.

  TestOpenReadOnlyRejectsMissingDB — the doctor path must not
  silently create an empty DB when none exists; that would make
  "no VMs yet" and "your state is missing" indistinguishable.

Package function coverage nudged 39.1% → 40.1%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:51:52 -03:00

432 lines
13 KiB
Go

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, &notNull, &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, &notNull, &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")
}
}
// TestRunMigrationsIgnoresUnknownAppliedIDs simulates an older banger
// opening a DB that was written by a newer version: schema_migrations
// carries rows with ids the current binary's migrations slice doesn't
// know about. The runner must leave those rows alone and still apply
// any of its own known migrations that haven't been recorded yet.
//
// Without this behaviour, upgrading forward then downgrading back
// (or running two daemon versions against the same state dir) would
// either fail outright or start destructively reinterpreting rows.
func TestRunMigrationsIgnoresUnknownAppliedIDs(t *testing.T) {
db := openRawDB(t)
// Bootstrap schema_migrations and pre-seed a row for a migration
// id the current binary doesn't know. Use a high id so it's
// clearly outside our slice.
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 := db.Exec(
"INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)",
9001, "from-the-future", "2099-01-01T00:00:00Z",
); err != nil {
t.Fatalf("seed future migration row: %v", err)
}
if err := runMigrations(db); err != nil {
t.Fatalf("runMigrations: %v", err)
}
// The alien row is untouched.
var name string
if err := db.QueryRow("SELECT name FROM schema_migrations WHERE id = 9001").Scan(&name); err != nil {
t.Fatalf("alien migration row disappeared: %v", err)
}
if name != "from-the-future" {
t.Fatalf("alien row name = %q, want 'from-the-future'", name)
}
// Every known migration in our slice was applied — their rows
// should exist too.
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 despite unknown alien row: %v", m.id, err)
}
}
}
// TestDropColumnIfExistsIsIdempotent pins the "run twice, no harm"
// property. A daemon that restarts after a successful migration 2
// on a fresh install shouldn't fail because the column is already
// gone. migrateDropDeadImageColumns calls dropColumnIfExists, which
// must silently succeed when the column is absent.
func TestDropColumnIfExistsIsIdempotent(t *testing.T) {
db := openRawDB(t)
// Set up a tiny table with a known column we're going to drop.
if _, err := db.Exec(`CREATE TABLE throwaway (keeper TEXT, victim TEXT)`); err != nil {
t.Fatalf("CREATE: %v", err)
}
run := func(label string) error {
tx, err := db.Begin()
if err != nil {
t.Fatalf("%s Begin: %v", label, err)
}
if err := dropColumnIfExists(tx, "throwaway", "victim"); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
if err := run("first"); err != nil {
t.Fatalf("first dropColumnIfExists: %v", err)
}
// Second call against a table that no longer has the column.
if err := run("second"); err != nil {
t.Fatalf("second dropColumnIfExists (column already gone): %v", err)
}
// The keeper column must still be there; victim is gone.
rows, err := db.Query("PRAGMA table_info(throwaway)")
if err != nil {
t.Fatalf("PRAGMA: %v", err)
}
defer rows.Close()
var haveKeeper, haveVictim 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, &notNull, &defaultV, &pk); err != nil {
t.Fatalf("scan: %v", err)
}
switch name {
case "keeper":
haveKeeper = true
case "victim":
haveVictim = true
}
}
if !haveKeeper {
t.Fatal("keeper column disappeared — dropColumnIfExists is too aggressive")
}
if haveVictim {
t.Fatal("victim column survived — dropColumnIfExists didn't actually drop")
}
}
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")
}
}