package api import ( "encoding/json" "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/stacks" ) // R-442 through the PRODUCTION WIRING: the POST /api/stacks/{name}/remove handler → the real // stacks.Manager (constructor + ScanStacks, no hand-set state) → RemoveStack. Two refusals, both // non-2xx with the exact customer sentence, and in both cases app.yaml is still on disk afterwards. // Neither reaches the compose boundary (the refusal precedes it), so no docker is touched. func newR442Router(t *testing.T, appYAML string) (*Router, string) { t.Helper() root := t.TempDir() dir := filepath.Join(root, "app") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } compose := "services:\n app:\n image: nginx:1.27\n volumes:\n - ${HDD_PATH}/appdata/app:/data\n" 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 = "/mnt/sys_drive" cfg.Stacks.ComposeCommand = "docker compose" // skips detection; never invoked on the refusal path lg := log.New(io.Discard, "", 0) m, err := stacks.NewManager(cfg, lg) if err != nil { t.Fatal(err) } if err := m.ScanStacks(); err != nil { t.Fatal(err) } return &Router{cfg: cfg, stackMgr: m, logger: lg}, dir } func postRemove(t *testing.T, r *Router, body string) (int, apiResponse) { t.Helper() req := httptest.NewRequest(http.MethodPost, "/api/stacks/app/remove", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.removeStack(w, req, "app") var resp apiResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("non-JSON body %q: %v", w.Body.String(), err) } return w.Code, resp } func TestRemoveHandler_R442_UnresolvedDriveIsRefusedNon2xx(t *testing.T) { r, dir := newR442Router(t, "deployed: true\nenv: {}\n") code, resp := postRemove(t, r, `{"remove_hdd_data":true,"remove_backups":false}`) if code/100 == 2 || resp.OK { t.Fatalf("HTTP %d ok=%v — a 2xx over a removal that could not resolve the data location is the R-442 defect (body error=%q)", code, resp.OK, resp.Error) } if code != http.StatusConflict { t.Fatalf("status = %d, want 409", code) } const want = "Az alkalmazás adatainak helye nem állapítható meg, ezért semmit nem töröltünk. Az alkalmazás nem lett eltávolítva." if resp.Error != want { t.Fatalf("error = %q, want the exact customer sentence", resp.Error) } if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil { t.Fatal("app.yaml gone — the app was removed with its data left behind") } } func TestRemoveHandler_R442_DriveAbsentIsRefusedNon2xx(t *testing.T) { drive := t.TempDir() // a plain directory is not a mountpoint → the drive is "absent" r, dir := newR442Router(t, "deployed: true\nenv:\n HDD_PATH: "+drive+"\n") code, resp := postRemove(t, r, `{"remove_hdd_data":true}`) if code != http.StatusConflict || resp.OK { t.Fatalf("HTTP %d ok=%v, want 409 refusal (error=%q)", code, resp.OK, resp.Error) } if !strings.Contains(resp.Error, drive) || !strings.Contains(resp.Error, "vissza nem csatlakozik") { t.Fatalf("error = %q, want the drive-absent sentence naming %s", resp.Error, drive) } if _, err := os.Stat(filepath.Join(dir, "app.yaml")); err != nil { t.Fatal("app.yaml gone on a drive-absent refusal") } }