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) } }