package appexport import ( "archive/tar" "io" "log" "os" "path/filepath" "reflect" "sort" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) // fabProv is a minimal ExportStackProvider for the plan tests: configurable classified binds + hddPath. type fabProv struct { *rtProvider hddPath string binds []appbackup.ClassifiedBind has bool mounts []string } func (p *fabProv) GetStackHDDPath(string) string { return p.hddPath } func (p *fabProv) GetStackHDDMounts(string) []string { return p.mounts } func (p *fabProv) GetStackClassifiedBinds(string) ([]appbackup.ClassifiedBind, bool) { return p.binds, p.has } func newFabExporter(binds []appbackup.ClassifiedBind, has bool, hddPath string, tree map[string][]string) *Exporter { e := NewExporter(&fabProv{rtProvider: &rtProvider{}, hddPath: hddPath, binds: binds, has: has}, log.New(io.Discard, "", 0), "test") e.dirLister = func(dir string) []string { return tree[dir] } return e } func mHDD(rel string) appbackup.ClassifiedBind { return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassMandatory} } func mUD(rel string) appbackup.ClassifiedBind { return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassMandatory} } func oUD(rel string) appbackup.ClassifiedBind { return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassOptional} } func xUD(rel string) appbackup.ClassifiedBind { return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootUserdata, RelPath: rel}, Class: appbackup.ClassExcluded} } const hp = "/srv/data" // hddPath (plan is string-only; the lister is injected) // udDir builds an OS-native userdata dir path (matches appbackup.UserdataDir + the walk's // filepath.Join, so injected-lister keys line up on any host). func udDir(rel ...string) string { return filepath.Join(append([]string{hp, "userdata"}, rel...)...) } // A — legacy app: empty plan (byte-identical v0.130.0 capture). func TestFabPlan_LegacyEmpty(t *testing.T) { e := newFabExporter(nil, false, hp, nil) mounts := []string{hp + "/appdata/app", hp + "/userdata"} plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts) if len(plan.SkipMounts) != 0 || plan.SkipUserdataTar || plan.UserdataExcludeRels != nil { t.Errorf("legacy app must yield an EMPTY plan (every mount kept, no excludes), got %+v", plan) } } // B — all-excluded app: the userdata root tar is skipped entirely. func TestFabPlan_AllExcludedSkipsUserdataTar(t *testing.T) { binds := []appbackup.ClassifiedBind{xUD("media/movies"), xUD("downloads")} e := newFabExporter(binds, true, hp, map[string][]string{"/srv/data/userdata": {"media", "downloads"}}) plan := e.computeFabPlan(ExportRequest{StackName: "radarr"}, []string{hp + "/userdata"}) if !plan.SkipUserdataTar { t.Error("no selected userdata bind → the whole root tar must be skipped (Scenario B)") } } // C — selected-complement: media/books kept, siblings excluded (R1-C). func TestFabPlan_SelectedComplement(t *testing.T) { binds := []appbackup.ClassifiedBind{mUD("media/books"), xUD("media/movies")} tree := map[string][]string{ udDir(): {"media", "music"}, udDir("media"): {"books", "movies", "comics"}, } e := newFabExporter(binds, true, hp, tree) plan := e.computeFabPlan(ExportRequest{StackName: "calibre-web"}, []string{hp + "/userdata"}) if plan.SkipUserdataTar { t.Fatal("a selected userdata bind exists — the root tar must NOT be skipped") } want := []string{"media/comics", "media/movies", "music"} if got := plan.UserdataExcludeRels; !reflect.DeepEqual(got, want) { t.Errorf("excludes = %v, want %v (media/books kept, siblings excluded)", got, want) } } // D — optional default-in / uncheck-out / excluded opt-in. func TestFabPlan_OptionalAndOptIn(t *testing.T) { // A mandatory anchor (data) keeps the root tar always produced, so comics/podcasts inclusion is // exercised via the EXCLUDE LIST (not the whole-tar skip). binds := []appbackup.ClassifiedBind{mUD("data"), oUD("media/comics"), xUD("media/podcasts")} tree := map[string][]string{ udDir(): {"data", "media"}, udDir("media"): {"comics", "podcasts", "junk"}, } // D1 default: optional comics IN → not excluded; podcasts (excluded) + junk (unselected) excluded. e := newFabExporter(binds, true, hp, tree) p1 := e.computeFabPlan(ExportRequest{StackName: "komga"}, []string{hp + "/userdata"}) if p1.SkipUserdataTar { t.Fatal("mandatory anchor selected — tar must be produced") } if effExcluded(p1.UserdataExcludeRels, "media/comics") { t.Error("D1: default → optional comics must be INCLUDED (not excluded)") } if !effExcluded(p1.UserdataExcludeRels, "media/podcasts") { t.Error("D1: excluded podcasts must be excluded by default") } // D2 uncheck the optional → excluded (effectively, via a topmost exclude covering it). p2 := e.computeFabPlan(ExportRequest{StackName: "komga", DeselectOptional: []string{"userdata/media/comics"}}, []string{hp + "/userdata"}) if !effExcluded(p2.UserdataExcludeRels, "media/comics") { t.Errorf("D2: unchecked optional must be excluded, excludes=%v", p2.UserdataExcludeRels) } // D3 opt-in the excluded → included. p3 := e.computeFabPlan(ExportRequest{StackName: "komga", OptInExcluded: []string{"userdata/media/podcasts"}}, []string{hp + "/userdata"}) if effExcluded(p3.UserdataExcludeRels, "media/podcasts") { t.Error("D3: opted-in excluded must be INCLUDED (not excluded)") } } // effExcluded reports whether rel (or an ancestor of it) is in the topmost exclude list. func effExcluded(excludes []string, rel string) bool { for _, e := range excludes { if rel == e || len(rel) > len(e) && rel[:len(e)+1] == e+"/" { return true } } return false } // D floor — a request deselecting a MANDATORY path is IGNORED (mandatory stays in). func TestFabPlan_MandatoryFloor(t *testing.T) { binds := []appbackup.ClassifiedBind{mUD("media/books")} tree := map[string][]string{udDir(): {"media"}, udDir("media"): {"books"}} e := newFabExporter(binds, true, hp, tree) // client tries to deselect the mandatory path — must be ignored (books NOT excluded). plan := e.computeFabPlan(ExportRequest{StackName: "x", DeselectOptional: []string{"userdata/media/books"}}, []string{hp + "/userdata"}) if contains(plan.UserdataExcludeRels, "media/books") || plan.SkipUserdataTar { t.Errorf("mandatory floor breached — media/books must stay in the bundle; plan=%+v", plan) } } // §8 — an HDD mount matching NO classified bind is KEPT (fail toward capture). func TestFabPlan_UnmatchedMountKept(t *testing.T) { binds := []appbackup.ClassifiedBind{mHDD("appdata/known")} e := newFabExporter(binds, true, hp, nil) mounts := []string{hp + "/appdata/known", hp + "/appdata/mystery"} plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts) if plan.SkipMounts[filepath.Clean(hp+"/appdata/mystery")] { t.Error("an unmatched mount must be KEPT (fail toward capture, C6B-F1)") } if plan.SkipMounts[filepath.Clean(hp+"/appdata/known")] { t.Error("a mandatory-matched mount must be kept") } } // §8 — a classified HDD mount that is NOT selected is skipped. func TestFabPlan_UnselectedHDDMountSkipped(t *testing.T) { binds := []appbackup.ClassifiedBind{xHDD("appdata/cache")} e := newFabExporter(binds, true, hp, nil) mounts := []string{hp + "/appdata/cache"} plan := e.computeFabPlan(ExportRequest{StackName: "x"}, mounts) if !plan.SkipMounts[filepath.Clean(hp+"/appdata/cache")] { t.Error("an excluded, un-opted-in HDD mount must be skipped") } } func xHDD(rel string) appbackup.ClassifiedBind { return appbackup.ClassifiedBind{ComposeBind: appbackup.ComposeBind{Root: appbackup.RootHDD, RelPath: rel}, Class: appbackup.ClassExcluded} } // classifyFabRel keep-rule truth table (the R1-C core, pure). func TestClassifyFabRel(t *testing.T) { sel := []string{"media/books"} cases := []struct { rel string want fabRelClass }{ {"media/books", fabKeepInside}, {"media/books/covers", fabKeepInside}, {"media", fabKeepAncestor}, {"media/movies", fabStale}, {"music", fabStale}, } for _, c := range cases { if got := classifyFabRel(c.rel, sel); got != c.want { t.Errorf("classifyFabRel(%q) = %d, want %d", c.rel, got, c.want) } } } // tarDirectoryExcluding FS-level: excluded subtrees are absent, kept content present. func TestTarDirectoryExcluding(t *testing.T) { src := t.TempDir() write := func(rel, content string) { p := filepath.Join(src, filepath.FromSlash(rel)) os.MkdirAll(filepath.Dir(p), 0755) os.WriteFile(p, []byte(content), 0644) } write("media/books/a.epub", "BOOK") write("media/movies/big.mkv", "MOVIE") write("music/song.flac", "SONG") out := filepath.Join(t.TempDir(), "userdata.tar") if err := tarDirectoryExcluding(src, out, []string{"media/movies", "music"}); err != nil { t.Fatal(err) } got := tarEntries(t, out) if !containsSuffix(got, "media/books/a.epub") { t.Errorf("kept content missing: %v", got) } for _, bad := range []string{"media/movies/big.mkv", "music/song.flac", "media/movies", "music"} { if containsSuffix(got, bad) { t.Errorf("excluded path %q present in tar: %v", bad, got) } } } func tarEntries(t *testing.T, tarPath string) []string { t.Helper() f, err := os.Open(tarPath) if err != nil { t.Fatal(err) } defer f.Close() tr := tar.NewReader(f) var names []string for { h, err := tr.Next() if err == io.EOF { break } if err != nil { t.Fatal(err) } names = append(names, filepath.ToSlash(h.Name)) } sort.Strings(names) return names } func contains(ss []string, want string) bool { for _, s := range ss { if s == want { return true } } return false } func containsSuffix(ss []string, suffix string) bool { for _, s := range ss { if s == suffix || filepath.ToSlash(s) == suffix { return true } } return false }