Files
felhom-controller/controller/internal/appbackup/classify_test.go
T
admin 0649f9a3e6 Backup classification: schema + parser + pure classifier (INERT, v0.132.0)
Task 2 of the backup-classification-redesign arc. Ships the referential-
coupling classification as DATA + PARSER + PURE CLASSIFIER, deliberately
inert — no backup tier changes behavior. Task 3 (tier policy engine) and
Task 4 (manual .fab UI) consume it.

- appbackup/classify.go: BackupSpec/BindSpec/ComposeBind/ClassifiedBind;
  ClassifyBinds (SQ5 two-level default — explicit beats :ro; unlisted
  writable→mandatory, unlisted :ro→excluded; nil spec→legacy/false);
  ValidateBackupSpec (whole-block-reject on any defect, first defect named).
- stacks/classify_binds.go: ParseComposeClassifiableBinds — ${VAR}-relative
  binds + :ro flag (NOT ParseComposeHDDMounts/ExportDataMounts, the traps).
- Metadata.Backup + LoadMetadata as the single validation choke point (bad
  catalog block → nil + one ERROR → legacy, within one sync cycle).
- Manager.ClassifiedBinds + StackDataProvider.GetStackClassifiedBinds seam
  (delegated by stackAdapter, nil-stubbed in every fake) — wired + tested
  now so Task 3 consumes a tested seam.

