package api import ( "bytes" "database/sql" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) // newOperatorStateHandlers spins up a ResourceHandlers wired against a // per-test temp data dir so the SQLite store is ephemeral, mirroring the // existing resources_test.go fixtures. func newOperatorStateHandlers(t *testing.T) *ResourceHandlers { t.Helper() h, _ := newOperatorStateHandlersWithDataDir(t) return h } func newOperatorStateHandlersWithDataDir(t *testing.T) (*ResourceHandlers, string) { t.Helper() dataDir := t.TempDir() cfg := &config.Config{DataPath: dataDir} return NewResourceHandlers(cfg), dataDir } func dropOperatorStateTimelineTable(t *testing.T, dataDir string) { t.Helper() db, err := sql.Open("sqlite", filepath.Join(dataDir, "resources", "unified_resources.db")) if err != nil { t.Fatalf("open resource db for failure injection: %v", err) } defer db.Close() if _, err := db.Exec(`DROP TABLE resource_changes`); err != nil { t.Fatalf("drop resource_changes for failure injection: %v", err) } } func TestHandleResourceOperatorState_GetReturns404WhenUnset(t *testing.T) { h := newOperatorStateHandlers(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("expected 404 on unset state; got %d body=%s", rec.Code, rec.Body.String()) } var body map[string]string if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("body must be JSON; got %q", rec.Body.String()) } // The handler must surface a stable error code so the frontend can // branch on it without string-matching the human message. if body["error"] != "operator_state_not_set" { t.Errorf("expected error=operator_state_not_set; got %v", body) } } func TestHandleResourceOperatorState_LookupViewReturnsCleanUnsetEnvelope(t *testing.T) { h := newOperatorStateHandlers(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/resources/vm:101/operator-state?view=lookup", nil) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusOK { t.Fatalf("expected 200 lookup envelope on unset state; got %d body=%s", rec.Code, rec.Body.String()) } var body resourceOperatorStateLookupAPI if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("lookup body must be JSON; got %q", rec.Body.String()) } if body.Configured || body.State != nil { t.Fatalf("unset lookup must preserve absence without a synthetic state: %+v", body) } } func TestHandleResourceOperatorState_PutPersistsAndGetReturns200(t *testing.T) { h := newOperatorStateHandlers(t) start := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC) end := time.Date(2026, 5, 9, 14, 0, 0, 0, time.UTC) payload := map[string]any{ // canonical_id in body is intentionally a different value to // confirm the URL path wins over body. Anything in the body must // not be able to retarget the write at a different resource. "canonicalId": "vm:999", "intentionallyOffline": true, "neverAutoRemediate": true, "maintenanceStartAt": start.Format(time.RFC3339), "maintenanceEndAt": end.Format(time.RFC3339), "maintenanceReason": "Q3 storage upgrade", "criticality": "high", "note": "do not auto-fix", } body, _ := json.Marshal(payload) putRec := httptest.NewRecorder() putReq := httptest.NewRequest(http.MethodPut, "/api/resources/vm:101/operator-state", bytes.NewReader(body)) h.HandleResourceOperatorState(putRec, putReq) if putRec.Code != http.StatusOK { t.Fatalf("expected 200 on PUT; got %d body=%s", putRec.Code, putRec.Body.String()) } var persisted resourceOperatorStateAPI if err := json.Unmarshal(putRec.Body.Bytes(), &persisted); err != nil { t.Fatalf("PUT response must be parseable as resourceOperatorStateAPI; got %q", putRec.Body.String()) } // URL canonical_id wins over body — even though body said vm:999. if persisted.CanonicalID != "vm:101" { t.Errorf("URL canonical_id must override body; got %q", persisted.CanonicalID) } if !persisted.IntentionallyOffline || !persisted.NeverAutoRemediate { t.Errorf("boolean flags must round-trip on PUT response: %+v", persisted) } if persisted.Criticality != "high" { t.Errorf("criticality must round-trip; got %q", persisted.Criticality) } // Server-populated attribution: SetAt is populated even when the // body omits it. Ignoring client-supplied values keeps the audit // trail honest. if persisted.SetAt.IsZero() { t.Error("server must populate SetAt; got zero") } // Read-back via GET. getRec := httptest.NewRecorder() getReq := httptest.NewRequest(http.MethodGet, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(getRec, getReq) if getRec.Code != http.StatusOK { t.Fatalf("expected 200 on GET after PUT; got %d body=%s", getRec.Code, getRec.Body.String()) } var got resourceOperatorStateAPI if err := json.Unmarshal(getRec.Body.Bytes(), &got); err != nil { t.Fatalf("GET response must be parseable; got %q", getRec.Body.String()) } if got.MaintenanceReason != "Q3 storage upgrade" { t.Errorf("maintenance reason must round-trip via GET; got %q", got.MaintenanceReason) } if got.MaintenanceStartAt == nil || !got.MaintenanceStartAt.Equal(start) { t.Errorf("maintenance start must round-trip; got %v", got.MaintenanceStartAt) } } func TestHandleResourceOperatorState_CanonicalPolicyTakesPrecedenceAndReconciles(t *testing.T) { h := newOperatorStateHandlers(t) var reconciledOrg, reconciledResource string h.SetOperatorStateChanged(func(orgID, resourceID string) { reconciledOrg, reconciledResource = orgID, resourceID }) body := bytes.NewBufferString(`{ "monitoringMode":"muted", "lifecycleState":"retired", "intentionallyOffline":true, "neverAutoRemediate":false }`) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/resources/vm:101/operator-state", body) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusOK { t.Fatalf("PUT status = %d body=%s", rec.Code, rec.Body.String()) } var got resourceOperatorStateAPI if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } if got.MonitoringMode != "muted" || got.LifecycleState != "retired" { t.Fatalf("canonical policy response = %+v", got) } if got.IntentionallyOffline { t.Fatal("compatibility boolean must derive from canonical monitoring mode") } if reconciledOrg != "default" || reconciledResource != "vm:101" { t.Fatalf("reconciliation callback = (%q, %q)", reconciledOrg, reconciledResource) } } func TestHandleResourceOperatorState_ResolvesSourceAliasToCanonicalResource(t *testing.T) { h := newOperatorStateHandlers(t) now := time.Date(2026, 8, 11, 10, 0, 0, 0, time.UTC) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, resources: []unified.Resource{{ ID: "system-container-canonical", Type: unified.ResourceTypeSystemContainer, Name: "dev-portal-01", LastSeen: now, SupersededCanonicalIDs: []string{"mock-cluster-pve3-115"}, }}, }) var reconciledResource string h.SetOperatorStateChanged(func(_, resourceID string) { reconciledResource = resourceID }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/resources/mock-cluster-pve3-115/operator-state", bytes.NewBufferString(`{ "monitoringMode":"expected_offline", "lifecycleState":"active" }`)) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusOK { t.Fatalf("PUT status = %d body=%s", rec.Code, rec.Body.String()) } var got resourceOperatorStateAPI if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } if got.CanonicalID != "system-container-canonical" { t.Fatalf("canonical ID = %q", got.CanonicalID) } if reconciledResource != "system-container-canonical" { t.Fatalf("reconciled resource = %q", reconciledResource) } store, err := h.getStore("default") if err != nil { t.Fatal(err) } if _, found, err := store.GetResourceOperatorState("mock-cluster-pve3-115"); err != nil || found { t.Fatalf("source alias must not receive a duplicate record: found=%v err=%v", found, err) } if state, found, err := store.GetResourceOperatorState("system-container-canonical"); err != nil || !found || state.MonitoringMode != unified.MonitoringModeExpectedOffline { t.Fatalf("canonical policy = %+v found=%v err=%v", state, found, err) } } func TestResourceOperatorState_RecordsMaintenanceWindowLifecycleChanges(t *testing.T) { h := newOperatorStateHandlers(t) store, err := h.getStore("default") if err != nil { t.Fatalf("get store: %v", err) } start := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC) end := time.Date(2026, 5, 9, 14, 0, 0, 0, time.UTC) body, _ := json.Marshal(map[string]any{ "maintenanceStartAt": start.Format(time.RFC3339), "maintenanceEndAt": end.Format(time.RFC3339), "maintenanceReason": "storage controller patch", }) putRec := httptest.NewRecorder() putReq := httptest.NewRequest(http.MethodPut, "/api/resources/vm:101/operator-state", bytes.NewReader(body)) h.HandleResourceOperatorState(putRec, putReq) if putRec.Code != http.StatusOK { t.Fatalf("expected 200 on PUT; got %d body=%s", putRec.Code, putRec.Body.String()) } changes, err := store.GetRecentChangesFiltered("vm:101", time.Time{}, 0, unified.ResourceChangeFilters{ SourceTypes: []unified.ChangeSourceType{unified.SourceUserAction}, }) if err != nil { t.Fatalf("recent changes after PUT: %v", err) } if len(changes) != 1 { t.Fatalf("expected one maintenance lifecycle change after PUT, got %d", len(changes)) } if changes[0].Metadata["activityType"] != unified.MaintenanceWindowLifecycleEventScheduled { t.Fatalf("activityType = %#v want scheduled", changes[0].Metadata["activityType"]) } if changes[0].Reason != "Maintenance window scheduled" { t.Fatalf("reason = %q want scheduled", changes[0].Reason) } delRec := httptest.NewRecorder() delReq := httptest.NewRequest(http.MethodDelete, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(delRec, delReq) if delRec.Code != http.StatusNoContent { t.Fatalf("expected 204 on DELETE; got %d body=%s", delRec.Code, delRec.Body.String()) } changes, err = store.GetRecentChangesFiltered("vm:101", time.Time{}, 0, unified.ResourceChangeFilters{ SourceTypes: []unified.ChangeSourceType{unified.SourceUserAction}, }) if err != nil { t.Fatalf("recent changes after DELETE: %v", err) } if len(changes) != 2 { t.Fatalf("expected scheduled + cleared changes, got %d", len(changes)) } var cleared *unified.ResourceChange for i := range changes { if changes[i].Metadata["activityType"] == unified.MaintenanceWindowLifecycleEventCleared { cleared = &changes[i] break } } if cleared == nil { t.Fatalf("expected cleared lifecycle change, got %+v", changes) } if cleared.To != "no maintenance window" { t.Fatalf("delete change to = %q want no maintenance window", cleared.To) } } func TestResourceOperatorState_PutRollsBackWhenTimelineProjectionFails(t *testing.T) { h, dataDir := newOperatorStateHandlersWithDataDir(t) store, err := h.getStore("default") if err != nil { t.Fatalf("get store: %v", err) } dropOperatorStateTimelineTable(t, dataDir) start := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC) end := time.Date(2026, 5, 9, 14, 0, 0, 0, time.UTC) body, _ := json.Marshal(map[string]any{ "maintenanceStartAt": start.Format(time.RFC3339), "maintenanceEndAt": end.Format(time.RFC3339), "maintenanceReason": "storage controller patch", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/resources/vm:101/operator-state", bytes.NewReader(body)) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected 500 on timeline projection failure; got %d body=%s", rec.Code, rec.Body.String()) } if _, found, err := store.GetResourceOperatorState("vm:101"); err != nil { t.Fatalf("get operator state after rollback: %v", err) } else if found { t.Fatal("operator state source row persisted despite projection failure") } } func TestResourceOperatorState_DeleteRollsBackWhenTimelineProjectionFails(t *testing.T) { h, dataDir := newOperatorStateHandlersWithDataDir(t) store, err := h.getStore("default") if err != nil { t.Fatalf("get store: %v", err) } start := time.Date(2026, 5, 9, 12, 0, 0, 0, time.UTC) end := time.Date(2026, 5, 9, 14, 0, 0, 0, time.UTC) seed := unified.ResourceOperatorState{ CanonicalID: "vm:101", MaintenanceStartAt: &start, MaintenanceEndAt: &end, MaintenanceReason: "storage controller patch", IntentionallyOffline: true, SetAt: start.Add(-time.Hour), SetBy: "operator", } if err := store.SetResourceOperatorState(seed); err != nil { t.Fatalf("seed operator state: %v", err) } dropOperatorStateTimelineTable(t, dataDir) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected 500 on timeline projection failure; got %d body=%s", rec.Code, rec.Body.String()) } got, found, err := store.GetResourceOperatorState("vm:101") if err != nil { t.Fatalf("get operator state after rollback: %v", err) } if !found { t.Fatal("operator state source row was deleted despite projection failure") } if got.MaintenanceStartAt == nil || !got.MaintenanceStartAt.Equal(start) { t.Fatalf("maintenance start after rollback = %v want %v", got.MaintenanceStartAt, start) } if got.MaintenanceEndAt == nil || !got.MaintenanceEndAt.Equal(end) { t.Fatalf("maintenance end after rollback = %v want %v", got.MaintenanceEndAt, end) } } func TestHandleResourceOperatorState_PutRejectsInvalidWith400(t *testing.T) { h := newOperatorStateHandlers(t) // Unknown criticality value — must be rejected with 400 + // operator_state_invalid code, NOT silently coerced or persisted. body, _ := json.Marshal(map[string]any{ "criticality": "very-high", }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPut, "/api/resources/vm:101/operator-state", bytes.NewReader(body)) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 on invalid state; got %d body=%s", rec.Code, rec.Body.String()) } var errBody map[string]string if err := json.Unmarshal(rec.Body.Bytes(), &errBody); err != nil { t.Fatalf("body must be JSON error; got %q", rec.Body.String()) } if errBody["error"] != "operator_state_invalid" { t.Errorf("expected error=operator_state_invalid; got %v", errBody) } // And the rejection must not have persisted: GET still returns 404. getRec := httptest.NewRecorder() getReq := httptest.NewRequest(http.MethodGet, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(getRec, getReq) if getRec.Code != http.StatusNotFound { t.Errorf("rejected PUT must not persist; GET should still 404; got %d", getRec.Code) } } func TestHandleResourceOperatorState_DeleteIsIdempotent(t *testing.T) { h := newOperatorStateHandlers(t) // DELETE on a fresh store with no entry: 204 (idempotent). rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusNoContent { t.Fatalf("expected 204 on idempotent DELETE; got %d body=%s", rec.Code, rec.Body.String()) } // Set, then DELETE, then GET should 404. setRec := httptest.NewRecorder() setReq := httptest.NewRequest(http.MethodPut, "/api/resources/vm:101/operator-state", bytes.NewReader([]byte(`{"intentionallyOffline":true}`))) h.HandleResourceOperatorState(setRec, setReq) if setRec.Code != http.StatusOK { t.Fatalf("setup PUT failed: %d %s", setRec.Code, setRec.Body.String()) } delRec := httptest.NewRecorder() delReq := httptest.NewRequest(http.MethodDelete, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(delRec, delReq) if delRec.Code != http.StatusNoContent { t.Fatalf("expected 204 on DELETE after PUT; got %d", delRec.Code) } getRec := httptest.NewRecorder() getReq := httptest.NewRequest(http.MethodGet, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(getRec, getReq) if getRec.Code != http.StatusNotFound { t.Errorf("expected 404 after DELETE; got %d", getRec.Code) } } func TestHandleResourceOperatorState_RejectsEmptyResourceID(t *testing.T) { h := newOperatorStateHandlers(t) rec := httptest.NewRecorder() // Path with no resource ID between /resources/ and /operator-state. req := httptest.NewRequest(http.MethodGet, "/api/resources//operator-state", nil) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("expected 400 on empty resource id; got %d", rec.Code) } } func TestHandleResourceOperatorState_RejectsUnsupportedMethod(t *testing.T) { h := newOperatorStateHandlers(t) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/api/resources/vm:101/operator-state", nil) h.HandleResourceOperatorState(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("expected 405 on POST; got %d", rec.Code) } } func TestExtractOperatorStateResourceID(t *testing.T) { cases := []struct { name string path string want string }{ {"canonical resource id", "/api/resources/vm:101/operator-state", "vm:101"}, {"trailing slash before suffix", "/api/resources/vm:101/operator-state/", "vm:101"}, {"empty id", "/api/resources//operator-state", ""}, {"colon-bearing id", "/api/resources/instance:node:200/operator-state", "instance:node:200"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := extractOperatorStateResourceID(tc.path); got != tc.want { t.Errorf("extractOperatorStateResourceID(%q) = %q, want %q", tc.path, got, tc.want) } }) } }