It has run since the beginning and nothing showed it. A backup nobody can see is a backup nobody knows has been failing, and the failure is quiet: a volume gone read-only, a disk that filled, a container that has not been up at midnight all week. The first anybody hears of it is the day they need one. `ListBackupRuns` was already in the store and exposed nowhere. Now there is a list, a download, and a button that takes one now — the same work the nightly job does without its "one a day" check, because waiting until tonight to learn whether backups work at all is how somebody finds out on the day they need one, and it is the only way to get a snapshot from *before* something you are about to do. Restoring is deliberately not a button. Swapping a database under a server that is writing to it means stopping every writer, replacing the file and reopening, and a half-done version of that destroys the thing it was restoring. The panel says what to do instead. `RunBackupNow` turns out to have been a second copy of the backup logic, written for a button that did not exist yet, and the two had already drifted: it never created the backups directory and logged nothing. One implementation now. Administrators only, and sessions only. A backup is a complete copy of the database, so this hands out everybody's data in one file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
279 lines
8.0 KiB
Go
279 lines
8.0 KiB
Go
package jobs
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
_ "modernc.org/sqlite"
|
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
|
"github.com/kristianwind/verdande/internal/mail"
|
|
"github.com/kristianwind/verdande/internal/realtime"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
func newRunner(t *testing.T) (*Runner, *store.DB, *config.Config) {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
cfg := &config.Config{
|
|
BaseURL: "http://localhost:8080",
|
|
DataDir: dir,
|
|
TrashRetention: 30 * 24 * time.Hour,
|
|
}
|
|
for _, d := range []string{cfg.FilesDir(), cfg.BackupsDir()} {
|
|
if err := os.MkdirAll(d, 0o750); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
db, err := store.Open(cfg.DBPath())
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
return New(cfg, db, log, mail.New(cfg.SMTP, cfg.BaseURL, log), realtime.NewHub(log)), db, cfg
|
|
}
|
|
|
|
func seedUser(t *testing.T, db *store.DB) *store.User {
|
|
t.Helper()
|
|
u := &store.User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
|
|
if err := db.CreateUser(t.Context(), u, "Indbakke"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
// A backup has to be a database somebody can actually open and read — not merely a
|
|
// file of the right size. This is the one piece of the system where a silent
|
|
// failure is only discovered at the worst possible moment.
|
|
func TestBackupProducesAReadableDatabase(t *testing.T) {
|
|
runner, db, cfg := newRunner(t)
|
|
user := seedUser(t, db)
|
|
|
|
inbox, err := db.InboxID(t.Context(), user.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := &store.Task{ProjectID: inbox, Content: "noget der skal overleve", CreatedBy: user.ID}
|
|
if err := db.CreateTask(t.Context(), task, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := runner.RunBackupNow(t.Context()); err != nil {
|
|
t.Fatalf("backup: %v", err)
|
|
}
|
|
|
|
backups := backupFiles(t, cfg)
|
|
if len(backups) != 1 {
|
|
t.Fatalf("got %d backup files, want 1", len(backups))
|
|
}
|
|
|
|
// Open the copy as a database of its own and read the row back out.
|
|
copyDB, err := sql.Open("sqlite", filepath.Join(cfg.BackupsDir(), backups[0]))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer copyDB.Close()
|
|
|
|
var content string
|
|
err = copyDB.QueryRow(`SELECT content FROM tasks WHERE id = ?`, task.ID).Scan(&content)
|
|
if err != nil {
|
|
t.Fatalf("reading the backup: %v", err)
|
|
}
|
|
if content != "noget der skal overleve" {
|
|
t.Errorf("content = %q", content)
|
|
}
|
|
|
|
// And the run was recorded, so the panel can say when it last worked.
|
|
runs, err := db.ListBackupRuns(t.Context(), 10)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(runs) != 1 || runs[0].Error != "" || runs[0].FinishedAt == nil {
|
|
t.Errorf("the run was not recorded as a success: %+v", runs)
|
|
}
|
|
if runs[0].SizeBytes == 0 {
|
|
t.Error("the recorded size is zero")
|
|
}
|
|
}
|
|
|
|
// Writes made after a backup must not appear in it — otherwise the "snapshot" is
|
|
// really a live handle and restoring from it would be unpredictable.
|
|
func TestABackupIsAPointInTime(t *testing.T) {
|
|
runner, db, cfg := newRunner(t)
|
|
user := seedUser(t, db)
|
|
inbox, _ := db.InboxID(t.Context(), user.ID)
|
|
|
|
before := &store.Task{ProjectID: inbox, Content: "før", CreatedBy: user.ID}
|
|
if err := db.CreateTask(t.Context(), before, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := runner.RunBackupNow(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after := &store.Task{ProjectID: inbox, Content: "efter", CreatedBy: user.ID}
|
|
if err := db.CreateTask(t.Context(), after, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
copyDB, err := sql.Open("sqlite", filepath.Join(cfg.BackupsDir(), backupFiles(t, cfg)[0]))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer copyDB.Close()
|
|
|
|
var n int
|
|
if err := copyDB.QueryRow(`SELECT count(*) FROM tasks`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("the backup holds %d tasks, want only the one that existed when it was taken", n)
|
|
}
|
|
}
|
|
|
|
// Fourteen are kept, counted by file rather than by age: a container that was off
|
|
// for a month must not come back and delete every backup it has for being old.
|
|
func TestBackupsRotateByCountNotAge(t *testing.T) {
|
|
runner, _, cfg := newRunner(t)
|
|
|
|
// Twenty files, all timestamped a month ago — older than any age-based cutoff.
|
|
old := time.Now().AddDate(0, -1, 0)
|
|
for i := 0; i < 20; i++ {
|
|
name := backupPrefix + old.Add(time.Duration(i)*time.Hour).UTC().Format("2006-01-02T150405Z") + ".db"
|
|
if err := os.WriteFile(filepath.Join(cfg.BackupsDir(), name), []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if err := rotateBackups(cfg, runner.log); err != nil {
|
|
t.Fatalf("rotate: %v", err)
|
|
}
|
|
|
|
kept := backupFiles(t, cfg)
|
|
if len(kept) != backupKeepDays {
|
|
t.Fatalf("kept %d backups, want %d", len(kept), backupKeepDays)
|
|
}
|
|
// The ones kept are the most recent, not an arbitrary fourteen.
|
|
for i := 1; i < len(kept); i++ {
|
|
if kept[i-1] > kept[i] {
|
|
t.Fatal("the remaining backups are not sorted by time")
|
|
}
|
|
}
|
|
if kept[0] <= backupPrefix+old.UTC().Format("2006-01-02T150405Z") {
|
|
t.Error("an older backup was kept over a newer one")
|
|
}
|
|
}
|
|
|
|
// A file left behind by a failed attempt would look like a backup to anybody
|
|
// reading the directory.
|
|
func TestAFailedBackupLeavesNoFile(t *testing.T) {
|
|
runner, db, cfg := newRunner(t)
|
|
|
|
// Point the snapshot at a path that cannot be written.
|
|
blocked := filepath.Join(cfg.BackupsDir(), "nope")
|
|
if err := os.MkdirAll(blocked, 0o500); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Snapshot(t.Context(), filepath.Join(blocked, "sub", "x.db")); err == nil {
|
|
t.Skip("this filesystem allowed the write; the failure path cannot be exercised here")
|
|
}
|
|
|
|
if err := runner.RunBackupNow(t.Context()); err != nil {
|
|
t.Fatalf("an ordinary backup should still work: %v", err)
|
|
}
|
|
if got := len(backupFiles(t, cfg)); got != 1 {
|
|
t.Errorf("got %d backup files, want 1", got)
|
|
}
|
|
}
|
|
|
|
// Snapshotting onto an existing file would silently produce a corrupt or partial
|
|
// result, so it is refused.
|
|
func TestSnapshotRefusesToOverwrite(t *testing.T) {
|
|
_, db, cfg := newRunner(t)
|
|
|
|
path := filepath.Join(cfg.BackupsDir(), "taken.db")
|
|
if err := os.WriteFile(path, []byte("allerede her"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Snapshot(t.Context(), path); err == nil {
|
|
t.Error("Snapshot overwrote an existing file")
|
|
}
|
|
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != "allerede her" {
|
|
t.Error("the existing file was modified")
|
|
}
|
|
}
|
|
|
|
// The nightly job is checked hourly but must run at most once a day, or every
|
|
// hourly tick would take a full copy.
|
|
func TestTheNightlyBackupRunsAtMostOncePerDay(t *testing.T) {
|
|
runner, _, cfg := newRunner(t)
|
|
ctx := context.Background()
|
|
|
|
if err := runner.nightlyBackup(ctx); err != nil {
|
|
t.Fatalf("first run: %v", err)
|
|
}
|
|
if got := len(backupFiles(t, cfg)); got != 1 {
|
|
t.Fatalf("got %d backups after the first run, want 1", got)
|
|
}
|
|
|
|
// An hour later — the next tick — nothing further should happen.
|
|
if err := runner.nightlyBackup(ctx); err != nil {
|
|
t.Fatalf("second run: %v", err)
|
|
}
|
|
if got := len(backupFiles(t, cfg)); got != 1 {
|
|
t.Errorf("got %d backups after a second tick, want still 1", got)
|
|
}
|
|
}
|
|
|
|
// The runner must stop when its context is cancelled, and Wait must return —
|
|
// otherwise a shutdown hangs until Docker loses patience and kills the process.
|
|
func TestJobsStopOnShutdown(t *testing.T) {
|
|
runner, _, _ := newRunner(t)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
runner.Start(ctx)
|
|
cancel()
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
runner.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("the background jobs did not stop within five seconds of shutdown")
|
|
}
|
|
}
|
|
|
|
func backupFiles(t *testing.T, cfg *config.Config) []string {
|
|
t.Helper()
|
|
entries, err := os.ReadDir(cfg.BackupsDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() && filepath.Ext(e.Name()) == ".db" &&
|
|
len(e.Name()) > len(backupPrefix) && e.Name()[:len(backupPrefix)] == backupPrefix {
|
|
out = append(out, e.Name())
|
|
}
|
|
}
|
|
return out
|
|
}
|