Pulse/internal/ai/investigation_records_test.go
rcourtman a2c3dc77f1 Lift remediation-plan rollback into investigation records
When a Patrol finding has a generated remediation plan, the plan's
per-step Rollback strings are now aggregated into the durable
InvestigationRecord.Rollback field at record-build time. Previously
rollback metadata existed only nested inside RemediationStep.Rollback,
forcing operator-facing surfaces and Assistant prompt context to walk
into per-step payload to answer "what's the undo for the proposed fix?"

Adds AggregatePlanRollbackSteps in internal/ai/investigation_records.go
which deduplicates non-empty rollback strings from a plan's steps.
Wires the aggregation into the patrol_findings.go investigation-record
build site so rollback flows automatically when the PatrolService has a
RemediationEngine attached and the finding has an active plan.

Adds three sub-tests covering nil plan, empty-rollback-step skip, and
duplicate-rollback dedup. Updates the ai-runtime contract to pin the
RemediationPlan to InvestigationRecord rollback aggregation rule.
2026-05-08 18:17:19 +01:00

186 lines
6.5 KiB
Go

package ai
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
)
func TestBuildFindingInvestigationRecord_FromSession(t *testing.T) {
detectedAt := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
completedAt := detectedAt.Add(3 * time.Minute)
finding := &Finding{
ID: "finding-1",
Key: "cpu-high",
Severity: FindingSeverityCritical,
Category: FindingCategoryPerformance,
ResourceID: "vm-100",
ResourceName: "web-server",
ResourceType: "vm",
Node: "pve-1",
Title: "High CPU",
Description: "CPU above threshold",
Impact: "Workload stalls until CPU pressure clears.",
Recommendation: "Reduce CPU pressure",
Evidence: "cpu=96%",
Source: "ai-analysis",
FailureCause: string(PatrolFailureCauseModelUnsupportedTools),
DetectedAt: detectedAt,
InvestigationSessionID: "chat-1",
InvestigationStatus: string(InvestigationStatusCompleted),
InvestigationOutcome: string(InvestigationOutcomeFixQueued),
LastInvestigatedAt: &completedAt,
}
session := &InvestigationSession{
ID: "investigation-1",
FindingID: "finding-1",
SessionID: "chat-1",
Status: aicontracts.InvestigationStatusCompleted,
StartedAt: detectedAt.Add(time.Minute),
CompletedAt: &completedAt,
Outcome: aicontracts.OutcomeFixQueued,
ToolsUsed: []string{"ssh.exec", "ssh.exec", " "},
EvidenceIDs: []string{"evidence-1"},
Summary: "Postgres was consuming CPU.",
ApprovalID: "approval-1",
ProposedFix: &aicontracts.Fix{
ID: "fix-1",
Description: "Restart postgres",
Commands: []string{"systemctl restart postgres"},
RiskLevel: "medium",
TargetHost: "pve-1",
Rationale: "Service is stuck in a busy loop",
},
}
record := BuildFindingInvestigationRecord(finding, session)
if record == nil {
t.Fatal("expected investigation record")
}
if record.ID != "investigation-1" {
t.Fatalf("record ID = %q, want investigation-1", record.ID)
}
if record.Subject.ResourceID != "vm-100" || record.Subject.Node != "pve-1" {
t.Fatalf("unexpected subject: %#v", record.Subject)
}
if record.Trigger.FindingKey != "cpu-high" || record.Trigger.Title != "High CPU" {
t.Fatalf("unexpected trigger: %#v", record.Trigger)
}
if record.Trigger.Cause != string(PatrolFailureCauseModelUnsupportedTools) {
t.Fatalf("trigger cause = %q", record.Trigger.Cause)
}
if record.Conclusion != "Postgres was consuming CPU." {
t.Fatalf("conclusion = %q", record.Conclusion)
}
if record.RecommendedAction != "Reduce CPU pressure" {
t.Fatalf("recommended action = %q", record.RecommendedAction)
}
if record.Confidence != aicontracts.InvestigationRecordConfidenceMedium {
t.Fatalf("confidence = %q", record.Confidence)
}
if len(record.Evidence) != 2 {
t.Fatalf("expected finding and investigation evidence, got %#v", record.Evidence)
}
if len(record.ToolsUsed) != 1 || record.ToolsUsed[0] != "ssh.exec" {
t.Fatalf("tools used not normalized: %#v", record.ToolsUsed)
}
if record.ProposedFix == nil || record.ProposedFix.RiskLevel != "medium" {
t.Fatalf("expected proposed fix, got %#v", record.ProposedFix)
}
if record.ApprovalID != "approval-1" {
t.Fatalf("approval ID = %q", record.ApprovalID)
}
if record.Impact != "Workload stalls until CPU pressure clears." {
t.Fatalf("expected finding impact to propagate to record, got %q", record.Impact)
}
if record.Rollback == nil || len(record.Rollback) != 0 {
t.Fatalf("expected normalized empty rollback slice, got %#v", record.Rollback)
}
}
func TestEmptyInvestigationRecord_NormalizesRollback(t *testing.T) {
record := aicontracts.EmptyInvestigationRecord()
if record.Rollback == nil || len(record.Rollback) != 0 {
t.Fatalf("expected empty rollback slice on empty record, got %#v", record.Rollback)
}
}
func TestAggregatePlanRollbackSteps(t *testing.T) {
t.Run("nil plan returns empty slice", func(t *testing.T) {
got := AggregatePlanRollbackSteps(nil)
if got == nil || len(got) != 0 {
t.Fatalf("expected empty slice for nil plan, got %#v", got)
}
})
t.Run("aggregates rollback strings from steps and drops empties", func(t *testing.T) {
plan := &aicontracts.RemediationPlan{
Steps: []aicontracts.RemediationStep{
{Order: 1, Description: "Restart service", Rollback: "systemctl stop unit && restore prior config"},
{Order: 2, Description: "Reseat datastore", Rollback: ""}, // empty rollback skipped
{Order: 3, Description: "Re-enable cron", Rollback: "Disable cron and revert maintenance window"},
},
}
got := AggregatePlanRollbackSteps(plan)
want := []string{
"systemctl stop unit && restore prior config",
"Disable cron and revert maintenance window",
}
if len(got) != len(want) {
t.Fatalf("expected %d rollback steps, got %d (%#v)", len(want), len(got), got)
}
for i, w := range want {
if got[i] != w {
t.Fatalf("step %d: expected %q, got %q", i, w, got[i])
}
}
})
t.Run("deduplicates identical rollback strings", func(t *testing.T) {
plan := &aicontracts.RemediationPlan{
Steps: []aicontracts.RemediationStep{
{Order: 1, Rollback: "rollback-a"},
{Order: 2, Rollback: "rollback-a"}, // duplicate
{Order: 3, Rollback: "rollback-b"},
},
}
got := AggregatePlanRollbackSteps(plan)
if len(got) != 2 {
t.Fatalf("expected 2 unique rollback steps, got %#v", got)
}
})
}
func TestFindingsStore_UpdateInvestigationRecord(t *testing.T) {
store := NewFindingsStore()
store.Add(&Finding{
ID: "finding-1",
Severity: FindingSeverityWarning,
Category: FindingCategoryPerformance,
ResourceID: "vm-100",
Title: "High CPU",
DetectedAt: time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC),
})
record := &aicontracts.InvestigationRecord{
ID: "investigation-1",
FindingID: "finding-1",
Status: aicontracts.InvestigationStatusCompleted,
Outcome: aicontracts.OutcomeFixVerified,
Evidence: []aicontracts.InvestigationRecordEvidence{},
}
if !store.UpdateInvestigationRecord("finding-1", record) {
t.Fatal("UpdateInvestigationRecord failed")
}
updated := store.Get("finding-1")
if updated == nil || updated.InvestigationRecord == nil {
t.Fatalf("expected persisted investigation record, got %#v", updated)
}
if updated.InvestigationRecord.ID != "investigation-1" {
t.Fatalf("record ID = %q", updated.InvestigationRecord.ID)
}
if store.UpdateInvestigationRecord("missing", record) {
t.Fatal("expected false for missing finding")
}
}