@skyhook-io/k8s-ui 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/resources/ResourcesView.tsx +338 -57
  25. package/src/components/resources/index.ts +1 -0
  26. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  27. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  28. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  29. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  30. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  31. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  32. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  33. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  34. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  35. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  36. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  37. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  39. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  40. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  41. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  42. package/src/components/resources/renderers/index.ts +8 -0
  43. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  44. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  45. package/src/components/resources/resource-utils-flux.ts +14 -0
  46. package/src/components/resources/resource-utils.ts +1 -0
  47. package/src/components/resources/resources-column-filter.test.ts +74 -0
  48. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  50. package/src/components/ui/YamlEditor.tsx +28 -15
  51. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  52. package/src/index.ts +3 -0
  53. package/src/types/index.ts +1 -0
  54. package/src/types/rbac.ts +99 -0
  55. package/src/utils/api-resources.ts +9 -1
  56. package/src/utils/index.ts +1 -0
  57. package/src/utils/rbac-badges.ts +61 -0
  58. package/src/utils/rbac-blast-radius.test.ts +137 -0
  59. package/src/utils/rbac-blast-radius.ts +98 -0
@@ -0,0 +1,609 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ isManagedResource,
4
+ isComposite,
5
+ isClaim,
6
+ getCrossplaneStatus,
7
+ getCrossplaneStatusReason,
8
+ getProviderStatus,
9
+ getProviderConfigStatus,
10
+ getProviderConfigRef,
11
+ getCrossplaneResourceRefs,
12
+ getExternalName,
13
+ getComposingXRRef,
14
+ isCrossplanePaused,
15
+ } from './resource-utils-crossplane'
16
+
17
+ describe('isManagedResource', () => {
18
+ it('returns true when spec.providerConfigRef is set (v1)', () => {
19
+ const resource = {
20
+ spec: { providerConfigRef: { name: 'default' } },
21
+ } as const
22
+ expect(isManagedResource(resource)).toBe(true)
23
+ })
24
+
25
+ it('returns true when spec.crossplane.providerConfigRef is set (v2)', () => {
26
+ const resource = {
27
+ spec: { crossplane: { providerConfigRef: { name: 'default' } } },
28
+ } as const
29
+ expect(isManagedResource(resource)).toBe(true)
30
+ })
31
+
32
+ it('returns false when no providerConfigRef exists at either path', () => {
33
+ expect(isManagedResource({ spec: {} } as const)).toBe(false)
34
+ expect(isManagedResource({ spec: { crossplane: {} } } as const)).toBe(false)
35
+ })
36
+
37
+ it('returns false when spec is missing or null', () => {
38
+ expect(isManagedResource({} as const)).toBe(false)
39
+ expect(isManagedResource({ spec: null } as const)).toBe(false)
40
+ expect(isManagedResource(null as any)).toBe(false)
41
+ expect(isManagedResource(undefined as any)).toBe(false)
42
+ })
43
+ })
44
+
45
+ describe('isComposite', () => {
46
+ it('returns true when spec.resourceRefs is an array (v1)', () => {
47
+ const resource = {
48
+ spec: {
49
+ resourceRefs: [
50
+ { apiVersion: 'rds.aws.upbound.io/v1beta1', kind: 'Instance', name: 'db-x' },
51
+ ],
52
+ },
53
+ } as const
54
+ expect(isComposite(resource)).toBe(true)
55
+ })
56
+
57
+ it('returns true when spec.crossplane.resourceRefs is an array (v2)', () => {
58
+ const resource = {
59
+ spec: {
60
+ crossplane: {
61
+ resourceRefs: [
62
+ { apiVersion: 'rds.aws.upbound.io/v1beta1', kind: 'Instance', name: 'db-x' },
63
+ ],
64
+ },
65
+ },
66
+ } as const
67
+ expect(isComposite(resource)).toBe(true)
68
+ })
69
+
70
+ it('returns true when resourceRefs is an empty array', () => {
71
+ expect(isComposite({ spec: { resourceRefs: [] } } as const)).toBe(true)
72
+ })
73
+
74
+ it('returns false when isManagedResource is true (MR with providerConfigRef wins)', () => {
75
+ const resource = {
76
+ spec: {
77
+ providerConfigRef: { name: 'default' },
78
+ resourceRefs: [{ apiVersion: 'v1', kind: 'Foo', name: 'bar' }],
79
+ },
80
+ } as const
81
+ expect(isComposite(resource)).toBe(false)
82
+ })
83
+
84
+ it('returns false when resourceRefs is missing', () => {
85
+ expect(isComposite({ spec: {} } as const)).toBe(false)
86
+ expect(isComposite({} as const)).toBe(false)
87
+ })
88
+
89
+ it('returns false when resourceRefs is not an array', () => {
90
+ expect(isComposite({ spec: { resourceRefs: 'oops' } } as const)).toBe(false)
91
+ })
92
+ })
93
+
94
+ describe('isClaim', () => {
95
+ it('returns true when spec.resourceRef and spec.compositionRef both exist (v1)', () => {
96
+ const resource = {
97
+ spec: {
98
+ resourceRef: { apiVersion: 'example.io/v1', kind: 'XPostgres', name: 'bound-xr' },
99
+ compositionRef: { name: 'postgres-aws' },
100
+ },
101
+ } as const
102
+ expect(isClaim(resource)).toBe(true)
103
+ })
104
+
105
+ it('returns false when only resourceRef exists', () => {
106
+ const resource = {
107
+ spec: {
108
+ resourceRef: { apiVersion: 'example.io/v1', kind: 'XPostgres', name: 'bound-xr' },
109
+ },
110
+ } as const
111
+ expect(isClaim(resource)).toBe(false)
112
+ })
113
+
114
+ it('returns false when only compositionRef exists', () => {
115
+ expect(isClaim({ spec: { compositionRef: { name: 'foo' } } } as const)).toBe(false)
116
+ })
117
+
118
+ it('returns false for a Managed Resource even if both refs were set somehow', () => {
119
+ const resource = {
120
+ spec: {
121
+ providerConfigRef: { name: 'default' },
122
+ resourceRef: { kind: 'Foo', name: 'bar' },
123
+ compositionRef: { name: 'baz' },
124
+ },
125
+ } as const
126
+ expect(isClaim(resource)).toBe(false)
127
+ })
128
+
129
+ it('returns false when spec is missing', () => {
130
+ expect(isClaim({} as const)).toBe(false)
131
+ expect(isClaim(null as any)).toBe(false)
132
+ })
133
+ })
134
+
135
+ describe('getCrossplaneStatus', () => {
136
+ it('returns Ready/healthy when Ready=True and Synced=True', () => {
137
+ const resource = {
138
+ status: {
139
+ conditions: [
140
+ { type: 'Ready', status: 'True' },
141
+ { type: 'Synced', status: 'True' },
142
+ ],
143
+ },
144
+ } as const
145
+ const badge = getCrossplaneStatus(resource)
146
+ expect(badge.text).toBe('Ready')
147
+ expect(badge.level).toBe('healthy')
148
+ })
149
+
150
+ it('returns Out of sync/alert when Synced=False (regardless of Ready)', () => {
151
+ const resource = {
152
+ status: {
153
+ conditions: [
154
+ { type: 'Ready', status: 'True' },
155
+ { type: 'Synced', status: 'False', reason: 'ReconcileError', message: 'bad spec' },
156
+ ],
157
+ },
158
+ } as const
159
+ const badge = getCrossplaneStatus(resource)
160
+ expect(badge.text).toBe('Out of sync')
161
+ expect(badge.level).toBe('alert')
162
+ })
163
+
164
+ it('returns Ready.reason/unhealthy when Ready=False and Synced is not False', () => {
165
+ const resource = {
166
+ status: {
167
+ conditions: [
168
+ { type: 'Ready', status: 'False', reason: 'Creating', message: 'still creating' },
169
+ { type: 'Synced', status: 'True' },
170
+ ],
171
+ },
172
+ } as const
173
+ const badge = getCrossplaneStatus(resource)
174
+ expect(badge.text).toBe('Creating')
175
+ expect(badge.level).toBe('unhealthy')
176
+ })
177
+
178
+ it('falls back to "Not ready" when Ready=False has no reason', () => {
179
+ const resource = {
180
+ status: {
181
+ conditions: [
182
+ { type: 'Ready', status: 'False' },
183
+ { type: 'Synced', status: 'True' },
184
+ ],
185
+ },
186
+ } as const
187
+ const badge = getCrossplaneStatus(resource)
188
+ expect(badge.text).toBe('Not ready')
189
+ expect(badge.level).toBe('unhealthy')
190
+ })
191
+
192
+ it('returns Provisioning/degraded when Ready=Unknown', () => {
193
+ const resource = {
194
+ status: {
195
+ conditions: [
196
+ { type: 'Ready', status: 'Unknown' },
197
+ { type: 'Synced', status: 'True' },
198
+ ],
199
+ },
200
+ } as const
201
+ const badge = getCrossplaneStatus(resource)
202
+ expect(badge.text).toBe('Provisioning')
203
+ expect(badge.level).toBe('degraded')
204
+ })
205
+
206
+ it('returns Pending/neutral when no Ready or Synced conditions exist', () => {
207
+ expect(getCrossplaneStatus({} as const)).toMatchObject({
208
+ text: 'Pending',
209
+ level: 'neutral',
210
+ })
211
+ expect(getCrossplaneStatus({ status: { conditions: [] } } as const)).toMatchObject({
212
+ text: 'Pending',
213
+ level: 'neutral',
214
+ })
215
+ expect(
216
+ getCrossplaneStatus({
217
+ status: { conditions: [{ type: 'Other', status: 'True' }] },
218
+ } as const),
219
+ ).toMatchObject({ text: 'Pending', level: 'neutral' })
220
+ })
221
+
222
+ it('returns Terminating/degraded when deletionTimestamp is set', () => {
223
+ const resource = {
224
+ metadata: { deletionTimestamp: '2026-05-18T00:00:00Z' },
225
+ status: {
226
+ conditions: [
227
+ { type: 'Ready', status: 'True' },
228
+ { type: 'Synced', status: 'True' },
229
+ ],
230
+ },
231
+ } as const
232
+ const badge = getCrossplaneStatus(resource)
233
+ expect(badge.text).toBe('Terminating')
234
+ expect(badge.level).toBe('degraded')
235
+ })
236
+ })
237
+
238
+ describe('getCrossplaneStatusReason', () => {
239
+ it('returns the Synced=False message when present (Synced wins over Ready)', () => {
240
+ const resource = {
241
+ status: {
242
+ conditions: [
243
+ { type: 'Ready', status: 'False', message: 'ready message' },
244
+ { type: 'Synced', status: 'False', message: 'synced message' },
245
+ ],
246
+ },
247
+ } as const
248
+ expect(getCrossplaneStatusReason(resource)).toBe('synced message')
249
+ })
250
+
251
+ it('returns the Ready=False message when Synced is True', () => {
252
+ const resource = {
253
+ status: {
254
+ conditions: [
255
+ { type: 'Ready', status: 'False', message: 'pod crashlooping' },
256
+ { type: 'Synced', status: 'True' },
257
+ ],
258
+ },
259
+ } as const
260
+ expect(getCrossplaneStatusReason(resource)).toBe('pod crashlooping')
261
+ })
262
+
263
+ it('falls back to the reason when message is empty', () => {
264
+ const resource = {
265
+ status: {
266
+ conditions: [
267
+ { type: 'Synced', status: 'False', reason: 'ReconcileError', message: '' },
268
+ ],
269
+ },
270
+ } as const
271
+ expect(getCrossplaneStatusReason(resource)).toBe('ReconcileError')
272
+ })
273
+
274
+ it('falls back to Ready.reason when Synced is True and Ready has no message', () => {
275
+ const resource = {
276
+ status: {
277
+ conditions: [
278
+ { type: 'Ready', status: 'False', reason: 'Creating' },
279
+ { type: 'Synced', status: 'True' },
280
+ ],
281
+ },
282
+ } as const
283
+ expect(getCrossplaneStatusReason(resource)).toBe('Creating')
284
+ })
285
+
286
+ it('returns null when all conditions are True', () => {
287
+ const resource = {
288
+ status: {
289
+ conditions: [
290
+ { type: 'Ready', status: 'True' },
291
+ { type: 'Synced', status: 'True' },
292
+ ],
293
+ },
294
+ } as const
295
+ expect(getCrossplaneStatusReason(resource)).toBeNull()
296
+ })
297
+
298
+ it('returns null when no conditions exist', () => {
299
+ expect(getCrossplaneStatusReason({} as const)).toBeNull()
300
+ expect(getCrossplaneStatusReason({ status: {} } as const)).toBeNull()
301
+ })
302
+ })
303
+
304
+ describe('getProviderStatus', () => {
305
+ it('returns Healthy/healthy when Installed=True and Healthy=True', () => {
306
+ const resource = {
307
+ status: {
308
+ conditions: [
309
+ { type: 'Installed', status: 'True' },
310
+ { type: 'Healthy', status: 'True' },
311
+ ],
312
+ },
313
+ } as const
314
+ const badge = getProviderStatus(resource)
315
+ expect(badge.text).toBe('Healthy')
316
+ expect(badge.level).toBe('healthy')
317
+ })
318
+
319
+ it('returns Installed=False reason as unhealthy', () => {
320
+ const resource = {
321
+ status: {
322
+ conditions: [
323
+ { type: 'Installed', status: 'False', reason: 'ImagePullBackOff' },
324
+ { type: 'Healthy', status: 'True' },
325
+ ],
326
+ },
327
+ } as const
328
+ const badge = getProviderStatus(resource)
329
+ expect(badge.text).toBe('ImagePullBackOff')
330
+ expect(badge.level).toBe('unhealthy')
331
+ })
332
+
333
+ it('falls back to "Install failed" when Installed=False has no reason', () => {
334
+ const resource = {
335
+ status: { conditions: [{ type: 'Installed', status: 'False' }] },
336
+ } as const
337
+ expect(getProviderStatus(resource)).toMatchObject({
338
+ text: 'Install failed',
339
+ level: 'unhealthy',
340
+ })
341
+ })
342
+
343
+ it('returns alert when Healthy=False and Installed is not False', () => {
344
+ const resource = {
345
+ status: {
346
+ conditions: [
347
+ { type: 'Installed', status: 'True' },
348
+ { type: 'Healthy', status: 'False', reason: 'CrashLoopBackOff' },
349
+ ],
350
+ },
351
+ } as const
352
+ const badge = getProviderStatus(resource)
353
+ expect(badge.text).toBe('CrashLoopBackOff')
354
+ expect(badge.level).toBe('alert')
355
+ })
356
+
357
+ it('returns Installed/degraded when only Installed=True (no Healthy condition)', () => {
358
+ const resource = {
359
+ status: { conditions: [{ type: 'Installed', status: 'True' }] },
360
+ } as const
361
+ const badge = getProviderStatus(resource)
362
+ expect(badge.text).toBe('Installed')
363
+ expect(badge.level).toBe('degraded')
364
+ })
365
+
366
+ it('returns Terminating/degraded when deletionTimestamp is set', () => {
367
+ const resource = {
368
+ metadata: { deletionTimestamp: '2026-05-18T00:00:00Z' },
369
+ status: {
370
+ conditions: [
371
+ { type: 'Installed', status: 'True' },
372
+ { type: 'Healthy', status: 'True' },
373
+ ],
374
+ },
375
+ } as const
376
+ expect(getProviderStatus(resource)).toMatchObject({
377
+ text: 'Terminating',
378
+ level: 'degraded',
379
+ })
380
+ })
381
+ })
382
+
383
+ describe('getProviderConfigStatus', () => {
384
+ it('returns "{n} in use"/healthy when status.users > 0', () => {
385
+ const resource = { status: { users: 3 } } as const
386
+ const badge = getProviderConfigStatus(resource)
387
+ expect(badge.text).toBe('3 in use')
388
+ expect(badge.level).toBe('healthy')
389
+ })
390
+
391
+ it('returns Idle/neutral when status.users is 0', () => {
392
+ const resource = { status: { users: 0 } } as const
393
+ const badge = getProviderConfigStatus(resource)
394
+ expect(badge.text).toBe('Idle')
395
+ expect(badge.level).toBe('neutral')
396
+ })
397
+
398
+ it('returns Idle/neutral when status.users is missing', () => {
399
+ expect(getProviderConfigStatus({} as const)).toMatchObject({
400
+ text: 'Idle',
401
+ level: 'neutral',
402
+ })
403
+ })
404
+
405
+ it('returns Ready=False reason as unhealthy (takes precedence over users count)', () => {
406
+ const resource = {
407
+ status: {
408
+ users: 5,
409
+ conditions: [{ type: 'Ready', status: 'False', reason: 'AuthFailure' }],
410
+ },
411
+ } as const
412
+ const badge = getProviderConfigStatus(resource)
413
+ expect(badge.text).toBe('AuthFailure')
414
+ expect(badge.level).toBe('unhealthy')
415
+ })
416
+
417
+ it('falls back to "Not ready" when Ready=False has no reason', () => {
418
+ const resource = {
419
+ status: { conditions: [{ type: 'Ready', status: 'False' }] },
420
+ } as const
421
+ expect(getProviderConfigStatus(resource)).toMatchObject({
422
+ text: 'Not ready',
423
+ level: 'unhealthy',
424
+ })
425
+ })
426
+ })
427
+
428
+ describe('getProviderConfigRef', () => {
429
+ it('reads spec.providerConfigRef (v1)', () => {
430
+ const resource = {
431
+ spec: { providerConfigRef: { name: 'aws-default', kind: 'ProviderConfig' } },
432
+ } as const
433
+ expect(getProviderConfigRef(resource)).toEqual({
434
+ name: 'aws-default',
435
+ kind: 'ProviderConfig',
436
+ })
437
+ })
438
+
439
+ it('reads spec.crossplane.providerConfigRef (v2) and prefers it over v1', () => {
440
+ const resource = {
441
+ spec: {
442
+ providerConfigRef: { name: 'v1-name' },
443
+ crossplane: { providerConfigRef: { name: 'v2-name', kind: 'ProviderConfig' } },
444
+ },
445
+ } as const
446
+ expect(getProviderConfigRef(resource)).toEqual({
447
+ name: 'v2-name',
448
+ kind: 'ProviderConfig',
449
+ })
450
+ })
451
+
452
+ it('returns null when no providerConfigRef exists', () => {
453
+ expect(getProviderConfigRef({ spec: {} } as const)).toBeNull()
454
+ expect(getProviderConfigRef({} as const)).toBeNull()
455
+ })
456
+
457
+ it('returns null when providerConfigRef has no name', () => {
458
+ expect(
459
+ getProviderConfigRef({ spec: { providerConfigRef: { kind: 'ProviderConfig' } } } as const),
460
+ ).toBeNull()
461
+ })
462
+ })
463
+
464
+ describe('getCrossplaneResourceRefs', () => {
465
+ it('returns refs from spec.resourceRefs (v1)', () => {
466
+ const resource = {
467
+ spec: {
468
+ resourceRefs: [
469
+ { apiVersion: 'rds.aws.upbound.io/v1beta1', kind: 'Instance', name: 'db-x' },
470
+ { apiVersion: 'ec2.aws.upbound.io/v1beta1', kind: 'VPC', name: 'vpc-x' },
471
+ ],
472
+ },
473
+ } as const
474
+ expect(getCrossplaneResourceRefs(resource)).toHaveLength(2)
475
+ })
476
+
477
+ it('returns refs from spec.crossplane.resourceRefs (v2) and prefers it over v1', () => {
478
+ const resource = {
479
+ spec: {
480
+ resourceRefs: [{ apiVersion: 'a/v1', kind: 'A', name: 'a-v1' }],
481
+ crossplane: {
482
+ resourceRefs: [{ apiVersion: 'b/v1', kind: 'B', name: 'b-v2' }],
483
+ },
484
+ },
485
+ } as const
486
+ const refs = getCrossplaneResourceRefs(resource)
487
+ expect(refs).toHaveLength(1)
488
+ expect(refs[0].name).toBe('b-v2')
489
+ })
490
+
491
+ it('filters out entries missing kind or name', () => {
492
+ const resource = {
493
+ spec: {
494
+ resourceRefs: [
495
+ { apiVersion: 'v1', kind: 'A', name: 'good' },
496
+ { apiVersion: 'v1', kind: 'B' },
497
+ { apiVersion: 'v1', name: 'no-kind' },
498
+ {},
499
+ null,
500
+ ],
501
+ },
502
+ } as const
503
+ const refs = getCrossplaneResourceRefs(resource)
504
+ expect(refs).toHaveLength(1)
505
+ expect(refs[0].name).toBe('good')
506
+ })
507
+
508
+ it('returns empty array when resourceRefs is missing or not an array', () => {
509
+ expect(getCrossplaneResourceRefs({} as const)).toEqual([])
510
+ expect(getCrossplaneResourceRefs({ spec: {} } as const)).toEqual([])
511
+ expect(getCrossplaneResourceRefs({ spec: { resourceRefs: 'oops' } } as const)).toEqual([])
512
+ })
513
+ })
514
+
515
+ describe('getExternalName', () => {
516
+ it('reads the crossplane.io/external-name annotation', () => {
517
+ const resource = {
518
+ metadata: { annotations: { 'crossplane.io/external-name': 'arn:aws:rds:db-x' } },
519
+ } as const
520
+ expect(getExternalName(resource)).toBe('arn:aws:rds:db-x')
521
+ })
522
+
523
+ it('returns null when the annotation is missing', () => {
524
+ expect(getExternalName({ metadata: { annotations: {} } } as const)).toBeNull()
525
+ expect(getExternalName({ metadata: {} } as const)).toBeNull()
526
+ expect(getExternalName({} as const)).toBeNull()
527
+ })
528
+ })
529
+
530
+ describe('getComposingXRRef', () => {
531
+ it('returns the controller=true ownerReference', () => {
532
+ const resource = {
533
+ metadata: {
534
+ ownerReferences: [
535
+ { apiVersion: 'example.io/v1', kind: 'XOther', name: 'not-controller', controller: false },
536
+ { apiVersion: 'example.io/v1', kind: 'XPostgres', name: 'the-xr', controller: true },
537
+ ],
538
+ },
539
+ } as const
540
+ expect(getComposingXRRef(resource)).toEqual({
541
+ apiVersion: 'example.io/v1',
542
+ kind: 'XPostgres',
543
+ name: 'the-xr',
544
+ })
545
+ })
546
+
547
+ it('falls back to refs[0] when no ownerReference has controller=true', () => {
548
+ const resource = {
549
+ metadata: {
550
+ ownerReferences: [
551
+ { apiVersion: 'example.io/v1', kind: 'XFirst', name: 'first' },
552
+ { apiVersion: 'example.io/v1', kind: 'XSecond', name: 'second' },
553
+ ],
554
+ },
555
+ } as const
556
+ expect(getComposingXRRef(resource)).toEqual({
557
+ apiVersion: 'example.io/v1',
558
+ kind: 'XFirst',
559
+ name: 'first',
560
+ })
561
+ })
562
+
563
+ it('returns null when ownerReferences is empty or missing', () => {
564
+ expect(getComposingXRRef({} as const)).toBeNull()
565
+ expect(getComposingXRRef({ metadata: {} } as const)).toBeNull()
566
+ expect(getComposingXRRef({ metadata: { ownerReferences: [] } } as const)).toBeNull()
567
+ })
568
+
569
+ it('returns null when the picked ref has no kind or name', () => {
570
+ const resource = {
571
+ metadata: { ownerReferences: [{ apiVersion: 'v1', controller: true }] },
572
+ } as const
573
+ expect(getComposingXRRef(resource)).toBeNull()
574
+ })
575
+ })
576
+
577
+ describe('isCrossplanePaused', () => {
578
+ it('returns true when annotation is the string "true"', () => {
579
+ const resource = {
580
+ metadata: { annotations: { 'crossplane.io/paused': 'true' } },
581
+ } as const
582
+ expect(isCrossplanePaused(resource)).toBe(true)
583
+ })
584
+
585
+ it('returns false when annotation is missing', () => {
586
+ expect(isCrossplanePaused({} as const)).toBe(false)
587
+ expect(isCrossplanePaused({ metadata: {} } as const)).toBe(false)
588
+ expect(isCrossplanePaused({ metadata: { annotations: {} } } as const)).toBe(false)
589
+ })
590
+
591
+ it('returns false for empty string, "false", or boolean true', () => {
592
+ expect(
593
+ isCrossplanePaused({ metadata: { annotations: { 'crossplane.io/paused': '' } } } as const),
594
+ ).toBe(false)
595
+ expect(
596
+ isCrossplanePaused({
597
+ metadata: { annotations: { 'crossplane.io/paused': 'false' } },
598
+ } as const),
599
+ ).toBe(false)
600
+ expect(
601
+ isCrossplanePaused({
602
+ metadata: { annotations: { 'crossplane.io/paused': true as any } },
603
+ } as const),
604
+ ).toBe(false)
605
+ })
606
+ })
607
+
608
+ // Pluralization moved to the shared kindToPlural in utils/navigation.ts
609
+ // (discovery-aware, handles provider-defined irregular plurals).