`banger doctor` used to call store.Open, which unconditionally runs migrations on the way up. Diagnostics mutating persistent state is a surprise — particularly now that migration 2 drops a column, so a plain `doctor` invocation against an old DB would silently schema- evolve it. Add store.OpenReadOnly: separate DSN builder with mode=ro and a minimal pragma set (foreign_keys, busy_timeout — no journal_mode=WAL, no wal_autocheckpoint), skips runMigrations, and pings on open so a missing DB fails up front rather than at first query. doctor.go now uses OpenReadOnly; the existing storeErr fallback path surfaces any failure as a failing check, unchanged. Tests pin two invariants: - OpenReadOnly against a DB whose migration 2 marker was removed and packages_path re-added must leave both alone (i.e. no drift is applied behind the user's back). - Any write attempted through the read-only handle is rejected at the driver layer (belt-and-braces for future refactors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
314 lines
9.5 KiB
Go
314 lines
9.5 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, ¬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")
|
|
}
|
|
}
|