package backup import ( "encoding/json" "io" "log" "os" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // hollowProvider: per-app volumes and running state; everything else from floorProvider. type hollowProvider struct { floorProvider volumes map[string][]string running map[string]bool } func (p *hollowProvider) GetDockerVolumes(n string) []string { return p.volumes[n] } func (p *hollowProvider) RefreshAndIsRunning(n string) bool { return p.running[n] } // Part D (v0.279.0) — the CONSEQUENCE: a running app whose newest copy holds no data reaches the operator // (once per app per tier per day) and the backup page; a copy with data clears it; a stopped (held) app // and an app with no volumes are never flagged. // COMPANION RED-PROOF: make judgeCopy ignore unitCarriesData (the pre-0.279.0 shape: nothing but a log) → // no notification and the first assertion fails. func TestPartD_ARunningAppWithAnEmptyCopyIsAnAlarm(t *testing.T) { sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.SystemDataPath = t.TempDir() m := NewManager(cfg, sett, log.New(io.Discard, "", 0)) p := &hollowProvider{floorProvider: floorProvider{stacks: []string{"cloud", "held", "novol"}, dir: t.TempDir()}, volumes: map[string][]string{"cloud": {"cloud_db"}, "held": {"held_db"}}, running: map[string]bool{"cloud": true, "novol": true}} m.SetStackProvider(p) day := time.Date(2026, 9, 28, 15, 20, 0, 0, time.UTC) m.hollow.now = func() time.Time { return day } var told []string m.SetHollowCopyNotify(func(app, tier string) { told = append(told, app+"/"+tier) }) unit := t.TempDir() writeMan := func(withData bool) { man := RecoveryManifest{SchemaVersion: 2, AppName: "cloud"} if withData { man.DBDumps = []string{"cloud.sql"} } b, _ := json.Marshal(man) _ = os.WriteFile(UnitManifestFile(unit), b, 0o644) } writeMan(false) // yesterday's shape: a manifest listing nothing for _, app := range []string{"cloud", "held", "novol"} { m.judgeCopy(app, HollowTierOffsite, unit) } if len(told) != 1 || told[0] != "cloud/offsite" { t.Fatalf("operator told %v, want exactly [cloud/offsite] (held+stopped and volume-less apps are not flagged)", told) } if h := m.HollowCopies(); len(h) != 1 || h[0].App != "cloud" || h[0].Tier != HollowTierOffsite { t.Fatalf("page list %+v, want the one running app", h) } // Same day again: still flagged, NOT told twice. m.judgeCopy("cloud", HollowTierOffsite, unit) if len(told) != 1 { t.Fatalf("told twice in one day: %v", told) } // A missing unit (no manifest) is also no data — fail closed. m.judgeCopy("cloud", HollowTierLocal, filepath.Join(unit, "absent")) if len(told) != 2 || told[1] != "cloud/local" { t.Fatalf("an absent local unit was not flagged: %v", told) } // The next day: told again. m.hollow.now = func() time.Time { return day.Add(24 * time.Hour) } m.judgeCopy("cloud", HollowTierOffsite, unit) if len(told) != 3 { t.Fatalf("not told again the next day: %v", told) } // The copy carries data again: cleared from the page. writeMan(true) m.judgeCopy("cloud", HollowTierOffsite, unit) m.judgeCopy("cloud", HollowTierLocal, unit) if h := m.HollowCopies(); len(h) != 0 { t.Fatalf("a copy with data is still listed: %+v", h) } }