Files
felhom-controller/controller/internal/appbackup/captureset_test.go
T
admin 2958946517 v0.172.0 — R-75: canonical import root, catalog-derived skeleton, import surfaces
${IMPORT_PATH} = <system namespace root>/userdata/import — ONE drop-zone per box,
on the system drive, injected at BOTH compose-env builders with NO per-drive
fallback (unresolvable leaves it unset so compose fails loudly rather than
quietly building a second, dead drop-zone).

Third BindRoot (RootImport) + Import list in BackupSpec, extended through
ValidateBackupSpec/ClassifyBinds. Load-bearing: a stale `userdata: import/<app>`
entry against the moved bind would be a WHOLE-BLOCK reject, taking the app's
mandatory hdd classification with it.

Exhaustive-root audit: resolveAbs/structuralGuard/ComputeCaptureSet/
ComputeFabBuckets now take importRoot explicitly (an import bind resolved
against hddPath would name a directory on the wrong drive); unresolvable is
refused loudly into Skipped. GetImportRoot added to both provider interfaces.

Catalog-derived skeleton: UserdataSkeleton() -> UserdataSkeletonCarry() +
BuildUserdataSkeleton(), SORTED. The carry-list makes zero-removals true by
construction (`documents` is in no catalog app but on both boxes) and is the
fresh-box floor. The sort is not tidiness: the naive map-order derivation
measured 20 distinct outputs from 20 identical runs, which with fbNeedsRecreate
is a fleet-wide FileBrowser restart loop.

One authoritative compose parser: ParseComposeUserdataMounts now delegates to
ParseComposeClassifiableBinds. Import root excluded from per-app migration.

Surfaces: FileBrowser /srv/beolvasas source; app-page "Hova tegyem a fajlokat?"
with PathEscape deep links (never QueryEscape) and class-driven copy;
data_paths: annotation with the Fork-3 asymmetry; system-owned beolvasas SMB
share refused server-side at handler AND store, button omitted in template.

Caught on the way: the sharing template's row struct was function-local, so
adding {{if .System}} would have 500'd every share row. ShareRow is now
package-level and the render test uses the handler's own type.

Tests 915 -> 949, all green. MinAgent unchanged.
2026-07-26 08:12:57 +02:00

248 lines
9.8 KiB
Go

