package backup import ( "context" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // ── R-399 — the off-site check reads the DATA, not just the catalogue ──────────────────────────── // // THE MEASUREMENT THESE TESTS DEFEND. On demo-hp, 2026-08-30, a pack in a real 134 MB store was // damaged WITHOUT changing its size. Plain `restic check` — the structure-and-index check that every // box in the fleet ran — reported `no errors were found` and exited clean. Every `--read-data*` form // caught it. Cost of the deeper run on that store: 39.2 s versus 35.0 s. // // So the assertions below are on the ARGUMENT LIST, never on the absence of an error. "The check // passed" is exactly what the broken configuration produced; only the argv can tell the two apart. // readDataArg returns the --read-data* argument the check passed to restic, or "" when it passed none. func readDataArg(argv []string) string { for _, a := range argv { if strings.HasPrefix(a, "--read-data") { return a } } return "" } // A1 — the fleet's actual configuration: no integrity block at all. func TestR399_AbsentConfigRunsFullDepth(t *testing.T) { m, cap := newIntegrityManager(t, okRepo(nil)) // Deliberately touch nothing: this is a box whose controller.yaml has no `integrity:` key. res := m.CheckOffboxIntegrity(context.Background()) argv := cap.checkArgv() if argv == nil { t.Fatal("no check ran") } if got := readDataArg(argv); got != "--read-data-subset=100%" { t.Fatalf("an unconfigured box ran at depth %q, want --read-data-subset=100%%; argv=%v\n"+ "A structure-only run is what every box did before R-399, and it PASSED a size-preserving "+ "pack corruption on real hardware — the store's rot would be found at restore time", got, argv) } if res.ReadDataSubset != "100%" { t.Errorf("the result did not record the depth it actually ran at: %+v", res) } } // A2 — an empty string is NOT the off switch. Empty means "not configured", so it means the default. func TestR399_EmptyStringIsNotOff(t *testing.T) { m, cap := newIntegrityManager(t, okRepo(nil)) m.cfg.Monitoring.Integrity.ReadDataSubset = "" m.CheckOffboxIntegrity(context.Background()) if got := readDataArg(cap.checkArgv()); got != "--read-data-subset=100%" { t.Fatalf("an empty value was treated as OFF (%q) — emptiness must mean 'not configured', or "+ "there is no way to distinguish an unset key from a deliberate downgrade", got) } } // A3 — the off switch. A setting with no off switch is not a setting. func TestR399_OffTokenRunsStructureOnly(t *testing.T) { m, cap := newIntegrityManager(t, okRepo(nil)) m.cfg.Monitoring.Integrity.ReadDataSubset = "off" res := m.CheckOffboxIntegrity(context.Background()) if got := readDataArg(cap.checkArgv()); got != "" { t.Fatalf("the off token still downloaded pack data (%q) — there would be no way to switch the "+ "deep check off without editing code", got) } if res.ReadDataSubset != "" { t.Errorf("a structure-only run recorded a subset: %q", res.ReadDataSubset) } if code := IntegrityDepthCode(res.ReadDataSubset); code != "structure" { t.Errorf("structure depth must be RECORDED as %q, not as an empty string a reader has to "+ "interpret; got %q", "structure", code) } } // A4 — an operator writing "OFF" or "Off" in a YAML file means the same thing. func TestR399_OffTokenIsCaseInsensitive(t *testing.T) { for _, tok := range []string{"OFF", "Off", " oFf "} { m, cap := newIntegrityManager(t, okRepo(nil)) m.cfg.Monitoring.Integrity.ReadDataSubset = tok m.CheckOffboxIntegrity(context.Background()) if got := readDataArg(cap.checkArgv()); got != "" { t.Errorf("%q was not recognised as the off token (argv carried %q)", tok, got) } } } // A5 — an explicit value wins over both the default and the off token. func TestR399_ExplicitValueWins(t *testing.T) { m, cap := newIntegrityManager(t, okRepo(nil)) m.cfg.Monitoring.Integrity.ReadDataSubset = "10%" res := m.CheckOffboxIntegrity(context.Background()) if got := readDataArg(cap.checkArgv()); got != "--read-data-subset=10%" { t.Fatalf("an explicit 10%% ran as %q — a chosen value must not be overridden by a default", got) } if res.ReadDataSubset != "10%" { t.Errorf("result recorded %q, want 10%%", res.ReadDataSubset) } } // A6 — a typo falls back to the DEFAULT, not to structure depth. // // The direction is the whole point. Passing "banana" through fails the entire check; downgrading to // structure would silently remove the protection R-399 exists to add, which is R-357's shape exactly — // a guard that opens quietly. Falling back to the default keeps the protection and still says loudly // that the config is wrong. func TestR399_MalformedFallsBackToTheDefault(t *testing.T) { m, cap := newIntegrityManager(t, okRepo(nil)) m.cfg.Monitoring.Integrity.ReadDataSubset = "banana" res := m.CheckOffboxIntegrity(context.Background()) argv := cap.checkArgv() for _, a := range argv { if strings.Contains(a, "banana") { t.Fatalf("a malformed value was handed to restic (%q) — restic rejects it and the WHOLE "+ "check fails, so one typo would stop the store being verified at all", a) } } if got := readDataArg(argv); got != "--read-data-subset=100%" { t.Fatalf("a typo downgraded the check to %q instead of falling back to the default 100%% — "+ "a guard that opens quietly on a typo is R-357's failure", got) } if res.ReadDataSubset != "100%" { t.Errorf("result recorded %q after a refused value, want the default", res.ReadDataSubset) } log := cap.logBuf.String() if !strings.Contains(log, "WARN") || !strings.Contains(log, "banana") { t.Errorf("a refused config value must WARN and NAME the bad value; log was:\n%s", log) } } // A7 — the depth is stated in the outcome, or the result cannot be judged afterwards. func TestR399_DepthIsStatedInTheOutcome(t *testing.T) { m, cap := newIntegrityManager(t, okRepo(nil)) res := m.CheckOffboxIntegrity(context.Background()) if !strings.Contains(cap.logBuf.String(), "100%") { t.Errorf("the PASSED log line does not say how deep the check looked:\n%s", cap.logBuf.String()) } // And it is PERSISTED with the verdict, so a later reader of the stored state can judge it too. m.RecordIntegrityVerdict(res) tgt := m.settings.GetOffboxTarget() if tgt == nil || tgt.LastIntegrityDepth != "100%" { t.Fatalf("the stored verdict does not carry its depth: %+v — 'checked, OK' means two different "+ "things at structure depth and at 100%%", tgt) } } // A8 — THE SEAM TEST. Everything above sets the config field directly; this one proves the default // survives the PRODUCTION resolution path: real YAML bytes -> config.LoadFromBytes -> the accessor -> // the argv. A default that only works when a test hand-builds the struct is the inert-seam failure // this project has shipped four times. func TestR399_DefaultResolvesThroughTheRealConfigPath(t *testing.T) { // A minimal but VALID controller.yaml — LoadFromBytes validates, and a config that fails // validation would never reach a running box, so a fixture that skipped the required keys would // not be the production path. const yaml = ` customer: id: "demo-hp" domain: "felhom.example.hu" monitoring: enabled: true thresholds: disk_warn_percent: 80 ` loaded, err := config.LoadFromBytes([]byte(yaml)) if err != nil { t.Fatalf("the fixture config does not parse: %v", err) } if loaded.Monitoring.Integrity.ReadDataSubset != "" { t.Fatalf("fixture wrong: the parsed config already carries a subset %q, so this test would "+ "prove nothing about the ABSENT case", loaded.Monitoring.Integrity.ReadDataSubset) } m, cap := newIntegrityManager(t, okRepo(nil)) // Only the parsed Monitoring block is transplanted; Paths must stay pointing at the test's temp // dir. The seam under test is config-parse -> Monitoring.Integrity -> integrityReadDataSubset. m.cfg.Monitoring = loaded.Monitoring m.CheckOffboxIntegrity(context.Background()) if got := readDataArg(cap.checkArgv()); got != "--read-data-subset=100%" { t.Fatalf("a real controller.yaml with no `integrity:` block resolved to depth %q, want "+ "--read-data-subset=100%% — that is every box in the fleet today", got) } }