mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 11:46:28 +00:00
f73d4d4d97
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.
410 lines
16 KiB
Go
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")
|
|
}
|
|
}
|