Files
felhom-controller/controller/internal/backup/offbox_verdict_r203_test.go
T
admin 58c703bd44
gates / gates (push) Successful in 8s
R-203 Part 2: a run that missed a MANDATORY directory is not a successful run (v0.197.0)
The gap was already detected and warned about, in Hungarian, naming the app and the folders --
that warning is what stopped the R-201 drill. The defect was that the run still reported `ok`
beside it, and a warning standing beside a success is read as a success.

last_status gains "incomplete": minted, because "ok" | "error" | "running" had nothing meaning
"it ran, and this app is not fully protected". NOT "error" -- the rest of the run worked and
what was captured is real, so SnapshotCount and the LastSuccess anchor still record it. Half a
backup is not no backup.

The gaps are now recorded STRUCTURALLY (offboxRunResult.mandatoryGaps), not only as prose, so
the verdict has something to act on. It reaches the operator through the EXISTING per-run digest
(backup_run_failures) rather than a new event type -- a new type is a two-repo change and the
hub drops anything outside allowedEventTypes.

The stat-filter gains the ClassMandatory check Tier 2 already had. It is a NO-OP today
(TierOffsite admits mandatory only), so no customer-visible warning disappears -- demonstrated
by widening the tier filter alone and watching the check hold the line.

ANTICIPATED: calibre-web on demo-hp has exactly this gap, so its off-site status becomes
incomplete the moment this ships. That is correct and is the point.

Red-proofs: my first Scenario-C proof PASSED because the test only reached offboxCaptureSet
while the mutation lives in runOffboxInternal -- a mutation the test cannot observe is not a
red-proof, and the fix was the test. The run-level test now fails under both mutations
(unreachable gap recording; unconditional ok).
2026-08-04 18:32:56 +02:00

219 lines
9.0 KiB
Go

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