package lease import ( "context" "database/sql" "errors" "os" "path/filepath" "testing" _ "modernc.org/sqlite" ) // TestCheckDB pins the three verdicts a restored file can earn. Ported from // ops.TestCheckRestoredDBRejectsEmptySchema, which now runs against this // implementation through the delegating wrapper. func TestCheckDB(t *testing.T) { dir := t.TempDir() good := filepath.Join(dir, "good.db") db, err := sql.Open("sqlite", good) if err != nil { t.Fatal(err) } if _, err := db.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (1)"); err != nil { t.Fatal(err) } db.Close() if err := CheckDB(good); err != nil { t.Fatalf("CheckDB(good) = %v, want nil", err) } // A schema-less database restores "successfully" but would be useless in a // disaster — the check must refuse it. empty := filepath.Join(dir, "empty.db") db, err = sql.Open("sqlite", empty) if err != nil { t.Fatal(err) } if _, err := db.Exec("PRAGMA user_version=1"); err != nil { // force file creation t.Fatal(err) } db.Close() if err := CheckDB(empty); err == nil { t.Fatal("CheckDB accepted a database with no tables") } // Garbage bytes must fail loudly, not pass as an open-able file. junk := filepath.Join(dir, "junk.db") if err := os.WriteFile(junk, []byte("this is not a sqlite database at all"), 0o644); err != nil { t.Fatal(err) } if err := CheckDB(junk); err == nil { t.Fatal("CheckDB accepted a non-SQLite file") } // A file that is not there at all is a failure, never a silent pass. if err := CheckDB(filepath.Join(dir, "absent.db")); err == nil { t.Fatal("CheckDB accepted a missing file") } } // TestCheckDBDetectsPageCorruption is the case the whole verification pass // exists for: a file that opens fine and has a schema, but whose pages are // damaged. integrity_check is the only thing that catches it. func TestCheckDBDetectsPageCorruption(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "corrupt.db") db, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } // No WAL: the damage below must land in the main database file, not in a // sidecar the reopen would replay over. if _, err := db.Exec("PRAGMA journal_mode=DELETE"); err != nil { t.Fatal(err) } if _, err := db.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, body TEXT)"); err != nil { t.Fatal(err) } for i := 0; i < 2000; i++ { if _, err := db.Exec("INSERT INTO t (body) VALUES (?)", "row padding to force several pages"); err != nil { t.Fatal(err) } } db.Close() if fi, err := os.Stat(path); err != nil { t.Fatal(err) } else if fi.Size() < 3*4096 { t.Fatalf("test fixture is only %d bytes — too few pages to corrupt one meaningfully", fi.Size()) } if err := CheckDB(path); err != nil { t.Fatalf("CheckDB on the intact file = %v, want nil", err) } // Scribble over page 2's header — its page type and cell-pointer array — // leaving the file length and page 1 (the schema) intact so the open still // succeeds and only integrity_check can tell. f, err := os.OpenFile(path, os.O_WRONLY, 0o644) if err != nil { t.Fatal(err) } junk := make([]byte, 512) for i := range junk { junk[i] = 0xA5 } if _, err := f.WriteAt(junk, 4096); err != nil { t.Fatal(err) } f.Close() if err := CheckDB(path); err == nil { t.Fatal("CheckDB passed a file with a scribbled-over page") } } // The restored file is WAL-journaled — that is what instances write and what // litestream restores — so the check must pass one. This is the shape both // callers actually hand it, and the box-wide drill has been opening exactly // this way in production daily. // // It also pins what the "mode=ro" in the DSN does NOT buy: the driver still // attaches WAL sidecars. That is harmless at both call sites and neither may // come to depend on the opposite — the wake path calls this holding the // lease, on a file it has just restored with the sidecars already cleared // (nothing else has the database open), and VerifyReplica works on a // throwaway copy it deletes, sidecars included. func TestCheckDBAcceptsWALDatabase(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "wal.db") seedDB(t, path) // WAL mode, 50 rows — the lease package's own fixture // litestream restores a checkpointed file with no live -wal beside it; // mimic that so this tests the shape the wake path actually sees. for _, p := range []string{path + "-wal", path + "-shm"} { _ = os.Remove(p) } if err := CheckDB(path); err != nil { t.Fatalf("CheckDB on a WAL-mode restore = %v, want nil", err) } } // VerifyReplica must leave nothing behind — including the WAL sidecars the // check attaches. A drill that littered the box with restored copies of every // tenant database would be a disk-usage bug and a data-exposure one. func TestVerifyReplicaCleansUpEvenOnFailure(t *testing.T) { dir := t.TempDir() // A restore that "succeeds" into a corrupt file: the check fails, and the // wreckage must still be swept. prev := restoreForVerify restoreForVerify = func(_ context.Context, out, _, _, _ string) (bool, error) { if err := os.WriteFile(out, []byte("not a database"), 0o600); err != nil { return false, err } return true, nil } t.Cleanup(func() { restoreForVerify = prev }) if _, err := VerifyReplica(context.Background(), "bucket", "hib/x.example.com", "", dir); err == nil { t.Fatal("VerifyReplica accepted a corrupt restore") } left, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } if len(left) != 0 { names := []string{} for _, e := range left { names = append(names, e.Name()) } t.Fatalf("VerifyReplica left %v behind", names) } } // A prefix that has never been replicated to is ErrNoReplica — "nothing to // restore yet", which callers must not report as a failure. func TestVerifyReplicaReportsNoReplica(t *testing.T) { prev := restoreForVerify restoreForVerify = func(context.Context, string, string, string, string) (bool, error) { return false, nil } t.Cleanup(func() { restoreForVerify = prev }) _, err := VerifyReplica(context.Background(), "bucket", "hib/new.example.com", "", t.TempDir()) if !errors.Is(err, ErrNoReplica) { t.Fatalf("VerifyReplica on a never-replicated prefix = %v, want ErrNoReplica", err) } } // VerifyReplica hands back the restored copy's change counter, which is what // lets the hibernation drill ask the second question — is this replica still // TRACKING its source? — after the throwaway copy has been swept. func TestVerifyReplicaReturnsTheRestoredChangeCounter(t *testing.T) { dir := t.TempDir() src := filepath.Join(t.TempDir(), "source.db") seedDB(t, src) want, err := ChangeCounter(src) if err != nil { t.Fatal(err) } prev := restoreForVerify restoreForVerify = func(_ context.Context, out, _, _, _ string) (bool, error) { body, err := os.ReadFile(src) if err != nil { return false, err } return true, os.WriteFile(out, body, 0o600) } t.Cleanup(func() { restoreForVerify = prev }) got, err := VerifyReplica(context.Background(), "bucket", "hib/x.example.com", "", dir) if err != nil { t.Fatalf("VerifyReplica: %v", err) } if got != want { t.Fatalf("restored change counter = %d, want %d", got, want) } } // A stop reaches the integrity scan: CheckDBContext returns the context's // error instead of reading the whole file first. func TestCheckDBContextHonoursCancellation(t *testing.T) { path := filepath.Join(t.TempDir(), "c.db") seedDB(t, path) if err := CheckDBContext(context.Background(), path); err != nil { t.Fatalf("live context: %v", err) } ctx, cancel := context.WithCancel(context.Background()) cancel() if err := CheckDBContext(ctx, path); !errors.Is(err, context.Canceled) { t.Fatalf("cancelled context = %v, want context.Canceled", err) } }