77 "github.com/skyhook-io/radar/pkg/subject"
88 "github.com/skyhook-io/radar/pkg/topology"
99 corev1 "k8s.io/api/core/v1"
10+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
1011)
1112
1213// rawInput builds a workload with no label overlay and its own structural root
@@ -141,6 +142,161 @@ func TestGroupApplications_StructuralManagerRoot(t *testing.T) {
141142 }
142143}
143144
145+ func TestGroupApplications_SourceRefRequiresExactSource (t * testing.T ) {
146+ helmApp := overlayInput ("Deployment" , "prod" , "api" , "1.0" , "healthy" , subject .TierHelmRelease , "prod/HelmRelease/checkout" , subject .ConfidenceMedium )
147+ helmApp .overlay .Winner .Ref = subject.Ref {Kind : "HelmRelease" , Namespace : "prod" , Name : "checkout" }
148+ helmApp .source = sourceRefForInput (helmApp .overlay , helmApp .rootKind , helmApp .rootKey )
149+
150+ labelApp := overlayInput ("Deployment" , "prod" , "payments-worker" , "1.0" , "healthy" , subject .TierPartOf , "prod/app/payments" , subject .ConfidenceMedium )
151+ labelApp .overlay .Winner .Ref = subject.Ref {Kind : "app" , Namespace : "prod" , Name : "payments" }
152+ labelApp .source = sourceRefForInput (labelApp .overlay , labelApp .rootKind , labelApp .rootKey )
153+
154+ structuralApp := rawInput ("Deployment" , "prod" , "admin" , "1.0" , "healthy" )
155+ structuralApp .rootKey , structuralApp .rootKind = "argocd/Application/admin" , "Application"
156+ structuralApp .source = sourceRefForInput (structuralApp .overlay , structuralApp .rootKind , structuralApp .rootKey )
157+
158+ rows := groupApplications ([]appWorkloadInput {helmApp , labelApp , structuralApp })
159+
160+ helmRow := rowByName (rows , "checkout" )
161+ if helmRow == nil || helmRow .SourceRef == nil || helmRow .SourceRef .Type != "helm" || helmRow .SourceRef .Name != "checkout" {
162+ t .Fatalf ("native Helm exact source ref missing: %+v" , helmRow )
163+ }
164+ labelRow := rowByName (rows , "payments" )
165+ if labelRow == nil || labelRow .SourceRef != nil {
166+ t .Fatalf ("label-inferred app should not expose source ref, got %+v" , labelRow )
167+ }
168+ structuralRow := rowByName (rows , "admin" )
169+ if structuralRow == nil || structuralRow .SourceRef == nil || structuralRow .SourceRef .Type != "gitops" || structuralRow .SourceRef .Tool != "argocd" {
170+ t .Fatalf ("structural GitOps source ref missing: %+v" , structuralRow )
171+ }
172+ }
173+
174+ func TestGroupApplications_SourceRefRequiresEveryWorkload (t * testing.T ) {
175+ api := overlayInput ("Deployment" , "prod" , "checkout-api" , "1.0" , "healthy" , subject .TierHelmRelease , "prod/HelmRelease/checkout" , subject .ConfidenceMedium )
176+ api .overlay .Winner .Ref = subject.Ref {Kind : "HelmRelease" , Namespace : "prod" , Name : "checkout" }
177+ api .source = sourceRefForInput (api .overlay , api .rootKind , api .rootKey )
178+
179+ worker := overlayInput ("Deployment" , "prod" , "checkout-worker" , "1.0" , "healthy" , subject .TierHelmRelease , "prod/HelmRelease/checkout" , subject .ConfidenceMedium )
180+
181+ rows := groupApplications ([]appWorkloadInput {api , worker })
182+ checkout := rowByName (rows , "checkout" )
183+ if checkout == nil || len (checkout .Workloads ) != 2 {
184+ t .Fatalf ("checkout app missing or malformed: %+v" , rows )
185+ }
186+ if checkout .SourceRef != nil {
187+ t .Fatalf ("partial source evidence should not expose an app-level source ref: %+v" , checkout .SourceRef )
188+ }
189+ }
190+
191+ func TestSetStrictSourceRefMarksConflictingSources (t * testing.T ) {
192+ row := appRow {}
193+ setStrictSourceRef (& row , []appWorkloadInput {
194+ {source : & appSourceRef {Type : "helm" , Tool : "helm" , Kind : "HelmRelease" , Namespace : "prod" , Name : "checkout" }},
195+ {source : & appSourceRef {Type : "gitops" , Tool : "argocd" , Group : "argoproj.io" , Kind : "Application" , Namespace : "argocd" , Name : "checkout" }},
196+ })
197+ if row .SourceRef != nil || row .sourceStrict || ! row .sourceConflict {
198+ t .Fatalf ("conflicting strict sources should mark conflict only: ref=%+v strict=%v conflict=%v" , row .SourceRef , row .sourceStrict , row .sourceConflict )
199+ }
200+ mergeSourceRef (& row , & appSourceRef {Type : "gitops" , Tool : "argocd" , Group : "argoproj.io" , Kind : "Application" , Namespace : "argocd" , Name : "checkout" })
201+ if row .SourceRef != nil {
202+ t .Fatalf ("managed source should not attach after strict source conflict: %+v" , row .SourceRef )
203+ }
204+ }
205+
206+ func TestManagedSourceRefs_CrossNamespaceArgoApplication (t * testing.T ) {
207+ app := & unstructured.Unstructured {Object : map [string ]any {
208+ "metadata" : map [string ]any {"namespace" : "argocd" , "name" : "billing" },
209+ "spec" : map [string ]any {
210+ "destination" : map [string ]any {"namespace" : "team-a" },
211+ },
212+ "status" : map [string ]any {"resources" : []any {
213+ map [string ]any {"kind" : "Deployment" , "name" : "api" },
214+ map [string ]any {"kind" : "Deployment" , "namespace" : "team-a" , "name" : "worker" },
215+ }},
216+ }}
217+ sources := map [string ][]appSourceRef {}
218+ addArgoManagedSourceRefs (sources , []* unstructured.Unstructured {app })
219+ ref := commonManagedSourceRef ([]appWorkload {
220+ {Kind : "Deployment" , Namespace : "team-a" , Name : "api" },
221+ {Kind : "Deployment" , Namespace : "team-a" , Name : "worker" },
222+ }, sources )
223+ if ref == nil || ref .Tool != "argocd" || ref .Namespace != "argocd" || ref .Name != "billing" {
224+ t .Fatalf ("cross-namespace Argo source ref = %+v, want argocd/Application/billing" , ref )
225+ }
226+ }
227+
228+ func TestMergeSourceRefPreservesStrictSourceRefOnManagedConflict (t * testing.T ) {
229+ row := appRow {
230+ SourceRef : & appSourceRef {Type : "helm" , Tool : "helm" , Kind : "HelmRelease" , Namespace : "prod" , Name : "checkout" },
231+ sourceStrict : true ,
232+ }
233+ mergeSourceRef (& row , & appSourceRef {Type : "gitops" , Tool : "argocd" , Group : "argoproj.io" , Kind : "Application" , Namespace : "argocd" , Name : "checkout" })
234+ if row .SourceRef == nil || row .SourceRef .Type != "helm" || row .SourceRef .Name != "checkout" {
235+ t .Fatalf ("strict source ref should survive managed conflict: %+v" , row .SourceRef )
236+ }
237+ if row .sourceConflict {
238+ t .Fatalf ("strict source ref conflict should not mark row conflicted" )
239+ }
240+ }
241+
242+ func TestMergeSourceRefClearsWeakConflict (t * testing.T ) {
243+ row := appRow {SourceRef : & appSourceRef {Type : "helm" , Tool : "helm" , Kind : "HelmRelease" , Namespace : "prod" , Name : "checkout" }}
244+ mergeSourceRef (& row , & appSourceRef {Type : "gitops" , Tool : "argocd" , Group : "argoproj.io" , Kind : "Application" , Namespace : "argocd" , Name : "checkout" })
245+ if row .SourceRef != nil || ! row .sourceConflict {
246+ t .Fatalf ("weak source conflict should clear source ref and mark conflict: ref=%+v conflict=%v" , row .SourceRef , row .sourceConflict )
247+ }
248+ }
249+
250+ func TestHistorySummaryPrioritizesCurrentIncidents (t * testing.T ) {
251+ summary := historySummary (
252+ []appHistoryAnchor {{Title : "Helm revision 3" , Status : "deployed" , Revision : "3" , Timestamp : "2026-07-08T10:00:00Z" }},
253+ []appHistoryIncident {{Title : "FailedScheduling on Pod/api" , Message : "0/9 nodes are available" , LastSeen : "2026-07-08T11:00:00Z" }},
254+ )
255+ if summary == nil || summary .State != "incident" || summary .Title != "Current incident: FailedScheduling on Pod/api" || summary .Detail != "0/9 nodes are available" {
256+ t .Fatalf ("incident summary = %+v" , summary )
257+ }
258+
259+ summary = historySummary (
260+ []appHistoryAnchor {{Title : "Helm revision 3" , Status : "deployed" , Revision : "3" , Message : "Upgrade complete" , Timestamp : "2026-07-08T10:00:00Z" }},
261+ nil ,
262+ )
263+ if summary == nil || summary .State != "change" || summary .Detail != "deployed · 3 · Upgrade complete" {
264+ t .Fatalf ("change summary = %+v" , summary )
265+ }
266+
267+ summary = historySummary (nil , nil )
268+ if summary == nil || summary .State != "none" {
269+ t .Fatalf ("empty summary = %+v" , summary )
270+ }
271+ }
272+
273+ func TestHistoryIncidentsSortAndTimestampFallbacks (t * testing.T ) {
274+ events := []appEvent {
275+ {Reason : "Older" , Object : "Pod/old" , Message : "old" , Count : 1 , LastSeen : "2026-07-08T10:00:00Z" },
276+ {Reason : "Newer" , Object : "Pod/new" , Message : "new" , Count : 3 , FirstSeen : "2026-07-08T09:00:00Z" , LastSeen : "2026-07-08T11:00:00Z" },
277+ }
278+ incidents := historyIncidents (events )
279+ if len (incidents ) != 2 || incidents [0 ].Title != "Newer on Pod/new" || incidents [0 ].Count != 3 || incidents [0 ].FirstSeen == "" {
280+ t .Fatalf ("incidents = %+v" , incidents )
281+ }
282+ }
283+
284+ func TestGitOpsPluralKind (t * testing.T ) {
285+ cases := map [string ]string {
286+ "Application" : "applications" ,
287+ "ApplicationSet" : "applicationsets" ,
288+ "AppProject" : "appprojects" ,
289+ "Kustomization" : "kustomizations" ,
290+ "HelmRelease" : "helmreleases" ,
291+ "Deployment" : "" ,
292+ }
293+ for kind , want := range cases {
294+ if got := gitOpsPluralKind (kind ); got != want {
295+ t .Fatalf ("gitOpsPluralKind(%q) = %q, want %q" , kind , got , want )
296+ }
297+ }
298+ }
299+
144300// Over-merge guardrail: two distinct apps that share a satellite Service must
145301// NOT fuse. Satellites are attached, never used to partition.
146302func TestGroupApplications_SharedSatelliteDoesNotMerge (t * testing.T ) {
@@ -166,23 +322,25 @@ func TestGroupApplications_RelationshipCountsDeduplicateSharedRefs(t *testing.T)
166322 }
167323 a := overlayInput ("Deployment" , "prod" , "api" , "1.0" , "healthy" , subject .TierPartOf , "prod/app/checkout" , subject .ConfidenceMedium )
168324 a .rels = & appRelationships {
169- configRefs : map [string ]struct {}{refKey (ref ("ConfigMap" , "prod" , "shared-config" )): {}},
170- scalerRefs : map [string ]struct {}{refKey (ref ("HorizontalPodAutoscaler" , "prod" , "checkout" )): {}},
171- pdbRefs : map [string ]struct {}{refKey (ref ("PodDisruptionBudget" , "prod" , "checkout" )): {}},
325+ configRefs : map [string ]struct {}{refKey (ref ("ConfigMap" , "prod" , "shared-config" )): {}},
326+ scalerRefs : map [string ]struct {}{refKey (ref ("HorizontalPodAutoscaler" , "prod" , "checkout" )): {}},
327+ storageRefs : map [string ]struct {}{refKey (ref ("PersistentVolumeClaim" , "prod" , "checkout-data" )): {}},
328+ pdbRefs : map [string ]struct {}{refKey (ref ("PodDisruptionBudget" , "prod" , "checkout" )): {}},
172329 }
173330 b := overlayInput ("Deployment" , "prod" , "worker" , "1.0" , "healthy" , subject .TierPartOf , "prod/app/checkout" , subject .ConfidenceMedium )
174331 b .rels = & appRelationships {
175- configRefs : map [string ]struct {}{refKey (ref ("ConfigMap" , "prod" , "shared-config" )): {}},
176- scalerRefs : map [string ]struct {}{refKey (ref ("HorizontalPodAutoscaler" , "prod" , "checkout" )): {}},
177- pdbRefs : map [string ]struct {}{refKey (ref ("PodDisruptionBudget" , "prod" , "checkout" )): {}},
332+ configRefs : map [string ]struct {}{refKey (ref ("ConfigMap" , "prod" , "shared-config" )): {}},
333+ scalerRefs : map [string ]struct {}{refKey (ref ("HorizontalPodAutoscaler" , "prod" , "checkout" )): {}},
334+ storageRefs : map [string ]struct {}{refKey (ref ("PersistentVolumeClaim" , "prod" , "checkout-data" )): {}},
335+ pdbRefs : map [string ]struct {}{refKey (ref ("PodDisruptionBudget" , "prod" , "checkout" )): {}},
178336 }
179337
180338 rows := groupApplications ([]appWorkloadInput {a , b })
181339 r := rowByName (rows , "checkout" )
182340 if r == nil || r .Relationships == nil {
183341 t .Fatalf ("checkout relationships missing: %+v" , rows )
184342 }
185- if r .Relationships .Configs != 1 || r .Relationships .Scalers != 1 || r .Relationships .PDBs != 1 {
343+ if r .Relationships .Configs != 1 || r .Relationships .Scalers != 1 || r .Relationships .Storage != 1 || r . Relationships . PDBs != 1 {
186344 t .Fatalf ("relationship counts = %+v, want each shared ref counted once" , r .Relationships )
187345 }
188346}
0 commit comments