diff --git a/server/internal/auditlifecycle/classify_test.go b/server/internal/auditlifecycle/classify_test.go new file mode 100644 index 0000000000..90d457ffaa --- /dev/null +++ b/server/internal/auditlifecycle/classify_test.go @@ -0,0 +1,60 @@ +package auditlifecycle + +import ( + "testing" + "time" +) + +func TestClassify(t *testing.T) { + options := Options{Now: observed, Grace: time.Hour} + old := observed.Add(-2 * time.Hour) + recent := observed.Add(-time.Minute) + tests := []struct { + name string + result Result + entry lifecycle + want string + completed int + pending int + missing int + }{ + {"terminal", Result{TailReached: true}, lifecycle{completed: 1, Finding: Finding{FirstSeenAt: old}}, "terminal_observed", 1, 0, 0}, + {"partial history", Result{}, lifecycle{Finding: Finding{FirstSeenAt: old}}, "unknown", 0, 0, 0}, + {"history gap", Result{TailReached: true, Issues: []string{"history_gap"}}, lifecycle{Finding: Finding{FirstSeenAt: old}}, "unknown", 0, 0, 0}, + {"zero time", Result{TailReached: true}, lifecycle{}, "unknown", 0, 0, 0}, + {"clock uncertain", Result{TailReached: true}, lifecycle{Finding: Finding{FirstSeenAt: old, Issues: []string{"clock_uncertain"}}}, "unknown", 0, 0, 0}, + {"within grace", Result{TailReached: true}, lifecycle{Finding: Finding{FirstSeenAt: recent}}, "pending", 0, 1, 0}, + {"past grace", Result{TailReached: true}, lifecycle{Finding: Finding{FirstSeenAt: old}}, "missing_terminal", 0, 0, 1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + classify(&test.result, &test.entry, options) + if test.entry.Classification != test.want || test.result.Completed != test.completed || + test.result.Pending != test.pending || test.result.MissingTerminal != test.missing { + t.Fatalf("classify = %q %+v, want %q", test.entry.Classification, test.result, test.want) + } + }) + } +} + +func TestResultStatus(t *testing.T) { + tests := []struct { + name string + result Result + want string + }{ + {"clean", Result{HistoryComplete: true}, "ok"}, + {"incomplete history", Result{}, "incomplete"}, + {"scan issue wins over missing terminal", Result{HistoryComplete: true, Issues: []string{"event_limit"}, MissingTerminal: 1}, "incomplete"}, + {"missing terminal", Result{HistoryComplete: true, MissingTerminal: 1}, "attention"}, + {"finding issue", Result{HistoryComplete: true, Findings: []Finding{{Issues: []string{"duplicate_start"}}}}, "attention"}, + {"finding without issues", Result{HistoryComplete: true, Findings: []Finding{{}}}, "ok"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := resultStatus(test.result); got != test.want { + t.Fatalf("resultStatus = %q, want %q", got, test.want) + } + }) + } +} diff --git a/server/internal/auditlifecycle/rebuild.go b/server/internal/auditlifecycle/rebuild.go index 652ce88d96..e39a3d5a17 100644 --- a/server/internal/auditlifecycle/rebuild.go +++ b/server/internal/auditlifecycle/rebuild.go @@ -10,9 +10,12 @@ import ( "slices" "time" + "github.com/githubnext/gh-cao/server/internal/logger" "github.com/githubnext/gh-cao/server/internal/operational" ) +var scanLog = logger.New("cao:auditlifecycle") + const ( DefaultGrace = time.Hour // MinimumGrace exceeds the rebuild's 30-minute lease and 25-minute work @@ -142,23 +145,7 @@ func Scan(ctx context.Context, reader Reader, options Options) (Result, error) { addIssue(&result.Issues, "selected_suffix") } for _, entry := range ids { - switch { - case entry.completed > 0: - entry.Classification = "terminal_observed" - result.Completed++ - case !result.TailReached || slices.Contains(result.Issues, "history_gap"): - entry.Classification = "unknown" - case entry.FirstSeenAt.IsZero() || slices.Contains(entry.Issues, "clock_uncertain"): - entry.Classification = "unknown" - case options.Now.Sub(entry.FirstSeenAt) < options.Grace: - // Recent evidence may still be active; status and lease expiry do - // not prove execution liveness or termination. - entry.Classification = "pending" - result.Pending++ - default: - entry.Classification = "missing_terminal" - result.MissingTerminal++ - } + classify(&result, entry, options) if entry.admitted == 0 { addIssue(&entry.Issues, "admission_not_observed") } @@ -177,19 +164,47 @@ func Scan(ctx context.Context, reader Reader, options Options) (Result, error) { return 0 }) slices.Sort(result.Issues) - result.Status = "ok" + result.Status = resultStatus(result) + scanLog.Printf("audit lifecycle scan finished: status=%s scanned=%d findings=%d tail_reached=%t", result.Status, result.Scanned, len(result.Findings), result.TailReached) + return result, nil +} + +// classify assigns a lifecycle its classification and updates the result tallies. +func classify(result *Result, entry *lifecycle, options Options) { + switch { + case entry.completed > 0: + entry.Classification = "terminal_observed" + result.Completed++ + case !result.TailReached || slices.Contains(result.Issues, "history_gap"): + entry.Classification = "unknown" + case entry.FirstSeenAt.IsZero() || slices.Contains(entry.Issues, "clock_uncertain"): + entry.Classification = "unknown" + case options.Now.Sub(entry.FirstSeenAt) < options.Grace: + // Recent evidence may still be active; status and lease expiry do + // not prove execution liveness or termination. + entry.Classification = "pending" + result.Pending++ + default: + entry.Classification = "missing_terminal" + result.MissingTerminal++ + } +} + +// resultStatus reports "incomplete" for partial history or scan issues, +// "attention" for missing terminals or finding issues, and "ok" otherwise. +func resultStatus(result Result) string { + if !result.HistoryComplete || len(result.Issues) > 0 { + return "incomplete" + } if result.MissingTerminal > 0 { - result.Status = "attention" + return "attention" } for _, finding := range result.Findings { if len(finding.Issues) > 0 { - result.Status = "attention" + return "attention" } } - if !result.HistoryComplete || len(result.Issues) > 0 { - result.Status = "incomplete" - } - return result, nil + return "ok" } func observe(result *Result, ids map[string]*lifecycle, event operational.AuditEvent) {