package web import ( "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // C6B-F2 guard (v0.130.0, scenario F): removing a network share while a DEPLOYED app's HDD_PATH // lives on it is REFUSED up front — the agent's unit teardown never starts, so the busy-mount → // tolerated-stop → unit-files-deleted-anyway → orphaned-autofs chain (the observed C6B-F2) cannot // be triggered through the product flow. RED-PROOF: drop the deployedAppsOnPath guard from // handleNetStorageRemove → the 409/zero-agent-call/still-registered assertions fail (the pre-fix // behavior: removed:true while sonarr ran on the share). // testRemoveGuardServer builds a Server with a REAL stacks.Manager over a temp stacks dir holding // one app (deployed flag per arg) whose HDD_PATH sits ON the share, plus the share registered. func testRemoveGuardServer(t *testing.T, deployed bool) (*Server, *fakeNetAgent, string) { t.Helper() lg := log.New(io.Discard, "", 0) dir := t.TempDir() cfg := &config.Config{} cfg.Paths.StacksDir = filepath.Join(dir, "stacks") cfg.Paths.DataDir = filepath.Join(dir, "data") cfg.Stacks.ComposeCommand = "docker compose" share := settings.NetworkMountRoot + "/campaign6" stackDir := filepath.Join(cfg.Paths.StacksDir, "sonarr") if err := os.MkdirAll(stackDir, 0o755); err != nil { t.Fatal(err) } os.WriteFile(filepath.Join(stackDir, ".felhom.yml"), []byte("display_name: Sonarr\n"), 0o644) os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644) appYAML := "deployed: false\n" if deployed { // the C6B live shape: HDD_PATH is a SUBPATH of the share root (/) appYAML = "deployed: true\nenv:\n HDD_PATH: " + share + "/sonarr\n" } os.WriteFile(filepath.Join(stackDir, "app.yaml"), []byte(appYAML), 0o644) sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) if err != nil { t.Fatal(err) } if err := sett.AddStoragePath(settings.StoragePath{ Path: share, Label: "Kampány 6 teszt", Schedulable: true, Kind: settings.StorageKindNetwork, }); err != nil { t.Fatal(err) } mgr, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatal(err) } _ = mgr.ScanStacks() // container-status refresh may fail on docker-less hosts — discovery is enough if _, ok := mgr.GetStack("sonarr"); !ok { t.Fatal("sonarr not discovered by ScanStacks") } agent := &fakeNetAgent{} s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg} s.netAgentFn = func() (netAgent, error) { return agent, nil } return s, agent, share } func postNetRemove(t *testing.T, s *Server, name string) *httptest.ResponseRecorder { t.Helper() r := httptest.NewRequest(http.MethodPost, "/api/storage/netstorage/remove", strings.NewReader(`{"name":"`+name+`"}`)) w := httptest.NewRecorder() s.handleNetStorageRemove(w, r) return w } func TestNetStorageRemove_RefusedWhileAppDeployedOnShare(t *testing.T) { s, agent, share := testRemoveGuardServer(t, true) w := postNetRemove(t, s, "campaign6") if w.Code != http.StatusConflict { t.Fatalf("remove with a deployed app on the share: got %d want 409 (%s)", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "Sonarr") { t.Errorf("the refusal must NAME the blocking app, got: %s", w.Body.String()) } if !strings.Contains(w.Body.String(), "nem távolítható el") { t.Errorf("expected the Hungarian refusal, got: %s", w.Body.String()) } // The agent teardown must NEVER have started — that is what orphans the automount. if got := agent.removed(); len(got) != 0 { t.Fatalf("agent RemoveNetStorage ran despite the refusal: %v", got) } // The share stays registered (nothing half-removed). found := false for _, sp := range s.settings.GetStoragePaths() { if sp.Path == share { found = true } } if !found { t.Fatal("the share was deregistered despite the refusal") } } func TestNetStorageRemove_ProceedsWithoutDeployedApps(t *testing.T) { s, agent, share := testRemoveGuardServer(t, false) w := postNetRemove(t, s, "campaign6") if w.Code != http.StatusOK { t.Fatalf("remove with no deployed apps: got %d want 200 (%s)", w.Code, w.Body.String()) } if got := agent.removed(); len(got) != 1 || got[0] != "campaign6" { t.Fatalf("agent RemoveNetStorage calls = %v, want [campaign6]", got) } for _, sp := range s.settings.GetStoragePaths() { if sp.Path == share { t.Fatal("the share must be deregistered after a successful remove") } } }