diff --git a/internal/unifiedresources/action_dispatch_bind_branchcov0723am_test.go b/internal/unifiedresources/action_dispatch_bind_branchcov0723am_test.go new file mode 100644 index 000000000..3f56d43cc --- /dev/null +++ b/internal/unifiedresources/action_dispatch_bind_branchcov0723am_test.go @@ -0,0 +1,458 @@ +package unifiedresources + +import ( + "strings" + "testing" + "time" +) + +// Branch/function coverage tests for three previously-uncovered (0.0%) PURE +// functions: +// - (ActionDispatchAttempt).HasOperationBinding() bool [action_dispatch.go:45] +// - BindActionDispatchAttempt(ActionDispatchAttempt, ActionDispatchBinding) (ActionDispatchAttempt, error) +// [action_dispatch.go:132] +// - EmptyResourcePolicyPostureSummary() *ResourcePolicyPostureSummary [policy_posture.go:74] +// +// Each subtest drives a concrete branch/return path and asserts the concrete +// output value or error. No source file or pre-existing test is modified. +// +// Conventions (package clause, table-driven subtests, in-package construction +// of inputs, t.Fatalf/t.Errorf assertions) mirror the sibling +// action_dispatch_store_test.go and the recent *_branchcov*_test.go files in +// this directory. + +// validDispatchBase returns a known-good ActionDispatchAttempt built through +// the public constructor so every field is canonical; callers copy it and +// mutate the specific field each failure arm needs. +func validDispatchBase(t *testing.T, actionID string, now time.Time) ActionDispatchAttempt { + t.Helper() + a, err := NewActionDispatchAttempt(actionID, now) + if err != nil { + t.Fatalf("NewActionDispatchAttempt(%q) unexpected error: %v", actionID, err) + } + return a +} + +// --------------------------------------------------------------------------- +// HasOperationBinding +// --------------------------------------------------------------------------- + +// TestBranchcov0723Am_HasOperationBinding drives both arms of every +// short-circuited conditional in HasOperationBinding (four conditions, eight +// arms) plus the strings.TrimSpace behaviour on the three string fields. +func TestBranchcov0723Am_HasOperationBinding(t *testing.T) { + cases := []struct { + name string + a ActionDispatchAttempt + want bool + }{ + { + // First condition's false arm: OperationKind trims to empty, + // short-circuits before any other condition is evaluated. + name: "ZeroValueReturnsFalse", + a: ActionDispatchAttempt{}, + want: false, + }, + { + // First condition's true arm + second condition's false arm: + // OperationKind is set, OperationVersion is zero. + name: "OnlyOperationKindSetShortCircuitsBeforeVersion", + a: ActionDispatchAttempt{OperationKind: "patch"}, + want: false, + }, + { + // Second condition's true arm + third condition's false arm. + name: "KindAndVersionSetShortCircuitsBeforeDigest", + a: ActionDispatchAttempt{OperationKind: "patch", OperationVersion: 3}, + want: false, + }, + { + // Third condition's true arm + fourth condition's false arm. + name: "KindVersionDigestSetShortCircuitsBeforeAgentID", + a: ActionDispatchAttempt{OperationKind: "patch", OperationVersion: 3, RequestDigest: "sha256:abc"}, + want: false, + }, + { + // All four conditions' true arms -> the only path returning true. + name: "AllFieldsSetReturnsTrue", + a: ActionDispatchAttempt{ + OperationKind: "patch", + OperationVersion: 3, + RequestDigest: "sha256:abc", + AgentID: "agent-7", + }, + want: true, + }, + { + // Drives the strings.TrimSpace call on OperationKind: a + // whitespace-only OperationKind with every other field valid + // must still return false. Without TrimSpace the first + // condition would be true and the function would return true. + name: "WhitespaceOperationKindTrimsToFalseDespiteOthersValid", + a: ActionDispatchAttempt{ + OperationKind: " ", + OperationVersion: 3, + RequestDigest: "sha256:abc", + AgentID: "agent-7", + }, + want: false, + }, + { + // Drives the strings.TrimSpace call on RequestDigest. + name: "WhitespaceRequestDigestTrimsToFalseDespiteOthersValid", + a: ActionDispatchAttempt{ + OperationKind: "patch", + OperationVersion: 3, + RequestDigest: "\t ", + AgentID: "agent-7", + }, + want: false, + }, + { + // Drives the strings.TrimSpace call on AgentID. + name: "WhitespaceAgentIDTrimsToFalseDespiteOthersValid", + a: ActionDispatchAttempt{ + OperationKind: "patch", + OperationVersion: 3, + RequestDigest: "sha256:abc", + AgentID: " ", + }, + want: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if got := tc.a.HasOperationBinding(); got != tc.want { + t.Fatalf("HasOperationBinding() = %v, want %v (attempt=%+v)", got, tc.want, tc.a) + } + }) + } +} + +// --------------------------------------------------------------------------- +// BindActionDispatchAttempt +// --------------------------------------------------------------------------- + +// TestBranchcov0723Am_BindActionDispatchAttempt covers every validation +// failure arm surfaced through BindActionDispatchAttempt (which delegates to +// NormalizeActionDispatchAttempt), the success path asserting every field the +// binding writes, value-semantics (input is not mutated), rebinding +// (overwrite vs error), and empty/whitespace identifiers in the binding. +func TestBranchcov0723Am_BindActionDispatchAttempt(t *testing.T) { + now := time.Date(2026, 7, 23, 9, 0, 0, 0, time.UTC) + + fullBinding := ActionDispatchBinding{ + OperationKind: "patch", + OperationVersion: 3, + RequestDigest: "sha256:full", + AgentID: "agent-7", + } + + t.Run("Success/WritesAllBindingFieldsAndPreservesAttemptIdentity", func(t *testing.T) { + base := validDispatchBase(t, "act-success", now) + // Base carries no binding to prove the bind writes the fields. + if base.HasOperationBinding() { + t.Fatalf("precondition: base must have no binding, got %+v", base) + } + + got, err := BindActionDispatchAttempt(base, fullBinding) + if err != nil { + t.Fatalf("BindActionDispatchAttempt unexpected error: %v", err) + } + // Every field the binding writes must land on the returned attempt. + if got.OperationKind != fullBinding.OperationKind { + t.Errorf("OperationKind = %q, want %q", got.OperationKind, fullBinding.OperationKind) + } + if got.OperationVersion != fullBinding.OperationVersion { + t.Errorf("OperationVersion = %d, want %d", got.OperationVersion, fullBinding.OperationVersion) + } + if got.RequestDigest != fullBinding.RequestDigest { + t.Errorf("RequestDigest = %q, want %q", got.RequestDigest, fullBinding.RequestDigest) + } + if got.AgentID != fullBinding.AgentID { + t.Errorf("AgentID = %q, want %q", got.AgentID, fullBinding.AgentID) + } + // The binding is now complete, so HasOperationBinding must agree. + if !got.HasOperationBinding() { + t.Fatalf("expected HasOperationBinding() true after successful bind, got %+v", got) + } + // Identity / lifecycle fields the bind must NOT touch are preserved. + if got.ID != base.ID { + t.Errorf("ID = %q, want %q (bind must not change identity)", got.ID, base.ID) + } + if got.ActionID != base.ActionID { + t.Errorf("ActionID = %q, want %q", got.ActionID, base.ActionID) + } + if got.State != base.State { + t.Errorf("State = %q, want %q", got.State, base.State) + } + if !got.CreatedAt.Equal(base.CreatedAt) { + t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, base.CreatedAt) + } + }) + + t.Run("Success/InputAttemptIsNotMutatedValueSemantics", func(t *testing.T) { + base := validDispatchBase(t, "act-valuesemantics", now) + + got, err := BindActionDispatchAttempt(base, fullBinding) + if err != nil { + t.Fatalf("BindActionDispatchAttempt unexpected error: %v", err) + } + // The returned attempt must actually differ (proving the bind ran). + if got == base { + t.Fatalf("returned attempt identical to input; bind did not write binding fields") + } + if !got.HasOperationBinding() || base.HasOperationBinding() { + t.Fatalf("value-semantics drift: got.HasOperationBinding=%v base.HasOperationBinding=%v", got.HasOperationBinding(), base.HasOperationBinding()) + } + }) + + t.Run("Success/RebindingOverwritesPreviousBindingWithoutError", func(t *testing.T) { + // First bind establishes an "old" binding on the attempt. + base := validDispatchBase(t, "act-rebind", now) + first, err := BindActionDispatchAttempt(base, ActionDispatchBinding{ + OperationKind: "create", OperationVersion: 1, + RequestDigest: "sha256:old", AgentID: "agent-old", + }) + if err != nil { + t.Fatalf("first bind unexpected error: %v", err) + } + if first.AgentID != "agent-old" { + t.Fatalf("precondition: first bind did not write AgentID, got %q", first.AgentID) + } + + // Re-binding with a new binding must overwrite cleanly (no error, + // no "already bound" rejection) and the result carries the new + // fields, not the old ones. + rebound, err := BindActionDispatchAttempt(first, fullBinding) + if err != nil { + t.Fatalf("rebind returned error (expected overwrite): %v", err) + } + if rebound.OperationKind != fullBinding.OperationKind || + rebound.OperationVersion != fullBinding.OperationVersion || + rebound.RequestDigest != fullBinding.RequestDigest || + rebound.AgentID != fullBinding.AgentID { + t.Fatalf("rebind did not overwrite every field, got %+v", rebound) + } + // The previous binding value must not linger anywhere. + if rebound.AgentID == "agent-old" { + t.Fatalf("rebind left stale AgentID from previous binding: %+v", rebound) + } + }) + + t.Run("Failure/EmptyActionID", func(t *testing.T) { + // ActionID=="" short-circuits before the ID/State/CreatedAt checks. + attempt := ActionDispatchAttempt{ + ActionID: "", State: ActionDispatchQueued, CreatedAt: now, + } + _, err := BindActionDispatchAttempt(attempt, fullBinding) + if err == nil { + t.Fatal("expected error for empty ActionID, got nil") + } + if !strings.Contains(err.Error(), "action dispatch action id required") { + t.Fatalf("error = %q, want substring %q", err.Error(), "action dispatch action id required") + } + }) + + t.Run("Failure/MismatchedAttemptID", func(t *testing.T) { + // ActionID is valid but ID is not the canonical + // ActionDispatchAttemptID(ActionID) form. + attempt := ActionDispatchAttempt{ + ID: "wrong.dispatch.1", ActionID: "act-mismatch", + State: ActionDispatchQueued, CreatedAt: now, + } + _, err := BindActionDispatchAttempt(attempt, fullBinding) + if err == nil { + t.Fatal("expected error for mismatched ID, got nil") + } + if !strings.Contains(err.Error(), "does not match action") { + t.Fatalf("error = %q, want substring %q", err.Error(), "does not match action") + } + }) + + t.Run("Failure/UnsupportedState", func(t *testing.T) { + // Valid identity + CreatedAt, but State is not one of the + // supported ActionDispatchState constants. + attempt := ActionDispatchAttempt{ + ActionID: "act-badstate", State: ActionDispatchState("bogus"), + CreatedAt: now, + } + _, err := BindActionDispatchAttempt(attempt, fullBinding) + if err == nil { + t.Fatal("expected error for unsupported state, got nil") + } + if !strings.Contains(err.Error(), "unsupported action dispatch state") { + t.Fatalf("error = %q, want substring %q", err.Error(), "unsupported action dispatch state") + } + }) + + t.Run("Failure/ZeroCreatedAt", func(t *testing.T) { + // Valid identity + valid state, but CreatedAt is the zero Time. + attempt := ActionDispatchAttempt{ + ActionID: "act-nocreated", State: ActionDispatchQueued, + } + _, err := BindActionDispatchAttempt(attempt, fullBinding) + if err == nil { + t.Fatal("expected error for zero CreatedAt, got nil") + } + if !strings.Contains(err.Error(), "createdAt required") { + t.Fatalf("error = %q, want substring %q", err.Error(), "createdAt required") + } + }) + + t.Run("Failure/NegativeDispatchCount", func(t *testing.T) { + base := validDispatchBase(t, "act-negativecount", now) + base.DispatchCount = -1 + _, err := BindActionDispatchAttempt(base, fullBinding) + if err == nil { + t.Fatal("expected error for negative DispatchCount, got nil") + } + if !strings.Contains(err.Error(), "action dispatch count cannot be negative") { + t.Fatalf("error = %q, want substring %q", err.Error(), "action dispatch count cannot be negative") + } + }) + + t.Run("Failure/IncompleteBindingWithOnlyOperationKind", func(t *testing.T) { + // Binding sets OperationKind but leaves OperationVersion at zero: + // Normalize sees bound=true and incomplete=true and rejects. + base := validDispatchBase(t, "act-incomplete", now) + partial := ActionDispatchBinding{ + OperationKind: "patch", + // OperationVersion intentionally zero. + RequestDigest: "sha256:x", + AgentID: "agent-x", + } + _, err := BindActionDispatchAttempt(base, partial) + if err == nil { + t.Fatal("expected error for incomplete binding, got nil") + } + if !strings.Contains(err.Error(), "action dispatch operation binding is incomplete") { + t.Fatalf("error = %q, want substring %q", err.Error(), "action dispatch operation binding is incomplete") + } + }) + + t.Run("EmptyAndWhitespaceBinding/AllWhitespaceTrimsToUnboundOnValidAttempt", func(t *testing.T) { + // Every binding field is whitespace/zero. After BindActionDispatchAttempt + // assigns them, Normalize trims the strings to empty; with all four + // empty, `bound` is false and the incomplete check is skipped, so the + // call succeeds and the result has no binding. + base := validDispatchBase(t, "act-emptybinding", now) + whitespaceBinding := ActionDispatchBinding{ + OperationKind: " ", + OperationVersion: 0, + RequestDigest: "\t", + AgentID: " ", + } + got, err := BindActionDispatchAttempt(base, whitespaceBinding) + if err != nil { + t.Fatalf("expected success for all-whitespace binding on valid attempt, got error: %v", err) + } + if got.OperationKind != "" || got.RequestDigest != "" || got.AgentID != "" || got.OperationVersion != 0 { + t.Fatalf("expected trimmed-to-empty binding fields, got %+v", got) + } + if got.HasOperationBinding() { + t.Fatalf("expected HasOperationBinding() false after whitespace binding, got %+v", got) + } + }) + + t.Run("EmptyAndWhitespaceBinding/WhitespaceAgentIDTriggersIncompleteAfterTrim", func(t *testing.T) { + // AgentID is whitespace-only while the other three fields are valid: + // after assignment the attempt is "bound" (kind/version/digest set), + // but Normalize trims AgentID to empty, making it incomplete, so the + // call is rejected. This proves the trim happens before the + // completeness check. + base := validDispatchBase(t, "act-wsagent", now) + wsAgentBinding := ActionDispatchBinding{ + OperationKind: "patch", + OperationVersion: 3, + RequestDigest: "sha256:x", + AgentID: " ", + } + _, err := BindActionDispatchAttempt(base, wsAgentBinding) + if err == nil { + t.Fatal("expected incomplete-binding error after trimming whitespace AgentID, got nil") + } + if !strings.Contains(err.Error(), "action dispatch operation binding is incomplete") { + t.Fatalf("error = %q, want substring %q", err.Error(), "action dispatch operation binding is incomplete") + } + }) +} + +// --------------------------------------------------------------------------- +// EmptyResourcePolicyPostureSummary +// --------------------------------------------------------------------------- + +// TestBranchcov0723Am_EmptyResourcePolicyPostureSummary covers the canonical +// empty-contract constructor: every field is zero with non-nil empty maps +// (NormalizeCollections replaces nil maps with allocated empty ones), and two +// calls return independent pointers whose internal maps do not alias. +func TestBranchcov0723Am_EmptyResourcePolicyPostureSummary(t *testing.T) { + t.Run("ReturnsZeroTotalWithNonNullEmptyMaps", func(t *testing.T) { + got := EmptyResourcePolicyPostureSummary() + if got == nil { + t.Fatal("expected non-nil ResourcePolicyPostureSummary, got nil") + } + if got.TotalResources != 0 { + t.Fatalf("TotalResources = %d, want 0", got.TotalResources) + } + // NormalizeCollections must allocate empty maps for each nil map; + // asserting non-nil + len 0 proves every conditional's "set to empty" + // arm ran. + if got.SensitivityCounts == nil { + t.Fatal("expected non-nil SensitivityCounts map, got nil") + } + if len(got.SensitivityCounts) != 0 { + t.Fatalf("len(SensitivityCounts) = %d, want 0", len(got.SensitivityCounts)) + } + if got.RoutingCounts == nil { + t.Fatal("expected non-nil RoutingCounts map, got nil") + } + if len(got.RoutingCounts) != 0 { + t.Fatalf("len(RoutingCounts) = %d, want 0", len(got.RoutingCounts)) + } + if got.RedactionCounts == nil { + t.Fatal("expected non-nil RedactionCounts map, got nil") + } + if len(got.RedactionCounts) != 0 { + t.Fatalf("len(RedactionCounts) = %d, want 0", len(got.RedactionCounts)) + } + }) + + t.Run("TwoCallsReturnDistinctPointers", func(t *testing.T) { + first := EmptyResourcePolicyPostureSummary() + second := EmptyResourcePolicyPostureSummary() + // Each call constructs a brand-new struct (&ResourcePolicyPostureSummary{}) + // before normalising, so the returned pointers must differ. + if first == second { + t.Fatalf("two calls returned the same pointer %p; constructor must allocate per call", first) + } + }) + + t.Run("MapsAreIndependentAcrossCalls", func(t *testing.T) { + // Mutating the maps returned by one call must not affect the maps + // returned by another call — proves the maps themselves do not alias. + first := EmptyResourcePolicyPostureSummary() + second := EmptyResourcePolicyPostureSummary() + + first.SensitivityCounts[ResourceSensitivityPublic] = 42 + first.RoutingCounts[ResourceRoutingScopeLocalOnly] = 7 + first.RedactionCounts[ResourceRedactionHostname] = 99 + + if got := second.SensitivityCounts[ResourceSensitivityPublic]; got != 0 { + t.Fatalf("SensitivityCounts aliasing detected: second call sees %d after mutating first", got) + } + if got := second.RoutingCounts[ResourceRoutingScopeLocalOnly]; got != 0 { + t.Fatalf("RoutingCounts aliasing detected: second call sees %d after mutating first", got) + } + if got := second.RedactionCounts[ResourceRedactionHostname]; got != 0 { + t.Fatalf("RedactionCounts aliasing detected: second call sees %d after mutating first", got) + } + // The pointer's own TotalResources field is also independent. + first.TotalResources = 1234 + if second.TotalResources != 0 { + t.Fatalf("TotalResources aliasing detected: second call sees %d after mutating first", second.TotalResources) + } + }) +} diff --git a/internal/unifiedresources/memorystore_actions_branchcov0723am_test.go b/internal/unifiedresources/memorystore_actions_branchcov0723am_test.go new file mode 100644 index 000000000..17ffdd0d9 --- /dev/null +++ b/internal/unifiedresources/memorystore_actions_branchcov0723am_test.go @@ -0,0 +1,1316 @@ +package unifiedresources + +import ( + "errors" + "strings" + "testing" + "time" +) + +// Branch-coverage tests for currently-0.0%-covered MemoryStore methods in +// internal/unifiedresources: +// - MemoryStore.RecordActionExpiry (action_dispatch_store.go) +// - MemoryStore.GetActionDispatchReceipt (action_dispatch_store.go) +// - MemoryStore.RecordActionDispatchCompletion (action_dispatch_store.go) +// - MemoryStore.ExpireActionAudits (action_dispatch_store.go) +// - MemoryStore.RecordActionExecutionRefusal (store.go) +// - MemoryStore.RecordActionLifecycleEvent (store.go) +// - MemoryStore.RecordActionPolicyExecutionAdmission (action_dispatch_store.go) +// +// Every subtest constructs its OWN MemoryStore so it passes when run alone via +// -run. The constructor, record, and event helpers reused here +// (NewMemoryStore, atomicLifecycleTestRecord, atomicLifecycleInitialEvents, +// admitDispatchTestAction, testBoundActionApproval) come from sibling _test.go +// files in this same package. + +// branchcov0723amNow is the deterministic "current time" used across these +// subtests so expiry comparisons can be reasoned about by hand. +var branchcov0723amNow = time.Date(2026, 7, 23, 9, 0, 0, 0, time.UTC) + +// branchcov0723amFutureRecord returns an audit record whose Plan.ExpiresAt is +// 2h AFTER branchcov0723amNow. This is required by helpers like +// BeginPolicyActionExecution and ApplyActionDecision, which call +// ValidateActionExecutionStart and reject any record whose ExpiresAt is not +// strictly in the future relative to the "now" they are invoked with. Using a +// record whose ExpiresAt equals branchcov0723amNow + 2h lets callers pass +// branchcov0723amNow (or any value up to +2h) as the decision/admission time. +func branchcov0723amFutureRecord(id string, state ActionState) ActionAuditRecord { + r := atomicLifecycleTestRecord(id, state) + r.CreatedAt = branchcov0723amNow + r.UpdatedAt = branchcov0723amNow + r.Plan.PlannedAt = branchcov0723amNow + r.Plan.ExpiresAt = branchcov0723amNow.Add(2 * time.Hour) + return r +} + +// branchcov0723amLeaseFor builds a ValidateActionPolicyAuthorizationLease-valid +// lease for record. Every field ValidateActionPolicyAuthorizationLease inspects +// is sourced from the record (so BeginPolicyActionExecution accepts it); the +// digest is recomputed last via the canonical helper. +func branchcov0723amLeaseFor(record ActionAuditRecord, now time.Time) ActionPolicyAuthorizationLease { + lease := ActionPolicyAuthorizationLease{ + Version: 1, + OrgID: record.Request.Actor.OrgID, + ActionID: record.ID, + ResourceID: CanonicalResourceID(record.Request.ResourceID), + CapabilityName: strings.TrimSpace(record.Request.CapabilityName), + PlanHash: record.Plan.PlanHash, + CapabilityPolicyVersion: record.Plan.PolicyVersion, + TenantPolicyVersion: "tenant:branchcov0723am", + ResourcePolicyVersion: "resource:branchcov0723am", + LicenseAllowsAutoFix: true, + IssuedAt: now, + ExpiresAt: now.Add(time.Hour), + } + lease.Digest = ActionPolicyAuthorizationDigest(lease) + return lease +} + +// branchcov0723amAdmitToReceiptPending drives an action all the way through +// admission, claim, and MarkActionDispatchStarted so that the attempt ends in +// ActionDispatchReceiptPending and the audit ends in ActionStateExecuting. It +// returns the persisted attempt ID and the executing audit so callers can build +// the terminal (Completed/Failed) record/event pair for completion tests. +func branchcov0723amAdmitToReceiptPending(t *testing.T, store *MemoryStore, id string, now time.Time) (ActionDispatchAttempt, ActionAuditRecord) { + t.Helper() + attempt := admitDispatchTestAction(t, store, id, now) + if _, claimed, err := store.ClaimActionDispatch(id, "worker", now, time.Minute); err != nil || !claimed { + t.Fatalf("ClaimActionDispatch claimed=%v err=%v", claimed, err) + } + if _, err := store.MarkActionDispatchStarted(attempt.ID, "worker", now); err != nil { + t.Fatalf("MarkActionDispatchStarted: %v", err) + } + executing, found, err := store.GetActionAudit(id) + if err != nil || !found || executing.State != ActionStateExecuting { + t.Fatalf("executing audit not in executing state: found=%v err=%v state=%q", found, err, executing.State) + } + started, startedFound, err := store.GetActionDispatchAttempt(id) + if err != nil || !startedFound || started.State != ActionDispatchReceiptPending { + t.Fatalf("attempt not receipt_pending: found=%v state=%q err=%v", startedFound, started.State, err) + } + return started, executing +} + +// --------------------------------------------------------------------------- +// MemoryStore.RecordActionExpiry +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_RecordActionExpiry(t *testing.T) { + // happyPlannedToExpired: audit currently Planned -> Expired stored. + t.Run("happy_planned_to_expired", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-expiry-planned", ActionStatePlanned) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + expired, event, err := ExpireAction(record, "system:expiry", branchcov0723amNow.Add(time.Hour)) + if err != nil { + t.Fatalf("ExpireAction: %v", err) + } + if err := store.RecordActionExpiry(expired, event); err != nil { + t.Fatalf("RecordActionExpiry: %v", err) + } + got, found, err := store.GetActionAudit(record.ID) + if err != nil || !found { + t.Fatalf("GetActionAudit found=%v err=%v", found, err) + } + if got.State != ActionStateExpired { + t.Fatalf("state=%q want %q", got.State, ActionStateExpired) + } + if !got.UpdatedAt.Equal(branchcov0723amNow.Add(time.Hour)) { + t.Fatalf("UpdatedAt=%v want %v", got.UpdatedAt, branchcov0723amNow.Add(time.Hour)) + } + // The expiry lifecycle event must be persisted too. + events, err := store.GetActionLifecycleEvents(record.ID, time.Time{}, 10) + if err != nil { + t.Fatalf("GetActionLifecycleEvents: %v", err) + } + var sawExpired bool + for _, e := range events { + if e.State == ActionStateExpired { + sawExpired = true + } + } + if !sawExpired { + t.Fatalf("expiry lifecycle event was not appended: %#v", events) + } + }) + + // happyPendingToExpired: audit currently Pending -> Expired stored. + t.Run("happy_pending_to_expired", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-expiry-pending", ActionStatePending) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + expired, event, err := ExpireAction(record, "system:expiry", branchcov0723amNow.Add(2*time.Hour)) + if err != nil { + t.Fatalf("ExpireAction: %v", err) + } + if err := store.RecordActionExpiry(expired, event); err != nil { + t.Fatalf("RecordActionExpiry: %v", err) + } + got, _, _ := store.GetActionAudit(record.ID) + if got.State != ActionStateExpired { + t.Fatalf("state=%q want %q", got.State, ActionStateExpired) + } + }) + + // happyApprovedToExpired: audit currently Approved -> Expired stored. + t.Run("happy_approved_to_expired", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-expiry-approved", ActionStateApproved) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + expired, event, err := ExpireAction(record, "system:expiry", branchcov0723amNow.Add(3*time.Hour)) + if err != nil { + t.Fatalf("ExpireAction: %v", err) + } + if err := store.RecordActionExpiry(expired, event); err != nil { + t.Fatalf("RecordActionExpiry: %v", err) + } + got, _, _ := store.GetActionAudit(record.ID) + if got.State != ActionStateExpired { + t.Fatalf("state=%q want %q", got.State, ActionStateExpired) + } + }) + + // conflictWhenCurrentlyExecuting: default arm -> ErrActionExecutionFinal is + // the fallback from actionTransitionConflict for an Executing current state + // when desired is Expired. + t.Run("conflict_when_currently_executing", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-expiry-conflict-exec", ActionStateExecuting) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + // Build a hypothetical Expired transition for the same id (does not + // need to be produced by ExpireAction — RecordActionExpiry only + // requires normalization + matching id). + expiredRecord := record + expiredRecord.State = ActionStateExpired + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: branchcov0723amNow, State: ActionStateExpired, Actor: "system:expiry"} + err := store.RecordActionExpiry(expiredRecord, event) + if !errors.Is(err, ErrActionNotExecuting) { + t.Fatalf("err=%v want %v", err, ErrActionNotExecuting) + } + // Audit must be unchanged. + got, _, _ := store.GetActionAudit(record.ID) + if got.State != ActionStateExecuting { + t.Fatalf("state was mutated: %q", got.State) + } + }) + + // conflictWhenCurrentlyCompleted: terminal current state. + t.Run("conflict_when_currently_completed", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-expiry-conflict-done", ActionStateCompleted) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + expiredRecord := record + expiredRecord.State = ActionStateExpired + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: branchcov0723amNow, State: ActionStateExpired, Actor: "system:expiry"} + err := store.RecordActionExpiry(expiredRecord, event) + if !errors.Is(err, ErrActionExecutionFinal) { + t.Fatalf("err=%v want %v", err, ErrActionExecutionFinal) + } + }) + + // notFound: pass a record whose audit was never created. + t.Run("not_found", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-expiry-missing", ActionStatePlanned) + expiredRecord := record + expiredRecord.State = ActionStateExpired + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: branchcov0723amNow, State: ActionStateExpired, Actor: "system:expiry"} + err := store.RecordActionExpiry(expiredRecord, event) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v want 'not found'", err) + } + }) + + // invalidRecordEmptyID: NormalizeActionAuditRecord fails before any locking. + t.Run("invalid_record_empty_id", func(t *testing.T) { + store := NewMemoryStore() + bad := ActionAuditRecord{State: ActionStateExpired} + event := ActionLifecycleEvent{ActionID: "anything", Timestamp: branchcov0723amNow, State: ActionStateExpired} + err := store.RecordActionExpiry(bad, event) + if err == nil { + t.Fatal("expected normalization error for empty record id") + } + }) + + // invalidEventEmptyActionID: NormalizeActionLifecycleEvent fails after the + // record normalizes successfully. + t.Run("invalid_event_empty_action_id", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-expiry-bad-event", ActionStateExpired) + badEvent := ActionLifecycleEvent{Timestamp: branchcov0723amNow, State: ActionStateExpired} + err := store.RecordActionExpiry(record, badEvent) + if err == nil { + t.Fatal("expected normalization error for empty event action id") + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.GetActionDispatchReceipt +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_GetActionDispatchReceipt(t *testing.T) { + // emptyStoreReturnsNotFound: zero-value receipt, ok=false, err=nil. + t.Run("empty_store_returns_not_found", func(t *testing.T) { + store := NewMemoryStore() + got, ok, err := store.GetActionDispatchReceipt("anything.dispatch.1") + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if ok { + t.Fatal("ok=true want false") + } + if got.AttemptID != "" || got.ActionID != "" || got.TransportRequestID != "" { + t.Fatalf("zero-value expected, got %#v", got) + } + }) + + // returnsPersistedReceipt: drive the full completion path, then read back + // the persisted receipt and assert its concrete values. + t.Run("returns_persisted_receipt", func(t *testing.T) { + store := NewMemoryStore() + now := branchcov0723amNow + attempt, executing := branchcov0723amAdmitToReceiptPending(t, store, "act-receipt-get", now) + completed, event, err := CompleteActionExecution(executing, &ExecutionResult{Success: true, Output: "ok"}, "operator", now.Add(time.Second)) + if err != nil { + t.Fatalf("CompleteActionExecution: %v", err) + } + receipt := ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: "act-receipt-get", TransportRequestID: attempt.ID, ReceivedAt: now.Add(time.Second)} + if err := store.RecordActionDispatchCompletion(receipt, completed, event); err != nil { + t.Fatalf("RecordActionDispatchCompletion: %v", err) + } + got, ok, err := store.GetActionDispatchReceipt(attempt.ID) + if err != nil || !ok { + t.Fatalf("GetActionDispatchReceipt ok=%v err=%v", ok, err) + } + if got.AttemptID != attempt.ID || got.ActionID != "act-receipt-get" || got.TransportRequestID != attempt.ID { + t.Fatalf("receipt=%#v", got) + } + if !got.ReceivedAt.Equal(now.Add(time.Second)) { + t.Fatalf("ReceivedAt=%v want %v", got.ReceivedAt, now.Add(time.Second)) + } + }) + + // whitespaceAttemptIDStillMatches: TrimSpace path on the lookup key. + t.Run("whitespace_attempt_id_still_matches", func(t *testing.T) { + store := NewMemoryStore() + now := branchcov0723amNow + attempt, executing := branchcov0723amAdmitToReceiptPending(t, store, "act-receipt-ws", now) + completed, event, err := CompleteActionExecution(executing, &ExecutionResult{Success: true}, "operator", now.Add(time.Second)) + if err != nil { + t.Fatalf("CompleteActionExecution: %v", err) + } + receipt := ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: "act-receipt-ws", TransportRequestID: attempt.ID, ReceivedAt: now.Add(time.Second)} + if err := store.RecordActionDispatchCompletion(receipt, completed, event); err != nil { + t.Fatalf("RecordActionDispatchCompletion: %v", err) + } + got, ok, err := store.GetActionDispatchReceipt(" " + attempt.ID + " ") + if err != nil || !ok || got.AttemptID != attempt.ID { + t.Fatalf("whitespace lookup: got=%#v ok=%v err=%v", got, ok, err) + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.RecordActionDispatchCompletion +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_RecordActionDispatchCompletion(t *testing.T) { + now := branchcov0723amNow + + // happyPathNoExistingReceipt: attempt in ReceiptPending, no receipt yet, + // audit Executing -> success and persisted state. + t.Run("happy_no_existing_receipt", func(t *testing.T) { + store := NewMemoryStore() + attempt, executing := branchcov0723amAdmitToReceiptPending(t, store, "act-completion-happy", now) + completed, event, err := CompleteActionExecution(executing, &ExecutionResult{Success: true, Output: "ok"}, "operator", now.Add(time.Second)) + if err != nil { + t.Fatalf("CompleteActionExecution: %v", err) + } + receipt := ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: "act-completion-happy", TransportRequestID: attempt.ID, ReceivedAt: now.Add(time.Second)} + if err := store.RecordActionDispatchCompletion(receipt, completed, event); err != nil { + t.Fatalf("RecordActionDispatchCompletion: %v", err) + } + gotAttempt, found, err := store.GetActionDispatchAttempt("act-completion-happy") + if err != nil || !found || gotAttempt.State != ActionDispatchReceiptRecorded { + t.Fatalf("attempt=%#v found=%v err=%v", gotAttempt, found, err) + } + gotReceipt, found, err := store.GetActionDispatchReceipt(attempt.ID) + if err != nil || !found || gotReceipt.TransportRequestID != attempt.ID { + t.Fatalf("receipt=%#v found=%v err=%v", gotReceipt, found, err) + } + gotAudit, _, _ := store.GetActionAudit("act-completion-happy") + if gotAudit.State != ActionStateCompleted || gotAudit.Result == nil || !gotAudit.Result.Success { + t.Fatalf("audit=%#v", gotAudit) + } + }) + + // happyPathExistingReceipt: persist the receipt first via + // RecordActionDispatchReceipt, then call completion. The completion path + // takes the receiptExists && matching-transport && state==ReceiptRecorded + // branch and succeeds. + t.Run("happy_existing_receipt_matching_transport", func(t *testing.T) { + store := NewMemoryStore() + attempt, executing := branchcov0723amAdmitToReceiptPending(t, store, "act-completion-existing", now) + // Move the attempt from ReceiptPending to ReceiptRecorded by recording + // the receipt once. This also stores the receipt row. + _, err := store.RecordActionDispatchReceipt(ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: "act-completion-existing", TransportRequestID: attempt.ID, ReceivedAt: now.Add(time.Second)}) + if err != nil { + t.Fatalf("RecordActionDispatchReceipt: %v", err) + } + completed, event, completeErr := CompleteActionExecution(executing, &ExecutionResult{Success: true, Output: "ok"}, "operator", now.Add(2*time.Second)) + if completeErr != nil { + t.Fatalf("CompleteActionExecution: %v", completeErr) + } + // Same TransportRequestID as the persisted receipt -> existing-receipt + // branch must succeed. + receipt := ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: "act-completion-existing", TransportRequestID: attempt.ID, ReceivedAt: now.Add(time.Second)} + if err := store.RecordActionDispatchCompletion(receipt, completed, event); err != nil { + t.Fatalf("RecordActionDispatchCompletion: %v", err) + } + gotAudit, _, _ := store.GetActionAudit("act-completion-existing") + if gotAudit.State != ActionStateCompleted { + t.Fatalf("audit state=%q", gotAudit.State) + } + }) + + // failAttemptNotFound: no attempt was created -> ErrActionDispatchNotFound. + t.Run("fail_attempt_not_found", func(t *testing.T) { + store := NewMemoryStore() + // Audit exists but no attempt was ever inserted for this action. + record := atomicLifecycleTestRecord("act-completion-no-attempt", ActionStateExecuting) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + completed := record + completed.State = ActionStateCompleted + completed.Result = &ExecutionResult{Success: true} + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateCompleted, Actor: "operator"} + receipt := ActionDispatchReceipt{AttemptID: ActionDispatchAttemptID(record.ID), ActionID: record.ID, TransportRequestID: ActionDispatchAttemptID(record.ID), ReceivedAt: now} + err := store.RecordActionDispatchCompletion(receipt, completed, event) + if !errors.Is(err, ErrActionDispatchNotFound) { + t.Fatalf("err=%v want %v", err, ErrActionDispatchNotFound) + } + }) + + // failAttemptIDMismatch: attempt exists for the action but its .ID differs + // from receipt.AttemptID. Only reachable by direct map manipulation since + // attempts are normally keyed by action ID with id == ActionDispatchAttemptID. + t.Run("fail_attempt_id_mismatch", func(t *testing.T) { + store := NewMemoryStore() + actionID := "act-completion-id-mismatch" + store.mu.Lock() + store.actionDispatchAttempts[actionID] = ActionDispatchAttempt{ + ID: "different.attempt.id", + ActionID: actionID, + State: ActionDispatchReceiptPending, + CreatedAt: now, + UpdatedAt: now, + } + store.mu.Unlock() + record := atomicLifecycleTestRecord(actionID, ActionStateExecuting) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + completed := record + completed.State = ActionStateCompleted + completed.Result = &ExecutionResult{Success: true} + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateCompleted, Actor: "operator"} + receipt := ActionDispatchReceipt{AttemptID: ActionDispatchAttemptID(actionID), ActionID: actionID, TransportRequestID: ActionDispatchAttemptID(actionID), ReceivedAt: now} + err := store.RecordActionDispatchCompletion(receipt, completed, event) + if !errors.Is(err, ErrActionDispatchNotFound) { + t.Fatalf("err=%v want %v", err, ErrActionDispatchNotFound) + } + }) + + // failExistingReceiptTransportMismatch: receipt persisted with + // TransportRequestID="A"; completion called with "B" for the same + // AttemptID -> ErrActionDispatchReceiptConflict. + t.Run("fail_existing_receipt_transport_mismatch", func(t *testing.T) { + store := NewMemoryStore() + attempt, executing := branchcov0723amAdmitToReceiptPending(t, store, "act-completion-transport-mismatch", now) + // Persist a receipt with one transport request id. + _, err := store.RecordActionDispatchReceipt(ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: "act-completion-transport-mismatch", TransportRequestID: "transport-A", ReceivedAt: now.Add(time.Second)}) + if err != nil { + t.Fatalf("RecordActionDispatchReceipt: %v", err) + } + completed, event, completeErr := CompleteActionExecution(executing, &ExecutionResult{Success: true}, "operator", now.Add(2*time.Second)) + if completeErr != nil { + t.Fatalf("CompleteActionExecution: %v", completeErr) + } + // Now call completion with a DIFFERENT transport request id. + receipt := ActionDispatchReceipt{AttemptID: attempt.ID, ActionID: "act-completion-transport-mismatch", TransportRequestID: "transport-B", ReceivedAt: now.Add(time.Second)} + err = store.RecordActionDispatchCompletion(receipt, completed, event) + if !errors.Is(err, ErrActionDispatchReceiptConflict) { + t.Fatalf("err=%v want %v", err, ErrActionDispatchReceiptConflict) + } + }) + + // failExistingReceiptAttemptStateNotRecorded: corner case where a receipt + // exists but attempt.State is not ReceiptRecorded (only reachable via + // direct map setup). + t.Run("fail_existing_receipt_attempt_state_not_recorded", func(t *testing.T) { + store := NewMemoryStore() + actionID := "act-completion-state-recorded" + attemptID := ActionDispatchAttemptID(actionID) + // Set up: receipt exists, attempt is still queued (not ReceiptRecorded). + store.mu.Lock() + store.actionDispatchAttempts[actionID] = ActionDispatchAttempt{ + ID: attemptID, + ActionID: actionID, + State: ActionDispatchQueued, + CreatedAt: now, + UpdatedAt: now, + } + store.actionDispatchReceipts[attemptID] = ActionDispatchReceipt{AttemptID: attemptID, ActionID: actionID, TransportRequestID: attemptID, ReceivedAt: now} + store.mu.Unlock() + record := atomicLifecycleTestRecord(actionID, ActionStateExecuting) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + completed := record + completed.State = ActionStateCompleted + completed.Result = &ExecutionResult{Success: true} + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateCompleted, Actor: "operator"} + receipt := ActionDispatchReceipt{AttemptID: attemptID, ActionID: actionID, TransportRequestID: attemptID, ReceivedAt: now} + err := store.RecordActionDispatchCompletion(receipt, completed, event) + if !errors.Is(err, ErrActionDispatchReceiptConflict) { + t.Fatalf("err=%v want %v", err, ErrActionDispatchReceiptConflict) + } + }) + + // failNoExistingReceiptAttemptStateNotPending: no receipt but attempt.State + // is not ReceiptPending -> ErrActionDispatchReceiptConflict. + t.Run("fail_no_existing_receipt_attempt_state_not_pending", func(t *testing.T) { + store := NewMemoryStore() + actionID := "act-completion-not-pending" + attemptID := ActionDispatchAttemptID(actionID) + store.mu.Lock() + store.actionDispatchAttempts[actionID] = ActionDispatchAttempt{ + ID: attemptID, + ActionID: actionID, + State: ActionDispatchQueued, // not ReceiptPending, no receipt + CreatedAt: now, + UpdatedAt: now, + } + store.mu.Unlock() + record := atomicLifecycleTestRecord(actionID, ActionStateExecuting) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + completed := record + completed.State = ActionStateCompleted + completed.Result = &ExecutionResult{Success: true} + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateCompleted, Actor: "operator"} + receipt := ActionDispatchReceipt{AttemptID: attemptID, ActionID: actionID, TransportRequestID: attemptID, ReceivedAt: now} + err := store.RecordActionDispatchCompletion(receipt, completed, event) + if !errors.Is(err, ErrActionDispatchReceiptConflict) { + t.Fatalf("err=%v want %v", err, ErrActionDispatchReceiptConflict) + } + }) + + // failAuditNotFound: attempt exists but no audit was created. + t.Run("fail_audit_not_found", func(t *testing.T) { + store := NewMemoryStore() + actionID := "act-completion-no-audit" + attemptID := ActionDispatchAttemptID(actionID) + store.mu.Lock() + store.actionDispatchAttempts[actionID] = ActionDispatchAttempt{ + ID: attemptID, + ActionID: actionID, + State: ActionDispatchReceiptPending, + CreatedAt: now, + UpdatedAt: now, + } + store.mu.Unlock() + // Build a synthetic completed record (no audit was created). + synthetic := ActionAuditRecord{ + ID: actionID, + CreatedAt: now, + UpdatedAt: now, + State: ActionStateCompleted, + Request: ActionRequest{RequestID: "req-" + actionID, ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "agent:test", Actor: ActionActor{SubjectID: "agent:test", Kind: ActionActorService, CredentialID: "service:test", OrgID: "default"}}, + Plan: ActionPlan{ActionID: actionID, RequestID: "req-" + actionID, PlanHash: "sha256:" + actionID}, + Result: &ExecutionResult{Success: true}, + } + event := ActionLifecycleEvent{ActionID: actionID, Timestamp: now, State: ActionStateCompleted, Actor: "operator"} + receipt := ActionDispatchReceipt{AttemptID: attemptID, ActionID: actionID, TransportRequestID: attemptID, ReceivedAt: now} + err := store.RecordActionDispatchCompletion(receipt, synthetic, event) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v want 'not found'", err) + } + }) + + // failAuditStateNotExecuting: audit is in a non-Executing state. + t.Run("fail_audit_state_not_executing", func(t *testing.T) { + store := NewMemoryStore() + actionID := "act-completion-not-exec" + attemptID := ActionDispatchAttemptID(actionID) + store.mu.Lock() + store.actionDispatchAttempts[actionID] = ActionDispatchAttempt{ + ID: attemptID, + ActionID: actionID, + State: ActionDispatchReceiptPending, + CreatedAt: now, + UpdatedAt: now, + } + store.mu.Unlock() + // Audit in Planned state (not Executing). + record := atomicLifecycleTestRecord(actionID, ActionStatePlanned) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + completed := record + completed.State = ActionStateCompleted + completed.Result = &ExecutionResult{Success: true} + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateCompleted, Actor: "operator"} + receipt := ActionDispatchReceipt{AttemptID: attemptID, ActionID: actionID, TransportRequestID: attemptID, ReceivedAt: now} + err := store.RecordActionDispatchCompletion(receipt, completed, event) + if !errors.Is(err, ErrActionNotExecuting) { + t.Fatalf("err=%v want %v", err, ErrActionNotExecuting) + } + }) + + // failAuditIdentityMismatch: audit exists with a different PlanHash than + // the proposed record -> ErrActionIdentityConflict. + t.Run("fail_audit_identity_mismatch", func(t *testing.T) { + store := NewMemoryStore() + actionID := "act-completion-id-mismatch" + attemptID := ActionDispatchAttemptID(actionID) + // Persisted audit uses one PlanHash. + persisted := atomicLifecycleTestRecord(actionID, ActionStateExecuting) + if _, _, err := store.CreateActionAudit(persisted, atomicLifecycleInitialEvents(persisted)); err != nil { + t.Fatal(err) + } + store.mu.Lock() + store.actionDispatchAttempts[actionID] = ActionDispatchAttempt{ + ID: attemptID, + ActionID: actionID, + State: ActionDispatchReceiptPending, + CreatedAt: now, + UpdatedAt: now, + } + store.mu.Unlock() + // Proposed record claims a DIFFERENT PlanHash. + proposed := persisted + proposed.Plan.PlanHash = "sha256:different" + proposed.State = ActionStateCompleted + proposed.Result = &ExecutionResult{Success: true} + event := ActionLifecycleEvent{ActionID: actionID, Timestamp: now, State: ActionStateCompleted, Actor: "operator"} + receipt := ActionDispatchReceipt{AttemptID: attemptID, ActionID: actionID, TransportRequestID: attemptID, ReceivedAt: now} + err := store.RecordActionDispatchCompletion(receipt, proposed, event) + if !errors.Is(err, ErrActionIdentityConflict) { + t.Fatalf("err=%v want %v", err, ErrActionIdentityConflict) + } + }) + + // failNormalizeIdentitiesMismatch: receipt.ActionID != record.ID trips + // normalizeActionDispatchCompletion before any locking. + t.Run("fail_normalize_identities_mismatch", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("action-X", ActionStateCompleted) + record.Result = &ExecutionResult{Success: true} + event := ActionLifecycleEvent{ActionID: "action-X", Timestamp: now, State: ActionStateCompleted, Actor: "operator"} + // Receipt claims a different action than the record. + receipt := ActionDispatchReceipt{AttemptID: ActionDispatchAttemptID("action-Y"), ActionID: "action-Y", TransportRequestID: ActionDispatchAttemptID("action-Y"), ReceivedAt: now} + err := store.RecordActionDispatchCompletion(receipt, record, event) + if err == nil { + t.Fatal("expected normalization error for receipt/record identity mismatch") + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.ExpireActionAudits +// --------------------------------------------------------------------------- + +// branchcov0723amExpirable builds an audit in the requested pre-terminal state +// with ExpiresAt set to expiresAt and the creation timestamps coherent with +// the global test "now". +func branchcov0723amExpirable(id string, state ActionState, expiresAt time.Time) ActionAuditRecord { + r := atomicLifecycleTestRecord(id, state) + r.Plan.ExpiresAt = expiresAt + return r +} + +func TestBranchcov0723Am_ExpireActionAudits(t *testing.T) { + now := branchcov0723amNow + pastExpiry := now.Add(-time.Hour) + futureExpiry := now.Add(time.Hour) + + t.Run("empty_store_returns_empty_no_error", func(t *testing.T) { + store := NewMemoryStore() + out, err := store.ExpireActionAudits(now, 100) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if len(out) != 0 { + t.Fatalf("out=%#v want empty", out) + } + }) + + t.Run("nothing_eligible_all_terminal_state", func(t *testing.T) { + store := NewMemoryStore() + for i, state := range []ActionState{ActionStateExpired, ActionStateCompleted, ActionStateFailed} { + id := "act-expire-terminal-" + string(rune('a'+i)) + r := branchcov0723amExpirable(id, state, pastExpiry) + if _, _, err := store.CreateActionAudit(r, atomicLifecycleInitialEvents(r)); err != nil { + t.Fatal(err) + } + } + out, err := store.ExpireActionAudits(now, 100) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(out) != 0 { + t.Fatalf("out=%d want 0", len(out)) + } + }) + + t.Run("nothing_eligible_zero_expires_at", func(t *testing.T) { + // NormalizeActionAuditRecord backfills a zero ExpiresAt to + // PlannedAt+5min, so this corner of ExpireActionAudits's + // `!r.Plan.ExpiresAt.IsZero()` condition is NOT reachable via the + // public CreateActionAudit API. Drive it directly by appending a + // record with a zero ExpiresAt to the store's internal slice. + store := NewMemoryStore() + store.mu.Lock() + r := atomicLifecycleTestRecord("act-expire-zero", ActionStatePlanned) + r.Plan.ExpiresAt = time.Time{} + store.actionAudits = append(store.actionAudits, r) + store.mu.Unlock() + out, err := store.ExpireActionAudits(now, 100) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(out) != 0 { + t.Fatalf("out=%d want 0 (zero ExpiresAt must be ineligible)", len(out)) + } + // Audit must remain in its original state. + got, _, _ := store.GetActionAudit(r.ID) + if got.State != ActionStatePlanned { + t.Fatalf("state=%q want %q", got.State, ActionStatePlanned) + } + }) + + t.Run("nothing_eligible_now_before_expiry", func(t *testing.T) { + store := NewMemoryStore() + r := branchcov0723amExpirable("act-expire-future", ActionStatePlanned, futureExpiry) + if _, _, err := store.CreateActionAudit(r, atomicLifecycleInitialEvents(r)); err != nil { + t.Fatal(err) + } + out, err := store.ExpireActionAudits(now, 100) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(out) != 0 { + t.Fatalf("out=%d want 0", len(out)) + } + got, _, _ := store.GetActionAudit(r.ID) + if got.State != ActionStatePlanned { + t.Fatalf("state=%q want %q (record was expired despite future ExpiresAt)", got.State, ActionStatePlanned) + } + }) + + t.Run("some_eligible_some_not_only_eligible_returned_and_ineligible_preserved", func(t *testing.T) { + store := NewMemoryStore() + // Two eligible: Planned + Approved with past ExpiresAt. + eligible1 := branchcov0723amExpirable("act-expire-elig-1", ActionStatePlanned, pastExpiry) + eligible2 := branchcov0723amExpirable("act-expire-elig-2", ActionStateApproved, pastExpiry) + // One ineligible: Planned with future ExpiresAt. + ineligible := branchcov0723amExpirable("act-expire-inelig", ActionStatePending, futureExpiry) + for _, r := range []ActionAuditRecord{eligible1, eligible2, ineligible} { + r := r + if _, _, err := store.CreateActionAudit(r, atomicLifecycleInitialEvents(r)); err != nil { + t.Fatal(err) + } + } + out, err := store.ExpireActionAudits(now, 100) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(out) != 2 { + t.Fatalf("out=%d want 2 (only eligible records)", len(out)) + } + // Ineligible record must remain Pending in the store. + got, _, _ := store.GetActionAudit(ineligible.ID) + if got.State != ActionStatePending { + t.Fatalf("ineligible state=%q want %q (it should NOT have been expired)", got.State, ActionStatePending) + } + // Eligible records must be Expired now. + for _, id := range []string{eligible1.ID, eligible2.ID} { + g, _, _ := store.GetActionAudit(id) + if g.State != ActionStateExpired { + t.Fatalf("eligible %q state=%q want %q", id, g.State, ActionStateExpired) + } + } + }) + + t.Run("limit_smaller_than_eligible_honours_limit", func(t *testing.T) { + store := NewMemoryStore() + // Three eligible records; insertion order is deterministic because + // MemoryStore.actionAudits is an ordered slice. + ids := []string{"act-expire-limit-a", "act-expire-limit-b", "act-expire-limit-c"} + for _, id := range ids { + r := branchcov0723amExpirable(id, ActionStatePlanned, pastExpiry) + if _, _, err := store.CreateActionAudit(r, atomicLifecycleInitialEvents(r)); err != nil { + t.Fatal(err) + } + } + out, err := store.ExpireActionAudits(now, 2) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(out) != 2 { + t.Fatalf("out=%d want 2 (limit honours)", len(out)) + } + // The first two inserted records must be the ones expired; the third + // must remain Planned. + outIDs := map[string]bool{out[0].ID: true, out[1].ID: true} + if !outIDs[ids[0]] || !outIDs[ids[1]] { + t.Fatalf("expected first two records to be expired, got %#v", outIDs) + } + third, _, _ := store.GetActionAudit(ids[2]) + if third.State != ActionStatePlanned { + t.Fatalf("third record state=%q want %q (limit must leave it untouched)", third.State, ActionStatePlanned) + } + }) + + t.Run("limit_zero_no_truncation", func(t *testing.T) { + store := NewMemoryStore() + for _, id := range []string{"act-expire-lim0-a", "act-expire-lim0-b"} { + r := branchcov0723amExpirable(id, ActionStatePlanned, pastExpiry) + if _, _, err := store.CreateActionAudit(r, atomicLifecycleInitialEvents(r)); err != nil { + t.Fatal(err) + } + } + out, err := store.ExpireActionAudits(now, 0) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(out) != 2 { + t.Fatalf("out=%d want 2 (limit 0 means no truncation)", len(out)) + } + }) + + t.Run("limit_negative_no_truncation", func(t *testing.T) { + store := NewMemoryStore() + for _, id := range []string{"act-expire-limneg-a", "act-expire-limneg-b"} { + r := branchcov0723amExpirable(id, ActionStatePlanned, pastExpiry) + if _, _, err := store.CreateActionAudit(r, atomicLifecycleInitialEvents(r)); err != nil { + t.Fatal(err) + } + } + out, err := store.ExpireActionAudits(now, -5) + if err != nil { + t.Fatalf("err=%v", err) + } + if len(out) != 2 { + t.Fatalf("out=%d want 2 (negative limit means no truncation)", len(out)) + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.RecordActionExecutionRefusal +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_RecordActionExecutionRefusal(t *testing.T) { + now := branchcov0723amNow + refuse := func(t *testing.T, record ActionAuditRecord) (ActionAuditRecord, ActionLifecycleEvent) { + t.Helper() + refused, event, err := RefuseActionExecution(record, ErrResourceRemediationLocked, "operator", now.Add(time.Hour)) + if err != nil { + t.Fatalf("RefuseActionExecution: %v", err) + } + return refused, event + } + + // happyFromPlanned/Pending/Approved: each pre-terminal current state must + // transition to Failed and persist the refusal event. + for _, initial := range []ActionState{ActionStatePlanned, ActionStatePending, ActionStateApproved} { + initial := initial + t.Run("happy_from_"+string(initial), func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-refuse-"+string(initial), initial) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + refused, event := refuse(t, record) + if err := store.RecordActionExecutionRefusal(refused, event); err != nil { + t.Fatalf("RecordActionExecutionRefusal: %v", err) + } + got, found, err := store.GetActionAudit(record.ID) + if err != nil || !found { + t.Fatalf("GetActionAudit found=%v err=%v", found, err) + } + if got.State != ActionStateFailed { + t.Fatalf("state=%q want %q", got.State, ActionStateFailed) + } + if got.Result == nil || got.Result.Success { + t.Fatalf("result=%#v want non-success", got.Result) + } + events, err := store.GetActionLifecycleEvents(record.ID, time.Time{}, 10) + if err != nil { + t.Fatalf("GetActionLifecycleEvents: %v", err) + } + var sawFailedEvent bool + for _, e := range events { + if e.State == ActionStateFailed { + sawFailedEvent = true + } + } + if !sawFailedEvent { + t.Fatalf("refusal lifecycle event not appended: %#v", events) + } + }) + } + + t.Run("fail_invalid_record_state_not_failed", func(t *testing.T) { + store := NewMemoryStore() + // record.State is Planned but event.State is Failed; the precondition + // record.State == Failed fails. + record := atomicLifecycleTestRecord("act-refuse-bad-state", ActionStatePlanned) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + notFailed := record // state still Planned + event := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateFailed, Actor: "operator"} + err := store.RecordActionExecutionRefusal(notFailed, event) + if err == nil || !strings.Contains(err.Error(), "matching failed state") { + t.Fatalf("err=%v want 'matching failed state'", err) + } + }) + + t.Run("fail_invalid_event_state_not_failed", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-refuse-bad-event-state", ActionStatePlanned) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + failedRecord := record + failedRecord.State = ActionStateFailed + // event.State is Planned, not Failed. + badEvent := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStatePlanned, Actor: "operator"} + err := store.RecordActionExecutionRefusal(failedRecord, badEvent) + if err == nil || !strings.Contains(err.Error(), "matching failed state") { + t.Fatalf("err=%v want 'matching failed state'", err) + } + }) + + t.Run("fail_event_action_id_mismatch", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-refuse-mismatch", ActionStateFailed) + // event.ActionID != record.ID. + event := ActionLifecycleEvent{ActionID: "different-id", Timestamp: now, State: ActionStateFailed, Actor: "operator"} + err := store.RecordActionExecutionRefusal(record, event) + if err == nil || !strings.Contains(err.Error(), "matching failed state") { + t.Fatalf("err=%v want 'matching failed state'", err) + } + }) + + t.Run("fail_record_normalization_empty_id", func(t *testing.T) { + store := NewMemoryStore() + bad := ActionAuditRecord{State: ActionStateFailed} + event := ActionLifecycleEvent{ActionID: "any", Timestamp: now, State: ActionStateFailed} + err := store.RecordActionExecutionRefusal(bad, event) + if err == nil { + t.Fatal("expected normalization error for empty record id") + } + }) + + t.Run("fail_event_normalization_empty_action_id", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-refuse-bad-event", ActionStateFailed) + badEvent := ActionLifecycleEvent{Timestamp: now, State: ActionStateFailed} + err := store.RecordActionExecutionRefusal(record, badEvent) + if err == nil { + t.Fatal("expected normalization error for empty event action id") + } + }) + + t.Run("fail_identity_mismatch", func(t *testing.T) { + store := NewMemoryStore() + persisted := atomicLifecycleTestRecord("act-refuse-idconflict", ActionStatePlanned) + if _, _, err := store.CreateActionAudit(persisted, atomicLifecycleInitialEvents(persisted)); err != nil { + t.Fatal(err) + } + // Proposed record claims a different PlanHash. + proposed := persisted + proposed.Plan.PlanHash = "sha256:different" + refused, event := refuse(t, proposed) + err := store.RecordActionExecutionRefusal(refused, event) + if !errors.Is(err, ErrActionIdentityConflict) { + t.Fatalf("err=%v want %v", err, ErrActionIdentityConflict) + } + }) + + t.Run("fail_current_terminal", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-refuse-terminal", ActionStateCompleted) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + refused, event := refuse(t, record) + err := store.RecordActionExecutionRefusal(refused, event) + if !errors.Is(err, ErrActionExecutionFinal) { + t.Fatalf("err=%v want %v", err, ErrActionExecutionFinal) + } + }) + + t.Run("fail_current_executing", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-refuse-executing", ActionStateExecuting) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + refused, event := refuse(t, record) + err := store.RecordActionExecutionRefusal(refused, event) + if !errors.Is(err, ErrActionNotExecuting) { + t.Fatalf("err=%v want %v", err, ErrActionNotExecuting) + } + }) + + t.Run("fail_record_not_found", func(t *testing.T) { + store := NewMemoryStore() + record := atomicLifecycleTestRecord("act-refuse-missing", ActionStatePlanned) + refused, event := refuse(t, record) + err := store.RecordActionExecutionRefusal(refused, event) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v want 'not found'", err) + } + }) + + // scansPastUnrelatedAudits: with two unrelated audits in the store, the + // refusal scan must skip past the unrelated one (exercising the `continue` + // arm of the loop) and still find the target audit. + t.Run("scans_past_unrelated_audits", func(t *testing.T) { + store := NewMemoryStore() + other := atomicLifecycleTestRecord("act-refuse-unrelated", ActionStatePlanned) + if _, _, err := store.CreateActionAudit(other, atomicLifecycleInitialEvents(other)); err != nil { + t.Fatal(err) + } + target := atomicLifecycleTestRecord("act-refuse-target", ActionStateApproved) + if _, _, err := store.CreateActionAudit(target, atomicLifecycleInitialEvents(target)); err != nil { + t.Fatal(err) + } + refused, event := refuse(t, target) + if err := store.RecordActionExecutionRefusal(refused, event); err != nil { + t.Fatalf("RecordActionExecutionRefusal: %v", err) + } + // Unrelated audit must remain Planned; target must be Failed. + gotOther, _, _ := store.GetActionAudit(other.ID) + if gotOther.State != ActionStatePlanned { + t.Fatalf("unrelated state=%q want %q (must be untouched)", gotOther.State, ActionStatePlanned) + } + gotTarget, _, _ := store.GetActionAudit(target.ID) + if gotTarget.State != ActionStateFailed { + t.Fatalf("target state=%q want %q", gotTarget.State, ActionStateFailed) + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.RecordActionLifecycleEvent +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_RecordActionLifecycleEvent(t *testing.T) { + now := branchcov0723amNow + + t.Run("happy_appends_new_transition", func(t *testing.T) { + store := NewMemoryStore() + event := ActionLifecycleEvent{ActionID: "act-event-1", Timestamp: now, State: ActionStatePlanned, Actor: "system"} + if err := store.RecordActionLifecycleEvent(event); err != nil { + t.Fatalf("RecordActionLifecycleEvent: %v", err) + } + events, err := store.GetActionLifecycleEvents("act-event-1", time.Time{}, 10) + if err != nil { + t.Fatalf("GetActionLifecycleEvents: %v", err) + } + if len(events) != 1 || events[0].State != ActionStatePlanned || events[0].ActionID != "act-event-1" { + t.Fatalf("events=%#v", events) + } + }) + + t.Run("fail_duplicate_transition_same_action_state", func(t *testing.T) { + store := NewMemoryStore() + event := ActionLifecycleEvent{ActionID: "act-event-dup", Timestamp: now, State: ActionStatePlanned, Actor: "system"} + if err := store.RecordActionLifecycleEvent(event); err != nil { + t.Fatalf("first RecordActionLifecycleEvent: %v", err) + } + // Re-recording the SAME (action, state) transition must be rejected. + err := store.RecordActionLifecycleEvent(event) + if err == nil || !strings.Contains(err.Error(), "already recorded") { + t.Fatalf("err=%v want 'already recorded'", err) + } + // The duplicate must NOT have been appended. + events, _ := store.GetActionLifecycleEvents("act-event-dup", time.Time{}, 10) + if len(events) != 1 { + t.Fatalf("events=%d want 1 (duplicate was appended)", len(events)) + } + }) + + t.Run("ok_transition_different_state_same_action", func(t *testing.T) { + store := NewMemoryStore() + first := ActionLifecycleEvent{ActionID: "act-event-multi", Timestamp: now, State: ActionStatePlanned, Actor: "system"} + second := ActionLifecycleEvent{ActionID: "act-event-multi", Timestamp: now.Add(time.Second), State: ActionStateApproved, Actor: "operator"} + if err := store.RecordActionLifecycleEvent(first); err != nil { + t.Fatal(err) + } + if err := store.RecordActionLifecycleEvent(second); err != nil { + t.Fatalf("second RecordActionLifecycleEvent: %v", err) + } + events, _ := store.GetActionLifecycleEvents("act-event-multi", time.Time{}, 10) + if len(events) != 2 { + t.Fatalf("events=%d want 2", len(events)) + } + }) + + t.Run("ok_transition_different_action_same_state", func(t *testing.T) { + store := NewMemoryStore() + a := ActionLifecycleEvent{ActionID: "act-event-A", Timestamp: now, State: ActionStatePlanned, Actor: "system"} + b := ActionLifecycleEvent{ActionID: "act-event-B", Timestamp: now, State: ActionStatePlanned, Actor: "system"} + if err := store.RecordActionLifecycleEvent(a); err != nil { + t.Fatal(err) + } + if err := store.RecordActionLifecycleEvent(b); err != nil { + t.Fatalf("different action with same state must be allowed: %v", err) + } + }) + + t.Run("fail_duplicate_decision_same_revision", func(t *testing.T) { + store := NewMemoryStore() + // Use ApplyActionDecision to derive a valid normalized decision event. + record := branchcov0723amFutureRecord("act-event-decision", ActionStatePending) + approval := testBoundActionApproval(record, "operator@example.com", MethodSession, OutcomeApproved, "approved", now) + updated, decisionEvent, err := ApplyActionDecision(record, approval, now) + if err != nil { + t.Fatalf("ApplyActionDecision: %v", err) + } + if err := store.RecordActionLifecycleEvent(decisionEvent); err != nil { + t.Fatalf("first decision RecordActionLifecycleEvent: %v", err) + } + // Re-recording the SAME (action, decisionRevision) must be rejected. + err = store.RecordActionLifecycleEvent(decisionEvent) + if err == nil || !strings.Contains(err.Error(), "already recorded") { + t.Fatalf("err=%v want 'already recorded'", err) + } + // The duplicate must NOT have been appended (only one event for this action). + events, _ := store.GetActionLifecycleEvents(updated.ID, time.Time{}, 10) + if len(events) != 1 { + t.Fatalf("events=%d want 1 (duplicate decision was appended)", len(events)) + } + }) + + t.Run("ok_decision_for_different_action_same_revision", func(t *testing.T) { + store := NewMemoryStore() + // Two different actions can each carry a decision at the same revision. + mkDecision := func(actionID string) ActionLifecycleEvent { + record := branchcov0723amFutureRecord(actionID, ActionStatePending) + approval := testBoundActionApproval(record, "operator@example.com", MethodSession, OutcomeApproved, "approved", now) + _, event, err := ApplyActionDecision(record, approval, now) + if err != nil { + t.Fatalf("ApplyActionDecision(%s): %v", actionID, err) + } + return event + } + first := mkDecision("act-event-decision-A") + second := mkDecision("act-event-decision-B") + if err := store.RecordActionLifecycleEvent(first); err != nil { + t.Fatal(err) + } + if err := store.RecordActionLifecycleEvent(second); err != nil { + t.Fatalf("decision for different action with same revision must be allowed: %v", err) + } + }) + + t.Run("fail_normalize_empty_action_id", func(t *testing.T) { + store := NewMemoryStore() + bad := ActionLifecycleEvent{Timestamp: now, State: ActionStatePlanned} + err := store.RecordActionLifecycleEvent(bad) + if err == nil { + t.Fatal("expected normalization error for empty event action id") + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.RecordActionPolicyExecutionAdmission +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_RecordActionPolicyExecutionAdmission(t *testing.T) { + now := branchcov0723amNow + + // happyFromPending: audit currently Pending -> Executing after admission. + t.Run("happy_from_pending", func(t *testing.T) { + store := NewMemoryStore() + record := branchcov0723amFutureRecord("act-policy-pending", ActionStatePending) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + lease := branchcov0723amLeaseFor(record, now.Add(time.Minute)) + approval := ActionApprovalRecord{Actor: "policy:auto", Method: MethodPolicy} + execRecord, approvedEvent, startedEvent, err := BeginPolicyActionExecution(record, approval, lease, now.Add(time.Minute)) + if err != nil { + t.Fatalf("BeginPolicyActionExecution: %v", err) + } + attempt, err := NewActionDispatchAttempt(record.ID, now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if err := store.RecordActionPolicyExecutionAdmission(execRecord, approvedEvent, startedEvent, attempt); err != nil { + t.Fatalf("RecordActionPolicyExecutionAdmission: %v", err) + } + gotAudit, found, err := store.GetActionAudit(record.ID) + if err != nil || !found || gotAudit.State != ActionStateExecuting { + t.Fatalf("audit found=%v state=%q err=%v", found, gotAudit.State, err) + } + gotAttempt, found, err := store.GetActionDispatchAttempt(record.ID) + if err != nil || !found || gotAttempt.State != ActionDispatchQueued { + t.Fatalf("attempt found=%v state=%q err=%v", found, gotAttempt.State, err) + } + // Both the approval and the executing events must be persisted. + events, _ := store.GetActionLifecycleEvents(record.ID, time.Time{}, 10) + states := map[ActionState]bool{} + for _, e := range events { + states[e.State] = true + } + if !states[ActionStateApproved] || !states[ActionStateExecuting] { + t.Fatalf("missing approved/executing event: %#v", states) + } + }) + + // happyFromPlanned: audit currently Planned (approval-free path) -> + // Executing after admission. + t.Run("happy_from_planned", func(t *testing.T) { + store := NewMemoryStore() + record := branchcov0723amFutureRecord("act-policy-planned", ActionStatePlanned) + record.Plan.RequiresApproval = false + record.Plan.ApprovalPolicy = ApprovalNone + record.Plan.ApprovalRequirement = ApprovalRequirementForFloor(ApprovalNone) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + lease := branchcov0723amLeaseFor(record, now.Add(time.Minute)) + approval := ActionApprovalRecord{Actor: "policy:auto", Method: MethodPolicy} + execRecord, approvedEvent, startedEvent, err := BeginPolicyActionExecution(record, approval, lease, now.Add(time.Minute)) + if err != nil { + t.Fatalf("BeginPolicyActionExecution: %v", err) + } + attempt, err := NewActionDispatchAttempt(record.ID, now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + if err := store.RecordActionPolicyExecutionAdmission(execRecord, approvedEvent, startedEvent, attempt); err != nil { + t.Fatalf("RecordActionPolicyExecutionAdmission: %v", err) + } + gotAudit, _, _ := store.GetActionAudit(record.ID) + if gotAudit.State != ActionStateExecuting { + t.Fatalf("state=%q want %q", gotAudit.State, ActionStateExecuting) + } + }) + + // failValidateAdmissionStatesMismatch: validateExecutionAdmission rejects + // an event with the wrong state. + t.Run("fail_validate_admission_event_state_mismatch", func(t *testing.T) { + store := NewMemoryStore() + record := branchcov0723amFutureRecord("act-policy-bad-event", ActionStateExecuting) + // event.State is Pending (not Executing). + badEvent := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStatePending, Actor: "operator"} + approvalEvent := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateApproved, Actor: "operator"} + attempt, err := NewActionDispatchAttempt(record.ID, now) + if err != nil { + t.Fatal(err) + } + err = store.RecordActionPolicyExecutionAdmission(record, approvalEvent, badEvent, attempt) + if err == nil { + t.Fatal("expected validateExecutionAdmission to reject mismatched event state") + } + }) + + // failNormalizeApprovalEventEmptyActionID: approvalEvent fails normalization. + t.Run("fail_normalize_approval_event_empty_action_id", func(t *testing.T) { + store := NewMemoryStore() + // Build a valid Executing record + executionEvent + attempt first. + record := branchcov0723amFutureRecord("act-policy-bad-approval", ActionStatePending) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + lease := branchcov0723amLeaseFor(record, now.Add(time.Minute)) + approval := ActionApprovalRecord{Actor: "policy:auto", Method: MethodPolicy} + execRecord, _, startedEvent, err := BeginPolicyActionExecution(record, approval, lease, now.Add(time.Minute)) + if err != nil { + t.Fatalf("BeginPolicyActionExecution: %v", err) + } + attempt, err := NewActionDispatchAttempt(record.ID, now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + // approvalEvent with empty ActionID cannot normalize. + badApprovalEvent := ActionLifecycleEvent{Timestamp: now, State: ActionStateApproved} + err = store.RecordActionPolicyExecutionAdmission(execRecord, badApprovalEvent, startedEvent, attempt) + if err == nil { + t.Fatal("expected normalization error for empty approvalEvent action id") + } + }) + + // failRecordNotFound: no audit was created for this action. + t.Run("fail_record_not_found", func(t *testing.T) { + store := NewMemoryStore() + record := branchcov0723amFutureRecord("act-policy-missing", ActionStatePending) + lease := branchcov0723amLeaseFor(record, now.Add(time.Minute)) + approval := ActionApprovalRecord{Actor: "policy:auto", Method: MethodPolicy} + execRecord, approvedEvent, startedEvent, err := BeginPolicyActionExecution(record, approval, lease, now.Add(time.Minute)) + if err != nil { + t.Fatalf("BeginPolicyActionExecution: %v", err) + } + attempt, err := NewActionDispatchAttempt(record.ID, now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + err = store.RecordActionPolicyExecutionAdmission(execRecord, approvedEvent, startedEvent, attempt) + if err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("err=%v want 'not found'", err) + } + }) + + // failCurrentStateNotAdmissible: audit currently Executing (not Planned or + // Pending) -> actionTransitionConflict returns ErrActionAlreadyExecuting. + t.Run("fail_current_state_not_admissible", func(t *testing.T) { + store := NewMemoryStore() + record := branchcov0723amFutureRecord("act-policy-executing", ActionStateExecuting) + if _, _, err := store.CreateActionAudit(record, atomicLifecycleInitialEvents(record)); err != nil { + t.Fatal(err) + } + // Build the executing record/event pair directly. BeginPolicyActionExecution + // rejects records that are already Executing, so construct manually to + // exercise the MemoryStore-side state check. + execRecord := record + execRecord.State = ActionStateExecuting + startedEvent := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now.Add(time.Minute), State: ActionStateExecuting, Actor: "policy:auto"} + approvedEvent := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now.Add(time.Minute), State: ActionStateApproved, Actor: "policy:auto"} + attempt, err := NewActionDispatchAttempt(record.ID, now.Add(time.Minute)) + if err != nil { + t.Fatal(err) + } + err = store.RecordActionPolicyExecutionAdmission(execRecord, approvedEvent, startedEvent, attempt) + if !errors.Is(err, ErrActionAlreadyExecuting) { + t.Fatalf("err=%v want %v", err, ErrActionAlreadyExecuting) + } + // Audit must remain Executing. + got, _, _ := store.GetActionAudit(record.ID) + if got.State != ActionStateExecuting { + t.Fatalf("state=%q want %q (must be unchanged)", got.State, ActionStateExecuting) + } + }) +} diff --git a/internal/unifiedresources/memorystore_changecounts_branchcov0723am_test.go b/internal/unifiedresources/memorystore_changecounts_branchcov0723am_test.go new file mode 100644 index 000000000..8e85793dc --- /dev/null +++ b/internal/unifiedresources/memorystore_changecounts_branchcov0723am_test.go @@ -0,0 +1,668 @@ +package unifiedresources + +import ( + "reflect" + "testing" + "time" +) + +// Branch-coverage tests for currently-0.0%-covered MemoryStore methods in +// internal/unifiedresources/store.go: +// - MemoryStore.CountRecentChangesByKind (store.go:3356) +// - MemoryStore.CountRecentChangesByKindFiltered (store.go:3360) +// - MemoryStore.CountRecentChangesBySourceType (store.go:3384) +// - MemoryStore.CountRecentChangesBySourceTypeFiltered (store.go:3388) +// - MemoryStore.CountRecentChangesBySourceAdapter (store.go:3412) +// - MemoryStore.CountRecentChangesBySourceAdapterFiltered (store.go:3416) +// - MemoryStore.RecordExportAudit (store.go:3964) +// - MemoryStore.GetExportAudits (store.go:3971) +// +// Every subtest constructs its OWN MemoryStore so it passes when run alone via +// -run. The shared "now" (branchcov0723amNow) comes from the sibling +// memorystore_actions_branchcov0723am_test.go in this same package. + +// branchcov0723amCountChange builds a minimal but valid ResourceChange for the +// count-family subtests, letting each subtest vary only the fields it reasons +// about. IDs are unique so RecordChange does not dedupe them (recordChangeLocked +// drops an incoming change only when its non-empty ID already exists). +func branchcov0723amCountChange(id string, at time.Time, kind ChangeKind, sourceType ChangeSourceType, adapter ChangeSourceAdapter, resourceID string) ResourceChange { + return ResourceChange{ + ID: id, + ResourceID: resourceID, + ObservedAt: at, + Kind: kind, + SourceType: sourceType, + SourceAdapter: adapter, + Confidence: ConfidenceHigh, + } +} + +// branchcov0723amSeed appends the supplied changes to store in order, failing +// the subtest if any RecordChange errors (e.g. an accidental duplicate id). +func branchcov0723amSeed(t *testing.T, store *MemoryStore, changes ...ResourceChange) { + t.Helper() + for _, c := range changes { + if err := store.RecordChange(c); err != nil { + t.Fatalf("RecordChange(%s): %v", c.ID, err) + } + } +} + +// --------------------------------------------------------------------------- +// MemoryStore.CountRecentChangesByKind / CountRecentChangesByKindFiltered +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_CountRecentChangesByKind(t *testing.T) { + now := branchcov0723amNow + + // emptyStore: source returns a nil map (not a non-nil empty map) when + // nothing is counted, because of the explicit `if len(counts) == 0` + // return. The non-Filtered entrypoint must inherit this via delegation. + t.Run("empty_store_returns_nil_map", func(t *testing.T) { + store := NewMemoryStore() + got, err := store.CountRecentChangesByKind("vm:1", now.Add(-time.Hour)) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil (empty result must be a nil map)", got) + } + // The filtered entrypoint must agree. + gotF, err := store.CountRecentChangesByKindFiltered("vm:1", now.Add(-time.Hour), ResourceChangeFilters{}) + if err != nil { + t.Fatalf("filtered err=%v want nil", err) + } + if gotF != nil { + t.Fatalf("filtered got=%#v want nil", gotF) + } + }) + + // differentCanonicalIDExcluded: changes recorded for vm:1 must not be + // counted when querying vm:2 (no identity pins relate the two), so the + // result is nil. + t.Run("different_canonical_id_excluded", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ck-diff-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesByKind("vm:2", now.Add(-time.Hour)) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil (vm:2 must not count vm:1 changes)", got) + } + }) + + // sinceBoundary: ObservedAt.Before(since) is the gate, so a change AT + // exactly `since` and one just AFTER are counted, while one just BEFORE + // is excluded. Asserts the real comparison side (>= on since). + t.Run("since_boundary_excludes_before_includes_at_and_after", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ck-before", now.Add(-2*time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ck-at", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ck-after", now.Add(time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesByKind("vm:1", now) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if !reflect.DeepEqual(got, map[ChangeKind]int{ChangeAnomaly: 2}) { + t.Fatalf("got=%#v want {Anomaly:2} (only at+after must count)", got) + } + }) + + // zeroSinceAndEmptyCanonicalID: a zero since disables the time gate and + // an empty canonicalID disables the resource gate, so every change + // across distinct resources is counted. + t.Run("zero_since_and_empty_canonical_id_count_all", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ck-all-1", now.Add(-10*time.Hour), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ck-all-2", now.Add(-9*time.Hour), ChangeStateTransition, SourcePlatformEvent, AdapterDocker, "vm:2"), + branchcov0723amCountChange("ck-all-3", now.Add(-8*time.Hour), ChangeAnomaly, SourceHeuristic, AdapterProxmox, "vm:3"), + ) + got, err := store.CountRecentChangesByKind("", time.Time{}) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if !reflect.DeepEqual(got, map[ChangeKind]int{ChangeAnomaly: 2, ChangeStateTransition: 1}) { + t.Fatalf("got=%#v want {Anomaly:2, StateTransition:1} across all resources", got) + } + }) + + // severalSameKindAndMultipleKinds: assert the concrete count value (3) + // for one kind and the presence of several kinds at once. + t.Run("several_same_kind_and_multiple_kinds", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ck-multi-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ck-multi-2", now, ChangeAnomaly, SourcePulseDiff, AdapterDocker, "vm:1"), + branchcov0723amCountChange("ck-multi-3", now, ChangeAnomaly, SourcePlatformEvent, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ck-multi-4", now, ChangeStateTransition, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ck-multi-5", now, ChangeCapability, SourceHeuristic, AdapterVMware, "vm:1"), + ) + got, err := store.CountRecentChangesByKind("vm:1", time.Time{}) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + want := map[ChangeKind]int{ChangeAnomaly: 3, ChangeStateTransition: 1, ChangeCapability: 1} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got=%#v want %#v", got, want) + } + }) + + // filteredExcludesEverything: a filter whose kind is not present must + // match nothing, yielding the nil-map empty result. + t.Run("filtered_excludes_everything_returns_nil", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ck-none-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesByKindFiltered("vm:1", time.Time{}, ResourceChangeFilters{ + Kinds: []ChangeKind{ChangeRestart}, + }) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil (filter excludes everything)", got) + } + }) + + // filteredSubsetDiffersFromUnfiltered: a SourceTypes filter keeps only + // the PulseDiff Anomalies, dropping the PlatformEvent StateTransition, + // so the filtered result must differ from the unfiltered one. + t.Run("filtered_subset_differs_from_unfiltered", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ck-sub-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ck-sub-2", now, ChangeAnomaly, SourcePulseDiff, AdapterDocker, "vm:1"), + branchcov0723amCountChange("ck-sub-3", now, ChangeStateTransition, SourcePlatformEvent, AdapterProxmox, "vm:1"), + ) + unfiltered, err := store.CountRecentChangesByKind("vm:1", time.Time{}) + if err != nil { + t.Fatalf("unfiltered err=%v", err) + } + filtered, err := store.CountRecentChangesByKindFiltered("vm:1", time.Time{}, ResourceChangeFilters{ + SourceTypes: []ChangeSourceType{SourcePulseDiff}, + }) + if err != nil { + t.Fatalf("filtered err=%v", err) + } + if reflect.DeepEqual(unfiltered, filtered) { + t.Fatalf("unfiltered==filtered=%#v (filter had no effect)", filtered) + } + if !reflect.DeepEqual(filtered, map[ChangeKind]int{ChangeAnomaly: 2}) { + t.Fatalf("filtered=%#v want {Anomaly:2}", filtered) + } + }) + + // filteredIncludeRelated: a change whose ResourceID is NOT the queried + // canonical id, but which lists it in RelatedResources, is counted only + // when IncludeRelated is true — covering the includeRelated branch of + // changeMatchesResource reached through the count path. + t.Run("filtered_include_related_matches_via_related_resources", func(t *testing.T) { + store := NewMemoryStore() + related := branchcov0723amCountChange("ck-rel", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:other") + related.RelatedResources = []string{"vm:1"} + branchcov0723amSeed(t, store, related) + + excluded, err := store.CountRecentChangesByKindFiltered("vm:1", time.Time{}, ResourceChangeFilters{IncludeRelated: false}) + if err != nil { + t.Fatalf("excluded err=%v", err) + } + if excluded != nil { + t.Fatalf("excluded=%#v want nil (related must NOT match when IncludeRelated=false)", excluded) + } + included, err := store.CountRecentChangesByKindFiltered("vm:1", time.Time{}, ResourceChangeFilters{IncludeRelated: true}) + if err != nil { + t.Fatalf("included err=%v", err) + } + if !reflect.DeepEqual(included, map[ChangeKind]int{ChangeAnomaly: 1}) { + t.Fatalf("included=%#v want {Anomaly:1} (related must match when IncludeRelated=true)", included) + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.CountRecentChangesBySourceType / CountRecentChangesBySourceTypeFiltered +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_CountRecentChangesBySourceType(t *testing.T) { + now := branchcov0723amNow + + t.Run("empty_store_returns_nil_map", func(t *testing.T) { + store := NewMemoryStore() + got, err := store.CountRecentChangesBySourceType("vm:1", now.Add(-time.Hour)) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil", got) + } + gotF, err := store.CountRecentChangesBySourceTypeFiltered("vm:1", now.Add(-time.Hour), ResourceChangeFilters{}) + if err != nil { + t.Fatalf("filtered err=%v want nil", err) + } + if gotF != nil { + t.Fatalf("filtered got=%#v want nil", gotF) + } + }) + + t.Run("different_canonical_id_excluded", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("cs-diff-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesBySourceType("vm:2", now.Add(-time.Hour)) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil", got) + } + }) + + t.Run("since_boundary_excludes_before_includes_at_and_after", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("cs-before", now.Add(-2*time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("cs-at", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("cs-after", now.Add(time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesBySourceType("vm:1", now) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if !reflect.DeepEqual(got, map[ChangeSourceType]int{SourcePulseDiff: 2}) { + t.Fatalf("got=%#v want {PulseDiff:2}", got) + } + }) + + t.Run("zero_since_and_empty_canonical_id_count_all", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("cs-all-1", now.Add(-10*time.Hour), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("cs-all-2", now.Add(-9*time.Hour), ChangeStateTransition, SourcePlatformEvent, AdapterDocker, "vm:2"), + branchcov0723amCountChange("cs-all-3", now.Add(-8*time.Hour), ChangeAnomaly, SourceHeuristic, AdapterProxmox, "vm:3"), + ) + got, err := store.CountRecentChangesBySourceType("", time.Time{}) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if !reflect.DeepEqual(got, map[ChangeSourceType]int{SourcePulseDiff: 1, SourcePlatformEvent: 1, SourceHeuristic: 1}) { + t.Fatalf("got=%#v want one of each source type across resources", got) + } + }) + + t.Run("several_same_source_and_multiple_sources", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("cs-multi-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("cs-multi-2", now, ChangeStateTransition, SourcePulseDiff, AdapterDocker, "vm:1"), + branchcov0723amCountChange("cs-multi-3", now, ChangeCapability, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("cs-multi-4", now, ChangeAnomaly, SourcePlatformEvent, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesBySourceType("vm:1", time.Time{}) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + want := map[ChangeSourceType]int{SourcePulseDiff: 3, SourcePlatformEvent: 1} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got=%#v want %#v", got, want) + } + }) + + t.Run("filtered_excludes_everything_returns_nil", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("cs-none-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesBySourceTypeFiltered("vm:1", time.Time{}, ResourceChangeFilters{ + Kinds: []ChangeKind{ChangeRestart}, + }) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil", got) + } + }) + + // filteredSubset: a Kinds filter keeps only the Anomaly changes, so the + // PlatformEvent count (driven by a StateTransition) is dropped. + t.Run("filtered_subset_differs_from_unfiltered", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("cs-sub-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("cs-sub-2", now, ChangeAnomaly, SourceHeuristic, AdapterDocker, "vm:1"), + branchcov0723amCountChange("cs-sub-3", now, ChangeStateTransition, SourcePlatformEvent, AdapterProxmox, "vm:1"), + ) + unfiltered, err := store.CountRecentChangesBySourceType("vm:1", time.Time{}) + if err != nil { + t.Fatalf("unfiltered err=%v", err) + } + filtered, err := store.CountRecentChangesBySourceTypeFiltered("vm:1", time.Time{}, ResourceChangeFilters{ + Kinds: []ChangeKind{ChangeAnomaly}, + }) + if err != nil { + t.Fatalf("filtered err=%v", err) + } + if reflect.DeepEqual(unfiltered, filtered) { + t.Fatalf("unfiltered==filtered=%#v (filter had no effect)", filtered) + } + if !reflect.DeepEqual(filtered, map[ChangeSourceType]int{SourcePulseDiff: 1, SourceHeuristic: 1}) { + t.Fatalf("filtered=%#v want {PulseDiff:1, Heuristic:1}", filtered) + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.CountRecentChangesBySourceAdapter / CountRecentChangesBySourceAdapterFiltered +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_CountRecentChangesBySourceAdapter(t *testing.T) { + now := branchcov0723amNow + + t.Run("empty_store_returns_nil_map", func(t *testing.T) { + store := NewMemoryStore() + got, err := store.CountRecentChangesBySourceAdapter("vm:1", now.Add(-time.Hour)) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil", got) + } + gotF, err := store.CountRecentChangesBySourceAdapterFiltered("vm:1", now.Add(-time.Hour), ResourceChangeFilters{}) + if err != nil { + t.Fatalf("filtered err=%v want nil", err) + } + if gotF != nil { + t.Fatalf("filtered got=%#v want nil", gotF) + } + }) + + t.Run("different_canonical_id_excluded", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ca-diff-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesBySourceAdapter("vm:2", now.Add(-time.Hour)) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil", got) + } + }) + + t.Run("since_boundary_excludes_before_includes_at_and_after", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ca-before", now.Add(-2*time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ca-at", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ca-after", now.Add(time.Minute), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesBySourceAdapter("vm:1", now) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if !reflect.DeepEqual(got, map[ChangeSourceAdapter]int{AdapterProxmox: 2}) { + t.Fatalf("got=%#v want {Proxmox:2}", got) + } + }) + + t.Run("zero_since_and_empty_canonical_id_count_all", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ca-all-1", now.Add(-10*time.Hour), ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ca-all-2", now.Add(-9*time.Hour), ChangeStateTransition, SourcePlatformEvent, AdapterDocker, "vm:2"), + branchcov0723amCountChange("ca-all-3", now.Add(-8*time.Hour), ChangeAnomaly, SourceHeuristic, AdapterProxmox, "vm:3"), + ) + got, err := store.CountRecentChangesBySourceAdapter("", time.Time{}) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if !reflect.DeepEqual(got, map[ChangeSourceAdapter]int{AdapterProxmox: 2, AdapterDocker: 1}) { + t.Fatalf("got=%#v want {Proxmox:2, Docker:1} across resources", got) + } + }) + + t.Run("several_same_adapter_and_multiple_adapters", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ca-multi-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ca-multi-2", now, ChangeStateTransition, SourcePlatformEvent, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ca-multi-3", now, ChangeCapability, SourceHeuristic, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ca-multi-4", now, ChangeAnomaly, SourcePulseDiff, AdapterDocker, "vm:1"), + ) + got, err := store.CountRecentChangesBySourceAdapter("vm:1", time.Time{}) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + want := map[ChangeSourceAdapter]int{AdapterProxmox: 3, AdapterDocker: 1} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got=%#v want %#v", got, want) + } + }) + + t.Run("filtered_excludes_everything_returns_nil", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ca-none-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + ) + got, err := store.CountRecentChangesBySourceAdapterFiltered("vm:1", time.Time{}, ResourceChangeFilters{ + SourceAdapters: []ChangeSourceAdapter{AdapterVMware}, + }) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil", got) + } + }) + + // filteredSubset: a SourceTypes filter keeps only PulseDiff changes, so + // the PlatformEvent/Proxmox contribution is dropped and the Proxmox + // adapter count shrinks from 2 to 1. + t.Run("filtered_subset_differs_from_unfiltered", func(t *testing.T) { + store := NewMemoryStore() + branchcov0723amSeed(t, store, + branchcov0723amCountChange("ca-sub-1", now, ChangeAnomaly, SourcePulseDiff, AdapterProxmox, "vm:1"), + branchcov0723amCountChange("ca-sub-2", now, ChangeAnomaly, SourcePulseDiff, AdapterDocker, "vm:1"), + branchcov0723amCountChange("ca-sub-3", now, ChangeStateTransition, SourcePlatformEvent, AdapterProxmox, "vm:1"), + ) + unfiltered, err := store.CountRecentChangesBySourceAdapter("vm:1", time.Time{}) + if err != nil { + t.Fatalf("unfiltered err=%v", err) + } + filtered, err := store.CountRecentChangesBySourceAdapterFiltered("vm:1", time.Time{}, ResourceChangeFilters{ + SourceTypes: []ChangeSourceType{SourcePulseDiff}, + }) + if err != nil { + t.Fatalf("filtered err=%v", err) + } + if reflect.DeepEqual(unfiltered, filtered) { + t.Fatalf("unfiltered==filtered=%#v (filter had no effect)", filtered) + } + if !reflect.DeepEqual(filtered, map[ChangeSourceAdapter]int{AdapterProxmox: 1, AdapterDocker: 1}) { + t.Fatalf("filtered=%#v want {Proxmox:1, Docker:1}", filtered) + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.RecordExportAudit / GetExportAudits +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_ExportAudits(t *testing.T) { + mkRecord := func(id string, at time.Time) ExportAuditRecord { + return ExportAuditRecord{ + ID: id, + Timestamp: at, + Actor: "agent:test", + EnvelopeHash: "sha256:" + id, + Decision: ExportRedacted, + Destination: "local-llama", + Redactions: []string{"metadata.hostname"}, + } + } + + // emptyStore: GetExportAudits returns a nil slice (var out is never + // appended) and no error. + t.Run("empty_store_returns_nil_slice", func(t *testing.T) { + store := NewMemoryStore() + got, err := store.GetExportAudits(time.Time{}, 10) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got != nil { + t.Fatalf("got=%#v want nil", got) + } + }) + + // recordRoundTripsFields: RecordExportAudit appends; GetExportAudits + // returns the same concrete field values. + t.Run("record_then_get_round_trips_fields", func(t *testing.T) { + store := NewMemoryStore() + now := branchcov0723amNow + rec := mkRecord("exp-rt", now) + if err := store.RecordExportAudit(rec); err != nil { + t.Fatalf("RecordExportAudit: %v", err) + } + got, err := store.GetExportAudits(time.Time{}, 10) + if err != nil { + t.Fatalf("GetExportAudits: %v", err) + } + if len(got) != 1 { + t.Fatalf("len=%d want 1", len(got)) + } + if got[0].ID != rec.ID || got[0].Decision != rec.Decision || got[0].Destination != rec.Destination { + t.Fatalf("round-trip mismatch: %+v", got[0]) + } + if !reflect.DeepEqual(got[0].Redactions, rec.Redactions) { + t.Fatalf("redactions=%#v want %#v", got[0].Redactions, rec.Redactions) + } + if !got[0].Timestamp.Equal(rec.Timestamp) { + t.Fatalf("Timestamp=%v want %v", got[0].Timestamp, rec.Timestamp) + } + }) + + // sinceCutoffExcludesOlder: records older than `since` (strictly Before) + // are skipped while records at-or-after `since` are returned. + t.Run("since_cutoff_excludes_older", func(t *testing.T) { + store := NewMemoryStore() + base := branchcov0723amNow + branch := []ExportAuditRecord{ + mkRecord("exp-old", base.Add(-2*time.Hour)), + mkRecord("exp-mid", base.Add(-1*time.Hour)), + mkRecord("exp-new", base), + } + for _, r := range branch { + if err := store.RecordExportAudit(r); err != nil { + t.Fatalf("RecordExportAudit(%s): %v", r.ID, err) + } + } + // since between mid and new: mid (-1h) is before base-30m -> excluded; + // new (base) is at-or-after -> included; old excluded. + got, err := store.GetExportAudits(base.Add(-30*time.Minute), 10) + if err != nil { + t.Fatalf("GetExportAudits: %v", err) + } + if len(got) != 1 || got[0].ID != "exp-new" { + t.Fatalf("got=%#v want only exp-new", got) + } + }) + + // sinceInclusiveAtExact: a record whose Timestamp equals `since` must + // be returned (Before(since) is false), covering the equality side. + t.Run("since_inclusive_at_exact_timestamp", func(t *testing.T) { + store := NewMemoryStore() + at := branchcov0723amNow + if err := store.RecordExportAudit(mkRecord("exp-at", at)); err != nil { + t.Fatalf("RecordExportAudit: %v", err) + } + got, err := store.GetExportAudits(at, 10) + if err != nil { + t.Fatalf("GetExportAudits: %v", err) + } + if len(got) != 1 || got[0].ID != "exp-at" { + t.Fatalf("got=%#v want exp-at (record AT since must be included)", got) + } + }) + + // orderingMostRecentFirst: GetExportAudits iterates from the last + // inserted record backwards, so the result is newest-first. + t.Run("ordering_most_recent_insertion_first", func(t *testing.T) { + store := NewMemoryStore() + base := branchcov0723amNow + ids := []string{"exp-order-1", "exp-order-2", "exp-order-3"} + for i, id := range ids { + if err := store.RecordExportAudit(mkRecord(id, base.Add(time.Duration(i)*time.Minute))); err != nil { + t.Fatalf("RecordExportAudit(%s): %v", id, err) + } + } + got, err := store.GetExportAudits(time.Time{}, 10) + if err != nil { + t.Fatalf("GetExportAudits: %v", err) + } + gotIDs := make([]string, len(got)) + for i, r := range got { + gotIDs[i] = r.ID + } + wantIDs := []string{"exp-order-3", "exp-order-2", "exp-order-1"} + if !reflect.DeepEqual(gotIDs, wantIDs) { + t.Fatalf("order=%#v want %#v (newest-inserted first)", gotIDs, wantIDs) + } + }) + + // limitSmallerThanMatches: with limit < match count, exactly `limit` + // records are returned and they are the most-recently-inserted ones. + t.Run("limit_smaller_than_matches_returns_most_recent", func(t *testing.T) { + store := NewMemoryStore() + base := branchcov0723amNow + ids := []string{"exp-lim-1", "exp-lim-2", "exp-lim-3"} + for i, id := range ids { + if err := store.RecordExportAudit(mkRecord(id, base.Add(time.Duration(i)*time.Minute))); err != nil { + t.Fatalf("RecordExportAudit(%s): %v", id, err) + } + } + got, err := store.GetExportAudits(time.Time{}, 2) + if err != nil { + t.Fatalf("GetExportAudits: %v", err) + } + if len(got) != 2 { + t.Fatalf("len=%d want 2 (limit honoured)", len(got)) + } + if got[0].ID != "exp-lim-3" || got[1].ID != "exp-lim-2" { + t.Fatalf("got=%#v want the two most-recent [exp-lim-3, exp-lim-2]", got) + } + }) + + // limitZeroAndNegativeReturnAll: limit <= 0 disables truncation. + t.Run("limit_zero_and_negative_return_all", func(t *testing.T) { + store := NewMemoryStore() + base := branchcov0723amNow + for i := 0; i < 3; i++ { + if err := store.RecordExportAudit(mkRecord("exp-lim0-"+string(rune('a'+i)), base.Add(time.Duration(i)*time.Minute))); err != nil { + t.Fatalf("RecordExportAudit: %v", err) + } + } + gotZero, err := store.GetExportAudits(time.Time{}, 0) + if err != nil { + t.Fatalf("limit=0 err=%v", err) + } + if len(gotZero) != 3 { + t.Fatalf("limit=0 len=%d want 3", len(gotZero)) + } + gotNeg, err := store.GetExportAudits(time.Time{}, -5) + if err != nil { + t.Fatalf("limit=-5 err=%v", err) + } + if len(gotNeg) != 3 { + t.Fatalf("limit=-5 len=%d want 3", len(gotNeg)) + } + }) +} diff --git a/internal/unifiedresources/memorystore_loopreports_branchcov0723am_test.go b/internal/unifiedresources/memorystore_loopreports_branchcov0723am_test.go new file mode 100644 index 000000000..d04448120 --- /dev/null +++ b/internal/unifiedresources/memorystore_loopreports_branchcov0723am_test.go @@ -0,0 +1,649 @@ +package unifiedresources + +import ( + "errors" + "reflect" + "strings" + "testing" + "time" +) + +// Branch-coverage tests for currently-0.0%-covered MemoryStore methods in +// internal/unifiedresources/loop_reports_store.go: +// - MemoryStore.ListResourceOperatorStates +// - MemoryStore.GetLoopReport +// - MemoryStore.FindLoopReportByWindow +// - MemoryStore.UpdateLoopReportUserOutcome +// +// Every subtest constructs its OWN MemoryStore so it passes when run alone via +// -run. The newLoopReport helper and the LoopReport / ResourceOperatorState +// types come from sibling _test.go / source files in this same package. + +// branchcov0723amFullReport returns a LoopReport with EVERY optional field +// populated so a round-trip read can assert each one. Values are chosen +// already-trimmed, already-UTC, and unique so NormalizeLoopReport is a no-op +// for them and the expected post-store value can be written out by hand. +func branchcov0723amFullReport(id, scope string, windowStart, windowEnd time.Time, status LoopReportStatus) LoopReport { + r := newLoopReport(id, scope, windowEnd, status) + r.Goal = "verify recovery goal" + r.WindowStartedAt = &windowStart + r.LinkedFindingIDs = []string{"finding-1", "finding-2"} + r.LinkedAlertIDs = []string{"alert-1"} + r.LinkedActionIDs = []string{"action-1", "action-2", "action-3"} + r.LinkedPatrolRunID = "patrol-run-42" + r.Recommendation = "operator should verify cpu baseline" + r.Evidence = LoopReportEvidence{ + OperatorStateSummary: "maintenance window ended", + ActiveCriticalAlerts: 1, + ActiveWarningAlerts: 2, + ActiveCriticalFindings: 0, + ActiveWarningFindings: 3, + FailedActionsSinceWindowStart: 1, + MetricRecovery: &MetricRecoveryEvidence{ + MetricsObserved: []string{"cpu", "memory"}, + SamplesAfterEnd: 5, + Trend: "improving", + Note: "trending back to baseline", + }, + } + return r +} + +// --------------------------------------------------------------------------- +// MemoryStore.ListResourceOperatorStates +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_MemListResourceOperatorStates(t *testing.T) { + // emptyStore: the method must return a non-nil empty slice and no error. + t.Run("empty_store_returns_empty_slice", func(t *testing.T) { + store := NewMemoryStore() + got, err := store.ListResourceOperatorStates() + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if got == nil { + t.Fatal("got nil slice, want non-nil empty slice") + } + if len(got) != 0 { + t.Fatalf("len=%d want 0", len(got)) + } + }) + + // severalStates: every seeded state must come back by its canonical id, + // with its persisted scalar fields intact. + t.Run("several_states_all_returned", func(t *testing.T) { + store := NewMemoryStore() + now := time.Date(2026, 7, 23, 9, 0, 0, 0, time.UTC) + seeded := []ResourceOperatorState{ + {CanonicalID: "vm:1", IntentionallyOffline: true, Criticality: CriticalityHigh, SetAt: now, SetBy: "alice"}, + {CanonicalID: "vm:2", NeverAutoRemediate: true, Note: "do not touch", SetAt: now, SetBy: "bob"}, + {CanonicalID: "vm:3", SetAt: now, SetBy: "carol"}, + } + for _, s := range seeded { + if err := store.SetResourceOperatorState(s); err != nil { + t.Fatalf("seed %s: %v", s.CanonicalID, err) + } + } + got, err := store.ListResourceOperatorStates() + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if len(got) != len(seeded) { + t.Fatalf("len=%d want %d", len(got), len(seeded)) + } + byID := make(map[string]ResourceOperatorState, len(got)) + for _, s := range got { + byID[s.CanonicalID] = s + } + for _, want := range seeded { + g, ok := byID[want.CanonicalID] + if !ok { + t.Fatalf("canonical id %q missing from result", want.CanonicalID) + } + if g.IntentionallyOffline != want.IntentionallyOffline { + t.Fatalf("%q IntentionallyOffline=%v want %v", want.CanonicalID, g.IntentionallyOffline, want.IntentionallyOffline) + } + if g.NeverAutoRemediate != want.NeverAutoRemediate { + t.Fatalf("%q NeverAutoRemediate=%v want %v", want.CanonicalID, g.NeverAutoRemediate, want.NeverAutoRemediate) + } + if g.Criticality != want.Criticality { + t.Fatalf("%q Criticality=%q want %q", want.CanonicalID, g.Criticality, want.Criticality) + } + if g.Note != want.Note { + t.Fatalf("%q Note=%q want %q", want.CanonicalID, g.Note, want.Note) + } + if g.SetBy != want.SetBy { + t.Fatalf("%q SetBy=%q want %q", want.CanonicalID, g.SetBy, want.SetBy) + } + } + }) + + // resultIndependentOfInternalMap: mutating the returned slice / its + // elements must not affect a subsequent listing (the method returns a + // copy of each map value, not a live reference into the store). + t.Run("result_independent_of_internal_map", func(t *testing.T) { + store := NewMemoryStore() + now := time.Date(2026, 7, 23, 9, 0, 0, 0, time.UTC) + if err := store.SetResourceOperatorState(ResourceOperatorState{CanonicalID: "vm:1", SetAt: now, SetBy: "alice"}); err != nil { + t.Fatalf("seed: %v", err) + } + first, err := store.ListResourceOperatorStates() + if err != nil { + t.Fatalf("first list: %v", err) + } + if len(first) != 1 { + t.Fatalf("first len=%d want 1", len(first)) + } + // Mutate the returned copy in every way a caller might: drop the + // element, rewrite a scalar, and clear the slice header. + originalID := first[0].CanonicalID + first[0].CanonicalID = "MUTATED" + first[0].IntentionallyOffline = true + first = first[:0] + + second, err := store.ListResourceOperatorStates() + if err != nil { + t.Fatalf("second list: %v", err) + } + if len(second) != 1 { + t.Fatalf("second len=%d want 1 (mutation leaked into store)", len(second)) + } + if second[0].CanonicalID != originalID { + t.Fatalf("CanonicalID=%q want %q (mutation leaked into store)", second[0].CanonicalID, originalID) + } + if second[0].IntentionallyOffline { + t.Fatal("IntentionallyOffline=true want false (mutation leaked into store)") + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.GetLoopReport +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_MemGetLoopReport(t *testing.T) { + // missingID: a non-empty id that was never recorded -> zero value, + // found=false, no error. + t.Run("missing_id_returns_zero_value", func(t *testing.T) { + store := NewMemoryStore() + got, found, err := store.GetLoopReport("never-recorded") + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatal("found=true want false") + } + if got.ID != "" || got.Scope != "" || got.Status != "" { + t.Fatalf("expected zero LoopReport, got %#v", got) + } + }) + + // presentID: every field round-trips through record -> get. + t.Run("present_id_round_trips_every_field", func(t *testing.T) { + store := NewMemoryStore() + windowStart := time.Date(2026, 5, 12, 11, 0, 0, 0, time.UTC) + windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + report := branchcov0723amFullReport("mv-rt-full", "vm:1", windowStart, windowEnd, LoopReportStatusNeedsReview) + if err := store.RecordLoopReport(report); err != nil { + t.Fatalf("record: %v", err) + } + got, found, err := store.GetLoopReport(report.ID) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if !found { + t.Fatal("found=false want true") + } + // Scalar fields. + if got.ID != "mv-rt-full" { + t.Fatalf("ID=%q", got.ID) + } + if got.Type != LoopReportTypeMaintenanceVerification { + t.Fatalf("Type=%q", got.Type) + } + if got.Scope != "vm:1" { + t.Fatalf("Scope=%q", got.Scope) + } + if got.Trigger != "maintenance_window_end" { + t.Fatalf("Trigger=%q", got.Trigger) + } + if got.Goal != "verify recovery goal" { + t.Fatalf("Goal=%q", got.Goal) + } + if got.Status != LoopReportStatusNeedsReview { + t.Fatalf("Status=%q", got.Status) + } + if got.LinkedPatrolRunID != "patrol-run-42" { + t.Fatalf("LinkedPatrolRunID=%q", got.LinkedPatrolRunID) + } + if got.Recommendation != "operator should verify cpu baseline" { + t.Fatalf("Recommendation=%q", got.Recommendation) + } + if got.UserOutcome != "" { + t.Fatalf("UserOutcome=%q want empty (not reviewed)", got.UserOutcome) + } + if got.ReviewedBy != "" || got.ReviewNote != "" { + t.Fatalf("review fields non-empty: by=%q note=%q", got.ReviewedBy, got.ReviewNote) + } + // Time fields. + wantStarted := windowEnd.Add(time.Minute) + if !got.StartedAt.Equal(wantStarted) { + t.Fatalf("StartedAt=%v want %v", got.StartedAt, wantStarted) + } + if !got.CompletedAt.Equal(wantStarted) { + t.Fatalf("CompletedAt=%v want %v", got.CompletedAt, wantStarted) + } + if got.WindowStartedAt == nil || !got.WindowStartedAt.Equal(windowStart) { + t.Fatalf("WindowStartedAt=%v want %v", got.WindowStartedAt, windowStart) + } + if got.WindowEndedAt == nil || !got.WindowEndedAt.Equal(windowEnd) { + t.Fatalf("WindowEndedAt=%v want %v", got.WindowEndedAt, windowEnd) + } + if got.ReviewedAt != nil { + t.Fatalf("ReviewedAt=%v want nil", got.ReviewedAt) + } + // Slice fields. + if !reflect.DeepEqual(got.LinkedFindingIDs, []string{"finding-1", "finding-2"}) { + t.Fatalf("LinkedFindingIDs=%#v", got.LinkedFindingIDs) + } + if !reflect.DeepEqual(got.LinkedAlertIDs, []string{"alert-1"}) { + t.Fatalf("LinkedAlertIDs=%#v", got.LinkedAlertIDs) + } + if !reflect.DeepEqual(got.LinkedActionIDs, []string{"action-1", "action-2", "action-3"}) { + t.Fatalf("LinkedActionIDs=%#v", got.LinkedActionIDs) + } + // Evidence struct (including nested MetricRecovery). + wantEvidence := LoopReportEvidence{ + OperatorStateSummary: "maintenance window ended", + ActiveCriticalAlerts: 1, + ActiveWarningAlerts: 2, + ActiveCriticalFindings: 0, + ActiveWarningFindings: 3, + FailedActionsSinceWindowStart: 1, + MetricRecovery: &MetricRecoveryEvidence{ + MetricsObserved: []string{"cpu", "memory"}, + SamplesAfterEnd: 5, + Trend: "improving", + Note: "trending back to baseline", + }, + } + if !reflect.DeepEqual(got.Evidence, wantEvidence) { + t.Fatalf("Evidence=%#v want %#v", got.Evidence, wantEvidence) + } + }) + + // emptyID: empty and whitespace-only ids short-circuit before the lookup + // (the trim path) and return found=false with no error. + t.Run("empty_id_returns_not_found", func(t *testing.T) { + store := NewMemoryStore() + // Seed one report so a non-trimmed bug would actually find it. + windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + if err := store.RecordLoopReport(newLoopReport("mv-present", "vm:1", windowEnd, LoopReportStatusHealthy)); err != nil { + t.Fatalf("seed: %v", err) + } + for _, id := range []string{"", " ", "\t\n"} { + got, found, err := store.GetLoopReport(id) + if err != nil { + t.Fatalf("id=%q err=%v want nil", id, err) + } + if found { + t.Fatalf("id=%q found=true want false", id) + } + if got.ID != "" { + t.Fatalf("id=%q got.ID=%q want empty", id, got.ID) + } + } + }) + + // whitespaceIDStillMatches: a recorded id with surrounding whitespace in + // the lookup key still resolves (TrimSpace on the key path). + t.Run("whitespace_id_still_matches", func(t *testing.T) { + store := NewMemoryStore() + windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + report := newLoopReport("mv-ws", "vm:1", windowEnd, LoopReportStatusHealthy) + if err := store.RecordLoopReport(report); err != nil { + t.Fatalf("seed: %v", err) + } + got, found, err := store.GetLoopReport(" " + report.ID + "\t") + if err != nil || !found { + t.Fatalf("found=%v err=%v", found, err) + } + if got.ID != report.ID { + t.Fatalf("ID=%q want %q", got.ID, report.ID) + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.FindLoopReportByWindow +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_MemFindLoopReportByWindow(t *testing.T) { + windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + + // noMatch: store holds reports but none match the triple -> not found. + t.Run("no_match", func(t *testing.T) { + store := NewMemoryStore() + if err := store.RecordLoopReport(newLoopReport("mv-other", "vm:999", windowEnd, LoopReportStatusHealthy)); err != nil { + t.Fatalf("seed: %v", err) + } + got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatal("found=true want false") + } + if got.ID != "" { + t.Fatalf("got.ID=%q want empty", got.ID) + } + }) + + // exactMatch: the full report for the matching triple comes back. + t.Run("exact_match_returns_full_report", func(t *testing.T) { + store := NewMemoryStore() + windowStart := time.Date(2026, 5, 12, 11, 0, 0, 0, time.UTC) + report := branchcov0723amFullReport("mv-exact", "vm:1", windowStart, windowEnd, LoopReportStatusNeedsReview) + if err := store.RecordLoopReport(report); err != nil { + t.Fatalf("seed: %v", err) + } + got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if !found { + t.Fatal("found=false want true") + } + if got.ID != report.ID || got.Scope != "vm:1" || got.Status != LoopReportStatusNeedsReview { + t.Fatalf("got=%#v", got) + } + if got.WindowEndedAt == nil || !got.WindowEndedAt.Equal(windowEnd) { + t.Fatalf("WindowEndedAt=%v want %v", got.WindowEndedAt, windowEnd) + } + if !reflect.DeepEqual(got.LinkedActionIDs, []string{"action-1", "action-2", "action-3"}) { + t.Fatalf("LinkedActionIDs=%#v", got.LinkedActionIDs) + } + }) + + // rightCanonicalIDWrongType: a report sharing scope + window but with a + // different report type must not match. RecordLoopReport rejects unknown + // types, so the different-type report is seeded directly under the lock. + t.Run("right_canonical_id_wrong_type", func(t *testing.T) { + store := NewMemoryStore() + other := newLoopReport("mv-other-type", "vm:1", windowEnd, LoopReportStatusHealthy) + other.Type = LoopReportType("other_loop") + store.mu.Lock() + store.loopReports[other.ID] = other + store.mu.Unlock() + got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatalf("found=true want false (matched by type wrongly); got=%#v", got) + } + }) + + // rightTypeWrongCanonicalID: same type + window but a different scope must + // not match. + t.Run("right_type_wrong_canonical_id", func(t *testing.T) { + store := NewMemoryStore() + if err := store.RecordLoopReport(newLoopReport("mv-other-scope", "vm:999", windowEnd, LoopReportStatusHealthy)); err != nil { + t.Fatalf("seed: %v", err) + } + got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatalf("found=true want false (matched by scope wrongly); got=%#v", got) + } + }) + + // windowEndedAtDiffersByOneNanosecond: matching type+scope but a window + // end that differs by exactly 1ns must NOT match — the lookup is exact, + // not fuzzy. + t.Run("window_ended_at_differs_by_one_nanosecond", func(t *testing.T) { + store := NewMemoryStore() + if err := store.RecordLoopReport(newLoopReport("mv-near", "vm:1", windowEnd, LoopReportStatusHealthy)); err != nil { + t.Fatalf("seed: %v", err) + } + got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd.Add(time.Nanosecond)) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatalf("found=true want false (matched a 1ns-off window); got=%#v", got) + } + }) + + // matchedScopeTypeButNilWindowEndedAt: a report matching type+scope whose + // WindowEndedAt is nil must be skipped (the explicit nil-check continue + // arm), so no false match occurs. WindowEndedAt is legitimately nil here + // because ValidateLoopReport does not require it. + t.Run("matched_scope_type_but_nil_window_ended_at", func(t *testing.T) { + store := NewMemoryStore() + nilWindow := newLoopReport("mv-nil-window", "vm:1", windowEnd, LoopReportStatusHealthy) + nilWindow.WindowEndedAt = nil + if err := store.RecordLoopReport(nilWindow); err != nil { + t.Fatalf("seed: %v", err) + } + got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", windowEnd) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatalf("found=true want false (nil-window report was matched); got=%#v", got) + } + }) + + // guardEmptyCanonicalID: empty canonical id short-circuits the guard and + // returns not found without scanning. + t.Run("guard_empty_canonical_id", func(t *testing.T) { + store := NewMemoryStore() + got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "", windowEnd) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatalf("found=true want false; got=%#v", got) + } + }) + + // guardInvalidReportType: an unknown report type short-circuits the guard. + t.Run("guard_invalid_report_type", func(t *testing.T) { + store := NewMemoryStore() + got, found, err := store.FindLoopReportByWindow(LoopReportType("bogus"), "vm:1", windowEnd) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatalf("found=true want false; got=%#v", got) + } + }) + + // guardZeroWindowEndedAt: a zero window end short-circuits the guard. + t.Run("guard_zero_window_ended_at", func(t *testing.T) { + store := NewMemoryStore() + got, found, err := store.FindLoopReportByWindow(LoopReportTypeMaintenanceVerification, "vm:1", time.Time{}) + if err != nil { + t.Fatalf("err=%v want nil", err) + } + if found { + t.Fatalf("found=true want false; got=%#v", got) + } + }) +} + +// --------------------------------------------------------------------------- +// MemoryStore.UpdateLoopReportUserOutcome +// --------------------------------------------------------------------------- + +func TestBranchcov0723Am_MemUpdateLoopReportUserOutcome(t *testing.T) { + // emptyReportID: empty / whitespace id is rejected with ErrLoopReportInvalid + // before any lookup. + t.Run("empty_report_id_invalid", func(t *testing.T) { + store := NewMemoryStore() + err := store.UpdateLoopReportUserOutcome(" ", LoopReportUserOutcomeReviewed, "alice", "note", time.Now().UTC()) + if !errors.Is(err, ErrLoopReportInvalid) { + t.Fatalf("err=%v want ErrLoopReportInvalid", err) + } + if !strings.Contains(err.Error(), "id is required") { + t.Fatalf("err=%v want 'id is required'", err) + } + }) + + // unknownOutcome: a value outside the known enum is rejected with + // ErrLoopReportInvalid, distinct from the missing-id error. + t.Run("unknown_outcome_invalid", func(t *testing.T) { + store := NewMemoryStore() + err := store.UpdateLoopReportUserOutcome("mv-x", LoopReportUserOutcome("bogus"), "alice", "note", time.Now().UTC()) + if !errors.Is(err, ErrLoopReportInvalid) { + t.Fatalf("err=%v want ErrLoopReportInvalid", err) + } + if !strings.Contains(err.Error(), "unknown user outcome") { + t.Fatalf("err=%v want 'unknown user outcome'", err) + } + }) + + // unknownReportID: a valid id that was never recorded returns the concrete + // ErrLoopReportNotFound sentinel, not a wrapped/derived error. + t.Run("unknown_report_id_not_found", func(t *testing.T) { + store := NewMemoryStore() + err := store.UpdateLoopReportUserOutcome("mv-missing", LoopReportUserOutcomeReviewed, "alice", "note", time.Now().UTC()) + if !errors.Is(err, ErrLoopReportNotFound) { + t.Fatalf("err=%v want ErrLoopReportNotFound", err) + } + }) + + // happyAllFourFieldsRoundTrip: a successful update writes ALL four fields + // (outcome, reviewedBy, note, reviewedAt) and leaves the immutable + // status untouched. reviewedAt is supplied in a non-UTC zone and must be + // stored as its UTC equivalent. + t.Run("happy_all_four_fields_round_trip", func(t *testing.T) { + store := NewMemoryStore() + windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + report := newLoopReport("mv-update-happy", "vm:1", windowEnd, LoopReportStatusNeedsReview) + if err := store.RecordLoopReport(report); err != nil { + t.Fatalf("seed: %v", err) + } + // Non-UTC offset: 2026-05-12 14:00 +02:00 == 12:00:00 UTC. Using a + // non-UTC input exercises the reviewedAt.UTC() conversion arm. + nonUTC := time.Date(2026, 5, 12, 14, 0, 0, 0, time.FixedZone("CEST", 2*3600)) + wantUTC := nonUTC.UTC() + if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, "alice", "acknowledged", nonUTC); err != nil { + t.Fatalf("update: %v", err) + } + got, _, err := store.GetLoopReport(report.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.UserOutcome != LoopReportUserOutcomeReviewed { + t.Fatalf("UserOutcome=%q want %q", got.UserOutcome, LoopReportUserOutcomeReviewed) + } + if got.ReviewedBy != "alice" { + t.Fatalf("ReviewedBy=%q want alice", got.ReviewedBy) + } + if got.ReviewNote != "acknowledged" { + t.Fatalf("ReviewNote=%q want acknowledged", got.ReviewNote) + } + if got.ReviewedAt == nil || !got.ReviewedAt.Equal(wantUTC) { + t.Fatalf("ReviewedAt=%v want %v (UTC)", got.ReviewedAt, wantUTC) + } + // Immutable fields must be unchanged. + if got.Status != LoopReportStatusNeedsReview { + t.Fatalf("Status=%q want %q (status must not be mutated by review)", got.Status, LoopReportStatusNeedsReview) + } + }) + + // emptyReviewedByAndNoteStored: empty / whitespace reviewedBy and note are + // NOT rejected — they are trimmed and stored as empty strings. + t.Run("empty_reviewed_by_and_note_stored", func(t *testing.T) { + store := NewMemoryStore() + windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + report := newLoopReport("mv-update-empty", "vm:1", windowEnd, LoopReportStatusNeedsReview) + if err := store.RecordLoopReport(report); err != nil { + t.Fatalf("seed: %v", err) + } + if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, " ", "\t", time.Now().UTC()); err != nil { + t.Fatalf("update with empty by/note: %v (expected stored, not rejected)", err) + } + got, _, err := store.GetLoopReport(report.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.ReviewedBy != "" { + t.Fatalf("ReviewedBy=%q want empty (trimmed)", got.ReviewedBy) + } + if got.ReviewNote != "" { + t.Fatalf("ReviewNote=%q want empty (trimmed)", got.ReviewNote) + } + if got.UserOutcome != LoopReportUserOutcomeReviewed { + t.Fatalf("UserOutcome=%q want %q (outcome still written)", got.UserOutcome, LoopReportUserOutcomeReviewed) + } + }) + + // zeroReviewedAtBackfilledToNow: a zero reviewedAt is backfilled to the + // current UTC time (the IsZero() true arm) rather than being stored as + // zero. + t.Run("zero_reviewed_at_backfilled_to_now", func(t *testing.T) { + store := NewMemoryStore() + windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + report := newLoopReport("mv-update-zero-at", "vm:1", windowEnd, LoopReportStatusNeedsReview) + if err := store.RecordLoopReport(report); err != nil { + t.Fatalf("seed: %v", err) + } + before := time.Now().UTC() + if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, "alice", "note", time.Time{}); err != nil { + t.Fatalf("update: %v", err) + } + after := time.Now().UTC() + got, _, err := store.GetLoopReport(report.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if got.ReviewedAt == nil { + t.Fatal("ReviewedAt=nil want non-nil (zero should be backfilled to now)") + } + stamped := got.ReviewedAt.UTC() + if stamped.Before(before.Add(-2*time.Second)) || stamped.After(after.Add(2*time.Second)) { + t.Fatalf("ReviewedAt=%v want within [%v, %v]", stamped, before, after) + } + }) + + // updateTwiceSecondOverwrites: a second update fully overwrites the first + // across all four writable fields (not merged). + t.Run("update_twice_second_overwrites", func(t *testing.T) { + store := NewMemoryStore() + windowEnd := time.Date(2026, 5, 12, 12, 0, 0, 0, time.UTC) + report := newLoopReport("mv-update-twice", "vm:1", windowEnd, LoopReportStatusNeedsReview) + if err := store.RecordLoopReport(report); err != nil { + t.Fatalf("seed: %v", err) + } + firstAt := time.Date(2026, 5, 12, 13, 0, 0, 0, time.UTC) + if err := store.UpdateLoopReportUserOutcome(report.ID, LoopReportUserOutcomeReviewed, "alice", "first note", firstAt); err != nil { + t.Fatalf("first update: %v", err) + } + secondAt := time.Date(2026, 5, 12, 14, 0, 0, 0, time.UTC) + if err := store.UpdateLoopReportUserOutcome(report.ID, "", "bob", "second note", secondAt); err != nil { + t.Fatalf("second update: %v", err) + } + got, _, err := store.GetLoopReport(report.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + // All four fields must reflect the SECOND update. + if got.UserOutcome != "" { + t.Fatalf("UserOutcome=%q want empty (second update cleared it)", got.UserOutcome) + } + if got.ReviewedBy != "bob" { + t.Fatalf("ReviewedBy=%q want bob (second update)", got.ReviewedBy) + } + if got.ReviewNote != "second note" { + t.Fatalf("ReviewNote=%q want 'second note'", got.ReviewNote) + } + if got.ReviewedAt == nil || !got.ReviewedAt.Equal(secondAt) { + t.Fatalf("ReviewedAt=%v want %v (second update)", got.ReviewedAt, secondAt) + } + }) +}