package httpapi import ( "encoding/json" "net/http" "strings" "testing" ) func decodeAutoCycle(t *testing.T, body []byte) autoCycleDTO { t.Helper() var dto autoCycleDTO if err := json.Unmarshal(body, &dto); err != nil { t.Fatalf("unmarshal auto-cycle %q: %v", body, err) } return dto } func TestAutoCycleGetDefaults(t *testing.T) { fc, _, _, _ := newConfigTestHarness(t) resp, body := fc.do(http.MethodGet, "/api/v1/admin/auto-cycle", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("get: status=%d body=%s", resp.StatusCode, body) } dto := decodeAutoCycle(t, body) if dto.Enabled || dto.IntervalSeconds != 3600 || dto.MaxRunSeconds != 0 || dto.Phase != "idle" || dto.RunsTotal != 0 { t.Fatalf("unexpected defaults: %+v", dto) } // Unset times must be explicit JSON nulls with snake_case keys. var raw map[string]json.RawMessage if err := json.Unmarshal(body, &raw); err != nil { t.Fatalf("unmarshal raw: %v", err) } for _, k := range []string{"enabled", "interval_seconds", "max_run_seconds", "phase", "run_started_at", "next_run_at", "last_run_started_at", "last_run_finished_at", "last_outcome", "last_error", "last_scanned_free", "runs_total"} { v, ok := raw[k] if !ok { t.Fatalf("missing key %q in %s", k, body) } if strings.HasSuffix(k, "_at") && string(v) != "null" { t.Fatalf("expected %s to be null, got %s", k, v) } } } func TestAutoCyclePutPartialUpdate(t *testing.T) { fc, _, _, _ := newConfigTestHarness(t) interval, maxRun := 120, 1800 resp, body := fc.do(http.MethodPut, "/api/v1/admin/auto-cycle", putAutoCycleRequest{IntervalSeconds: &interval, MaxRunSeconds: &maxRun}) if resp.StatusCode != http.StatusOK { t.Fatalf("put: status=%d body=%s", resp.StatusCode, body) } dto := decodeAutoCycle(t, body) if dto.IntervalSeconds != 120 || dto.MaxRunSeconds != 1800 { t.Fatalf("expected 120/1800, got %+v", dto) } // Only max_run_seconds in the body: interval stays. resp, body = fc.do(http.MethodPut, "/api/v1/admin/auto-cycle", map[string]int{"max_run_seconds": 0}) if resp.StatusCode != http.StatusOK { t.Fatalf("partial put: status=%d body=%s", resp.StatusCode, body) } dto = decodeAutoCycle(t, body) if dto.IntervalSeconds != 120 || dto.MaxRunSeconds != 0 { t.Fatalf("expected 120/0 after partial update, got %+v", dto) } resp, body = fc.do(http.MethodGet, "/api/v1/admin/auto-cycle", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("get: status=%d body=%s", resp.StatusCode, body) } if dto = decodeAutoCycle(t, body); dto.IntervalSeconds != 120 { t.Fatalf("expected persisted interval 120, got %+v", dto) } } func TestAutoCyclePutValidation(t *testing.T) { fc, _, _, _ := newConfigTestHarness(t) resp, body := fc.do(http.MethodPut, "/api/v1/admin/auto-cycle", map[string]int{"interval_seconds": 59}) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("interval 59: expected 400, got %d body=%s", resp.StatusCode, body) } resp, body = fc.do(http.MethodPut, "/api/v1/admin/auto-cycle", map[string]int{"max_run_seconds": -1}) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("max_run -1: expected 400, got %d body=%s", resp.StatusCode, body) } // Boundary values are accepted. resp, body = fc.do(http.MethodPut, "/api/v1/admin/auto-cycle", map[string]int{"interval_seconds": 60, "max_run_seconds": 0}) if resp.StatusCode != http.StatusOK { t.Fatalf("boundary put: status=%d body=%s", resp.StatusCode, body) } // Nothing from the rejected requests leaked through. resp, body = fc.do(http.MethodGet, "/api/v1/admin/auto-cycle", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("get: status=%d body=%s", resp.StatusCode, body) } if dto := decodeAutoCycle(t, body); dto.IntervalSeconds != 60 || dto.MaxRunSeconds != 0 { t.Fatalf("expected 60/0, got %+v", dto) } } func TestAutoCycleStartStop(t *testing.T) { fc, _, orch, mock := newConfigTestHarness(t) mock.Seed("fip-1", "1.1.1.1", "svc-project") resp, body := fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/start", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("start: status=%d body=%s", resp.StatusCode, body) } dto := decodeAutoCycle(t, body) if !dto.Enabled || dto.Phase != "idle" || dto.NextRunAt == nil { t.Fatalf("expected enabled+idle with next_run_at after start, got %+v", dto) } // The engine picks it up on the next step and the API reflects it. orch.AutoCycleStep(t.Context()) resp, body = fc.do(http.MethodGet, "/api/v1/admin/auto-cycle", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("get: status=%d body=%s", resp.StatusCode, body) } dto = decodeAutoCycle(t, body) if dto.Phase != "running" || dto.RunStartedAt == nil || dto.LastScannedFree != 1 { t.Fatalf("expected running with 1 scanned FIP, got %+v", dto) } resp, body = fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("stop: status=%d body=%s", resp.StatusCode, body) } dto = decodeAutoCycle(t, body) if dto.Enabled || dto.Phase != "idle" || dto.LastOutcome != "stopped" { t.Fatalf("expected disabled/idle/stopped, got %+v", dto) } } func TestAutoCycleStartIsIdempotent(t *testing.T) { fc, _, orch, mock := newConfigTestHarness(t) mock.Seed("fip-1", "1.1.1.1", "svc-project") if resp, body := fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/start", nil); resp.StatusCode != http.StatusOK { t.Fatalf("start: status=%d body=%s", resp.StatusCode, body) } orch.AutoCycleStep(t.Context()) resp, body := fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/start", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("second start: status=%d body=%s", resp.StatusCode, body) } dto := decodeAutoCycle(t, body) if !dto.Enabled || dto.Phase != "running" { t.Fatalf("second start must not disturb a running cycle, got %+v", dto) } // Stop is idempotent too. for i := 0; i < 2; i++ { resp, body = fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("stop #%d: status=%d body=%s", i, resp.StatusCode, body) } } } func TestAutoCyclePreservesParamsAcrossStartStop(t *testing.T) { fc, _, _, _ := newConfigTestHarness(t) interval := 300 if resp, body := fc.do(http.MethodPut, "/api/v1/admin/auto-cycle", putAutoCycleRequest{IntervalSeconds: &interval}); resp.StatusCode != http.StatusOK { t.Fatalf("put: status=%d body=%s", resp.StatusCode, body) } fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/start", nil) resp, body := fc.do(http.MethodPost, "/api/v1/admin/auto-cycle/stop", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("stop: status=%d body=%s", resp.StatusCode, body) } if dto := decodeAutoCycle(t, body); dto.IntervalSeconds != 300 { t.Fatalf("expected interval 300 preserved, got %+v", dto) } }