Files
felhom-controller/controller/internal/backup/r399_depth_test.go
T
admin 3c49dc8ea4
gates / gates (push) Successful in 12s
v0.228.0 — the off-site check reads the data; the debug page stops lying (R-399 + R-400)
R-399: monitoring.integrity.read_data_subset defaults to 100%. A pack damaged
without changing its size made plain `restic check` report "no errors were found"
on demo-hp 2026-08-30; every read-data form caught it. Cost on that 134 MB store:
35.0s structure vs 39.2s at 100%. "off" (any case) is the off token; empty means
not-configured, therefore the default; a malformed value falls back to the DEFAULT,
never to structure. A completed check over 5 minutes logs a WARN naming the
duration, the depth and R-401 — operator log only, no hub event, no depth change.
The depth is now recorded with the verdict (LastIntegrityDepth; empty = NOT
RECORDED, never "structure").

R-400: 24 debug-page references, 17 dispatched, 7 dead — three of which fetched on
page LOAD, so those panels were permanently blank. backup/crossdrive implemented;
backup/infra, hub/infra-push, dr/infra-status, storage/watchdog-status and both
storage/simulate-* deleted with their panels and JavaScript.
scripts/debug_route_gate.py fails in both directions and is registered after the
seven were resolved. 18 referenced, 18 dispatched, none orphaned.

Corrections: the dead-field warning in report/types.go said the controller runs no
integrity check and the notifiers are called from nowhere — both false since
v0.227.0. controller.yaml.example gains its missing integrity: block.
integrityCheckTimeout's "ships OFF" comment rewritten.
2026-08-31 10:24:29 +02:00

194 lines
8.2 KiB
Go

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