Pulse/internal/api/agent_events_test.go
rcourtman ee8a24e14a backend and governance: MCP contract, agent capabilities, API, and release-control
Manifest-backed MCP tools, prompts, and resources with surface affordance contracts; agent capability manifest and governance projection; API contract tests and capability route projection; operations-loop and intelligence-funnel telemetry; release-control subsystem documentation, registry, and tooling; licensing and configuration.
2026-06-23 17:26:15 +01:00

741 lines
24 KiB
Go

package api
import (
"bufio"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestAgentEventBroadcaster_SubscribeReceivesPublishedEvents(t *testing.T) {
b := NewAgentEventBroadcaster()
events, unsubscribe := b.Subscribe()
defer unsubscribe()
b.PublishFindingCreated(AgentEventFindingCreatedPayload{
FindingID: "f1",
ResourceID: "vm:101",
Severity: "warning",
Title: "CPU saturated",
})
select {
case event := <-events:
if event.Kind != AgentEventFindingCreated {
t.Errorf("kind: got %q want %q", event.Kind, AgentEventFindingCreated)
}
if event.ID == 0 {
t.Error("event ID must be assigned by the broadcaster (monotonic)")
}
if event.At.IsZero() {
t.Error("event timestamp must be populated")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for event")
}
}
func TestAgentEventBroadcaster_UnsubscribeStopsDelivery(t *testing.T) {
b := NewAgentEventBroadcaster()
events, unsubscribe := b.Subscribe()
unsubscribe()
// After unsubscribe the channel must be closed; receive should
// drain whatever was buffered then return zero-value with !ok.
b.PublishFindingCreated(AgentEventFindingCreatedPayload{FindingID: "f-after"})
select {
case _, open := <-events:
if open {
t.Error("channel must be closed after unsubscribe; agents that resubscribe should get a fresh channel")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for channel close")
}
}
func TestAgentEventBroadcaster_FanOutToMultipleSubscribers(t *testing.T) {
b := NewAgentEventBroadcaster()
a1, unsub1 := b.Subscribe()
defer unsub1()
a2, unsub2 := b.Subscribe()
defer unsub2()
b.PublishFindingCreated(AgentEventFindingCreatedPayload{FindingID: "fan-out"})
deadline := time.After(time.Second)
for _, ch := range []<-chan AgentEvent{a1, a2} {
select {
case event := <-ch:
if event.Payload == nil {
t.Error("event payload must round-trip to all subscribers")
}
case <-deadline:
t.Fatal("a subscriber did not receive the published event")
}
}
}
func TestAgentEventBroadcaster_DropsForSlowSubscriber(t *testing.T) {
// Slow consumers that don't drain their channel must not stall
// the publish path. Fill the buffer past capacity and verify
// publishes don't block.
b := NewAgentEventBroadcaster()
_, unsub := b.Subscribe() // never read
defer unsub()
done := make(chan struct{})
go func() {
// Publish more than the buffer's bufSize. The publish must
// not block; if it did the goroutine would not finish and
// the test would time out.
for i := 0; i < 200; i++ {
b.PublishFindingCreated(AgentEventFindingCreatedPayload{FindingID: "spam"})
}
close(done)
}()
select {
case <-done:
// Pass: publishes did not block on the slow subscriber.
case <-time.After(2 * time.Second):
t.Fatal("publish path stalled on a slow subscriber; broadcaster must drop rather than block")
}
}
func TestAgentEventBroadcaster_AssignsMonotonicIDs(t *testing.T) {
b := NewAgentEventBroadcaster()
events, unsub := b.Subscribe()
defer unsub()
for i := 0; i < 3; i++ {
b.PublishFindingCreated(AgentEventFindingCreatedPayload{FindingID: "mono"})
}
var ids []uint64
for i := 0; i < 3; i++ {
select {
case event := <-events:
ids = append(ids, event.ID)
case <-time.After(time.Second):
t.Fatalf("timed out at i=%d", i)
}
}
for i := 1; i < len(ids); i++ {
if ids[i] <= ids[i-1] {
t.Errorf("event IDs must be strictly monotonic; got %v", ids)
break
}
}
}
func TestHandleAgentEvents_RejectsNonGet(t *testing.T) {
b := NewAgentEventBroadcaster()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/agent/events", nil)
b.HandleAgentEvents(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405; got %d", rec.Code)
}
}
func TestHandleAgentEvents_StreamsConnectedAndPublishedEvents(t *testing.T) {
b := NewAgentEventBroadcaster()
server := httptest.NewServer(http.HandlerFunc(b.HandleAgentEvents))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("NewRequestWithContext: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
// Pin the streaming-response contract: text/event-stream content
// type, no-cache, and X-Accel-Buffering off so reverse proxies
// don't buffer the response.
if got := resp.Header.Get("Content-Type"); got != "text/event-stream" {
t.Errorf("Content-Type: got %q want text/event-stream", got)
}
if got := resp.Header.Get("Cache-Control"); got != "no-cache" {
t.Errorf("Cache-Control: got %q want no-cache", got)
}
if got := resp.Header.Get("X-Accel-Buffering"); got != "no" {
t.Errorf("X-Accel-Buffering must be 'no' so nginx doesn't buffer; got %q", got)
}
// Wait for at least one subscriber so the publish below
// definitely lands; the stream-connected event should have
// already been written.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && b.SubscriberCount() == 0 {
time.Sleep(10 * time.Millisecond)
}
if b.SubscriberCount() == 0 {
t.Fatal("no subscriber registered after handler started")
}
// Publish a finding event so the read loop sees content beyond
// the connected event.
b.PublishFindingCreated(AgentEventFindingCreatedPayload{
FindingID: "f-stream",
Severity: "critical",
Title: "Streamed",
})
// Read the response in a goroutine; close the context after we've
// seen what we expect so the handler returns and the test does
// not hang on the open stream.
got := make(chan string, 1)
go func() {
scanner := bufio.NewScanner(resp.Body)
var collected strings.Builder
for scanner.Scan() {
collected.WriteString(scanner.Text())
collected.WriteString("\n")
text := collected.String()
if strings.Contains(text, "\"kind\":\"finding.created\"") {
got <- text
return
}
}
}()
select {
case payload := <-got:
if !strings.Contains(payload, string(agentcapabilities.EventKindStreamConnected)) {
t.Error("expected stream.connected event before any published events")
}
if !strings.Contains(payload, "f-stream") {
t.Error("published finding payload must reach the wire")
}
// Cancel context so handler exits cleanly.
cancel()
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for finding.created event on the stream")
}
}
func TestHandleAgentEvents_HeartbeatsAreStreamLocal(t *testing.T) {
b := NewAgentEventBroadcaster()
b.heartbeatInterval = 20 * time.Millisecond
otherEvents, unsubscribe := b.Subscribe()
defer unsubscribe()
server := httptest.NewServer(http.HandlerFunc(b.HandleAgentEvents))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
if err != nil {
t.Fatalf("NewRequestWithContext: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("Do: %v", err)
}
defer resp.Body.Close()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) && b.SubscriberCount() < 2 {
time.Sleep(5 * time.Millisecond)
}
if b.SubscriberCount() < 2 {
t.Fatalf("subscriber count = %d; want at least 2", b.SubscriberCount())
}
heartbeatSeen := make(chan string, 1)
go func() {
scanner := bufio.NewScanner(resp.Body)
var collected strings.Builder
for scanner.Scan() {
line := scanner.Text()
collected.WriteString(line)
collected.WriteString("\n")
if strings.Contains(line, "\"kind\":\"heartbeat\"") {
heartbeatSeen <- collected.String()
return
}
}
}()
select {
case payload := <-heartbeatSeen:
if !strings.Contains(payload, "event: heartbeat") {
t.Fatalf("heartbeat must be serialized as an SSE event; payload=%s", payload)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for stream-local heartbeat")
}
select {
case event := <-otherEvents:
t.Fatalf("stream-local heartbeat leaked to another subscriber: %+v", event)
case <-time.After(3 * b.heartbeatInterval):
// Pass: the direct subscriber did not receive the handler's
// heartbeat. Real events still fan out through Publish.
}
}
// nonFlushingResponseWriter is a ResponseWriter that explicitly does
// NOT implement http.Flusher, so we can verify the SSE handler
// degrades cleanly on writers that can't stream. Standard
// httptest.ResponseRecorder satisfies Flusher (as a no-op) so it
// can't be used to test this branch.
type nonFlushingResponseWriter struct {
header http.Header
body strings.Builder
statusCode int
}
func newNonFlushingResponseWriter() *nonFlushingResponseWriter {
return &nonFlushingResponseWriter{header: make(http.Header), statusCode: http.StatusOK}
}
func (w *nonFlushingResponseWriter) Header() http.Header { return w.header }
func (w *nonFlushingResponseWriter) WriteHeader(code int) { w.statusCode = code }
func (w *nonFlushingResponseWriter) Write(b []byte) (int, error) {
return w.body.Write(b)
}
func TestHandleAgentEvents_RequiresFlusher(t *testing.T) {
// Without a Flusher, SSE responses would buffer indefinitely on
// the server before reaching the agent. The handler must refuse
// rather than open a stream that never delivers.
b := NewAgentEventBroadcaster()
w := newNonFlushingResponseWriter()
req := httptest.NewRequest(http.MethodGet, "/api/agent/events", nil)
b.HandleAgentEvents(w, req)
if w.statusCode != http.StatusInternalServerError {
t.Fatalf("expected 500 when ResponseWriter does not implement Flusher; got %d", w.statusCode)
}
}
func TestAgentEventBroadcaster_PublishApprovalPendingDelivers(t *testing.T) {
// approval.pending is the second event kind the broadcaster
// publishes (after finding.created). Pin the contract: kind name,
// payload round-trip with the agent-decision-relevant fields,
// monotonic id assignment shared across kinds.
b := NewAgentEventBroadcaster()
events, unsub := b.Subscribe()
defer unsub()
expires := time.Now().Add(5 * time.Minute).UTC()
requested := time.Now().UTC()
b.PublishApprovalPending(AgentEventApprovalPendingPayload{
ApprovalID: "appr-1",
ResourceID: "container:web-1",
TargetType: "container",
TargetID: "web-1",
TargetName: "web-1",
Command: "docker restart web-1",
RiskLevel: "medium",
RequestedBy: "ai:patrol",
RequestedAt: requested,
ExpiresAt: expires,
})
select {
case event := <-events:
if event.Kind != AgentEventApprovalPending {
t.Fatalf("kind: got %q want %q", event.Kind, AgentEventApprovalPending)
}
if event.ID == 0 {
t.Error("event id must be assigned")
}
if event.At.IsZero() {
t.Error("event timestamp must be populated")
}
payload, ok := event.Payload.(AgentEventApprovalPendingPayload)
if !ok {
t.Fatalf("payload type: got %T want AgentEventApprovalPendingPayload", event.Payload)
}
if payload.ApprovalID != "appr-1" {
t.Errorf("ApprovalID: got %q want %q", payload.ApprovalID, "appr-1")
}
if payload.ResourceID != "container:web-1" {
t.Errorf("ResourceID: got %q want %q", payload.ResourceID, "container:web-1")
}
if payload.Command != "docker restart web-1" {
t.Errorf("Command did not round-trip: got %q", payload.Command)
}
if payload.RiskLevel != "medium" {
t.Errorf("RiskLevel did not round-trip: got %q", payload.RiskLevel)
}
if !payload.ExpiresAt.Equal(expires) {
t.Errorf("ExpiresAt did not round-trip: got %v want %v", payload.ExpiresAt, expires)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for approval.pending event")
}
}
func TestAgentEventApprovalPendingKindIsStable(t *testing.T) {
// Pin the wire-stable kind string. Renaming it breaks every agent
// that branches on the event type; the constant is part of the
// contract, not an implementation detail.
if AgentEventApprovalPending != AgentEventKind(agentcapabilities.EventKindApprovalPending) {
t.Fatalf("AgentEventApprovalPending changed: got %q want %q",
AgentEventApprovalPending, agentcapabilities.EventKindApprovalPending)
}
}
func TestAgentEventCommandRedactionForMonitoringReadTokens(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/agent/events", nil)
attachAPITokenRecord(req, &config.APITokenRecord{Scopes: []string{config.ScopeMonitoringRead}})
event := redactAgentEventCommandsForRequest(AgentEvent{
Kind: AgentEventActionCompleted,
Payload: AgentEventActionCompletedPayload{
ActionID: "action-1",
Command: "systemctl restart nginx",
Verification: &AgentResourceActionVerification{
Ran: true,
Success: true,
Command: "systemctl is-active nginx",
},
},
}, req)
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
if !ok {
t.Fatalf("payload type: got %T", event.Payload)
}
if payload.Command != "" || !payload.CommandRedacted {
t.Fatalf("action command must be redacted for monitoring-read token; got %+v", payload)
}
if payload.Verification == nil {
t.Fatal("verification must remain present")
}
if payload.Verification.Command != "" || !payload.Verification.CommandRedacted {
t.Fatalf("verification command must be redacted for monitoring-read token; got %+v", payload.Verification)
}
approval := redactAgentEventCommandsForRequest(AgentEvent{
Kind: AgentEventApprovalPending,
Payload: AgentEventApprovalPendingPayload{
ApprovalID: "appr-1",
Command: "systemctl restart nginx",
},
}, req)
approvalPayload, ok := approval.Payload.(AgentEventApprovalPendingPayload)
if !ok {
t.Fatalf("payload type: got %T", approval.Payload)
}
if approvalPayload.Command != "" || !approvalPayload.CommandRedacted {
t.Fatalf("approval command must be redacted for monitoring-read token; got %+v", approvalPayload)
}
}
func TestAgentEventCommandRedactionKeepsCommandsForActionTokens(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/agent/events", nil)
attachAPITokenRecord(req, &config.APITokenRecord{
Scopes: []string{config.ScopeMonitoringRead, config.ScopeAIExecute},
})
event := redactAgentEventCommandsForRequest(AgentEvent{
Kind: AgentEventApprovalPending,
Payload: AgentEventApprovalPendingPayload{
ApprovalID: "appr-1",
Command: "systemctl restart nginx",
},
}, req)
payload, ok := event.Payload.(AgentEventApprovalPendingPayload)
if !ok {
t.Fatalf("payload type: got %T", event.Payload)
}
if payload.Command != "systemctl restart nginx" || payload.CommandRedacted {
t.Fatalf("action-capable token should keep command; got %+v", payload)
}
}
func TestAgentEventBroadcaster_PublishActionCompletedDelivers(t *testing.T) {
// action.completed is the third event kind the broadcaster
// publishes (alongside finding.created and approval.pending).
// Pin the contract: kind name, full payload round-trip with
// dispatch-outcome fields agents branch on, monotonic id
// assignment shared across kinds.
b := NewAgentEventBroadcaster()
events, unsub := b.Subscribe()
defer unsub()
completed := time.Now().UTC()
b.PublishActionCompleted(AgentEventActionCompletedPayload{
ActionID: "action-1",
ResourceID: "container:web-1",
CapabilityName: "pulse_docker",
Command: "docker restart web-1",
State: "completed",
Success: true,
RequestedBy: "ai:patrol",
CompletedAt: completed,
})
select {
case event := <-events:
if event.Kind != AgentEventActionCompleted {
t.Fatalf("kind: got %q want %q", event.Kind, AgentEventActionCompleted)
}
if event.ID == 0 {
t.Error("event id must be assigned")
}
if event.At.IsZero() {
t.Error("event timestamp must be populated")
}
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
if !ok {
t.Fatalf("payload type: got %T want AgentEventActionCompletedPayload", event.Payload)
}
if payload.ActionID != "action-1" {
t.Errorf("ActionID: got %q want %q", payload.ActionID, "action-1")
}
if payload.ResourceID != "container:web-1" {
t.Errorf("ResourceID: got %q want %q", payload.ResourceID, "container:web-1")
}
if payload.State != "completed" {
t.Errorf("State: got %q want %q", payload.State, "completed")
}
if !payload.Success {
t.Error("Success did not round-trip")
}
if !payload.CompletedAt.Equal(completed) {
t.Errorf("CompletedAt did not round-trip: got %v want %v", payload.CompletedAt, completed)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for action.completed event")
}
}
func TestAgentEventBroadcaster_PublishActionCompletedPreservesRefusalToken(t *testing.T) {
// Refused-before-dispatch failures carry a stable error-token
// prefix in ErrorMessage (`plan_drift:` /
// `resource_remediation_locked:`) that agents branch on. The
// broadcaster must round-trip it verbatim — drift here breaks
// the contract that lets agents distinguish "the action was
// refused due to operator state" from "the action errored".
b := NewAgentEventBroadcaster()
events, unsub := b.Subscribe()
defer unsub()
b.PublishActionCompleted(AgentEventActionCompletedPayload{
ActionID: "action-refused",
ResourceID: "vm:db-1",
State: "failed",
Success: false,
ErrorMessage: "resource_remediation_locked: resource has been locked by the operator",
CompletedAt: time.Now().UTC(),
})
select {
case event := <-events:
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
if !ok {
t.Fatalf("payload type: got %T", event.Payload)
}
if payload.Success {
t.Error("refused dispatches must not surface as Success=true")
}
if !strings.HasPrefix(payload.ErrorMessage, "resource_remediation_locked:") {
t.Errorf("ErrorMessage prefix lost: got %q", payload.ErrorMessage)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for action.completed event")
}
}
func TestAgentEventActionCompletedKindIsStable(t *testing.T) {
// Pin the wire-stable kind string. Renaming it breaks every
// agent that branches on the event type.
if AgentEventActionCompleted != AgentEventKind(agentcapabilities.EventKindActionCompleted) {
t.Fatalf("AgentEventActionCompleted changed: got %q want %q",
AgentEventActionCompleted, agentcapabilities.EventKindActionCompleted)
}
}
func TestAgentEventBroadcaster_PublishActionCompletedRoundTripsVerification(t *testing.T) {
// The verification block is the agent-stable projection of the
// post-execution read-after-write probe. Pin the contract: it
// round-trips through the broadcaster with the fields agents
// branch on (Ran, Success, Command, Note, RanAt) intact.
b := NewAgentEventBroadcaster()
events, unsub := b.Subscribe()
defer unsub()
ranAt := time.Now().UTC()
b.PublishActionCompleted(AgentEventActionCompletedPayload{
ActionID: "action-verify-1",
ResourceID: "container:web-1",
State: "completed",
Success: true,
CompletedAt: ranAt,
Verification: &AgentResourceActionVerification{
Ran: true,
Success: true,
Command: "docker inspect --format '{{.State.Status}}' web-1",
RanAt: ranAt,
},
})
select {
case event := <-events:
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
if !ok {
t.Fatalf("payload type: got %T", event.Payload)
}
if payload.Verification == nil {
t.Fatal("verification block must round-trip — agents close the certainty loop on this field")
}
if !payload.Verification.Ran || !payload.Verification.Success {
t.Errorf("verification flags lost: ran=%t success=%t", payload.Verification.Ran, payload.Verification.Success)
}
if payload.Verification.Command == "" {
t.Error("verification command must round-trip so agents see what was checked")
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for action.completed event")
}
recordBroadcaster := NewAgentEventBroadcaster()
recordEvents, recordUnsub := recordBroadcaster.Subscribe()
defer recordUnsub()
recordBroadcaster.PublishActionCompletedRecord(unifiedresources.ActionAuditRecord{
ID: "action-verify-record",
UpdatedAt: ranAt,
State: unifiedresources.ActionStateCompleted,
Request: unifiedresources.ActionRequest{
ResourceID: "container:web-1",
CapabilityName: "restart_service",
RequestedBy: "agent:ops",
},
Result: &unifiedresources.ExecutionResult{Success: true},
Verification: &unifiedresources.ActionVerificationResult{
Ran: true,
Success: true,
Command: "systemctl is-active 'nginx'",
RanAt: ranAt,
},
})
select {
case event := <-recordEvents:
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
if !ok {
t.Fatalf("record payload type: got %T", event.Payload)
}
if payload.Verification == nil || payload.Verification.Command != "systemctl is-active 'nginx'" {
t.Fatalf("record canonical verification did not project onto action.completed: %+v", payload.Verification)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for record-backed action.completed event")
}
}
func TestProjectAgentActionCompletedPayloadUsesCanonicalVerification(t *testing.T) {
ranAt := time.Now().UTC()
payload, ok := projectAgentActionCompletedPayload(unifiedresources.ActionAuditRecord{
ID: "action-router-canonical-verification",
UpdatedAt: ranAt,
State: unifiedresources.ActionStateCompleted,
Request: unifiedresources.ActionRequest{
ResourceID: "vm:router-canonical",
CapabilityName: "restart_service",
RequestedBy: "agent:ops",
},
Result: &unifiedresources.ExecutionResult{Success: true},
Verification: &unifiedresources.ActionVerificationResult{
Ran: true,
Success: true,
Command: "systemctl is-active nginx",
RanAt: ranAt,
},
})
if !ok {
t.Fatal("expected terminal action to project")
}
if payload.Verification == nil || payload.Verification.Command != "systemctl is-active nginx" {
t.Fatalf("top-level canonical verification did not project onto router payload: %+v", payload.Verification)
}
payload, ok = projectAgentActionCompletedPayload(unifiedresources.ActionAuditRecord{
ID: "action-router-unrun-verification",
UpdatedAt: ranAt,
State: unifiedresources.ActionStateCompleted,
Request: unifiedresources.ActionRequest{
ResourceID: "vm:router-unrun",
CapabilityName: "restart_service",
RequestedBy: "agent:ops",
},
Result: &unifiedresources.ExecutionResult{
Success: true,
Verification: &unifiedresources.ActionVerificationResult{
Ran: false,
Success: true,
Command: "should not leak",
Output: "sensitive output",
Note: "sensitive note",
RanAt: ranAt,
},
},
})
if !ok {
t.Fatal("expected terminal action to project")
}
if payload.Verification == nil || payload.Verification.Ran {
t.Fatalf("expected sanitized ran=false verification, got %+v", payload.Verification)
}
if payload.Verification.Command != "" || payload.Verification.Note != "" || !payload.Verification.RanAt.IsZero() || payload.Verification.Success {
t.Fatalf("router payload leaked ran=false verification details: %+v", payload.Verification)
}
}
func TestAgentEventBroadcaster_PublishActionCompletedAbsentVerificationOmitsField(t *testing.T) {
// Refused-before-dispatch failures have no verification result
// (verification only runs after a successful execute). The
// payload's Verification must surface as omitted (nil) rather
// than an empty struct so agents can branch on field presence
// to distinguish "no verification attempted" from "verification
// ran with empty result".
b := NewAgentEventBroadcaster()
events, unsub := b.Subscribe()
defer unsub()
b.PublishActionCompleted(AgentEventActionCompletedPayload{
ActionID: "action-refused-1",
ResourceID: "vm:db-1",
State: "failed",
Success: false,
ErrorMessage: "resource_remediation_locked: ...",
CompletedAt: time.Now().UTC(),
// Verification deliberately unset.
})
select {
case event := <-events:
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
if !ok {
t.Fatalf("payload type: got %T", event.Payload)
}
if payload.Verification != nil {
t.Errorf("verification must remain nil for refused dispatches; got %+v", payload.Verification)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for action.completed event")
}
}