package stacks import ( "os" "path/filepath" "slices" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/appbackup" ) const beltCompose = `services: app: image: x volumes: - app_config:/config - ${USERDATA_PATH}/media/movies:/media/movies - ${USERDATA_PATH}/downloads:/downloads - ${HDD_PATH}/appdata/app:/data - /etc/passwd:/host:ro volumes: app_config: ` // TestParseComposeUserdataMounts: only ${USERDATA_PATH}/... bind sources are returned, resolved; HDD // appdata mounts, named volumes, and unrelated host paths are ignored. func TestParseComposeUserdataMounts(t *testing.T) { dir := t.TempDir() cp := filepath.Join(dir, "docker-compose.yml") if err := os.WriteFile(cp, []byte(beltCompose), 0o644); err != nil { t.Fatal(err) } ud := filepath.Clean("/mnt/felhom-usb/userdata") got := map[string]bool{} for _, m := range ParseComposeUserdataMounts(cp, ud) { got[m] = true } for _, want := range []string{ filepath.Join(ud, "media", "movies"), filepath.Join(ud, "downloads"), } { if !got[want] { t.Errorf("missing userdata mount %q (got %v)", want, got) } } if len(got) != 2 { t.Errorf("expected exactly 2 userdata mounts, got %d: %v", len(got), got) } } // TestEnsureUserdataMounts_CreatesBeltDirs: the deploy belt pre-creates every declared ${USERDATA_PATH} // bind source before compose-up (so Docker never auto-creates one as root). Uses a real temp userdata // root via env injection. func TestEnsureUserdataMounts_CreatesBeltDirs(t *testing.T) { m := newMigManager(t, "") // minimal Manager (cfg+logger+settings) stackDir := t.TempDir() if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte(beltCompose), 0o644); err != nil { t.Fatal(err) } ud := filepath.Join(t.TempDir(), "userdata") env := []string{"USERDATA_PATH=" + ud} // movies dir absent before if _, err := os.Stat(filepath.Join(ud, "media", "movies")); err == nil { t.Fatal("precondition: movies dir should not exist yet") } m.ensureUserdataMounts(stackDir, env) for _, p := range []string{filepath.Join(ud, "media", "movies"), filepath.Join(ud, "downloads")} { if fi, err := os.Stat(p); err != nil || !fi.IsDir() { t.Errorf("belt did not create %s (%v)", p, err) } } _ = appbackup.SharedContentGID // keep import referenced cross-platform } // TestWithPathVars: the shared injector adds USERDATA_PATH=/userdata when HDD_PATH is set, and // adds nothing when it's empty. Regression for the initial-deploy bug where ${USERDATA_PATH} resolved // to "" and bound a bogus root-owned dir at the container root. func TestWithPathVars(t *testing.T) { const importRoot = "/mnt/sys_drive/felhom-data/userdata/import" got := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-usb", importRoot) want := "USERDATA_PATH=" + appbackup.UserdataDir("/mnt/felhom-usb") found := false for _, e := range got { if e == want { found = true } } if !found { t.Errorf("USERDATA_PATH not injected: got %v, want %q", got, want) } // companion: empty HDD_PATH → no USERDATA_PATH at all for _, e := range withPathVars([]string{"DOMAIN=x"}, "", importRoot) { if strings.HasPrefix(e, "USERDATA_PATH") { t.Errorf("USERDATA_PATH must NOT be set when HDD_PATH is empty: %q", e) } } } // TestWithPathVars_ImportPath pins the R-75 half. IMPORT_PATH has the SAME failure mode // USERDATA_PATH had: a site that forgets it resolves ${IMPORT_PATH} to "" and binds a bogus // root-owned dir at the container root. And the unresolvable case must leave the variable UNSET — // never fall back to a per-drive path, which would recreate the dead-drop-zone shape R-75 removes. func TestWithPathVars_ImportPath(t *testing.T) { const importRoot = "/mnt/sys_drive/felhom-data/userdata/import" got := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/hdd_1", importRoot) if !slices.Contains(got, "IMPORT_PATH="+importRoot) { t.Errorf("IMPORT_PATH not injected: got %v", got) } // It is CANONICAL: it must not be derived from HDD_PATH. A second app on a different drive gets // the identical value — that is the whole point of the canonical root. other := withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/nvme-1tb", importRoot) if !slices.Contains(other, "IMPORT_PATH="+importRoot) { t.Errorf("IMPORT_PATH must not vary with HDD_PATH: got %v", other) } for _, e := range got { if strings.HasPrefix(e, "IMPORT_PATH=") && strings.Contains(e, "felhom-drives") { t.Errorf("IMPORT_PATH must never point at a data drive: %q", e) } } // Unresolvable → UNSET (compose then fails loudly on ${IMPORT_PATH}). for _, e := range withPathVars([]string{"DOMAIN=x"}, "/mnt/felhom-drives/hdd_1", "") { if strings.HasPrefix(e, "IMPORT_PATH") { t.Errorf("IMPORT_PATH must NOT be set when the import root is unresolvable: %q", e) } } }