mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Dead-code audit batch 3: hasPrefix in internal/ai/service_test.go and the staticExternalAgentActivityProvider test double in internal/api/agent_resource_context_test.go have no callers in any test or production code. Skipped from the audit list after re-verification: createTestEncryptionKey in cmd/pulse/test_helpers_test.go (called nine times by commands_integration_test.go under the integration build tag).
2948 lines
109 KiB
Go
2948 lines
109 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
)
|
|
|
|
// staticFindingsProvider is a tiny test double for AgentFindingsProvider
|
|
// — keyed by resource id so each test can stage the snapshot it
|
|
// expects to see in the bundle.
|
|
type staticFindingsProvider struct {
|
|
byResource map[string][]AgentResourceFindingSnapshot
|
|
activeCount *int
|
|
}
|
|
|
|
func (s staticFindingsProvider) ActiveFindingsForResource(resourceID string) []AgentResourceFindingSnapshot {
|
|
if s.byResource == nil {
|
|
return nil
|
|
}
|
|
return s.byResource[resourceID]
|
|
}
|
|
|
|
func (s staticFindingsProvider) ActiveFindingCount() int {
|
|
if s.activeCount != nil {
|
|
return *s.activeCount
|
|
}
|
|
total := 0
|
|
for _, findings := range s.byResource {
|
|
total += len(findings)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// staticApprovalsProvider is a tiny test double for
|
|
// AgentApprovalsProvider — keyed by (resource id, org id) so each
|
|
// test can stage the pending approvals it expects to see in the
|
|
// bundle without setting up a global approval store.
|
|
type staticApprovalsProvider struct {
|
|
byResource map[string][]AgentResourceApprovalSummary
|
|
}
|
|
|
|
type staticWorkflowPromptActivityProvider struct {
|
|
history *config.WorkflowPromptActivityHistoryData
|
|
err error
|
|
}
|
|
|
|
func (s staticWorkflowPromptActivityProvider) WorkflowPromptActivityHistory(_ context.Context) (*config.WorkflowPromptActivityHistoryData, error) {
|
|
return s.history, s.err
|
|
}
|
|
|
|
type staticAIUsageProvider struct {
|
|
history *config.AIUsageHistoryData
|
|
err error
|
|
}
|
|
|
|
func (s staticAIUsageProvider) AIUsageHistory(_ context.Context) (*config.AIUsageHistoryData, error) {
|
|
return s.history, s.err
|
|
}
|
|
|
|
func (s staticApprovalsProvider) PendingApprovalsForResource(resourceID, _ string) []AgentResourceApprovalSummary {
|
|
if s.byResource == nil {
|
|
return nil
|
|
}
|
|
return s.byResource[resourceID]
|
|
}
|
|
|
|
func (s staticApprovalsProvider) PendingApprovalCountsByResource(_ string) map[string]int {
|
|
if s.byResource == nil {
|
|
return nil
|
|
}
|
|
counts := map[string]int{}
|
|
for resourceID, pending := range s.byResource {
|
|
if len(pending) > 0 {
|
|
counts[resourceID] = len(pending)
|
|
}
|
|
}
|
|
if len(counts) == 0 {
|
|
return nil
|
|
}
|
|
return counts
|
|
}
|
|
|
|
type countingApprovalsProvider struct {
|
|
staticApprovalsProvider
|
|
resourceCalls int
|
|
countCalls int
|
|
}
|
|
|
|
func (p *countingApprovalsProvider) PendingApprovalsForResource(resourceID, orgID string) []AgentResourceApprovalSummary {
|
|
p.resourceCalls++
|
|
return p.staticApprovalsProvider.PendingApprovalsForResource(resourceID, orgID)
|
|
}
|
|
|
|
func (p *countingApprovalsProvider) PendingApprovalCountsByResource(orgID string) map[string]int {
|
|
p.countCalls++
|
|
return p.staticApprovalsProvider.PendingApprovalCountsByResource(orgID)
|
|
}
|
|
|
|
type staticResourceDiscoveryReadinessProvider struct {
|
|
byResource map[string]unified.ResourceDiscoveryReadiness
|
|
}
|
|
|
|
func (p staticResourceDiscoveryReadinessProvider) DiscoveryReadinessForResource(resource unified.Resource, now time.Time) unified.ResourceDiscoveryReadiness {
|
|
if readiness, ok := p.byResource[unified.CanonicalResourceID(resource.ID)]; ok {
|
|
if readiness.GeneratedAt.IsZero() {
|
|
readiness.GeneratedAt = now
|
|
}
|
|
if readiness.ResourceType == "" && resource.DiscoveryTarget != nil {
|
|
readiness.ResourceType = resource.DiscoveryTarget.ResourceType
|
|
}
|
|
if readiness.TargetID == "" && resource.DiscoveryTarget != nil {
|
|
readiness.TargetID = resource.DiscoveryTarget.AgentID
|
|
}
|
|
if readiness.ResourceID == "" && resource.DiscoveryTarget != nil {
|
|
readiness.ResourceID = resource.DiscoveryTarget.ResourceID
|
|
}
|
|
return readiness
|
|
}
|
|
readiness := unified.ResourceDiscoveryReadiness{
|
|
State: unified.ResourceDiscoveryReadinessMissing,
|
|
Source: "service-discovery",
|
|
GeneratedAt: now,
|
|
}
|
|
if resource.DiscoveryTarget != nil {
|
|
readiness.ResourceType = resource.DiscoveryTarget.ResourceType
|
|
readiness.TargetID = resource.DiscoveryTarget.AgentID
|
|
readiness.ResourceID = resource.DiscoveryTarget.ResourceID
|
|
}
|
|
return readiness
|
|
}
|
|
|
|
// agentContextFixtureHandlers wires a ResourceHandlers around a
|
|
// pre-built unified-resource seed (using the same
|
|
// `resourceUnifiedSeedProvider` pattern from resources_test.go) plus
|
|
// the agent context handler with a findings-provider stub. The seed
|
|
// provider bypasses the snapshot → unified adapter, letting us stage
|
|
// exactly the resource the test expects to see in the bundle.
|
|
func agentContextFixtureHandlersForResource(t *testing.T, resource unified.Resource) (*AgentContextHandler, *staticFindingsProvider) {
|
|
t.Helper()
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
resources := NewResourceHandlers(cfg)
|
|
resources.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: time.Now()},
|
|
resources: []unified.Resource{resource},
|
|
})
|
|
provider := &staticFindingsProvider{byResource: map[string][]AgentResourceFindingSnapshot{}}
|
|
h := NewAgentContextHandler(resources)
|
|
h.SetFindingsProvider(provider)
|
|
return h, provider
|
|
}
|
|
|
|
func agentContextFixtureHandlers(t *testing.T, resourceID string) (*AgentContextHandler, *staticFindingsProvider) {
|
|
t.Helper()
|
|
return agentContextFixtureHandlersForResource(t, unified.Resource{
|
|
ID: resourceID,
|
|
Type: "vm",
|
|
Name: "db-01",
|
|
})
|
|
}
|
|
|
|
func findAgentContextSection(sections []AgentResourceContextSection, id string) (AgentResourceContextSection, bool) {
|
|
for _, section := range sections {
|
|
if section.ID == id {
|
|
return section, true
|
|
}
|
|
}
|
|
return AgentResourceContextSection{}, false
|
|
}
|
|
|
|
func findAgentContextFact(section AgentResourceContextSection, label string) (AgentResourceContextFact, bool) {
|
|
for _, fact := range section.Facts {
|
|
if fact.Label == label {
|
|
return fact, true
|
|
}
|
|
}
|
|
return AgentResourceContextFact{}, false
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_BundlesIdentityOperatorStateAndFindings(t *testing.T) {
|
|
h, findings := agentContextFixtureHandlers(t, "vm:101")
|
|
|
|
// Stage operator state — server should compute
|
|
// MaintenanceWindowActive once and surface it on the wire so agents
|
|
// don't re-evaluate timestamps client-side.
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
start := time.Now().Add(-time.Hour)
|
|
end := time.Now().Add(time.Hour)
|
|
if err := store.SetResourceOperatorState(unified.ResourceOperatorState{
|
|
CanonicalID: "vm:101",
|
|
NeverAutoRemediate: true,
|
|
MaintenanceStartAt: &start,
|
|
MaintenanceEndAt: &end,
|
|
MaintenanceReason: "Q3 storage upgrade",
|
|
Criticality: unified.CriticalityHigh,
|
|
SetAt: time.Now().UTC(),
|
|
SetBy: "operator:richard",
|
|
}); err != nil {
|
|
t.Fatalf("SetResourceOperatorState: %v", err)
|
|
}
|
|
|
|
// Stage two active findings the provider would return.
|
|
findings.byResource["vm:101"] = []AgentResourceFindingSnapshot{
|
|
{
|
|
ID: "f-cpu",
|
|
Title: "CPU saturated",
|
|
Severity: "warning",
|
|
Category: "performance",
|
|
Impact: "Workload stalls until pressure clears.",
|
|
Recommendation: "Restart workload service.",
|
|
RegressionCount: 2,
|
|
Confidence: "medium",
|
|
},
|
|
{
|
|
ID: "f-disk",
|
|
Title: "Disk pressure",
|
|
Severity: "critical",
|
|
},
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/vm:101", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
|
|
h.HandleResourceContext(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
|
|
// Identity round-trips.
|
|
if bundle.CanonicalID != "vm:101" {
|
|
t.Errorf("canonical id: got %q want vm:101", bundle.CanonicalID)
|
|
}
|
|
if bundle.ResourceName != "db-01" {
|
|
t.Errorf("resource name: got %q want db-01", bundle.ResourceName)
|
|
}
|
|
if bundle.ResourceType != "vm" {
|
|
t.Errorf("resource type: got %q want vm", bundle.ResourceType)
|
|
}
|
|
|
|
// Operator-state projected with computed maintenance-active flag.
|
|
if bundle.OperatorState == nil {
|
|
t.Fatal("operator state must be present")
|
|
}
|
|
if !bundle.OperatorState.NeverAutoRemediate {
|
|
t.Error("never_auto_remediate must round-trip")
|
|
}
|
|
if !bundle.OperatorState.MaintenanceWindowActive {
|
|
t.Error("MaintenanceWindowActive must be true when window covers now (computed server-side, not by the agent)")
|
|
}
|
|
if bundle.OperatorState.Criticality != "high" {
|
|
t.Errorf("criticality: got %q want high", bundle.OperatorState.Criticality)
|
|
}
|
|
|
|
// Findings come through the provider verbatim — no leakage of
|
|
// internal Finding shape.
|
|
if len(bundle.ActiveFindings) != 2 {
|
|
t.Fatalf("expected 2 findings; got %d", len(bundle.ActiveFindings))
|
|
}
|
|
if bundle.ActiveFindings[0].Confidence != "medium" {
|
|
t.Errorf("confidence must round-trip; got %q", bundle.ActiveFindings[0].Confidence)
|
|
}
|
|
if bundle.ActiveFindings[0].RegressionCount != 2 {
|
|
t.Errorf("regression count must round-trip; got %d", bundle.ActiveFindings[0].RegressionCount)
|
|
}
|
|
|
|
// Generated timestamp populated so agents can age the data.
|
|
if bundle.GeneratedAt.IsZero() {
|
|
t.Error("GeneratedAt must be populated")
|
|
}
|
|
if len(bundle.ContextSections) == 0 {
|
|
t.Fatal("contextSections must be populated for resource-aware agents")
|
|
}
|
|
if section, ok := findAgentContextSection(bundle.ContextSections, "identity"); !ok {
|
|
t.Fatal("identity context section missing")
|
|
} else if section.Source == "" || section.TrustTier == "" || section.GeneratedAt.IsZero() {
|
|
t.Fatalf("identity section must carry provenance and freshness metadata: %+v", section)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_IncludesDiscoveryReadiness(t *testing.T) {
|
|
observedAt := time.Date(2026, 6, 4, 10, 30, 0, 0, time.UTC)
|
|
resourceID := "vm:node-a:101"
|
|
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
|
ID: resourceID,
|
|
Type: unified.ResourceTypeVM,
|
|
Name: "homeassistant",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: observedAt,
|
|
UpdatedAt: observedAt,
|
|
Proxmox: &unified.ProxmoxData{
|
|
NodeName: "node-a",
|
|
VMID: 101,
|
|
LinkedAgentID: "agent-a",
|
|
},
|
|
})
|
|
h.resources.SetDiscoveryReadinessProvider(staticResourceDiscoveryReadinessProvider{
|
|
byResource: map[string]unified.ResourceDiscoveryReadiness{
|
|
resourceID: {
|
|
State: unified.ResourceDiscoveryReadinessFresh,
|
|
Source: "service-discovery",
|
|
ObservedAt: &observedAt,
|
|
AgeSeconds: 120,
|
|
StaleAfterSeconds: int64((30 * 24 * time.Hour).Seconds()),
|
|
FactCount: 7,
|
|
ServiceName: "Home Assistant",
|
|
ServiceCategory: "home_automation",
|
|
Confidence: 0.91,
|
|
},
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/"+resourceID, nil)
|
|
h.HandleResourceContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
if bundle.DiscoveryReadiness == nil || bundle.DiscoveryReadiness.State != unified.ResourceDiscoveryReadinessFresh {
|
|
t.Fatalf("discovery readiness = %+v, want fresh", bundle.DiscoveryReadiness)
|
|
}
|
|
|
|
runtimeSection, ok := findAgentContextSection(bundle.ContextSections, "runtime")
|
|
if !ok {
|
|
t.Fatal("runtime section missing")
|
|
}
|
|
readinessFact, ok := findAgentContextFact(runtimeSection, "Discovery readiness")
|
|
if !ok {
|
|
t.Fatal("Discovery readiness fact missing")
|
|
}
|
|
if !strings.Contains(readinessFact.Value, "fresh") || !strings.Contains(readinessFact.Value, "staleAfter=") {
|
|
t.Fatalf("readiness fact value = %q", readinessFact.Value)
|
|
}
|
|
if readinessFact.Source != "service-discovery" || readinessFact.TrustTier != "pulse-authored" {
|
|
t.Fatalf("readiness fact provenance = source %q trust %q", readinessFact.Source, readinessFact.TrustTier)
|
|
}
|
|
serviceFact, ok := findAgentContextFact(runtimeSection, "Discovered service")
|
|
if !ok || serviceFact.TrustTier != "discovered" {
|
|
t.Fatalf("discovered service fact = %+v", serviceFact)
|
|
}
|
|
if serviceFact.Value != "Home Assistant" && serviceFact.Value != unified.ResourcePolicyRedactedLabel {
|
|
t.Fatalf("discovered service value = %q, want service name or policy redaction", serviceFact.Value)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_ResolvesSourceIDReference(t *testing.T) {
|
|
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
|
ID: "system-container-6adaf34f529d241a",
|
|
Type: unified.ResourceTypeSystemContainer,
|
|
Technology: "lxc",
|
|
Name: "homeassistant",
|
|
Status: unified.StatusOnline,
|
|
Sources: []unified.DataSource{unified.SourceProxmox},
|
|
Proxmox: &unified.ProxmoxData{
|
|
SourceID: "delly:delly:101",
|
|
NodeName: "delly",
|
|
Instance: "delly",
|
|
VMID: 101,
|
|
ContainerType: "lxc",
|
|
},
|
|
DiscoveryTarget: &unified.DiscoveryTarget{
|
|
ResourceType: "system-container",
|
|
AgentID: "delly",
|
|
ResourceID: "101",
|
|
Hostname: "homeassistant",
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/delly:delly:101", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
if bundle.CanonicalID != "system-container-6adaf34f529d241a" {
|
|
t.Fatalf("canonical id = %q, want registered unified resource id", bundle.CanonicalID)
|
|
}
|
|
if bundle.ResourceName != "homeassistant" || bundle.ResourceType != "system-container" {
|
|
t.Fatalf("bundle resource = %s/%s, want homeassistant/system-container", bundle.ResourceName, bundle.ResourceType)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_UsesLinkedNodeAgentForProxmoxGuestDiscovery(t *testing.T) {
|
|
parentID := "agent-delly"
|
|
resources := NewResourceHandlers(&config.Config{DataPath: t.TempDir()})
|
|
resources.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: time.Now()},
|
|
resources: []unified.Resource{
|
|
{
|
|
ID: parentID,
|
|
Type: unified.ResourceTypeAgent,
|
|
Name: "delly",
|
|
Status: unified.StatusOnline,
|
|
Sources: []unified.DataSource{
|
|
unified.SourceAgent,
|
|
},
|
|
Agent: &unified.AgentData{
|
|
AgentID: "agent-delly",
|
|
Hostname: "delly",
|
|
},
|
|
},
|
|
{
|
|
ID: "system-container-homeassistant",
|
|
Type: unified.ResourceTypeSystemContainer,
|
|
Name: "homeassistant",
|
|
Status: unified.StatusOnline,
|
|
ParentID: &parentID,
|
|
Sources: []unified.DataSource{
|
|
unified.SourceProxmox,
|
|
},
|
|
Proxmox: &unified.ProxmoxData{
|
|
SourceID: "delly:delly:101",
|
|
NodeName: "delly",
|
|
Instance: "delly",
|
|
VMID: 101,
|
|
ContainerType: "lxc",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
resources.SetDiscoveryReadinessProvider(staticResourceDiscoveryReadinessProvider{})
|
|
h := NewAgentContextHandler(resources)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/delly:delly:101", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
if bundle.CanonicalID != "system-container-homeassistant" {
|
|
t.Fatalf("canonical id = %q, want system-container-homeassistant", bundle.CanonicalID)
|
|
}
|
|
if bundle.DiscoveryReadiness == nil {
|
|
t.Fatal("expected discovery readiness in context bundle")
|
|
}
|
|
if bundle.DiscoveryReadiness.State != unified.ResourceDiscoveryReadinessMissing {
|
|
t.Fatalf("readiness state = %q, want missing", bundle.DiscoveryReadiness.State)
|
|
}
|
|
if bundle.DiscoveryReadiness.ResourceType != "system-container" ||
|
|
bundle.DiscoveryReadiness.TargetID != "agent-delly" ||
|
|
bundle.DiscoveryReadiness.ResourceID != "101" {
|
|
t.Fatalf("readiness target mismatch: %+v", bundle.DiscoveryReadiness)
|
|
}
|
|
runtime, ok := findAgentContextSection(bundle.ContextSections, "runtime")
|
|
if !ok {
|
|
t.Fatalf("expected runtime section in context bundle: %+v", bundle.ContextSections)
|
|
}
|
|
if _, ok := findAgentContextFact(runtime, "Discovery readiness"); !ok {
|
|
t.Fatalf("runtime section must include discovery readiness fact: %+v", runtime.Facts)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_ContextSectionsIncludeRuntimeTopologyAndRecentChanges(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
parentID := "agent:pve1"
|
|
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
|
ID: "app-container:homeassistant",
|
|
Type: unified.ResourceTypeAppContainer,
|
|
Name: "homeassistant",
|
|
Status: unified.StatusOnline,
|
|
Technology: "docker",
|
|
LastSeen: now.Add(-time.Minute),
|
|
UpdatedAt: now,
|
|
ParentID: &parentID,
|
|
ParentName: "ha-lxc",
|
|
DiscoveryTarget: &unified.DiscoveryTarget{
|
|
ResourceType: "app-container",
|
|
AgentID: "agent:pve1",
|
|
ResourceID: "homeassistant",
|
|
Hostname: "homeassistant.local",
|
|
},
|
|
Docker: &unified.DockerData{
|
|
Runtime: "docker",
|
|
RuntimeVersion: "27.0",
|
|
ContainerState: "running",
|
|
Health: "healthy",
|
|
RestartCount: 2,
|
|
Image: "ghcr.io/home-assistant/home-assistant:stable",
|
|
Ports: []unified.DockerPortMeta{
|
|
{PrivatePort: 8123, PublicPort: 8123, Protocol: "tcp"},
|
|
},
|
|
Mounts: []unified.DockerMountMeta{
|
|
{Source: "/srv/homeassistant/secret-config", Destination: "/config", RW: true},
|
|
},
|
|
Labels: map[string]string{
|
|
"com.example.env": "TOKEN=should-not-leak",
|
|
},
|
|
},
|
|
Relationships: []unified.ResourceRelationship{
|
|
{
|
|
SourceID: "app-container:homeassistant",
|
|
TargetID: "agent:pve1",
|
|
Type: unified.RelRunsOn,
|
|
Confidence: 0.95,
|
|
Active: true,
|
|
Discoverer: "docker_adapter",
|
|
ObservedAt: now.Add(-2 * time.Minute),
|
|
LastSeenAt: now.Add(-time.Minute),
|
|
Metadata: map[string]any{"raw": "metadata is intentionally summarized only"},
|
|
},
|
|
},
|
|
Capabilities: []unified.ResourceCapability{
|
|
{
|
|
Name: "restart_container",
|
|
MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
Platform: "docker",
|
|
Params: []unified.CapabilityParam{
|
|
{Name: "token", IsSensitive: true},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
if err := store.RecordChange(unified.ResourceChange{
|
|
ID: "change-ha-restart",
|
|
ResourceID: "app-container:homeassistant",
|
|
ObservedAt: now.Add(-30 * time.Second),
|
|
Kind: unified.ChangeRestart,
|
|
From: "running",
|
|
To: "restarted",
|
|
SourceType: unified.SourcePulseDiff,
|
|
SourceAdapter: unified.AdapterDocker,
|
|
Confidence: unified.ConfidenceHigh,
|
|
Reason: "container restarted after update",
|
|
}); err != nil {
|
|
t.Fatalf("RecordChange: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/app-container:homeassistant", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
|
|
runtimeSection, ok := findAgentContextSection(bundle.ContextSections, "runtime")
|
|
if !ok {
|
|
t.Fatalf("runtime section missing from context pack: %+v", bundle.ContextSections)
|
|
}
|
|
if fact, ok := findAgentContextFact(runtimeSection, "Ports"); !ok || !strings.Contains(fact.Value, "8123:8123/tcp") {
|
|
t.Fatalf("runtime ports fact missing or wrong: %+v", fact)
|
|
}
|
|
if fact, ok := findAgentContextFact(runtimeSection, "Mounts"); !ok || fact.Value != "1" {
|
|
t.Fatalf("runtime must include mount count without path values; got %+v", fact)
|
|
}
|
|
|
|
topologySection, ok := findAgentContextSection(bundle.ContextSections, "topology")
|
|
if !ok {
|
|
t.Fatal("topology section missing from context pack")
|
|
}
|
|
if fact, ok := findAgentContextFact(topologySection, "Relationship 1"); !ok || !strings.Contains(fact.Value, "runs_on") || !strings.Contains(fact.Value, "metadata present") {
|
|
t.Fatalf("relationship fact missing bounded topology summary: %+v", fact)
|
|
}
|
|
|
|
safetySection, ok := findAgentContextSection(bundle.ContextSections, "safety")
|
|
if !ok {
|
|
t.Fatal("safety section missing from context pack")
|
|
}
|
|
if fact, ok := findAgentContextFact(safetySection, "Capability 1"); !ok || !strings.Contains(fact.Value, "1 sensitive params hidden") {
|
|
t.Fatalf("capability fact must hide sensitive params: %+v", fact)
|
|
}
|
|
|
|
changesSection, ok := findAgentContextSection(bundle.ContextSections, "recent_changes")
|
|
if !ok {
|
|
t.Fatal("recent_changes section missing from context pack")
|
|
}
|
|
if fact, ok := findAgentContextFact(changesSection, "Change 1"); !ok || !strings.Contains(fact.Value, "container restarted after update") {
|
|
t.Fatalf("recent change fact missing canonical timeline context: %+v", fact)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
for _, forbidden := range []string{"/srv/homeassistant/secret-config", "TOKEN=should-not-leak", "metadata is intentionally summarized only"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("context pack leaked raw unsafe detail %q in body: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_ContextSectionsApplyPolicyRedactions(t *testing.T) {
|
|
h, _ := agentContextFixtureHandlersForResource(t, unified.Resource{
|
|
ID: "storage:dataset-1",
|
|
Type: unified.ResourceTypeStorage,
|
|
Name: "dataset-1",
|
|
Status: unified.StatusOnline,
|
|
LastSeen: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
Policy: &unified.ResourcePolicy{
|
|
Sensitivity: unified.ResourceSensitivitySensitive,
|
|
Routing: unified.ResourceRoutingPolicy{
|
|
Scope: unified.ResourceRoutingScopeLocalOnly,
|
|
Redact: []unified.ResourceRedactionHint{
|
|
unified.ResourceRedactionHostname,
|
|
unified.ResourceRedactionPath,
|
|
unified.ResourceRedactionPlatformID,
|
|
},
|
|
},
|
|
},
|
|
AISafeSummary: "storage resource; status online; local-only context",
|
|
DiscoveryTarget: &unified.DiscoveryTarget{
|
|
ResourceType: "storage",
|
|
AgentID: "agent-secret-id",
|
|
ResourceID: "storage-secret-id",
|
|
Hostname: "secret-storage.local",
|
|
},
|
|
Storage: &unified.StorageMeta{
|
|
Type: "zfs",
|
|
Path: "/mnt/secret-dataset",
|
|
RiskSummary: "secret-storage.local has elevated writes",
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/storage:dataset-1", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
|
|
runtimeSection, ok := findAgentContextSection(bundle.ContextSections, "runtime")
|
|
if !ok {
|
|
t.Fatal("runtime section missing")
|
|
}
|
|
for _, label := range []string{"Discovery hostname", "Storage path"} {
|
|
fact, ok := findAgentContextFact(runtimeSection, label)
|
|
if !ok {
|
|
t.Fatalf("%s fact missing", label)
|
|
}
|
|
if fact.Value != unified.ResourcePolicyRedactedLabel || !fact.Redacted {
|
|
t.Fatalf("%s must be explicitly redacted by policy; got %+v", label, fact)
|
|
}
|
|
}
|
|
policySection, ok := findAgentContextSection(bundle.ContextSections, "policy")
|
|
if !ok || len(policySection.Redactions) == 0 {
|
|
t.Fatalf("policy section must explain redactions; got %+v", policySection)
|
|
}
|
|
body := rec.Body.String()
|
|
for _, forbidden := range []string{"secret-storage.local", "/mnt/secret-dataset"} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("context pack leaked policy-redacted value %q in body: %s", forbidden, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_OperatorStateAbsentWhenNoEntry(t *testing.T) {
|
|
// A resource with no operator state recorded must omit the
|
|
// operatorState field entirely (omitempty), distinguishing "no
|
|
// operator overrides" from "operator overrides happen to be all
|
|
// zero." Agents branch on field presence rather than value.
|
|
h, _ := agentContextFixtureHandlers(t, "vm:fresh")
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/vm:fresh", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
if _, has := raw["operatorState"]; has {
|
|
t.Errorf("operatorState must be omitted when no entry exists; got %v", raw["operatorState"])
|
|
}
|
|
// Active findings must be an empty array (not null), so agents can
|
|
// iterate without nil-checking the field.
|
|
if findings, ok := raw["activeFindings"].([]any); !ok {
|
|
t.Errorf("activeFindings must be an array; got %T", raw["activeFindings"])
|
|
} else if len(findings) != 0 {
|
|
t.Errorf("expected empty findings array; got %d", len(findings))
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_404OnUnknownResource(t *testing.T) {
|
|
h, _ := agentContextFixtureHandlers(t, "vm:101")
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/vm:nonexistent", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404; got %d", rec.Code)
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal error body: %v", err)
|
|
}
|
|
if body["error"] != "resource_not_found" {
|
|
t.Errorf("expected error=resource_not_found; got %v", body)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_RejectsNonGet(t *testing.T) {
|
|
h, _ := agentContextFixtureHandlers(t, "vm:101")
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agent/resource-context/vm:101", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405; got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceCapabilities_ReturnsStructuredCapabilitiesAndParams(t *testing.T) {
|
|
// Stage a resource advertising one capability with typed params. The
|
|
// response must surface the full param schema (what formatCapabilityFact
|
|
// deliberately omits in the resource-context bundle) so an agent can
|
|
// populate plan_action.params without guessing. The internal handler
|
|
// field must be absent from the JSON.
|
|
resource := unified.Resource{
|
|
ID: "vm:101",
|
|
Type: "vm",
|
|
Name: "db-01",
|
|
Capabilities: []unified.ResourceCapability{
|
|
{
|
|
Name: "restart",
|
|
Type: unified.CapabilityTypeNative,
|
|
Description: "Restart the guest workload.",
|
|
MinimumApprovalLevel: unified.ApprovalAdmin,
|
|
Platform: "qemu",
|
|
InternalHandler: "shouldNotAppearOnWire",
|
|
Params: []unified.CapabilityParam{
|
|
{
|
|
Name: "force",
|
|
Type: "boolean",
|
|
Required: false,
|
|
Description: "Force a hard restart when the graceful stop times out.",
|
|
},
|
|
{
|
|
Name: "timeout",
|
|
Type: "int",
|
|
Required: true,
|
|
Enum: []string{"30", "60", "120"},
|
|
Pattern: `^\d+$`,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
h, _ := agentContextFixtureHandlersForResource(t, resource)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:101", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
|
|
h.HandleResourceCapabilities(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceCapabilities
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
if bundle.ResourceID != "vm:101" {
|
|
t.Errorf("resourceId: got %q want vm:101", bundle.ResourceID)
|
|
}
|
|
if len(bundle.Capabilities) != 1 {
|
|
t.Fatalf("capabilities: got %d want 1", len(bundle.Capabilities))
|
|
}
|
|
cap := bundle.Capabilities[0]
|
|
if cap.Name != "restart" {
|
|
t.Errorf("capability name: got %q want restart", cap.Name)
|
|
}
|
|
if cap.Platform != "qemu" {
|
|
t.Errorf("platform: got %q want qemu", cap.Platform)
|
|
}
|
|
if cap.MinimumApprovalLevel != unified.ApprovalAdmin {
|
|
t.Errorf("approval level: got %q want admin", cap.MinimumApprovalLevel)
|
|
}
|
|
if len(cap.Params) != 2 {
|
|
t.Fatalf("params: got %d want 2", len(cap.Params))
|
|
}
|
|
|
|
// Param schemas must round-trip fully — this is the whole point of
|
|
// the new endpoint relative to the prose summary in get_resource_context.
|
|
force := cap.Params[0]
|
|
if force.Name != "force" || force.Type != "boolean" || force.Required {
|
|
t.Errorf("force param = %+v, want {force boolean false}", force)
|
|
}
|
|
if force.Description == "" {
|
|
t.Errorf("force param description must be preserved, got empty")
|
|
}
|
|
timeout := cap.Params[1]
|
|
if !timeout.Required || len(timeout.Enum) != 3 || timeout.Pattern == "" {
|
|
t.Errorf("timeout param = %+v, want required with enum+pattern", timeout)
|
|
}
|
|
|
|
// Internal execution plumbing must never leak to the wire.
|
|
if raw := strings.TrimSpace(rec.Body.String()); strings.Contains(raw, "shouldNotAppearOnWire") {
|
|
t.Errorf("internal handler leaked to wire: %s", raw)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceCapabilities_EmptyCapabilitiesReturns200(t *testing.T) {
|
|
// A resource advertising nothing is the common case across the fleet
|
|
// and is exactly the signal an agent needs before attempting
|
|
// plan_action. It must be 200 with a JSON array, never null.
|
|
h, _ := agentContextFixtureHandlers(t, "vm:101")
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:101", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
|
|
h.HandleResourceCapabilities(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceCapabilities
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
if bundle.Capabilities == nil {
|
|
t.Fatal("capabilities is null; want non-nil empty array on the wire")
|
|
}
|
|
if len(bundle.Capabilities) != 0 {
|
|
t.Errorf("capabilities: got %d want 0", len(bundle.Capabilities))
|
|
}
|
|
// Pin the wire shape directly: capabilities must serialize as [].
|
|
if !strings.Contains(rec.Body.String(), `"capabilities":[]`) {
|
|
t.Errorf("wire body must contain capabilities:[]; got %s", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceCapabilities_404OnUnknownResource(t *testing.T) {
|
|
h, _ := agentContextFixtureHandlers(t, "vm:101")
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-capabilities/vm:nonexistent", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, "default"))
|
|
h.HandleResourceCapabilities(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404; got %d", rec.Code)
|
|
}
|
|
var body map[string]string
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal error body: %v", err)
|
|
}
|
|
if body["error"] != agentcapabilities.AgentErrCodeResourceNotFound {
|
|
t.Errorf("expected error=%s; got %v", agentcapabilities.AgentErrCodeResourceNotFound, body)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceCapabilities_RejectsNonGet(t *testing.T) {
|
|
h, _ := agentContextFixtureHandlers(t, "vm:101")
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agent/resource-capabilities/vm:101", nil)
|
|
h.HandleResourceCapabilities(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405; got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_RecentActionsCarryRefusalTokens(t *testing.T) {
|
|
// Refused dispatches (resource_remediation_locked:, plan_drift:)
|
|
// must surface verbatim in the recent-actions slice so agents can
|
|
// branch on the stable token without parsing human messages. Pin
|
|
// this to keep the agent-paradigm contract honest — Pulse already
|
|
// records these refusals as Failed audit records (slices 17, 33);
|
|
// this test verifies the bundle exposes them.
|
|
h, _ := agentContextFixtureHandlers(t, "vm:locked")
|
|
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
plan := unified.ActionPlan{
|
|
ActionID: "act-refused",
|
|
RequestID: "req-refused",
|
|
PlannedAt: now,
|
|
ExpiresAt: now.Add(5 * time.Minute),
|
|
}
|
|
if err := store.RecordActionAudit(unified.ActionAuditRecord{
|
|
ID: "act-refused",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
State: unified.ActionStateFailed,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-refused",
|
|
ResourceID: "vm:locked",
|
|
CapabilityName: "pulse_control",
|
|
Reason: "restart workload",
|
|
RequestedBy: "pulse_patrol",
|
|
Params: map[string]any{"command": "systemctl restart workload"},
|
|
},
|
|
Plan: plan,
|
|
Result: &unified.ExecutionResult{
|
|
Success: false,
|
|
ErrorMessage: "resource_remediation_locked: resource is operator-locked against automated remediation",
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/vm:locked", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200; got %d", rec.Code)
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &bundle); err != nil {
|
|
t.Fatalf("unmarshal bundle: %v", err)
|
|
}
|
|
if len(bundle.RecentActions) != 1 {
|
|
t.Fatalf("expected 1 recent action; got %d", len(bundle.RecentActions))
|
|
}
|
|
action := bundle.RecentActions[0]
|
|
if action.State != string(unified.ActionStateFailed) {
|
|
t.Errorf("state: got %q want failed", action.State)
|
|
}
|
|
if action.Success {
|
|
t.Error("Success must be false on refused dispatch")
|
|
}
|
|
if action.ErrorMessage == "" || action.Command == "" {
|
|
t.Errorf("error message and command must round-trip; got %+v", action)
|
|
}
|
|
// Stable token preservation — agents branch on the prefix.
|
|
if !strings.Contains(action.ErrorMessage, "resource_remediation_locked:") {
|
|
t.Errorf("ErrorMessage must preserve the stable refusal token; got %q", action.ErrorMessage)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_PendingApprovalsAreScopedToResource(t *testing.T) {
|
|
// pendingApprovals must list every still-pending approval
|
|
// targeting the bundle's resource. The provider is the seam that
|
|
// scopes by (resource id, org id) — the bundle handler trusts
|
|
// the provider's filtering. Pin the contract: if the provider
|
|
// returns N entries, the bundle returns N entries with the same
|
|
// agent-stable shape (id, command, riskLevel, requestedBy,
|
|
// requestedAt, expiresAt) and an empty array (never null) when
|
|
// the provider returns nothing.
|
|
h, _ := agentContextFixtureHandlers(t, "vm:101")
|
|
now := time.Now().UTC()
|
|
expires := now.Add(5 * time.Minute)
|
|
h.SetApprovalsProvider(staticApprovalsProvider{
|
|
byResource: map[string][]AgentResourceApprovalSummary{
|
|
"vm:101": {
|
|
{
|
|
ID: "appr-1",
|
|
Command: "systemctl restart nginx",
|
|
RiskLevel: "medium",
|
|
RequestedBy: "ai:patrol",
|
|
RequestedAt: now,
|
|
ExpiresAt: expires,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/vm:101", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.NewDecoder(rec.Body).Decode(&bundle); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(bundle.PendingApprovals) != 1 {
|
|
t.Fatalf("PendingApprovals len = %d; want 1; body=%s", len(bundle.PendingApprovals), rec.Body.String())
|
|
}
|
|
got := bundle.PendingApprovals[0]
|
|
if got.ID != "appr-1" {
|
|
t.Errorf("ID: got %q, want %q", got.ID, "appr-1")
|
|
}
|
|
if got.Command != "systemctl restart nginx" {
|
|
t.Errorf("Command did not round-trip: got %q", got.Command)
|
|
}
|
|
if got.RiskLevel != "medium" {
|
|
t.Errorf("RiskLevel did not round-trip: got %q", got.RiskLevel)
|
|
}
|
|
if !got.ExpiresAt.Equal(expires) {
|
|
t.Errorf("ExpiresAt did not round-trip: got %v, want %v", got.ExpiresAt, expires)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_RedactsCommandsForMonitoringReadTokens(t *testing.T) {
|
|
h, _ := agentContextFixtureHandlers(t, "vm:101")
|
|
now := time.Now().UTC()
|
|
expires := now.Add(5 * time.Minute)
|
|
h.SetApprovalsProvider(staticApprovalsProvider{
|
|
byResource: map[string][]AgentResourceApprovalSummary{
|
|
"vm:101": {
|
|
{
|
|
ID: "appr-1",
|
|
Command: "systemctl restart nginx",
|
|
RiskLevel: "medium",
|
|
RequestedBy: "ai:patrol",
|
|
RequestedAt: now,
|
|
ExpiresAt: expires,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
if err := store.RecordActionAudit(unified.ActionAuditRecord{
|
|
ID: "act-1",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-1",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "pulse_control",
|
|
RequestedBy: "pulse_patrol",
|
|
Params: map[string]any{"command": "systemctl restart nginx"},
|
|
},
|
|
Result: &unified.ExecutionResult{
|
|
Success: true,
|
|
Verification: &unified.ActionVerificationResult{
|
|
Ran: true,
|
|
Success: true,
|
|
Command: "systemctl is-active nginx",
|
|
RanAt: now,
|
|
},
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/vm:101", nil)
|
|
token := &config.APITokenRecord{Scopes: []string{config.ScopeMonitoringRead}}
|
|
attachAPITokenRecord(req, token)
|
|
|
|
h.HandleResourceContext(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var bundle AgentResourceContext
|
|
if err := json.NewDecoder(rec.Body).Decode(&bundle); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(bundle.PendingApprovals) != 1 {
|
|
t.Fatalf("PendingApprovals len = %d; want 1", len(bundle.PendingApprovals))
|
|
}
|
|
if got := bundle.PendingApprovals[0]; got.Command != "" || !got.CommandRedacted {
|
|
t.Fatalf("pending approval command must be redacted for monitoring-read token; got %+v", got)
|
|
}
|
|
if len(bundle.RecentActions) != 1 {
|
|
t.Fatalf("RecentActions len = %d; want 1", len(bundle.RecentActions))
|
|
}
|
|
action := bundle.RecentActions[0]
|
|
if action.Command != "" || !action.CommandRedacted {
|
|
t.Fatalf("recent action command must be redacted for monitoring-read token; got %+v", action)
|
|
}
|
|
if action.Verification == nil {
|
|
t.Fatal("expected verification to remain present")
|
|
}
|
|
if action.Verification.Command != "" || !action.Verification.CommandRedacted {
|
|
t.Fatalf("verification command must be redacted for monitoring-read token; got %+v", action.Verification)
|
|
}
|
|
}
|
|
|
|
func TestProjectAgentResourceActionsUsesCanonicalVerification(t *testing.T) {
|
|
ranAt := time.Now().UTC()
|
|
summaries := projectAgentResourceActions([]unified.ActionAuditRecord{
|
|
{
|
|
ID: "action-resource-canonical-verification",
|
|
CreatedAt: ranAt.Add(-time.Minute),
|
|
UpdatedAt: ranAt,
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
ResourceID: "vm:resource-canonical",
|
|
CapabilityName: "restart_service",
|
|
RequestedBy: "agent:ops",
|
|
},
|
|
Result: &unified.ExecutionResult{Success: true},
|
|
Verification: &unified.ActionVerificationResult{
|
|
Ran: true,
|
|
Success: true,
|
|
Command: "systemctl is-active nginx",
|
|
RanAt: ranAt,
|
|
},
|
|
},
|
|
{
|
|
ID: "action-resource-unrun-verification",
|
|
CreatedAt: ranAt.Add(-2 * time.Minute),
|
|
UpdatedAt: ranAt.Add(-time.Minute),
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
ResourceID: "vm:resource-unrun",
|
|
CapabilityName: "restart_service",
|
|
RequestedBy: "agent:ops",
|
|
},
|
|
Result: &unified.ExecutionResult{
|
|
Success: true,
|
|
Verification: &unified.ActionVerificationResult{
|
|
Ran: false,
|
|
Success: true,
|
|
Command: "should not leak",
|
|
Output: "sensitive output",
|
|
Note: "sensitive note",
|
|
RanAt: ranAt,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if len(summaries) != 2 {
|
|
t.Fatalf("summaries len = %d, want 2", len(summaries))
|
|
}
|
|
if summaries[0].Verification == nil || summaries[0].Verification.Command != "systemctl is-active nginx" {
|
|
t.Fatalf("top-level canonical verification did not project onto resource summary: %+v", summaries[0].Verification)
|
|
}
|
|
if summaries[1].Verification == nil || summaries[1].Verification.Ran {
|
|
t.Fatalf("expected sanitized ran=false verification, got %+v", summaries[1].Verification)
|
|
}
|
|
if summaries[1].Verification.Command != "" || summaries[1].Verification.Note != "" || !summaries[1].Verification.RanAt.IsZero() || summaries[1].Verification.Success {
|
|
t.Fatalf("resource summary leaked ran=false verification details: %+v", summaries[1].Verification)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentResourceContext_PendingApprovalsEmptyArrayWhenNone(t *testing.T) {
|
|
// Absent or empty must surface as an empty array, not as a
|
|
// missing field — agents iterate without nil-checking. This
|
|
// mirrors the contract for ActiveFindings and RecentActions.
|
|
h, _ := agentContextFixtureHandlers(t, "vm:404")
|
|
// No approvals provider wired — same observable shape as a
|
|
// provider that returns nil for this resource.
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/resource-context/vm:404", nil)
|
|
h.HandleResourceContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `"pendingApprovals":[]`) {
|
|
t.Errorf("expected pendingApprovals to surface as empty array; body=%s", body)
|
|
}
|
|
}
|
|
|
|
// fleetFixtureHandlers seeds the registry with a multi-resource set
|
|
// so the fleet sweep has something to walk. Mirrors the per-resource
|
|
// fixture but with N resources and an explicit findings/approvals
|
|
// stub for each.
|
|
func fleetFixtureHandlers(t *testing.T, resources []unified.Resource) (*AgentContextHandler, *staticFindingsProvider, *staticApprovalsProvider) {
|
|
t.Helper()
|
|
cfg := &config.Config{DataPath: t.TempDir()}
|
|
rh := NewResourceHandlers(cfg)
|
|
rh.SetStateProvider(resourceUnifiedSeedProvider{
|
|
snapshot: models.StateSnapshot{LastUpdate: time.Now()},
|
|
resources: resources,
|
|
})
|
|
findings := &staticFindingsProvider{byResource: map[string][]AgentResourceFindingSnapshot{}}
|
|
approvals := &staticApprovalsProvider{byResource: map[string][]AgentResourceApprovalSummary{}}
|
|
h := NewAgentContextHandler(rh)
|
|
h.SetFindingsProvider(findings)
|
|
h.SetApprovalsProvider(approvals)
|
|
h.SetExternalAgentReadinessProvider(agentExternalAgentReadinessProviderFunc(func(context.Context, agentcapabilities.Manifest, time.Time) bool {
|
|
return true
|
|
}))
|
|
return h, findings, approvals
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_RollsUpEveryResource(t *testing.T) {
|
|
// The fleet view must surface one entry per resource in the
|
|
// registry so an agent can scan the whole org in one read. Pin
|
|
// the contract: identity round-trips, count is 1:1 with the
|
|
// registry, list is always an array (never null).
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
{ID: "container:web-1", Type: "container", Name: "web-1", Technology: "docker"},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var fleet AgentFleetContext
|
|
if err := json.NewDecoder(rec.Body).Decode(&fleet); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(fleet.Resources) != 2 {
|
|
t.Fatalf("Resources len = %d; want 2; body=%s", len(fleet.Resources), rec.Body.String())
|
|
}
|
|
byID := map[string]AgentFleetResourceSummary{}
|
|
for _, s := range fleet.Resources {
|
|
byID[s.CanonicalID] = s
|
|
}
|
|
if got, ok := byID["vm:101"]; !ok || got.ResourceName != "db-01" || got.ResourceType != "vm" {
|
|
t.Errorf("vm:101 entry missing or wrong: %+v", got)
|
|
}
|
|
if got, ok := byID["container:web-1"]; !ok || got.Technology != "docker" {
|
|
t.Errorf("container:web-1 entry missing or wrong technology: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_CountsFindingsBySeverity(t *testing.T) {
|
|
// Per-severity finding counts are the headline number agents use
|
|
// to triage. Pin the contract: counts roll up correctly across
|
|
// (critical, warning, info) and total is the sum.
|
|
h, findings, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
findings.byResource["vm:101"] = []AgentResourceFindingSnapshot{
|
|
{ID: "f1", Severity: "critical"},
|
|
{ID: "f2", Severity: "warning"},
|
|
{ID: "f3", Severity: "warning"},
|
|
{ID: "f4", Severity: "info"},
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d", rec.Code)
|
|
}
|
|
var fleet AgentFleetContext
|
|
if err := json.NewDecoder(rec.Body).Decode(&fleet); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(fleet.Resources) != 1 {
|
|
t.Fatalf("Resources len = %d; want 1", len(fleet.Resources))
|
|
}
|
|
got := fleet.Resources[0].Findings
|
|
want := AgentFleetFindingCounts{Total: 4, Critical: 1, Warning: 2, Info: 1}
|
|
if got != want {
|
|
t.Errorf("findings counts = %+v; want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_PropagatesPendingApprovalCount(t *testing.T) {
|
|
// pendingApprovalCount must reflect the provider's per-resource
|
|
// count so an agent triaging the fleet sees governance-blocked
|
|
// resources at a glance.
|
|
h, _, approvals := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
{ID: "container:web-1", Type: "container", Name: "web-1"},
|
|
})
|
|
approvals.byResource["vm:101"] = []AgentResourceApprovalSummary{
|
|
{ID: "appr-1"},
|
|
{ID: "appr-2"},
|
|
}
|
|
// container:web-1 has no pending approvals — should surface as 0.
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d", rec.Code)
|
|
}
|
|
var fleet AgentFleetContext
|
|
if err := json.NewDecoder(rec.Body).Decode(&fleet); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
byID := map[string]AgentFleetResourceSummary{}
|
|
for _, s := range fleet.Resources {
|
|
byID[s.CanonicalID] = s
|
|
}
|
|
if got := byID["vm:101"].PendingApprovalCount; got != 2 {
|
|
t.Errorf("vm:101 pendingApprovalCount = %d; want 2", got)
|
|
}
|
|
if got := byID["container:web-1"].PendingApprovalCount; got != 0 {
|
|
t.Errorf("container:web-1 pendingApprovalCount = %d; want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_UsesBulkPendingApprovalCounts(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
{ID: "vm:202", Type: "vm", Name: "db-02"},
|
|
{ID: "container:web-1", Type: "container", Name: "web-1"},
|
|
})
|
|
approvals := &countingApprovalsProvider{
|
|
staticApprovalsProvider: staticApprovalsProvider{
|
|
byResource: map[string][]AgentResourceApprovalSummary{
|
|
"vm:101": {
|
|
{ID: "appr-1"},
|
|
{ID: "appr-2"},
|
|
},
|
|
"container:web-1": {
|
|
{ID: "appr-3"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
h.SetApprovalsProvider(approvals)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if approvals.countCalls != 1 {
|
|
t.Fatalf("PendingApprovalCountsByResource calls = %d; want 1", approvals.countCalls)
|
|
}
|
|
if approvals.resourceCalls != 0 {
|
|
t.Fatalf("PendingApprovalsForResource calls = %d; want 0", approvals.resourceCalls)
|
|
}
|
|
var fleet AgentFleetContext
|
|
if err := json.NewDecoder(rec.Body).Decode(&fleet); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
byID := map[string]AgentFleetResourceSummary{}
|
|
for _, s := range fleet.Resources {
|
|
byID[s.CanonicalID] = s
|
|
}
|
|
if got := byID["vm:101"].PendingApprovalCount; got != 2 {
|
|
t.Errorf("vm:101 pendingApprovalCount = %d; want 2", got)
|
|
}
|
|
if got := byID["vm:202"].PendingApprovalCount; got != 0 {
|
|
t.Errorf("vm:202 pendingApprovalCount = %d; want 0", got)
|
|
}
|
|
if got := byID["container:web-1"].PendingApprovalCount; got != 1 {
|
|
t.Errorf("container:web-1 pendingApprovalCount = %d; want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_EmptyArrayWhenRegistryEmpty(t *testing.T) {
|
|
// Empty registry must surface as `resources: []`, never null —
|
|
// agents iterate without nil-checking. This mirrors the
|
|
// iteration-safe contract the per-resource bundle's sections
|
|
// already follow.
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d", rec.Code)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `"resources":[]`) {
|
|
t.Errorf("expected resources to surface as empty array; body=%s", body)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_RejectsNonGet(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{})
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agent/fleet-context", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("status: got %d, want 405", rec.Code)
|
|
}
|
|
}
|
|
|
|
// fleetFilterFixture seeds a mixed fleet for filter tests: one healthy VM
|
|
// (vm:healthy, no findings), one degraded VM (vm:critical, a critical finding),
|
|
// one docker container (container:web, a warning finding), and one LXC
|
|
// (container:lxcone, info finding). Covers every filter dimension.
|
|
func fleetFilterFixture(t *testing.T) *AgentContextHandler {
|
|
t.Helper()
|
|
h, findings, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:healthy", Type: "vm", Name: "healthy-vm", Technology: "qemu"},
|
|
{ID: "vm:critical", Type: "vm", Name: "critical-vm", Technology: "qemu"},
|
|
{ID: "container:web", Type: "container", Name: "web", Technology: "docker"},
|
|
{ID: "container:lxcone", Type: "system-container", Name: "lxcone", Technology: "lxc"},
|
|
})
|
|
findings.byResource["vm:critical"] = []AgentResourceFindingSnapshot{
|
|
{ID: "c1", Severity: "critical"},
|
|
}
|
|
findings.byResource["container:web"] = []AgentResourceFindingSnapshot{
|
|
{ID: "w1", Severity: "warning"},
|
|
}
|
|
findings.byResource["container:lxcone"] = []AgentResourceFindingSnapshot{
|
|
{ID: "i1", Severity: "info"},
|
|
}
|
|
return h
|
|
}
|
|
|
|
func decodeFleetResponse(t *testing.T, rec *httptest.ResponseRecorder) AgentFleetContext {
|
|
t.Helper()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var fleet AgentFleetContext
|
|
if err := json.NewDecoder(rec.Body).Decode(&fleet); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
return fleet
|
|
}
|
|
|
|
func fleetCanonicalIDs(fleet AgentFleetContext) []string {
|
|
ids := make([]string, 0, len(fleet.Resources))
|
|
for _, r := range fleet.Resources {
|
|
ids = append(ids, r.CanonicalID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_HasFindingsFilter(t *testing.T) {
|
|
// hasFindings=true must drop healthy resources and keep only those with at
|
|
// least one finding — the headline triage filter. vm:healthy has none and
|
|
// must be excluded.
|
|
h := fleetFilterFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?hasFindings=true", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
fleet := decodeFleetResponse(t, rec)
|
|
got := fleetCanonicalIDs(fleet)
|
|
want := []string{"vm:critical", "container:web", "container:lxcone"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("hasFindings=true returned %v; want %v (healthy must be excluded)", got, want)
|
|
}
|
|
gotSet := map[string]bool{}
|
|
for _, id := range got {
|
|
gotSet[id] = true
|
|
}
|
|
for _, id := range want {
|
|
if !gotSet[id] {
|
|
t.Errorf("hasFindings=true missing %s; got %v", id, got)
|
|
}
|
|
}
|
|
if gotSet["vm:healthy"] {
|
|
t.Errorf("vm:healthy must be excluded by hasFindings=true; got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_SeverityFilter(t *testing.T) {
|
|
// severity=critical must keep only resources with a critical finding.
|
|
h := fleetFilterFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?severity=critical", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
fleet := decodeFleetResponse(t, rec)
|
|
got := fleetCanonicalIDs(fleet)
|
|
if len(got) != 1 || got[0] != "vm:critical" {
|
|
t.Fatalf("severity=critical returned %v; want [vm:critical]", got)
|
|
}
|
|
|
|
// severity=warning must keep only the warning-bearing resource.
|
|
h2 := fleetFilterFixture(t)
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?severity=warning", nil)
|
|
h2.HandleFleetContext(rec2, req2)
|
|
fleet2 := decodeFleetResponse(t, rec2)
|
|
got2 := fleetCanonicalIDs(fleet2)
|
|
if len(got2) != 1 || got2[0] != "container:web" {
|
|
t.Fatalf("severity=warning returned %v; want [container:web]", got2)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_TechnologyFilter(t *testing.T) {
|
|
// technology=docker (case-insensitive) must keep only the docker container.
|
|
h := fleetFilterFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=docker", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
fleet := decodeFleetResponse(t, rec)
|
|
got := fleetCanonicalIDs(fleet)
|
|
if len(got) != 1 || got[0] != "container:web" {
|
|
t.Fatalf("technology=docker returned %v; want [container:web]", got)
|
|
}
|
|
|
|
// Case-insensitivity: DOCKER must match the same resource.
|
|
h2 := fleetFilterFixture(t)
|
|
rec2 := httptest.NewRecorder()
|
|
req2 := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=DOCKER", nil)
|
|
h2.HandleFleetContext(rec2, req2)
|
|
fleet2 := decodeFleetResponse(t, rec2)
|
|
got2 := fleetCanonicalIDs(fleet2)
|
|
if len(got2) != 1 || got2[0] != "container:web" {
|
|
t.Fatalf("technology=DOCKER returned %v; want [container:web] (case-insensitive)", got2)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_ResourceTypeFilter(t *testing.T) {
|
|
// resourceType=vm must keep only VMs (the two qemu VMs).
|
|
h := fleetFilterFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?resourceType=vm", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
fleet := decodeFleetResponse(t, rec)
|
|
got := fleetCanonicalIDs(fleet)
|
|
if len(got) != 2 {
|
|
t.Fatalf("resourceType=vm returned %v; want 2 vms", got)
|
|
}
|
|
gotSet := map[string]bool{}
|
|
for _, id := range got {
|
|
gotSet[id] = true
|
|
}
|
|
if !gotSet["vm:healthy"] || !gotSet["vm:critical"] {
|
|
t.Errorf("resourceType=vm must return both vms; got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_NoFilterReturnsAll(t *testing.T) {
|
|
// Backward compatibility: no query params returns the full fleet.
|
|
h := fleetFilterFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
fleet := decodeFleetResponse(t, rec)
|
|
if len(fleet.Resources) != 4 {
|
|
t.Fatalf("no-filter fleet returned %d resources; want 4 (backward compat)", len(fleet.Resources))
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_UnknownFilterValueReturnsEmpty(t *testing.T) {
|
|
// An unknown technology must return 200 with an empty array, not a 400.
|
|
// The signal "no resource matched this filter" is a valid triage answer.
|
|
h := fleetFilterFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?technology=nonexistent", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
fleet := decodeFleetResponse(t, rec)
|
|
if fleet.Resources == nil {
|
|
t.Fatal("resources is null; want non-nil empty array")
|
|
}
|
|
if len(fleet.Resources) != 0 {
|
|
t.Errorf("unknown technology returned %d resources; want 0", len(fleet.Resources))
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentFleetContext_FiltersCompose(t *testing.T) {
|
|
// Composed filters must intersect: hasFindings=true AND technology=qemu
|
|
// returns only the degraded VM (vm:critical), excluding the healthy qemu
|
|
// VM and the non-qemu degraded resources.
|
|
h := fleetFilterFixture(t)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/fleet-context?hasFindings=true&technology=qemu", nil)
|
|
h.HandleFleetContext(rec, req)
|
|
fleet := decodeFleetResponse(t, rec)
|
|
got := fleetCanonicalIDs(fleet)
|
|
if len(got) != 1 || got[0] != "vm:critical" {
|
|
t.Fatalf("composed filter returned %v; want [vm:critical] (degraded qemu only)", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_StartsAtPatrolWithoutIssueEvidence(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.NextAction != "run_patrol" {
|
|
t.Fatalf("NextAction = %q; want run_patrol", status.NextAction)
|
|
}
|
|
if status.PatrolIssueEvidenceCount != 0 || status.ActiveFindingCount != 0 || status.PendingApprovalCount != 0 {
|
|
t.Fatalf("empty loop should have zero issue counts: %+v", status)
|
|
}
|
|
if status.ProActivationValueProofState != "not_started" {
|
|
t.Fatalf("ProActivationValueProofState = %q; want not_started", status.ProActivationValueProofState)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if got := steps["patrol"].Status; got != AgentOperationsLoopStepCurrent {
|
|
t.Fatalf("patrol step status = %q; want current", got)
|
|
}
|
|
if got := steps["assistant"].Status; got != AgentOperationsLoopStepPending {
|
|
t.Fatalf("assistant step status = %q; want pending", got)
|
|
}
|
|
}
|
|
|
|
func TestAgentOperationsLoopExternalAgentTokenReadyUsesPulseMCPSurfaceScopes(t *testing.T) {
|
|
now := time.Now().UTC()
|
|
expiredAt := now.Add(-time.Minute)
|
|
manifest := agentcapabilities.CanonicalManifest()
|
|
operationLoopScopes := agentcapabilities.RequiredCapabilityScopes(
|
|
agentcapabilities.ManifestSurfaceToolCapabilities(manifest, agentcapabilities.SurfaceIDPulseMCP),
|
|
)
|
|
if len(operationLoopScopes) < 2 {
|
|
t.Fatalf("Pulse MCP operations loop must require multiple scope classes for this readiness test, got %v", operationLoopScopes)
|
|
}
|
|
|
|
if agentOperationsLoopExternalAgentTokenReady(manifest, nil, now) {
|
|
t.Fatal("missing token must not satisfy external-agent readiness")
|
|
}
|
|
if agentOperationsLoopExternalAgentTokenReady(manifest, []config.APITokenRecord{
|
|
{Scopes: []string{config.ScopeAIChat}, CreatedAt: now.Add(-time.Hour)},
|
|
}, now) {
|
|
t.Fatal("generic AI chat token must not satisfy external-agent readiness")
|
|
}
|
|
if agentOperationsLoopExternalAgentTokenReady(manifest, []config.APITokenRecord{
|
|
{Scopes: []string{config.ScopeMonitoringRead}, ExpiresAt: &expiredAt, CreatedAt: now.Add(-time.Hour)},
|
|
}, now) {
|
|
t.Fatal("expired MCP-capable token must not satisfy external-agent readiness")
|
|
}
|
|
if agentOperationsLoopExternalAgentTokenReady(manifest, []config.APITokenRecord{
|
|
{Scopes: []string{config.ScopeMonitoringRead}, CreatedAt: now.Add(-time.Hour)},
|
|
}, now) {
|
|
t.Fatal("read-only Pulse MCP token must not satisfy full operations-loop readiness")
|
|
}
|
|
if agentOperationsLoopExternalAgentTokenReady(manifest, []config.APITokenRecord{
|
|
{Scopes: []string{config.ScopeAIExecute}, CreatedAt: now.Add(-time.Hour)},
|
|
}, now) {
|
|
t.Fatal("AI-execute-only Pulse MCP token must not satisfy full operations-loop readiness")
|
|
}
|
|
if agentOperationsLoopExternalAgentTokenReady(manifest, []config.APITokenRecord{
|
|
{Scopes: []string{config.ScopeMonitoringRead}, CreatedAt: now.Add(-time.Hour)},
|
|
{Scopes: []string{config.ScopeAIExecute}, CreatedAt: now.Add(-time.Hour)},
|
|
}, now) {
|
|
t.Fatal("split partial tokens must not satisfy single-token operations-loop readiness")
|
|
}
|
|
if !agentOperationsLoopExternalAgentTokenReady(manifest, []config.APITokenRecord{
|
|
{Scopes: operationLoopScopes, CreatedAt: now.Add(-time.Hour)},
|
|
}, now) {
|
|
t.Fatalf("one token covering all Pulse MCP operations-loop scopes should satisfy external-agent readiness; scopes=%v", operationLoopScopes)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_AggregatesContentSafeLoopCounts(t *testing.T) {
|
|
h, findings, approvals := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
findings.byResource["vm:101"] = []AgentResourceFindingSnapshot{
|
|
{ID: "finding-cpu", Title: "CPU pressure", Severity: "warning"},
|
|
{ID: "finding-disk", Title: "Disk pressure", Severity: "critical"},
|
|
}
|
|
approvals.byResource["vm:101"] = []AgentResourceApprovalSummary{{ID: "approval-1"}}
|
|
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
h.SetAIUsageProvider(staticAIUsageProvider{
|
|
history: &config.AIUsageHistoryData{
|
|
Events: []config.AIUsageEventRecord{{
|
|
Timestamp: now.Add(-7 * time.Minute),
|
|
UseCase: "chat",
|
|
ContextScope: "patrol_finding",
|
|
FindingID: "finding-cpu",
|
|
}},
|
|
},
|
|
})
|
|
if err := store.RecordActionAudit(unified.ActionAuditRecord{
|
|
ID: "act-verified",
|
|
CreatedAt: now.Add(-5 * time.Minute),
|
|
UpdatedAt: now,
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-verified",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "restart_service",
|
|
Params: map[string]any{"command": "systemctl restart nginx"},
|
|
RequestedBy: "pulse_patrol",
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: "act-verified",
|
|
RequestID: "req-verified",
|
|
PlannedAt: now.Add(-6 * time.Minute),
|
|
ExpiresAt: now.Add(time.Hour),
|
|
},
|
|
Approvals: []unified.ActionApprovalRecord{{
|
|
Actor: "operator@example.com",
|
|
Method: unified.MethodUI,
|
|
Timestamp: now.Add(-4 * time.Minute),
|
|
Outcome: unified.OutcomeApproved,
|
|
}},
|
|
Result: &unified.ExecutionResult{
|
|
Success: true,
|
|
Output: "service restarted",
|
|
},
|
|
VerificationOutcome: unified.VerificationOutcome{Status: unified.VerificationVerified},
|
|
}); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, forbidden := range []string{
|
|
"finding-cpu",
|
|
"finding-disk",
|
|
"act-verified",
|
|
"approval-1",
|
|
"vm:101",
|
|
"db-01",
|
|
"systemctl",
|
|
"service restarted",
|
|
"operator@example.com",
|
|
} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("operations-loop status leaked %q: %s", forbidden, body)
|
|
}
|
|
}
|
|
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.Unmarshal([]byte(body), &status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.NextAction != "review_approvals" {
|
|
t.Fatalf("NextAction = %q; want review_approvals; status=%+v", status.NextAction, status)
|
|
}
|
|
if !status.ExternalAgentReady {
|
|
t.Fatal("canonical manifest should make external-agent readiness true")
|
|
}
|
|
if status.ActiveFindingCount != 2 ||
|
|
status.PendingApprovalCount != 1 ||
|
|
status.GovernedActionCount != 1 ||
|
|
status.ApprovedDecisionCount != 1 ||
|
|
status.RejectedDecisionCount != 0 ||
|
|
status.VerifiedOutcomeCount != 1 {
|
|
t.Fatalf("loop counts wrong: %+v", status)
|
|
}
|
|
if status.PatrolEvidenceCount != 4 || status.PatrolIssueEvidenceCount != 5 {
|
|
t.Fatalf("evidence counts wrong: patrol=%d issue=%d", status.PatrolEvidenceCount, status.PatrolIssueEvidenceCount)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if got := steps["governance"].Count; got != 1 {
|
|
t.Fatalf("governance step count = %d; want approved decision count", got)
|
|
}
|
|
if got := steps["assistant"].Count; got != 1 {
|
|
t.Fatalf("assistant step count = %d; want contextual collaboration count", got)
|
|
}
|
|
if got := steps["verification"].Count; got != 1 {
|
|
t.Fatalf("verification step count = %d; want verified outcome count", got)
|
|
}
|
|
if got := steps["patrol"].Status; got != AgentOperationsLoopStepComplete {
|
|
t.Fatalf("patrol step status = %q; want complete; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["assistant"].Status; got != AgentOperationsLoopStepComplete {
|
|
t.Fatalf("assistant step status = %q; want complete; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["governance"].Status; got != AgentOperationsLoopStepCurrent {
|
|
t.Fatalf("governance step status = %q; want current; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["verification"].Status; got != AgentOperationsLoopStepPending {
|
|
t.Fatalf("verification step status = %q; want pending; steps=%+v", got, status.Steps)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_ActiveAggregateFindingOutranksHistoricalCompletion(t *testing.T) {
|
|
h, findings, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
activeCount := 1
|
|
findings.activeCount = &activeCount
|
|
now := time.Now().UTC()
|
|
h.SetWorkflowPromptActivityProvider(staticWorkflowPromptActivityProvider{
|
|
history: &config.WorkflowPromptActivityHistoryData{
|
|
Events: []config.WorkflowPromptActivityRecord{{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulsePatrol,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
}},
|
|
},
|
|
})
|
|
h.SetAIUsageProvider(staticAIUsageProvider{
|
|
history: &config.AIUsageHistoryData{
|
|
Events: []config.AIUsageEventRecord{{
|
|
Timestamp: now.Add(-7 * time.Minute),
|
|
UseCase: "chat",
|
|
ContextScope: "patrol_finding",
|
|
}},
|
|
},
|
|
})
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
if err := store.RecordActionAudit(unified.ActionAuditRecord{
|
|
ID: "act-old-verified",
|
|
CreatedAt: now.Add(-5 * time.Minute),
|
|
UpdatedAt: now,
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-old-verified",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "restart_service",
|
|
RequestedBy: "pulse_patrol",
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: "act-old-verified",
|
|
RequestID: "req-old-verified",
|
|
PlannedAt: now.Add(-6 * time.Minute),
|
|
ExpiresAt: now.Add(time.Hour),
|
|
},
|
|
Approvals: []unified.ActionApprovalRecord{{
|
|
Method: unified.MethodUI,
|
|
Timestamp: now.Add(-4 * time.Minute),
|
|
Outcome: unified.OutcomeApproved,
|
|
}},
|
|
Result: &unified.ExecutionResult{Success: true},
|
|
VerificationOutcome: unified.VerificationOutcome{Status: unified.VerificationVerified},
|
|
}); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.ActiveFindingCount != 1 {
|
|
t.Fatalf("ActiveFindingCount = %d; want aggregate active finding", status.ActiveFindingCount)
|
|
}
|
|
if status.PatrolEvidenceCount != 2 || status.PatrolIssueEvidenceCount != 3 {
|
|
t.Fatalf("evidence counts wrong: patrol=%d issue=%d status=%+v", status.PatrolEvidenceCount, status.PatrolIssueEvidenceCount, status)
|
|
}
|
|
if status.PatrolControlResolvedLoopCount != 1 || status.PatrolControlValueState != "verified" {
|
|
t.Fatalf("historical resolved proof should remain recorded: %+v", status)
|
|
}
|
|
if status.NextAction != "open_assistant" {
|
|
t.Fatalf("NextAction = %q; want open_assistant for the current active finding; status=%+v", status.NextAction, status)
|
|
}
|
|
if status.ProgressLabel != "Open Assistant on the active Patrol finding before treating previous verified work as current." {
|
|
t.Fatalf("ProgressLabel = %q", status.ProgressLabel)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if steps["patrol"].Status != AgentOperationsLoopStepComplete ||
|
|
steps["assistant"].Status != AgentOperationsLoopStepCurrent ||
|
|
steps["verification"].Status != AgentOperationsLoopStepPending {
|
|
t.Fatalf("steps should point back to the current active finding: %+v", status.Steps)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_AggregatesWorkflowStarterCounts(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
now := time.Now().UTC()
|
|
h.SetWorkflowPromptActivityProvider(staticWorkflowPromptActivityProvider{
|
|
history: &config.WorkflowPromptActivityHistoryData{
|
|
Events: []config.WorkflowPromptActivityRecord{
|
|
{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulseAssistant,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-9 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulseAssistant,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-8 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulsePatrol,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-7 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePatrolControl,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-6 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePatrolAutonomy,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-5 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfaceProActivation,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-4 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulseMCP,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-3 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfaceAgentAPI,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-2 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulseAssistant,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptTriageFleet,
|
|
},
|
|
{
|
|
Timestamp: now.Add(-agentOperationsLoopActionWindow - time.Hour),
|
|
Surface: config.WorkflowPromptActivitySurfacePulseMCP,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, forbidden := range []string{
|
|
agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
agentcapabilities.PulseWorkflowPromptTriageFleet,
|
|
config.WorkflowPromptActivitySurfacePulseAssistant,
|
|
config.WorkflowPromptActivitySurfacePulsePatrol,
|
|
config.WorkflowPromptActivitySurfacePatrolControl,
|
|
config.WorkflowPromptActivitySurfacePatrolAutonomy,
|
|
config.WorkflowPromptActivitySurfaceProActivation,
|
|
config.WorkflowPromptActivitySurfacePulseMCP,
|
|
config.WorkflowPromptActivitySurfaceAgentAPI,
|
|
} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("operations-loop status leaked workflow activity detail %q: %s", forbidden, body)
|
|
}
|
|
}
|
|
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.Unmarshal([]byte(body), &status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.OperationsLoopStarterCount != 8 ||
|
|
status.AssistantOperationsLoopStarterCount != 2 ||
|
|
status.PatrolOperationsLoopStarterCount != 1 ||
|
|
status.PatrolControlLoopStarterCount != 4 ||
|
|
status.PatrolControlResolvedLoopCount != 0 ||
|
|
status.ProActivationLoopStarterCount != 1 ||
|
|
status.ProActivationResolvedLoopCount != 0 ||
|
|
status.MCPOperationsLoopStarterCount != 1 {
|
|
t.Fatalf("workflow starter counts wrong: %+v", status)
|
|
}
|
|
if status.PatrolControlValueState != "in_progress" {
|
|
t.Fatalf("PatrolControlValueState = %q; want in_progress", status.PatrolControlValueState)
|
|
}
|
|
if status.ProActivationValueProofState != "in_progress" {
|
|
t.Fatalf("ProActivationValueProofState = %q; want in_progress", status.ProActivationValueProofState)
|
|
}
|
|
if status.NextAction != "run_patrol" ||
|
|
status.ProgressLabel != "Patrol is ready to check the fleet and investigate the next real infrastructure issue." {
|
|
t.Fatalf("workflow starter progress wrong: %+v", status)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_CountsPulsePatrolStarterAsPatrolControl(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
now := time.Now().UTC()
|
|
h.SetWorkflowPromptActivityProvider(staticWorkflowPromptActivityProvider{
|
|
history: &config.WorkflowPromptActivityHistoryData{
|
|
Events: []config.WorkflowPromptActivityRecord{{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulsePatrol,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
}},
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.PatrolOperationsLoopStarterCount != 1 ||
|
|
status.PatrolControlLoopStarterCount != 1 ||
|
|
status.ProActivationLoopStarterCount != 0 {
|
|
t.Fatalf("native Patrol starter counts wrong: %+v", status)
|
|
}
|
|
if status.PatrolControlValueState != "in_progress" ||
|
|
status.ProActivationValueProofState != "in_progress" {
|
|
t.Fatalf("native Patrol starter value state wrong: %+v", status)
|
|
}
|
|
if status.NextAction != "run_patrol" ||
|
|
status.ProgressLabel != "Patrol is ready to check the fleet and investigate the next real infrastructure issue." {
|
|
t.Fatalf("native Patrol starter progress wrong: %+v", status)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_CountsPatrolControlStarterWithoutLegacyProActivation(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
now := time.Now().UTC()
|
|
h.SetWorkflowPromptActivityProvider(staticWorkflowPromptActivityProvider{
|
|
history: &config.WorkflowPromptActivityHistoryData{
|
|
Events: []config.WorkflowPromptActivityRecord{{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePatrolControl,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
}},
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.PatrolOperationsLoopStarterCount != 0 ||
|
|
status.PatrolControlLoopStarterCount != 1 ||
|
|
status.ProActivationLoopStarterCount != 0 {
|
|
t.Fatalf("Patrol control starter counts wrong: %+v", status)
|
|
}
|
|
if status.PatrolControlValueState != "in_progress" ||
|
|
status.ProActivationValueProofState != "in_progress" {
|
|
t.Fatalf("Patrol control starter value state wrong: %+v", status)
|
|
}
|
|
if status.NextAction != "run_patrol" ||
|
|
status.ProgressLabel != "Patrol is ready to check the fleet and investigate the next real infrastructure issue." {
|
|
t.Fatalf("Patrol control starter progress wrong: %+v", status)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_ProjectsPatrolControlResolvedLoop(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
now := time.Now().UTC()
|
|
h.SetAIUsageProvider(staticAIUsageProvider{
|
|
history: &config.AIUsageHistoryData{
|
|
Events: []config.AIUsageEventRecord{{
|
|
Timestamp: now.Add(-9 * time.Minute),
|
|
UseCase: "chat",
|
|
ContextScope: "patrol_finding",
|
|
FindingID: "finding-pro",
|
|
}},
|
|
},
|
|
})
|
|
h.SetWorkflowPromptActivityProvider(staticWorkflowPromptActivityProvider{
|
|
history: &config.WorkflowPromptActivityHistoryData{
|
|
Events: []config.WorkflowPromptActivityRecord{
|
|
{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulsePatrol,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
if err := store.RecordActionAudit(unified.ActionAuditRecord{
|
|
ID: "act-pro-verified",
|
|
CreatedAt: now.Add(-5 * time.Minute),
|
|
UpdatedAt: now,
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-pro-verified",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "restart_service",
|
|
Params: map[string]any{"command": "systemctl restart nginx"},
|
|
RequestedBy: "pulse_patrol",
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: "act-pro-verified",
|
|
RequestID: "req-pro-verified",
|
|
PlannedAt: now.Add(-6 * time.Minute),
|
|
ExpiresAt: now.Add(time.Hour),
|
|
},
|
|
Approvals: []unified.ActionApprovalRecord{{
|
|
Actor: "operator@example.com",
|
|
Method: unified.MethodUI,
|
|
Timestamp: now.Add(-4 * time.Minute),
|
|
Outcome: unified.OutcomeApproved,
|
|
}},
|
|
Result: &unified.ExecutionResult{
|
|
Success: true,
|
|
Output: "service restarted",
|
|
},
|
|
VerificationOutcome: unified.VerificationOutcome{Status: unified.VerificationVerified},
|
|
}); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
for _, forbidden := range []string{
|
|
agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
config.WorkflowPromptActivitySurfacePulsePatrol,
|
|
config.WorkflowPromptActivitySurfacePatrolAutonomy,
|
|
config.WorkflowPromptActivitySurfaceProActivation,
|
|
"act-pro-verified",
|
|
"req-pro-verified",
|
|
"vm:101",
|
|
"db-01",
|
|
"systemctl",
|
|
"service restarted",
|
|
"operator@example.com",
|
|
} {
|
|
if strings.Contains(body, forbidden) {
|
|
t.Fatalf("operations-loop status leaked %q: %s", forbidden, body)
|
|
}
|
|
}
|
|
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.Unmarshal([]byte(body), &status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.PatrolControlLoopStarterCount != 1 ||
|
|
status.PatrolControlCompletedLoopCount != 1 ||
|
|
status.PatrolControlResolvedLoopCount != 1 ||
|
|
status.ProActivationLoopStarterCount != 0 ||
|
|
status.ProActivationCompletedLoopCount != 1 ||
|
|
status.ProActivationResolvedLoopCount != 1 ||
|
|
status.VerifiedOutcomeCount != 1 ||
|
|
status.ApprovedDecisionCount != 1 {
|
|
t.Fatalf("Patrol control resolved-loop counts wrong: %+v", status)
|
|
}
|
|
if status.PatrolControlValueState != "verified" {
|
|
t.Fatalf("PatrolControlValueState = %q; want verified", status.PatrolControlValueState)
|
|
}
|
|
if status.ProActivationValueProofState != "verified" {
|
|
t.Fatalf("ProActivationValueProofState = %q; want verified", status.ProActivationValueProofState)
|
|
}
|
|
if status.NextAction != "complete" ||
|
|
status.ProgressLabel != "Patrol handled an infrastructure issue, verified the outcome, and recorded what happened." {
|
|
t.Fatalf("Patrol control resolved-loop progress wrong: %+v", status)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if got := steps["assistant"].Count; got != 1 {
|
|
t.Fatalf("assistant step count = %d; want contextual collaboration count", got)
|
|
}
|
|
for _, step := range status.Steps {
|
|
if step.Status != AgentOperationsLoopStepComplete {
|
|
t.Fatalf("step %s status = %q; want complete; steps=%+v", step.ID, step.Status, status.Steps)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_ResolvesPatrolControlWithoutExternalAgentReadiness(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
h.SetExternalAgentReadinessProvider(nil)
|
|
now := time.Now().UTC()
|
|
h.SetAIUsageProvider(staticAIUsageProvider{
|
|
history: &config.AIUsageHistoryData{
|
|
Events: []config.AIUsageEventRecord{{
|
|
Timestamp: now.Add(-9 * time.Minute),
|
|
UseCase: "chat",
|
|
ContextScope: "patrol_finding",
|
|
FindingID: "finding-pro",
|
|
}},
|
|
},
|
|
})
|
|
h.SetWorkflowPromptActivityProvider(staticWorkflowPromptActivityProvider{
|
|
history: &config.WorkflowPromptActivityHistoryData{
|
|
Events: []config.WorkflowPromptActivityRecord{{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfaceProActivation,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
}},
|
|
},
|
|
})
|
|
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
if err := store.RecordActionAudit(unified.ActionAuditRecord{
|
|
ID: "act-pro-verified",
|
|
CreatedAt: now.Add(-5 * time.Minute),
|
|
UpdatedAt: now,
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-pro-verified",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "restart_service",
|
|
Params: map[string]any{"command": "systemctl restart nginx"},
|
|
RequestedBy: "pulse_patrol",
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: "act-pro-verified",
|
|
RequestID: "req-pro-verified",
|
|
PlannedAt: now.Add(-6 * time.Minute),
|
|
ExpiresAt: now.Add(time.Hour),
|
|
},
|
|
Approvals: []unified.ActionApprovalRecord{{
|
|
Actor: "operator@example.com",
|
|
Method: unified.MethodUI,
|
|
Timestamp: now.Add(-4 * time.Minute),
|
|
Outcome: unified.OutcomeApproved,
|
|
}},
|
|
Result: &unified.ExecutionResult{
|
|
Success: true,
|
|
Output: "service restarted",
|
|
},
|
|
VerificationOutcome: unified.VerificationOutcome{Status: unified.VerificationVerified},
|
|
}); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.ExternalAgentReady ||
|
|
status.PatrolControlCompletedLoopCount != 1 ||
|
|
status.PatrolControlResolvedLoopCount != 1 ||
|
|
status.ProActivationCompletedLoopCount != 1 ||
|
|
status.ProActivationResolvedLoopCount != 1 ||
|
|
status.VerifiedOutcomeCount != 1 ||
|
|
status.ApprovedDecisionCount != 1 {
|
|
t.Fatalf("Patrol control should resolve independently from optional external-agent readiness: %+v", status)
|
|
}
|
|
if status.PatrolControlValueState != "verified" {
|
|
t.Fatalf("PatrolControlValueState = %q; want verified", status.PatrolControlValueState)
|
|
}
|
|
if status.ProActivationValueProofState != "verified" {
|
|
t.Fatalf("ProActivationValueProofState = %q; want verified", status.ProActivationValueProofState)
|
|
}
|
|
if status.NextAction != "complete" ||
|
|
status.ProgressLabel != "Patrol handled an infrastructure issue, verified the outcome, and recorded what happened." {
|
|
t.Fatalf("Patrol control without external-agent readiness progress wrong: %+v", status)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if _, ok := steps["external_agents"]; ok {
|
|
t.Fatalf("external-agent readiness must not render as an operator step: %+v", status.Steps)
|
|
}
|
|
for _, stepID := range []string{"patrol", "assistant", "governance", "verification"} {
|
|
if got := steps[stepID].Status; got != AgentOperationsLoopStepComplete {
|
|
t.Fatalf("%s step status = %q; want complete; steps=%+v", stepID, got, status.Steps)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_DoesNotResolvePatrolControlWithoutContextualCollaboration(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
now := time.Now().UTC()
|
|
h.SetWorkflowPromptActivityProvider(staticWorkflowPromptActivityProvider{
|
|
history: &config.WorkflowPromptActivityHistoryData{
|
|
Events: []config.WorkflowPromptActivityRecord{
|
|
{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
Surface: config.WorkflowPromptActivitySurfacePulsePatrol,
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
if err := store.RecordActionAudit(unified.ActionAuditRecord{
|
|
ID: "act-pro-verified-no-context",
|
|
CreatedAt: now.Add(-5 * time.Minute),
|
|
UpdatedAt: now,
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-pro-verified-no-context",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "restart_service",
|
|
RequestedBy: "pulse_patrol",
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: "act-pro-verified-no-context",
|
|
RequestID: "req-pro-verified-no-context",
|
|
PlannedAt: now.Add(-6 * time.Minute),
|
|
ExpiresAt: now.Add(time.Hour),
|
|
},
|
|
Approvals: []unified.ActionApprovalRecord{{
|
|
Actor: "operator@example.com",
|
|
Method: unified.MethodUI,
|
|
Timestamp: now.Add(-4 * time.Minute),
|
|
Outcome: unified.OutcomeApproved,
|
|
}},
|
|
Result: &unified.ExecutionResult{Success: true},
|
|
VerificationOutcome: unified.VerificationOutcome{Status: unified.VerificationVerified},
|
|
}); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.ProActivationLoopStarterCount != 0 ||
|
|
status.PatrolControlLoopStarterCount != 1 ||
|
|
status.PatrolControlResolvedLoopCount != 0 ||
|
|
status.ProActivationResolvedLoopCount != 0 ||
|
|
status.ApprovedDecisionCount != 1 ||
|
|
status.VerifiedOutcomeCount != 1 {
|
|
t.Fatalf("Patrol control weak-evidence counts wrong: %+v", status)
|
|
}
|
|
if status.NextAction != "open_assistant" ||
|
|
status.ProgressLabel != "Open Assistant to explain the Patrol issue and safest next step." {
|
|
t.Fatalf("weak Patrol control progress wrong: %+v", status)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if got := steps["assistant"].Status; got != AgentOperationsLoopStepCurrent {
|
|
t.Fatalf("assistant step status = %q; want current; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["verification"].Status; got != AgentOperationsLoopStepPending {
|
|
t.Fatalf("verification step status = %q; want pending until context proof; steps=%+v", got, status.Steps)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_DoesNotTreatUnapprovedExecutionAsGovernedDecision(t *testing.T) {
|
|
h, findings, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
findings.byResource["vm:101"] = []AgentResourceFindingSnapshot{
|
|
{ID: "finding-cpu", Title: "CPU pressure", Severity: "warning"},
|
|
}
|
|
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
if err := store.RecordActionAudit(unified.ActionAuditRecord{
|
|
ID: "act-unapproved",
|
|
CreatedAt: now.Add(-5 * time.Minute),
|
|
UpdatedAt: now,
|
|
State: unified.ActionStateCompleted,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-unapproved",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "restart_service",
|
|
Params: map[string]any{"command": "systemctl restart nginx"},
|
|
RequestedBy: "pulse_patrol",
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: "act-unapproved",
|
|
RequestID: "req-unapproved",
|
|
PlannedAt: now.Add(-6 * time.Minute),
|
|
ExpiresAt: now.Add(time.Hour),
|
|
RequiresApproval: false,
|
|
},
|
|
Result: &unified.ExecutionResult{
|
|
Success: true,
|
|
Output: "service restarted",
|
|
},
|
|
VerificationOutcome: unified.VerificationOutcome{Status: unified.VerificationVerified},
|
|
}); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.NextAction != "open_assistant" {
|
|
t.Fatalf("NextAction = %q; want open_assistant; status=%+v", status.NextAction, status)
|
|
}
|
|
if status.GovernedActionCount != 0 ||
|
|
status.ApprovedDecisionCount != 0 ||
|
|
status.RejectedDecisionCount != 0 ||
|
|
status.VerifiedOutcomeCount != 0 {
|
|
t.Fatalf("unapproved execution must not satisfy governance or verification: %+v", status)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if got := steps["governance"].Status; got != AgentOperationsLoopStepPending {
|
|
t.Fatalf("governance status = %q; want pending; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["verification"].Status; got != AgentOperationsLoopStepPending {
|
|
t.Fatalf("verification status = %q; want pending; steps=%+v", got, status.Steps)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_UsesDecisionLifecycleEvidenceForOlderPlans(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
h.SetAIUsageProvider(staticAIUsageProvider{
|
|
history: &config.AIUsageHistoryData{
|
|
Events: []config.AIUsageEventRecord{{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
UseCase: "chat",
|
|
ContextScope: "patrol_finding",
|
|
}},
|
|
},
|
|
})
|
|
h.SetWorkflowPromptActivityProvider(staticWorkflowPromptActivityProvider{
|
|
history: &config.WorkflowPromptActivityHistoryData{
|
|
Events: []config.WorkflowPromptActivityRecord{{
|
|
PromptName: agentcapabilities.PulseWorkflowPromptOperationsLoop,
|
|
Surface: config.WorkflowPromptActivitySurfaceProActivation,
|
|
Timestamp: now.Add(-9 * time.Minute),
|
|
}},
|
|
},
|
|
})
|
|
old := now.Add(-agentOperationsLoopActionWindow - time.Hour)
|
|
record := unified.ActionAuditRecord{
|
|
ID: "act-rejected",
|
|
CreatedAt: old,
|
|
UpdatedAt: old,
|
|
State: unified.ActionStatePending,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-rejected",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "restart_service",
|
|
Params: map[string]any{"command": "systemctl restart nginx"},
|
|
RequestedBy: "pulse_patrol",
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: "act-rejected",
|
|
RequestID: "req-rejected",
|
|
PlannedAt: old,
|
|
ExpiresAt: now.Add(time.Hour),
|
|
RequiresApproval: true,
|
|
},
|
|
}
|
|
if err := store.RecordActionAudit(record); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
rejected, event, err := unified.ApplyActionDecision(record, unified.ActionApprovalRecord{
|
|
Actor: "operator@example.com",
|
|
Method: unified.MethodUI,
|
|
Outcome: unified.OutcomeRejected,
|
|
}, now)
|
|
if err != nil {
|
|
t.Fatalf("ApplyActionDecision: %v", err)
|
|
}
|
|
if err := store.RecordActionDecision(rejected, event); err != nil {
|
|
t.Fatalf("RecordActionDecision: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.GovernedActionCount != 1 || status.RejectedDecisionCount != 1 || status.ApprovedDecisionCount != 0 {
|
|
t.Fatalf("decision counts wrong for rejected action: %+v", status)
|
|
}
|
|
if status.VerifiedOutcomeCount != 0 {
|
|
t.Fatalf("VerifiedOutcomeCount = %d; want 0 for rejected decision", status.VerifiedOutcomeCount)
|
|
}
|
|
if status.PatrolControlCompletedLoopCount != 1 ||
|
|
status.PatrolControlResolvedLoopCount != 0 ||
|
|
status.ProActivationCompletedLoopCount != 1 ||
|
|
status.ProActivationResolvedLoopCount != 0 {
|
|
t.Fatalf("Patrol control terminal counts wrong for rejected action: %+v", status)
|
|
}
|
|
if status.PatrolControlValueState != "governed_decision_recorded" {
|
|
t.Fatalf("PatrolControlValueState = %q; want governed_decision_recorded", status.PatrolControlValueState)
|
|
}
|
|
if status.ProActivationValueProofState != "governed_decision_recorded" {
|
|
t.Fatalf("ProActivationValueProofState = %q; want governed_decision_recorded", status.ProActivationValueProofState)
|
|
}
|
|
if status.NextAction != "complete" {
|
|
t.Fatalf("NextAction = %q; want complete; status=%+v", status.NextAction, status)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if got := steps["governance"].Status; got != AgentOperationsLoopStepComplete {
|
|
t.Fatalf("governance status = %q; want complete; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["governance"].Count; got != 1 {
|
|
t.Fatalf("governance step count = %d; want rejected decision count", got)
|
|
}
|
|
if got := steps["verification"].Status; got != AgentOperationsLoopStepComplete {
|
|
t.Fatalf("verification status = %q; want complete for rejected terminal decision; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["verification"].Count; got != 1 {
|
|
t.Fatalf("verification step count = %d; want terminal rejection count", got)
|
|
}
|
|
if status.ProgressLabel != "Patrol recorded a rejected change decision. Nothing was changed; approve a safer fix before marking the issue resolved." {
|
|
t.Fatalf("ProgressLabel = %q; want Patrol control governed rejection summary", status.ProgressLabel)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_ApprovedDecisionStillNeedsVerifiedOutcome(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
|
|
store, err := h.resources.getStore("default")
|
|
if err != nil {
|
|
t.Fatalf("getStore: %v", err)
|
|
}
|
|
now := time.Now().UTC()
|
|
h.SetAIUsageProvider(staticAIUsageProvider{
|
|
history: &config.AIUsageHistoryData{
|
|
Events: []config.AIUsageEventRecord{{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
UseCase: "chat",
|
|
ContextScope: "patrol_finding",
|
|
}},
|
|
},
|
|
})
|
|
record := unified.ActionAuditRecord{
|
|
ID: "act-approved",
|
|
CreatedAt: now.Add(-10 * time.Minute),
|
|
UpdatedAt: now.Add(-10 * time.Minute),
|
|
State: unified.ActionStatePending,
|
|
Request: unified.ActionRequest{
|
|
RequestID: "req-approved",
|
|
ResourceID: "vm:101",
|
|
CapabilityName: "restart_service",
|
|
Params: map[string]any{"command": "systemctl restart nginx"},
|
|
RequestedBy: "pulse_patrol",
|
|
},
|
|
Plan: unified.ActionPlan{
|
|
ActionID: "act-approved",
|
|
RequestID: "req-approved",
|
|
PlannedAt: now.Add(-10 * time.Minute),
|
|
ExpiresAt: now.Add(time.Hour),
|
|
RequiresApproval: true,
|
|
},
|
|
}
|
|
if err := store.RecordActionAudit(record); err != nil {
|
|
t.Fatalf("RecordActionAudit: %v", err)
|
|
}
|
|
approved, event, err := unified.ApplyActionDecision(record, unified.ActionApprovalRecord{
|
|
Actor: "operator@example.com",
|
|
Method: unified.MethodUI,
|
|
Outcome: unified.OutcomeApproved,
|
|
}, now)
|
|
if err != nil {
|
|
t.Fatalf("ApplyActionDecision: %v", err)
|
|
}
|
|
if err := store.RecordActionDecision(approved, event); err != nil {
|
|
t.Fatalf("RecordActionDecision: %v", err)
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.GovernedActionCount != 1 || status.ApprovedDecisionCount != 1 || status.RejectedDecisionCount != 0 {
|
|
t.Fatalf("decision counts wrong for approved action: %+v", status)
|
|
}
|
|
if status.VerifiedOutcomeCount != 0 {
|
|
t.Fatalf("VerifiedOutcomeCount = %d; want 0 before verification", status.VerifiedOutcomeCount)
|
|
}
|
|
if status.NextAction != "review_findings" {
|
|
t.Fatalf("NextAction = %q; want review_findings; status=%+v", status.NextAction, status)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if got := steps["governance"].Status; got != AgentOperationsLoopStepComplete {
|
|
t.Fatalf("governance status = %q; want complete; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["governance"].Count; got != 1 {
|
|
t.Fatalf("governance step count = %d; want approved decision count", got)
|
|
}
|
|
if got := steps["verification"].Status; got != AgentOperationsLoopStepCurrent {
|
|
t.Fatalf("verification status = %q; want current; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["verification"].Count; got != 0 {
|
|
t.Fatalf("verification step count = %d; want zero before verified outcome", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_ReviewApprovalsWhenApprovalPending(t *testing.T) {
|
|
h, findings, approvals := fleetFixtureHandlers(t, []unified.Resource{
|
|
{ID: "vm:101", Type: "vm", Name: "db-01"},
|
|
})
|
|
findings.byResource["vm:101"] = []AgentResourceFindingSnapshot{{ID: "finding-cpu", Severity: "warning"}}
|
|
approvals.byResource["vm:101"] = []AgentResourceApprovalSummary{{ID: "approval-1"}}
|
|
now := time.Now().UTC()
|
|
h.SetAIUsageProvider(staticAIUsageProvider{
|
|
history: &config.AIUsageHistoryData{
|
|
Events: []config.AIUsageEventRecord{{
|
|
Timestamp: now.Add(-10 * time.Minute),
|
|
UseCase: "chat",
|
|
ContextScope: "patrol_finding",
|
|
}},
|
|
},
|
|
})
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var status AgentOperationsLoopStatus
|
|
if err := json.NewDecoder(rec.Body).Decode(&status); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
assertPatrolControlCompatibilityAliases(t, status)
|
|
if status.NextAction != "review_approvals" {
|
|
t.Fatalf("NextAction = %q; want review_approvals", status.NextAction)
|
|
}
|
|
steps := agentOperationsLoopStepsByID(status.Steps)
|
|
if got := steps["governance"].Status; got != AgentOperationsLoopStepCurrent {
|
|
t.Fatalf("governance status = %q; want current; steps=%+v", got, status.Steps)
|
|
}
|
|
if got := steps["governance"].Count; got != 1 {
|
|
t.Fatalf("governance step count = %d; want pending approval count", got)
|
|
}
|
|
if got := steps["verification"].Status; got != AgentOperationsLoopStepPending {
|
|
t.Fatalf("verification status = %q; want pending; steps=%+v", got, status.Steps)
|
|
}
|
|
}
|
|
|
|
func TestHandleAgentOperationsLoopStatus_RejectsNonGet(t *testing.T) {
|
|
h, _, _ := fleetFixtureHandlers(t, []unified.Resource{})
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/agent/operations-loop/status", nil)
|
|
h.HandleOperationsLoopStatus(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("status: got %d, want 405", rec.Code)
|
|
}
|
|
}
|
|
|
|
func agentOperationsLoopStepsByID(steps []AgentOperationsLoopStep) map[string]AgentOperationsLoopStep {
|
|
byID := map[string]AgentOperationsLoopStep{}
|
|
for _, step := range steps {
|
|
byID[step.ID] = step
|
|
}
|
|
return byID
|
|
}
|
|
|
|
func assertPatrolControlCompatibilityAliases(t *testing.T, status AgentOperationsLoopStatus) {
|
|
t.Helper()
|
|
if status.PatrolAutonomyLoopStarterCount != status.PatrolControlLoopStarterCount {
|
|
t.Fatalf("legacy Patrol autonomy starter alias drifted from Patrol control: control=%d legacy=%d",
|
|
status.PatrolControlLoopStarterCount, status.PatrolAutonomyLoopStarterCount)
|
|
}
|
|
if status.PatrolAutonomyCompletedLoopCount != status.PatrolControlCompletedLoopCount ||
|
|
status.PatrolAutonomyResolvedLoopCount != status.PatrolControlResolvedLoopCount ||
|
|
status.PatrolAutonomyValueState != status.PatrolControlValueState {
|
|
t.Fatalf("legacy Patrol autonomy aliases drifted from Patrol control: control=%d/%d/%q legacy=%d/%d/%q",
|
|
status.PatrolControlCompletedLoopCount,
|
|
status.PatrolControlResolvedLoopCount,
|
|
status.PatrolControlValueState,
|
|
status.PatrolAutonomyCompletedLoopCount,
|
|
status.PatrolAutonomyResolvedLoopCount,
|
|
status.PatrolAutonomyValueState,
|
|
)
|
|
}
|
|
if status.ProActivationCompletedLoopCount != status.PatrolControlCompletedLoopCount ||
|
|
status.ProActivationResolvedLoopCount != status.PatrolControlResolvedLoopCount ||
|
|
status.ProActivationValueProofState != status.PatrolControlValueState {
|
|
t.Fatalf("legacy Pro activation aliases drifted from Patrol control: control=%d/%d/%q legacy=%d/%d/%q",
|
|
status.PatrolControlCompletedLoopCount,
|
|
status.PatrolControlResolvedLoopCount,
|
|
status.PatrolControlValueState,
|
|
status.ProActivationCompletedLoopCount,
|
|
status.ProActivationResolvedLoopCount,
|
|
status.ProActivationValueProofState,
|
|
)
|
|
}
|
|
}
|
|
|
|
// pendingApprovalsForResourceFromStore is the named seam the
|
|
// router-side AgentApprovalsProvider closure delegates to. The
|
|
// next four tests pin the substrate's tenant- and
|
|
// resource-isolation property: every approval the bundle returns
|
|
// must match BOTH the requested org AND the requested resource.
|
|
// Cross-org or cross-resource leaks at this seam would let an
|
|
// agent with one org's token see approvals targeting another org's
|
|
// infrastructure, which is the multi-tenant property the rest of
|
|
// Pulse depends on.
|
|
|
|
// newApprovalsTestStore builds an in-memory approval store seeded
|
|
// with the given approvals. Used as the fixture for each isolation
|
|
// test below.
|
|
func newApprovalsTestStore(t *testing.T, approvals ...*approval.ApprovalRequest) *approval.Store {
|
|
t.Helper()
|
|
store, err := approval.NewStore(approval.StoreConfig{
|
|
DataDir: t.TempDir(),
|
|
DefaultTimeout: 5 * time.Minute,
|
|
DisablePersistence: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("approval.NewStore: %v", err)
|
|
}
|
|
for _, req := range approvals {
|
|
if err := store.CreateApproval(req); err != nil {
|
|
t.Fatalf("CreateApproval(%s): %v", req.ID, err)
|
|
}
|
|
}
|
|
return store
|
|
}
|
|
|
|
func TestPendingApprovalsForResource_FiltersByOrg(t *testing.T) {
|
|
// Two orgs each have a pending approval against the same
|
|
// canonical resource id. The filter must return only the org
|
|
// that matches.
|
|
orgA := &approval.ApprovalRequest{
|
|
ID: "appr-org-a", OrgID: "org-a",
|
|
Command: "systemctl restart nginx", TargetType: "agent", TargetID: "host-1", TargetName: "host-1",
|
|
}
|
|
orgB := &approval.ApprovalRequest{
|
|
ID: "appr-org-b", OrgID: "org-b",
|
|
Command: "systemctl restart nginx", TargetType: "agent", TargetID: "host-1", TargetName: "host-1",
|
|
}
|
|
store := newApprovalsTestStore(t, orgA, orgB)
|
|
|
|
gotA := pendingApprovalsForResourceFromStore(store, "agent:host-1", "org-a")
|
|
if len(gotA) != 1 || gotA[0].ID != "appr-org-a" {
|
|
t.Errorf("org-a query must return only org-a's approval; got %+v", gotA)
|
|
}
|
|
gotB := pendingApprovalsForResourceFromStore(store, "agent:host-1", "org-b")
|
|
if len(gotB) != 1 || gotB[0].ID != "appr-org-b" {
|
|
t.Errorf("org-b query must return only org-b's approval; got %+v", gotB)
|
|
}
|
|
}
|
|
|
|
func TestPendingApprovalsForResource_FiltersByResource(t *testing.T) {
|
|
// Same org, two approvals against different resources. The
|
|
// filter must return only the one matching the requested
|
|
// canonical resource id, never both.
|
|
a := &approval.ApprovalRequest{
|
|
ID: "appr-vm-101", OrgID: "org-a",
|
|
Command: "systemctl restart nginx", TargetType: "vm", TargetID: "101", TargetName: "vm-101",
|
|
}
|
|
b := &approval.ApprovalRequest{
|
|
ID: "appr-vm-202", OrgID: "org-a",
|
|
Command: "systemctl restart nginx", TargetType: "vm", TargetID: "202", TargetName: "vm-202",
|
|
}
|
|
store := newApprovalsTestStore(t, a, b)
|
|
|
|
got := pendingApprovalsForResourceFromStore(store, "vm:101", "org-a")
|
|
if len(got) != 1 || got[0].ID != "appr-vm-101" {
|
|
t.Errorf("vm:101 query must return only that resource's approval; got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPendingApprovalsForResource_LegacyEmptyOrgIsDefaultOnly(t *testing.T) {
|
|
// Approvals without OrgID set are treated as default-org per
|
|
// approval.BelongsToOrg. Pin that legacy approvals do not leak
|
|
// into a non-default org's bundle, and that the default-org
|
|
// query sees them.
|
|
legacy := &approval.ApprovalRequest{
|
|
ID: "appr-legacy",
|
|
Command: "systemctl restart nginx", TargetType: "agent", TargetID: "host-1", TargetName: "host-1",
|
|
// OrgID intentionally empty.
|
|
}
|
|
store := newApprovalsTestStore(t, legacy)
|
|
|
|
gotDefault := pendingApprovalsForResourceFromStore(store, "agent:host-1", "default")
|
|
if len(gotDefault) != 1 {
|
|
t.Errorf("default org must see legacy approvals; got %d", len(gotDefault))
|
|
}
|
|
gotOther := pendingApprovalsForResourceFromStore(store, "agent:host-1", "org-other")
|
|
if len(gotOther) != 0 {
|
|
t.Errorf("non-default org must NOT see legacy approvals; got %+v", gotOther)
|
|
}
|
|
}
|
|
|
|
func TestPendingApprovalsForResource_EmptyInputsReturnNil(t *testing.T) {
|
|
// Nil store, empty resource id, and an empty store all return
|
|
// nil. The bundle handler normalizes nil to []
|
|
// AgentResourceApprovalSummary{} so the wire shape stays an
|
|
// array; this function only owns the "no data" case.
|
|
if got := pendingApprovalsForResourceFromStore(nil, "vm:101", "org-a"); got != nil {
|
|
t.Errorf("nil store must return nil; got %+v", got)
|
|
}
|
|
var typedNil *approval.Store
|
|
if got := pendingApprovalsForResourceFromStore(typedNil, "vm:101", "org-a"); got != nil {
|
|
t.Errorf("typed nil store must return nil; got %+v", got)
|
|
}
|
|
store := newApprovalsTestStore(t)
|
|
if got := pendingApprovalsForResourceFromStore(store, "", "org-a"); got != nil {
|
|
t.Errorf("empty resource id must return nil; got %+v", got)
|
|
}
|
|
if got := pendingApprovalsForResourceFromStore(store, "vm:none", "org-a"); got != nil {
|
|
t.Errorf("empty store must return nil; got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPendingApprovalCountsByResourceFromStore_EmptyInputsReturnNil(t *testing.T) {
|
|
if got := pendingApprovalCountsByResourceFromStore(nil, "org-a"); got != nil {
|
|
t.Errorf("nil store must return nil; got %+v", got)
|
|
}
|
|
var typedNil *approval.Store
|
|
if got := pendingApprovalCountsByResourceFromStore(typedNil, "org-a"); got != nil {
|
|
t.Errorf("typed nil store must return nil; got %+v", got)
|
|
}
|
|
store := newApprovalsTestStore(t)
|
|
if got := pendingApprovalCountsByResourceFromStore(store, "org-a"); got != nil {
|
|
t.Errorf("empty store must return nil; got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestPendingApprovalCountsByResourceFromStore_GroupsByOrgAndResource(t *testing.T) {
|
|
store := newApprovalsTestStore(t,
|
|
&approval.ApprovalRequest{
|
|
ID: "appr-org-a-vm-101-a", OrgID: "org-a",
|
|
Command: "systemctl restart nginx", TargetType: "vm", TargetID: "101", TargetName: "vm-101",
|
|
},
|
|
&approval.ApprovalRequest{
|
|
ID: "appr-org-a-vm-101-b", OrgID: "org-a",
|
|
Command: "systemctl restart nginx", TargetType: "vm", TargetID: "101", TargetName: "vm-101",
|
|
},
|
|
&approval.ApprovalRequest{
|
|
ID: "appr-org-a-vm-202", OrgID: "org-a",
|
|
Command: "systemctl restart nginx", TargetType: "vm", TargetID: "202", TargetName: "vm-202",
|
|
},
|
|
&approval.ApprovalRequest{
|
|
ID: "appr-org-b-vm-101", OrgID: "org-b",
|
|
Command: "systemctl restart nginx", TargetType: "vm", TargetID: "101", TargetName: "vm-101",
|
|
},
|
|
)
|
|
|
|
got := pendingApprovalCountsByResourceFromStore(store, "org-a")
|
|
if got["vm:101"] != 2 {
|
|
t.Errorf("vm:101 count = %d; want 2", got["vm:101"])
|
|
}
|
|
if got["vm:202"] != 1 {
|
|
t.Errorf("vm:202 count = %d; want 1", got["vm:202"])
|
|
}
|
|
if len(got) != 2 {
|
|
t.Errorf("counts len = %d; want 2; got %+v", len(got), got)
|
|
}
|
|
|
|
gotB := pendingApprovalCountsByResourceFromStore(store, "org-b")
|
|
if gotB["vm:101"] != 1 || len(gotB) != 1 {
|
|
t.Errorf("org-b counts = %+v; want only vm:101=1", gotB)
|
|
}
|
|
}
|
|
|
|
func TestExtractAgentResourceContextID(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"/api/agent/resource-context/vm:101", "vm:101"},
|
|
{"/api/agent/resource-context/vm:101/", "vm:101"},
|
|
{"/api/agent/resource-context/instance:node:200", "instance:node:200"},
|
|
{"/api/agent/resource-context/", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := extractAgentResourceContextID(tc.path); got != tc.want {
|
|
t.Errorf("extractAgentResourceContextID(%q) = %q, want %q", tc.path, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractAgentResourceCapabilitiesID(t *testing.T) {
|
|
cases := []struct {
|
|
path string
|
|
want string
|
|
}{
|
|
{"/api/agent/resource-capabilities/vm:101", "vm:101"},
|
|
{"/api/agent/resource-capabilities/vm:101/", "vm:101"},
|
|
{"/api/agent/resource-capabilities/instance:node:200", "instance:node:200"},
|
|
{"/api/agent/resource-capabilities/", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := extractAgentResourceCapabilitiesID(tc.path); got != tc.want {
|
|
t.Errorf("extractAgentResourceCapabilitiesID(%q) = %q, want %q", tc.path, got, tc.want)
|
|
}
|
|
}
|
|
}
|