Files
pulse/internal/api/actions_projection_ceremony_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

93 lines
3.5 KiB
Go

package api
import (
"encoding/json"
"testing"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
// The review dialog gates its single-confirmation control on the projected
// capability class and renders blast-radius names from the projection. Both
// are read-time presentation and must never enter the persisted plan identity.
func TestContract_ActionReadProjectionCarriesCapabilityClassAndBlastRadiusPresentation(t *testing.T) {
projection := actionAuditProjection{
ActionAuditRecord: unifiedresources.ActionAuditRecord{
ID: "action-ceremony-contract",
State: unifiedresources.ActionStatePending,
Request: unifiedresources.ActionRequest{
RequestID: "request-ceremony-contract",
ResourceID: "app-container:7",
CapabilityName: "update",
Reason: "Routine image update",
},
Plan: unifiedresources.ActionPlan{
ActionID: "action-ceremony-contract",
PlanHash: "sha256:stable-plan-identity",
PredictedBlastRadius: []string{"app-container:7", "agent:host-1"},
},
},
CapabilityAutoAuthorization: unifiedresources.AutoAuthorizeLowRisk,
BlastRadius: []actionResourcePresentation{
{ID: "app-container:7", Name: "heimdall", Type: unifiedresources.ResourceTypeAppContainer},
{ID: "agent:host-1"},
},
}
encoded, err := json.Marshal(projection)
if err != nil {
t.Fatalf("marshal action read projection: %v", err)
}
var wire map[string]any
if err := json.Unmarshal(encoded, &wire); err != nil {
t.Fatalf("decode action read projection: %v", err)
}
if wire["capabilityAutoAuthorization"] != "low_risk" {
t.Fatalf("capabilityAutoAuthorization = %#v", wire["capabilityAutoAuthorization"])
}
blast, ok := wire["blastRadius"].([]any)
if !ok || len(blast) != 2 {
t.Fatalf("blastRadius = %#v", wire["blastRadius"])
}
named, ok := blast[0].(map[string]any)
if !ok || named["id"] != "app-container:7" || named["name"] != "heimdall" {
t.Fatalf("named blast radius entry = %#v", blast[0])
}
plan, ok := wire["plan"].(map[string]any)
if !ok || plan["planHash"] != "sha256:stable-plan-identity" {
t.Fatalf("plan identity changed in read projection: %#v", wire["plan"])
}
radius, ok := plan["predictedBlastRadius"].([]any)
if !ok || len(radius) != 2 || radius[0] != "app-container:7" {
t.Fatalf("persisted blast radius IDs changed: %#v", plan["predictedBlastRadius"])
}
}
// Without a registry the projection must still return every blast-radius ID
// so the review surface can fall back to raw identifiers.
func TestProjectActionAuditFallsBackToRawBlastRadiusIDsWithoutRegistry(t *testing.T) {
record := unifiedresources.ActionAuditRecord{
ID: "action-no-registry",
Request: unifiedresources.ActionRequest{
ResourceID: "app-container:7",
CapabilityName: "update",
},
Plan: unifiedresources.ActionPlan{
PredictedBlastRadius: []string{"app-container:7", "docker-network:9"},
},
}
projection := projectActionAudit(record, nil)
if projection.Resource != nil {
t.Fatalf("resource presentation resolved without a registry: %#v", projection.Resource)
}
if projection.CapabilityAutoAuthorization != "" {
t.Fatalf("capability class resolved without a registry: %q", projection.CapabilityAutoAuthorization)
}
if len(projection.BlastRadius) != 2 || projection.BlastRadius[0].ID != "app-container:7" || projection.BlastRadius[1].ID != "docker-network:9" {
t.Fatalf("blast radius fallback = %#v", projection.BlastRadius)
}
if projection.BlastRadius[0].Name != "" {
t.Fatalf("unresolvable entry gained a name: %#v", projection.BlastRadius[0])
}
}