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
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user