Files
minstrel/internal/backup/service_test.go
T
bvandeusenandClaude Opus 5.5 9d324ab2d0
release / web (push) Failing after 15s
release / govulncheck (push) Successful in 16s
release / go (push) Failing after 41s
release / integration (push) Failing after 3m29s
release / android (push) Successful in 4m7s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / Build signed APK (push) Canceled after 5m34s
feat: Minstrel backs itself up nightly to a folder or an S3 bucket, off until an admin switches it on (#6773)
Admin › Backups shows a verdict first (off, not set up, waiting, backed up,
overdue, failing since when), Back up now, Test destination and the latest
runs, with the settings beside them.

- backup.Service: one run at a time, bounded at 30 minutes; the schedule
  ticks every 5 minutes, catches up a slot missed while the server was down,
  and retries a failure after an hour and then after as long as it has been
  failing. Runs are recorded in backup_runs, pruned after 90 days; a run cut
  off by a shutdown is marked failed at the next start.
- A folder destination writes database/minstrel-<UTC stamp>.tar and refuses
  a folder that isn't there. An S3 destination stages the file, hashes it,
  and uploads it under database/ through a throttled reader (4 MiB/s by
  default, 0 for no limit).
- Test destination checks the form as typed and only lists.
- The bucket secret is sealed like every stored credential; backup_settings
  and backup_runs are left out of the backup itself.
- The panel polls only while a run is going, capped at the run limit plus
  five minutes.

The music switch named in the task is left to #6774, so no control ships
that does nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:50:55 -05:00

172 lines
6.6 KiB
Go

package backup
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func at(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
panic(err)
}
return t
}
func TestDueRunsAtTheHourCatchesUpAndBacksOffAfterAFailure(t *testing.T) {
cases := []struct {
name string
now string
t runTimes
want string
}{
{"never run: the slot that passed is due", "2026-11-02T10:00:00Z",
runTimes{}, "2026-11-02T03:00:00Z"},
{"ran at the hour: tomorrow's slot", "2026-11-02T10:00:00Z",
runTimes{lastSuccess: at("2026-11-02T03:00:05Z"), lastAttempt: at("2026-11-02T03:00:05Z")}, "2026-11-03T03:00:00Z"},
{"before today's hour, yesterday's ran: today's slot", "2026-11-02T02:00:00Z",
runTimes{lastSuccess: at("2026-11-01T03:00:05Z"), lastAttempt: at("2026-11-01T03:00:05Z")}, "2026-11-02T03:00:00Z"},
{"server was down at the hour: catches up", "2026-11-02T10:00:00Z",
runTimes{lastSuccess: at("2026-11-01T03:00:05Z"), lastAttempt: at("2026-11-01T03:00:05Z")}, "2026-11-02T03:00:00Z"},
{"first failure: an hour later", "2026-11-02T03:30:00Z",
runTimes{lastAttempt: at("2026-11-02T03:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-02T04:00:00Z"},
{"failing for two hours: two hours later", "2026-11-02T05:30:00Z",
runTimes{lastAttempt: at("2026-11-02T05:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-02T07:00:00Z"},
{"a retry past the next slot waits for the slot", "2026-11-02T19:30:00Z",
runTimes{lastAttempt: at("2026-11-02T19:00:00Z"), failingSince: at("2026-11-02T03:00:00Z")}, "2026-11-03T03:00:00Z"},
}
for _, c := range cases {
if got := due(at(c.now), 3, c.t); !got.Equal(at(c.want)) {
t.Errorf("%s: due = %s, want %s", c.name, got.Format(time.RFC3339), c.want)
}
}
}
func TestTheHourIsUTC(t *testing.T) {
// 22:00 at UTC-5 is 03:00 UTC the next day, which is that day's slot.
now := time.Date(2026, 11, 1, 22, 0, 0, 0, time.FixedZone("x", -5*3600))
if got := slotBefore(now, 3); !got.Equal(at("2026-11-02T03:00:00Z")) {
t.Errorf("slot = %s", got)
}
}
func TestVerdict(t *testing.T) {
now := at("2026-11-02T10:00:00Z")
on := dbq.BackupSetting{Enabled: true}
good := runTimes{lastSuccess: at("2026-11-02T03:00:00Z"), lastAttempt: at("2026-11-02T03:00:00Z")}
cases := []struct {
name string
st dbq.BackupSetting
probs []string
t runTimes
want string
}{
{"switched off, even with problems and failures", dbq.BackupSetting{}, []string{"x"}, runTimes{failingSince: now}, StateOff},
{"on but not set up", on, []string{"x"}, good, StateIncomplete},
{"the latest run failed", on, nil, runTimes{lastSuccess: good.lastSuccess, failingSince: now}, StateFailing},
{"nothing has run yet", on, nil, runTimes{}, StateWaiting},
{"the latest backup is old", on, nil, runTimes{lastSuccess: now.Add(-27 * time.Hour)}, StateOverdue},
{"a day and a bit is still fine", on, nil, runTimes{lastSuccess: now.Add(-25 * time.Hour)}, StateOK},
{"fine", on, nil, good, StateOK},
}
for _, c := range cases {
if got := verdict(c.st, c.probs, c.t, now); got != c.want {
t.Errorf("%s: %s, want %s", c.name, got, c.want)
}
}
}
func TestProblemsNameWhatIsMissing(t *testing.T) {
if p := problems(dbq.BackupSetting{Destination: DestinationS3}); len(p) != 4 {
t.Errorf("an empty bucket: %v, want endpoint, name, access key and secret key", p)
}
if p := problems(dbq.BackupSetting{Destination: DestinationS3, Endpoint: "https://s3.example.com", Bucket: "b", AccessKey: "a", SecretKey: "v1.x"}); len(p) != 0 {
t.Errorf("a whole bucket: %v", p)
}
if p := problems(dbq.BackupSetting{Destination: DestinationFolder}); len(p) != 1 {
t.Errorf("an empty folder: %v", p)
}
if p := problems(dbq.BackupSetting{Destination: DestinationFolder, Folder: "/backups"}); len(p) != 0 {
t.Errorf("a folder: %v", p)
}
}
func TestMergeRefusesWhatCannotBeKept(t *testing.T) {
ok := Update{Destination: DestinationS3, RunHour: 3}
for field, u := range map[string]func(Update) Update{
"destination": func(u Update) Update { u.Destination = "ftp"; return u },
"run_hour": func(u Update) Update { u.RunHour = 24; return u },
"upload_limit_kib": func(u Update) Update { u.UploadLimitKiB = -1; return u },
"folder": func(u Update) Update { u.Folder = "backups"; return u },
"endpoint": func(u Update) Update { u.Endpoint = "s3.example.com"; return u },
} {
_, err := merge(dbq.BackupSetting{}, u(ok))
var fe *FieldError
if !errors.As(err, &fe) || fe.Field != field {
t.Errorf("%s: err = %v, want a FieldError on it", field, err)
}
}
}
func TestMergeTidiesAndKeepsTheStoredSecret(t *testing.T) {
st, err := merge(dbq.BackupSetting{SecretKey: "v1.stored"}, Update{
Enabled: true, Destination: DestinationS3, Endpoint: " https://s3.example.com/ ",
Bucket: " minstrel ", AccessKey: " ak ", RunHour: 4, UploadLimitKiB: 512, Folder: "/srv/backups/",
})
if err != nil {
t.Fatal(err)
}
if st.Endpoint != "https://s3.example.com" || st.Bucket != "minstrel" || st.AccessKey != "ak" ||
st.SecretKey != "v1.stored" || st.RunHour != 4 || st.UploadLimitKib != 512 || st.Folder != "/srv/backups" || !st.Enabled {
t.Errorf("merged = %+v", st)
}
}
func TestThrottleKeepsToItsRate(t *testing.T) {
data := bytes.Repeat([]byte("x"), 20*1024)
start := time.Now()
got, err := io.ReadAll(Throttle(context.Background(), bytes.NewReader(data), 100*1024))
if err != nil || !bytes.Equal(got, data) {
t.Fatalf("read %d bytes, err %v", len(got), err)
}
// 20 KiB at 100 KiB a second takes at least a fifth of a second.
if took := time.Since(start); took < 180*time.Millisecond {
t.Errorf("took %s, want at least 200ms", took)
}
}
func TestThrottleWithNoLimitIsTheReaderItself(t *testing.T) {
r := strings.NewReader("x")
if Throttle(context.Background(), r, 0) != io.Reader(r) {
t.Error("a zero limit wrapped the reader")
}
}
func TestThrottleStopsWhenItsContextEnds(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := io.ReadAll(Throttle(ctx, bytes.NewReader(make([]byte, 4096)), 1024))
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDescribeSaysWhyARunStopped(t *testing.T) {
if got := describe(context.DeadlineExceeded); !strings.Contains(got, "longer than 30 minutes") {
t.Errorf("deadline: %q", got)
}
if got := describe(context.Canceled); !strings.Contains(got, "server stopped") {
t.Errorf("canceled: %q", got)
}
if got := describe(errors.New("backup: the folder /x isn't there")); got != "the folder /x isn't there" {
t.Errorf("other: %q", got)
}
}