package api import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/backup" "gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // Update arc slice 4 through the PRODUCTION handler: actionStack → the real stacks.Manager // (NewManager + ScanStacks) and the real backup.Manager over real settings. No docker is reached: // every path here refuses, or fails at the pin (the app's catalog template is absent on purpose). type apiFakeGuards struct { b *backup.Manager points []stacks.UpdateRestorePoint cannotBackUp bool // blindToHolds makes the manager-side preflight NOT see holds, so a test can prove the ROUTER's // own hold check refuses — the two layers are each pinned separately (the preflight's by // TestSlice4_D_CheapRefusals/held). Without it, removing either layer passes inertly, because the // other refuses with the same sentence (observed on the first run of red-proof 4, 2026-09-13). blindToHolds bool } func (g *apiFakeGuards) HoldFor(n string) (bool, string) { if g.blindToHolds { return false, "" } return g.b.RestoreHoldFor(n) } func (g *apiFakeGuards) Busy(string) (bool, string) { return false, "" } func (g *apiFakeGuards) RestorePoints(_ context.Context, _ string, accept func(stacks.UpdateRestorePoint) bool) (stacks.UpdateRestorePoint, bool, []stacks.UpdateRestorePoint) { for _, p := range g.points { if accept == nil || accept(p) { return p, true, g.points } } return stacks.UpdateRestorePoint{}, false, g.points } func (g *apiFakeGuards) CanBackUp(string) (bool, string) { return !g.cannotBackUp, "fake: no drive" } func (g *apiFakeGuards) BackupNow(context.Context, string) error { return nil } func (g *apiFakeGuards) SafetyDump(context.Context, string) ([]string, error) { return nil, nil } func (g *apiFakeGuards) HoldAfterFailedUpdate(string, time.Time, stacks.UpdateRestorePoint, string) error { return nil } const slice4AppYAML = "deployed: true\nenv: {}\npinned_images:\n app: nginx:1.27\n" func newSlice4Router(t *testing.T) (*Router, *settings.Settings, *apiFakeGuards, string) { t.Helper() root := t.TempDir() dir := filepath.Join(root, "stacks", "app") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n app:\n image: nginx:1.27\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(slice4AppYAML), 0o600); err != nil { t.Fatal(err) } cfg := &config.Config{} cfg.Paths.StacksDir = filepath.Join(root, "stacks") cfg.Paths.DataDir = filepath.Join(root, "data") cfg.Paths.SystemDataPath = filepath.Join(root, "sys") cfg.Stacks.ComposeCommand = "docker compose" 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) } sett, err := settings.Load(filepath.Join(root, "settings.json"), lg) if err != nil { t.Fatal(err) } b := backup.NewManager(cfg, sett, lg) g := &apiFakeGuards{b: b, points: []stacks.UpdateRestorePoint{{Tier: stacks.UpdateTierSecondDrive, ProvenAt: time.Now().Add(-time.Hour)}}} m.SetUpdateGuards(g) return &Router{cfg: cfg, stackMgr: m, backupMgr: b, logger: lg}, sett, g, dir } func postUpdate(t *testing.T, r *Router) (int, apiResponse) { t.Helper() w := httptest.NewRecorder() r.actionStack(w, httptest.NewRequest("POST", "/api/stacks/x/action", nil), "update", "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 } // TestR439_UpdateOfAHeldAppIsRefused — R-439 closed. // // COMPANION RED-PROOF 4 (REPORT.md): remove `|| action == "update"` from actionStack's hold check. The // preflight then refuses on its own grounds with a DIFFERENT sentence, and this test fails on the // message — which is what proves the router line is the one doing it. func TestR439_UpdateOfAHeldAppIsRefused(t *testing.T) { r, sett, g, dir := newSlice4Router(t) g.points, g.cannotBackUp = nil, true // the preflight's own refusal would say "no backup" — not the hold g.blindToHolds = true // only the router's line can produce the hold's sentence if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "app", At: "2026-09-13T08:00:00Z", Reason: settings.HoldReasonUpdateFailed, CopyDate: "2026-09-13T01:30:00Z"}); err != nil { t.Fatal(err) } before, _ := os.ReadFile(filepath.Join(dir, "app.yaml")) code, resp := postUpdate(t, r) _, holdText := r.backupMgr.RestoreHoldFor("app") if code != http.StatusConflict || resp.OK || resp.Error != holdText { t.Fatalf("a HELD app's update must be refused with the hold's own sentence: code=%d ok=%v error=%q", code, resp.OK, resp.Error) } after, _ := os.ReadFile(filepath.Join(dir, "app.yaml")) if string(before) != string(after) { t.Error("a refused update must record no intent — app.yaml changed") } } func TestSlice4_Router_NoBackupIs409AndRecordsNothing(t *testing.T) { r, _, g, dir := newSlice4Router(t) g.points, g.cannotBackUp = nil, true before, _ := os.ReadFile(filepath.Join(dir, "app.yaml")) code, resp := postUpdate(t, r) if code != http.StatusConflict || resp.Error != fmt.Sprintf(stacks.MsgUpdateNoBackupFmt, "app") { t.Fatalf("code=%d error=%q", code, resp.Error) } if after, _ := os.ReadFile(filepath.Join(dir, "app.yaml")); string(before) != string(after) { t.Error("the preflight refusal must come BEFORE the intent write") } } // TestR443_UpdateIsNeverReportedCompleteSynchronously — R-443 closed. The handler answers 202 with // completed:false; the job then runs (and here fails at the pin, the catalog being absent), and the // outcome exists ONLY on GET /api/stacks/{name}. func TestR443_UpdateIsNeverReportedCompleteSynchronously(t *testing.T) { r, _, _, _ := newSlice4Router(t) code, resp := postUpdate(t, r) if code != http.StatusAccepted { t.Fatalf("an accepted update must answer 202, got %d (%+v)", code, resp) } data, _ := resp.Data.(map[string]interface{}) if data["completed"] != false || data["accepted"] != true { t.Errorf("the body must say accepted and NOT completed, got %v", resp.Data) } if resp.Message == "Stack app update completed" { t.Error("the synchronous response claimed completion — R-443") } deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if st, ok := r.stackMgr.GetStack("app"); ok && !st.Updating { if st.UpdatePhase != stacks.UpdatePhaseFailed || st.UpdateError != stacks.MsgUpdatePinFailed { t.Errorf("the job's truth must be on the stack: phase=%q err=%q", st.UpdatePhase, st.UpdateError) } return } time.Sleep(10 * time.Millisecond) } t.Fatal("the job never finished") }