From fc6671b92ed1cc222ebaf016e70f3a897532bd8f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 23 Jul 2026 07:25:38 +0100 Subject: [PATCH] Cover action dispatch binding and in-memory store record keeping Four new branch-coverage tests over internal/unifiedresources, taking the dispatch binding helpers and the in-memory store's action, loop report and change counting paths from zero to covered. No source or existing test touched. The dispatch binding is exercised through every validation failure arm with its concrete error, a successful bind asserted field by field, a re-bind over an already bound attempt, and blank identifiers, alongside the operation binding predicate and the empty policy posture summary proved to hand back independent pointers. The in-memory store paths are all write-then-read round trips rather than error checks: expiry, dispatch receipt lookup, dispatch completion, execution refusal and lifecycle event recording each assert the stored record afterwards, and the audit expiry is driven with nothing eligible, a mix of eligible and ineligible records proving the ineligible ones stay, and limits smaller than the eligible count. The loop report lookups pin the triple key exactly, with a window end differing by a single nanosecond proved not to match, and the user outcome update asserted through a subsequent read. The six change counters each cover an empty store, a different resource that must not be counted, the cutoff boundary on the real comparison, and for every filtered variant a filter that selects a strict subset so the filtered and unfiltered results are proved to differ. Contract-Neutral: test-only branch coverage, no contract surface touched --- ...tion_dispatch_bind_branchcov0723am_test.go | 458 ++++++ ...emorystore_actions_branchcov0723am_test.go | 1316 +++++++++++++++++ ...store_changecounts_branchcov0723am_test.go | 668 +++++++++ ...ystore_loopreports_branchcov0723am_test.go | 649 ++++++++ 4 files changed, 3091 insertions(+) create mode 100644 internal/unifiedresources/action_dispatch_bind_branchcov0723am_test.go create mode 100644 internal/unifiedresources/memorystore_actions_branchcov0723am_test.go create mode 100644 internal/unifiedresources/memorystore_changecounts_branchcov0723am_test.go create mode 100644 internal/unifiedresources/memorystore_loopreports_branchcov0723am_test.go 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) + } + }) +}