7861bf9dde
gates / gates (push) Successful in 28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
564 lines
24 KiB
Go
564 lines
24 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// ── decision 68/69 (v0.289.0): the append-only tier ─────────────────────────────────────────────────
|
|
|
|
func pinTarget(t *testing.T, sett *settings.Settings) {
|
|
t.Helper()
|
|
if err := sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
|
o.Transport = settings.TransportRclonePinned
|
|
o.Port = 23
|
|
o.Host, o.User, o.RepoPath = "u1-sub4.example", "u1-sub4", "/home/felhom-repo"
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func snapJSON(s []guardSnap) []byte { b, _ := json.Marshal(s); return b }
|
|
|
|
func planJSON(remove []guardSnap) []byte {
|
|
b, _ := json.Marshal([]map[string]any{{"tags": []string{"app1"}, "remove": remove}})
|
|
return b
|
|
}
|
|
|
|
// windowRunner fakes restic for the retention step: snapshots, the dry-run plan, and records every
|
|
// non-dry-run forget (the only call that deletes).
|
|
type windowRunner struct {
|
|
snaps []guardSnap
|
|
plan []guardSnap
|
|
forgets [][]string
|
|
}
|
|
|
|
func (w *windowRunner) run(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
|
switch {
|
|
case contains(args, "forget") && contains(args, "--dry-run"):
|
|
return planJSON(w.plan), nil
|
|
case contains(args, "forget"):
|
|
w.forgets = append(w.forgets, append([]string{}, args...))
|
|
return nil, nil
|
|
case contains(args, "snapshots"):
|
|
return snapJSON(w.snaps), nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
type fakeWindow struct {
|
|
grant OffsiteWindow
|
|
opened int
|
|
closed []OffsiteWindowResult
|
|
}
|
|
|
|
func (f *fakeWindow) Open(context.Context, int) (OffsiteWindow, error) {
|
|
f.opened++
|
|
return f.grant, nil
|
|
}
|
|
func (f *fakeWindow) Close(_ context.Context, r OffsiteWindowResult) error {
|
|
f.closed = append(f.closed, r)
|
|
return nil
|
|
}
|
|
|
|
func snap(id string, at time.Time) guardSnap {
|
|
return guardSnap{ID: id + "-full", ShortID: id, Time: at}
|
|
}
|
|
|
|
// The pinned transport: rclone over port 23, the pinned key; never sftp.
|
|
func TestOffboxBaseArgs_PinnedUsesRcloneOnPort23(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
pinTarget(t, sett)
|
|
sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.Port = 0 })
|
|
args, _ := m.offboxBaseArgs(sett.GetOffboxTarget())
|
|
j := strings.Join(args, " ")
|
|
if !strings.Contains(j, "-r rclone:/home/felhom-repo") || !strings.Contains(j, "rclone.program=ssh -p 23 ") || strings.Contains(j, "sftp") {
|
|
t.Fatalf("pinned args = %q", j)
|
|
}
|
|
if !argsContainTimeout(args) {
|
|
t.Fatal("ConnectTimeout lost on the pinned transport")
|
|
}
|
|
// The household's NAS stays SFTP.
|
|
m2, sett2 := newOffboxManager(t)
|
|
if j2 := strings.Join(func() []string { a, _ := m2.offboxBaseArgs(sett2.GetOffboxTarget()); return a }(), " "); !strings.Contains(j2, "sftp:") {
|
|
t.Fatalf("NAS args = %q", j2)
|
|
}
|
|
}
|
|
|
|
// THE CONSEQUENCE: on the pinned tier, a run with no window deletes NOTHING — no forget reaches restic.
|
|
// RED-PROOF: the pre-v0.289.0 retention ran `forget … --prune` unconditionally after every run.
|
|
func TestRetention_PinnedWithoutWindowDeletesNothing(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
pinTarget(t, sett)
|
|
wr := &windowRunner{snaps: []guardSnap{snap("a", time.Now().Add(-40*24*time.Hour))}, plan: []guardSnap{snap("a", time.Now().Add(-40*24*time.Hour))}}
|
|
m.SetOffboxRunner(wr.run)
|
|
m.offsiteWindowRetention(context.Background(), nil, nil, "after-run") // no client wired
|
|
fw := &fakeWindow{grant: OffsiteWindow{Granted: false, Reason: "not due"}}
|
|
m.SetOffsiteWindowClient(fw)
|
|
m.offsiteWindowRetention(context.Background(), nil, nil, "after-run")
|
|
if len(wr.forgets) != 0 {
|
|
t.Fatalf("a forget ran without a window: %v", wr.forgets)
|
|
}
|
|
if fw.opened != 1 || len(fw.closed) != 0 {
|
|
t.Fatalf("opened=%d closed=%d", fw.opened, len(fw.closed))
|
|
}
|
|
}
|
|
|
|
// The full run path: RunOffboxBackup on a pinned target with no window never calls forget.
|
|
func TestRunOffboxBackup_PinnedNeverForgetsWithoutWindow(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
pinTarget(t, sett)
|
|
var forgets int
|
|
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) {
|
|
if contains(args, "forget") {
|
|
forgets++
|
|
}
|
|
rr := &recordingOffboxRunner{}
|
|
return rr.run(ctx, env, args...)
|
|
})
|
|
_ = m.RunOffboxBackup(context.Background())
|
|
if forgets != 0 {
|
|
t.Fatalf("the pinned run reached forget %d time(s)", forgets)
|
|
}
|
|
}
|
|
|
|
// R-822, the lab's shape: 13 future-dated fakes. The guard REFUSES and nothing is deleted; the hub
|
|
// is told why (window closed with outcome guard-refused).
|
|
func TestOffsiteGuard_LabThirteenFutureFakes_Refused(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
pinTarget(t, sett)
|
|
now := time.Now()
|
|
real := []guardSnap{snap("r1", now.Add(-2*time.Hour)), snap("r2", now.Add(-26*time.Hour)), snap("r3", now.Add(-50*time.Hour))}
|
|
all := append([]guardSnap{}, real...)
|
|
for d := 1; d <= 7; d++ {
|
|
all = append(all, snap(fmt.Sprintf("f%d", d), time.Date(2027, 1, d, 3, 0, 0, 0, time.UTC)))
|
|
}
|
|
for mth := 2; mth <= 7; mth++ {
|
|
all = append(all, snap(fmt.Sprintf("m%d", mth), time.Date(2027, time.Month(mth), 15, 3, 0, 0, 0, time.UTC)))
|
|
}
|
|
wr := &windowRunner{snaps: all, plan: real} // the poisoned policy selects every REAL snapshot
|
|
m.SetOffboxRunner(wr.run)
|
|
fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 7, NewestAllowed: now, MaxRemove: 50}}
|
|
m.SetOffsiteWindowClient(fw)
|
|
m.offsiteWindowRetention(context.Background(), nil, nil, "after-run")
|
|
if len(wr.forgets) != 0 {
|
|
t.Fatalf("the guard let a poisoned plan delete: %v", wr.forgets)
|
|
}
|
|
if len(fw.closed) != 1 || fw.closed[0].Outcome != "guard-refused" || !strings.Contains(fw.closed[0].Reason, "future") {
|
|
t.Fatalf("window close = %+v", fw.closed)
|
|
}
|
|
}
|
|
|
|
// Past-dated fakes that make the policy drop a RECENT real snapshot: refused too.
|
|
func TestOffsiteGuard_RecentRemovalRefused(t *testing.T) {
|
|
now := time.Now()
|
|
all := []guardSnap{snap("old", now.Add(-60*24*time.Hour)), snap("recent", now.Add(-3*24*time.Hour))}
|
|
_, why := offsiteGuard(all, []guardSnap{snap("recent", now.Add(-3*24*time.Hour))}, now, now, 50)
|
|
if !strings.Contains(why, "within the last 7 days kept daily") {
|
|
t.Fatalf("why = %q", why)
|
|
}
|
|
_, why = offsiteGuard(all, nil, now, now.Add(-10*24*time.Hour), 50)
|
|
if !strings.Contains(why, "newer than the hub allows") {
|
|
t.Fatalf("newest-allowed bound not enforced: %q", why)
|
|
}
|
|
}
|
|
|
|
// Honest retention inside a window: oldest first, the forget names ids.
|
|
func TestOffsiteGuard_HonestPlanPrunesOldestFirst(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
pinTarget(t, sett)
|
|
now := time.Now()
|
|
plan := []guardSnap{snap("c", now.Add(-20*24*time.Hour)), snap("a", now.Add(-90*24*time.Hour)), snap("b", now.Add(-60*24*time.Hour))}
|
|
all := append([]guardSnap{snap("keep", now.Add(-time.Hour))}, plan...)
|
|
wr := &windowRunner{snaps: all, plan: plan}
|
|
m.SetOffboxRunner(wr.run)
|
|
fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 9, NewestAllowed: now, MaxRemove: 5}}
|
|
m.SetOffsiteWindowClient(fw)
|
|
m.offsiteWindowRetention(context.Background(), nil, nil, "after-run")
|
|
if len(wr.forgets) != 1 || !strings.Contains(strings.Join(wr.forgets[0], " "), "forget a-full b-full c-full --prune") {
|
|
t.Fatalf("forget = %v", wr.forgets)
|
|
}
|
|
if len(fw.closed) != 1 || fw.closed[0].Outcome != "pruned" || fw.closed[0].ID != 9 {
|
|
t.Fatalf("close = %+v", fw.closed)
|
|
}
|
|
}
|
|
|
|
// A plan larger than one week's removal REFUSES (the 2026-10-04 brief), nothing removed.
|
|
func TestOffsiteGuard_AboveWeeklyCapRefused(t *testing.T) {
|
|
now := time.Now()
|
|
var plan []guardSnap
|
|
for i := 0; i < 6; i++ {
|
|
plan = append(plan, snap(fmt.Sprintf("o%d", i), now.Add(-time.Duration(30+i)*24*time.Hour)))
|
|
}
|
|
ids, why := offsiteGuard(plan, plan, now, now, 5)
|
|
if ids != nil || !strings.Contains(why, "more than one week") {
|
|
t.Fatalf("ids=%v why=%q", ids, why)
|
|
}
|
|
}
|
|
|
|
// R-824, THE MEASURED SHAPE (demo-hp window 1, 2026-10-03): the night run's snapshots of each app were
|
|
// superseded the SAME DAY by a manual run, so the honest policy removes them while they are young.
|
|
// v0.289 refused the whole window; now they are EXCLUDED (kept for later) and the old removal goes ahead.
|
|
func TestOffsiteGuard_SameDaySupersededYoungExcluded(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
pinTarget(t, sett)
|
|
now := time.Now()
|
|
day := now.Add(-26 * time.Hour).Truncate(24 * time.Hour)
|
|
night := guardSnap{ID: "night-full", ShortID: "night", Time: day.Add(2 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}}
|
|
manual := guardSnap{ID: "manual-full", ShortID: "manual", Time: day.Add(15 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}}
|
|
old := guardSnap{ID: "old-full", ShortID: "old", Time: now.Add(-40 * 24 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}}
|
|
wr := &windowRunner{snaps: []guardSnap{old, night, manual}, plan: []guardSnap{night, old}}
|
|
m.SetOffboxRunner(wr.run)
|
|
fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 2, NewestAllowed: now, MaxRemove: 5}}
|
|
m.SetOffsiteWindowClient(fw)
|
|
m.offsiteWindowRetention(context.Background(), nil, nil, "after-run")
|
|
if len(fw.closed) != 1 || fw.closed[0].Outcome != "pruned" {
|
|
t.Fatalf("the window must run, not refuse: %+v", fw.closed)
|
|
}
|
|
got := strings.Join(wr.forgets[0], " ")
|
|
if !strings.Contains(got, "old-full") || strings.Contains(got, "night-full") {
|
|
t.Fatalf("forget = %q (the young superseded copy must be KEPT for now)", got)
|
|
}
|
|
// A young removal with NO same-day successor in its group is still the poisoning signature.
|
|
lone := guardSnap{ID: "lone-full", ShortID: "lone", Time: now.Add(-50 * time.Hour), Hostname: "demo-hp", Tags: []string{"bookstack"}}
|
|
if _, why := offsiteGuard([]guardSnap{lone, manual}, []guardSnap{lone}, now, now, 5); !strings.Contains(why, "not superseded the same day") {
|
|
t.Fatalf("why = %q", why)
|
|
}
|
|
}
|
|
|
|
// The orphan reset on the pinned tier asks the HUB; no ssh `mv` from the box.
|
|
func TestResetOrphaned_PinnedAsksTheHub(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
pinTarget(t, sett)
|
|
m.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
|
|
t.Fatal("the box issued a raw ssh command on the pinned tier")
|
|
return nil, nil
|
|
})
|
|
called := 0
|
|
m.SetOffsiteMoveAside(func(context.Context) (string, error) { called++; return "/home/felhom-repo.orphaned-20261003", nil })
|
|
m.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) { return nil, nil })
|
|
if err := m.resetOrphanedRepo(context.Background(), nil, nil, "test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if called != 1 || sett.GetOffboxTarget().OrphanedRenamedTo != "/home/felhom-repo.orphaned-20261003" {
|
|
t.Fatalf("hub move-aside called=%d recorded=%q", called, sett.GetOffboxTarget().OrphanedRenamedTo)
|
|
}
|
|
}
|
|
|
|
// R-869 (v0.294.0): the move-aside line names the destination. MEASURED 2026-10-05 03:08 UTC, Tester 1 box
|
|
// (v0.293.0): `the hub set the orphaned repo aside: /home/felhom-repo -> (nothing deleted)`.
|
|
// COMPANION RED-PROOF: move `newPath = np` below the log line → "the line names no destination".
|
|
func TestR869_MoveAsideLineNamesTheDestination(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
var buf bytes.Buffer
|
|
m.logger = log.New(&buf, "", 0)
|
|
pinTarget(t, sett)
|
|
m.SetOffsiteMoveAside(func(context.Context) (string, error) { return "/home/felhom-repo.orphaned-20261005", nil })
|
|
m.SetOffboxRunner(func(context.Context, []string, ...string) ([]byte, error) { return nil, nil })
|
|
if err := m.resetOrphanedRepo(context.Background(), nil, nil, "test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(buf.String(), "aside: /home/felhom-repo -> /home/felhom-repo.orphaned-20261005 (nothing deleted)") {
|
|
t.Fatalf("the line names no destination:\n%s", buf.String())
|
|
}
|
|
}
|
|
|
|
// The provider's rclone notice must not reach a JSON parser — measured live on demo-felhom (v0.289.0).
|
|
func TestStripRcloneNotice(t *testing.T) {
|
|
in := "rclone: 2026/10/03 15:05:42 NOTICE: Config file \"/home/.config/rclone/rclone.conf\" not found - using defaults\n[{\"id\":\"s1\"}]\n"
|
|
if got := string(stripRcloneNotice([]byte(in))); got != "[{\"id\":\"s1\"}]\n" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
keep := "rclone: 2026/10/03 15:05:42 ERROR : something real\n"
|
|
if got := string(stripRcloneNotice([]byte(keep))); got != keep {
|
|
t.Fatalf("an rclone ERROR line was stripped: %q", got)
|
|
}
|
|
}
|
|
|
|
// THE CONSEQUENCE (R-331/R-431): a run whose snapshot listing cannot be read must NOT record 0 as a
|
|
// measurement. Before the fix the box reported 0 snapshots with stats_known over a store holding 12.
|
|
func TestRunOffbox_UnreadableCountIsNotZero(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.SnapshotCount, o.StatsKnown = 11, true })
|
|
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) {
|
|
if contains(args, "snapshots") {
|
|
return []byte("rclone: 2026/10/03 15:05:42 ERROR : boom\nnot json"), nil
|
|
}
|
|
rr := &recordingOffboxRunner{}
|
|
return rr.run(ctx, env, args...)
|
|
})
|
|
_ = m.RunOffboxBackup(context.Background())
|
|
got := sett.GetOffboxTarget()
|
|
if got.StatsKnown && got.SnapshotCount == 0 {
|
|
t.Fatalf("an unreadable count was recorded as a measured zero: %+v", got.SnapshotCount)
|
|
}
|
|
}
|
|
|
|
type fakeAbandon struct {
|
|
requested []string
|
|
state string
|
|
cancels int
|
|
due time.Time
|
|
}
|
|
|
|
func (f *fakeAbandon) Request(_ context.Context, p string) (time.Time, error) {
|
|
f.requested = append(f.requested, p)
|
|
f.state = "pending"
|
|
return f.due, nil
|
|
}
|
|
func (f *fakeAbandon) Status(context.Context) (string, error) { return f.state, nil }
|
|
func (f *fakeAbandon) Cancel(context.Context) error { f.cancels++; f.state = "cancelled"; return nil }
|
|
|
|
// Decision 74 (R-823), the box side: a due abandonment on the pinned tier is HANDED to the hub (nothing
|
|
// deleted by the box), the page keeps a dated, cancellable deletion, a recovery cancels it at the hub, and
|
|
// a "deleted" from the hub completes the two-phase commit.
|
|
func TestAbandon_PinnedHandsToHubAndFollows(t *testing.T) {
|
|
m, sett := newOffboxManager(t)
|
|
pinTarget(t, sett)
|
|
setDue := func() {
|
|
sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
|
o.AbandonRepoPath = "/home/felhom-repo.orphaned-20260901"
|
|
o.AbandonStartedAt = time.Now().Add(-20 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
|
o.AbandonAt = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
|
})
|
|
}
|
|
setDue()
|
|
m.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
|
|
t.Fatal("the box tried to delete on the pinned tier")
|
|
return nil, nil
|
|
})
|
|
fa := &fakeAbandon{due: time.Now().Add(7 * 24 * time.Hour)}
|
|
m.SetOffsiteAbandonClient(fa)
|
|
if del, err := m.AbandonSweep(context.Background()); del || err != nil || len(fa.requested) != 1 {
|
|
t.Fatalf("del=%v err=%v requested=%v", del, err, fa.requested)
|
|
}
|
|
st := m.AbandonStatus()
|
|
if !st.Active || !st.HubPending || st.DaysLeft < 6 {
|
|
t.Fatalf("the page would lose the dated deletion: %+v", st)
|
|
}
|
|
// Still pending: nothing happens, nothing re-requested.
|
|
if del, _ := m.AbandonSweep(context.Background()); del || len(fa.requested) != 1 {
|
|
t.Fatal("re-requested or deleted while pending")
|
|
}
|
|
// The household recovers → cancelled at the hub, countdown gone.
|
|
m.CancelAbandon("recovery succeeded")
|
|
if fa.cancels != 1 || m.AbandonStatus().Active {
|
|
t.Fatalf("cancels=%d status=%+v", fa.cancels, m.AbandonStatus())
|
|
}
|
|
// Again, and this time the hub deletes.
|
|
setDue()
|
|
_, _ = m.AbandonSweep(context.Background())
|
|
fa.state = "deleted"
|
|
var evs []string
|
|
m.SetOffboxOrphanEvent(func(e, _ string) { evs = append(evs, e) })
|
|
if del, err := m.AbandonSweep(context.Background()); !del || err != nil || !m.AbandonStatus().PurgeRequested || len(evs) != 1 || evs[0] != "offbox_abandon_completed" {
|
|
t.Fatalf("del=%v err=%v status=%+v evs=%v", del, err, m.AbandonStatus(), evs)
|
|
}
|
|
}
|
|
|
|
// ── R-867 (v0.294.0): the guard against the REAL policy ─────────────────────────────────────────────────
|
|
|
|
// restic0140Plan is restic 0.14.0's `forget --group-by host,tags --keep-daily D --keep-weekly W
|
|
// --keep-monthly M` (internal/restic/snapshot_policy.go ApplyPolicy): per group, newest first, a snapshot
|
|
// is kept when it opens a new day / ISO week / month bucket while that rule still has count left; every
|
|
// other snapshot is removed. Equivalence with the real binary over the same snapshot set is recorded in
|
|
// felhom.eu/documentation/audits/night-fixes-2026-10-05/partA/ (the lab run).
|
|
func restic0140Plan(all []guardSnap, daily, weekly, monthly int) (remove []guardSnap) {
|
|
groups := map[string][]guardSnap{}
|
|
var order []string
|
|
for _, s := range all {
|
|
if _, ok := groups[s.group()]; !ok {
|
|
order = append(order, s.group())
|
|
}
|
|
groups[s.group()] = append(groups[s.group()], s)
|
|
}
|
|
for _, g := range order {
|
|
list := append([]guardSnap{}, groups[g]...)
|
|
sort.SliceStable(list, func(i, j int) bool { return list[i].Time.After(list[j].Time) })
|
|
type bucket struct {
|
|
count int
|
|
f func(time.Time) int
|
|
last int
|
|
}
|
|
b := []bucket{
|
|
{daily, func(d time.Time) int { return d.Year()*10000 + int(d.Month())*100 + d.Day() }, -1},
|
|
{weekly, func(d time.Time) int { y, w := d.ISOWeek(); return y*100 + w }, -1},
|
|
{monthly, func(d time.Time) int { return d.Year()*100 + int(d.Month()) }, -1},
|
|
}
|
|
for _, cur := range list {
|
|
keep := false
|
|
for i := range b {
|
|
if b[i].count > 0 {
|
|
if v := b[i].f(cur.Time); v != b[i].last {
|
|
keep = true
|
|
b[i].last = v
|
|
b[i].count--
|
|
}
|
|
}
|
|
}
|
|
if !keep {
|
|
remove = append(remove, cur)
|
|
}
|
|
}
|
|
}
|
|
return remove
|
|
}
|
|
|
|
func without(all []guardSnap, ids []string) []guardSnap {
|
|
gone := map[string]bool{}
|
|
for _, id := range ids {
|
|
gone[id] = true
|
|
}
|
|
var out []guardSnap
|
|
for _, s := range all {
|
|
if !gone[s.ID] {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// THE MEASURED SHAPE (demo-felhom window 3, 2026-10-05 02:15 UTC): the night's snapshot is taken, then the
|
|
// window runs; the policy drops the snapshot of 7 days + 5 s ago. v0.293.0's 8-day line refused it.
|
|
func TestOffsiteGuard_RealPolicyMeasuredShapeAllowed(t *testing.T) {
|
|
var all []guardSnap
|
|
for d := 0; d < 8; d++ {
|
|
at := time.Date(2026, 9, 28+d, 2, 15, 5, 0, time.UTC)
|
|
all = append(all, guardSnap{ID: fmt.Sprintf("n%d-full", d), ShortID: fmt.Sprintf("n%d", d), Time: at, Hostname: "demo-felhom", Tags: []string{"opengist"}})
|
|
}
|
|
now := time.Date(2026, 10, 5, 2, 15, 10, 0, time.UTC)
|
|
plan := restic0140Plan(all, keepDaily, keepWeekly, keepMonthly)
|
|
if len(plan) != 1 || plan[0].ShortID != "n0" {
|
|
t.Fatalf("the policy's plan = %v, want the 2026-09-28 snapshot only", plan)
|
|
}
|
|
ids, why := offsiteGuard(all, plan, now, now, 5)
|
|
if why != "" || len(ids) != 1 || ids[0] != "n0-full" {
|
|
t.Fatalf("the honest drop of a 7 d + 5 s snapshot must pass: ids=%v why=%q", ids, why)
|
|
}
|
|
}
|
|
|
|
// Sixty nights of a household with three apps, a window after every night's run (the worst case: the hub
|
|
// opens weekly), a same-day manual run on two days, and a month boundary: the real policy's plan is never
|
|
// refused, deletes happen, and the store settles at the policy's size. Weekly and monthly keeps survive.
|
|
func TestOffsiteGuard_RealPolicySixtyNightsNeverRefuses(t *testing.T) {
|
|
apps := []string{"opengist", "bookstack", "immich"}
|
|
var all []guardSnap
|
|
removedTotal, windowsWithRemoval := 0, 0
|
|
start := time.Date(2026, 8, 20, 2, 15, 0, 0, time.UTC)
|
|
for night := 0; night < 60; night++ {
|
|
at := start.AddDate(0, 0, night)
|
|
for i, a := range apps {
|
|
all = append(all, guardSnap{ID: fmt.Sprintf("%s-%d-full", a, night), ShortID: fmt.Sprintf("%s%d", a, night),
|
|
Time: at.Add(time.Duration(i) * time.Second), Hostname: "box", Tags: []string{a}})
|
|
}
|
|
if night == 20 || night == 41 { // the household pressed "back up now" in the afternoon
|
|
for _, a := range apps {
|
|
all = append(all, guardSnap{ID: fmt.Sprintf("%s-%d-manual-full", a, night), ShortID: fmt.Sprintf("%s%dm", a, night),
|
|
Time: at.Add(13 * time.Hour), Hostname: "box", Tags: []string{a}})
|
|
}
|
|
}
|
|
now := at.Add(2 * time.Minute)
|
|
if night == 20 || night == 41 {
|
|
now = at.Add(13*time.Hour + 2*time.Minute)
|
|
}
|
|
plan := restic0140Plan(all, keepDaily, keepWeekly, keepMonthly)
|
|
ids, why := offsiteGuard(all, plan, now, now, 500)
|
|
if why != "" {
|
|
t.Fatalf("night %d (%s): the guard refused the honest policy: %s", night, now.Format(time.RFC3339), why)
|
|
}
|
|
if len(ids) > 0 {
|
|
windowsWithRemoval++
|
|
removedTotal += len(ids)
|
|
}
|
|
all = without(all, ids)
|
|
}
|
|
if windowsWithRemoval < 15 || removedTotal < 45 {
|
|
t.Fatalf("too little was ever removed (%d windows, %d snapshots): the test does not exercise deletion", windowsWithRemoval, removedTotal)
|
|
}
|
|
// The store holds the policy's shape per app, never only the last 7 days: the end-of-month keeps remain.
|
|
per := map[string]int{}
|
|
monthEnds := 0
|
|
for _, s := range all {
|
|
per[s.Tags[0]]++
|
|
if s.Tags[0] == "opengist" && (s.Time.Format("01-02") == "08-31" || s.Time.Format("01-02") == "09-30") {
|
|
monthEnds++
|
|
}
|
|
}
|
|
for _, a := range apps {
|
|
if per[a] < keepDaily || per[a] > keepDaily+keepWeekly+keepMonthly {
|
|
t.Fatalf("%s holds %d snapshots after 60 nights; policy bounds %d..%d", a, per[a], keepDaily, keepDaily+keepWeekly+keepMonthly)
|
|
}
|
|
}
|
|
if monthEnds != 2 {
|
|
t.Fatalf("the monthly keeps of 31 Aug and 30 Sep must survive; found %d", monthEnds)
|
|
}
|
|
}
|
|
|
|
// A weekly window (the hub's real cadence) over the same nights: the backlog of one week is removed in one
|
|
// go and is never refused by the day line.
|
|
func TestOffsiteGuard_RealPolicyWeeklyWindows(t *testing.T) {
|
|
var all []guardSnap
|
|
start := time.Date(2026, 9, 1, 2, 15, 0, 0, time.UTC)
|
|
removed := 0
|
|
for night := 0; night < 35; night++ {
|
|
at := start.AddDate(0, 0, night)
|
|
all = append(all, guardSnap{ID: fmt.Sprintf("s%d-full", night), ShortID: fmt.Sprintf("s%d", night), Time: at, Hostname: "box", Tags: []string{"app"}})
|
|
if night%7 != 6 {
|
|
continue
|
|
}
|
|
now := at.Add(time.Minute)
|
|
plan := restic0140Plan(all, keepDaily, keepWeekly, keepMonthly)
|
|
ids, why := offsiteGuard(all, plan, now, now, 7)
|
|
if why != "" {
|
|
t.Fatalf("weekly window on %s refused: %s", now.Format("2006-01-02"), why)
|
|
}
|
|
removed += len(ids)
|
|
all = without(all, ids)
|
|
}
|
|
if removed == 0 {
|
|
t.Fatal("five weekly windows removed nothing")
|
|
}
|
|
}
|
|
|
|
// Poisoning that the day line still catches with the REAL policy: just before midnight an add-only
|
|
// attacker plants a snapshot 50 minutes ahead — inside the future-date skew, so not refused as future —
|
|
// dated TOMORROW. The policy then counts tomorrow as a day and drops the real snapshot of six days ago.
|
|
// (Past-dated fakes cannot make the policy drop a snapshot inside the last keepDaily calendar days: those
|
|
// days are at most keepDaily distinct days and keep-daily keeps them all. Past-dated gap-fills that steer
|
|
// OLDER keeps are R-822's residual, bounded by MaxRemove and the hub's count check, not by this line.)
|
|
func TestOffsiteGuard_RealPolicySkewWindowFakeRefused(t *testing.T) {
|
|
now := time.Date(2026, 10, 12, 23, 30, 0, 0, time.UTC)
|
|
var all []guardSnap
|
|
for d := 0; d < 7; d++ {
|
|
all = append(all, guardSnap{ID: fmt.Sprintf("real%d-full", d), ShortID: fmt.Sprintf("real%d", d),
|
|
Time: time.Date(2026, 10, 12-d, 2, 15, 0, 0, time.UTC), Hostname: "box", Tags: []string{"app"}})
|
|
}
|
|
all = append(all, guardSnap{ID: "fake-full", ShortID: "fake", Time: now.Add(50 * time.Minute), Hostname: "box", Tags: []string{"app"}})
|
|
plan := restic0140Plan(all, keepDaily, keepWeekly, keepMonthly)
|
|
if len(plan) != 1 || plan[0].ShortID != "real6" {
|
|
t.Fatalf("plan = %v (want the real snapshot of 6 days ago)", plan)
|
|
}
|
|
_, why := offsiteGuard(all, plan, now, now, 50)
|
|
if !strings.Contains(why, "within the last 7 days kept daily") {
|
|
t.Fatalf("why = %q — a real snapshot inside the daily window must not be removed", why)
|
|
}
|
|
}
|
|
|
|
// The policy's arguments and the guard's line come from the same constants (R-867).
|
|
func TestRetentionPolicy_BuiltFromTheGuardConstants(t *testing.T) {
|
|
got := strings.Join(retentionPolicy, " ")
|
|
want := fmt.Sprintf("--group-by host,tags --keep-daily %d --keep-weekly %d --keep-monthly %d", keepDaily, keepWeekly, keepMonthly)
|
|
if got != want || keepDaily != 7 || keepWeekly != 4 || keepMonthly != 6 {
|
|
t.Fatalf("policy = %q (want %q, the ruled 7/4/6)", got, want)
|
|
}
|
|
}
|