186 lines
8.0 KiB
Go
186 lines
8.0 KiB
Go
package backup
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// D62 (operator ruling 2026-10-29): an unused restore copy is kept 30 days; the household sees each copy (app, the
|
|
// restore's date, size, the day it goes) and may delete one earlier. A copy a hold or a pending put-back needs is never
|
|
// deleted, by the sweep or by hand.
|
|
|
|
// RED-PROOF: with the age at 14 days the 20-day copy is swept → FAILS; at 30 days only the 31-day copy goes.
|
|
func TestD62_AnUnusedCopyIsKept30Days(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
now := time.Now()
|
|
day29 := r1256Copy(t, m, "a", 29*24*time.Hour, now)
|
|
day20 := r1256Copy(t, m, "b", 20*24*time.Hour, now)
|
|
day31 := r1256Copy(t, m, "c", 31*24*time.Hour, now)
|
|
got := m.SweepUnusedRestoreCopies(now)
|
|
if len(got) != 1 || got[0] != day31 {
|
|
t.Fatalf("swept %v — want only the 31-day copy %s", got, day31)
|
|
}
|
|
if !r1256Exists(day29) || !r1256Exists(day20) {
|
|
t.Fatal("a copy younger than 30 days was swept")
|
|
}
|
|
if restoreCopyUnusedAge != 30*24*time.Hour {
|
|
t.Fatalf("restoreCopyUnusedAge = %s, D62 says 30 days", restoreCopyUnusedAge)
|
|
}
|
|
}
|
|
|
|
// The list shows each copy with its restore date, its size and the day it goes; a needed copy has no such day and
|
|
// says why. RED-PROOF: drop the hold check from restoreCopyNeededFor → the held copy is offered → FAILS.
|
|
func TestD62_TheListShowsDateSizeAndWhyACopyIsNeeded(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
free := r1256Copy(t, m, "free", 2*24*time.Hour, now)
|
|
r1256Copy(t, m, "held", 2*24*time.Hour, now)
|
|
r1256Copy(t, m, "waiting", 2*24*time.Hour, now)
|
|
r1256Copy(t, m, "named", 2*24*time.Hour, now)
|
|
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "held", At: now.Format(time.RFC3339), Reason: settings.HoldReasonRestoreMixed}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "waiting", At: now.Format(time.RFC3339), Reason: settings.HoldReasonPutBackPending}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list := m.RestoreCopies()
|
|
if len(list) != 4 {
|
|
t.Fatalf("listed %d copies, want 4: %+v", len(list), list)
|
|
}
|
|
want := map[string]string{"free": "", "held": RestoreCopyNeededHold, "waiting": RestoreCopyNeededPutBack, "named": RestoreCopyNeededPutBack}
|
|
for _, c := range list {
|
|
if c.Needed != want[c.App] {
|
|
t.Errorf("%s: needed %q, want %q", c.App, c.Needed, want[c.App])
|
|
}
|
|
if !c.At.Equal(now.Add(-2 * 24 * time.Hour)) {
|
|
t.Errorf("%s: restore date %s, want the stamp's %s", c.App, c.At, now.Add(-2*24*time.Hour))
|
|
}
|
|
if c.SizeBytes != int64(len("LIVE")) {
|
|
t.Errorf("%s: size %d", c.App, c.SizeBytes)
|
|
}
|
|
if c.Needed == "" && !c.DeleteOn.Equal(c.At.Add(30*24*time.Hour)) {
|
|
t.Errorf("%s: deleted on %s, want 30 days after the restore", c.App, c.DeleteOn)
|
|
}
|
|
if c.Needed != "" && !c.DeleteOn.IsZero() {
|
|
t.Errorf("%s: a needed copy shows a delete day %s — the sweep never takes it", c.App, c.DeleteOn)
|
|
}
|
|
if c.App == "free" && c.Path != free {
|
|
t.Errorf("path %s, want %s", c.Path, free)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A held or waiting copy cannot be deleted by hand. RED-PROOF: drop the Needed check in DeleteRestoreCopy → FAILS.
|
|
func TestD62_AHeldCopyCannotBeDeleted(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
now := time.Now()
|
|
for _, tc := range []struct{ app, reason string }{
|
|
{"held", settings.HoldReasonRestoreMixed},
|
|
{"waiting", settings.HoldReasonPutBackPending},
|
|
{"failed", settings.HoldReasonRestoreFailed},
|
|
} {
|
|
dir := r1256Copy(t, m, tc.app, time.Hour, now)
|
|
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: tc.app, At: now.Format(time.RFC3339), Reason: tc.reason}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := m.DeleteRestoreCopy(tc.app, filepath.Base(dir)); !errors.Is(err, ErrRestoreCopyNeeded) {
|
|
t.Errorf("%s: err %v, want ErrRestoreCopyNeeded", tc.app, err)
|
|
}
|
|
if !r1256Exists(dir) {
|
|
t.Errorf("%s: a held copy was deleted", tc.app)
|
|
}
|
|
}
|
|
// a copy a cut restore names (the marker) is needed too
|
|
named := r1256Copy(t, m, "named", time.Hour, now)
|
|
if err := m.appStop.Begin("unit-restore:named", ReasonUnitRestore, []string{"named"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := m.DeleteRestoreCopy("named", filepath.Base(named)); !errors.Is(err, ErrRestoreCopyNeeded) || !r1256Exists(named) {
|
|
t.Errorf("a copy the marker names: err %v, exists %v", err, r1256Exists(named))
|
|
}
|
|
// an unreadable marker offers nothing
|
|
mustWrite(t, m.appStop.path, "{not json")
|
|
free := r1256Copy(t, m, "free", time.Hour, now)
|
|
if _, err := m.DeleteRestoreCopy("free", filepath.Base(free)); !errors.Is(err, ErrRestoreCopyNeeded) || !r1256Exists(free) {
|
|
t.Errorf("an unreadable marker: err %v, exists %v", err, r1256Exists(free))
|
|
}
|
|
}
|
|
|
|
// A delete removes ONLY that copy: the app's other copy, another app's copy and anything outside the copy tree stay;
|
|
// a name that is not one path element, or a link in the copy's place, is refused.
|
|
// RED-PROOF: remove the validCopyName check → "../.." reaches outside → FAILS; remove the Lstat link check → the
|
|
// link is followed/removed → FAILS.
|
|
func TestD62_ADeleteRemovesOnlyThatCopy(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
now := time.Now()
|
|
one := r1256Copy(t, m, "app", 3*time.Hour, now)
|
|
two := r1256Copy(t, m, "app", 2*time.Hour, now)
|
|
other := r1256Copy(t, m, "other", 3*time.Hour, now)
|
|
got, err := m.DeleteRestoreCopy("app", filepath.Base(one))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Path != one || got.SizeBytes != int64(len("LIVE")) || got.App != "app" {
|
|
t.Fatalf("returned %+v", got)
|
|
}
|
|
if r1256Exists(one) || !r1256Exists(two) || !r1256Exists(other) {
|
|
t.Fatalf("one gone=%v, two kept=%v, other kept=%v", !r1256Exists(one), r1256Exists(two), r1256Exists(other))
|
|
}
|
|
// path guard
|
|
outside := filepath.Join(m.namespaceRoot(m.systemDataPath), "backups", "primary", "keep.txt")
|
|
mustWrite(t, outside, "UNIT")
|
|
for _, bad := range [][2]string{{"app", ".."}, {"..", "primary"}, {"app", "../../primary"}, {"", "x"}, {"app", ""}, {"app/x", "y"}} {
|
|
if _, err := m.DeleteRestoreCopy(bad[0], bad[1]); !errors.Is(err, ErrRestoreCopyNotFound) {
|
|
t.Errorf("DeleteRestoreCopy(%q,%q) = %v, want not found", bad[0], bad[1], err)
|
|
}
|
|
}
|
|
if !r1256Exists(outside) || !r1256Exists(two) {
|
|
t.Fatal("a bad name reached outside the copy")
|
|
}
|
|
// a link in a copy's place is not a copy
|
|
target := t.TempDir()
|
|
mustWrite(t, filepath.Join(target, "precious"), "X")
|
|
link := filepath.Join(filepath.Dir(two), "20260101T000000Z")
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := m.DeleteRestoreCopy("app", "20260101T000000Z"); !errors.Is(err, ErrRestoreCopyNotFound) {
|
|
t.Errorf("a link: %v, want not found", err)
|
|
}
|
|
if !r1256Exists(filepath.Join(target, "precious")) {
|
|
t.Fatal("the link's target was removed")
|
|
}
|
|
if _, err := m.DeleteRestoreCopy("app", "20990101T000000Z"); !errors.Is(err, ErrRestoreCopyNotFound) {
|
|
t.Errorf("a missing copy: %v", err)
|
|
}
|
|
}
|
|
|
|
// While a backup or restore runs, a delete is refused and nothing moves. RED-PROOF: drop the busy check → FAILS.
|
|
func TestD62_NoDeleteWhileAnOperationRuns(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
c := r1256Copy(t, m, "app", time.Hour, time.Now())
|
|
m.BeginRestoreOp("restore", "other")
|
|
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); !errors.Is(err, ErrRestoreCopyBusy) || !r1256Exists(c) {
|
|
t.Fatalf("during a restore: err %v, exists %v", err, r1256Exists(c))
|
|
}
|
|
m.EndRestoreOp(true, "ok")
|
|
if err := m.acquireRunning(); err != nil {
|
|
t.Fatalf("could not take the backup single-flight: %v", err)
|
|
}
|
|
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); !errors.Is(err, ErrRestoreCopyBusy) || !r1256Exists(c) {
|
|
t.Fatalf("during a backup run: err %v, exists %v", err, r1256Exists(c))
|
|
}
|
|
m.ReleaseRunningForTest()
|
|
if _, err := m.DeleteRestoreCopy("app", filepath.Base(c)); err != nil || r1256Exists(c) {
|
|
t.Fatalf("control: with nothing running the delete goes through: %v", err)
|
|
}
|
|
}
|