Files
hexis/internal/api/capability_shape_test.go
T
kami dda4acfbb6 Serve capabilities through one serializer and add GET /api/v1/executions
The two "refactor later" items from REVIEW-2026-07-30.md; they share the wire
types, so they land together.

A capability had four divergent wire shapes — the HTTP handler, the MCP
adapter, pkg/client, and Maven's vendored copy of it. There is now a single
definition in pkg/client, mapped from domain by internal/wire and used by the
HTTP list/create/get paths and all four MCP surfaces. It lives in pkg/client
rather than internal so external consumers need not vendor internal/domain,
and so producer and consumer are literally the same type.

The unified shape is a strict superset of all four predecessors; nothing was
dropped. It adds enabled and requires_confirmation to the list responses
(never omitempty — an absent bool reads as unknown, not false), capability_id
to the MCP and client shapes, and the timing/attribute/version fields
previously only on get-by-ID. target_types and the list itself now serialize
as [] rather than null.

Both `id` and `capability_id` are deliberately kept, carrying the same value.
Maven decodes `id`; the spec and the rest of the API say `capability_id`.
Bearer auth is already a breaking change for that consumer, and stacking a
second silent one is the wrong trade — the redundancy stays until every
consumer is confirmed on capability_id, then `id` goes in an announced
removal. A test pins this and says so.

GET /api/v1/executions?entity_id=&since=&limit= implements spec §4.5, which
the Command Center needs. `since` reuses the changes-feed cursor convention
rather than inventing a second paging idiom. That cursor is the row's implicit
SQLite rowid, which is safe only while nothing deletes executions and nothing
VACUUMs — both would renumber and silently invalidate outstanding cursors. If
retention is ever added, this must become an explicit monotonic column first;
the constraint is documented at the query site.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
2026-07-30 23:40:20 +04:00

125 lines
4.2 KiB
Go

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)
}
}