@skyhook-io/k8s-ui 1.10.4 → 1.11.0

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 (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,698 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { routeMark, routeTone, routeChip, orderRoutes, scenariosFor, groupRoutes, vantageSignature, routeIdentity, isSlow, formatLatency, hostMatches, declaredHosts, routeHostOf, routeForOrigin, routeAsSeenFrom, originRouteEvidence, MARKS, edgeHelp, markHelp, edgeHelpIsRedundant } from './reachMarks'
3
+ import type { RouteResult, Hop } from './types'
4
+
5
+ const r = (o: Partial<RouteResult>): RouteResult => ({ route: 'GET /', outcome: 'verified', ...o })
6
+
7
+ describe('routeMark', () => {
8
+ it('only real-traffic evidence is ever proved', () => {
9
+ expect(routeMark(r({ outcome: 'verified', confidence: 'real' }))).toBe('proved')
10
+ // An apiserver relay bypasses kube-proxy, NetworkPolicy and the mesh, so a
11
+ // clean response through it can never be proof of the real path.
12
+ expect(routeMark(r({ outcome: 'verified', confidence: 'indirect' }))).toBe('proxied')
13
+ })
14
+
15
+ it('a proxy-only failure never condemns the real path', () => {
16
+ expect(routeMark(r({ outcome: 'unreachable', confidence: 'indirect' }))).not.toBe('failed')
17
+ expect(routeMark(r({ outcome: 'unreachable', confidence: 'real' }))).toBe('failed')
18
+ })
19
+
20
+ it('a deliberate scale-to-zero is excluded, not failed', () => {
21
+ expect(routeMark(r({ outcome: 'unreachable', confidence: 'real', benign: true }))).toBe('excluded')
22
+ })
23
+
24
+ it('reached is weaker than verified', () => {
25
+ expect(routeMark(r({ outcome: 'reached', confidence: 'real' }))).toBe('answered')
26
+ })
27
+
28
+ it('running and stale override the outcome', () => {
29
+ expect(routeMark(r({ outcome: 'verified', confidence: 'real' }), { running: true })).toBe('running')
30
+ expect(routeMark(r({ outcome: 'verified', confidence: 'real' }), { stale: true })).toBe('stale')
31
+ })
32
+
33
+ it('not-tested is untested, never a failure', () => {
34
+ expect(routeMark(r({ outcome: 'not-tested' }))).toBe('untested')
35
+ })
36
+ })
37
+
38
+ describe('routeTone', () => {
39
+ it('indirect verified is never green', () => {
40
+ expect(routeTone(r({ outcome: 'verified', confidence: 'indirect' }))).toBe('degraded')
41
+ expect(routeTone(r({ outcome: 'verified', confidence: 'real' }))).toBe('healthy')
42
+ })
43
+
44
+ it('not-tested is never red', () => {
45
+ expect(routeTone(r({ outcome: 'not-tested' }))).toBe('unknown')
46
+ })
47
+
48
+ it('benign and indirect unreachables stay amber', () => {
49
+ expect(routeTone(r({ outcome: 'unreachable', benign: true, confidence: 'real' }))).toBe('degraded')
50
+ expect(routeTone(r({ outcome: 'unreachable', confidence: 'indirect' }))).toBe('degraded')
51
+ expect(routeTone(r({ outcome: 'unreachable', confidence: 'real' }))).toBe('unhealthy')
52
+ })
53
+ })
54
+
55
+ describe('routeChip', () => {
56
+ it('names the caveat rather than claiming verification', () => {
57
+ // Asserts the meaning: an indirect result must name the API-server relay
58
+ // rather than claim a clean pass. ("Skipped the cluster network" was the old
59
+ // wording - false, since the proxy still crosses the cluster network; what
60
+ // it skips is the front door and the normal traffic path.)
61
+ expect(routeChip(r({ outcome: 'verified', confidence: 'indirect' }))).toMatch(/API server/i)
62
+ expect(routeChip(r({ outcome: 'verified', confidence: 'real' }))).toBe('got through')
63
+ })
64
+ })
65
+
66
+ describe('orderRoutes', () => {
67
+ it('leads with the worst outcome', () => {
68
+ const out = orderRoutes([r({ route: 'a', outcome: 'verified' }), r({ route: 'b', outcome: 'unreachable' }), r({ route: 'c', outcome: 'not-tested' })])
69
+ expect(out.map((x) => x.route)).toEqual(['b', 'c', 'a'])
70
+ })
71
+ })
72
+
73
+ describe('marks vocabulary', () => {
74
+ it('only proved and failed draw solid — everything softer must be dashed', () => {
75
+ for (const [name, style] of Object.entries(MARKS)) {
76
+ if (name === 'proved' || name === 'failed') expect(style.dash).toBe('none')
77
+ else expect(style.dash).not.toBe('none')
78
+ }
79
+ })
80
+ })
81
+
82
+ describe('latency', () => {
83
+ it('flags pathological latency only', () => {
84
+ expect(isSlow({ layer: 'http', target: 't', vantage: 'local', ok: true, latencyNs: 11_000_000 })).toBe(false)
85
+ expect(isSlow({ layer: 'http', target: 't', vantage: 'local', ok: true, latencyNs: 1_900_000_000 })).toBe(true)
86
+ })
87
+
88
+ it('formats across unit boundaries', () => {
89
+ expect(formatLatency(11_000_000)).toBe('11 ms')
90
+ expect(formatLatency(1_900_000_000)).toBe('1.9 s')
91
+ expect(formatLatency(undefined)).toBe('')
92
+ })
93
+ })
94
+
95
+ describe('scenariosFor', () => {
96
+ const r2 = (o: Partial<RouteResult>): RouteResult => ({ route: 'h/', outcome: 'verified', ...o })
97
+
98
+ it('folds routes that agree in every respect into one scenario', () => {
99
+ // A Gateway route with three hostnames and one backend is one situation
100
+ // with three front doors, not three situations.
101
+ const s = scenariosFor(
102
+ [
103
+ r2({ route: 'a.example.com/', target: 'svc:8080', outcome: 'reached', confidence: 'indirect' }),
104
+ r2({ route: 'b.example.com/', target: 'svc:8080', outcome: 'reached', confidence: 'indirect' }),
105
+ r2({ route: 'c.example.com/', target: 'svc:8080', outcome: 'reached', confidence: 'indirect' }),
106
+ ],
107
+ [],
108
+ )
109
+ expect(s).toHaveLength(1)
110
+ expect(s[0].hosts).toEqual(['a.example.com', 'b.example.com', 'c.example.com'])
111
+ expect(s[0].sub).toMatch(/3 hostnames/)
112
+ })
113
+
114
+ it('keeps a host that behaves differently in its own scenario', () => {
115
+ const s = scenariosFor(
116
+ [
117
+ r2({ route: 'a.example.com/', target: 'svc:8080', outcome: 'verified', confidence: 'real' }),
118
+ r2({ route: 'b.example.com/', target: 'svc:8080', outcome: 'unreachable', confidence: 'real' }),
119
+ ],
120
+ [],
121
+ )
122
+ expect(s).toHaveLength(2)
123
+ // Worst first, so the failure leads.
124
+ expect(s[0].primary.outcome).toBe('unreachable')
125
+ })
126
+
127
+ it('separates different backends even when the outcome matches', () => {
128
+ const s = scenariosFor(
129
+ [r2({ route: 'a/', target: 'svc:80', outcome: 'verified' }), r2({ route: 'b/', target: 'svc:9090', outcome: 'verified' })],
130
+ [],
131
+ )
132
+ expect(s).toHaveLength(2)
133
+ })
134
+
135
+ it('surfaces untested routes as scenarios — otherwise an unprobed resource shows no paths at all', () => {
136
+ const s = scenariosFor([], [{ route: 'a.example.com/', reason: 'route not actively tested' }])
137
+ expect(s).toHaveLength(1)
138
+ expect(s[0].primary.outcome).toBe('not-tested')
139
+ })
140
+
141
+ it('keeps untested routes apart when their reasons differ', () => {
142
+ // "port 443 can't be verified through the proxy" and "port 80 timed out"
143
+ // are different situations, even though both are merely not-tested.
144
+ const s = scenariosFor([], [
145
+ { route: 'port 80', reason: 'the backend did not respond within the probe budget' },
146
+ { route: 'port 443', reason: 'HTTPS backend - the API-server proxy speaks plain HTTP' },
147
+ ])
148
+ expect(s).toHaveLength(2)
149
+ expect(s.map((x) => x.label).sort()).toEqual(['port 443', 'port 80'])
150
+ })
151
+
152
+ it('still folds untested routes that share one reason', () => {
153
+ const s = scenariosFor([], [
154
+ { route: 'a.example.com/', reason: 'route not actively tested' },
155
+ { route: 'b.example.com/', reason: 'route not actively tested' },
156
+ ])
157
+ expect(s).toHaveLength(1)
158
+ })
159
+
160
+ it('gives a scenario a key that survives re-sorting', () => {
161
+ // The strip is sorted worst-first, so a re-run that changes an outcome
162
+ // re-orders it. Selection is stored by key precisely because a positional
163
+ // index would silently move the user to a different path.
164
+ const before = scenariosFor(
165
+ [r2({ route: 'a/', target: 'svc:80', outcome: 'unreachable', confidence: 'real' }), r2({ route: 'b/', target: 'svc:90', outcome: 'verified', confidence: 'real' })],
166
+ [],
167
+ )
168
+ expect(before[0].primary.target).toBe('svc:80')
169
+ const bKey = before[1].key
170
+
171
+ // Outcomes swap on the next run; worst-first now puts svc:90 first.
172
+ const after = scenariosFor(
173
+ [r2({ route: 'a/', target: 'svc:80', outcome: 'verified', confidence: 'real' }), r2({ route: 'b/', target: 'svc:90', outcome: 'unreachable', confidence: 'real' })],
174
+ [],
175
+ )
176
+ expect(after[0].primary.target).toBe('svc:90')
177
+ // The old index 1 now points at a different scenario - which is why the key,
178
+ // not the index, is what selection must be stored by.
179
+ expect(after[1].key).not.toBe(bKey)
180
+ expect(new Set(after.map((x) => x.key)).size).toBe(after.length)
181
+ })
182
+
183
+ it('uses the single route label verbatim when nothing was grouped', () => {
184
+ const s = scenariosFor([r2({ route: 'only.example.com/', target: 'svc:80' })], [])
185
+ expect(s[0].label).toBe('only.example.com/')
186
+ })
187
+
188
+ // A GATEWAY declares hostnames per listener and has NO top-level hostnames -
189
+ // reading only `hostnames` (an Ingress/HTTPRoute field) made every real
190
+ // Gateway look like it served nothing. See internal/trace gatewayConfig.
191
+ const gw = (name: string, ...hostnames: string[]): Hop => ({
192
+ resource: { kind: 'Gateway', namespace: 'infra', name },
193
+ edge: 'routes',
194
+ findings: [],
195
+ config: { listeners: hostnames.map((hostname, i) => ({ name: `l${i}`, port: 443, hostname })) },
196
+ })
197
+ const ingress = (name: string, ...hostnames: string[]): Hop => ({
198
+ resource: { kind: 'Ingress', namespace: 'infra', name },
199
+ edge: 'routes',
200
+ findings: [],
201
+ config: { hostnames },
202
+ })
203
+
204
+ it('splits two hosts that reach the same backend through DIFFERENT front doors', () => {
205
+ // One Gateway can be broken while its sibling is fine. Folding them behind a
206
+ // shared verdict hides exactly the difference worth debugging.
207
+ const s = scenariosFor(
208
+ [
209
+ r2({ route: 'a.example.com/', target: 'svc:8080', outcome: 'verified', confidence: 'real' }),
210
+ r2({ route: 'b.example.com/', target: 'svc:8080', outcome: 'verified', confidence: 'real' }),
211
+ ],
212
+ [],
213
+ [gw('public-gw', 'a.example.com'), gw('internal-gw', 'b.example.com')],
214
+ )
215
+ expect(s).toHaveLength(2)
216
+ expect(s.map((x) => x.entry).sort()).toEqual(['internal-gw', 'public-gw'])
217
+ expect(s[0].sub).toMatch(/via /)
218
+ })
219
+
220
+ it('still folds hosts that share one front door', () => {
221
+ const s = scenariosFor(
222
+ [
223
+ r2({ route: 'a.example.com/', target: 'svc:8080', outcome: 'verified', confidence: 'real' }),
224
+ r2({ route: 'b.example.com/', target: 'svc:8080', outcome: 'verified', confidence: 'real' }),
225
+ ],
226
+ [],
227
+ [gw('public-gw', 'a.example.com', 'b.example.com')],
228
+ )
229
+ expect(s).toHaveLength(1)
230
+ expect(s[0].entry).toBe('public-gw')
231
+ })
232
+
233
+ it('does not invent a front door when no upstream serves the host', () => {
234
+ const s = scenariosFor([r2({ route: 'a.example.com/', target: 'svc:80' })], [], [gw('other-gw', 'z.example.com')])
235
+ expect(s[0].entry).toBeUndefined()
236
+ expect(s[0].sub).not.toMatch(/via /)
237
+ })
238
+
239
+ it('reads an Ingress top-level hostname too, not only Gateway listeners', () => {
240
+ const s = scenariosFor(
241
+ [
242
+ r2({ route: 'a.example.com/', target: 'svc:80', outcome: 'verified', confidence: 'real' }),
243
+ r2({ route: 'b.example.com/', target: 'svc:80', outcome: 'verified', confidence: 'real' }),
244
+ ],
245
+ [],
246
+ [ingress('public-ing', 'a.example.com'), gw('gw', 'b.example.com')],
247
+ )
248
+ expect(s.map((x) => x.entry).sort()).toEqual(['gw', 'public-ing'])
249
+ })
250
+
251
+ it('a wildcard listener serves its subdomains and keeps them one scenario', () => {
252
+ const s = scenariosFor(
253
+ [
254
+ r2({ route: 'api.example.com/', target: 'svc:80', outcome: 'verified', confidence: 'real' }),
255
+ r2({ route: 'web.example.com/', target: 'svc:80', outcome: 'verified', confidence: 'real' }),
256
+ ],
257
+ [],
258
+ [gw('wild-gw', '*.example.com')],
259
+ )
260
+ expect(s).toHaveLength(1)
261
+ expect(s[0].entry).toBe('wild-gw')
262
+ })
263
+
264
+ it('a catch-all listener does not claim a port-based route', () => {
265
+ // The front-door fixture declares a listener with no hostname. Without the
266
+ // route-label guard it matched ":80 → 8080" and reported a Gateway the
267
+ // request never touched.
268
+ const s = scenariosFor([r2({ route: 'GET / · :80 → 8080', target: ':80 → 8080' })], [], [gw('catch-all', '')])
269
+ expect(s[0].entry).toBeUndefined()
270
+ })
271
+
272
+ it('an exact listener wins over a catch-all that would swallow every host', () => {
273
+ // Otherwise a hostname-less listener matches everything and collapses the
274
+ // scenarios the grouping exists to keep apart.
275
+ const s = scenariosFor(
276
+ [r2({ route: 'api.example.com/', target: 'svc:80', outcome: 'verified', confidence: 'real' })],
277
+ [],
278
+ [gw('catch-all', ''), gw('exact-gw', 'api.example.com')],
279
+ )
280
+ expect(s[0].entry).toBe('exact-gw')
281
+ })
282
+ })
283
+
284
+ describe('routeHostOf', () => {
285
+ it('reads the host out of a host-based label', () => {
286
+ expect(routeHostOf('example.com/api')).toBe('example.com')
287
+ expect(routeHostOf('*.example.com/')).toBe('*.example.com')
288
+ })
289
+
290
+ it('returns nothing for labels that are not hostnames', () => {
291
+ // A catch-all Gateway listener legitimately serves ANY host, so feeding it
292
+ // a port label would make it claim a route that never went near it.
293
+ expect(routeHostOf(':80 → 8080')).toBe('')
294
+ expect(routeHostOf('GET / · :80 → 8080')).toBe('')
295
+ expect(routeHostOf('default backend')).toBe('')
296
+ expect(routeHostOf('/api')).toBe('')
297
+ })
298
+ })
299
+
300
+ describe('hostMatches', () => {
301
+ it('matches exactly, case-insensitively', () => {
302
+ expect(hostMatches('API.example.com', 'api.example.com')).toBe(true)
303
+ expect(hostMatches('api.example.com', 'other.example.com')).toBe(false)
304
+ })
305
+
306
+ it('a wildcard covers exactly one extra label - never the apex or a deeper name', () => {
307
+ expect(hostMatches('*.example.com', 'api.example.com')).toBe(true)
308
+ expect(hostMatches('*.example.com', 'a.b.example.com')).toBe(false)
309
+ expect(hostMatches('*.example.com', 'example.com')).toBe(false)
310
+ })
311
+
312
+ it('an empty declared hostname is the Gateway-API "any host" listener', () => {
313
+ expect(hostMatches('', 'anything.example.com')).toBe(true)
314
+ // ...but an empty REQUEST host matches nothing - there is no host to serve.
315
+ expect(hostMatches('', '')).toBe(false)
316
+ })
317
+ })
318
+
319
+ describe('declaredHosts', () => {
320
+ it('collects Gateway listener hostnames, which live nowhere else on the hop', () => {
321
+ expect(
322
+ declaredHosts({
323
+ resource: { kind: 'Gateway', name: 'g' },
324
+ edge: '',
325
+ findings: [],
326
+ config: { listeners: [{ port: 443, hostname: 'a.example.com' }, { port: 80 }] },
327
+ }),
328
+ ).toEqual(['a.example.com', ''])
329
+ })
330
+
331
+ it('collects Ingress hostnames and TLS hosts', () => {
332
+ expect(
333
+ declaredHosts({
334
+ resource: { kind: 'Ingress', name: 'i' },
335
+ edge: '',
336
+ findings: [],
337
+ config: { hostnames: ['a.example.com'], tlsHosts: ['b.example.com'] },
338
+ }),
339
+ ).toEqual(['a.example.com', 'b.example.com'])
340
+ })
341
+ })
342
+
343
+ describe('per-vantage route resolution', () => {
344
+ // The rollup buckets by mechanism and takes worst-wins, so in-cluster success
345
+ // + laptop failure collapses to unreachable. byVantage keeps both.
346
+ const disagreeing: RouteResult = {
347
+ route: 'checkout.example.com/',
348
+ target: 'checkout:80',
349
+ outcome: 'unreachable',
350
+ confidence: 'real',
351
+ evidence: 'connection refused',
352
+ byVantage: [
353
+ { vantage: 'in-cluster', path: 'data', outcome: 'verified', confidence: 'real', evidence: 'HTTP 200' },
354
+ { vantage: 'local', path: 'data', outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' },
355
+ { vantage: 'local', path: 'apiserver', outcome: 'verified', confidence: 'indirect', evidence: 'HTTP 200 via proxy' },
356
+ ],
357
+ }
358
+
359
+ it('gives each origin its OWN result, not the merged one', () => {
360
+ expect(routeAsSeenFrom(disagreeing, 'incluster')!.outcome).toBe('verified')
361
+ expect(routeAsSeenFrom(disagreeing, 'local')!.outcome).toBe('unreachable')
362
+ })
363
+
364
+ it('the vantage that worked no longer inherits the merged failure', () => {
365
+ // This is the misattribution the whole change exists to remove.
366
+ expect(routeMark(routeAsSeenFrom(disagreeing, 'incluster')!)).toBe('proved')
367
+ expect(routeMark(routeAsSeenFrom(disagreeing, 'local')!)).toBe('failed')
368
+ })
369
+
370
+ it('the apiserver origin reads the relayed row, whatever machine issued it', () => {
371
+ const v = routeForOrigin(disagreeing, 'apiserver')!
372
+ expect(v.path).toBe('apiserver')
373
+ expect(v.confidence).toBe('indirect')
374
+ // ...and a relay can still never be proof.
375
+ expect(routeMark(routeAsSeenFrom(disagreeing, 'apiserver')!)).toBe('proxied')
376
+ })
377
+
378
+ it('never hands a data-path row to the apiserver origin, or vice versa', () => {
379
+ const dataOnly: RouteResult = {
380
+ route: 'r',
381
+ outcome: 'verified',
382
+ byVantage: [{ vantage: 'local', path: 'data', outcome: 'verified', confidence: 'real' }],
383
+ }
384
+ expect(routeForOrigin(dataOnly, 'apiserver')).toBeUndefined()
385
+ })
386
+
387
+ it('reports nothing for an origin that produced nothing', () => {
388
+ expect(routeForOrigin(disagreeing, 'incluster')).toBeDefined()
389
+ const laptopOnly: RouteResult = {
390
+ route: 'r',
391
+ outcome: 'verified',
392
+ byVantage: [{ vantage: 'local', path: 'data', outcome: 'verified', confidence: 'real' }],
393
+ }
394
+ expect(routeForOrigin(laptopOnly, 'incluster')).toBeUndefined()
395
+ })
396
+
397
+ it('falls back to the rollup when the producer sent no breakdown', () => {
398
+ // Older backend, or a route with no probes: behave exactly as before rather
399
+ // than blanking the view.
400
+ const legacy: RouteResult = { route: 'r', outcome: 'verified', confidence: 'real', evidence: 'HTTP 200' }
401
+ expect(routeAsSeenFrom(legacy, 'incluster')).toEqual(legacy)
402
+ expect(routeForOrigin(legacy, 'incluster')).toBeUndefined()
403
+ })
404
+
405
+ it('is undefined for no route at all', () => {
406
+ expect(routeAsSeenFrom(undefined, 'local')).toBeUndefined()
407
+ expect(routeForOrigin(undefined, 'local')).toBeUndefined()
408
+ })
409
+ })
410
+
411
+ describe('missing-row semantics', () => {
412
+ // The subtle one: falling back to the rollup when the producer DID send a
413
+ // breakdown and this origin simply is not in it re-creates the exact
414
+ // misattribution byVantage exists to remove.
415
+ const laptopOnly: RouteResult = {
416
+ route: 'checkout.example.com/',
417
+ outcome: 'unreachable',
418
+ confidence: 'real',
419
+ byVantage: [{ vantage: 'local', path: 'data', outcome: 'unreachable', confidence: 'real' }],
420
+ }
421
+
422
+ it('an origin absent from a breakdown is "not tested", not the rollup', () => {
423
+ expect(originRouteEvidence(laptopOnly, 'incluster').kind).toBe('none')
424
+ expect(routeAsSeenFrom(laptopOnly, 'incluster')).toBeUndefined()
425
+ })
426
+
427
+ it('what an origin found on OTHER routes cannot vouch for this one', () => {
428
+ // in-cluster may well have probed a sibling route; this route says nothing
429
+ // about it, and the absence is the evidence.
430
+ const ev = originRouteEvidence(laptopOnly, 'incluster')
431
+ expect(ev.kind).not.toBe('rollup')
432
+ })
433
+
434
+ it('a trace with NO breakdown still yields the rollup, flagged as such', () => {
435
+ const legacy: RouteResult = { route: 'r', outcome: 'verified', confidence: 'real' }
436
+ const ev = originRouteEvidence(legacy, 'incluster')
437
+ expect(ev.kind).toBe('rollup')
438
+ expect(ev.kind === 'rollup' && ev.result.outcome).toBe('verified')
439
+ })
440
+
441
+ it('an empty breakdown array counts as no breakdown', () => {
442
+ const empty: RouteResult = { route: 'r', outcome: 'verified', byVantage: [] }
443
+ expect(originRouteEvidence(empty, 'incluster').kind).toBe('rollup')
444
+ })
445
+
446
+ it("the origin that IS in the breakdown still gets its own row", () => {
447
+ const ev = originRouteEvidence(laptopOnly, 'local')
448
+ expect(ev.kind).toBe('own')
449
+ expect(ev.kind === 'own' && ev.result.outcome).toBe('unreachable')
450
+ })
451
+ })
452
+
453
+ describe('groupRoutes keeps apart what the board would otherwise speak for', () => {
454
+ const base = (o: Partial<RouteResult>): RouteResult => ({
455
+ route: 'a.example.com/', target: 'shop:80', outcome: 'verified', confidence: 'real', ...o,
456
+ })
457
+
458
+ // The whole point of the per-vantage split: two routes whose ROLLUPS agree can
459
+ // still disagree per vantage. Folding them puts rs[0] in charge of the board,
460
+ // so the route that fails from a laptop is represented by the one that works.
461
+ it('splits routes whose rollups agree but whose vantages disagree', () => {
462
+ const works = base({
463
+ route: 'a.example.com/',
464
+ byVantage: [{ vantage: 'local', path: 'data', outcome: 'verified' }],
465
+ })
466
+ const fails = base({
467
+ route: 'b.example.com/',
468
+ byVantage: [{ vantage: 'local', path: 'data', outcome: 'unreachable' }],
469
+ })
470
+ expect(groupRoutes([works, fails])).toHaveLength(2)
471
+ })
472
+
473
+ it('still folds routes that agree on every axis', () => {
474
+ const one = base({ route: 'a.example.com/', byVantage: [{ vantage: 'local', path: 'data', outcome: 'verified' }] })
475
+ const two = base({ route: 'b.example.com/', byVantage: [{ vantage: 'local', path: 'data', outcome: 'verified' }] })
476
+ expect(groupRoutes([one, two])).toHaveLength(1)
477
+ })
478
+
479
+ it('keeps same-named Services in different namespaces apart', () => {
480
+ const prod = base({ route: 'a.example.com/', targetNamespace: 'prod' })
481
+ const staging = base({ route: 'b.example.com/', targetNamespace: 'staging' })
482
+ expect(groupRoutes([prod, staging])).toHaveLength(2)
483
+ })
484
+
485
+ // Same outcome and layer, different reason: showing one and hiding the other
486
+ // behind a tab that claims to speak for both loses the actual diagnosis.
487
+ it('keeps differing evidence apart at every outcome, not just not-tested', () => {
488
+ const refused = base({ route: 'a.example.com/', outcome: 'unreachable', failedLayer: 'tcp', evidence: 'connection refused' })
489
+ const timeout = base({ route: 'b.example.com/', outcome: 'unreachable', failedLayer: 'tcp', evidence: 'timed out' })
490
+ expect(groupRoutes([refused, timeout])).toHaveLength(2)
491
+ })
492
+
493
+ it('counts paths, not hostnames, when a fold shares one host', () => {
494
+ const admin = base({ route: 'a.example.com/admin' })
495
+ const web = base({ route: 'a.example.com/web' })
496
+ const [s] = groupRoutes([admin, web])
497
+ expect(s.routes).toHaveLength(2)
498
+ expect(s.sub).toContain('2 paths')
499
+ })
500
+
501
+ it('vantageSignature is order-independent', () => {
502
+ const a = base({ byVantage: [{ vantage: 'local', path: 'data', outcome: 'verified' }, { vantage: 'in-cluster', path: 'data', outcome: 'unreachable' }] })
503
+ const b = base({ byVantage: [{ vantage: 'in-cluster', path: 'data', outcome: 'unreachable' }, { vantage: 'local', path: 'data', outcome: 'verified' }] })
504
+ expect(vantageSignature(a)).toBe(vantageSignature(b))
505
+ })
506
+ })
507
+
508
+ describe('a config-derived break belongs to no vantage', () => {
509
+ const known: RouteResult = {
510
+ route: 'a.example.com/', target: 'missing:80', outcome: 'unreachable',
511
+ evidence: 'backend Service does not exist', basis: 'declared-config',
512
+ }
513
+
514
+ // It has no byVantage by construction, and the rollup fallback would otherwise
515
+ // hand it to whichever origin happened to be selected - presenting a static
516
+ // configuration fact as that vantage's failed dial.
517
+ it('is reported as config, never as the selected origin\'s observation', () => {
518
+ for (const id of ['local', 'incluster', 'apiserver']) {
519
+ expect(originRouteEvidence(known, id).kind).toBe('config')
520
+ }
521
+ })
522
+
523
+ it('still carries its evidence', () => {
524
+ const ev = originRouteEvidence(known, 'local')
525
+ expect(ev.kind === 'config' && ev.result.evidence).toBe('backend Service does not exist')
526
+ })
527
+
528
+ it('an observed route is unaffected', () => {
529
+ const observed: RouteResult = {
530
+ route: 'a/', target: 'shop:80', outcome: 'verified',
531
+ byVantage: [{ vantage: 'local', path: 'data', outcome: 'verified' }],
532
+ }
533
+ expect(originRouteEvidence(observed, 'local').kind).toBe('own')
534
+ })
535
+ })
536
+
537
+ describe('derived breaks are never described as requests', () => {
538
+ const declared: RouteResult = {
539
+ route: 'a/', target: 'missing:80', outcome: 'unreachable', basis: 'declared-config',
540
+ }
541
+ const state: RouteResult = {
542
+ route: 'b/', target: 'shop:80', outcome: 'unreachable', basis: 'cluster-state',
543
+ }
544
+
545
+ // 'failed' is the mark for a request that was sent and did not arrive. Using
546
+ // it for something never dialled is what made the page contradict itself:
547
+ // "could not get through" beside "read from configuration".
548
+ it('marks a derived break as config, not failed', () => {
549
+ expect(routeMark(declared)).toBe('config')
550
+ expect(routeMark(state)).toBe('config')
551
+ })
552
+
553
+ it('never says "could not get through" for either class', () => {
554
+ expect(routeChip(declared)).not.toMatch(/get through/)
555
+ expect(routeChip(state)).not.toMatch(/get through/)
556
+ })
557
+
558
+ // The two derived classes are different facts: one is broken regardless of
559
+ // what the cluster is doing, the other changes when the workload does.
560
+ it('distinguishes declared config from current cluster state', () => {
561
+ expect(routeChip(declared)).not.toBe(routeChip(state))
562
+ })
563
+
564
+ it('an observed failure is still a failure', () => {
565
+ expect(routeMark({ route: 'c/', target: 's:80', outcome: 'unreachable', confidence: 'real' })).toBe('failed')
566
+ })
567
+ })
568
+
569
+ describe('routeIdentity is what a route IS, not what happened to it', () => {
570
+ const base = { route: 'a.example.com/', target: 'shop:80', targetNamespace: 'prod' }
571
+
572
+ // Selection anchored on the scenario group key moved the user to a different
573
+ // path whenever a re-run changed an outcome - losing their place exactly when
574
+ // they had just asked for new evidence about the path they were reading.
575
+ it('survives a change in outcome, evidence and per-vantage results', () => {
576
+ const before: RouteResult = { ...base, outcome: 'unreachable', evidence: 'refused' }
577
+ const after: RouteResult = {
578
+ ...base,
579
+ outcome: 'verified',
580
+ confidence: 'real',
581
+ evidence: 'HTTP 200',
582
+ byVantage: [{ vantage: 'local', path: 'data', outcome: 'verified' }],
583
+ }
584
+ expect(routeIdentity(after)).toBe(routeIdentity(before))
585
+ })
586
+
587
+ it('separates same-named backends in different namespaces', () => {
588
+ expect(routeIdentity({ ...base, targetNamespace: 'prod', outcome: 'verified' })).not.toBe(
589
+ routeIdentity({ ...base, targetNamespace: 'staging', outcome: 'verified' }),
590
+ )
591
+ })
592
+
593
+ it('separates different paths on the same backend', () => {
594
+ expect(routeIdentity({ ...base, route: 'a.example.com/admin', outcome: 'verified' })).not.toBe(
595
+ routeIdentity({ ...base, route: 'a.example.com/web', outcome: 'verified' }),
596
+ )
597
+ })
598
+ })
599
+
600
+ describe('a folded tab is named for what it actually folds', () => {
601
+ const notTested = (route: string, evidence: string): RouteResult => ({ route, outcome: 'not-tested', evidence })
602
+
603
+ // hostOf returns the whole label when there is no path separator, so a
604
+ // Service port and a Pod were counted and announced as "2 hostnames".
605
+ it('does not call port- and pod-shaped labels hostnames', () => {
606
+ const same = 'the backend did not respond within the probe budget'
607
+ const [s] = groupRoutes([notTested('port 80', same), notTested('web-6c6d9468c-bftj4 port 8080', same)])
608
+ expect(s.hosts).toEqual([])
609
+ expect(s.sub).toContain('2 paths')
610
+ expect(s.sub).not.toContain('hostname')
611
+ })
612
+
613
+ it('still says hostnames when every member really is one', () => {
614
+ const r = (host: string): RouteResult => ({ route: `${host}/`, target: 'web:80', outcome: 'verified', confidence: 'real' })
615
+ const [s] = groupRoutes([r('a.example.com'), r('b.example.com')])
616
+ expect(s.hosts).toHaveLength(2)
617
+ expect(s.sub).toContain('2 hostnames')
618
+ })
619
+
620
+ it('carries every folded label for the hover, hostname or not', () => {
621
+ const same = 'skipped'
622
+ const [s] = groupRoutes([notTested('port 80', same), notTested('web-abc port 8080', same)])
623
+ expect(s.members).toEqual(['port 80', 'web-abc port 8080'])
624
+ })
625
+ })
626
+
627
+ describe('a backend-scoped pass never reads as a bare pass', () => {
628
+ const scoped = (outcome: 'verified' | 'reached'): RouteResult =>
629
+ r({
630
+ outcome,
631
+ confidence: 'real',
632
+ byVantage: [{ vantage: 'in-cluster', path: 'data', source: 'probe-job', outcome, segment: 'backend' }],
633
+ })
634
+
635
+ it('appends the segment to the outcome word', () => {
636
+ expect(routeChip(scoped('verified'))).toBe('got through · backend')
637
+ expect(routeChip(scoped('reached'))).toBe('answered · backend')
638
+ })
639
+
640
+ it('a route with no front door keeps the plain words', () => {
641
+ expect(routeChip(r({ outcome: 'verified', confidence: 'real' }))).toBe('got through')
642
+ })
643
+
644
+ it('routes differing only in segment stay separate scenarios', () => {
645
+ const a = scoped('verified')
646
+ const b = r({ outcome: 'verified', confidence: 'real', byVantage: [{ vantage: 'in-cluster', path: 'data', source: 'probe-job', outcome: 'verified' }] })
647
+ expect(vantageSignature(a)).not.toBe(vantageSignature(b))
648
+ })
649
+ })
650
+
651
+ describe('a preserved candidate is one gap, not two scenarios', () => {
652
+ it('a skip row for a host a not-tested route covers is absorbed', () => {
653
+ const s = scenariosFor(
654
+ [{ route: 'argocd:80', target: 'argocd:80', outcome: 'not-tested' }],
655
+ [{ route: 'argocd:80', reason: 'the API-server proxy timed out' }],
656
+ )
657
+ expect(s).toHaveLength(1)
658
+ })
659
+
660
+ it('a skip row for an UNcovered host still surfaces', () => {
661
+ const s = scenariosFor(
662
+ [{ route: 'argocd:80', target: 'argocd:80', outcome: 'not-tested' }],
663
+ [{ route: 'other-svc:9090', reason: 'not reachable from here' }],
664
+ )
665
+ expect(s).toHaveLength(2)
666
+ })
667
+ })
668
+
669
+ describe('a structural line explains itself, not a test that never existed', () => {
670
+ it('ownership and selection never claim they were "not tested"', () => {
671
+ expect(edgeHelp('runs', 'config')).toContain('ownership')
672
+ expect(edgeHelp('runs', 'config')).not.toContain('not tested')
673
+ expect(edgeHelp('selects', 'config')).toContain('read from the cluster')
674
+ expect(edgeHelp('selects', 'config')).not.toContain('not tested')
675
+ })
676
+ it('declared routing says what WOULD exercise it', () => {
677
+ for (const l of ['routes to', 'sends to']) {
678
+ expect(edgeHelp(l, 'config')).toContain('request through this entry')
679
+ }
680
+ })
681
+ it('anything actually observed keeps the mark vocabulary', () => {
682
+ expect(edgeHelp('selects', 'proved')).toBe(markHelp('proved'))
683
+ expect(edgeHelp('routes to', 'failed')).toBe(markHelp('failed'))
684
+ })
685
+ })
686
+
687
+ describe('the rest of the edge vocabulary', () => {
688
+ it('a collapsed sibling and an off-path backend say why, not "not tested"', () => {
689
+ expect(edgeHelp('also serves', 'config')).toContain('collapsed')
690
+ expect(edgeHelp('also serves', 'config')).not.toContain('not tested')
691
+ expect(edgeHelp('other host', 'excluded')).toContain('different host')
692
+ })
693
+ it("a line whose own label already says it does not say it twice", () => {
694
+ // origin edges are labelled with their evidence
695
+ expect(edgeHelpIsRedundant('not tested', '', 'untested')).toBe(true)
696
+ expect(edgeHelpIsRedundant('HTTP 404 · reached', '', 'proxied')).toBe(false)
697
+ })
698
+ })