Files
pulse/internal/actionplanner/planner_test.go
T
rcourtman f73d4d4d97 Collapse the container update ceremony to a single review
A routine Docker container update took five clicks across two in-row
confirmations and a two-phase Approve then Run dialog, and the review
screen said "Rollback declared: No" while its own intended-change text
promised automatic backup and rollback (#1564 feedback).

- Capabilities can declare rollback support; the planner carries it
  into plan.rollbackAvailable instead of hard-coding false, so the
  container update review now truthfully says rollback is available.
- Container update is classed AutoAuthorizeLowRisk (it ships backup and
  rollback; restart was already low_risk). The action projection
  exposes the read-time capability class plus resolved blast-radius
  names, none of which enter plan identity.
- The review dialog collapses pending approval for low-risk
  capabilities into one Approve and run control that records the
  approval and dispatches execution from a single confirmation; other
  capabilities keep the two-phase flow. Blast radius renders resource
  names with IDs demoted.
- The in-row Update button plans on first click; the dialog is the
  confirmation surface, so the Confirm hop and its cancel affordance
  are gone.

Routine update ceremony is now Update, Approve and run, Close, with
the durable action record unchanged.
2026-07-19 14:48:24 +01:00

410 lines
16 KiB
Go

package actionplanner
import (
"errors"
"strings"
"testing"
"time"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestPlannerBuildsDeterministicGovernedPlan(t *testing.T) {
now := time.Date(2026, 5, 3, 9, 30, 0, 0, time.UTC)
parentID := "agent:node-1"
resource := unified.Resource{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Name: "web-42",
Status: unified.StatusWarning,
LastSeen: now.Add(-time.Minute),
UpdatedAt: now.Add(-30 * time.Second),
ParentID: &parentID,
Capabilities: []unified.ResourceCapability{
{
Name: "restart",
Type: unified.CapabilityTypeCommon,
Description: "Restart the VM",
MinimumApprovalLevel: unified.ApprovalAdmin,
InternalHandler: "proxmox.vm.restart",
Params: []unified.CapabilityParam{
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
},
},
},
Relationships: []unified.ResourceRelationship{
{
SourceID: "vm:42",
TargetID: "service:web",
Type: unified.RelDependsOn,
Active: true,
},
},
}
req := unified.ActionRequest{
RequestID: "agent-run-123",
ResourceID: " vm:42 ",
CapabilityName: "restart",
Params: map[string]any{"mode": "graceful"},
Reason: "Recover after confirmed outage",
RequestedBy: "agent:oncall-helper",
Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
}
planner := Planner{Now: func() time.Time { return now }}
plan, err := planner.Plan(req, resource)
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
second, err := planner.Plan(req, resource)
if err != nil {
t.Fatalf("Plan() second error = %v", err)
}
if plan.ActionID == "" || plan.ActionID != second.ActionID {
t.Fatalf("action id is not deterministic: first=%q second=%q", plan.ActionID, second.ActionID)
}
if plan.PlanHash == "" || plan.PlanHash != second.PlanHash {
t.Fatalf("plan hash is not deterministic: first=%q second=%q", plan.PlanHash, second.PlanHash)
}
if !plan.Allowed {
t.Fatalf("Allowed = false, want true")
}
if !plan.RequiresApproval {
t.Fatalf("RequiresApproval = false, want true")
}
if plan.ApprovalPolicy != unified.ApprovalAdmin {
t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalAdmin)
}
if !plan.PlannedAt.Equal(now) {
t.Fatalf("PlannedAt = %s, want %s", plan.PlannedAt, now)
}
if !plan.ExpiresAt.Equal(now.Add(DefaultPlanTTL)) {
t.Fatalf("ExpiresAt = %s, want %s", plan.ExpiresAt, now.Add(DefaultPlanTTL))
}
if plan.PolicyDecision.Version != unified.ActionPolicyDecisionVersion || plan.PolicyDecision.Status != unified.ActionPolicyDecisionResolved || len(plan.PolicyDecision.Authorities) != 1 || plan.PolicyDecision.Authorities[0].Kind != unified.ActionPolicyAuthorityCapability {
t.Fatalf("direct plan must record only its consulted capability authority: %#v", plan.PolicyDecision)
}
if len(plan.PredictedBlastRadius) != 3 ||
plan.PredictedBlastRadius[0] != "vm:42" ||
plan.PredictedBlastRadius[1] != "agent:node-1" ||
plan.PredictedBlastRadius[2] != "service:web" {
t.Fatalf("PredictedBlastRadius = %#v", plan.PredictedBlastRadius)
}
if plan.Preflight == nil {
t.Fatalf("Preflight is nil")
}
if plan.Preflight.Target != "vm:42" {
t.Fatalf("Preflight.Target = %q, want vm:42", plan.Preflight.Target)
}
if plan.Preflight.DryRunAvailable {
t.Fatalf("DryRunAvailable = true, want false without provider dry-run contract")
}
}
func TestPlannerPolicyDecisionChangesPlanHashWithoutReusingExecutionAuthority(t *testing.T) {
now := time.Date(2026, 7, 11, 20, 30, 0, 0, time.UTC)
resource := unified.Resource{ID: "vm:42", Type: unified.ResourceTypeVM, Name: "web", Status: unified.StatusOnline, Capabilities: []unified.ResourceCapability{{Name: "restart", MinimumApprovalLevel: unified.ApprovalAdmin}}}
req := unified.ActionRequest{RequestID: "proposal-1", ResourceID: "vm:42", CapabilityName: "restart", Reason: "recover", Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:patrol", OrgID: "default"}}
factor := unified.ActionPolicyAuthorityFactor{Kind: unified.ActionPolicyAuthorityTenant, SourceID: "patrol-tenant-policy", Revision: "tenant-policy:sha256:0123456789abcdef01234567", Status: unified.ActionPolicyAuthorityConsulted, ReasonCodes: []unified.ActionPolicyReasonCode{unified.PolicyReasonTenantModeAssisted, unified.PolicyReasonTenantFullLocked}}
planner := Planner{Now: func() time.Time { return now }}
first, err := planner.PlanWithPolicyFactors(req, resource, unified.ApprovalRequirement{}, []unified.ActionPolicyAuthorityFactor{factor})
if err != nil {
t.Fatal(err)
}
factor.Revision = "tenant-policy:sha256:1123456789abcdef01234567"
second, err := planner.PlanWithPolicyFactors(req, resource, unified.ApprovalRequirement{}, []unified.ActionPolicyAuthorityFactor{factor})
if err != nil {
t.Fatal(err)
}
if first.ActionID != second.ActionID {
t.Fatalf("descriptive tenant snapshot must not replace request identity: %q != %q", first.ActionID, second.ActionID)
}
if first.PlanHash == second.PlanHash || first.PolicyDecision.DecisionID == second.PolicyDecision.DecisionID {
t.Fatalf("policy provenance revision must change plan hash and decision digest")
}
}
func TestPlannerBuildsDryRunOnlyPlanWithoutExecutionApproval(t *testing.T) {
now := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC)
resource := unified.Resource{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Name: "web-42",
Status: unified.StatusOnline,
Capabilities: []unified.ResourceCapability{
{
Name: "restart",
Type: unified.CapabilityTypeCommon,
Description: "Restart the VM",
MinimumApprovalLevel: unified.ApprovalDryRun,
},
},
}
req := unified.ActionRequest{
RequestID: "agent-run-dry-run",
ResourceID: "vm:42",
CapabilityName: "restart",
Reason: "Validate restart path without execution",
RequestedBy: "agent:oncall-helper",
Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
}
plan, err := (Planner{Now: func() time.Time { return now }}).Plan(req, resource)
if err != nil {
t.Fatalf("Plan() error = %v", err)
}
if plan.ApprovalPolicy != unified.ApprovalDryRun {
t.Fatalf("ApprovalPolicy = %q, want %q", plan.ApprovalPolicy, unified.ApprovalDryRun)
}
if plan.RequiresApproval {
t.Fatalf("RequiresApproval = true, want false because dry-run-only plans cannot be executed")
}
if plan.Preflight == nil || !strings.Contains(strings.Join(plan.Preflight.SafetyChecks, " "), "dry-run-only") {
t.Fatalf("dry-run-only safety checks missing: %#v", plan.Preflight)
}
if !strings.Contains(plan.Message, "dry-run only") {
t.Fatalf("plan message = %q", plan.Message)
}
}
func TestPlannerRejectsUndeclaredParams(t *testing.T) {
resource := unified.Resource{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Capabilities: []unified.ResourceCapability{
{Name: "restart", Type: unified.CapabilityTypeCommon, MinimumApprovalLevel: unified.ApprovalAdmin},
},
}
req := unified.ActionRequest{
RequestID: "agent-run-123",
ResourceID: "vm:42",
CapabilityName: "restart",
Params: map[string]any{"force": true},
Reason: "Recover after confirmed outage",
RequestedBy: "agent:oncall-helper",
Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
}
_, err := Planner{}.Plan(req, resource)
validationErr, ok := AsValidationError(err)
if !ok {
t.Fatalf("Plan() error = %v, want validation error", err)
}
if validationErr.Field != "params.force" {
t.Fatalf("validation field = %q, want params.force", validationErr.Field)
}
}
func TestResourceVersionIgnoresObservationOnlyTimestamps(t *testing.T) {
base := unified.Resource{
ID: "vm:42",
Type: unified.ResourceTypeVM,
Name: "web-42",
Status: unified.StatusOnline,
LastSeen: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
Identity: unified.ResourceIdentity{
Hostnames: []string{"web-42"},
IPAddresses: []string{"192.0.2.10"},
},
SourceStatus: map[unified.DataSource]unified.SourceStatus{
unified.SourceProxmox: {
Status: "online",
LastSeen: time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC),
},
},
}
refreshed := base
refreshed.LastSeen = base.LastSeen.Add(time.Minute)
refreshed.UpdatedAt = base.UpdatedAt.Add(time.Minute)
refreshed.Identity = unified.ResourceIdentity{
Hostnames: []string{"web-42", "web-42.local"},
IPAddresses: []string{"192.0.2.10", "192.0.2.11"},
}
refreshed.SourceStatus = map[unified.DataSource]unified.SourceStatus{
unified.SourceProxmox: {
Status: "online",
LastSeen: base.SourceStatus[unified.SourceProxmox].LastSeen.Add(time.Minute),
},
}
if got, want := ResourceVersion(refreshed), ResourceVersion(base); got != want {
t.Fatalf("ResourceVersion changed for observation-only timestamp drift: got %q want %q", got, want)
}
changed := base
changed.Status = unified.StatusWarning
if got, unchanged := ResourceVersion(changed), ResourceVersion(base); got == unchanged {
t.Fatalf("ResourceVersion did not change for status drift: %q", got)
}
}
func TestPlannerReturnsCapabilityNotFound(t *testing.T) {
resource := unified.Resource{ID: "vm:42", Type: unified.ResourceTypeVM}
req := unified.ActionRequest{
RequestID: "agent-run-123",
ResourceID: "vm:42",
CapabilityName: "restart",
Reason: "Recover after confirmed outage",
RequestedBy: "agent:oncall-helper",
Actor: unified.ActionActor{SubjectID: "agent:oncall-helper", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
}
_, err := Planner{}.Plan(req, resource)
if !errors.Is(err, ErrCapabilityNotFound) {
t.Fatalf("Plan() error = %v, want ErrCapabilityNotFound", err)
}
}
func TestPlannerIdentityChangesWhenAutoAuthorizationEligibilityChanges(t *testing.T) {
resource := unified.Resource{
ID: "app-container:demo",
Type: unified.ResourceTypeAppContainer,
Name: "demo",
Status: unified.StatusOnline,
Capabilities: []unified.ResourceCapability{{
Name: "restart",
MinimumApprovalLevel: unified.ApprovalAdmin,
}},
}
req := unified.ActionRequest{
RequestID: "proposal-1",
ResourceID: resource.ID,
CapabilityName: "restart",
Reason: "recover service",
RequestedBy: "pulse_patrol",
Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:test", OrgID: "default"},
}
withoutEligibility, err := (Planner{}).Plan(req, resource)
if err != nil {
t.Fatalf("plan without eligibility: %v", err)
}
resource.Capabilities[0].AutoAuthorization = unified.AutoAuthorizeLowRisk
withEligibility, err := (Planner{}).Plan(req, resource)
if err != nil {
t.Fatalf("plan with eligibility: %v", err)
}
if withoutEligibility.PolicyVersion == withEligibility.PolicyVersion || withoutEligibility.ActionID == withEligibility.ActionID || withoutEligibility.PlanHash == withEligibility.PlanHash {
t.Fatalf("auto-authorization eligibility must participate in plan identity: without=%#v with=%#v", withoutEligibility, withEligibility)
}
}
func TestExportedValidationMatchesPlanningExactly(t *testing.T) {
capabilities := []unified.ResourceCapability{{
Name: "restart",
Params: []unified.CapabilityParam{
{Name: "mode", Type: "string", Required: true, Enum: []string{"graceful", "force"}},
},
}}
// FindCapability is the planner's exact-name matcher, exported so
// proposal acceptance can never drift on resolution.
if _, ok := FindCapability(capabilities, "restart"); !ok {
t.Fatal("exact name must resolve")
}
if _, ok := FindCapability(capabilities, "Restart"); ok {
t.Fatal("case-mismatched name must not resolve; planning matches exactly")
}
if _, ok := FindCapability(capabilities, " restart "); !ok {
t.Fatal("trimmed name must resolve like planning")
}
// ValidateParams is the canonical parameter validation shared with
// proposal acceptance.
if err := ValidateParams(map[string]any{"mode": "graceful"}, capabilities[0].Params); err != nil {
t.Fatalf("valid params rejected: %v", err)
}
if err := ValidateParams(map[string]any{"mode": " "}, capabilities[0].Params); err == nil {
t.Fatal("whitespace-only required value must fail")
}
if err := ValidateParams(map[string]any{"mode": 42}, capabilities[0].Params); err == nil {
t.Fatal("wrong-typed value must fail")
}
if err := ValidateParams(map[string]any{"mode": "graceful", "extra": "x"}, capabilities[0].Params); err == nil {
t.Fatal("undeclared parameter must fail")
}
}
func TestResourceVersionIgnoresRelationshipObservationTimestamps(t *testing.T) {
base := unified.Resource{
ID: "app-container:1",
Type: unified.ResourceTypeAppContainer,
Name: "api",
Status: unified.StatusOnline,
Relationships: []unified.ResourceRelationship{{
SourceID: "app-container:1", TargetID: "docker-network:1",
Type: unified.RelationshipType("attached_to"), Confidence: 1, Active: true,
Discoverer: "docker_adapter",
ObservedAt: time.Date(2026, 7, 14, 11, 0, 0, 0, time.UTC),
LastSeenAt: time.Date(2026, 7, 14, 11, 0, 0, 0, time.UTC),
}},
}
restamped := base
restamped.Relationships = []unified.ResourceRelationship{base.Relationships[0]}
restamped.Relationships[0].ObservedAt = base.Relationships[0].ObservedAt.Add(15 * time.Second)
restamped.Relationships[0].LastSeenAt = base.Relationships[0].LastSeenAt.Add(15 * time.Second)
if ResourceVersion(base) != ResourceVersion(restamped) {
t.Fatal("resource version drifted on relationship observation restamp alone")
}
rewired := base
rewired.Relationships = []unified.ResourceRelationship{base.Relationships[0]}
rewired.Relationships[0].TargetID = "docker-network:2"
if ResourceVersion(base) == ResourceVersion(rewired) {
t.Fatal("resource version ignored a real relationship change")
}
}
func TestPlannerCarriesCapabilityRollbackSupportIntoPlan(t *testing.T) {
now := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)
resource := unified.Resource{
ID: "app-container:7",
Type: unified.ResourceTypeAppContainer,
Name: "heimdall",
Status: unified.StatusOnline,
Capabilities: []unified.ResourceCapability{
{
Name: "update",
Type: unified.CapabilityTypeCommon,
Description: "Update this Docker container",
MinimumApprovalLevel: unified.ApprovalAdmin,
SupportsRollback: true,
InternalHandler: "docker.container.update",
},
{
Name: "stop",
Type: unified.CapabilityTypeCommon,
Description: "Stop this Docker container",
MinimumApprovalLevel: unified.ApprovalAdmin,
InternalHandler: "docker.container.lifecycle",
},
},
}
actor := unified.ActionActor{SubjectID: "user:admin", Kind: unified.ActionActorUser, CredentialID: "session:test", OrgID: "default"}
planner := Planner{Now: func() time.Time { return now }}
withRollback, err := planner.Plan(unified.ActionRequest{
RequestID: "req-update", ResourceID: "app-container:7", CapabilityName: "update",
Reason: "Routine image update", Actor: actor,
}, resource)
if err != nil {
t.Fatalf("Plan(update) error = %v", err)
}
if !withRollback.RollbackAvailable {
t.Fatal("RollbackAvailable = false for a capability that declares rollback support")
}
withoutRollback, err := planner.Plan(unified.ActionRequest{
RequestID: "req-stop", ResourceID: "app-container:7", CapabilityName: "stop",
Reason: "Stop for maintenance", Actor: actor,
}, resource)
if err != nil {
t.Fatalf("Plan(stop) error = %v", err)
}
if withoutRollback.RollbackAvailable {
t.Fatal("RollbackAvailable = true for a capability with no declared rollback support")
}
}