package wire import ( "encoding/json" "testing" "time" "github.com/kami/hexis/internal/domain" "github.com/kami/hexis/pkg/client" ) func sampleCapability() *domain.Capability { now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) return &domain.Capability{ ID: "cap_docker_restart", Name: "docker.restart_container", Description: "Restart a container", TargetTypes: []string{"container"}, TargetEntityID: "", Provider: "workspace_mcp", Operation: "docker.restart_container", Risk: domain.RiskMedium, ReadOnly: false, ExpectedSideEffects: "container restarts", RequiresConfirmation: true, Enabled: true, TimeoutSeconds: 30, Attributes: map[string]any{"allowlist": "docker"}, CreatedAt: now, UpdatedAt: now, Version: 1, } } func marshalCapability(t *testing.T, c *domain.Capability) map[string]any { t.Helper() data, err := json.Marshal(Capability(c)) if err != nil { t.Fatalf("marshal: %v", err) } var out map[string]any if err := json.Unmarshal(data, &out); err != nil { t.Fatalf("unmarshal: %v", err) } return out } // TestCapability_EmitsBothIDAliases pins the deliberate redundancy documented // on client.Capability. Maven's consumer reads `id`; the spec and the rest of // the API say `capability_id`. Hexis is already breaking that consumer with // bearer auth, so dropping either alias here would be a second, silent break. // If this test is ever changed, it must be as an announced removal, not a // tidy-up. func TestCapability_EmitsBothIDAliases(t *testing.T) { out := marshalCapability(t, sampleCapability()) id, hasID := out["id"] capID, hasCapID := out["capability_id"] if !hasID { t.Error("`id` missing — Maven's vendored client decodes this field") } if !hasCapID { t.Error("`capability_id` missing — this is the ECOSYSTEM-SPEC.md §4.1 name") } if id != capID { t.Errorf("aliases disagree: id=%v capability_id=%v", id, capID) } if id != "cap_docker_restart" { t.Errorf("unexpected id %v", id) } } // TestCapability_CarriesServerDerivedGuards pins the review finding that // listCapabilities omitted `enabled` and `requires_confirmation`, leaving a // client unable to distinguish a callable capability from one that would be // refused with 403. Both are always present, even when false. func TestCapability_CarriesServerDerivedGuards(t *testing.T) { out := marshalCapability(t, sampleCapability()) if got, ok := out["enabled"].(bool); !ok || !got { t.Errorf("enabled: expected true, got %#v", out["enabled"]) } if got, ok := out["requires_confirmation"].(bool); !ok || !got { t.Errorf("requires_confirmation: expected true, got %#v", out["requires_confirmation"]) } // Both must be emitted even at their zero value — `omitempty` here would // read to a client as "unknown", not "false". c := sampleCapability() c.Enabled = false c.RequiresConfirmation = false out = marshalCapability(t, c) if _, ok := out["enabled"]; !ok { t.Error("`enabled` omitted when false; it must always be present") } if _, ok := out["requires_confirmation"]; !ok { t.Error("`requires_confirmation` omitted when false; it must always be present") } } // TestCapability_IsASupersetOfEveryPreviousShape guards against a silent field // removal for consumers of any of the four shapes this serializer replaced: // the HTTP list response, the HTTP get response (raw domain.Capability), the // MCP adapter's map, and pkg/client. func TestCapability_IsASupersetOfEveryPreviousShape(t *testing.T) { out := marshalCapability(t, sampleCapability()) required := []string{ // union of the old listCapabilities map and the MCP adapter map "id", "capability_id", "name", "description", "target_types", "provider", "operation", "risk", "read_only", "expected_side_effects", // previously only on the raw domain.Capability returned by GET by ID "requires_confirmation", "enabled", "timeout_seconds", "attributes", "created_at", "updated_at", "version", } for _, k := range required { if _, ok := out[k]; !ok { t.Errorf("field %q dropped by the unified serializer", k) } } } // TestCapability_TargetTypesNeverNull — clients range over this array; `null` // is a decode hazard for the non-Go consumers the Command Center will add. func TestCapability_TargetTypesNeverNull(t *testing.T) { c := sampleCapability() c.TargetTypes = nil out := marshalCapability(t, c) tt, ok := out["target_types"].([]any) if !ok { t.Fatalf("target_types is %#v, want []", out["target_types"]) } if len(tt) != 0 { t.Errorf("expected empty array, got %v", tt) } } // TestCapabilities_EmptySliceIsNotNull — the HTTP list endpoint must render // `[]`, not `null`, when an entity has no capabilities. func TestCapabilities_EmptySliceIsNotNull(t *testing.T) { data, err := json.Marshal(Capabilities(nil)) if err != nil { t.Fatalf("marshal: %v", err) } if string(data) != "[]" { t.Errorf("expected [], got %s", string(data)) } } // TestCapability_RoundTripsThroughPublicClient closes the loop: what the // server serializes is exactly what pkg/client — and therefore Maven — // decodes. A drift between producer and consumer shape is the whole class of // bug this unification exists to remove. func TestCapability_RoundTripsThroughPublicClient(t *testing.T) { src := sampleCapability() data, err := json.Marshal(Capability(src)) if err != nil { t.Fatalf("marshal: %v", err) } var got client.Capability if err := json.Unmarshal(data, &got); err != nil { t.Fatalf("decode into client.Capability: %v", err) } if got.ID != src.ID || got.CapabilityID != src.ID { t.Errorf("id round-trip: ID=%q CapabilityID=%q want %q", got.ID, got.CapabilityID, src.ID) } if got.EffectiveID() != src.ID { t.Errorf("EffectiveID() = %q, want %q", got.EffectiveID(), src.ID) } if got.Name != src.Name || got.ReadOnly != src.ReadOnly || got.Risk != src.Risk { t.Errorf("field drift: %+v", got) } if !got.Enabled || !got.RequiresConfirmation { t.Errorf("guard fields lost in round trip: %+v", got) } if got.Version != src.Version || !got.CreatedAt.Equal(src.CreatedAt) { t.Errorf("metadata lost in round trip: %+v", got) } } // TestCapability_EffectiveIDToleratesEitherAlias covers a peer (an older Hexis // or a hand-rolled client) that sends only one of the two names. func TestCapability_EffectiveIDToleratesEitherAlias(t *testing.T) { cases := map[string]string{ `{"id":"cap_x"}`: "cap_x", `{"capability_id":"cap_y"}`: "cap_y", `{"id":"cap_z","capability_id":"cap_z"}`: "cap_z", } for body, want := range cases { var c client.Capability if err := json.Unmarshal([]byte(body), &c); err != nil { t.Fatalf("decode %s: %v", body, err) } if got := c.EffectiveID(); got != want { t.Errorf("%s: EffectiveID() = %q, want %q", body, got, want) } } } func TestCapability_NilIsZeroValue(t *testing.T) { if got := Capability(nil); got.EffectiveID() != "" { t.Errorf("nil capability produced %+v", got) } }