package api import ( "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/kami/hexis/internal/domain" "github.com/kami/hexis/internal/storage" ) func seedCapability(t *testing.T, store *storage.Store, entityID string) *domain.Capability { t.Helper() now := time.Now().UTC() c := &domain.Capability{ ID: domain.NewCapabilityID(), Name: "docker.restart_container", Description: "Restart a container", TargetTypes: []string{"container"}, TargetEntityID: entityID, Provider: "workspace_mcp", Operation: "docker.restart_container", Risk: domain.RiskMedium, ReadOnly: false, RequiresConfirmation: domain.RequiresConfirmationForRisk(domain.RiskMedium), Enabled: domain.EnabledForRisk(domain.RiskMedium), TimeoutSeconds: domain.DefaultCapabilityTimeoutSeconds, Attributes: map[string]any{}, CreatedAt: now, UpdatedAt: now, Version: 1, } if err := store.CreateCapability(c); err != nil { t.Fatalf("create capability: %v", err) } return c } // TestCapabilityWireShape_ListAndGetAgree pins the unification: the list // response and the single-capability response are now produced by the same // serializer, so a client cannot see one shape from one endpoint and a // different shape from the other. Before this, list emitted a hand-built map // (no enabled/requires_confirmation) while get returned the raw domain struct. func TestCapabilityWireShape_ListAndGetAgree(t *testing.T) { h, store := newTestHandler(t) c := seedCapability(t, store, "ent_alpha") listReq := httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil) listW := httptest.NewRecorder() h.listCapabilities(listW, listReq) if listW.Code != http.StatusOK { t.Fatalf("list: expected 200, got %d: %s", listW.Code, listW.Body.String()) } var list []map[string]any if err := json.Unmarshal(listW.Body.Bytes(), &list); err != nil { t.Fatalf("decode list: %v", err) } if len(list) != 1 { t.Fatalf("expected 1 capability, got %d", len(list)) } getW := httptest.NewRecorder() h.getCapability(getW, httptest.NewRequest(http.MethodGet, "/api/v1/capabilities/"+c.ID, nil), c.ID) if getW.Code != http.StatusOK { t.Fatalf("get: expected 200, got %d: %s", getW.Code, getW.Body.String()) } var single map[string]any if err := json.Unmarshal(getW.Body.Bytes(), &single); err != nil { t.Fatalf("decode get: %v", err) } for k := range single { if _, ok := list[0][k]; !ok { t.Errorf("field %q present on GET by ID but missing from the list response", k) } } for k := range list[0] { if _, ok := single[k]; !ok { t.Errorf("field %q present in the list response but missing from GET by ID", k) } } } // TestListCapabilities_ExposesGuardsAndBothIDAliases pins the two wire // decisions at the HTTP boundary: `enabled`/`requires_confirmation` are no // longer omitted (the review finding), and `id`/`capability_id` are both // emitted for the duration of Maven's migration. func TestListCapabilities_ExposesGuardsAndBothIDAliases(t *testing.T) { h, store := newTestHandler(t) c := seedCapability(t, store, "ent_alpha") w := httptest.NewRecorder() h.listCapabilities(w, httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil)) var list []map[string]any if err := json.Unmarshal(w.Body.Bytes(), &list); err != nil { t.Fatalf("decode: %v", err) } got := list[0] if got["id"] != c.ID || got["capability_id"] != c.ID { t.Errorf("id aliases: id=%v capability_id=%v want %q", got["id"], got["capability_id"], c.ID) } if enabled, ok := got["enabled"].(bool); !ok || !enabled { t.Errorf("enabled: got %#v, want true", got["enabled"]) } if rc, ok := got["requires_confirmation"].(bool); !ok || !rc { t.Errorf("requires_confirmation: got %#v, want true (risk=medium)", got["requires_confirmation"]) } } // TestListCapabilities_EmptyIsArrayNotNull — clients iterate unconditionally. func TestListCapabilities_EmptyIsArrayNotNull(t *testing.T) { h, _ := newTestHandler(t) w := httptest.NewRecorder() h.listCapabilities(w, httptest.NewRequest(http.MethodGet, "/api/v1/capabilities", nil)) if body := w.Body.String(); body != "[]\n" { t.Errorf("expected [], got %q", body) } }