package subject import ( "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) // --- test doubles (layering-clean: implement the injected interfaces) --- // fakeOwners is a multi-hop OwnerResolver: refKey(child) -> parent controller. type fakeOwners map[string]Ref func (f fakeOwners) ParentOf(child Ref) (Ref, bool) { p, ok := f[refKey(child)]; return p, ok } // fakeLookup is a single-hop OwnerLookup for the operator-root hook. type fakeLookup map[string]Ref func (f fakeLookup) ImmediateOwner(child Ref) (Ref, bool) { p, ok := f[refKey(child)]; return p, ok } func obj(ns, name string, labels, annos map[string]string, owners ...metav1.OwnerReference) metav1.Object { return &metav1.ObjectMeta{Namespace: ns, Name: name, Labels: labels, Annotations: annos, OwnerReferences: owners} } func ctrlRef(kind, name, apiVersion string) metav1.OwnerReference { c := true return metav1.OwnerReference{Kind: kind, Name: name, APIVersion: apiVersion, Controller: &c} } // ============================ TIER 1: SUBJECT ============================ func TestResolveSubject_PodToDeploymentOwnerCollapse(t *testing.T) { pod := obj("ns", "web-abc12345", nil, nil, ctrlRef("ReplicaSet", "web-5d8f9c", "apps/v1")) start := Ref{Kind: "Pod", Namespace: "ns", Name: "web-abc12345"} got := ResolveSubject(start, HeuristicPodOwnerResolver{Pod: pod}, nil) want := Subject{Ref: Ref{Group: "apps", Kind: "Deployment", Namespace: "ns", Name: "web"}, Scope: ScopeWorkload, Anchor: AnchorOwnerCollapsed} if got != want { t.Errorf("Pod->RS->Deployment: got %+v, want %+v", got, want) } } func TestResolveSubject_MultiHopJobToCronJob(t *testing.T) { // A topology-backed OwnerResolver supplies the deeper Pod->Job->CronJob chain. podRef := Ref{Kind: "Pod", Namespace: "ns", Name: "backup-29384-xyz"} jobRef := Ref{Group: "batch", Kind: "Job", Namespace: "ns", Name: "backup-29384"} cronRef := Ref{Group: "batch", Kind: "CronJob", Namespace: "ns", Name: "backup"} owners := fakeOwners{refKey(podRef): jobRef, refKey(jobRef): cronRef} got := ResolveSubject(podRef, owners, nil) want := Subject{Ref: cronRef, Scope: ScopeWorkload, Anchor: AnchorOwnerCollapsed} if got != want { t.Errorf("Pod->Job->CronJob: got %+v, want %+v", got, want) } } func TestResolveSubject_BarePod(t *testing.T) { pod := obj("ns", "loner", nil, nil) // no owner refs start := Ref{Kind: "Pod", Namespace: "ns", Name: "loner"} got := ResolveSubject(start, HeuristicPodOwnerResolver{Pod: pod}, nil) if got.Anchor != AnchorBare || got.Ref != start { t.Errorf("bare pod: got %+v, want anchor=bare ref=%+v", got, start) } } func TestResolveSubject_StaticPodOwnedByNode(t *testing.T) { podRef := Ref{Kind: "Pod", Namespace: "kube-system", Name: "kube-apiserver-node1"} owners := fakeOwners{refKey(podRef): {Kind: "Node", Name: "node1"}} got := ResolveSubject(podRef, owners, nil) if got.Anchor != AnchorNode { t.Errorf("static pod (owner=Node): anchor %q, want node", got.Anchor) } if got.Ref != podRef { // must NOT collapse "up into" the Node t.Errorf("static pod must stay the pod, not become the Node: %+v", got.Ref) } } func TestResolveSubject_NonPodSelf(t *testing.T) { svc := Ref{Kind: "Service", Namespace: "ns", Name: "api"} got := ResolveSubject(svc, nil, nil) // owners=nil: resource is its own subject want := Subject{Ref: svc, Scope: ScopeService, Anchor: AnchorSelf} if got != want { t.Errorf("non-pod self: got %+v, want %+v", got, want) } } func TestResolveSubject_OperatorCRRootHop(t *testing.T) { podRef := Ref{Kind: "Pod", Namespace: "ns", Name: "pg-1"} stsRef := Ref{Group: "apps", Kind: "StatefulSet", Namespace: "ns", Name: "pg"} cnpg := Ref{Group: "postgresql.cnpg.io", Kind: "Cluster", Namespace: "ns", Name: "pg"} owners := fakeOwners{refKey(podRef): stsRef} // Pod->STS; STS has no further controller ops := DefaultOperatorRoots{Owners: fakeLookup{refKey(stsRef): cnpg}} got := ResolveSubject(podRef, owners, ops) if got.Anchor != AnchorOperatorCR || got.Ref != cnpg { t.Errorf("operator-CR hop: got %+v, want ref=%+v anchor=operator_cr", got, cnpg) } } func TestResolveSubject_StrimziKafkaRootHop(t *testing.T) { podRef := Ref{Kind: "Pod", Namespace: "ns", Name: "kafka-0"} stsRef := Ref{Group: "apps", Kind: "StatefulSet", Namespace: "ns", Name: "kafka-kafka"} kafka := Ref{Group: "kafka.strimzi.io", Kind: "Kafka", Namespace: "ns", Name: "kafka"} owners := fakeOwners{refKey(podRef): stsRef} ops := DefaultOperatorRoots{Owners: fakeLookup{refKey(stsRef): kafka}} got := ResolveSubject(podRef, owners, ops) if got.Anchor != AnchorOperatorCR || got.Ref != kafka { t.Errorf("Strimzi Kafka hop: got %+v, want ref=%+v anchor=operator_cr", got, kafka) } } func TestResolveSubject_CrossplaneXRRootHop(t *testing.T) { // Crossplane XRs are matched structurally by the apiextensions.crossplane.io // group rather than by an enumerated kind. podRef := Ref{Kind: "Pod", Namespace: "ns", Name: "db-1"} stsRef := Ref{Group: "apps", Kind: "StatefulSet", Namespace: "ns", Name: "db"} xr := Ref{Group: "apiextensions.crossplane.io", Kind: "XPostgreSQLInstance", Namespace: "ns", Name: "db"} owners := fakeOwners{refKey(podRef): stsRef} ops := DefaultOperatorRoots{Owners: fakeLookup{refKey(stsRef): xr}} got := ResolveSubject(podRef, owners, ops) if got.Anchor != AnchorOperatorCR || got.Ref != xr { t.Errorf("Crossplane XR hop: got %+v, want ref=%+v anchor=operator_cr", got, xr) } } func TestResolveSubject_UnknownOperatorDegradesToWorkload(t *testing.T) { // A CR not on the allowlist must NOT hop — degrade to the workload (raw-always). podRef := Ref{Kind: "Pod", Namespace: "ns", Name: "x-1"} stsRef := Ref{Group: "apps", Kind: "StatefulSet", Namespace: "ns", Name: "x"} unknownCR := Ref{Group: "unknown.example.io", Kind: "Widget", Namespace: "ns", Name: "x"} owners := fakeOwners{refKey(podRef): stsRef} ops := DefaultOperatorRoots{Owners: fakeLookup{refKey(stsRef): unknownCR}} got := ResolveSubject(podRef, owners, ops) if got.Ref != stsRef || got.Anchor != AnchorOwnerCollapsed { t.Errorf("unknown operator CR must degrade to the workload: got %+v", got) } } func TestScopeForKind(t *testing.T) { cases := map[string]Scope{ "Pod": ScopeWorkload, "Deployment": ScopeWorkload, "Rollout": ScopeWorkload, "CronJob": ScopeWorkload, "Service": ScopeService, "Ingress": ScopeIngress, "PersistentVolumeClaim": ScopePVC, "Node": ScopeNode, "Frobnicator": ScopeUnknown, } for k, want := range cases { if got := ScopeForKind(k); got != want { t.Errorf("ScopeForKind(%q)=%q want %q", k, got, want) } } } func TestStripReplicaSetHash(t *testing.T) { cases := map[string]string{"web-5d8f9c": "web", "my-app-7b9": "my-app", "noHyphen": "noHyphen", "-leading": "-leading"} for in, want := range cases { if got := StripReplicaSetHash(in); got != want { t.Errorf("StripReplicaSetHash(%q)=%q want %q", in, got, want) } } } func TestIssueID_DeterministicAndCategoryKeyed(t *testing.T) { a := StableID(ScopeWorkload, "apps|Deployment|ns|web", "crashloop") if a != StableID(ScopeWorkload, "apps|Deployment|ns|web", "crashloop") { t.Error("IssueID must be deterministic") } if len(a) != 16 { t.Errorf("IssueID len=%d, want 16 hex chars (8 bytes)", len(a)) } // Each component must change the id. category being part of the key is what // the detector-monotonicity contract depends on (a flapping category churns // the id), so it must hold; subject-key and scope must also be load-bearing. if StableID(ScopeWorkload, "apps|Deployment|ns|web", "image_pull_failed") == a { t.Error("IssueID must change with category") } if StableID(ScopeWorkload, "apps|Deployment|ns|other", "crashloop") == a { t.Error("IssueID must change with the subject key") } if StableID(ScopeService, "apps|Deployment|ns|web", "crashloop") == a { t.Error("IssueID must change with scope") } } // ============================ TIER 2: APP OVERLAY ============================ func TestResolveOverlay_FluxHelmReleaseTier1(t *testing.T) { ov := ResolveOverlay(obj("ns", "x", map[string]string{fluxHelmNameLabel: "podinfo", fluxHelmNSLabel: "flux-system"}, nil), false) if ov == nil || ov.Winner.Tier != TierFluxHelmRelease { t.Fatalf("flux HelmRelease: got %+v", ov) } if ov.Winner.Ref.Kind != "HelmRelease" || ov.Winner.Ref.Group != fluxHelmGroup { t.Errorf("flux HR ref: %+v", ov.Winner.Ref) } if ov.Winner.Confidence != ConfidenceHigh { t.Errorf("tier1 confidence=%q want high", ov.Winner.Confidence) } } func TestResolveOverlay_NativeHelmSentinelGroupEmpty(t *testing.T) { ov := ResolveOverlay(obj("ns", "x", nil, map[string]string{helmReleaseNameAnno: "redis", helmReleaseNSAnno: "ns"}), false) if ov == nil || ov.Winner.Tier != TierHelmRelease { t.Fatalf("native helm: got %+v", ov) } // The {Kind:HelmRelease, Group:""} sentinel must survive — the Source // classifier keys "helm" off the empty group. if ov.Winner.Ref.Kind != "HelmRelease" || ov.Winner.Ref.Group != "" { t.Errorf("native-helm sentinel broken: %+v", ov.Winner.Ref) } if ov.Winner.Confidence != ConfidenceMedium { t.Errorf("native helm (tier5) confidence=%q want medium", ov.Winner.Confidence) } } func TestResolveOverlay_ArgoInstanceBeatsHelm(t *testing.T) { // Fixed precedence: argo instance (tier 4) wins over native Helm (tier 5), // and Helm is retained as a conflict runner-up. ov := ResolveOverlay(obj("ns", "x", map[string]string{argoInstanceLabel: "checkout"}, map[string]string{helmReleaseNameAnno: "checkout"}), false) if ov == nil || ov.Winner.Tier != TierArgoInstance { t.Fatalf("argo-instance must beat helm: got %+v", ov) } if len(ov.Conflicts) != 1 || ov.Conflicts[0].Tier != TierHelmRelease { t.Errorf("helm must be retained as a conflict: %+v", ov.Conflicts) } } func TestResolveOverlay_HelmBeatsLabelTiers(t *testing.T) { // Helm release (tier 5) ranks above the app.kubernetes.io label tiers (7-8). ov := ResolveOverlay(obj("ns", "x", map[string]string{appNameLabel: "web", partOfLabel: "store"}, map[string]string{helmReleaseNameAnno: "web"}), false) if ov == nil || ov.Winner.Tier != TierHelmRelease { t.Fatalf("helm must beat labels: got %+v", ov) } if len(ov.Conflicts) != 2 { // part-of (7) + name (8) retained t.Errorf("label tiers must be retained as conflicts: %+v", ov.Conflicts) } } func TestResolveOverlay_InstanceTierDisambiguatesInstallations(t *testing.T) { // app.kubernetes.io/instance is Argo's default tracking label and Helm's // install-identity label. It must rank above the semantic name/part-of tiers // (so two releases of one chart — identical app.kubernetes.io/name — split on // instance) but below the explicit Helm-release annotation. ov := ResolveOverlay(obj("ns", "x", map[string]string{appInstanceLabel: "grafana-prod", appNameLabel: "grafana", partOfLabel: "platform"}, nil), false) if ov == nil || ov.Winner.Tier != TierInstance { t.Fatalf("instance must win over name/part-of: got %+v", ov) } if ov.Winner.Key != "ns/app/grafana-prod" || ov.Winner.Confidence != ConfidenceMedium { t.Errorf("instance winner key/confidence = %q/%q, want ns/app/grafana-prod/medium", ov.Winner.Key, ov.Winner.Confidence) } if len(ov.Conflicts) != 2 { // name + part-of retained t.Errorf("name + part-of must be retained as conflicts: %+v", ov.Conflicts) } // The explicit Helm-release annotation still outranks the instance label. ov2 := ResolveOverlay(obj("ns", "x", map[string]string{appInstanceLabel: "grafana-prod"}, map[string]string{helmReleaseNameAnno: "grafana-prod", helmReleaseNSAnno: "ns"}), false) if ov2 == nil || ov2.Winner.Tier != TierHelmRelease { t.Fatalf("Helm-release annotation must beat the instance label: got %+v", ov2) } } func TestResolveOverlay_RawWinsWhenNoSignal(t *testing.T) { if ov := ResolveOverlay(obj("ns", "x", nil, nil), true); ov != nil { t.Errorf("no signal must yield nil overlay (raw-always), got %+v", ov) } } func TestResolveOverlay_BareAppIsOptIn(t *testing.T) { o := obj("ns", "x", map[string]string{bareAppLabel: "legacy"}, nil) if ov := ResolveOverlay(o, false); ov != nil { t.Errorf("bare-app-only must be nil without allowBareApp (never silent): %+v", ov) } ov := ResolveOverlay(o, true) if ov == nil || ov.Winner.Tier != TierBareApp || ov.Winner.Confidence != ConfidenceLow { t.Errorf("bare-app with allowBareApp: got %+v", ov) } } func TestParseArgoTrackingID(t *testing.T) { cases := []struct { in string ns, name string ok bool }{ {"guestbook:apps/Deployment:default/guestbook", "", "guestbook", true}, // default {"argocd_guestbook:apps/Deployment:default/guestbook", "argocd", "guestbook", true}, // namespaced {"legacyname", "", "", false}, // no colon {"", "", "", false}, } for _, c := range cases { ns, name, ok := parseArgoTrackingID(c.in) if ns != c.ns || name != c.name || ok != c.ok { t.Errorf("parseArgoTrackingID(%q)=(%q,%q,%v) want (%q,%q,%v)", c.in, ns, name, ok, c.ns, c.name, c.ok) } } } // TestConfidenceForTier pins every tier→confidence band, including the boundary // tiers (Argo-instance #4 / Helm #5 = high/medium edge, name #8 / bare-app #9 = // medium/low edge) that the overlay spot-checks don't cover — an off-by-one in // the range checks would silently mislabel trust. func TestConfidenceForTier(t *testing.T) { cases := []struct { tier Tier want Confidence }{ {TierFluxHelmRelease, ConfidenceHigh}, {TierFluxKustomize, ConfidenceHigh}, {TierArgoTrackingID, ConfidenceHigh}, {TierArgoInstance, ConfidenceHigh}, {TierHelmRelease, ConfidenceMedium}, {TierInstance, ConfidenceMedium}, {TierPartOf, ConfidenceMedium}, {TierAppName, ConfidenceMedium}, {TierBareApp, ConfidenceLow}, } for _, c := range cases { if got := confidenceForTier(c.tier); got != c.want { t.Errorf("confidenceForTier(tier %d) = %q, want %q", c.tier, got, c.want) } } } // TestResolveSubject_CycleIsDeterministic exercises the visited-set guard: a // corrupted ownership cycle (a↔b) must terminate AND resolve to the same // canonical subject regardless of where the walk starts — a canonical identity // can't depend on traversal order. The representative is the min-key member. func TestResolveSubject_CycleIsDeterministic(t *testing.T) { a := Ref{Kind: "Foo", Namespace: "ns", Name: "a"} b := Ref{Kind: "Bar", Namespace: "ns", Name: "b"} owners := fakeOwners{refKey(a): b, refKey(b): a} fromA := ResolveSubject(a, owners, nil) fromB := ResolveSubject(b, owners, nil) if fromA.Ref == (Ref{}) || fromA.Ref != fromB.Ref { t.Fatalf("cycle subject must be start-independent: from a=%+v, from b=%+v", fromA.Ref, fromB.Ref) } // min(refKey) — "|Bar|ns|b" < "|Foo|ns|a", so both collapse to b. want := b if refKey(a) < refKey(b) { want = a } if fromA.Ref != want { t.Errorf("cycle representative = %+v, want min-key %+v", fromA.Ref, want) } } // TestHeuristicPodOwnerResolver_ControllerOnly pins the contract: only a // controller==true ownerRef yields a parent. A pod with only NON-controller // ownerRefs has no canonical owner — it must NOT be collapsed under an arbitrary // owner (the old refs[0] fallback). func TestHeuristicPodOwnerResolver_ControllerOnly(t *testing.T) { notCtrl := false nonController := metav1.OwnerReference{APIVersion: "example.com/v1", Kind: "Thing", Name: "t1", Controller: ¬Ctrl} pod := obj("ns", "p1", nil, nil, nonController) start := Ref{Kind: "Pod", Namespace: "ns", Name: "p1"} if got := ResolveSubject(start, HeuristicPodOwnerResolver{Pod: pod}, nil); got.Ref != start || got.Anchor != AnchorBare { t.Errorf("non-controller-only owner: got %+v/%s, want the pod itself (bare)", got.Ref, got.Anchor) } // A controller ref IS followed. podCtrl := obj("ns", "p2", nil, nil, ctrlRef("ReplicaSet", "web-7d8", "apps/v1")) start2 := Ref{Kind: "Pod", Namespace: "ns", Name: "p2"} if got := ResolveSubject(start2, HeuristicPodOwnerResolver{Pod: podCtrl}, nil); got.Ref.Kind != "Deployment" || got.Ref.Name != "web" { t.Errorf("controller owner: got %+v, want Deployment/web", got.Ref) } } // TestResolveSubject_StopsAtController pins the Tier-1/Tier-2 boundary: with a // controller-ownership resolver (Pod→ReplicaSet→Deployment, Deployment having no // further CONTROLLER), the Subject is the Deployment — even though an Argo // Application "manages" that Deployment. Application ownership is declarative, // not a controllerRef, so it must NEVER appear as the Subject; it belongs in // Tier-2 AppOverlay. A topology adapter that wrapped the GitOps-inclusive // EdgeManages walk would break this — hence the explicit OwnerResolver contract. func TestResolveSubject_StopsAtController(t *testing.T) { pod := Ref{Kind: "Pod", Namespace: "ns", Name: "web-7d8-xyz"} rs := Ref{Group: "apps", Kind: "ReplicaSet", Namespace: "ns", Name: "web-7d8"} dep := Ref{Group: "apps", Kind: "Deployment", Namespace: "ns", Name: "web"} app := Ref{Group: "argoproj.io", Kind: "Application", Namespace: "argocd", Name: "web-app"} // Controller chain ONLY — the Application "manages" dep but is not its // controllerRef, so the resolver does not return it. owners := fakeOwners{refKey(pod): rs, refKey(rs): dep} _ = app // present in the cluster as an overlay, never an owner here if got := ResolveSubject(pod, owners, nil); got.Ref != dep { t.Errorf("Pod subject = %+v, want Deployment %+v (Application must not collapse in)", got.Ref, dep) } if got := ResolveSubject(dep, owners, nil); got.Ref != dep { t.Errorf("Deployment subject = %+v, want itself %+v (no controller above it)", got.Ref, dep) } } // TestResolveSubject_DepthCapStops exercises maxOwnerWalkDepth: a chain longer // than the cap must stop without hanging or panicking. func TestResolveSubject_DepthCapStops(t *testing.T) { owners := fakeOwners{} var refs []Ref for i := 0; i < 18; i++ { // > maxOwnerWalkDepth (16) refs = append(refs, Ref{Kind: "K", Namespace: "ns", Name: "n" + string(rune('a'+i))}) } for i := 0; i < len(refs)-1; i++ { owners[refKey(refs[i])] = refs[i+1] } if got := ResolveSubject(refs[0], owners, nil); got.Ref.Name == "" { t.Errorf("over-cap walk produced empty subject: %+v", got) } }