package backup import ( "io" "log" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" ) // t2v2Provider is a configurable StackDataProvider for the v2 tier-2 tests (per-stack hdd/mounts/binds). type t2v2Provider struct { hdd string mounts map[string][]string binds map[string][]ClassifiedBind has map[string]bool } func (p *t2v2Provider) GetStackComposePath(string) (string, bool) { return "", false } func (p *t2v2Provider) ListDeployedStacks() []StackSummary { return nil } func (p *t2v2Provider) GetStackHDDMounts(n string) []string { return p.mounts[n] } func (p *t2v2Provider) GetStackHDDPath(string) string { return p.hdd } func (p *t2v2Provider) GetImportRoot() string { return "" } // R-75: no import binds in this fixture func (p *t2v2Provider) GetDockerVolumes(string) []string { return nil } func (p *t2v2Provider) StopStack(string) error { return nil } func (p *t2v2Provider) StartStack(string) error { return nil } func (p *t2v2Provider) RefreshAndIsRunning(string) bool { return true } func (p *t2v2Provider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) { return RecoveryInfo{}, false } func (p *t2v2Provider) RecoverStackSecrets(string, []string) map[string]string { return nil } func (p *t2v2Provider) RecreateStackDefinitionFromUnit(string, string, map[string]string) error { return nil } func (p *t2v2Provider) StartStackServices(string, []string) error { return nil } func (p *t2v2Provider) GetStackClassifiedBinds(n string) ([]ClassifiedBind, bool) { return p.binds[n], p.has[n] } // newTier2V2 builds a Manager with a source drive (holding a recovery unit) + a registered off-drive // target, a real (copyTree) mirror seam so the v2 dest tree forms on disk, and the SSD headroom seam // forced to "fits" so the state-only branch is testable on any platform. func newTier2V2(t *testing.T, stack string) (m *Manager, src, target string, prov *t2v2Provider) { t.Helper() tmp := t.TempDir() src = filepath.Join(tmp, "usb") target = filepath.Join(tmp, "flash") sys := filepath.Join(tmp, "sys") sett, err := settings.Load(filepath.Join(tmp, "settings.json"), log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } if err := sett.AddStoragePath(settings.StoragePath{Path: target, Label: "flash", Schedulable: true}); err != nil { t.Fatal(err) } mustWrite(t, filepath.Join(RecoveryUnitPath(src, stack), "manifest.json"), "{}") prov = &t2v2Provider{hdd: src, mounts: map[string][]string{}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{}} cfg := &config.Config{} cfg.Paths.SystemDataPath = sys m = NewManager(cfg, sett, log.New(io.Discard, "", 0)) m.stackProvider = prov m.systemDataPath = sys m.tier2Mirror = copyTree m.tier2SSDFits = func(string, int64) bool { return true } m.samePhysicalDevice = oneDrivePerSubtree // src/target/sys are separate "drives" on one filesystem return m, src, target, prov } func mHDD(rel string) ClassifiedBind { return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory} } func oUD(rel string) ClassifiedBind { return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel, ReadOnly: true}, Class: appbackup.ClassOptional} } func xHDD(rel string) ClassifiedBind { return ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded} } func exists(p string) bool { _, err := os.Stat(p); return err == nil } // A — classified paperless: v2 tree, export ABSENT (copy shrinks), marker written. func TestTier2V2_ClassifiedPaperless(t *testing.T) { m, src, target, prov := newTier2V2(t, "paperless-ngx") prov.has["paperless-ngx"] = true prov.binds["paperless-ngx"] = []ClassifiedBind{mHDD("appdata/paperless/media"), xHDD("appdata/paperless/export"), xHDD("import/paperless")} // mounts point at the whole appdata/paperless dir — the LEGACY resolver set (captured only if a bug // routes a classified app through it; the A red-proof). The classified path ignores mounts. prov.mounts["paperless-ngx"] = []string{filepath.Join(src, "appdata", "paperless", "media"), filepath.Join(src, "appdata", "paperless", "export")} mustWrite(t, filepath.Join(src, "appdata", "paperless", "media", "doc.pdf"), "PDF") mustWrite(t, filepath.Join(src, "appdata", "paperless", "export", "junk.zip"), "ZIP") if err := m.RunTier2("paperless-ngx"); err != nil { t.Fatalf("RunTier2: %v", err) } destBase := filepath.Join(target, "backups", "secondary", "paperless-ngx") if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" { t.Errorf("marker = %q, want 2", got) } if !exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "media", "doc.pdf")) { t.Error("mandatory media leg missing from v2 dest") } if exists(filepath.Join(destBase, "hdd", "appdata", "paperless", "export")) { t.Error("excluded export must NOT appear in the tier-2 copy (legacy set leaking)") } if !exists(filepath.Join(destBase, "recovery-unit", "manifest.json")) { t.Error("recovery-unit leg missing") } } // B — legacy: resolver dirs as legs, N>1 works (no refusal). func TestTier2V2_LegacyMultiDir(t *testing.T) { m, src, target, prov := newTier2V2(t, "twodir") prov.has["twodir"] = false prov.mounts["twodir"] = []string{filepath.Join(src, "appdata", "alpha", "x"), filepath.Join(src, "appdata", "beta", "y")} mustWrite(t, filepath.Join(src, "appdata", "alpha", "a.txt"), "A") mustWrite(t, filepath.Join(src, "appdata", "beta", "b.txt"), "B") if err := m.RunTier2("twodir"); err != nil { t.Fatalf("RunTier2 must SUCCEED for N>1 now (refusal lifted): %v", err) } destBase := filepath.Join(target, "backups", "secondary", "twodir") if !exists(filepath.Join(destBase, "hdd", "appdata", "alpha", "a.txt")) || !exists(filepath.Join(destBase, "hdd", "appdata", "beta", "b.txt")) { t.Error("both legacy appdata dirs must be mirrored as separate v2 legs") } if cd := m.settings.GetCrossDriveConfig("twodir"); cd == nil || cd.LastStatus != "ok" { t.Errorf("want recorded ok, got %+v", cd) } } // C — migration = rebuild, marker LAST; a leg failure leaves NO marker. func TestTier2V2_MigrationAndMarkerLast(t *testing.T) { m, src, target, prov := newTier2V2(t, "app") prov.has["app"] = true prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/data")} mustWrite(t, filepath.Join(src, "appdata", "app", "data", "f"), "F") destBase := filepath.Join(target, "backups", "secondary", "app") // Pre-v2 dest: recovery-unit + old flat appdata, NO marker. mustWrite(t, filepath.Join(destBase, "recovery-unit", "old.json"), "{}") mustWrite(t, filepath.Join(destBase, "appdata", "stale.bin"), "STALE") if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } if exists(filepath.Join(destBase, "appdata")) { t.Error("old flat appdata/ must be removed on migration") } if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "data", "f")) { t.Error("v2 legs must be mirrored") } if got, _ := os.ReadFile(filepath.Join(destBase, tier2LayoutMarker)); string(got) != "2" { t.Errorf("marker not written LAST: %q", got) } // Marker-LAST on failure: a fresh app whose leg mirror fails must leave NO marker. m2, src2, target2, prov2 := newTier2V2(t, "boom") prov2.has["boom"] = true prov2.binds["boom"] = []ClassifiedBind{mHDD("appdata/boom/x")} mustWrite(t, filepath.Join(src2, "appdata", "boom", "x", "f"), "F") m2.tier2Mirror = func(s, d string) error { if strings.Contains(d, "boom") && strings.Contains(d, filepath.Join("hdd", "appdata")) { return os.ErrPermission // fail the LEG (not the unit) } return copyTree(s, d) } if err := m2.RunTier2("boom"); err == nil { t.Fatal("a leg mirror failure must surface as an error") } if exists(filepath.Join(target2, "backups", "secondary", "boom", tier2LayoutMarker)) { t.Error("marker must NOT be written when a leg fails (self-heal next run)") } } // D — reconcile: stale dir removed, ancestor/descendant kept; + the safe-remove boundary proof. func TestTier2V2_Reconcile(t *testing.T) { m, src, target, prov := newTier2V2(t, "app") prov.has["app"] = true prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/media")} mustWrite(t, filepath.Join(src, "appdata", "app", "media", "keep.jpg"), "J") destBase := filepath.Join(target, "backups", "secondary", "app") // Seed the dest with the marker + stale dirs a prior run left. mustWrite(t, filepath.Join(destBase, tier2LayoutMarker), "2") mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg"), "C") // descendant of leg → keep mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "app", "export", "e.bin"), "E") // sibling of leg → stale mustWrite(t, filepath.Join(destBase, "hdd", "appdata", "other", "o.bin"), "O") // unrelated → stale if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "media", "covers", "c.jpg")) { t.Error("descendant content under a leg must be kept") } if exists(filepath.Join(destBase, "hdd", "appdata", "app", "export")) { t.Error("stale sibling dir (re-classed/removed bind) must be reconciled away") } if exists(filepath.Join(destBase, "hdd", "appdata", "other")) { t.Error("unrelated stale dir must be reconciled away") } // Boundary proof: tier2SafeRemove refuses a target outside backups/secondary/. if err := tier2SafeRemove(destBase, filepath.Join(target, "live-appdata")); err == nil { t.Error("tier2SafeRemove must REFUSE a target outside destBase") } if err := tier2SafeRemove("/some/other/root", filepath.Join("/some/other/root", "x")); err == nil { t.Error("tier2SafeRemove must REFUSE a destBase not under backups/secondary/") } } // E — SSD state-only: optional legs dropped, mandatory kept, honest warning. func TestTier2V2_SSDStateOnly(t *testing.T) { m, src, target, prov := newTier2V2(t, "app") // Remove the off-drive data-drive target so the auto-pick falls to the SSD (state-only). _ = target _ = m.settings.RemoveStoragePath(target) prov.has["app"] = true prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/state"), oUD("media/pics")} mustWrite(t, filepath.Join(src, "appdata", "app", "state", "db.sql"), "SQL") mustWrite(t, filepath.Join(src, "userdata", "media", "pics", "p.jpg"), "J") if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } // The SSD dest is under the system data path (NamespaceRoot(sys, false) → sys/felhom-data). destBase := filepath.Join(NamespaceRoot(m.systemDataPath, false), "backups", "secondary", "app") if !exists(filepath.Join(destBase, "hdd", "appdata", "app", "state", "db.sql")) { t.Error("mandatory state leg must be on the SSD") } if exists(filepath.Join(destBase, "userdata", "media", "pics")) { t.Error("optional leg must NOT be copied to the SSD (state-only tier)") } cd := m.settings.GetCrossDriveConfig("app") if cd == nil || !strings.Contains(cd.LastWarning, "választható tartalom nem került másolásra") { t.Errorf("state-only warning missing: %+v", cd) } } // F1/F2 — network exclusion, pinned AND auto (two separate assertions). func TestTier2V2_NetworkExclusion(t *testing.T) { // F2 (auto): an NFS candidate registered BEFORE a real local drive → the NFS is skipped and the // local drive wins (NFS-first ordering makes this a genuine red-proof of the auto IsNetwork skip). t.Run("auto skips network, picks local", func(t *testing.T) { m, _, fixtureTarget, _ := newTier2V2(t, "app") _ = m.settings.RemoveStoragePath(fixtureTarget) // drop the fixture drive, re-add after the NAS nas := "/mnt/nas" if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil { t.Fatal(err) } local := t.TempDir() if err := m.settings.AddStoragePath(settings.StoragePath{Path: local, Label: "local", Schedulable: true}); err != nil { t.Fatal(err) } target, err := m.selectTier2Target("app", 1<<20, 1<<20) if err != nil { t.Fatalf("selectTier2Target: %v", err) } if strings.Contains(target.NamespaceRoot, "nas") { t.Errorf("the NFS path (iterated first) must be skipped, got %q", target.NamespaceRoot) } if target.NamespaceRoot != filepath.FromSlash(local) { t.Errorf("want the local drive %q, got %q", local, target.NamespaceRoot) } }) // F2b: NAS is the ONLY off-disk candidate → the honest network reason. t.Run("network-only → honest reason", func(t *testing.T) { m, _, target, _ := newTier2V2(t, "app") _ = m.settings.RemoveStoragePath(target) // drop the local off-drive target nas := "/mnt/nas" if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil { t.Fatal(err) } m.systemDataPath = "" // no SSD either → network is the only candidate _, err := m.selectTier2Target("app", 1<<20, 1<<20) if err == nil || !strings.Contains(err.Error(), "Hálózati tároló nem lehet") { t.Fatalf("network-only must refuse with the honest reason, got %v", err) } }) // F1 (pinned): a pinned NFS target is invalid → falls through to auto (the local drive). t.Run("pinned network invalid → falls through", func(t *testing.T) { m, _, target, _ := newTier2V2(t, "app") nas := "/mnt/nas" if err := m.settings.AddStoragePath(settings.StoragePath{Path: nas, Label: "nas", Schedulable: true, Kind: settings.StorageKindNetwork, Protocol: "nfs"}); err != nil { t.Fatal(err) } if err := m.settings.SetTier2Preference("app", false, nas); err != nil { t.Fatal(err) } got, err := m.selectTier2Target("app", 1<<20, 1<<20) if err != nil { t.Fatalf("selectTier2Target: %v", err) } if got.NamespaceRoot != filepath.FromSlash(target) { t.Errorf("pinned NFS must fall through to the local auto-pick %q, got %q", target, got.NamespaceRoot) } }) } // G2 — restore refuses a pre-v2 (no marker) copy. func TestTier2V2_RestoreRefusesOldLayout(t *testing.T) { m, fake, _, destDrive := newT2RManager(t) _ = fake // Remove the marker the v2 fixture wrote → simulate an old flat copy. if err := os.Remove(filepath.Join(destDrive, "backups", "secondary", "app", tier2LayoutMarker)); err != nil { t.Fatal(err) } m.restoreFilesCopier = func(string, string) (int, error) { t.Fatal("copier must not run on an old-layout refusal") return 0, nil } if _, err := m.RestoreTier2Files("app"); err == nil || !strings.Contains(err.Error(), "régi formátumú") { t.Fatalf("restore must refuse a pre-v2 copy with the marker-refusal string, got %v", err) } } // H — a structurally-refused MANDATORY path is a loud gap in the app's warning. func TestTier2V2_CaptureGapLoud(t *testing.T) { m, src, _, prov := newTier2V2(t, "app") prov.has["app"] = true prov.binds["app"] = []ClassifiedBind{mHDD("appdata/app/ok"), mHDD("../evil")} // traversal → Skipped mustWrite(t, filepath.Join(src, "appdata", "app", "ok", "f"), "F") if err := m.RunTier2("app"); err != nil { t.Fatalf("RunTier2: %v", err) } cd := m.settings.GetCrossDriveConfig("app") if cd == nil || !strings.Contains(cd.LastWarning, "nem kerültek a másodlagos mentésbe") { t.Errorf("capture-gap warning missing from tier-2 status: %+v", cd) } } // classifyTier2Rel keep/remove truth table (the reconcile core, pure). func TestClassifyTier2Rel(t *testing.T) { legs := []string{"hdd/appdata/app/media", "userdata/media/books"} cases := []struct { rel string want tier2RelClass }{ {"hdd/appdata/app/media", tier2KeepInside}, // exact leg {"hdd/appdata/app/media/covers", tier2KeepInside}, // descendant {"hdd/appdata", tier2KeepAncestor}, // ancestor {"hdd/appdata/app", tier2KeepAncestor}, // ancestor {"hdd/appdata/app/export", tier2Stale}, // sibling → stale {"hdd/other", tier2Stale}, // unrelated → stale {"userdata/media", tier2KeepAncestor}, // ancestor of userdata leg } for _, c := range cases { if got := classifyTier2Rel(c.rel, legs); got != c.want { t.Errorf("classifyTier2Rel(%q) = %d, want %d", c.rel, got, c.want) } } }