package appbackup
import (
"path"
"reflect"
"testing"
)
// absList extracts the sorted Abs slice from a CaptureSet's Paths (Paths is already Abs-sorted).
func absList(cs CaptureSet) []string {
out := make([]string, 0, len(cs.Paths))
for _, p := range cs.Paths {
out = append(out, p.Abs)
}
return out
}
// classOfAbs finds the resolved class for an Abs in a CaptureSet (empty if absent).
func classOfAbs(cs CaptureSet, abs string) BindClass {
for _, p := range cs.Paths {
if p.Abs == abs {
return p.Class
}
}
return ""
}
const drv = "/mnt/drv"
func hdd(p string) string { return path.Join(drv, p) }
func udat(p string) string { return path.Join(drv, "userdata", p) }
// --- Group A (Scenario A): classified per-tier split, immich shape ---
func TestComputeCaptureSet_PerTierSplit(t *testing.T) {
binds := []ClassifiedBind{
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/immich"}, Class: ClassMandatory, Origin: OriginExplicit},
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, Class: ClassOptional, Origin: OriginExplicit},
}
off := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
if got, want := absList(off), []string{hdd("appdata/immich")}; !reflect.DeepEqual(got, want) {
t.Errorf("offsite Paths = %v, want %v (mandatory only — the :ro optional must NOT ship offsite)", got, want)
}
sec := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
want := []string{hdd("appdata/immich"), udat("media/photos")}
if got := absList(sec); !reflect.DeepEqual(got, want) {
t.Errorf("secondary Paths = %v, want %v (sorted)", got, want)
}
// each CapturePath carries its originating identity
for _, p := range sec.Paths {
switch p.Abs {
case hdd("appdata/immich"):
if p.Root != RootHDD || p.RelPath != "appdata/immich" || p.Class != ClassMandatory {
t.Errorf("immich CapturePath identity = %+v", p)
}
case udat("media/photos"):
if p.Root != RootUserdata || p.RelPath != "media/photos" || p.Class != ClassOptional {
t.Errorf("photos CapturePath identity = %+v", p)
}
}
}
}
// --- Group B (Scenario B): legacy inertness — THE single most important test (SQ5 guard) ---
func TestComputeCaptureSet_LegacyInert(t *testing.T) {
// A legacy app still has binds (the parser returns them), but with no class and origin=legacy.
binds := []ClassifiedBind{
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media/tv"}, Origin: OriginLegacy},
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/sonarr"}, Origin: OriginLegacy},
}
for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
cs := ComputeCaptureSet(binds, false, tier, drv, "")
if cs.HasClassification {
t.Errorf("%s: HasClassification=true for a legacy app", tier)
}
if cs.Paths != nil {
t.Errorf("%s: legacy app resolved Paths=%v — MUST be nil (unmigrated-sonarr-ships-its-TV-library regression)", tier, cs.Paths)
}
if cs.Skipped != nil {
t.Errorf("%s: legacy app Skipped=%v — MUST be nil", tier, cs.Skipped)
}
}
}
// --- Group C (Scenario C): excluded is invisible — not in Paths, not in Skipped ---
func TestComputeCaptureSet_ExcludedInvisible(t *testing.T) {
binds := []ClassifiedBind{
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/media"}, Class: ClassMandatory, Origin: OriginExplicit},
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/export"}, Class: ClassExcluded, Origin: OriginExplicit},
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "import/paperless"}, Class: ClassExcluded, Origin: OriginExplicit},
}
for _, tier := range []CaptureTier{TierOffsite, TierSecondary} {
cs := ComputeCaptureSet(binds, true, tier, drv, "")
if got, want := absList(cs), []string{hdd("appdata/paperless/media")}; !reflect.DeepEqual(got, want) {
t.Errorf("%s Paths = %v, want %v (excluded filtered)", tier, got, want)
}
if len(cs.Skipped) != 0 {
t.Errorf("%s: excluded binds must NOT appear in Skipped, got %v", tier, cs.Skipped)
}
}
}
// --- Group D (Scenario D): structural guards + allowed bare-userdata + legit a..b name ---
func TestComputeCaptureSet_StructuralGuards(t *testing.T) {
binds := []ClassifiedBind{
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "../evil"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d1 traversal
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d2 bare hdd root
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "backups/primary/x"}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d3 reserved zone
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: ""}, Class: ClassMandatory, Origin: OriginDefaultWritable}, // d4 bare userdata — ALLOWED
}
cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
// Paths: ONLY d4's userdata root — no escaped root, no backups/ anywhere.
if got, want := absList(cs), []string{udat("")}; !reflect.DeepEqual(got, want) {
t.Errorf("Paths = %v, want %v (only the allowed bare-userdata)", got, want)
}
for _, p := range cs.Paths {
if p.Abs == "/mnt/evil" {
t.Fatal("escaped-root Abs present in Paths — traversal guard failed")
}
if containsSeg(p.Abs, "backups") {
t.Fatalf("reserved backups/ path present in Paths: %s", p.Abs)
}
}
// Skipped: d1,d2,d3 each with a DISTINCT reason naming its rule.
reasons := map[string]string{} // "<root>/<rel>" -> reason
for _, s := range cs.Skipped {
reasons[string(s.Root)+"/"+s.RelPath] = s.Reason
}
if len(cs.Skipped) != 3 {
t.Fatalf("want 3 skipped, got %d: %+v", len(cs.Skipped), cs.Skipped)
}
if reasons["hdd/../evil"] != reasonEscape {
t.Errorf("../evil reason = %q, want %q", reasons["hdd/../evil"], reasonEscape)
}
if reasons["hdd/"] != reasonBareRoot {
t.Errorf("bare-hdd reason = %q, want %q", reasons["hdd/"], reasonBareRoot)
}
if reasons["hdd/backups/primary/x"] != reasonReserved {
t.Errorf("backups reason = %q, want %q", reasons["hdd/backups/primary/x"], reasonReserved)
}
// distinctness
if reasonEscape == reasonBareRoot || reasonBareRoot == reasonReserved || reasonEscape == reasonReserved {
t.Error("guard reasons are not distinct")
}
}
// TestComputeCaptureSet_LegitDotDotName: a component literally named "a..b" is NOT traversal.
func TestComputeCaptureSet_LegitDotDotName(t *testing.T) {
binds := []ClassifiedBind{
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/a..b"}, Class: ClassMandatory, Origin: OriginExplicit},
}
cs := ComputeCaptureSet(binds, true, TierOffsite, drv, "")
if got, want := absList(cs), []string{hdd("appdata/a..b")}; !reflect.DeepEqual(got, want) {
t.Errorf("Paths = %v, want %v (a..b is a legit name, not traversal)", got, want)
}
if len(cs.Skipped) != 0 {
t.Errorf("a..b must not be skipped, got %v", cs.Skipped)
}
}
// --- Group E (Scenario E): containment dedup + equal-Abs mandatory-wins + determinism ---
func TestComputeCaptureSet_ContainmentAndCollision(t *testing.T) {
binds := []ClassifiedBind{
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless"}, Class: ClassMandatory, Origin: OriginExplicit},
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "appdata/paperless/media"}, Class: ClassMandatory, Origin: OriginExplicit}, // descendant
{ComposeBind: ComposeBind{Root: RootHDD, RelPath: "userdata/media"}, Class: ClassOptional, Origin: OriginExplicit}, // Abs collides with next
{ComposeBind: ComposeBind{Root: RootUserdata, RelPath: "media"}, Class: ClassMandatory, Origin: OriginExplicit}, // same Abs, mandatory
}
cs := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
want := []string{hdd("appdata/paperless"), udat("media")}
if got := absList(cs); !reflect.DeepEqual(got, want) {
t.Errorf("Paths = %v, want %v (descendant dropped; two spellings collapsed)", got, want)
}
// mandatory beats optional on the equal-Abs collision
if c := classOfAbs(cs, udat("media")); c != ClassMandatory {
t.Errorf("collapsed /userdata/media class = %q, want mandatory (mandatory must never degrade)", c)
}
// determinism: recompute and compare full struct
cs2 := ComputeCaptureSet(binds, true, TierSecondary, drv, "")
if !reflect.DeepEqual(cs, cs2) {
t.Error("ComputeCaptureSet is non-deterministic across runs")
}
}
// --- Group F (Scenario F): cross-app overlap advisory (pure) ---
func TestCrossAppOverlaps(t *testing.T) {
X, Y, Z := "/mnt/drv/x", "/mnt/drv/y", "/mnt/drv/z"
sets := map[string]CaptureSet{
"appA": {HasClassification: true, Paths: []CapturePath{{Abs: X}, {Abs: Y}}},
"appB": {HasClassification: true, Paths: []CapturePath{{Abs: Y}}},
"appC": {HasClassification: true, Paths: []CapturePath{{Abs: Z}}},
}
got := CrossAppOverlaps(sets)
want := []Overlap{{Abs: Y, Apps: []string{"appA", "appB"}}}
if !reflect.DeepEqual(got, want) {
t.Errorf("CrossAppOverlaps = %+v, want %+v", got, want)
}
// exact-match only: cross-app CONTAINMENT is legitimate, must NOT report.
cont := map[string]CaptureSet{
"plex": {Paths: []CapturePath{{Abs: "/mnt/drv/userdata/media"}}},
"calibre-web": {Paths: []CapturePath{{Abs: "/mnt/drv/userdata/media/books"}}},
}
if got := CrossAppOverlaps(cont); len(got) != 0 {
t.Errorf("containment across apps must NOT report an overlap, got %+v", got)
}
// empty input → empty (non-nil) slice, not a flaky nil
if got := CrossAppOverlaps(map[string]CaptureSet{}); got == nil || len(got) != 0 {
t.Errorf("empty input → empty non-nil slice, got %#v", got)
}
}
// containsSeg reports whether abs has seg as a path component (test helper).
func containsSeg(abs, seg string) bool {
for _, s := range splitSlash(abs) {
if s == seg {
return true
}
}
return false
}
func splitSlash(s string) []string {
var out []string
cur := ""
for _, r := range s {
if r == '/' {
out = append(out, cur)
cur = ""
continue
}
cur += string(r)
}
return append(out, cur)
}