mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-22 11:45:52 +00:00
ee8a24e14a
Manifest-backed MCP tools, prompts, and resources with surface affordance contracts; agent capability manifest and governance projection; API contract tests and capability route projection; operations-loop and intelligence-funnel telemetry; release-control subsystem documentation, registry, and tooling; licensing and configuration.
312 lines
9.9 KiB
Go
312 lines
9.9 KiB
Go
package agentcapabilities
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestFetchManifestUsesSharedDiscoveryPath(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != AgentCapabilitiesPath {
|
|
t.Fatalf("path = %q, want %q", r.URL.Path, AgentCapabilitiesPath)
|
|
}
|
|
if token := r.Header.Get(AgentAPITokenHeader); token != "" {
|
|
t.Fatalf("manifest discovery must not send API token, got %q", token)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(Manifest{
|
|
Version: "v1",
|
|
Capabilities: []Capability{{
|
|
Name: "get_fleet_context",
|
|
Method: http.MethodGet,
|
|
Path: "/api/agent/fleet-context",
|
|
}},
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
manifest, err := FetchManifest(context.Background(), server.Client(), server.URL)
|
|
if err != nil {
|
|
t.Fatalf("FetchManifest: %v", err)
|
|
}
|
|
if manifest.Version != "v1" || len(manifest.Capabilities) != 1 {
|
|
t.Fatalf("manifest = %+v", manifest)
|
|
}
|
|
}
|
|
|
|
func TestBuildCapabilityHTTPRequestProjectsPathBodyAndAuth(t *testing.T) {
|
|
cap := Capability{
|
|
Name: SetOperatorStateCapabilityName,
|
|
Method: http.MethodPost,
|
|
Path: "/api/agent/operator-state/{resourceId}",
|
|
}
|
|
req, projected, err := BuildCapabilityHTTPRequest(context.Background(), "http://pulse.local/", "test-token", cap, map[string]any{
|
|
ResourceIDArgumentName: "vm:101/console",
|
|
"note": "maintenance",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("BuildCapabilityHTTPRequest: %v", err)
|
|
}
|
|
|
|
if req.Method != http.MethodPost {
|
|
t.Fatalf("method = %q, want POST", req.Method)
|
|
}
|
|
if req.URL.String() != "http://pulse.local/api/agent/operator-state/vm%3A101%2Fconsole" {
|
|
t.Fatalf("url = %q", req.URL.String())
|
|
}
|
|
if got := req.Header.Get(AgentAPITokenHeader); got != "test-token" {
|
|
t.Fatalf("%s = %q, want test-token", AgentAPITokenHeader, got)
|
|
}
|
|
if got := req.Header.Get("Content-Type"); got != "application/json" {
|
|
t.Fatalf("Content-Type = %q, want application/json", got)
|
|
}
|
|
if projected.Path != "/api/agent/operator-state/vm%3A101%2Fconsole" || !projected.HasBody {
|
|
t.Fatalf("projected = %+v", projected)
|
|
}
|
|
body, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
t.Fatalf("read body: %v", err)
|
|
}
|
|
if strings.Contains(string(body), "resourceId") {
|
|
t.Fatalf("body must not duplicate path args, got %s", body)
|
|
}
|
|
if !strings.Contains(string(body), `"note":"maintenance"`) {
|
|
t.Fatalf("body = %s", body)
|
|
}
|
|
}
|
|
|
|
func TestCallCapabilityHTTPExecutesManifestProjection(t *testing.T) {
|
|
var got struct {
|
|
Method string
|
|
Path string
|
|
Token string
|
|
ContentType string
|
|
Body string
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got.Method = r.Method
|
|
got.Path = r.URL.EscapedPath()
|
|
got.Token = r.Header.Get(AgentAPITokenHeader)
|
|
got.ContentType = r.Header.Get("Content-Type")
|
|
body, _ := io.ReadAll(r.Body)
|
|
got.Body = string(body)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
resp, err := CallCapabilityHTTP(context.Background(), server.Client(), server.URL, "shared-token", Capability{
|
|
Name: PlanActionCapabilityName,
|
|
Method: http.MethodPost,
|
|
Path: "/api/actions/{actionId}/plan",
|
|
}, map[string]any{
|
|
ActionIDArgumentName: "act:123",
|
|
"body": map[string]any{
|
|
"reason": "operator requested",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CallCapabilityHTTP: %v", err)
|
|
}
|
|
if !resp.OK() || resp.StatusCode != http.StatusAccepted || string(resp.Body) != `{"ok":true}` {
|
|
t.Fatalf("response = %+v body=%s", resp, resp.Body)
|
|
}
|
|
if got.Method != http.MethodPost || got.Path != "/api/actions/act%3A123/plan" {
|
|
t.Fatalf("request method/path = %s %s", got.Method, got.Path)
|
|
}
|
|
if got.Token != "shared-token" {
|
|
t.Fatalf("token = %q", got.Token)
|
|
}
|
|
if got.ContentType != "application/json" {
|
|
t.Fatalf("content type = %q", got.ContentType)
|
|
}
|
|
if got.Body != `{"reason":"operator requested"}` {
|
|
t.Fatalf("body = %s", got.Body)
|
|
}
|
|
}
|
|
|
|
func TestCallCapabilityHTTPByNameResolvesAndExecutesManifestProjection(t *testing.T) {
|
|
var got struct {
|
|
Method string
|
|
Path string
|
|
Token string
|
|
Body string
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got.Method = r.Method
|
|
got.Path = r.URL.EscapedPath()
|
|
got.Token = r.Header.Get(AgentAPITokenHeader)
|
|
body, _ := io.ReadAll(r.Body)
|
|
got.Body = string(body)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
resp, err := CallCapabilityHTTPByName(context.Background(), server.Client(), server.URL, "shared-token", []Capability{{
|
|
Name: ResourceContextCapabilityName,
|
|
Method: http.MethodPost,
|
|
Path: "/api/agent/resource-context/{resourceId}/note",
|
|
}}, ResourceContextCapabilityName, map[string]any{
|
|
"resourceId": "vm:101/console",
|
|
"note": "focus first",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CallCapabilityHTTPByName: %v", err)
|
|
}
|
|
if !resp.OK() || resp.StatusCode != http.StatusAccepted || string(resp.Body) != `{"ok":true}` {
|
|
t.Fatalf("response = %+v body=%s", resp, resp.Body)
|
|
}
|
|
if got.Method != http.MethodPost || got.Path != "/api/agent/resource-context/vm%3A101%2Fconsole/note" {
|
|
t.Fatalf("request method/path = %s %s", got.Method, got.Path)
|
|
}
|
|
if got.Token != "shared-token" {
|
|
t.Fatalf("token = %q", got.Token)
|
|
}
|
|
if strings.Contains(got.Body, "resourceId") || got.Body != `{"note":"focus first"}` {
|
|
t.Fatalf("body = %s", got.Body)
|
|
}
|
|
}
|
|
|
|
func TestCallCapabilityHTTPByNameReturnsTypedLookupError(t *testing.T) {
|
|
_, err := CallCapabilityHTTPByName(context.Background(), nil, "http://pulse.local", "token", []Capability{{
|
|
Name: FleetContextCapabilityName,
|
|
Method: http.MethodGet,
|
|
Path: "/api/agent/fleet-context",
|
|
}}, "missing_capability", nil)
|
|
if err == nil {
|
|
t.Fatal("expected missing capability error")
|
|
}
|
|
var lookupErr CapabilityLookupError
|
|
if !errors.As(err, &lookupErr) {
|
|
t.Fatalf("error type = %T %v, want CapabilityLookupError", err, err)
|
|
}
|
|
if lookupErr.Name != "missing_capability" || err.Error() != "unknown capability: missing_capability" {
|
|
t.Fatalf("lookup error = %#v %q", lookupErr, err.Error())
|
|
}
|
|
}
|
|
|
|
func TestCallRequestResponseCapabilityHTTPByNameRejectsStreamingCapabilities(t *testing.T) {
|
|
called := false
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
t.Fatalf("streaming capability must not be invoked through request/response HTTP helper")
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err := CallRequestResponseCapabilityHTTPByName(context.Background(), server.Client(), server.URL, "shared-token", []Capability{{
|
|
Name: EventSubscriptionCapabilityName,
|
|
Method: http.MethodGet,
|
|
Path: AgentEventsPath,
|
|
}}, EventSubscriptionCapabilityName, nil)
|
|
if err == nil {
|
|
t.Fatal("expected streaming capability to be rejected")
|
|
}
|
|
var lookupErr CapabilityLookupError
|
|
if !errors.As(err, &lookupErr) || lookupErr.Name != EventSubscriptionCapabilityName {
|
|
t.Fatalf("error = %T %[1]v, want CapabilityLookupError for %s", err, EventSubscriptionCapabilityName)
|
|
}
|
|
if called {
|
|
t.Fatal("streaming capability reached upstream HTTP server")
|
|
}
|
|
}
|
|
|
|
func TestCallRequestResponseCapabilityHTTPBodyByNameReturnsSuccessfulBody(t *testing.T) {
|
|
var got struct {
|
|
Method string
|
|
Path string
|
|
Token string
|
|
Body string
|
|
}
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got.Method = r.Method
|
|
got.Path = r.URL.EscapedPath()
|
|
got.Token = r.Header.Get(AgentAPITokenHeader)
|
|
body, _ := io.ReadAll(r.Body)
|
|
got.Body = string(body)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_, _ = w.Write([]byte(`{"resource":"ok"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
body, err := CallRequestResponseCapabilityHTTPBodyByName(
|
|
context.Background(),
|
|
server.Client(),
|
|
server.URL,
|
|
"body-token",
|
|
[]Capability{{
|
|
Name: ResourceContextCapabilityName,
|
|
Method: http.MethodPost,
|
|
Path: "/api/agent/resource-context/{resourceId}/focus",
|
|
}},
|
|
ResourceContextCapabilityName,
|
|
map[string]any{
|
|
"resourceId": "vm:101/console",
|
|
"reason": "critical finding",
|
|
},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CallRequestResponseCapabilityHTTPBodyByName: %v", err)
|
|
}
|
|
if string(body) != `{"resource":"ok"}` {
|
|
t.Fatalf("body = %s", body)
|
|
}
|
|
if got.Method != http.MethodPost || got.Path != "/api/agent/resource-context/vm%3A101%2Fconsole/focus" {
|
|
t.Fatalf("request method/path = %s %s", got.Method, got.Path)
|
|
}
|
|
if got.Token != "body-token" {
|
|
t.Fatalf("token = %q", got.Token)
|
|
}
|
|
if strings.Contains(got.Body, "resourceId") || got.Body != `{"reason":"critical finding"}` {
|
|
t.Fatalf("body = %s", got.Body)
|
|
}
|
|
}
|
|
|
|
func TestCallRequestResponseCapabilityHTTPBodyByNameReturnsStableFailure(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"error":"scope_required","message":"monitoring:read is required"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
_, err := CallRequestResponseCapabilityHTTPBodyByName(
|
|
context.Background(),
|
|
server.Client(),
|
|
server.URL,
|
|
"body-token",
|
|
[]Capability{{
|
|
Name: FleetContextCapabilityName,
|
|
Method: http.MethodGet,
|
|
Path: "/api/agent/fleet-context",
|
|
}},
|
|
FleetContextCapabilityName,
|
|
nil,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected stable non-2xx error")
|
|
}
|
|
if got := err.Error(); got != "GET /api/agent/fleet-context: 403 scope_required (monitoring:read is required)" {
|
|
t.Fatalf("error = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHTTPCallResponseFailureErrorUsesStableEnvelope(t *testing.T) {
|
|
resp := HTTPCallResponse{
|
|
Method: http.MethodGet,
|
|
Path: "/api/agent/resource-context/vm%3A101",
|
|
StatusCode: http.StatusForbidden,
|
|
Body: []byte(`{"error":"scope_required","message":"monitoring:read is required"}`),
|
|
}
|
|
if resp.OK() {
|
|
t.Fatal("forbidden response must not be OK")
|
|
}
|
|
if got := resp.FailureError().Error(); got != "GET /api/agent/resource-context/vm%3A101: 403 scope_required (monitoring:read is required)" {
|
|
t.Fatalf("failure error = %q", got)
|
|
}
|
|
}
|