Files
pulse/internal/api/attention_actions_test.go
T
2026-07-19 15:11:02 +01:00

318 lines
11 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type attentionActionTestExecutor struct {
result *unified.ExecutionResult
calls int
}
func (e *attentionActionTestExecutor) ExecuteAction(
context.Context,
unified.ActionAuditRecord,
) (*unified.ExecutionResult, error) {
e.calls++
return e.result, nil
}
func (*attentionActionTestExecutor) CheckActionAvailable(
_ context.Context,
request unified.ActionRequest,
_ unified.Resource,
) unified.ResourceActionReadiness {
return unified.ResourceActionReadiness{Name: request.CapabilityName, Available: true}
}
func TestAttentionActionPlanBindsCanonicalRecordEvidenceAndReplaysIdempotently(t *testing.T) {
now := time.Now().UTC()
alert := attentionHandlerAlert("docker-health-record", operationaltrust.OperationalOpen, now)
resourceID := "docker:host-1/container-1"
alert.Type = ai.AttentionDockerHealthKind
alert.ResourceID = resourceID
alert.ResourceName = "API container"
alert.Metadata = map[string]interface{}{"resourceType": string(unified.ResourceTypeAppContainer)}
alert.OperationalRecord.SubjectResourceID = resourceID
alert.Evidence[0].Subject.ResourceID = resourceID
resources := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
resources.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{{
ID: resourceID,
Type: unified.ResourceTypeAppContainer,
Name: "API container",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Capabilities: []unified.ResourceCapability{{
Name: ai.AttentionDockerRestartCapability,
Type: unified.CapabilityTypeCommon,
Description: "Restart this Docker container.",
MinimumApprovalLevel: unified.ApprovalAdmin,
AutoAuthorization: unified.AutoAuthorizeLowRisk,
InternalHandler: ai.AttentionDockerLifecycleHandler,
}},
}},
})
executor := &attentionActionTestExecutor{result: &unified.ExecutionResult{
Success: true,
Output: "Container restart completed.",
Verification: &unified.ActionVerificationResult{
Ran: true,
Success: true,
Note: "Fresh container readback observed the same container running.",
},
}}
resources.SetActionExecutor(executor)
authority := testActionAuthority()
resolved := false
handler := &AttentionHandlers{
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
if resolved {
return nil, []alerts.Alert{alert}, nil
}
return []alerts.Alert{alert}, nil, nil
},
}
handler.SetActionDependencies(resources, authority)
handler.SetActionLicenseChecker(func(context.Context) bool { return true })
detailRequest := actionHandlerTestRequest(
httptest.NewRequest(
http.MethodGet,
"/api/ai/patrol/attention/docker-health-record",
nil,
),
"operator@example.com",
)
detailResponse := httptest.NewRecorder()
handler.HandleAttention(detailResponse, detailRequest)
if detailResponse.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", detailResponse.Code, detailResponse.Body.String())
}
var detail ai.AttentionItemDetail
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if len(detail.Item.AvailableActions) != 1 ||
detail.Item.AvailableActions[0].Capability != ai.AttentionDockerRestartCapability ||
detail.Item.AvailableActions[0].ActionID != "" {
t.Fatalf("initial offers = %+v", detail.Item.AvailableActions)
}
var first unified.ActionPlan
for attempt := 0; attempt < 2; attempt++ {
request := actionHandlerTestRequest(
httptest.NewRequest(
http.MethodPost,
"/api/ai/patrol/attention/docker-health-record/actions/restart/plan",
nil,
),
"operator@example.com",
)
response := httptest.NewRecorder()
handler.HandleAttention(response, request)
if response.Code != http.StatusOK {
t.Fatalf("attempt %d status=%d body=%s", attempt, response.Code, response.Body.String())
}
var plan unified.ActionPlan
if err := json.Unmarshal(response.Body.Bytes(), &plan); err != nil {
t.Fatalf("decode plan: %v", err)
}
if attempt == 0 {
first = plan
} else if plan.ActionID != first.ActionID || plan.PlanHash != first.PlanHash {
t.Fatalf("replay plan=%+v first=%+v", plan, first)
}
}
if !first.RequiresApproval {
t.Fatalf("plan = %+v, want explicit approval", first)
}
store, err := resources.getStore("default")
if err != nil {
t.Fatalf("get store: %v", err)
}
reader, ok := store.(unified.OperationalActionAuditOriginReader)
if !ok {
t.Fatalf("%T lacks operational action lookup", store)
}
audit, found, err := reader.GetLatestActionAuditByOperationalRecord(
operationalTrustActionOriginSurface,
"docker-health-record",
)
if err != nil || !found {
t.Fatalf("origin audit found=%t err=%v", found, err)
}
if audit.ID != first.ActionID ||
audit.Origin == nil ||
audit.Origin.OperationalRecordID != "docker-health-record" ||
len(audit.Origin.EvidenceIDs) != 1 ||
audit.Origin.EvidenceIDs[0] != alert.Evidence[0].ID {
t.Fatalf("audit origin = %+v", audit.Origin)
}
decisionRequest := actionHandlerTestRequest(
httptest.NewRequest(
http.MethodPost,
"/api/actions/"+first.ActionID+"/decision",
bytes.NewBufferString(`{"outcome":"approved","planHash":"`+first.PlanHash+`"}`),
),
"operator@example.com",
)
decisionRequest.SetPathValue("id", first.ActionID)
decisionResponse := httptest.NewRecorder()
resources.HandleDecideAction(decisionResponse, decisionRequest)
if decisionResponse.Code != http.StatusOK {
t.Fatalf("decision status=%d body=%s", decisionResponse.Code, decisionResponse.Body.String())
}
for attempt := 0; attempt < 2; attempt++ {
executeRequest := actionHandlerTestRequest(
httptest.NewRequest(
http.MethodPost,
"/api/actions/"+first.ActionID+"/execute",
bytes.NewBufferString(`{"planHash":"`+first.PlanHash+`"}`),
),
"operator@example.com",
)
executeRequest.SetPathValue("id", first.ActionID)
executeResponse := httptest.NewRecorder()
resources.HandleExecuteAction(executeResponse, executeRequest)
if executeResponse.Code != http.StatusOK {
t.Fatalf("execute attempt %d status=%d body=%s", attempt, executeResponse.Code, executeResponse.Body.String())
}
}
if executor.calls != 1 {
t.Fatalf("executor calls=%d, want exactly one", executor.calls)
}
detailResponse = httptest.NewRecorder()
handler.HandleAttention(detailResponse, detailRequest)
if detailResponse.Code != http.StatusOK {
t.Fatalf("reloaded detail status=%d body=%s", detailResponse.Code, detailResponse.Body.String())
}
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode reloaded detail: %v", err)
}
if len(detail.Item.AvailableActions) != 1 ||
detail.Item.AvailableActions[0].ActionID != first.ActionID ||
detail.Item.VerificationState != ai.AttentionVerificationSucceeded ||
detail.Item.State != operationaltrust.OperationalOpen {
t.Fatalf("reloaded item = %+v", detail.Item)
}
// A successful command/readback never closes the operational record by
// itself. Only a fresh detector-owned recovery observation moves the item
// to resolved.
recoveredAt := time.Now().UTC().Add(time.Second)
alert.OperationalRecord.State = operationaltrust.OperationalResolved
alert.OperationalRecord.StateChangedAt = recoveredAt
alert.OperationalRecord.LastObservedAt = recoveredAt
alert.OperationalRecord.ResolvedAt = &recoveredAt
alert.Evidence[0].ObservedAt = recoveredAt
alert.Evidence[0].IngestedAt = recoveredAt
validUntil := recoveredAt.Add(time.Hour)
alert.Evidence[0].ValidUntil = &validUntil
resolved = true
detailResponse = httptest.NewRecorder()
handler.HandleAttention(detailResponse, detailRequest)
if detailResponse.Code != http.StatusOK {
t.Fatalf("resolved detail status=%d body=%s", detailResponse.Code, detailResponse.Body.String())
}
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode resolved detail: %v", err)
}
if detail.Item.State != operationaltrust.OperationalResolved ||
detail.Item.VerificationState != ai.AttentionVerificationSucceeded {
t.Fatalf("resolved item = %+v", detail.Item)
}
}
func TestAttentionActionOfferFailsClosedWithoutFreshEvidenceOrExecutorReadiness(t *testing.T) {
now := time.Now().UTC()
detail := ai.AttentionItemDetail{
Item: ai.AttentionItem{
ID: "record",
OperationalRecordID: "record",
SubjectResourceID: "docker:host/container",
SubjectResourceType: string(unified.ResourceTypeAppContainer),
Kind: ai.AttentionDockerHealthKind,
State: operationaltrust.OperationalOpen,
},
Evidence: []operationaltrust.EvidenceEnvelope{{
ID: "evidence",
Subject: operationaltrust.EvidenceSubject{ResourceID: "docker:host/container"},
ObservedAt: now.Add(-time.Hour),
IngestedAt: now.Add(-time.Hour),
Completeness: operationaltrust.EvidenceComplete,
Confidence: operationaltrust.EvidenceConfirmed,
Permissions: operationaltrust.EvidencePermissionsSufficient,
}},
}
resource := unified.Resource{
ID: detail.Item.SubjectResourceID,
Type: unified.ResourceTypeAppContainer,
Capabilities: []unified.ResourceCapability{{
Name: ai.AttentionDockerRestartCapability,
MinimumApprovalLevel: unified.ApprovalAdmin,
InternalHandler: ai.AttentionDockerLifecycleHandler,
}},
}
if offer, reason := ai.ProjectAttentionAction(
&detail,
ai.AttentionActionCandidate{
Resource: &resource,
Readiness: unified.ResourceActionReadiness{
Name: ai.AttentionDockerRestartCapability,
Available: true,
},
Authorized: true,
},
now,
); reason != ai.AttentionActionEvidenceStale || offer.Capability != "" {
t.Fatalf("stale offer=%+v reason=%q", offer, reason)
}
}
func TestAttentionActionPlanRequiresAutoFixEntitlementBeforeProjection(t *testing.T) {
handler := &AttentionHandlers{resources: &ResourceHandlers{}}
handler.SetActionLicenseChecker(func(context.Context) bool { return false })
request := httptest.NewRequest(
http.MethodPost,
"/api/ai/patrol/attention/docker-health/actions/restart/plan",
nil,
)
response := httptest.NewRecorder()
handler.HandleAttention(response, request)
if response.Code != http.StatusPaymentRequired {
t.Fatalf("status = %d, want %d: %s", response.Code, http.StatusPaymentRequired, response.Body.String())
}
var payload struct {
Error string `json:"error"`
Feature string `json:"feature"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.Error != "license_required" || payload.Feature != featureAIAutoFixKey {
t.Fatalf("license response = %+v", payload)
}
}