package api import ( "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // R-609 (v0.261.0) — the 409 carries a machine-readable REASON, additively. // // WHY. `UpdateRefusal.Reason` has existed since v0.237.0 and never left the process: the body carried // only the translated sentence. A caller that is not a person cannot parse a Hungarian sentence, and // the distinction it needs is not decorative: // // TRANSIENT — busy, updating, deploying, migrating, self_updating → try again later // TERMINAL — held, downgrade → never press this again // // An unattended caller that cannot tell them apart either gives up on a passing backup window, or // presses a terminally-refused button on every pass for ever. `09` §6.2's caller reads exactly this. // // The SENTENCE is unchanged and stays where it was, so no page moves — this is purely additive. // reasonOf pulls `data.reason` out of a refusal body, or "" when absent. func reasonOf(t *testing.T, resp apiResponse) string { t.Helper() if resp.Data == nil { return "" } m, ok := resp.Data.(map[string]interface{}) if !ok { t.Fatalf("data is not an object: %#v", resp.Data) } s, _ := m["reason"].(string) return s } // TestR609_EveryRefusalCarriesItsReason is table-driven over the refusal paths reachable without // docker, and it INCLUDES the router's own hold line — which fires before UpdatePreflight and was // the one that had no reason at all until this test was written. // // COMPANION RED-PROOF (run 2026-09-21): drop the `Data:` field from either writeJSON in actionStack's // update paths. The corresponding row fails with `reason = ""`. Reverted. func TestR609_EveryRefusalCarriesItsReason(t *testing.T) { cases := []struct { name string arrange func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string) wantReason string wantCode int }{ { name: "held — the ROUTER's own line, before the preflight", arrange: func(t *testing.T, r *Router, sett *settings.Settings, g *apiFakeGuards, dir string) { if err := sett.SetRestoreHold(settings.RestoreHold{ Stack: "app", At: "2026-09-21T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-21T01:30:00Z", }); err != nil { t.Fatal(err) } }, wantReason: "held", wantCode: http.StatusConflict, }, { name: "no_backup — nothing on any tier and none can be taken", arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, g *apiFakeGuards, _ string) { g.points, g.cannotBackUp = nil, true }, wantReason: "no_backup", wantCode: http.StatusConflict, }, { name: "self_updating — the controller is swapping itself (v0.261.0)", arrange: func(_ *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) { r.stackMgr.SetSelfUpdatingCheck(func() bool { return true }) }, wantReason: "self_updating", wantCode: http.StatusConflict, }, { name: "downgrade — the box runs something newer than the catalog (R-524)", arrange: func(t *testing.T, r *Router, _ *settings.Settings, _ *apiFakeGuards, dir string) { setInstalledAndCatalog(t, r, dir, "nginx:1.28", "nginx:1.27") }, wantReason: "downgrade", wantCode: http.StatusConflict, }, { name: "not_found — an app that exists nowhere", arrange: func(_ *testing.T, _ *Router, _ *settings.Settings, _ *apiFakeGuards, _ string) {}, wantReason: "not_found", wantCode: http.StatusNotFound, }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { r, sett, g, dir := newSlice4Router(t) c.arrange(t, r, sett, g, dir) name := "app" if c.wantReason == "not_found" { name = "no-such-app" } code, resp := postUpdateNamed(t, r, name) if code != c.wantCode { t.Errorf("code = %d, want %d (error %q)", code, c.wantCode, resp.Error) } if resp.OK { t.Fatal("a refusal must not answer ok") } if got := reasonOf(t, resp); got != c.wantReason { t.Errorf("reason = %q, want %q", got, c.wantReason) } // The sentence is the household's and must still be there, in words — never a bare key. if resp.Error == "" { t.Error("the reason is ADDITIVE: the sentence must still be present") } }) } } // TestR609_ASuccessfulActionCarriesNoReason — the field is for refusals only, so a caller cannot // mistake a normal answer for one. func TestR609_ASuccessfulActionCarriesNoReason(t *testing.T) { r, _, _, _ := newSlice4Router(t) _, resp := postStopNamed(t, r, "app") if got := reasonOf(t, resp); got != "" { t.Errorf("a non-refusal must carry no reason, got %q", got) } } // ── helpers ───────────────────────────────────────────────────────────────────────────────────── // postUpdateNamed is postUpdate for an arbitrary app name (the not_found row needs one that is absent). func postUpdateNamed(t *testing.T, r *Router, name string) (int, apiResponse) { t.Helper() w := httptest.NewRecorder() r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", name) 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 } // postStopNamed exercises a NON-update action, as the control that `reason` is refusal-only. func postStopNamed(t *testing.T, r *Router, name string) (int, apiResponse) { t.Helper() w := httptest.NewRecorder() r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "stop", name) 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 } // setInstalledAndCatalog puts the app AHEAD of the catalog, which is R-524's Ahead arm. // // It writes the REAL files the real code reads — `installed_images` into the app's app.yaml and a // catalog template under /catalog-cache/templates// — and then rescans. **No test-only // seam is added to production code for this**: the whole point of the api tests here is that they go // through the production handler over a real stacks.Manager, and a setter that only tests call would // be a second way to reach the state, which is how the two drift apart. func setInstalledAndCatalog(t *testing.T, r *Router, dir, installed, catalog string) { t.Helper() appYAML := slice4AppYAML + "installed_images:\n app:\n ref: " + installed + "\n digest: sha256:x\n at: \"2026-09-01T00:00:00Z\"\n" if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(appYAML), 0o600); err != nil { t.Fatal(err) } catDir := filepath.Join(r.cfg.Paths.DataDir, "catalog-cache", "templates", "app") if err := os.MkdirAll(catDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(catDir, "docker-compose.yml"), []byte("services:\n app:\n image: "+catalog+"\n"), 0o644); err != nil { t.Fatal(err) } if err := r.stackMgr.ScanStacks(); err != nil { t.Fatal(err) } // Positive control: the state this helper claims to create must actually exist, or the row it // serves would pass for the wrong reason (a different refusal firing first). if got := stacks.CatalogOrder(mustStack(t, r, "app")); got != stacks.UpdateOrderAhead { t.Fatalf("fixture did not produce the AHEAD state; CatalogOrder = %v", got) } } func mustStack(t *testing.T, r *Router, name string) stacks.Stack { t.Helper() st, ok := r.stackMgr.GetStack(name) if !ok { t.Fatalf("stack %q missing", name) } return *st }