package backup import ( "context" "os" "path/filepath" "strings" "testing" "time" "jarl/internal/store" ) func newDB(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "jarl.db") st, err := store.Open(path) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) return path } func TestWriteVerifyInspect(t *testing.T) { ctx := context.Background() src := newDB(t) dir := filepath.Join(t.TempDir(), "backups") now := time.Date(2026, 10, 8, 3, 15, 12, 0, time.UTC) f, err := Write(ctx, src, dir, KindJarl, now, "") if err != nil { t.Fatal(err) } if filepath.Base(f.Path) != "jarl-20261008T031512Z.db" || f.Size == 0 { t.Errorf("backup = %+v", f) } if fi, _ := os.Stat(f.Path); fi.Mode().Perm() != 0o600 { t.Errorf("mode %v", fi.Mode()) } if err := Verify(ctx, f.Path); err != nil { t.Error(err) } in, err := Inspect(ctx, f.Path) if err != nil || in.Schema != store.SchemaVersion() { t.Errorf("inspect: %+v %v", in, err) } // The same second again is refused rather than overwritten. if _, err := Write(ctx, src, dir, KindJarl, now, ""); err == nil { t.Error("overwrote a backup") } // No temporary files are left. ents, _ := os.ReadDir(dir) if len(ents) != 1 { t.Errorf("files: %v", ents) } // A missing database is an error, and isn't created. missing := filepath.Join(t.TempDir(), "nope.db") if _, err := Write(ctx, missing, dir, KindJarl, now.Add(time.Second), ""); err == nil { t.Error("backed up a missing database") } if _, err := os.Stat(missing); err == nil { t.Error("created the missing database") } } func TestVerifyCorrupt(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "bad.db") os.WriteFile(path, []byte("SQLite format 3\x00 but not really a database, just text that goes on"), 0o600) if err := Verify(ctx, path); err == nil { t.Error("a corrupt file passed") } if _, err := Inspect(ctx, path); err == nil { t.Error("inspected a corrupt file") } } func TestPrune(t *testing.T) { dir := t.TempDir() now := time.Date(2026, 10, 8, 3, 0, 0, 0, time.UTC) touch := func(name string) { os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o600) } // Daily backups from 0 to 39 days old, plus files that aren't ours. for d := 0; d < 40; d++ { touch(Name(KindJarl, now.Add(-time.Duration(d)*24*time.Hour))) } touch(Name(KindStats, now.Add(-100*24*time.Hour))) touch("jarl-before-upgrade.db") touch("jarl-20200101T000000Z.db.bak") touch("notes.txt") removed, err := Prune(dir, KindJarl, now, 30*24*time.Hour, 7) if err != nil { t.Fatal(err) } if len(removed) != 9 { // 31..39 days old t.Errorf("removed %d: %v", len(removed), removed) } files, _ := List(dir) jarl := 0 for _, f := range files { if f.Kind == KindJarl { jarl++ if now.Sub(f.Time) > 30*24*time.Hour { t.Errorf("kept %s", f.Path) } } } if jarl != 31 { t.Errorf("%d jarl backups left", jarl) } for _, name := range []string{"jarl-before-upgrade.db", "jarl-20200101T000000Z.db.bak", "notes.txt", Name(KindStats, now.Add(-100*24*time.Hour))} { if _, err := os.Stat(filepath.Join(dir, name)); err != nil { t.Errorf("%s was deleted", name) } } // After a long outage every backup is old: the newest keepAtLeast stay. removed, _ = Prune(dir, KindJarl, now.Add(365*24*time.Hour), 30*24*time.Hour, 7) files, _ = List(dir) n := 0 for _, f := range files { if f.Kind == KindJarl { n++ } } if n != 7 || len(removed) != 24 { t.Errorf("after a year: %d left, %d removed", n, len(removed)) } // Retention 0 keeps everything. if removed, _ := Prune(dir, KindJarl, now.Add(3650*24*time.Hour), 0, 1); len(removed) != 0 { t.Errorf("retention 0 removed %v", removed) } } func TestPruneTempFiles(t *testing.T) { dir := t.TempDir() now := time.Now() old := filepath.Join(dir, "."+Name(KindJarl, now.Add(-48*time.Hour))+".tmp") fresh := filepath.Join(dir, "."+Name(KindJarl, now)+".tmp") os.WriteFile(old, nil, 0o600) os.WriteFile(fresh, nil, 0o600) os.Chtimes(old, now.Add(-48*time.Hour), now.Add(-48*time.Hour)) Prune(dir, KindJarl, now, 24*time.Hour, 1) if _, err := os.Stat(old); err == nil { t.Error("old temporary file kept") } if _, err := os.Stat(fresh); err != nil { t.Error("a running backup's temporary file was deleted") } } func TestKeyFiles(t *testing.T) { ctx := context.Background() src := newDB(t) keys := t.TempDir() os.MkdirAll(filepath.Join(keys, "view-3"), 0o755) os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.key"), []byte("public"), 0o644) os.WriteFile(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), []byte("private-key-material-xyz"), 0o600) dir := t.TempDir() f, err := Write(ctx, src, dir, KindJarl, time.Now(), keys) if err != nil { t.Fatal(err) } root, files, err := Files(ctx, f.Path) if err != nil || root != keys || len(files) != 2 { t.Fatalf("files: %q %+v %v", root, files, err) } if files[1].Path != "view-3/Kexample.com.+013+12345.private" || string(files[1].Data) != "private-key-material-xyz" || files[1].Mode != 0o600 { t.Errorf("private key: %+v", files[1]) } if in, err := Inspect(ctx, f.Path); err != nil || in.KeyFiles != 2 { t.Errorf("inspect: %+v %v", in, err) } // A restored database loses the table again. restored := filepath.Join(t.TempDir(), "jarl.db") b, _ := os.ReadFile(f.Path) os.WriteFile(restored, b, 0o600) if err := DropFiles(ctx, restored); err != nil { t.Fatal(err) } if _, files, _ := Files(ctx, restored); len(files) != 0 { t.Error("files left after DropFiles") } if b, _ := os.ReadFile(restored); strings.Contains(string(b), "private-key-material-xyz") { t.Error("the private key is still in the file") } // No key directory: nothing stored. An unreadable key: an error. if f, err := Write(ctx, src, dir, KindJarl, time.Now().Add(time.Second), filepath.Join(keys, "missing")); err != nil { t.Error(err) } else if _, files, _ := Files(ctx, f.Path); len(files) != 0 { t.Errorf("files from a missing directory: %v", files) } if os.Geteuid() != 0 { os.Chmod(filepath.Join(keys, "view-3", "Kexample.com.+013+12345.private"), 0) if _, err := Write(ctx, src, dir, KindJarl, time.Now().Add(2*time.Second), keys); err == nil || !strings.Contains(err.Error(), "back up as root") { t.Errorf("unreadable key: %v", err) } } }