package httpapi // Integration tests for the Phase 2 mutation surface: entity create/patch // with optimistic concurrency, idempotency, lifecycle-transition validation, // and the audit/event side effects. Guarded by OIKOS_TEST_DATABASE_URL. import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" ) // do issues a JSON request and returns the recorder + decoded body. func do(t *testing.T, h http.Handler, method, path string, body any, headers map[string]string) (*httptest.ResponseRecorder, map[string]any) { t.Helper() var rdr *bytes.Reader if body != nil { b, _ := json.Marshal(body) rdr = bytes.NewReader(b) } else { rdr = bytes.NewReader(nil) } req := httptest.NewRequest(method, path, rdr) req.Header.Set("Content-Type", "application/json") applyHeaders(req, headers) rec := httptest.NewRecorder() h.ServeHTTP(rec, req) var decoded map[string]any json.Unmarshal(rec.Body.Bytes(), &decoded) return rec, decoded } func TestEntityCreateAndPatch(t *testing.T) { h := newTestHandler(t, devConfig()) // ── create ────────────────────────────────────────────────── rec, body := do(t, h, "POST", "/api/v1/entities", map[string]any{ "slug": "service:test-widget", "type": "service", "name": "test-widget", "attributes": map[string]any{"port": 9999}, }, nil) if rec.Code != 201 { t.Fatalf("create status %d: %v", rec.Code, body) } if body["slug"] != "service:test-widget" { t.Fatalf("created slug = %v", body["slug"]) } // default lifecycle state applied if body["state"] != "active" { t.Errorf("default state = %v, want active", body["state"]) } etag := rec.Header().Get("ETag") if etag == "" { t.Error("missing ETag on create") } version := int(body["version"].(float64)) // ── duplicate slug → 409 ──────────────────────────────────── rec, _ = do(t, h, "POST", "/api/v1/entities", map[string]any{ "slug": "service:test-widget", "type": "service", "name": "dup", }, nil) if rec.Code != 409 { t.Errorf("duplicate slug status = %d, want 409", rec.Code) } // ── abstract type → 422 ───────────────────────────────────── rec, _ = do(t, h, "POST", "/api/v1/entities", map[string]any{ "slug": "machine:ghost", "type": "machine", "name": "ghost", }, nil) if rec.Code != 422 { t.Errorf("abstract type status = %d, want 422", rec.Code) } // ── patch without If-Match → 400 ──────────────────────────── rec, _ = do(t, h, "PATCH", "/api/v1/entities/service:test-widget", map[string]any{"name": "renamed"}, nil) if rec.Code != 400 { t.Errorf("patch w/o If-Match = %d, want 400", rec.Code) } // ── patch with stale If-Match → 409 ───────────────────────── rec, _ = do(t, h, "PATCH", "/api/v1/entities/service:test-widget", map[string]any{"name": "renamed"}, map[string]string{"If-Match": `"999"`}) if rec.Code != 409 { t.Errorf("stale If-Match = %d, want 409", rec.Code) } // ── valid attribute patch → 200, version bumps ────────────── rec, body = do(t, h, "PATCH", "/api/v1/entities/service:test-widget", map[string]any{"name": "renamed"}, map[string]string{"If-Match": itoaQ(version)}) if rec.Code != 200 { t.Fatalf("patch status %d: %v", rec.Code, body) } if body["name"] != "renamed" || int(body["version"].(float64)) != version+1 { t.Errorf("patch result: name=%v version=%v", body["name"], body["version"]) } version++ // ── valid lifecycle transition active→deprecated → 200 ────── // (this is the regression guard for the transitions-parsing 500 bug) rec, body = do(t, h, "PATCH", "/api/v1/entities/service:test-widget", map[string]any{"state": "deprecated"}, map[string]string{"If-Match": itoaQ(version)}) if rec.Code != 200 { t.Fatalf("valid transition status %d: %v", rec.Code, body) } if body["state"] != "deprecated" { t.Errorf("state = %v, want deprecated", body["state"]) } version++ // ── invalid lifecycle transition deprecated→provisioning → 409 ── rec, _ = do(t, h, "PATCH", "/api/v1/entities/service:test-widget", map[string]any{"state": "provisioning"}, map[string]string{"If-Match": itoaQ(version)}) if rec.Code != 409 { t.Errorf("invalid transition status = %d, want 409", rec.Code) } } func TestEntityCreateIdempotency(t *testing.T) { h := newTestHandler(t, devConfig()) payload := map[string]any{"slug": "service:idem", "type": "service", "name": "idem"} key := map[string]string{"Idempotency-Key": "abc-123"} rec1, body1 := do(t, h, "POST", "/api/v1/entities", payload, key) if rec1.Code != 201 { t.Fatalf("first create %d: %v", rec1.Code, body1) } // replay same key + body → same response, not a duplicate-slug 409 rec2, body2 := do(t, h, "POST", "/api/v1/entities", payload, key) if rec2.Code != 201 { t.Fatalf("idempotent replay = %d, want 201: %v", rec2.Code, body2) } if body1["id"] != body2["id"] { t.Errorf("replay returned different entity: %v vs %v", body1["id"], body2["id"]) } // same key, different body → 409 conflict rec3, _ := do(t, h, "POST", "/api/v1/entities", map[string]any{"slug": "service:idem2", "type": "service", "name": "idem2"}, key) if rec3.Code != 409 { t.Errorf("key reuse w/ different body = %d, want 409", rec3.Code) } } func TestMutationEmitsEventAndAudit(t *testing.T) { h := newTestHandler(t, devConfig()) rec, _ := do(t, h, "POST", "/api/v1/entities", map[string]any{"slug": "service:evt", "type": "service", "name": "evt"}, nil) if rec.Code != 201 { t.Fatalf("create failed: %d", rec.Code) } // event stream recorded the creation _, body := do(t, h, "GET", "/api/v1/events?type=entity.created", nil, nil) items, _ := body["items"].([]any) if len(items) == 0 { t.Fatal("no entity.created event recorded") } // audit trail recorded the create (operator-visible) _, abody := do(t, h, "GET", "/api/v1/audit?action=create", nil, nil) aitems, _ := abody["items"].([]any) if len(aitems) == 0 { t.Fatal("no create audit entry recorded") } } // itoaQ formats an int as a quoted ETag value. func itoaQ(v int) string { b, _ := json.Marshal(v) return `"` + string(b) + `"` }