INERT: full pre-existing suite green with zero test-logic edits. +14 tests;
red-proofs RP-1..RP-4 confirmed. The 13 catalog backup: blocks ship in the
same app-catalog change (this controller deploys first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A45Qop8YY8tS94bz63LFne
2026-07-14 18:46:32 +02:00

166 lines
7.7 KiB
Go

package appbackup
import (
"strings"
"testing"
)
// classOf finds the resolved class+origin for a (root, relpath) in a ClassifiedBind slice.
func classOf(cbs []ClassifiedBind, root BindRoot, rel string) (BindClass, ClassOrigin, bool) {
for _, c := range cbs {
if c.Root == root && c.RelPath == rel {
return c.Class, c.Origin, true
}
}
return "", "", false
}
// --- Group A: classifier ---
// TestClassify_ImmichShape is Scenario A: explicit classes resolve, and an EXPLICIT entry beats the
// :ro reader-default (media/photos is :ro but ruled optional). Companion RP-2: making the ro-default
// override explicit entries forces media/photos to excluded and fails the optional assertion.
func TestClassify_ImmichShape(t *testing.T) {
binds := []ComposeBind{
{Root: RootHDD, RelPath: "appdata/immich", ReadOnly: false},
{Root: RootUserdata, RelPath: "media/photos", ReadOnly: true}, // :ro external library
}
spec := &BackupSpec{
HDD: []BindSpec{{Path: "appdata/immich", Class: ClassMandatory}},
Userdata: []BindSpec{{Path: "media/photos", Class: ClassOptional}},
}
cbs, has := ClassifyBinds(spec, binds)
if !has {
t.Fatal("hasClassification should be true with a spec present")
}
if cls, org, ok := classOf(cbs, RootHDD, "appdata/immich"); !ok || cls != ClassMandatory || org != OriginExplicit {
t.Errorf("appdata/immich = %v/%v, want mandatory/explicit", cls, org)
}
// The crux: an explicit optional beats the :ro default_ro that would otherwise force excluded.
if cls, org, ok := classOf(cbs, RootUserdata, "media/photos"); !ok || cls != ClassOptional || org != OriginExplicit {
t.Errorf("media/photos (:ro, explicit optional) = %v/%v, want optional/explicit (explicit beats ro-default)", cls, org)
}
}
// TestClassify_TwoLevelDefault is Scenario B: with a block PRESENT, an unlisted writable bind
// defaults mandatory (capture, the C6B-F1 direction) and an unlisted :ro bind defaults excluded
// (reader rule). Companion RP-3: flipping the unlisted-writable default to excluded fails the
// mandatory assertion.
func TestClassify_TwoLevelDefault(t *testing.T) {
binds := []ComposeBind{
{Root: RootHDD, RelPath: "appdata/app", ReadOnly: false}, // listed
{Root: RootUserdata, RelPath: "data/extra", ReadOnly: false}, // UNLISTED writable
{Root: RootUserdata, RelPath: "media/ro", ReadOnly: true}, // UNLISTED :ro
}
spec := &BackupSpec{HDD: []BindSpec{{Path: "appdata/app", Class: ClassMandatory}}}
cbs, has := ClassifyBinds(spec, binds)
if !has {
t.Fatal("hasClassification should be true")
}
if cls, org, _ := classOf(cbs, RootUserdata, "data/extra"); cls != ClassMandatory || org != OriginDefaultWritable {
t.Errorf("unlisted writable = %v/%v, want mandatory/default_writable (capture direction)", cls, org)
}
if cls, org, _ := classOf(cbs, RootUserdata, "media/ro"); cls != ClassExcluded || org != OriginDefaultRO {
t.Errorf("unlisted :ro = %v/%v, want excluded/default_ro (reader rule)", cls, org)
}
}
// TestClassify_NilSpecLegacy is Scenario C: a nil spec → every bind is legacy with no class, and
// hasClassification=false. This is the inertness gate at the classifier level.
func TestClassify_NilSpecLegacy(t *testing.T) {
binds := []ComposeBind{{Root: RootUserdata, RelPath: "media", ReadOnly: true}}
cbs, has := ClassifyBinds(nil, binds)
if has {
t.Error("nil spec must report hasClassification=false")
}
if len(cbs) != 1 || cbs[0].Origin != OriginLegacy || cbs[0].Class != "" {
t.Errorf("nil-spec bind = %+v, want origin=legacy, empty class", cbs[0])
}
}
// TestClassify_BareRootFallsToDefault: a bare-root bind (RelPath "") can't be matched by any explicit
// entry (empty paths are invalid), so it falls to the ro/writable default.
func TestClassify_BareRootFallsToDefault(t *testing.T) {
spec := &BackupSpec{Userdata: []BindSpec{{Path: "media/x", Class: ClassOptional}}}
cbs, _ := ClassifyBinds(spec, []ComposeBind{
{Root: RootUserdata, RelPath: "", ReadOnly: false}, // bare ${USERDATA_PATH}
{Root: RootHDD, RelPath: "", ReadOnly: true}, // bare ${HDD_PATH} :ro
})
if cls, org, _ := classOf(cbs, RootUserdata, ""); cls != ClassMandatory || org != OriginDefaultWritable {
t.Errorf("bare writable root = %v/%v, want mandatory/default_writable", cls, org)
}
if cls, org, _ := classOf(cbs, RootHDD, ""); cls != ClassExcluded || org != OriginDefaultRO {
t.Errorf("bare :ro root = %v/%v, want excluded/default_ro", cls, org)
}
}
// TestClassify_SameRelPathBothRoots: userdata/x and hdd/x are DISTINCT binds — Root is part of
// identity, so an explicit hdd entry must not classify the userdata bind.
func TestClassify_SameRelPathBothRoots(t *testing.T) {
binds := []ComposeBind{
{Root: RootUserdata, RelPath: "shared", ReadOnly: false},
{Root: RootHDD, RelPath: "shared", ReadOnly: false},
}
spec := &BackupSpec{HDD: []BindSpec{{Path: "shared", Class: ClassExcluded}}}
cbs, _ := ClassifyBinds(spec, binds)
if cls, org, _ := classOf(cbs, RootHDD, "shared"); cls != ClassExcluded || org != OriginExplicit {
t.Errorf("hdd/shared = %v/%v, want excluded/explicit", cls, org)
}
if cls, org, _ := classOf(cbs, RootUserdata, "shared"); cls != ClassMandatory || org != OriginDefaultWritable {
t.Errorf("userdata/shared = %v/%v, want mandatory/default_writable (hdd entry must NOT match it)", cls, org)
}
}
// --- Group B: validation (Scenario D) — every defect rejects the WHOLE block; error names the entry ---
func TestValidateBackupSpec_Defects(t *testing.T) {
// The compose binds the valid entries reference (so only the seeded defect is the failure).
binds := []ComposeBind{
{Root: RootUserdata, RelPath: "media/tv"},
{Root: RootHDD, RelPath: "appdata/x"},
}
cases := []struct {
name string
spec *BackupSpec
wantFrag string // substring the error must contain (the offending entry / rule)
}{
{"unknown class", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: "keepit"}}}, "invalid class"},
{"empty class (typoed key)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tv", Class: ""}}}, "invalid class"},
{"empty path", &BackupSpec{HDD: []BindSpec{{Path: "", Class: ClassMandatory}}}, "empty path"},
{"absolute path", &BackupSpec{HDD: []BindSpec{{Path: "/etc/x", Class: ClassMandatory}}}, "absolute"},
{"dotdot path", &BackupSpec{HDD: []BindSpec{{Path: "../escape", Class: ClassMandatory}}}, "escapes"},
{"backslash path", &BackupSpec{HDD: []BindSpec{{Path: "appdata\\x", Class: ClassMandatory}}}, "backslash"},
{"non-clean path", &BackupSpec{HDD: []BindSpec{{Path: "appdata/./x", Class: ClassMandatory}}}, "non-clean"},
{"duplicate path", &BackupSpec{HDD: []BindSpec{
{Path: "appdata/x", Class: ClassMandatory}, {Path: "appdata/x", Class: ClassExcluded},
}}, "duplicate"},
{"no matching bind (typo)", &BackupSpec{Userdata: []BindSpec{{Path: "media/tvv", Class: ClassExcluded}}}, "matches no compose bind"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateBackupSpec(tc.spec, binds)
if err == nil {
t.Fatalf("expected rejection, got nil")
}
if !strings.Contains(err.Error(), tc.wantFrag) {
t.Errorf("error %q must contain %q", err.Error(), tc.wantFrag)
}
})
}
}
// TestValidateBackupSpec_ValidAndNil: a clean block validates, and a nil spec is vacuously valid.
func TestValidateBackupSpec_ValidAndNil(t *testing.T) {
binds := []ComposeBind{{Root: RootHDD, RelPath: "appdata/x"}, {Root: RootUserdata, RelPath: "media/tv"}}
spec := &BackupSpec{
HDD: []BindSpec{{Path: "appdata/x", Class: ClassMandatory}},
Userdata: []BindSpec{{Path: "media/tv", Class: ClassExcluded}},
}
if err := ValidateBackupSpec(spec, binds); err != nil {
t.Errorf("clean block should validate: %v", err)
}
if err := ValidateBackupSpec(nil, binds); err != nil {
t.Errorf("nil spec must be vacuously valid: %v", err)
}
}