mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
385 lines
14 KiB
Go
385 lines
14 KiB
Go
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"
|
|
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_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 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)
|
|
}
|
|
})
|
|
}
|
|
}
|