Align action audit verification projection

This commit is contained in:
rcourtman
2026-05-13 18:14:29 +01:00
parent 43b491efc7
commit f024d3b560
16 changed files with 446 additions and 36 deletions
+4 -1
View File
@@ -424,7 +424,7 @@ func TestServer_StreamSSEOnceTranslatesEventsToNotifications(t *testing.T) {
"event: heartbeat",
"",
"event: action.completed",
"data: {\"actionId\":\"x1\",\"success\":true}",
"data: {\"actionId\":\"x1\",\"success\":true,\"verification\":{\"ran\":true,\"success\":true,\"commandRedacted\":true}}",
"",
}, "\n") + "\n"
@@ -456,6 +456,9 @@ func TestServer_StreamSSEOnceTranslatesEventsToNotifications(t *testing.T) {
if !strings.Contains(body, `"method":"notifications/action.completed"`) {
t.Errorf("missing action.completed notification; got %s", body)
}
if !strings.Contains(body, `"verification":{"ran":true,"success":true,"commandRedacted":true}`) {
t.Errorf("action.completed verification must round-trip through MCP notification params; got %s", body)
}
if strings.Contains(body, "stream.connected") {
t.Errorf("stream.connected must be filtered out as transport plumbing; got %s", body)
}
@@ -95,13 +95,13 @@ describe('ActionAuditAPI', () => {
result: {
success: true,
output: 'OK',
verification: {
ran: true,
command: "systemctl is-active 'workload'",
output: 'active',
success: true,
ranAt: '2026-04-29T12:00:25Z',
},
},
verification: {
ran: true,
command: "systemctl is-active 'workload'",
output: 'active',
success: true,
ranAt: '2026-04-29T12:00:25Z',
},
},
],
@@ -110,11 +110,52 @@ describe('ActionAuditAPI', () => {
const response = await ActionAuditAPI.listActionAudits({ resourceId: 'vm:42' });
expect(response.audits).toHaveLength(1);
const v = response.audits[0].result?.verification;
const v = response.audits[0].verification;
expect(v?.ran).toBe(true);
expect(v?.command).toBe("systemctl is-active 'workload'");
expect(v?.output).toBe('active');
expect(v?.success).toBe(true);
expect(response.audits[0].result?.verification).toEqual(v);
});
it('normalizes legacy result verification onto the canonical action audit field', async () => {
apiFetchJSONMock.mockResolvedValueOnce({
audits: [
{
id: 'action-legacy-verify',
createdAt: '2026-04-29T12:00:00Z',
updatedAt: '2026-04-29T12:00:30Z',
state: 'completed',
request: {
requestId: 'req-legacy-verify',
resourceId: 'vm:42',
capabilityName: 'pulse_control',
reason: 'restart workload after backup',
requestedBy: 'pulse_patrol',
},
plan: {
actionId: 'action-legacy-verify',
requestId: 'req-legacy-verify',
allowed: true,
requiresApproval: false,
approvalPolicy: 'none',
rollbackAvailable: false,
},
result: {
success: true,
verification: {
ran: false,
success: false,
},
},
},
],
count: 1,
} as any);
const response = await ActionAuditAPI.listActionAudits({ resourceId: 'vm:42' });
expect(response.audits[0].verification).toEqual({ ran: false, success: false });
expect(response.audits[0].result?.verification).toEqual({ ran: false, success: false });
});
it('treats gated action audit endpoints as unavailable instead of throwing', async () => {
+19 -1
View File
@@ -53,7 +53,9 @@ const normalizeActionAuditListResponse = (
raw: RawActionAuditListResponse | null | undefined,
fallbackResourceId?: string,
): ActionAuditListResponse => {
const audits = objectArrayFieldOrEmpty<ActionAuditRecord>(raw, 'audits');
const audits = objectArrayFieldOrEmpty<ActionAuditRecord>(raw, 'audits').map(
normalizeActionAuditRecord,
);
const count = Number.isFinite(raw?.count) ? Number(raw?.count) : audits.length;
const resourceId =
typeof raw?.resourceId === 'string' && raw.resourceId.trim()
@@ -68,6 +70,22 @@ const normalizeActionAuditListResponse = (
};
};
const normalizeActionAuditRecord = (audit: ActionAuditRecord): ActionAuditRecord => {
const verification = audit.verification ?? audit.result?.verification;
if (!verification) return audit;
return {
...audit,
verification,
result: audit.result
? {
...audit.result,
verification: audit.result.verification ?? verification,
}
: audit.result,
};
};
export class ActionAuditAPI {
static async listActionAudits(
options?: ListActionAuditsOptions,
@@ -5,7 +5,9 @@ import { formatRelativeTime } from '@/utils/format';
import {
formatActionApprovalPolicyLabel,
formatActionCapabilityLabel,
getActionAuditVerification,
getActionAuditStatePresentation,
shouldRenderActionAuditVerification,
} from '@/utils/actionAuditPresentation';
interface ResourceActionHistoryProps {
@@ -20,6 +22,7 @@ const ActionHistoryRow: Component<{ audit: ActionAuditRecord }> = (props) => {
const state = () => getActionAuditStatePresentation(props.audit.state);
const preflight = () => props.audit.plan?.preflight;
const result = () => props.audit.result;
const verification = () => getActionAuditVerification(props.audit);
return (
<div class="rounded border border-border bg-surface-hover px-2 py-1.5 text-[10px]">
@@ -87,17 +90,15 @@ const ActionHistoryRow: Component<{ audit: ActionAuditRecord }> = (props) => {
</Show>
</div>
</Show>
<Show when={result()?.verification?.ran}>
<Show when={shouldRenderActionAuditVerification(props.audit)}>
{(() => {
const v = result()!.verification!;
const v = verification()!;
const toneClass = v.success
? 'border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300'
: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300';
return (
<div class={`rounded border px-2 py-1 text-[10px] ${toneClass}`}>
<div class="font-medium">
{v.success ? 'Verified' : 'Verification failed'}
</div>
<div class="font-medium">{v.success ? 'Verified' : 'Verification failed'}</div>
<Show when={v.command}>
<div class="mt-0.5 font-mono text-[10px] opacity-80">{v.command}</div>
</Show>
@@ -1,11 +1,12 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { cleanup, render, screen, within } from '@solidjs/testing-library';
const sourceText = readFileSync(
resolve(__dirname, '..', 'ResourceActionHistory.tsx'),
'utf-8',
);
import { ResourceActionHistory } from '../ResourceActionHistory';
import type { ActionAuditRecord } from '@/types/actionAudit';
const sourceText = readFileSync(resolve(__dirname, '..', 'ResourceActionHistory.tsx'), 'utf-8');
describe('ResourceActionHistory verification rendering', () => {
it('renders the post-dispatch verification outcome on each audit row when ran=true', () => {
@@ -14,7 +15,7 @@ describe('ResourceActionHistory verification rendering', () => {
// must surface it so operators can see "Pulse confirmed the workload
// service is active" — not just "command exit 0". Pin the wiring so the
// surface cannot silently regress to an output-only render.
expect(sourceText).toContain('result()?.verification?.ran');
expect(sourceText).toContain('shouldRenderActionAuditVerification(props.audit)');
expect(sourceText).toContain('Verified');
expect(sourceText).toContain('Verification failed');
});
@@ -28,4 +29,80 @@ describe('ResourceActionHistory verification rendering', () => {
expect(sourceText).toContain('v.output');
expect(sourceText).toContain('v.note');
});
it('renders exactly one verification row for ran=true and omits ran=false', () => {
cleanup();
render(() =>
ResourceActionHistory({
audits: [
actionAudit({
id: 'action-verified',
verification: {
ran: true,
success: true,
command: "systemctl is-active 'nginx'",
output: 'active',
note: 'service reported active',
},
}),
actionAudit({
id: 'action-unverified',
request: {
requestId: 'req-action-unverified',
resourceId: 'vm:42',
capabilityName: 'restart_service',
reason: 'Ran without a derivable verifier',
requestedBy: 'agent:ops',
},
verification: {
ran: false,
success: false,
command: 'should not render',
output: 'sensitive output',
note: 'should not render',
},
}),
],
count: 2,
loadingLabel: 'Actions loaded',
error: '',
onRetry: () => undefined,
}),
);
const actionHistory = within(screen.getByTestId('resource-action-history-section'));
expect(actionHistory.getAllByText('Verified')).toHaveLength(1);
expect(actionHistory.getByText("systemctl is-active 'nginx'")).toBeInTheDocument();
expect(actionHistory.queryByText('should not render')).toBeNull();
expect(actionHistory.queryByText('sensitive output')).toBeNull();
});
});
const actionAudit = (overrides: Partial<ActionAuditRecord> = {}): ActionAuditRecord => ({
id: overrides.id ?? 'action-1',
createdAt: '2026-04-29T12:00:00Z',
updatedAt: '2026-04-29T12:01:00Z',
state: 'completed',
request: overrides.request ?? {
requestId: 'req-1',
resourceId: 'vm:42',
capabilityName: 'restart_service',
reason: 'Restart nginx after patching',
requestedBy: 'agent:ops',
},
plan: overrides.plan ?? {
actionId: overrides.id ?? 'action-1',
requestId: 'req-1',
allowed: true,
requiresApproval: true,
approvalPolicy: 'admin',
rollbackAvailable: true,
},
result: overrides.result ?? {
success: true,
output: 'completed',
},
verification: overrides.verification,
approvals: overrides.approvals,
verificationOutcome: overrides.verificationOutcome,
});
@@ -198,7 +198,9 @@ describe('ResourceDetailDrawer change history section', () => {
// verification outcome alongside the dispatch result, not silently
// drop it. Pin the wiring so future refactors cannot regress to an
// output-only render.
expect(resourceActionHistorySource).toContain('result()?.verification?.ran');
expect(resourceActionHistorySource).toContain(
'shouldRenderActionAuditVerification(props.audit)',
);
expect(resourceActionHistorySource).toContain('Verified');
expect(resourceActionHistorySource).toContain('Verification failed');
expect(actionAuditApiSource).toContain('/api/audit/actions');
@@ -781,6 +783,13 @@ describe('ResourceDetailDrawer change history section', () => {
success: true,
output: 'nginx restarted',
},
verification: {
ran: true,
command: "systemctl is-active 'nginx'",
output: 'active',
success: true,
ranAt: '2026-04-29T12:05:20Z',
},
},
],
});
@@ -822,6 +831,9 @@ describe('ResourceDetailDrawer change history section', () => {
expect(actionHistory.getByText('Restart nginx')).toBeInTheDocument();
expect(actionHistory.getByText('Approval scoped to this resource.')).toBeInTheDocument();
expect(actionHistory.getByText('nginx restarted')).toBeInTheDocument();
expect(actionHistory.getByText('Verified')).toBeInTheDocument();
expect(actionHistory.getByText("systemctl is-active 'nginx'")).toBeInTheDocument();
expect(actionHistory.getByText('active')).toBeInTheDocument();
});
it('keeps service details summary-first until the service-local reveal is opened', () => {
+9
View File
@@ -74,6 +74,13 @@ export interface ActionAuditExecutionResult {
verification?: ActionVerificationResult;
}
export type ActionVerificationStatus = 'unknown' | 'verified' | 'unverified' | 'failed' | string;
export interface ActionVerificationOutcome {
status: ActionVerificationStatus;
evidenceSummary?: string;
}
export interface ActionAuditRecord {
id: string;
createdAt: string;
@@ -83,6 +90,8 @@ export interface ActionAuditRecord {
plan: ActionAuditPlan;
approvals?: ActionAuditApprovalRecord[];
result?: ActionAuditExecutionResult;
verification?: ActionVerificationResult;
verificationOutcome?: ActionVerificationOutcome;
}
export interface ActionAuditListResponse {
@@ -3,7 +3,9 @@ import { describe, expect, it } from 'vitest';
import {
formatActionApprovalPolicyLabel,
formatActionCapabilityLabel,
getActionAuditVerification,
getActionAuditStatePresentation,
shouldRenderActionAuditVerification,
} from '@/utils/actionAuditPresentation';
describe('actionAuditPresentation', () => {
@@ -24,4 +26,43 @@ describe('actionAuditPresentation', () => {
expect(formatActionApprovalPolicyLabel('dry_run_only')).toBe('Dry run only');
expect(formatActionApprovalPolicyLabel('mfa')).toBe('MFA approval');
});
it('uses the canonical top-level verification field for rendering decisions', () => {
expect(
getActionAuditVerification({
verification: {
ran: true,
success: true,
command: "systemctl is-active 'nginx'",
},
}),
).toMatchObject({ ran: true, command: "systemctl is-active 'nginx'" });
expect(
getActionAuditVerification({
result: {
success: true,
verification: {
ran: false,
success: false,
},
},
}),
).toEqual({ ran: false, success: false });
expect(
shouldRenderActionAuditVerification({
verification: {
ran: true,
success: true,
},
}),
).toBe(true);
expect(
shouldRenderActionAuditVerification({
verification: {
ran: false,
success: false,
},
}),
).toBe(false);
});
});
@@ -1,4 +1,8 @@
import type { ActionAuditState } from '@/types/actionAudit';
import type {
ActionAuditRecord,
ActionAuditState,
ActionVerificationResult,
} from '@/types/actionAudit';
export interface ActionAuditStatePresentation {
label: string;
@@ -76,3 +80,11 @@ export const formatActionApprovalPolicyLabel = (policy: string | undefined): str
return formatActionCapabilityLabel(policy || 'Policy');
}
};
export const getActionAuditVerification = (
audit: Pick<ActionAuditRecord, 'verification' | 'result'>,
): ActionVerificationResult | undefined => audit.verification ?? audit.result?.verification;
export const shouldRenderActionAuditVerification = (
audit: Pick<ActionAuditRecord, 'verification' | 'result'>,
): boolean => getActionAuditVerification(audit)?.ran === true;
@@ -1433,10 +1433,16 @@ func TestSetOnActionCompleted_RecordCarriesVerificationResult(t *testing.T) {
if received.Result.Verification == nil {
t.Fatal("callback record must carry a Verification block for service-restart actions — drift here breaks the certainty loop on action.completed")
}
if received.Verification == nil {
t.Fatal("callback record must carry the canonical top-level Verification block")
}
v := received.Result.Verification
if !v.Ran {
t.Error("Verification.Ran must be true after the broker dispatched the probe")
}
if received.Verification.Command != v.Command {
t.Errorf("canonical Verification.Command = %q, want %q", received.Verification.Command, v.Command)
}
if !v.Success {
t.Errorf("Verification.Success: probe returned exit 0, expected Success=true; got %+v", v)
}
+22
View File
@@ -520,6 +520,7 @@ func (h *AuditHandlers) HandleListUnifiedActionAudits(w http.ResponseWriter, r *
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query action audits", nil)
return
}
audits = normalizeUnifiedActionAuditResponse(audits)
response := map[string]any{
"audits": audits,
@@ -533,6 +534,27 @@ func (h *AuditHandlers) HandleListUnifiedActionAudits(w http.ResponseWriter, r *
json.NewEncoder(w).Encode(response)
}
func normalizeUnifiedActionAuditResponse(audits []unifiedresources.ActionAuditRecord) []unifiedresources.ActionAuditRecord {
if len(audits) == 0 {
return audits
}
normalized := make([]unifiedresources.ActionAuditRecord, 0, len(audits))
for _, audit := range audits {
canonical, err := unifiedresources.NormalizeActionAuditRecord(audit)
if err != nil {
canonical = audit
canonical.Verification = unifiedresources.CanonicalActionVerification(audit)
if canonical.Result != nil && canonical.Verification != nil && canonical.Result.Verification == nil {
result := *canonical.Result
result.Verification = canonical.Verification
canonical.Result = &result
}
}
normalized = append(normalized, canonical)
}
return normalized
}
// HandleListUnifiedActionLifecycleEvents handles GET /api/audit/actions/{id}/events.
func (h *AuditHandlers) HandleListUnifiedActionLifecycleEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
+3 -3
View File
@@ -372,9 +372,9 @@ func (b *AgentEventBroadcaster) PublishActionCompletedRecord(record unifiedresou
if record.Result != nil {
payload.Success = record.Result.Success
payload.ErrorMessage = record.Result.ErrorMessage
if v := projectAgentResourceVerification(record.Result.Verification); v != nil {
payload.Verification = v
}
}
if v := projectAgentResourceVerification(unifiedresources.CanonicalActionVerification(record)); v != nil {
payload.Verification = v
}
b.PublishActionCompleted(payload)
}
+34
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
unifiedresources "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestAgentEventBroadcaster_SubscribeReceivesPublishedEvents(t *testing.T) {
@@ -610,6 +611,39 @@ func TestAgentEventBroadcaster_PublishActionCompletedRoundTripsVerification(t *t
case <-time.After(time.Second):
t.Fatal("timed out waiting for action.completed event")
}
recordBroadcaster := NewAgentEventBroadcaster()
recordEvents, recordUnsub := recordBroadcaster.Subscribe()
defer recordUnsub()
recordBroadcaster.PublishActionCompletedRecord(unifiedresources.ActionAuditRecord{
ID: "action-verify-record",
UpdatedAt: ranAt,
State: unifiedresources.ActionStateCompleted,
Request: unifiedresources.ActionRequest{
ResourceID: "container:web-1",
CapabilityName: "restart_service",
RequestedBy: "agent:ops",
},
Result: &unifiedresources.ExecutionResult{Success: true},
Verification: &unifiedresources.ActionVerificationResult{
Ran: true,
Success: true,
Command: "systemctl is-active 'nginx'",
RanAt: ranAt,
},
})
select {
case event := <-recordEvents:
payload, ok := event.Payload.(AgentEventActionCompletedPayload)
if !ok {
t.Fatalf("record payload type: got %T", event.Payload)
}
if payload.Verification == nil || payload.Verification.Command != "systemctl is-active 'nginx'" {
t.Fatalf("record canonical verification did not project onto action.completed: %+v", payload.Verification)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for record-backed action.completed event")
}
}
func TestAgentEventBroadcaster_PublishActionCompletedAbsentVerificationOmitsField(t *testing.T) {
+71 -9
View File
@@ -171,17 +171,64 @@ func NormalizeVerificationOutcome(outcome VerificationOutcome) VerificationOutco
return outcome
}
// NormalizeActionVerificationResult applies the canonical verification field
// hygiene used by stored audit records and every action-audit projection.
func NormalizeActionVerificationResult(result *ActionVerificationResult) *ActionVerificationResult {
if result == nil {
return nil
}
normalized := *result
normalized.Command = strings.TrimSpace(normalized.Command)
normalized.Output = strings.TrimSpace(normalized.Output)
normalized.Note = strings.TrimSpace(normalized.Note)
if normalized.RanAt.IsZero() {
normalized.RanAt = time.Time{}
} else {
normalized.RanAt = normalized.RanAt.UTC()
}
if !normalized.Ran {
normalized.Command = ""
normalized.Output = ""
normalized.Success = false
normalized.RanAt = time.Time{}
normalized.Note = ""
}
return &normalized
}
func cloneActionVerificationResult(result *ActionVerificationResult) *ActionVerificationResult {
if result == nil {
return nil
}
clone := *result
return &clone
}
// CanonicalActionVerification returns the top-level verification projection for
// an audit record, falling back to legacy result.verification records while
// older persisted rows are still being read.
func CanonicalActionVerification(record ActionAuditRecord) *ActionVerificationResult {
if result := NormalizeActionVerificationResult(record.Verification); result != nil {
return result
}
if record.Result == nil {
return nil
}
return NormalizeActionVerificationResult(record.Result.Verification)
}
// ActionAuditRecord tracks the full end-to-end lifecycle of a tool invocation.
type ActionAuditRecord struct {
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
State ActionState `json:"state"`
Request ActionRequest `json:"request"`
Plan ActionPlan `json:"plan"`
Approvals []ActionApprovalRecord `json:"approvals,omitempty"`
Result *ExecutionResult `json:"result,omitempty"`
VerificationOutcome VerificationOutcome `json:"verificationOutcome"`
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
State ActionState `json:"state"`
Request ActionRequest `json:"request"`
Plan ActionPlan `json:"plan"`
Approvals []ActionApprovalRecord `json:"approvals,omitempty"`
Result *ExecutionResult `json:"result,omitempty"`
Verification *ActionVerificationResult `json:"verification,omitempty"`
VerificationOutcome VerificationOutcome `json:"verificationOutcome"`
}
// ActionLifecycleEvent represents an append-only state transition in an action's life.
@@ -555,6 +602,21 @@ func NormalizeActionAuditRecord(record ActionAuditRecord) (ActionAuditRecord, er
}
}
record.Plan.Preflight = NormalizeActionPreflight(record.Plan.Preflight, record.Request, record.Plan)
if record.Result != nil {
result := *record.Result
result.Output = strings.TrimSpace(result.Output)
result.ErrorMessage = strings.TrimSpace(result.ErrorMessage)
result.Verification = NormalizeActionVerificationResult(result.Verification)
record.Result = &result
}
record.Verification = NormalizeActionVerificationResult(record.Verification)
if record.Verification == nil && record.Result != nil {
record.Verification = cloneActionVerificationResult(record.Result.Verification)
}
if record.Verification != nil && record.Result != nil {
record.Result.Verification = cloneActionVerificationResult(record.Verification)
}
record.VerificationOutcome = NormalizeVerificationOutcome(record.VerificationOutcome)
for i := range record.Approvals {
+56 -1
View File
@@ -51,6 +51,48 @@ func TestNormalizeActionAuditRecordPopulatesGovernedPlanPreflight(t *testing.T)
if len(record.Plan.Preflight.SafetyChecks) == 0 || len(record.Plan.Preflight.VerificationSteps) == 0 {
t.Fatalf("preflight should carry safety and verification checks: %#v", record.Plan.Preflight)
}
withLegacyVerification := record
withLegacyVerification.Result = &ExecutionResult{
Success: true,
Verification: &ActionVerificationResult{
Ran: true,
Command: " systemctl is-active 'nginx' ",
Output: " active\n",
Success: true,
RanAt: now.Add(time.Minute),
},
}
withLegacyVerification, err = NormalizeActionAuditRecord(withLegacyVerification)
if err != nil {
t.Fatalf("NormalizeActionAuditRecord(legacy verification) error = %v", err)
}
if withLegacyVerification.Verification == nil || withLegacyVerification.Verification.Command != "systemctl is-active 'nginx'" || withLegacyVerification.Verification.Output != "active" {
t.Fatalf("canonical verification not populated from result verification: %#v", withLegacyVerification.Verification)
}
if withLegacyVerification.Result.Verification == nil || withLegacyVerification.Result.Verification.Command != withLegacyVerification.Verification.Command {
t.Fatalf("legacy verification not kept aligned: result=%#v canonical=%#v", withLegacyVerification.Result.Verification, withLegacyVerification.Verification)
}
withUnrunVerification := record
withUnrunVerification.Verification = &ActionVerificationResult{
Ran: false,
Command: "should not persist",
Output: "sensitive",
Success: true,
RanAt: now.Add(time.Minute),
Note: "details",
}
withUnrunVerification, err = NormalizeActionAuditRecord(withUnrunVerification)
if err != nil {
t.Fatalf("NormalizeActionAuditRecord(unrun verification) error = %v", err)
}
if withUnrunVerification.Verification == nil || withUnrunVerification.Verification.Ran {
t.Fatalf("expected canonical ran=false verification, got %#v", withUnrunVerification.Verification)
}
if withUnrunVerification.Verification.Command != "" || withUnrunVerification.Verification.Output != "" || withUnrunVerification.Verification.Note != "" || !withUnrunVerification.Verification.RanAt.IsZero() {
t.Fatalf("ran=false verification must not retain details: %#v", withUnrunVerification.Verification)
}
}
func TestNormalizeActionAuditRecordRejectsUngovernedRecords(t *testing.T) {
@@ -365,13 +407,26 @@ func TestCompleteActionExecutionRecordsResult(t *testing.T) {
},
}
updated, event, err := CompleteActionExecution(record, &ExecutionResult{Success: true, Output: "done"}, "operator@example.com", now)
updated, event, err := CompleteActionExecution(record, &ExecutionResult{
Success: true,
Output: "done",
Verification: &ActionVerificationResult{
Ran: true,
Command: "systemctl is-active 'nginx'",
Output: "active",
Success: true,
RanAt: now,
},
}, "operator@example.com", now)
if err != nil {
t.Fatalf("CompleteActionExecution: %v", err)
}
if updated.State != ActionStateCompleted || updated.Result == nil || updated.Result.Output != "done" {
t.Fatalf("completed action = %#v", updated)
}
if updated.Verification == nil || !updated.Verification.Ran || updated.Verification.Command != "systemctl is-active 'nginx'" {
t.Fatalf("completed action verification = %#v", updated.Verification)
}
if event.State != ActionStateCompleted || event.Message != "Action execution completed." {
t.Fatalf("completed event = %#v", event)
}
+18 -1
View File
@@ -1202,7 +1202,17 @@ func TestActionAuditRecord_RoundTrip(t *testing.T) {
now := time.Date(2026, 3, 18, 13, 0, 0, 0, time.UTC)
expires := now.Add(15 * time.Minute)
approvedAt := now.Add(2 * time.Minute)
result := &ExecutionResult{Success: true, Output: "completed"}
result := &ExecutionResult{
Success: true,
Output: "completed",
Verification: &ActionVerificationResult{
Ran: true,
Command: "systemctl is-active 'nginx'",
Output: "active",
Success: true,
RanAt: now.Add(4 * time.Minute),
},
}
record := ActionAuditRecord{
ID: "action-1",
@@ -1274,6 +1284,13 @@ func TestActionAuditRecord_RoundTrip(t *testing.T) {
if got.Result == nil || !got.Result.Success || got.Result.Output != result.Output {
t.Fatalf("result round-trip failed: %+v", got.Result)
}
verification := CanonicalActionVerification(got)
if verification == nil || !verification.Ran || verification.Command != result.Verification.Command {
t.Fatalf("canonical verification round-trip failed: %+v", verification)
}
if got.Result.Verification == nil || got.Result.Verification.Command != verification.Command {
t.Fatalf("result verification did not stay aligned with canonical verification: result=%+v canonical=%+v", got.Result.Verification, verification)
}
}
func TestMemoryStore_RecordActionAudit_UpsertsByID(t *testing.T) {