From 02ccfad4a625f6a4a4b05085f8ade35cc318143d Mon Sep 17 00:00:00 2001 From: Alex Savanovich <40720931+savme@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:08:26 -0400 Subject: [PATCH] fix: shared processor event extractors --- internal/processor/activity.go | 45 ++--- internal/processor/event.go | 88 +--------- .../processor/event_activity_parity_test.go | 163 ++++++++++++++++++ internal/processor/event_test.go | 26 ++- internal/processor/utils.go | 79 +++++++++ internal/reindex/batch.go | 8 +- internal/reindex/batch_test.go | 148 ++++++++++++++++ 7 files changed, 423 insertions(+), 134 deletions(-) create mode 100644 internal/processor/event_activity_parity_test.go create mode 100644 internal/reindex/batch_test.go diff --git a/internal/processor/activity.go b/internal/processor/activity.go index 84ded593..28634b65 100644 --- a/internal/processor/activity.go +++ b/internal/processor/activity.go @@ -142,27 +142,13 @@ func (b *ActivityBuilder) BuildFromEvent( links []cel.Link, resolveKind KindResolver, ) (*v1alpha1.Activity, error) { - regarding, _ := eventMap["regarding"].(map[string]interface{}) + // Extract the involved/regarding object using the shared fallback chain: + // "regarding" (events.k8s.io/v1) -> "involvedObject" (core/v1). + regarding := ResolveInvolvedObject(eventMap) - // Extract timestamps - var timestamp time.Time - if ts, ok := eventMap["eventTime"].(string); ok { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - if timestamp.IsZero() { - if metadata, ok := eventMap["metadata"].(map[string]interface{}); ok { - if ts, ok := metadata["creationTimestamp"].(string); ok { - if t, err := time.Parse(time.RFC3339, ts); err == nil { - timestamp = t - } - } - } - } - if timestamp.IsZero() { - timestamp = time.Now() - } + // Extract timestamp using the shared fallback chain: eventTime -> lastTimestamp + // -> firstTimestamp -> metadata.creationTimestamp -> now(). + timestamp := resolveEventTimestamp(eventMap) // Extract resource info from regarding namespace := GetNestedString(regarding, "namespace") @@ -173,15 +159,9 @@ func (b *ActivityBuilder) BuildFromEvent( // Events are typically system-generated changeSource := ChangeSourceSystem - // For events, actor is usually the reporting component - reportingController := GetNestedString(eventMap, "reportingController") - actor := v1alpha1.ActivityActor{ - Type: ActorTypeSystem, - Name: reportingController, - } - if actor.Name == "" { - actor.Name = "unknown" - } + // Resolve actor from reporting controller or source component, using the + // fallback chain shared with the live event processor. + actor := resolveActorFromEvent(eventMap) // Extract tenant from scope annotations; fall back to platform scope when absent. tenant := ExtractTenantFromAnnotations(eventMap) @@ -211,12 +191,7 @@ func (b *ActivityBuilder) BuildFromEvent( Name: name, Namespace: namespace, CreationTimestamp: metav1.NewTime(timestamp), - Labels: map[string]string{ - "activity.miloapis.com/origin-type": "event", - "activity.miloapis.com/change-source": changeSource, - "activity.miloapis.com/api-group": b.APIGroup, - "activity.miloapis.com/resource-kind": b.Kind, - }, + Labels: eventActivityLabels(changeSource, b.APIGroup, b.Kind, getStringFromMap(eventMap, "reason")), }, Spec: v1alpha1.ActivitySpec{ Summary: summary, diff --git a/internal/processor/event.go b/internal/processor/event.go index 034957dd..76269763 100644 --- a/internal/processor/event.go +++ b/internal/processor/event.go @@ -152,7 +152,7 @@ func (p *EventProcessor) processMessage(ctx context.Context, msg *nats.Msg) erro // Extract involved object info to find matching policy. // Kubernetes events have either "regarding" (events.k8s.io/v1) or // "involvedObject" (core/v1) to identify the subject resource. - involvedObject := p.getInvolvedObject(event) + involvedObject := ResolveInvolvedObject(event) if involvedObject == nil { klog.V(4).Info("Event has no involved object, skipping") return nil @@ -234,20 +234,6 @@ func (p *EventProcessor) processMessage(ctx context.Context, msg *nats.Msg) erro return nil } -// getInvolvedObject extracts the involved object from a Kubernetes event. -// Handles both v1.Event (regarding) and corev1.Event (involvedObject) formats. -func (p *EventProcessor) getInvolvedObject(event map[string]interface{}) map[string]interface{} { - // Try "regarding" first (events.k8s.io/v1). - if regarding, ok := event["regarding"].(map[string]interface{}); ok { - return regarding - } - // Fall back to "involvedObject" (v1). - if involvedObject, ok := event["involvedObject"].(map[string]interface{}); ok { - return involvedObject - } - return nil -} - // normalizeEvent creates a copy of the event with a "regarding" field. // This ensures CEL expressions can consistently use event.regarding regardless // of whether the original event used "regarding" or "involvedObject". @@ -275,41 +261,9 @@ func (p *EventProcessor) buildActivity( summary string, links []cel.Link, ) *v1alpha1.Activity { - // Extract timestamps - try eventTime first (events.k8s.io/v1). - var timestamp time.Time - if ts := getStringFromMap(event, "eventTime"); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - // Fall back to lastTimestamp or firstTimestamp. - if timestamp.IsZero() { - if ts := getStringFromMap(event, "lastTimestamp"); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - } - if timestamp.IsZero() { - if ts := getStringFromMap(event, "firstTimestamp"); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - } - // Fall back to metadata.creationTimestamp. - if timestamp.IsZero() { - if metadata, ok := event["metadata"].(map[string]interface{}); ok { - if ts := getStringFromMap(metadata, "creationTimestamp"); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - } - } - if timestamp.IsZero() { - timestamp = time.Now() - } + // Extract timestamp using the shared fallback chain: eventTime -> lastTimestamp + // -> firstTimestamp -> metadata.creationTimestamp -> now(). + timestamp := resolveEventTimestamp(event) // Extract resource info from involved object. namespace := getStringFromMap(involvedObject, "namespace") @@ -318,7 +272,7 @@ func (p *EventProcessor) buildActivity( apiVersion := getStringFromMap(involvedObject, "apiVersion") // Resolve actor from reporting controller or source component. - actor := p.resolveEventActor(event) + actor := resolveActorFromEvent(event) // Events from controllers are always system-initiated. changeSource := ChangeSourceSystem @@ -356,13 +310,7 @@ func (p *EventProcessor) buildActivity( Name: name, Namespace: namespace, CreationTimestamp: metav1.NewTime(timestamp), - Labels: map[string]string{ - "activity.miloapis.com/origin-type": "event", - "activity.miloapis.com/change-source": changeSource, - "activity.miloapis.com/api-group": matched.APIGroup, - "activity.miloapis.com/resource-kind": matched.Kind, - "activity.miloapis.com/event-reason": getStringFromMap(event, "reason"), - }, + Labels: eventActivityLabels(changeSource, matched.APIGroup, matched.Kind, getStringFromMap(event, "reason")), }, Spec: v1alpha1.ActivitySpec{ Summary: summary, @@ -387,30 +335,6 @@ func (p *EventProcessor) buildActivity( } } -// resolveEventActor extracts actor information from a Kubernetes event. -// Events are generated by controllers, so we extract the reporting controller or source component. -func (p *EventProcessor) resolveEventActor(event map[string]interface{}) v1alpha1.ActivityActor { - // Try reportingController first (events.k8s.io/v1). - reportingController := getStringFromMap(event, "reportingController") - - // Fall back to source.component (v1). - if reportingController == "" { - if source, ok := event["source"].(map[string]interface{}); ok { - reportingController = getStringFromMap(source, "component") - } - } - - // Default to unknown if we can't find the controller. - if reportingController == "" { - reportingController = "unknown" - } - - return v1alpha1.ActivityActor{ - Type: ActorTypeController, - Name: reportingController, - } -} - // publishActivity serializes and publishes an Activity to the NATS ACTIVITIES stream. func (p *EventProcessor) publishActivity(ctx context.Context, activity *v1alpha1.Activity) error { data, err := json.Marshal(activity) diff --git a/internal/processor/event_activity_parity_test.go b/internal/processor/event_activity_parity_test.go new file mode 100644 index 00000000..7bde1fae --- /dev/null +++ b/internal/processor/event_activity_parity_test.go @@ -0,0 +1,163 @@ +package processor + +import ( + "reflect" + "testing" +) + +// TestEventBuildersParity asserts EventProcessor.buildActivity (the live path) +// and ActivityBuilder.BuildFromEvent (PolicyPreview/reindex) produce identical +// output for the same legacy-format event. +func TestEventBuildersParity(t *testing.T) { + event := map[string]any{ + "metadata": map[string]any{ + "uid": "event-legacy-123", + // Differs from lastTimestamp so a wrong fallback choice is visible. + "creationTimestamp": "2024-01-15T10:30:00Z", + }, + // No eventTime; the fallback chain must pick this over creationTimestamp. + "lastTimestamp": "2024-01-14T09:00:00Z", + "reason": "Scheduled", + "message": "Successfully assigned default/my-pod to node-1", + // Legacy actor field; no reportingController. + "source": map[string]any{ + "component": "kubelet", + }, + // Legacy subject field; no regarding. + "involvedObject": map[string]any{ + "kind": "Pod", + "name": "my-pod", + "namespace": "default", + "uid": "pod-456", + "apiVersion": "v1", + }, + } + + involvedObject := ResolveInvolvedObject(event) + if involvedObject == nil { + t.Fatal("ResolveInvolvedObject() = nil, want the legacy involvedObject map") + } + + matched := &MatchedPolicy{ + PolicyName: "core-pods", + Generation: 1, + APIGroup: "", + Kind: "Pod", + Summary: "Pod my-pod was scheduled", + } + + // Live path: EventProcessor.buildActivity. + p := &EventProcessor{} + liveActivity := p.buildActivity(event, matched, involvedObject, matched.Summary, nil) + + // PolicyPreview/reindex path: ActivityBuilder.BuildFromEvent. + builder := &ActivityBuilder{APIGroup: matched.APIGroup, Kind: matched.Kind} + previewActivity, err := builder.BuildFromEvent(event, matched.Summary, nil, nil) + if err != nil { + t.Fatalf("BuildFromEvent() error = %v", err) + } + + // Confirm the fallbacks actually fired, not just that both sides agree. + + if liveActivity.Spec.Resource.Name != "my-pod" || + liveActivity.Spec.Resource.Namespace != "default" || + liveActivity.Spec.Resource.UID != "pod-456" || + liveActivity.Spec.Resource.APIVersion != "v1" { + t.Fatalf("live Resource not populated from legacy involvedObject fallback: %+v", liveActivity.Spec.Resource) + } + + if liveActivity.Spec.Actor.Type != ActorTypeController || liveActivity.Spec.Actor.Name != "kubelet" { + t.Fatalf("live Actor not resolved via source.component fallback: %+v", liveActivity.Spec.Actor) + } + + wantTimestamp := "2024-01-14T09:00:00Z" // lastTimestamp, not metadata.creationTimestamp + if got := liveActivity.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z"); got != wantTimestamp { + t.Fatalf("live CreationTimestamp not resolved via lastTimestamp fallback: got %q, want %q", got, wantTimestamp) + } + + // The two paths must now agree exactly. + if !previewActivity.CreationTimestamp.Time.Equal(liveActivity.CreationTimestamp.Time) { + t.Errorf("CreationTimestamp mismatch between buildActivity and BuildFromEvent: live=%v preview=%v", + liveActivity.CreationTimestamp.Time, previewActivity.CreationTimestamp.Time) + } + + if previewActivity.Name != liveActivity.Name { + t.Errorf("Name mismatch between buildActivity and BuildFromEvent: live=%q preview=%q", + liveActivity.Name, previewActivity.Name) + } + + if previewActivity.Spec.Resource != liveActivity.Spec.Resource { + t.Errorf("Resource mismatch between buildActivity and BuildFromEvent:\n live: %+v\n preview: %+v", + liveActivity.Spec.Resource, previewActivity.Spec.Resource) + } + + if previewActivity.Spec.Actor != liveActivity.Spec.Actor { + t.Errorf("Actor mismatch between buildActivity and BuildFromEvent:\n live: %+v\n preview: %+v", + liveActivity.Spec.Actor, previewActivity.Spec.Actor) + } + + if !reflect.DeepEqual(previewActivity.Labels, liveActivity.Labels) { + t.Errorf("Labels mismatch between buildActivity and BuildFromEvent:\n live: %+v\n preview: %+v", + liveActivity.Labels, previewActivity.Labels) + } + + // BuildFromEvent previously omitted this label entirely. + if got := previewActivity.Labels["activity.miloapis.com/event-reason"]; got != "Scheduled" { + t.Errorf("preview activity missing event-reason label: got %q, want %q", got, "Scheduled") + } + + if liveActivity.Spec.ChangeSource != previewActivity.Spec.ChangeSource { + t.Errorf("ChangeSource mismatch: live=%q preview=%q", liveActivity.Spec.ChangeSource, previewActivity.Spec.ChangeSource) + } +} + +// TestActivityBuilderBuildFromEventUsesControllerActorType asserts +// BuildFromEvent uses ActorTypeController, matching the live path (it +// previously used ActorTypeSystem). +func TestActivityBuilderBuildFromEventUsesControllerActorType(t *testing.T) { + event := map[string]any{ + "reportingController": "deployment-controller", + "regarding": map[string]any{ + "kind": "Deployment", + "name": "my-deployment", + }, + } + + builder := &ActivityBuilder{APIGroup: "apps", Kind: "Deployment"} + activity, err := builder.BuildFromEvent(event, "summary", nil, nil) + if err != nil { + t.Fatalf("BuildFromEvent() error = %v", err) + } + + if activity.Spec.Actor.Type != ActorTypeController { + t.Errorf("Actor.Type = %q, want %q", activity.Spec.Actor.Type, ActorTypeController) + } + if activity.Spec.Actor.Name != "deployment-controller" { + t.Errorf("Actor.Name = %q, want %q", activity.Spec.Actor.Name, "deployment-controller") + } +} + +// TestActivityBuilderBuildFromEventTimestampFallbackChain asserts +// BuildFromEvent falls back to lastTimestamp (it previously skipped straight +// to creationTimestamp). +func TestActivityBuilderBuildFromEventTimestampFallbackChain(t *testing.T) { + event := map[string]any{ + "lastTimestamp": "2024-03-01T12:00:00Z", + "regarding": map[string]any{ + "kind": "Pod", + "name": "my-pod", + }, + } + + builder := &ActivityBuilder{APIGroup: "", Kind: "Pod"} + activity, err := builder.BuildFromEvent(event, "summary", nil, nil) + if err != nil { + t.Fatalf("BuildFromEvent() error = %v", err) + } + + want := "2024-03-01T12:00:00Z" + got := activity.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z") + if got != want { + t.Errorf("CreationTimestamp = %q, want %q (should fall back to lastTimestamp)", got, want) + } +} diff --git a/internal/processor/event_test.go b/internal/processor/event_test.go index 2fd16973..cb5a5a7a 100644 --- a/internal/processor/event_test.go +++ b/internal/processor/event_test.go @@ -5,8 +5,6 @@ import ( ) func TestGetInvolvedObject(t *testing.T) { - p := &EventProcessor{} - tests := []struct { name string event map[string]any @@ -61,19 +59,19 @@ func TestGetInvolvedObject(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := p.getInvolvedObject(tt.event) + got := ResolveInvolvedObject(tt.event) if tt.wantNil { if got != nil { - t.Errorf("getInvolvedObject() = %v, want nil", got) + t.Errorf("ResolveInvolvedObject() = %v, want nil", got) } return } if got == nil { - t.Errorf("getInvolvedObject() = nil, want non-nil") + t.Errorf("ResolveInvolvedObject() = nil, want non-nil") return } if kind := getStringFromMap(got, "kind"); kind != tt.wantKind { - t.Errorf("getInvolvedObject() kind = %v, want %v", kind, tt.wantKind) + t.Errorf("ResolveInvolvedObject() kind = %v, want %v", kind, tt.wantKind) } }) } @@ -102,13 +100,11 @@ func TestParseAPIGroup(t *testing.T) { } func TestResolveEventActor(t *testing.T) { - p := &EventProcessor{} - tests := []struct { - name string - event map[string]any - wantType string - wantName string + name string + event map[string]any + wantType string + wantName string }{ { name: "events.k8s.io/v1 with reportingController", @@ -149,12 +145,12 @@ func TestResolveEventActor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - actor := p.resolveEventActor(tt.event) + actor := resolveActorFromEvent(tt.event) if actor.Type != tt.wantType { - t.Errorf("resolveEventActor() Type = %v, want %v", actor.Type, tt.wantType) + t.Errorf("resolveActorFromEvent() Type = %v, want %v", actor.Type, tt.wantType) } if actor.Name != tt.wantName { - t.Errorf("resolveEventActor() Name = %v, want %v", actor.Name, tt.wantName) + t.Errorf("resolveActorFromEvent() Name = %v, want %v", actor.Name, tt.wantName) } }) } diff --git a/internal/processor/utils.go b/internal/processor/utils.go index aa472c4b..9325da05 100644 --- a/internal/processor/utils.go +++ b/internal/processor/utils.go @@ -3,6 +3,7 @@ package processor import ( "fmt" "strings" + "time" authnv1 "k8s.io/api/authentication/v1" @@ -211,3 +212,81 @@ func ExtractTenantFromAnnotations(eventMap map[string]any) v1alpha1.ActivityTena return tenant } + +// ResolveInvolvedObject extracts an event's subject object, preferring the +// modern "regarding" field (events.k8s.io/v1) over the legacy "involvedObject" +// field (core/v1). Shared by every Event-to-Activity code path to prevent +// divergence. +func ResolveInvolvedObject(event map[string]interface{}) map[string]interface{} { + if regarding, ok := event["regarding"].(map[string]interface{}); ok { + return regarding + } + if involvedObject, ok := event["involvedObject"].(map[string]interface{}); ok { + return involvedObject + } + return nil +} + +// resolveEventTimestamp extracts a timestamp from an event map, trying in +// order: eventTime, lastTimestamp, firstTimestamp, metadata.creationTimestamp, +// then now(). A candidate must parse and be non-zero, or it falls through to +// the next one. +func resolveEventTimestamp(event map[string]interface{}) time.Time { + if ts := getStringFromMap(event, "eventTime"); ts != "" { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil && !t.IsZero() { + return t + } + } + if ts := getStringFromMap(event, "lastTimestamp"); ts != "" { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil && !t.IsZero() { + return t + } + } + if ts := getStringFromMap(event, "firstTimestamp"); ts != "" { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil && !t.IsZero() { + return t + } + } + if metadata, ok := event["metadata"].(map[string]interface{}); ok { + if ts := getStringFromMap(metadata, "creationTimestamp"); ts != "" { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil && !t.IsZero() { + return t + } + } + } + return time.Now() +} + +// resolveActorFromEvent resolves an event's acting controller: reportingController, +// then the legacy source.component, then "unknown". Always attributed as a +// controller actor. +func resolveActorFromEvent(event map[string]interface{}) v1alpha1.ActivityActor { + reportingController := getStringFromMap(event, "reportingController") + + if reportingController == "" { + if source, ok := event["source"].(map[string]interface{}); ok { + reportingController = getStringFromMap(source, "component") + } + } + + if reportingController == "" { + reportingController = "unknown" + } + + return v1alpha1.ActivityActor{ + Type: ActorTypeController, + Name: reportingController, + } +} + +// eventActivityLabels builds the standard label set for an Activity generated +// from a Kubernetes event, shared across all Event-to-Activity code paths. +func eventActivityLabels(changeSource, apiGroup, kind, eventReason string) map[string]string { + return map[string]string{ + "activity.miloapis.com/origin-type": "event", + "activity.miloapis.com/change-source": changeSource, + "activity.miloapis.com/api-group": apiGroup, + "activity.miloapis.com/resource-kind": kind, + "activity.miloapis.com/event-reason": eventReason, + } +} diff --git a/internal/reindex/batch.go b/internal/reindex/batch.go index c67fce41..b84b5cfd 100644 --- a/internal/reindex/batch.go +++ b/internal/reindex/batch.go @@ -182,8 +182,12 @@ func (r *Reindexer) evaluateEventBatch(ctx context.Context, batch []map[string]i var activities []*v1alpha1.Activity for _, eventMap := range batch { - // Extract apiGroup and kind from the event's regarding field - regarding, _ := eventMap["regarding"].(map[string]interface{}) + // Extract apiGroup and kind from the event's involved/regarding object, + // using the shared fallback chain: "regarding" (events.k8s.io/v1) -> + // "involvedObject" (core/v1). This must match the resolution used by + // processor.ActivityBuilder.BuildFromEvent so policy matching and + // activity building agree on the same involved object. + regarding := processor.ResolveInvolvedObject(eventMap) if regarding == nil { continue } diff --git a/internal/reindex/batch_test.go b/internal/reindex/batch_test.go new file mode 100644 index 00000000..02c90c1e --- /dev/null +++ b/internal/reindex/batch_test.go @@ -0,0 +1,148 @@ +package reindex + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.miloapis.com/activity/internal/activityprocessor" + "go.miloapis.com/activity/internal/processor" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// newTestReindexerForPodEvents builds a *Reindexer whose PolicyCache matches +// any core/v1 Pod event with reason "SomeReason". +func newTestReindexerForPodEvents(t *testing.T) *Reindexer { + t.Helper() + + policy := &v1alpha1.ActivityPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "core-pods"}, + Spec: v1alpha1.ActivityPolicySpec{ + Resource: v1alpha1.ActivityPolicyResource{ + APIGroup: "", + Kind: "Pod", + }, + EventRules: []v1alpha1.ActivityPolicyRule{ + { + Name: "rule-pod-events", + Match: `event.reason == "SomeReason"`, + Summary: `Pod event occurred`, + }, + }, + }, + } + + cache := activityprocessor.NewPolicyCache() + if err := cache.Add(policy, "pods"); err != nil { + t.Fatalf("failed to add policy to cache: %v", err) + } + + fakeKindResolver := processor.KindResolver(func(apiGroup, resource string) (string, error) { + return "Pod", nil + }) + + return &Reindexer{ + policyCache: cache, + kindResolver: fakeKindResolver, + } +} + +// TestEvaluateEventBatch_LegacyInvolvedObject asserts evaluateEventBatch +// resolves the legacy "involvedObject" field (it previously only checked +// "regarding", producing zero activities for this input). +func TestEvaluateEventBatch_LegacyInvolvedObject(t *testing.T) { + r := newTestReindexerForPodEvents(t) + + batch := []map[string]interface{}{ + { + "metadata": map[string]interface{}{"uid": "test-uid-1"}, + "reason": "SomeReason", + "involvedObject": map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "namespace": "default", + "name": "my-pod", + "uid": "pod-uid-1", + }, + }, + } + + activities, err := r.evaluateEventBatch(context.Background(), batch) + if err != nil { + t.Fatalf("evaluateEventBatch() error = %v", err) + } + + if len(activities) != 1 { + t.Fatalf("len(activities) = %d, want 1", len(activities)) + } + + got := activities[0].Spec.Resource + if got == (v1alpha1.ActivityResource{}) { + t.Fatalf("Spec.Resource is empty; want it populated from legacy involvedObject fields") + } + if got.Namespace != "default" { + t.Errorf("Resource.Namespace = %q, want %q", got.Namespace, "default") + } + if got.Name != "my-pod" { + t.Errorf("Resource.Name = %q, want %q", got.Name, "my-pod") + } + if got.UID != "pod-uid-1" { + t.Errorf("Resource.UID = %q, want %q", got.UID, "pod-uid-1") + } + if got.APIVersion != "v1" { + t.Errorf("Resource.APIVersion = %q, want %q", got.APIVersion, "v1") + } + if got.Kind != "Pod" { + t.Errorf("Resource.Kind = %q, want %q", got.Kind, "Pod") + } +} + +// TestEvaluateEventBatch_ModernRegarding confirms the "regarding" path still +// works alongside the legacy fallback. +func TestEvaluateEventBatch_ModernRegarding(t *testing.T) { + r := newTestReindexerForPodEvents(t) + + batch := []map[string]interface{}{ + { + "metadata": map[string]interface{}{"uid": "test-uid-2"}, + "reason": "SomeReason", + "regarding": map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "namespace": "default", + "name": "my-pod-2", + "uid": "pod-uid-2", + }, + }, + } + + activities, err := r.evaluateEventBatch(context.Background(), batch) + if err != nil { + t.Fatalf("evaluateEventBatch() error = %v", err) + } + + if len(activities) != 1 { + t.Fatalf("len(activities) = %d, want 1", len(activities)) + } + + got := activities[0].Spec.Resource + if got == (v1alpha1.ActivityResource{}) { + t.Fatalf("Spec.Resource is empty; want it populated from regarding fields") + } + if got.Namespace != "default" { + t.Errorf("Resource.Namespace = %q, want %q", got.Namespace, "default") + } + if got.Name != "my-pod-2" { + t.Errorf("Resource.Name = %q, want %q", got.Name, "my-pod-2") + } + if got.UID != "pod-uid-2" { + t.Errorf("Resource.UID = %q, want %q", got.UID, "pod-uid-2") + } + if got.APIVersion != "v1" { + t.Errorf("Resource.APIVersion = %q, want %q", got.APIVersion, "v1") + } + if got.Kind != "Pod" { + t.Errorf("Resource.Kind = %q, want %q", got.Kind, "Pod") + } +}