package stacks import ( "encoding/json" "errors" "io" "log" "os" "path/filepath" "runtime" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" ) // R-442 — "delete my data too" must delete the data, or say that it could not. // // Every test here asserts the EFFECT on a real t.TempDir() tree (the folder is gone / is still // there / app.yaml is gone / is still there), never "no error". The compose process boundary is a // stub script that leaves a marker file, so a refusal can be shown to have run NOTHING. // // COMPANION RED-PROOFS (both run, both recorded in REPORT.md): // 1. Scenario C — model the pre-fix resolver (fall back to the global cfg.Paths.HDDPath, never // refuse) → TestRemoveStack_R442_RefusesWhenDriveUnresolvable FAILS: err=nil, app.yaml gone, // hdd_paths_removed empty. // 2. Scenario D — make "declares no drive" refuse → TestRemoveStack_R442_SSDAppIsNotRefused FAILS. const r442SysData = "/mnt/sys_drive" // newR442Manager builds a Manager with ONE deployed, stopped stack from the given compose and // app.yaml, a marker-leaving stub compose on PATH, and the mountpoint seam answering "live" for // `liveDrive` only. Returns the manager, the stack dir and the compose marker path. func newR442Manager(t *testing.T, name, compose, appYAML, liveDrive string) (*Manager, string, string) { t.Helper() if runtime.GOOS != "linux" { t.Skip("the stub compose binary is a shell script") } root := t.TempDir() dir := filepath.Join(root, name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.StacksDir = root cfg.Paths.SystemDataPath = r442SysData m := &Manager{ cfg: cfg, logger: log.New(io.Discard, "", 0), encKey: []byte("0123456789abcdef0123456789abcdef"), sysDataPath: r442SysData, stacks: map[string]*Stack{}, } m.stacks[name] = &Stack{Name: name, ComposePath: filepath.Join(dir, "docker-compose.yml"), Deployed: true, State: StateStopped} m.stacks[name].AppConfig = LoadAppConfig(dir) // The compose boundary: a script that records it ran. A refusal must leave NO marker. bin := t.TempDir() marker := filepath.Join(bin, "compose-ran") script := "#!/bin/sh\ntouch " + marker + "\nexit 0\n" if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil { t.Fatal(err) } t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) m.composeCmd = "docker-compose" m.execFn = func(string, ...string) (string, error) { return "", nil } m.isMountPoint = func(p string) bool { return liveDrive != "" && filepath.Clean(p) == filepath.Clean(liveDrive) } return m, dir, marker } func plantFile(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("customer bytes\n"), 0o644); err != nil { t.Fatal(err) } } func exists(path string) bool { _, err := os.Stat(path) return err == nil } const driveCompose = "services:\n" + " app:\n" + " image: nginx:1.27\n" + " volumes:\n" + " - ${HDD_PATH}/appdata/app:/data\n" + " - app_cfg:/config\n" + "volumes:\n" + " app_cfg:\n" const ssdCompose = "services:\n" + " app:\n" + " image: nginx:1.27\n" + " volumes:\n" + " - app_cfg:/config\n" + "volumes:\n" + " app_cfg:\n" func driveAppYAML(drive string) string { return "deployed: true\nenv:\n HDD_PATH: " + drive + "\n" } // Scenario A — a drive-backed app is removed WITH its data: the folder is gone, listed with its size. func TestRemoveStack_R442_RemovesDeclaredDriveData(t *testing.T) { drive := t.TempDir() m, dir, marker := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) data := filepath.Join(drive, "appdata", "app") plantFile(t, filepath.Join(data, "photos", "one.jpg")) resp, err := m.RemoveStack("app", true, nil) if err != nil { t.Fatalf("RemoveStack: %v", err) } if exists(data) { t.Fatalf("data folder %s STILL EXISTS after remove_hdd_data=true — the R-442 defect", data) } if len(resp.HDDPathsRemoved) != 1 || !strings.HasPrefix(resp.HDDPathsRemoved[0], data+" (") { t.Fatalf("hdd_paths_removed = %v, want exactly [%q (size)]", resp.HDDPathsRemoved, data) } if exists(filepath.Join(dir, "app.yaml")) { t.Fatal("app.yaml still present — the app was not removed") } if !exists(marker) { t.Fatal("compose down never ran on the success path") } // The drive's protected roots are untouched. if !exists(filepath.Join(drive, "appdata")) { t.Fatal("the protected appdata/ root was removed") } } // Scenario B — the same app, data KEPT: folder untouched, listed under preserved, removed is `[]` // (never null). func TestRemoveStack_R442_KeepsDataWhenNotAsked(t *testing.T) { drive := t.TempDir() m, dir, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) data := filepath.Join(drive, "appdata", "app") plantFile(t, filepath.Join(data, "photos", "one.jpg")) resp, err := m.RemoveStack("app", false, nil) if err != nil { t.Fatalf("RemoveStack: %v", err) } if !exists(filepath.Join(data, "photos", "one.jpg")) { t.Fatal("customer file was deleted on a keep-data removal") } if len(resp.HDDPathsPreserved) != 1 || !strings.HasPrefix(resp.HDDPathsPreserved[0], data+" (") { t.Fatalf("hdd_paths_preserved = %v, want [%q (size)]", resp.HDDPathsPreserved, data) } raw, _ := json.Marshal(resp) if !strings.Contains(string(raw), `"hdd_paths_removed":[]`) { t.Fatalf("hdd_paths_removed must serialise as [] — got %s", raw) } if exists(filepath.Join(dir, "app.yaml")) { t.Fatal("app.yaml still present — the app was not removed") } } // Scenario C — THE R-442 CASE: data removal requested, the compose binds ${HDD_PATH}, app.yaml // records none → REFUSED, typed, exact Hungarian sentence, and NOTHING was touched: compose never // ran, app.yaml is still there. func TestRemoveStack_R442_RefusesWhenDriveUnresolvable(t *testing.T) { m, dir, marker := newR442Manager(t, "app", driveCompose, "deployed: true\nenv: {}\n", "") resp, err := m.RemoveStack("app", true, nil) var refused *RemoveRefusedError if !errors.As(err, &refused) { removed := []string(nil) if resp != nil { removed = resp.HDDPathsRemoved } t.Fatalf("want *RemoveRefusedError, got err=%v resp.hdd_paths_removed=%v app.yaml present=%v — a 200 over inaction", err, removed, exists(filepath.Join(dir, "app.yaml"))) } if refused.Reason != RefuseHDDUnresolved { t.Fatalf("reason = %q, want %q", refused.Reason, RefuseHDDUnresolved) } if refused.Message != msgHDDUnresolved { t.Fatalf("message = %q, want the exact customer sentence", refused.Message) } if exists(marker) { t.Fatal("compose down RAN on a refused removal — the refusal must precede every mutation") } if !exists(filepath.Join(dir, "app.yaml")) { t.Fatal("app.yaml is gone — the app was removed with its data left behind, the worst outcome") } } // Scenario D — an SSD-resident app (never declared HDD_PATH, binds no drive path) is NOT refused: // hdd_paths_removed is `[]`, the note says there was no drive data, the app is removed. func TestRemoveStack_R442_SSDAppIsNotRefused(t *testing.T) { m, dir, marker := newR442Manager(t, "app", ssdCompose, "deployed: true\nenv: {}\n", "") resp, err := m.RemoveStack("app", true, nil) if err != nil { t.Fatalf("an SSD app must not be refused on HDD grounds, got: %v", err) } if resp.HDDPathsRemoved == nil || len(resp.HDDPathsRemoved) != 0 { t.Fatalf("hdd_paths_removed = %#v, want a non-nil empty list", resp.HDDPathsRemoved) } raw, _ := json.Marshal(resp) if !strings.Contains(string(raw), `"hdd_paths_removed":[]`) { t.Fatalf("hdd_paths_removed must serialise as [] — got %s", raw) } if resp.HDDNote != noteNoDriveData { t.Fatalf("hdd_note = %q, want %q", resp.HDDNote, noteNoDriveData) } if exists(filepath.Join(dir, "app.yaml")) || !exists(marker) { t.Fatalf("SSD app not removed: app.yaml present=%v compose ran=%v", exists(filepath.Join(dir, "app.yaml")), exists(marker)) } } // Edge: HDD_PATH recorded but the drive is not mounted right now → refused with the drive-absent // sentence naming the path; app kept; nothing ran. func TestRemoveStack_R442_RefusesWhenDriveAbsent(t *testing.T) { drive := t.TempDir() m, dir, marker := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), "" /* nothing is live */) _, err := m.RemoveStack("app", true, nil) var refused *RemoveRefusedError if !errors.As(err, &refused) || refused.Reason != RefuseDriveAbsent { t.Fatalf("want drive-absent refusal, got %v", err) } if !strings.Contains(refused.Message, drive) || !strings.Contains(refused.Message, "vissza nem csatlakozik") { t.Fatalf("message = %q, want the drive-absent sentence naming %s", refused.Message, drive) } if exists(marker) || !exists(filepath.Join(dir, "app.yaml")) { t.Fatal("a drive-absent refusal must run nothing and keep the app") } } // Edge: a keep-data removal is NEVER refused on HDD grounds, even with the drive absent. func TestRemoveStack_R442_KeepDataNeverRefusedOnHDDGrounds(t *testing.T) { drive := t.TempDir() m, dir, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), "") if _, err := m.RemoveStack("app", false, nil); err != nil { t.Fatalf("keep-data removal refused: %v", err) } if exists(filepath.Join(dir, "app.yaml")) { t.Fatal("app not removed") } } // Edge: HDD_PATH recorded, folder already absent → not a refusal; listed under hdd_paths_missing, // stated in the note, app removed. func TestRemoveStack_R442_MissingFolderIsStatedNotRefused(t *testing.T) { drive := t.TempDir() m, dir, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) want := filepath.Join(drive, "appdata", "app") resp, err := m.RemoveStack("app", true, nil) if err != nil { t.Fatalf("absent folder must not refuse: %v", err) } if len(resp.HDDPathsMissing) != 1 || resp.HDDPathsMissing[0] != want { t.Fatalf("hdd_paths_missing = %v, want [%s]", resp.HDDPathsMissing, want) } if !strings.Contains(resp.HDDNote, want) { t.Fatalf("hdd_note = %q, must name the missing folder", resp.HDDNote) } if len(resp.HDDPathsRemoved) != 0 || exists(filepath.Join(dir, "app.yaml")) { t.Fatal("removed list must be empty and the app gone") } } // Scenario E — the backup half: a path inside the app's backups/ is removed and listed; a path // outside it is refused AND the refusal reaches the response, not only the log. func TestRemoveStack_R442_BackupRefusalReachesResponse(t *testing.T) { drive := t.TempDir() m, _, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) inside := filepath.Join(drive, "backups", "primary", "app", "db-dumps") plantFile(t, filepath.Join(inside, "app.sql")) outside := filepath.Join(t.TempDir(), "elsewhere", "db-dumps") plantFile(t, filepath.Join(outside, "x.sql")) resp, err := m.RemoveStack("app", true, []string{inside, outside}) if err != nil { t.Fatalf("RemoveStack: %v", err) } if exists(inside) { t.Fatalf("backup path inside the app's backups/ was not removed: %s", inside) } if len(resp.BackupPathsRemoved) != 1 || !strings.HasPrefix(resp.BackupPathsRemoved[0], inside+" (") { t.Fatalf("backup_paths_removed = %v, want [%s (size)]", resp.BackupPathsRemoved, inside) } if !exists(filepath.Join(outside, "x.sql")) { t.Fatal("a path OUTSIDE backups/ was deleted — the guard is gone") } if len(resp.BackupPathsRefused) != 1 || !strings.HasPrefix(resp.BackupPathsRefused[0], outside+" ") { t.Fatalf("backup_paths_refused = %v, want the outside path with its reason", resp.BackupPathsRefused) } } // Scenario E for an SSD app: its DB dumps live under /felhom-data/backups — the same // namespace root the router's AppNamespaceRoot resolves — and are removable. Under the old global // lookup every backup path of every app was refused. func TestRemoveStack_R442_SSDAppBackupsUnderSystemNamespace(t *testing.T) { sys := t.TempDir() m, _, _ := newR442Manager(t, "app", ssdCompose, "deployed: true\nenv: {}\n", "") m.sysDataPath = sys m.cfg.Paths.SystemDataPath = sys inside := filepath.Join(sys, "felhom-data", "backups", "primary", "app", "db-dumps") plantFile(t, filepath.Join(inside, "app.sql")) resp, err := m.RemoveStack("app", true, []string{inside}) if err != nil { t.Fatalf("RemoveStack: %v", err) } if exists(inside) || len(resp.BackupPathsRemoved) != 1 || len(resp.BackupPathsRefused) != 0 { t.Fatalf("SSD-app backup under the system namespace must be removed: exists=%v removed=%v refused=%v", exists(inside), resp.BackupPathsRemoved, resp.BackupPathsRefused) } } // The ${USERDATA_PATH} convention (delete.go, ExportDataMounts) keeps working from the PER-APP // path: removal deletes the app's own ${HDD_PATH}/appdata bind and leaves the shared userdata root // alone, and the export resolver still derives /userdata from the same per-app value. func TestRemoveStack_R442_UserdataConventionFromPerAppPath(t *testing.T) { drive := t.TempDir() compose := "services:\n" + " app:\n" + " image: nginx:1.27\n" + " volumes:\n" + " - ${HDD_PATH}/appdata/app:/data\n" + " - ${USERDATA_PATH}/media:/media\n" m, _, _ := newR442Manager(t, "app", compose, driveAppYAML(drive), drive) data := filepath.Join(drive, "appdata", "app") plantFile(t, filepath.Join(data, "one.bin")) shared := filepath.Join(drive, "userdata", "media", "holiday.jpg") plantFile(t, shared) hdd, declared := m.appHDDPath("app") if !declared || hdd != filepath.Clean(drive) { t.Fatalf("appHDDPath = (%q,%v), want (%q,true)", hdd, declared, drive) } mounts := ExportDataMounts(m.stacks["app"].ComposePath, hdd, hdd) wantUD := filepath.Join(drive, "userdata") found := false for _, mnt := range mounts { if mnt == wantUD { found = true } } if !found { t.Fatalf("ExportDataMounts from the per-app path = %v, want it to include %s", mounts, wantUD) } if _, err := m.RemoveStack("app", true, nil); err != nil { t.Fatalf("RemoveStack: %v", err) } if exists(data) { t.Fatal("app data not removed") } if !exists(shared) { t.Fatal("the shared userdata root was deleted by an app removal") } } // DeleteStack (orphan delete) takes the same refusal: Scenario C shape, nothing touched. func TestDeleteStack_R442_RefusesWhenDriveUnresolvable(t *testing.T) { m, dir, marker := newR442Manager(t, "app", driveCompose, "deployed: true\nenv: {}\n", "") m.stacks["app"].Orphaned = true _, err := m.DeleteStack("app", true) var refused *RemoveRefusedError if !errors.As(err, &refused) || refused.Message != msgHDDUnresolved { t.Fatalf("want the unresolved refusal, got %v (stack dir present=%v)", err, exists(dir)) } if exists(marker) || !exists(dir) { t.Fatal("orphan delete refused but something ran or the stack dir is gone") } } // DeleteStack success path: the app's own drive data goes, listed. func TestDeleteStack_R442_RemovesDeclaredDriveData(t *testing.T) { drive := t.TempDir() m, dir, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) m.stacks["app"].Orphaned = true data := filepath.Join(drive, "appdata", "app") plantFile(t, filepath.Join(data, "one.bin")) resp, err := m.DeleteStack("app", true) if err != nil { t.Fatalf("DeleteStack: %v", err) } if exists(data) || len(resp.HDDPathsRemoved) != 1 || exists(dir) { t.Fatalf("data exists=%v removed=%v stackdir exists=%v", exists(data), resp.HDDPathsRemoved, exists(dir)) } } // GetStackHDDData (the modal's source) reads the per-app record too: with HDD_PATH recorded it // lists the folder; the global config stays empty throughout. func TestGetStackHDDData_R442_ReadsPerAppRecord(t *testing.T) { drive := t.TempDir() m, _, _ := newR442Manager(t, "app", driveCompose, driveAppYAML(drive), drive) data := filepath.Join(drive, "appdata", "app") plantFile(t, filepath.Join(data, "one.bin")) if m.cfg.Paths.HDDPath != "" { t.Fatal("fixture error: the global must stay empty to prove the per-app read") } resp, err := m.GetStackHDDData("app") if err != nil { t.Fatal(err) } if !resp.HasHDDData || len(resp.HDDPaths) != 1 || resp.HDDPaths[0].Path != data || !resp.HDDPaths[0].Exists { t.Fatalf("hdd-data = %+v, want one existing entry for %s", resp, data) } }