phase 2 review: fix SSE deadlock, MCP panic, lifecycle 500, NOT NULL bug
Reviewed the phase-2 implementation (parts 2–5) end to end. The suite hung
for 600s and several handlers were never exercised because there were no
tests for the new mutation/event/MCP surface. Fixes:
- CRITICAL: sseListener ran on context.Background() and held a pooled
connection forever, so pool.Close() deadlocked (600s test timeout).
NewHandler now takes a ctx that governs the listener; ListenAndServe and
the test helper cancel it before closing the pool.
- CRITICAL: MCP AddTool panicked ("missing input schema") at construction
under go-sdk v1.6.1 — so NewHandler (and every API handler) panicked.
Added object input schemas to all 8 tools via an objSchema helper.
- HIGH: PatchEntity parsed lifecycle transitions as map[string][]string but
the shape is {from:{to:{requires:[]}}}, so every state-change PATCH 500'd.
Parse the nested shape; allow same-state no-ops.
- HIGH: CreateEntity bound SQL NULL for attributes when omitted, violating
the NOT NULL column (the default only applies when omitted). Default to
'{}'.
- MED: serveSSEWriter ignored the request ctx (per-client goroutine leak on
disconnect) and set an invalid Content-Length: -1. Thread ctx through;
omit the header. writeSSE now nil-checks the flusher (io.Pipe path passed
nil → would have panicked on first event).
- MED: SSE `data:` leaked raw sqlcgen.Event (PascalCase, base64 JSONB).
Emit canonical gen.Event so SSE matches GET /events. Verified live.
- LOW: CreateEntity uses uuid.NewV7 (ADR-0005) + real actor from context in
audit; removed dead bearerAuth; fixed vet unkeyed-field warnings.
Tests (would have caught all of the above): entity create/patch with
If-Match 409/400, valid+invalid lifecycle transitions, idempotency replay,
duplicate-slug 409, abstract-type 422, event+audit side effects, MCP tool
registration. Live smoke test confirmed NOTIFY→listener→SSE delivery.
Also adds the missing Phase 2 deliverable: Gitea Actions CI (vet,
golangci-lint, govulncheck, generated-code drift guard, race tests against
TimescaleDB, docker build) and wires sqlc into `make generate`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
178
internal/httpapi/mutations_test.go
Normal file
178
internal/httpapi/mutations_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
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")
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
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) + `"`
|
||||
}
|
||||
Reference in New Issue
Block a user