quicksetup_hold_corrupt_rebuild_test.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"context"
"crypto/rand"
"database/sql"
"os"
"path/filepath"
"testing"
"time"
"github.com/pingular/pingularity/internal/config"
"github.com/pingular/pingularity/internal/settings"
"github.com/pingular/pingularity/internal/store"
)
// tearDatabase leaves the SQLite header intact and overwrites everything after
// it - a torn file, the fault the store's set-aside recovery exists for.
func tearDatabase(t *testing.T, path string) {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if _, err := rand.Read(b[100:]); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatal(err)
}
}
// TestCorruptRebuildDoesNotReholdAnEstablishedInstall: asked for the rebuild
// (`-on-corrupt rebuild`, which is what the reopens here arm - without it the
// start refuses and there is no rebuilt store to rehold), the store sets a torn
// database aside and rebuilds an empty one "so the daemon comes back up
// monitoring". Booted the way the packaged unit and the container boot it
// otherwise - `run -db <path>`, no consent flag - the first-run decision then
// saw an empty store, seeded a fresh offer clock, and held monitoring for the
// whole 48h consent grace, on an install that had consented long ago. The
// daemon holds the evidence that this is no first run: it just moved a
// database aside.
func TestCorruptRebuildDoesNotReholdAnEstablishedInstall(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "pingularity.db")
// An established install that answered Quick Setup and has been measuring.
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
var sms []store.Sample
for i := 0; i < 20; i++ {
sms = append(sms, store.Sample{TS: time.Now().Add(-time.Duration(i+1) * time.Second), Target: "cloudflare", Family: "ipv4", LatencyMS: 12, Success: true})
}
if err := st.InsertSamples(ctx, sms); err != nil {
t.Fatalf("seed history: %v", err)
}
set, err := settings.New(ctx, st, testDefaultsFor(config.Config{}))
if err != nil {
t.Fatalf("settings: %v", err)
}
if err := set.SetQuickSetupDone(ctx, true); err != nil {
t.Fatalf("mark answered: %v", err)
}
if got := quickSetupHoldState(ctx, set); got != qsReleased {
t.Fatalf("fixture: an answered install reads %v, want released", got)
}
st.Close()
// The disk fault, then the restart in the packaged shape.
tearDatabase(t, dbPath)
cfg := config.Config{DBPath: dbPath}
created := dbCreatedNow(dbPath) // what Start records: the file was there
st2, err := store.Open(dbPath, store.RebuildOnCorruption())
if err != nil {
t.Fatalf("reopen after the fault: %v", err)
}
defer st2.Close()
if matches, _ := filepath.Glob(dbPath + ".*.corrupt"); len(matches) == 0 {
t.Fatal("fixture: the torn database was not set aside")
}
set2, err := settings.New(ctx, st2, testDefaultsFor(cfg), settings.WithBornVersion("test"), settings.WithDatabaseCreated(created))
if err != nil {
t.Fatalf("settings after the rebuild: %v", err)
}
p := &program{cfg: cfg}
p.materializeQuickSetup(ctx, set2, func(msg string, e error) { t.Logf("%s: %v", msg, e) })
if got := quickSetupHoldState(ctx, set2); got != qsReleased {
t.Fatalf("after its corrupt database was set aside and rebuilt, the install is back on the first-run hold (%v, want released): nothing is measured for up to 48h while /healthz answers 200, on an install that consented long ago", got)
}
if !newMonitoringLiveFn(ctx, set2, nil)() {
t.Fatal("monitoring is not live after the rebuild")
}
}
// tearTableRoot trashes one table's b-tree root page in a closed database and
// leaves the rest of the file - header, settings table and all - intact: a
// single bad sector rather than a wiped file, so what the old database still
// says about itself can be read after the fault.
func tearTableRoot(t *testing.T, path, table string) {
t.Helper()
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
var root int
err = db.QueryRow(`SELECT rootpage FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&root)
db.Close()
if err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
ps := int(b[16])<<8 | int(b[17])
if ps == 1 {
ps = 65536
}
off := (root - 1) * ps
if root < 2 || off+ps > len(b) {
t.Fatalf("%s sits on page %d of a %d-byte file", table, root, len(b))
}
for i := 0; i < ps; i++ {
b[off+i] = 0xFF
}
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatal(err)
}
}
// The mirror of the test above, and the reason the rebuild does not simply
// declare every rebuilt store established. An install whose database is torn
// three hours after it was installed has answered nothing: releasing its hold
// would start it probing on the defaults and mark a Quick Setup dialog answered
// that nobody was ever shown. When the old file survives well enough to say so,
// the hold stays.
func TestCorruptRebuildKeepsAFreshInstallOnItsHold(t *testing.T) {
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "pingularity.db")
cfg := config.Config{DBPath: dbPath}
// A first boot in the packaged shape: no history, no consent flag, so the
// offer clock is seeded and monitoring is held.
st, err := store.Open(dbPath)
if err != nil {
t.Fatalf("open: %v", err)
}
set, err := settings.New(ctx, st, testDefaultsFor(cfg), settings.WithBornVersion("test"), settings.WithDatabaseCreated(true))
if err != nil {
t.Fatalf("settings: %v", err)
}
p := &program{cfg: cfg}
p.materializeQuickSetup(ctx, set, func(msg string, e error) { t.Logf("%s: %v", msg, e) })
if got := quickSetupHoldState(ctx, set); got != qsHeld {
t.Fatalf("fixture: a fresh install reads %v, want held", got)
}
st.Close()
// The disk fault, then the restart in the same shape.
tearTableRoot(t, dbPath, "pauses")
st2, err := store.Open(dbPath, store.RebuildOnCorruption())
if err != nil {
t.Fatalf("reopen after the fault: %v", err)
}
defer st2.Close()
if matches, _ := filepath.Glob(dbPath + ".*.corrupt"); len(matches) == 0 {
t.Fatal("fixture: the torn database was not set aside")
}
set2, err := settings.New(ctx, st2, testDefaultsFor(cfg), settings.WithBornVersion("test"), settings.WithDatabaseCreated(dbCreatedNow(dbPath)))
if err != nil {
t.Fatalf("settings after the rebuild: %v", err)
}
p.materializeQuickSetup(ctx, set2, func(msg string, e error) { t.Logf("%s: %v", msg, e) })
if got := quickSetupHoldState(ctx, set2); got != qsHeld {
t.Fatalf("a fresh install that never answered Quick Setup came back from a disk fault %v (want held): it starts probing on the defaults and is never shown the dialog", got)
}
if newMonitoringLiveFn(ctx, set2, nil)() {
t.Fatal("monitoring is live on an install that never answered Quick Setup")
}
}