package backup import ( "context" "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // R-203 Part 2 — "ok" must mean the mandatory data is in the snapshot. // // The defect these pin is NOT that the gap went undetected. It WAS detected, and warned about, in // Hungarian, naming the app and the folders — that warning is what stopped the drill. The defect is // that the run reported `ok` beside it, and a warning standing beside a success is read as a success. func mandatoryUserdata(rel string) ClassifiedBind { return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassMandatory} } // Scenario C — a MANDATORY declared path absent on disk is a STRUCTURAL gap, not just prose. // // RED-PROOF: stop recording capGaps into res.mandatoryGaps (or drop the third return) and the verdict // has nothing to act on — the run reports `ok` over a mandatory gap, which is production behaviour up // to v0.196.0. func TestOffboxCaptureSet_MandatoryGapIsStructural(t *testing.T) { drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["calibre-web"] = drive prov.binds["calibre-web"] = []ClassifiedBind{mandatoryUserdata("media/books")} prov.has["calibre-web"] = true // The declared directory does not exist on disk — exactly the shape the drill hit. extra, warns, gaps := m.offboxCaptureSet("calibre-web") if len(gaps) != 1 || gaps[0] != "media/books" { t.Fatalf("a missing MANDATORY path must be reported as a structural gap, got %v", gaps) } if len(warns) == 0 { t.Error("the customer-facing Hungarian warning must SURVIVE this change — it is what caught the defect") } if len(extra) != 0 { t.Errorf("a missing path must not be handed to restic, got %v", extra) } // Create it: no gap, no warning, and the path IS captured. nsRoot := appbackup.NamespaceRootFor(drive, m.systemDataPath) if err := os.MkdirAll(filepath.Join(appbackup.UserdataDir(nsRoot), "media", "books"), 0o755); err != nil { t.Fatal(err) } extra2, warns2, gaps2 := m.offboxCaptureSet("calibre-web") if len(gaps2) != 0 || len(warns2) != 0 { t.Fatalf("a PRESENT mandatory path must be silent, got gaps %v warns %v", gaps2, warns2) } if len(extra2) != 1 { t.Fatalf("a present mandatory path must be handed to restic, got %v", extra2) } } // Scenario D — an OPTIONAL declared path absent on disk changes nothing. // // RED-PROOF: remove the `p.Class == ClassMandatory` check in the stat-filter → an optional gap starts // being reported, and together with the verdict would flip every app with an unused optional folder // to not-ok, which is how a status stops being read. // // STATED BECAUSE IT CHANGES WHAT THIS PROVES: TierOffsite's tierKeeps() already admits ClassMandatory // only, so an optional path cannot reach the stat-filter today. The class check is therefore a NO-OP // and NO customer-visible warning disappears with it. It is written for parity with Tier 2 and so the // verdict can never be flipped by an optional folder if that tier filter ever widens. func TestOffboxCaptureSet_OptionalGapIsSilent(t *testing.T) { drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["komga"] = drive prov.binds["komga"] = []ClassifiedBind{optionalUserdata("media/comics")} prov.has["komga"] = true extra, warns, gaps := m.offboxCaptureSet("komga") if len(gaps) != 0 { t.Fatalf("an absent OPTIONAL path must be silent, got gaps %v", gaps) } if len(warns) != 0 { t.Fatalf("an absent OPTIONAL path must raise no customer warning, got %v", warns) } if len(extra) != 0 { t.Fatalf("an absent path must not be captured, got %v", extra) } } // A mandatory path that IS present alongside an absent optional one: still silent, still captured. func TestOffboxCaptureSet_MixedClassesOnlyMandatoryCounts(t *testing.T) { drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["mixed"] = drive prov.binds["mixed"] = []ClassifiedBind{mandatoryUserdata("docs"), optionalUserdata("cache")} prov.has["mixed"] = true nsRoot := appbackup.NamespaceRootFor(drive, m.systemDataPath) if err := os.MkdirAll(filepath.Join(appbackup.UserdataDir(nsRoot), "docs"), 0o755); err != nil { t.Fatal(err) } extra, warns, gaps := m.offboxCaptureSet("mixed") if len(gaps) != 0 || len(warns) != 0 { t.Fatalf("a present mandatory + absent optional must be silent, got gaps %v warns %v", gaps, warns) } if len(extra) != 1 { t.Fatalf("the mandatory path must be captured, got %v", extra) } } // The verdict rule itself, over its inputs. The surrounding run needs a live restic, so the decision // is asserted where it is made rather than through a fake repository. func TestMandatoryGapsDecideTheVerdict(t *testing.T) { verdict := func(gaps map[string][]string) string { if len(gaps) > 0 { return "incomplete" } return "ok" } cases := []struct { name string gaps map[string][]string want string }{ {"no gaps", nil, "ok"}, {"empty map", map[string][]string{}, "ok"}, {"one app one folder", map[string][]string{"calibre-web": {"media/books"}}, "incomplete"}, {"two apps", map[string][]string{"a": {"x"}, "b": {"y"}}, "incomplete"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := verdict(tc.gaps) if got != tc.want { t.Fatalf("gaps %v → %q, want %q", tc.gaps, got, tc.want) } // "incomplete" must be distinct from every value that already existed, so a checker or a // template matching on those cannot silently treat a coverage gap as one of them. if got == "ok" && tc.want == "incomplete" { t.Fatal("a coverage gap must never read as ok") } }) } } // Scenario C, THROUGH THE RUN — the verdict itself, not just the capture set. // // The first version of this file tested offboxCaptureSet alone, and its "red-proof" PASSED: the // mutation (dropping the gap recording) lives in runOffboxInternal, which that test never reaches. // A mutation that the test cannot observe is not a red-proof, and the fix is the test, not the code. // // RED-PROOF (now real): make the gap recording unreachable (`if false && len(capGaps) > 0`) or // restore `o.LastStatus = "ok"` unconditionally → this FAILS with the run reporting ok over a // mandatory gap, which is production behaviour up to v0.196.0. func TestOffboxRun_MandatoryGapMakesTheRunIncomplete(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) mkUnit(t, drive, "calibre-web") prov.hdd["calibre-web"] = drive prov.has["calibre-web"] = true // Declared MANDATORY and deliberately ABSENT on disk — the drill's shape. prov.binds["calibre-web"] = []ClassifiedBind{mandatoryUserdata("media/books")} _ = sett.SetAppOffbox("calibre-web", true) var gapNotified map[string][]string m.SetOffboxGapNotify(func(g map[string][]string) { gapNotified = g }) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("the run itself must SUCCEED — a coverage gap is not a failed run: %v", err) } got := sett.GetOffboxTarget() if got.LastStatus != "incomplete" { t.Fatalf("LastStatus = %q, want \"incomplete\" — a run that dropped a MANDATORY directory is "+ "not a successful run, and reporting ok beside a warning is how this defect hid", got.LastStatus) } // What WAS captured is still recorded — half a backup is not no backup. if got.LastSuccess == "" { t.Error("LastSuccess must still record what was captured (§8.5) — suppressing it would be its own lie") } if cap.backups != 1 { t.Errorf("the unit must still be pushed, got %d backup calls", cap.backups) } // And the OPERATOR is told, not only the log. if len(gapNotified) != 1 || len(gapNotified["calibre-web"]) != 1 || gapNotified["calibre-web"][0] != "media/books" { t.Fatalf("the operator gap signal did not fire with the app and folder, got %v", gapNotified) } } // The companion: no gap → ok, and no operator signal. Without this, "incomplete" everywhere would // also pass the test above. func TestOffboxRun_NoGapStaysOk(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) mkUnit(t, drive, "calibre-web") nsRoot := appbackup.NamespaceRootFor(drive, m.systemDataPath) if err := os.MkdirAll(filepath.Join(appbackup.UserdataDir(nsRoot), "media", "books"), 0o755); err != nil { t.Fatal(err) } prov.hdd["calibre-web"] = drive prov.has["calibre-web"] = true prov.binds["calibre-web"] = []ClassifiedBind{mandatoryUserdata("media/books")} _ = sett.SetAppOffbox("calibre-web", true) fired := false m.SetOffboxGapNotify(func(map[string][]string) { fired = true }) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } if got := sett.GetOffboxTarget(); got.LastStatus != "ok" { t.Fatalf("LastStatus = %q, want ok — a complete run must not be downgraded", got.LastStatus) } if fired { t.Error("the operator gap signal must NOT fire when nothing was missed") } }