package backup import ( "context" "os" pathpkg "path" "path/filepath" "strings" "sync" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // mandAbs builds the capture-set Abs the code produces: ComputeCaptureSet uses path.Join (slash) — // the 3-core separator rule — so on the Windows test host the mandatory path is drive + "/rel". func mandAbs(drive, rel string) string { return pathpkg.Join(drive, rel) } // offbox3aProvider is a configurable StackDataProvider for the 3a capture-set tests: per-stack HDD // path + classified binds. type offbox3aProvider struct { hdd map[string]string binds map[string][]ClassifiedBind has map[string]bool } func (p *offbox3aProvider) GetStackComposePath(string) (string, bool) { return "", false } func (p *offbox3aProvider) ListDeployedStacks() []StackSummary { return nil } func (p *offbox3aProvider) GetStackHDDMounts(string) []string { return nil } func (p *offbox3aProvider) GetStackHDDPath(n string) string { return p.hdd[n] } func (p *offbox3aProvider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *offbox3aProvider) GetDockerVolumes(string) []string { return nil } func (p *offbox3aProvider) StopStack(string) error { return nil } func (p *offbox3aProvider) StartStack(string) error { return nil } func (p *offbox3aProvider) RefreshAndIsRunning(string) bool { return false } func (p *offbox3aProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return RecoveryInfo{}, false } func (p *offbox3aProvider) RecoverStackSecrets(string, []string) map[string]string { return nil } func (p *offbox3aProvider) RecreateStackDefinitionFromUnit(_, _ string, _ map[string]string) error { return nil } func (p *offbox3aProvider) StartStackServices(string, []string) error { return nil } func (p *offbox3aProvider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) { return p.binds[n], p.has[n] } func mandatoryHDD(rel string) ClassifiedBind { return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory} } func optionalUserdata(rel string) ClassifiedBind { return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel, ReadOnly: true}, Class: appbackup.ClassOptional} } func excludedHDD(rel string) ClassifiedBind { return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded} } // classifiedOffboxManager: a configured offbox manager + a classified provider + the drive registered // as a schedulable storage path (so discoverOffboxUnit finds units on it). func classifiedOffboxManager(t *testing.T, drive string) (*Manager, *settings.Settings, *offbox3aProvider) { t.Helper() m, sett := newOffboxManager(t) prov := &offbox3aProvider{hdd: map[string]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}} m.SetStackProvider(prov) if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "USB", Schedulable: true}); err != nil { t.Fatal(err) } return m, sett, prov } // mkUnit lays down a discoverable recovery unit for stack on drive. func mkUnit(t *testing.T, drive, stack string) string { t.Helper() u := RecoveryUnitPath(drive, stack) if err := os.MkdirAll(u, 0o755); err != nil { t.Fatal(err) } return u } // captureBackupRunner records the FULL argv of each backup call (keyed by stack tag) + forget argv, and // answers the probes so RunOffboxBackup completes. type backupCapture struct { mu sync.Mutex byStack map[string][]string forgets [][]string backups int } func (c *backupCapture) runner() offboxRunner { c.byStack = map[string][]string{} return func(_ context.Context, _ []string, args ...string) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() switch { case contains(args, "cat") && contains(args, "config"): return []byte(`{"version":2}`), nil case contains(args, "backup"): c.backups++ c.byStack[tagOf(args)] = append([]string{}, args...) return nil, nil case contains(args, "forget"): c.forgets = append(c.forgets, append([]string{}, args...)) return nil, nil case contains(args, "snapshots"): return []byte(`[]`), nil case contains(args, "stats"): return []byte(`{"total_size":123}`), nil } return nil, nil } } // --- Scenario A: classified enlarged push (immich shape) --- func TestOffbox3a_EnlargedPush_MandatoryOnly(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) unit := mkUnit(t, drive, "immich") if err := os.MkdirAll(filepath.Join(drive, "appdata", "immich"), 0o755); err != nil { t.Fatal(err) } // The optional :ro library exists on disk — so if the tier filter ever leaked it, the stat-filter // would NOT hide it (this makes the RP-A tier-filter red-proof observable). if err := os.MkdirAll(filepath.Join(drive, "userdata", "media", "photos"), 0o755); err != nil { t.Fatal(err) } prov.hdd["immich"] = drive prov.has["immich"] = true prov.binds["immich"] = []ClassifiedBind{mandatoryHDD("appdata/immich"), optionalUserdata("media/photos")} _ = sett.SetAppOffbox("immich", true) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } if cap.backups != 1 { t.Fatalf("exactly ONE snapshot per app, got %d backup calls", cap.backups) } args := cap.byStack["immich"] wantMandatory := mandAbs(drive, "appdata/immich") if !contains(args, unit) { t.Errorf("backup argv missing the unit path %q: %v", unit, args) } if !contains(args, wantMandatory) { t.Errorf("backup argv missing the mandatory userdata path %q: %v", wantMandatory, args) } if contains(args, mandAbs(drive, "userdata/media/photos")) { t.Errorf("OPTIONAL :ro path must NOT ship offsite: %v", args) } } // --- Scenario B: legacy / undeployed stay unit-only --- func TestOffbox3a_LegacyUnitOnly(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) unit := mkUnit(t, drive, "sonarr") if err := os.MkdirAll(filepath.Join(drive, "appdata", "sonarr"), 0o755); err != nil { t.Fatal(err) } prov.hdd["sonarr"] = drive prov.has["sonarr"] = false // block REJECTED / absent → legacy (binds present but no class semantics) prov.binds["sonarr"] = []ClassifiedBind{mandatoryHDD("appdata/sonarr")} _ = sett.SetAppOffbox("sonarr", true) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } args := cap.byStack["sonarr"] // unit-only: exactly the base shape, last arg is the unit, no extra resolved paths. if args[len(args)-1] != unit { t.Errorf("legacy app argv must END at the unit (no resolved paths), got %v", args) } for _, a := range args { if strings.Contains(a, "appdata") || strings.Contains(a, "userdata") { t.Errorf("legacy app resolved a bind into offsite argv (SQ5 regression): %v", args) } } } func TestOffbox3a_UndeployedUnitOnlyWithWarning(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) _ = mkUnit(t, drive, "immich") prov.hdd["immich"] = "" // undeployed → no live HDD_PATH prov.has["immich"] = true prov.binds["immich"] = []ClassifiedBind{mandatoryHDD("appdata/immich")} _ = sett.SetAppOffbox("immich", true) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } args := cap.byStack["immich"] if strings.Contains(strings.Join(args, " "), "appdata") { t.Errorf("undeployed app must push unit-only: %v", args) } if w := sett.GetOffboxTarget().LastWarning; !strings.Contains(w, "nincs telepítve") { t.Errorf("undeployed warning missing from LastWarning: %q", w) } } // --- Scenario C: pre-push enlargement gate --- func TestOffbox3a_EnlargementGateBlocks(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) for _, app := range []string{"immich", "small"} { _ = mkUnit(t, drive, app) if err := os.MkdirAll(filepath.Join(drive, "appdata", app), 0o755); err != nil { t.Fatal(err) } prov.hdd[app] = drive prov.has[app] = true prov.binds[app] = []ClassifiedBind{mandatoryHDD("appdata/" + app)} _ = sett.SetAppOffbox(app, true) } _ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 50; o.RepoSizeBytes = 20 << 30 }) // immich's mandatory set is 40 GiB (20+40 ≥ 50 → blocked); small's is 1 GiB (20+1 < 50 → fits). m.SetOffboxSizer(func(p string) int64 { if strings.Contains(p, "immich") { return 40 << 30 } return 1 << 30 }) var noteMu sync.Mutex var notes []string m.SetOffboxEnlargeBlockedNotifier(func(stack string, _ int64, usedGB, quotaGB int) { noteMu.Lock() defer noteMu.Unlock() notes = append(notes, stack) if usedGB != 20 || quotaGB != 50 { t.Errorf("notifier numbers wrong: used=%d quota=%d", usedGB, quotaGB) } }) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run must be OK (a blocked enlargement is not a run failure): %v", err) } // immich → unit-only; small → enlarged. if strings.Contains(strings.Join(cap.byStack["immich"], " "), "appdata") { t.Errorf("blocked immich must be unit-only: %v", cap.byStack["immich"]) } if !contains(cap.byStack["small"], mandAbs(drive, "appdata/small")) { t.Errorf("fitting 'small' must still push enlarged: %v", cap.byStack["small"]) } tgt := sett.GetOffboxTarget() if len(tgt.EnlargedBlocked) != 1 || tgt.EnlargedBlocked[0] != "immich" { t.Errorf("EnlargedBlocked = %v, want [immich]", tgt.EnlargedBlocked) } if tgt.LastStatus != "ok" { t.Errorf("run status = %q, want ok", tgt.LastStatus) } if !strings.Contains(tgt.LastWarning, "tárhelykeret miatt") || !strings.Contains(tgt.LastWarning, "immich") { t.Errorf("blocked LastWarning missing: %q", tgt.LastWarning) } if len(notes) != 1 || notes[0] != "immich" { t.Errorf("notifier must fire ONCE for immich, got %v", notes) } // EnlargedBlocked clears on a subsequent run where nothing is blocked. m.SetOffboxSizer(func(string) int64 { return 1 << 30 }) // now immich fits too if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatal(err) } if b := sett.GetOffboxTarget().EnlargedBlocked; len(b) != 0 { t.Errorf("EnlargedBlocked must clear when nothing is blocked, got %v", b) } } // --- Scenario D: capture gaps are loud (SP-3.4) --- func TestOffbox3a_CaptureGapsAreLoud(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) _ = mkUnit(t, drive, "app") if err := os.MkdirAll(filepath.Join(drive, "appdata", "good"), 0o755); err != nil { t.Fatal(err) } prov.hdd["app"] = drive prov.has["app"] = true prov.binds["app"] = []ClassifiedBind{ mandatoryHDD("appdata/good"), // exists → captured mandatoryHDD("../evil"), // D1: traversal → Skipped mandatoryHDD("appdata/ghost"), // D2: passes guards but absent on disk → stat-filtered } _ = sett.SetAppOffbox("app", true) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } args := cap.byStack["app"] joined := strings.Join(args, " ") if !contains(args, mandAbs(drive, "appdata/good")) { t.Errorf("the valid mandatory path must still push: %v", args) } if strings.Contains(joined, "evil") { t.Errorf("traversal path escaped into argv: %v", args) } if strings.Contains(joined, "ghost") { t.Errorf("stat-missing mandatory path must NOT be in argv (SP-3.4 silent-skip): %v", args) } if w := sett.GetOffboxTarget().LastWarning; !strings.Contains(w, "nem kerültek a távoli mentésbe") { t.Errorf("capture-gap warning missing from LastWarning: %q", w) } } // --- §8 all-excluded row (radarr shape): unit-only, NO warning --- func TestOffbox3a_AllExcludedUnitOnlyNoWarning(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) unit := mkUnit(t, drive, "radarr") prov.hdd["radarr"] = drive prov.has["radarr"] = true prov.binds["radarr"] = []ClassifiedBind{excludedHDD("appdata/radarr"), excludedHDD("downloads")} _ = sett.SetAppOffbox("radarr", true) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatalf("run: %v", err) } if args := cap.byStack["radarr"]; args[len(args)-1] != unit { t.Errorf("all-excluded app must be unit-only: %v", args) } if w := sett.GetOffboxTarget().LastWarning; strings.Contains(w, "nem kerültek") { t.Errorf("all-excluded is correct, NOT a gap — no warning expected, got %q", w) } } // --- Scenario E: raw-data stats mode --- func TestOffbox3a_StatsRawDataMode(t *testing.T) { m, sett := newOffboxManager(t) var statsArgs []string m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { switch { case contains(args, "snapshots"): return []byte(`[{"id":"a"}]`), nil case contains(args, "stats"): statsArgs = append([]string{}, args...) return []byte(`{"total_size":987654321}`), nil } return nil, nil }) base, env := m.offboxBaseArgs(sett.GetOffboxTarget()) m.offboxRecordStats(context.Background(), base, env) if !contains(statsArgs, "--mode") || valAfter(statsArgs, "--mode") != "raw-data" { t.Fatalf("stats must run in raw-data mode, got %v", statsArgs) } if got := sett.GetOffboxTarget().RepoSizeBytes; got != 987654321 { t.Errorf("RepoSizeBytes = %d, want 987654321 (parsed from raw-data total_size)", got) } } // --- Scenario F: both forget call sites carry --group-by host,tags --- func TestOffbox3a_ForgetGrouping_MainRun(t *testing.T) { drive := t.TempDir() m, sett, prov := classifiedOffboxManager(t, drive) _ = mkUnit(t, drive, "app") prov.has["app"] = false _ = sett.SetAppOffbox("app", true) cap := &backupCapture{} m.SetOffboxRunner(cap.runner()) if err := m.RunOffboxBackup(context.Background()); err != nil { t.Fatal(err) } if len(cap.forgets) != 1 { t.Fatalf("expected one forget call, got %d", len(cap.forgets)) } if valAfter(cap.forgets[0], "--group-by") != "host,tags" { t.Errorf("main-run forget missing --group-by host,tags: %v", cap.forgets[0]) } } func TestOffbox3a_ForgetGrouping_OverQuotaPrune(t *testing.T) { m, sett := newOffboxManager(t) _ = sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.QuotaGB = 50; o.RepoSizeBytes = 51 << 30 }) var forgetArgs []string m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { switch { case contains(args, "cat") && contains(args, "config"): return []byte(`{}`), nil case contains(args, "forget"): forgetArgs = append([]string{}, args...) case contains(args, "snapshots"): return []byte(`[]`), nil case contains(args, "stats"): return []byte(`{"total_size":1}`), nil } return nil, nil }) _ = m.RunOffboxBackup(context.Background()) // over-quota → prune-only path if valAfter(forgetArgs, "--group-by") != "host,tags" { t.Errorf("over-quota prune forget missing --group-by host,tags: %v", forgetArgs) } } // --- Scenario E-restore: unit-only restore argv (ID-first + --include) + scratch OFF the rootfs --- func TestOffbox3a_UnitOnlyRestoreArgv(t *testing.T) { drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["immich"] = drive m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 }) // plenty unitPath := filepath.ToSlash(filepath.Join(drive, "backups", "primary", "immich")) var restoreArgs []string m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { switch { case contains(args, "snapshots"): return []byte(`[{"short_id":"deadbeef","time":"2026-07-14T00:00:00Z","paths":["` + filepath.ToSlash(filepath.Join(drive, "appdata", "immich")) + `","` + unitPath + `"]}]`), nil case contains(args, "restore"): restoreArgs = append([]string{}, args...) } return nil, nil }) if err := m.RestoreOffboxScratch(context.Background(), "immich", false); err != nil { t.Fatalf("unit-only restore: %v", err) } if valAfter(restoreArgs, "restore") != "deadbeef" { t.Errorf("restore must be ID-first (deadbeef): %v", restoreArgs) } if valAfter(restoreArgs, "--include") != unitPath { t.Errorf("unit-only restore must --include the absolute unit path %q: %v", unitPath, restoreArgs) } target := valAfter(restoreArgs, "--target") if !strings.HasPrefix(target, drive) || strings.Contains(target, m.cfg.Paths.DataDir) { t.Errorf("scratch target must be on the data drive, never DataDir: %q", target) } } // full restore refuses fail-closed when the snapshot size is unknown (no restore call made). func TestOffbox3a_FullRestoreRefusesOnSizeUnknown(t *testing.T) { drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["immich"] = drive m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 }) unitPath := filepath.Join(drive, "backups", "primary", "immich") restoreCalled := false m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { switch { case contains(args, "snapshots"): return []byte(`[{"short_id":"a","time":"2026-07-14T00:00:00Z","paths":["` + filepath.ToSlash(unitPath) + `"]}]`), nil case contains(args, "stats"): return nil, context.DeadlineExceeded // size lookup fails → unknown case contains(args, "restore"): restoreCalled = true } return nil, nil }) err := m.RestoreOffboxScratch(context.Background(), "immich", true) if err == nil || !strings.Contains(err.Error(), "nem állapítható meg") { t.Fatalf("full restore must refuse fail-closed on unknown size, got err=%v", err) } if restoreCalled { t.Error("no restic restore call may run when the size is unknown") } } // old rootfs scratch is cleaned up on a new restore. func TestOffbox3a_LegacyRootfsScratchCleanup(t *testing.T) { drive := t.TempDir() m, _, prov := classifiedOffboxManager(t, drive) prov.hdd["immich"] = drive m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 }) legacy := filepath.Join(m.cfg.Paths.DataDir, "offbox-restore", "immich") if err := os.MkdirAll(legacy, 0o755); err != nil { t.Fatal(err) } unitPath := filepath.Join(drive, "backups", "primary", "immich") m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) { if contains(args, "snapshots") { return []byte(`[{"short_id":"a","time":"2026-07-14T00:00:00Z","paths":["` + filepath.ToSlash(unitPath) + `"]}]`), nil } return nil, nil }) if err := m.RestoreOffboxScratch(context.Background(), "immich", false); err != nil { t.Fatal(err) } if _, err := os.Stat(legacy); !os.IsNotExist(err) { t.Errorf("legacy rootfs scratch %s must be removed, stat err=%v", legacy, err) } } // --- Scenario G: place-to-live mapping (pure) + the wrong cases --- func TestMapOffsiteRestorePaths(t *testing.T) { old := "/old/ns" newNs := "/new/ns" scratch := "/scratch" snap := []string{ old + "/backups/primary/app", old + "/appdata/app", old + "/userdata/media/x", } got, err := mapOffsiteRestorePaths(snap, "app", scratch, newNs) if err != nil { t.Fatalf("unexpected err: %v", err) } if len(got) != 3 { t.Fatalf("got %d placements, want 3: %+v", len(got), got) } byDst := map[string]placement{} for _, pl := range got { byDst[pl.dst] = pl } // anchor derived by trimming backups/primary/app off the unit path → oldNs; dst = newNs/, // src = scratch/ (SP-3.1). Built with filepath.Join to match the code (OS separators). check := func(snapPath, rel string, isUnit bool) { dst := filepath.Join(newNs, rel) pl, ok := byDst[dst] if !ok { t.Errorf("missing placement for dst %q", dst) return } if pl.src != filepath.Join(scratch, snapPath) { t.Errorf("src for %q = %q, want %q", snapPath, pl.src, filepath.Join(scratch, snapPath)) } if pl.isUnit != isUnit { t.Errorf("isUnit for %q = %v, want %v", snapPath, pl.isUnit, isUnit) } } check(old+"/backups/primary/app", "backups/primary/app", true) check(old+"/appdata/app", "appdata/app", false) check(old+"/userdata/media/x", "userdata/media/x", false) // Wrong cases — each REFUSES the whole placement. if _, err := mapOffsiteRestorePaths([]string{old + "/appdata/app"}, "app", scratch, newNs); err == nil { t.Error("no unit path → must refuse") } if _, err := mapOffsiteRestorePaths([]string{old + "/backups/primary/app", "/elsewhere/x"}, "app", scratch, newNs); err == nil { t.Error("a path outside the namespace → must refuse") } if _, err := mapOffsiteRestorePaths([]string{old + "/backups/primary/app", old + "/backups/secondary/y"}, "app", scratch, newNs); err == nil { t.Error("a non-unit path in the reserved backups/ zone → must refuse") } }