package backup_test import ( "bytes" "context" "errors" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/backup" ) func newService(t *testing.T) (*backup.Service, *pgxpool.Pool) { t.Helper() pool := newPool(t) svc := backup.NewService(pool, slog.New(slog.NewTextHandler(io.Discard, nil)), "test-build") t.Cleanup(svc.Close) return svc, pool } // waitIdle waits for the running backup to finish, for at most 30 seconds. func waitIdle(t *testing.T, svc *backup.Service) { t.Helper() deadline := time.Now().Add(30 * time.Second) for !svc.Running().IsZero() { if time.Now().After(deadline) { t.Fatal("the backup is still running after 30s") } time.Sleep(20 * time.Millisecond) } } func status(t *testing.T, svc *backup.Service) backup.Status { t.Helper() st, err := svc.Status(context.Background()) if err != nil { t.Fatal(err) } return st } func TestABackupIsOffAndRefusesToStartUntilSetUp_Integration(t *testing.T) { svc, _ := newService(t) st := status(t, svc) if st.State != backup.StateOff || st.Config.Enabled || st.NextRun != nil || len(st.Runs) != 0 { t.Errorf("a fresh server's backup = %+v, want off with no runs", st) } if err := svc.Start(context.Background(), backup.TriggerManual); !errors.Is(err, backup.ErrNotReady) { t.Errorf("Start = %v, want ErrNotReady", err) } if err := svc.Save(context.Background(), backup.Update{Enabled: true, Destination: backup.DestinationS3, RunHour: 3}); err != nil { t.Fatal(err) } if st := status(t, svc); st.State != backup.StateIncomplete || len(st.Problems) != 4 { t.Errorf("switched on with no bucket: %s %v", st.State, st.Problems) } } func TestABackupRunsIntoAFolder_Integration(t *testing.T) { svc, _ := newService(t) dir := t.TempDir() ctx := context.Background() if err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: dir, RunHour: 3}); err != nil { t.Fatal(err) } if st := status(t, svc); st.State != backup.StateWaiting || st.NextRun == nil { t.Errorf("set up, never run: %s next %v", st.State, st.NextRun) } if err := svc.Start(ctx, backup.TriggerManual); err != nil { t.Fatal(err) } waitIdle(t, svc) st := status(t, svc) if st.State != backup.StateOK || len(st.Runs) != 1 || st.LastSuccess == nil { t.Fatalf("after a run: %+v", st) } run := st.Runs[0] if run.Status != "succeeded" || run.Trigger != backup.TriggerManual || run.FinishedAt == nil || filepath.Dir(run.Location) != filepath.Join(dir, "database") || run.Error != "" { t.Fatalf("run = %+v", run) } f, err := os.Open(run.Location) if err != nil { t.Fatal(err) } defer func() { _ = f.Close() }() info, _ := f.Stat() if info.Size() != run.Bytes { t.Errorf("recorded %d bytes, the file holds %d", run.Bytes, info.Size()) } m, _, err := backup.Read(f) if err != nil || m.Build != "test-build" { t.Errorf("the backup reads back as %+v, %v", m, err) } res, err := svc.Test(ctx, backup.Update{Destination: backup.DestinationFolder, Folder: dir, RunHour: 3}) if err != nil || res.Backups != 1 || res.Latest == nil { t.Errorf("Test = %+v, %v; want the one backup", res, err) } } func TestAMissingFolderFailsTheRunAndSaysSo_Integration(t *testing.T) { svc, _ := newService(t) ctx := context.Background() missing := filepath.Join(t.TempDir(), "not-mounted") if err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: missing, RunHour: 3}); err != nil { t.Fatal(err) } if _, err := svc.Test(ctx, backup.Update{Destination: backup.DestinationFolder, Folder: missing}); err == nil { t.Error("Test passed on a missing folder") } if err := svc.Start(ctx, backup.TriggerManual); err != nil { t.Fatal(err) } waitIdle(t, svc) st := status(t, svc) if st.State != backup.StateFailing || st.FailingSince == nil || len(st.Runs) != 1 || !strings.Contains(st.Runs[0].Error, "isn't there") { t.Errorf("after a failed run: %s %+v", st.State, st.Runs) } if _, err := os.Stat(missing); !os.IsNotExist(err) { t.Error("the run created the missing folder") } } // fakeBucket answers PUTs and lists like an S3 bucket. While hold is open a // PUT waits on it. type fakeBucket struct { mu sync.Mutex puts map[string][]byte hold chan struct{} } func (b *fakeBucket) ServeHTTP(w http.ResponseWriter, r *http.Request) { if !strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256 Credential=ak/") { w.WriteHeader(http.StatusForbidden) return } switch r.Method { case http.MethodPut: if b.hold != nil { <-b.hold } body, _ := io.ReadAll(r.Body) b.mu.Lock() b.puts[r.URL.Path] = body b.mu.Unlock() case http.MethodGet: b.mu.Lock() defer b.mu.Unlock() _, _ = fmt.Fprint(w, `false`) for p, body := range b.puts { _, _ = fmt.Fprintf(w, `%s%d2026-11-02T03:00:05.000Z`, strings.TrimPrefix(p, "/minstrel/"), len(body)) } _, _ = fmt.Fprint(w, ``) } } func bucketUpdate(endpoint string) backup.Update { return backup.Update{Enabled: true, Destination: backup.DestinationS3, Endpoint: endpoint, Bucket: "minstrel", AccessKey: "ak", SecretKey: "sk", RunHour: 3} } func TestABackupRunsIntoABucket_Integration(t *testing.T) { svc, pool := newService(t) fake := &fakeBucket{puts: map[string][]byte{}} srv := httptest.NewServer(fake) defer srv.Close() ctx := context.Background() if err := svc.Save(ctx, bucketUpdate(srv.URL)); err != nil { t.Fatal(err) } var stored string if err := pool.QueryRow(ctx, "SELECT secret_key FROM backup_settings").Scan(&stored); err != nil { t.Fatal(err) } if !strings.HasPrefix(stored, "v1.") { t.Fatalf("the secret key is stored as %q, want it sealed", stored) } if cfg, _ := svc.Config(ctx); !cfg.HasSecretKey { t.Error("the config doesn't say a secret key is stored") } if err := svc.Start(ctx, backup.TriggerManual); err != nil { t.Fatal(err) } waitIdle(t, svc) st := status(t, svc) if st.State != backup.StateOK || len(st.Runs) != 1 || st.Runs[0].Status != "succeeded" { t.Fatalf("after a run: %s %+v", st.State, st.Runs) } key := st.Runs[0].Location if !strings.HasPrefix(key, "database/minstrel-") || !backup.IsFileName(filepath.Base(key)) { t.Errorf("stored under %q", key) } fake.mu.Lock() body := fake.puts["/minstrel/"+key] fake.mu.Unlock() if int64(len(body)) != st.Runs[0].Bytes { t.Errorf("the bucket got %d bytes, the run recorded %d", len(body), st.Runs[0].Bytes) } if _, _, err := backup.Read(bytes.NewReader(body)); err != nil { t.Errorf("the uploaded backup doesn't read: %v", err) } // The test sends no secret key: it uses the stored one, and only lists. u := bucketUpdate(srv.URL) u.SecretKey = "" res, err := svc.Test(ctx, u) if err != nil || res.Backups != 1 { t.Errorf("Test = %+v, %v; want the one backup", res, err) } fake.mu.Lock() defer fake.mu.Unlock() if len(fake.puts) != 1 { t.Errorf("the test wrote to the bucket: %d objects", len(fake.puts)) } } func TestOneBackupAtATime_Integration(t *testing.T) { svc, _ := newService(t) fake := &fakeBucket{puts: map[string][]byte{}, hold: make(chan struct{})} srv := httptest.NewServer(fake) defer srv.Close() ctx := context.Background() if err := svc.Save(ctx, bucketUpdate(srv.URL)); err != nil { t.Fatal(err) } if err := svc.Start(ctx, backup.TriggerManual); err != nil { t.Fatal(err) } if err := svc.Start(ctx, backup.TriggerManual); !errors.Is(err, backup.ErrRunning) { t.Errorf("a second Start = %v, want ErrRunning", err) } if st := status(t, svc); st.RunningSince == nil { t.Error("the status doesn't say a backup is running") } close(fake.hold) waitIdle(t, svc) } func TestRecoverFailsARunTheLastShutdownCutOff_Integration(t *testing.T) { svc, pool := newService(t) ctx := context.Background() if _, err := pool.Exec(ctx, "INSERT INTO backup_runs (trigger) VALUES ('schedule')"); err != nil { t.Fatal(err) } svc.Recover(ctx) st := status(t, svc) if len(st.Runs) != 1 || st.Runs[0].Status != "failed" || st.Runs[0].FinishedAt == nil || !strings.Contains(st.Runs[0].Error, "server stopped") { t.Errorf("runs = %+v", st.Runs) } } func TestSaveRefusesABadValueAndKeepsTheOldSettings_Integration(t *testing.T) { svc, _ := newService(t) ctx := context.Background() err := svc.Save(ctx, backup.Update{Enabled: true, Destination: backup.DestinationFolder, Folder: "relative/path"}) var fe *backup.FieldError if !errors.As(err, &fe) || fe.Field != "folder" { t.Fatalf("err = %v, want a FieldError on folder", err) } if cfg, _ := svc.Config(ctx); cfg.Enabled || cfg.Folder != "" { t.Errorf("a refused save changed the settings: %+v", cfg) } }