@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,537 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { reachVerdict, hostFromTarget, VIA_API_SERVER } from './reachVerdict'
3
+ import type { Trace, RouteResult, Hop, ProbeResult } from './types'
4
+
5
+ // Anti-drift pin: the one operator-facing name for the apiserver-proxy vantage
6
+ // MUST stay identical to the Go const VantageAPIServer (coverage.go), which
7
+ // TestVantageAPIServerName pins to the same literal.
8
+ describe('VIA_API_SERVER - single vantage name, must match the Go const', () => {
9
+ it('is exactly "via API server"', () => {
10
+ expect(VIA_API_SERVER).toBe('via API server')
11
+ })
12
+ })
13
+
14
+ const trace = (o: Partial<Trace>): Trace => ({
15
+ subject: { kind: 'Service', namespace: 'p', name: 's' },
16
+ upstreams: [], downstream: [], verdict: 'healthy', brokenAt: -1, ...o,
17
+ })
18
+ const route = (o: Partial<RouteResult>): RouteResult => ({ route: '/', outcome: 'verified', ...o })
19
+
20
+ // An Ingress subject whose entry-host probe(s) live on its own hop. The subject is the
21
+ // FRONT DOOR, so the verdict must speak to it - not let the backend route speak for it.
22
+ const probe = (o: Partial<ProbeResult>): ProbeResult => ({ layer: 'http', target: 'https://shop.example.com/', vantage: 'local', ok: true, ...o })
23
+ const ingressTrace = (hostProbes: ProbeResult[], routes: RouteResult[] = [route({ outcome: 'reached', confidence: 'indirect' })]): Trace =>
24
+ trace({
25
+ subject: { kind: 'Ingress', namespace: 'p', name: 'ing' },
26
+ downstream: [{ resource: { kind: 'Ingress', name: 'ing' }, edge: 'entry:Ingress', findings: [], probes: hostProbes } as Hop],
27
+ routes,
28
+ })
29
+
30
+ describe('hostFromTarget - host extraction (linear, no ReDoS-prone regex)', () => {
31
+ it('strips scheme, path, and trailing numeric port', () => {
32
+ expect(hostFromTarget('http://shop.example.com:80/api/v1')).toBe('shop.example.com')
33
+ expect(hostFromTarget('https://host:8443/')).toBe('host')
34
+ expect(hostFromTarget('svc.ns:9090')).toBe('svc.ns')
35
+ expect(hostFromTarget('plainhost')).toBe('plainhost')
36
+ expect(hostFromTarget('')).toBe('')
37
+ expect(hostFromTarget(undefined)).toBe('')
38
+ })
39
+ })
40
+
41
+ describe('reachVerdict - a front-door 502/504 is a network fault, never "check app logs"', () => {
42
+ it('a degraded HTTP front door (502/504) says it could not reach the backend, and never asserts the network is fine or points at app logs', () => {
43
+ const v = reachVerdict(
44
+ ingressTrace([probe({ layer: 'http', ok: true, tone: 'degraded', detail: "HTTP 502 · the front door couldn't reach the backend" })]),
45
+ true,
46
+ )
47
+ expect(v.tone).toBe('degraded')
48
+ expect(v.text).toContain("couldn't reach the backend")
49
+ // The 5xx reclassification (app 5xx = reached) leaves only 502/504/cert here, so
50
+ // these old-bucket assertions must never appear on a real network-path fault.
51
+ expect(v.text).not.toContain('the app returned a server error')
52
+ expect(v.text).not.toContain('network path is fine')
53
+ expect(v.text).not.toContain("check the app's logs")
54
+ })
55
+ })
56
+
57
+ describe('reachVerdict - a degraded route names the layer that failed (per-layer model)', () => {
58
+ it('failedLayer "tls" reads as a TLS/certificate failure, never a generic "server error"', () => {
59
+ const v = reachVerdict(trace({ verdict: 'degraded', routes: [route({ outcome: 'server-error', confidence: 'real', failedLayer: 'tls' })] }), true)
60
+ expect(v.tone).toBe('degraded')
61
+ expect(v.text).toMatch(/TLS|certificate/i)
62
+ expect(v.text).not.toContain('server error')
63
+ expect(v.text).not.toContain('app')
64
+ })
65
+ it('failedLayer "upstream" (a 502/504) reads as HTTP-reached but the backend/upstream unreachable', () => {
66
+ const v = reachVerdict(trace({ verdict: 'degraded', routes: [route({ outcome: 'server-error', confidence: 'real', failedLayer: 'upstream' })] }), true)
67
+ expect(v.tone).toBe('degraded')
68
+ expect(v.text).toContain("couldn't reach its backend")
69
+ expect(v.text).not.toContain('server error')
70
+ })
71
+ })
72
+
73
+ describe('reachVerdict - an ingress-controller problem leads the verdict', () => {
74
+ it('no-controller finding on the entry hop headlines the banner (not "backend reachable, entry not tested")', () => {
75
+ const t = trace({
76
+ subject: { kind: 'Ingress', namespace: 'p', name: 'orphan' },
77
+ verdict: 'degraded',
78
+ downstream: [{ resource: { kind: 'Ingress', name: 'orphan' }, edge: 'entry:Ingress', findings: [
79
+ { code: 'ingress:no-controller', severity: 'warning', message: 'No ingress controller is handling this - the routing rules exist but nothing is serving them.' },
80
+ ], probes: [probe({ layer: 'dns', ok: false, skipped: true, reason: 'no resolve' })] } as Hop],
81
+ routes: [route({ outcome: 'reached', confidence: 'indirect' })],
82
+ })
83
+ const v = reachVerdict(t, true)
84
+ expect(v.tone).toBe('degraded')
85
+ expect(v.text).toContain('No ingress controller is handling this')
86
+ expect(v.text).not.toContain('entry point wasn')
87
+ })
88
+ })
89
+
90
+ describe('reachVerdict - no live backend leads with the cause, not "via API server"', () => {
91
+ const backendDownTrace = (podsFindings: Hop['findings'], selected: number): Trace =>
92
+ trace({
93
+ verdict: 'broken',
94
+ brokenAt: 0,
95
+ downstream: [
96
+ // The Service symptom carries a Pod ref in reality (linkNoReadyToCulprit) -
97
+ // it must NOT outrank the specific pod-failure cause for the headline.
98
+ { resource: { kind: 'Service', name: 's' }, edge: 'entry:Service', findings: [
99
+ { code: `problem:0/${selected} selected pods ready`, severity: 'critical', message: `0/${selected} selected pods ready`, resource: { kind: 'Pod', name: 'culprit' } },
100
+ ] } as Hop,
101
+ { resource: { kind: 'Pods', name: '' }, edge: 'Service->Pods', meta: { ready: 0, selected }, findings: podsFindings } as Hop,
102
+ ],
103
+ routes: [route({ outcome: 'unreachable', confidence: 'indirect', evidence: 'No ready backend endpoints' })],
104
+ })
105
+
106
+ it('pods wired but 0 ready (crashloop) → headline NAMES the failing container from the pod diagnosis, not a generic "backend app"', () => {
107
+ const v = reachVerdict(backendDownTrace([
108
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'CrashLoopBackOff', cause: 'container "c" is crashlooping after exit code 1.', resource: { kind: 'Pod', name: 'crashloop-x' } },
109
+ ], 1), true)
110
+ expect(v.tone).toBe('unhealthy')
111
+ expect(v.text).toBe('No healthy backend - container "c" is crashlooping after exit code 1.')
112
+ expect(v.text).not.toContain('management API')
113
+ expect(v.text).not.toContain('the backend app')
114
+ expect(v.caveat).toContain('wired')
115
+ expect(v.caveat).toContain('the break is in the pods')
116
+ expect(v.caveat).toContain('0/1 pod ready')
117
+ })
118
+
119
+ it('image-pull failure reads plainly + pluralizes the pod count', () => {
120
+ const v = reachVerdict(backendDownTrace([
121
+ { code: 'problem:ImagePullBackOff', severity: 'critical', message: 'ImagePullBackOff', resource: { kind: 'Pod', name: 'bad' } },
122
+ ], 2), true)
123
+ expect(v.text).toContain("can't pull its image")
124
+ expect(v.caveat).toContain('0/2 pods ready')
125
+ })
126
+
127
+ it('0 ready but NO failure finding (fresh rollout / pending) → amber "may be starting", NOT a hard ✗ - a transitional state isn\'t an outage', () => {
128
+ const v = reachVerdict(backendDownTrace([], 1), true)
129
+ expect(v.tone).toBe('degraded')
130
+ expect(v.icon).toBe('⚠')
131
+ expect(v.text).toContain('No ready backend yet')
132
+ expect(v.text).not.toContain('No healthy backend')
133
+ expect(v.caveat).toContain('starting')
134
+ })
135
+
136
+ it('partial-ready floored (1/2 pods, one crashing) → headline LEADS with the "N of M ready" count + the problem, never the optimistic "Reached"', () => {
137
+ const v = reachVerdict(trace({
138
+ verdict: 'degraded',
139
+ downstream: [
140
+ { resource: { kind: 'Service', name: 's' }, edge: 'entry:Service', findings: [] } as Hop,
141
+ { resource: { kind: 'Pods', name: '' }, edge: 'Service->Pods', meta: { ready: 1, selected: 2 }, findings: [
142
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'Container "web" is crash-looping (CrashLoopBackOff)' },
143
+ ] } as Hop,
144
+ ],
145
+ routes: [route({ outcome: 'reached', confidence: 'indirect' })],
146
+ }), true)
147
+ expect(v.tone).toBe('degraded')
148
+ // The readiness split leads the warning message.
149
+ expect(v.text).toBe('1 of 2 pods ready - Container "web" is crash-looping (CrashLoopBackOff)')
150
+ expect(v.text).not.toContain('Reached')
151
+ })
152
+
153
+ it('partial-ready count is NOT doubled when the finding already carries one', () => {
154
+ const v = reachVerdict(trace({
155
+ verdict: 'degraded',
156
+ downstream: [
157
+ { resource: { kind: 'Service', name: 's' }, edge: 'entry:Service', findings: [] } as Hop,
158
+ { resource: { kind: 'Pods', name: '' }, edge: 'Service->Pods', meta: { ready: 1, selected: 2 }, findings: [
159
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'A pod is crash-looping (1 of 2 down)' },
160
+ ] } as Hop,
161
+ ],
162
+ routes: [route({ outcome: 'reached', confidence: 'indirect' })],
163
+ }), true)
164
+ expect(v.text).toBe('A pod is crash-looping (1 of 2 down)')
165
+ })
166
+
167
+ it('pod-state finding headlines the clean `cause`, not the raw kubelet `message` (no back-off/pod-UID noise)', () => {
168
+ const v = reachVerdict(trace({
169
+ verdict: 'degraded',
170
+ downstream: [
171
+ { resource: { kind: 'Service', name: 's' }, edge: 'entry:Service', findings: [] } as Hop,
172
+ { resource: { kind: 'Pods', name: '' }, edge: 'Service->Pods', meta: { ready: 1, selected: 2 }, findings: [
173
+ { code: 'problem:CrashLoopBackOff', severity: 'critical',
174
+ message: 'CrashLoopBackOff - back-off 5m0s restarting failed container=c pod=dual-bad-844665666-9tl6z_radar-netdiag(7fd8a1c5)',
175
+ cause: 'Container "c" is crash-looping (CrashLoopBackOff)' },
176
+ ] } as Hop,
177
+ ],
178
+ routes: [route({ outcome: 'reached', confidence: 'indirect' })],
179
+ }), true)
180
+ expect(v.text).toBe('1 of 2 pods ready - Container "c" is crash-looping (CrashLoopBackOff)')
181
+ expect(v.text).not.toContain('back-off')
182
+ expect(v.text).not.toContain('pod=')
183
+ })
184
+
185
+ it('does NOT prepend "N of M pods ready" when the headline is a NON-pod concern (partial pods, but a config finding leads)', () => {
186
+ const v = reachVerdict(trace({
187
+ verdict: 'degraded',
188
+ downstream: [
189
+ { resource: { kind: 'Service', name: 's' }, edge: 'entry:Service', findings: [
190
+ { code: 'netpol:would-deny', severity: 'critical', message: 'A cluster network rule would block traffic to these pods on :80 - no rule allows it.' },
191
+ ] } as Hop,
192
+ { resource: { kind: 'Pods', name: '' }, edge: 'Service->Pods', meta: { ready: 1, selected: 2 }, findings: [] } as Hop,
193
+ ],
194
+ routes: [route({ outcome: 'reached', confidence: 'indirect' })],
195
+ }), true)
196
+ expect(v.text).not.toContain('pods ready')
197
+ expect(v.text).not.toMatch(/1 of 2/)
198
+ })
199
+
200
+ it('multi-backend: the "N of M ready" count comes from the DIAGNOSED hop, not the first Pods hop', () => {
201
+ const v = reachVerdict(trace({
202
+ verdict: 'degraded',
203
+ downstream: [
204
+ { resource: { kind: 'Service', name: 's' }, edge: 'entry:Service', findings: [] } as Hop,
205
+ // First Pods hop: a different backend, partially up, NO finding.
206
+ { resource: { kind: 'Pods', name: 'a' }, edge: 'Service->Pods', meta: { ready: 1, selected: 2 }, findings: [] } as Hop,
207
+ // Second Pods hop: the DIAGNOSED backend - carries the critical finding + its own count.
208
+ { resource: { kind: 'Pods', name: 'b' }, edge: 'Service->Pods', meta: { ready: 1, selected: 3 }, findings: [
209
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'Container "c" is crash-looping (CrashLoopBackOff)' },
210
+ ] } as Hop,
211
+ ],
212
+ routes: [route({ outcome: 'reached', confidence: 'indirect' })],
213
+ }), true)
214
+ expect(v.text).toBe('1 of 3 pods ready - Container "c" is crash-looping (CrashLoopBackOff)')
215
+ expect(v.text).not.toContain('1 of 2')
216
+ })
217
+
218
+ it('multi-backend with ONE healthy backend does NOT condemn "No healthy backend" - a serving sibling means the entry can still serve', () => {
219
+ const v = reachVerdict(trace({
220
+ subject: { kind: 'Ingress', namespace: 'p', name: 'multi' },
221
+ verdict: 'degraded',
222
+ downstream: [
223
+ { resource: { kind: 'Ingress', name: 'multi' }, edge: 'entry:Ingress', findings: [] } as Hop,
224
+ { resource: { kind: 'Pods', name: '' }, edge: 'Ingress->Pods', meta: { ready: 0, selected: 1 }, findings: [
225
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'CrashLoopBackOff', resource: { kind: 'Pod', name: 'dead' } },
226
+ ] } as Hop,
227
+ { resource: { kind: 'Pods', name: '' }, edge: 'Ingress->Pods', meta: { ready: 2, selected: 2 }, findings: [] } as Hop,
228
+ ],
229
+ routes: [route({ route: '/ok', outcome: 'verified', confidence: 'real' }), route({ route: '/dead', outcome: 'unreachable', confidence: 'indirect' })],
230
+ }), true)
231
+ expect(v.text).not.toContain('No healthy backend')
232
+ })
233
+
234
+ it('an unreadable (RBAC-redacted) sibling backend blocks the "No healthy backend" headline - an unseen backend might be serving', () => {
235
+ const v = reachVerdict(trace({
236
+ subject: { kind: 'Ingress', namespace: 'p', name: 'multi' },
237
+ verdict: 'broken',
238
+ downstream: [
239
+ { resource: { kind: 'Pods', name: '' }, edge: 'Ingress->Pods', meta: { ready: 0, selected: 1 }, findings: [
240
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'CrashLoopBackOff', resource: { kind: 'Pod', name: 'dead' } },
241
+ ] } as Hop,
242
+ { resource: { kind: 'Service', name: 'other' }, edge: 'Ingress->Service', findings: [
243
+ { code: 'rbac:cross-namespace-redacted', severity: 'info', message: 'backend in another namespace - not readable' },
244
+ ] } as Hop,
245
+ ],
246
+ routes: [route({ route: '/dead', outcome: 'unreachable', confidence: 'indirect' })],
247
+ }), true)
248
+ expect(v.text).not.toContain('No healthy backend')
249
+ })
250
+
251
+ it('a selectorless (manual-endpoints) sibling backend blocks "No healthy backend" - we can\'t judge it from pod readiness', () => {
252
+ const v = reachVerdict(trace({
253
+ subject: { kind: 'Ingress', namespace: 'p', name: 'mix' },
254
+ verdict: 'broken',
255
+ downstream: [
256
+ { resource: { kind: 'Pods', name: '' }, edge: 'Ingress->Pods', meta: { ready: 0, selected: 1 }, findings: [
257
+ { code: 'problem:CrashLoopBackOff', severity: 'critical', message: 'CrashLoopBackOff', resource: { kind: 'Pod', name: 'dead' } },
258
+ ] } as Hop,
259
+ { resource: { kind: 'Service', name: 'manual' }, edge: 'Ingress->Service', meta: { selectorless: true }, findings: [
260
+ { code: 'svc:selectorless', severity: 'info', message: 'Service has no selector; endpoints are managed manually' },
261
+ ] } as Hop,
262
+ ],
263
+ routes: [route({ route: '/dead', outcome: 'unreachable', confidence: 'indirect' })],
264
+ }), true)
265
+ expect(v.text).not.toContain('No healthy backend')
266
+ })
267
+
268
+ it('scaled-to-0 (0 pods SELECTED) does NOT trigger backend-down - it keeps its own benign read', () => {
269
+ const v = reachVerdict(trace({
270
+ verdict: 'degraded',
271
+ downstream: [
272
+ { resource: { kind: 'Pods', name: '' }, edge: 'Service->Pods', meta: { ready: 0, selected: 0 }, findings: [] } as Hop,
273
+ ],
274
+ routes: [route({ outcome: 'unreachable', confidence: 'indirect', benign: true })],
275
+ }), true)
276
+ expect(v.text).not.toContain('No healthy backend')
277
+ })
278
+ })
279
+
280
+ describe('reachVerdict - Ingress front door is stated separately from the backend', () => {
281
+ it('host REACHED from outside → ✓ "Front door confirmed from outside" (success is first-class, not buried)', () => {
282
+ // A genuine 2xx carries tone 'healthy' - only that earns "confirmed" (a bare
283
+ // TCP/TLS connect or a 5xx must not, per the front-door honesty fix).
284
+ const v = reachVerdict(ingressTrace([probe({ layer: 'http', ok: true, tone: 'healthy', detail: 'HTTP 200' })]), true)
285
+ expect(v.icon).toBe('✓')
286
+ expect(v.text).toContain('Front door confirmed from outside')
287
+ })
288
+ it('host DNS skipped (split-horizon) + backend reachable → neutral, names front door NOT verified, never "confirmed"', () => {
289
+ const v = reachVerdict(ingressTrace([probe({ layer: 'dns', ok: false, skipped: true, reason: 'doesn’t resolve from here' })]), true)
290
+ expect(v.icon).toBe('')
291
+ expect(v.tone).toBe('neutral')
292
+ // Headline connects the two facts (backend up BUT entry untested); the caveat
293
+ // explains how the backend was reached + the action. Never claim the entry
294
+ // works, and never the self-contradicting "verify from outside".
295
+ expect(v.text).toBe('Backend answered via API server - but the Ingress entry point wasn’t tested')
296
+ // Short caveat stays on screen; the full why + fix lives in detail (tooltip).
297
+ expect(v.caveat?.toLowerCase()).toContain('didn’t resolve')
298
+ expect(v.caveat!.length).toBeLessThan(90) // one line, not a wall of text
299
+ expect(v.detail?.toLowerCase()).toContain('request shop.example.com from a client that can resolve it')
300
+ expect(v.caveat).not.toContain('verify from outside')
301
+ expect(v.detail).not.toContain('verify from outside')
302
+ expect(v.text).not.toContain('confirmed from inside')
303
+ })
304
+ it('host genuinely FAILED (transport) → ✗ front door unreachable', () => {
305
+ const v = reachVerdict(ingressTrace([probe({ layer: 'tcp', ok: false, error: 'connection refused' })]), true)
306
+ expect(v.icon).toBe('✗')
307
+ expect(v.text).toContain('Front door unreachable')
308
+ })
309
+ it('host returned an HTTP 3xx/4xx (TCP ok + http reached) → neutral "HTTP responded", never the dishonest "transport only / HTTP wasn’t checked"', () => {
310
+ // Common ingress shape: a preceding TCP-ok probe AND an HTTP 301/404. The front
311
+ // door DID return an HTTP response, so the copy must not claim transport-only.
312
+ const v = reachVerdict(ingressTrace([
313
+ probe({ layer: 'tcp', ok: true }),
314
+ probe({ layer: 'http', ok: true, tone: 'reached', detail: 'HTTP 301' }),
315
+ ]), true)
316
+ expect(v.icon).toBe('')
317
+ expect(v.tone).toBe('neutral')
318
+ expect(v.text).toContain('Front door reached - HTTP responded')
319
+ expect(v.text).not.toContain('transport only')
320
+ expect(v.caveat).toContain('HTTP 301')
321
+ expect(v.caveat).not.toContain('HTTP response wasn’t checked')
322
+ })
323
+ it('host transport-only (TCP ok, NO http probe) → still "transport only - HTTP wasn’t checked"', () => {
324
+ const v = reachVerdict(ingressTrace([probe({ layer: 'tcp', ok: true })]), true)
325
+ expect(v.icon).toBe('')
326
+ expect(v.tone).toBe('neutral')
327
+ expect(v.text).toContain('transport only')
328
+ expect(v.caveat).toContain('HTTP response wasn’t checked')
329
+ })
330
+ })
331
+
332
+ // The honesty rule the redesign exists to enforce: confidence is a LEVEL, not an
333
+ // alarm. A reached/200 - even via the proxy - reads ✓; ⚠ is reserved for real
334
+ // problems (server-error, partial, unreachable). The Tree + Diagram both use this.
335
+ describe('reachVerdict - never a ⚠ on a success', () => {
336
+ it('reached via proxy (indirect) is NEUTRAL with a caveat - not a green ✓ (overclaims), never a ⚠ (it did reach)', () => {
337
+ const v = reachVerdict(trace({ verdict: 'unknown', routes: [route({ outcome: 'reached', confidence: 'indirect' })] }))
338
+ expect(v.icon).toBe('')
339
+ expect(v.tone).toBe('neutral')
340
+ expect(v.icon).not.toBe('⚠')
341
+ expect(v.caveat).toBeTruthy()
342
+ })
343
+ it('verified real traffic reads ✓', () => {
344
+ const v = reachVerdict(trace({ verdict: 'healthy', routes: [route({ outcome: 'verified', confidence: 'real' })] }))
345
+ expect(v.icon).toBe('✓')
346
+ })
347
+ it('REACHED-not-verified real route is NEUTRAL, not a green ✓ - "real" confidence means the live path was exercised, not that the route was verified end-to-end (mirrors coverageBannerTone realPass)', () => {
348
+ const v = reachVerdict(trace({ verdict: 'healthy', routes: [route({ outcome: 'reached', confidence: 'real' })] }))
349
+ expect(v.icon).not.toBe('✓')
350
+ expect(v.tone).toBe('neutral')
351
+ expect(v.caveat).toBeTruthy()
352
+ })
353
+ it('a 5xx server-error IS a ⚠ (a real problem)', () => {
354
+ const v = reachVerdict(trace({ verdict: 'degraded', routes: [route({ outcome: 'server-error' })] }))
355
+ expect(v.icon).toBe('⚠')
356
+ })
357
+ it('partial (some unreachable) is ⚠', () => {
358
+ const v = reachVerdict(trace({
359
+ verdict: 'degraded',
360
+ routes: [route({ outcome: 'verified', confidence: 'real' }), route({ route: '/api', outcome: 'unreachable' })],
361
+ }))
362
+ expect(v.icon).toBe('⚠')
363
+ })
364
+ it('fully broken is ✗', () => {
365
+ const v = reachVerdict(trace({ verdict: 'broken', routes: [route({ outcome: 'unreachable' })] }))
366
+ expect(v.icon).toBe('✗')
367
+ })
368
+ it('un-probed clean config reads "Config valid - not yet tested" (neutral, no green ✓ until a real probe)', () => {
369
+ const v = reachVerdict(trace({ verdict: 'healthy', routes: [] }))
370
+ expect(v.icon).toBe('')
371
+ expect(v.tone).toBe('neutral')
372
+ expect(v.text).toContain('Config valid')
373
+ expect(v.text).toContain('not yet tested')
374
+ expect(v.caveat).toContain('run the test')
375
+ })
376
+ it('un-probed DEGRADED config (e.g. targetPort mismatch) is a ⚠ with the cause, never "valid"', () => {
377
+ const v = reachVerdict(trace({ verdict: 'degraded', routes: [], diagnosis: { summary: 'Service targetPort likely wrong' } as Trace['diagnosis'] }))
378
+ expect(v.icon).toBe('⚠')
379
+ expect(v.text).toContain('targetPort')
380
+ expect(v.text).not.toContain('looks valid')
381
+ })
382
+ })
383
+
384
+ describe('reachVerdict - benign / indirect / front-door honesty', () => {
385
+ it('benign-only (scaled-to-0) probed trace reads amber dormant, NOT "Config looks healthy"', () => {
386
+ const v = reachVerdict(trace({ verdict: 'degraded', routes: [route({ outcome: 'unreachable', benign: true })] }))
387
+ expect(v.tone).toBe('degraded')
388
+ expect(v.text.toLowerCase()).toContain('scaled to 0')
389
+ expect(v.text).not.toContain('Config looks healthy')
390
+ })
391
+
392
+ it('indirect-only unreachable does NOT hard-condemn (✗) - falls to neutral/unknown keeping the honest headline', () => {
393
+ const v = reachVerdict(trace({
394
+ verdict: 'degraded',
395
+ headline: 'Unreachable via API server - real path not confirmed',
396
+ routes: [route({ outcome: 'unreachable', confidence: 'indirect' })],
397
+ }))
398
+ expect(v.icon).not.toBe('✗')
399
+ expect(v.tone).not.toBe('unhealthy')
400
+ })
401
+
402
+ it('a REAL (non-indirect) unreachable still hard-condemns (✗)', () => {
403
+ const v = reachVerdict(trace({ verdict: 'broken', routes: [route({ outcome: 'unreachable', confidence: 'real' })] }))
404
+ expect(v.icon).toBe('✗')
405
+ })
406
+
407
+ it('front door 2xx but a backend route actually FAILED → ⚠/degraded, not a green ✓', () => {
408
+ const v = reachVerdict(ingressTrace(
409
+ [probe({ layer: 'http', ok: true, tone: 'healthy', detail: 'HTTP 200' })],
410
+ [route({ outcome: 'unreachable', confidence: 'real' })],
411
+ ), true)
412
+ expect(v.icon).toBe('⚠')
413
+ expect(v.tone).toBe('degraded')
414
+ })
415
+
416
+ it('front door 2xx with backend untested keeps the green ✓', () => {
417
+ const v = reachVerdict(ingressTrace(
418
+ [probe({ layer: 'http', ok: true, tone: 'healthy', detail: 'HTTP 200' })],
419
+ [],
420
+ ), true)
421
+ expect(v.icon).toBe('✓')
422
+ })
423
+
424
+ it('un-probed ExternalName surfaces the alias + invites the test (no condemnation)', () => {
425
+ const t = trace({
426
+ subject: { kind: 'Service', namespace: 'p', name: 'ext' },
427
+ downstream: [{ resource: { kind: 'ExternalName', name: 'db.example.com' }, edge: 'Service->ExternalName', findings: [] } as Hop],
428
+ routes: [],
429
+ })
430
+ const v = reachVerdict(t, false)
431
+ expect(v.text).toContain('db.example.com')
432
+ expect(v.icon).not.toBe('✗')
433
+ })
434
+
435
+ it('probed ExternalName reached directly is NEUTRAL (not a green real-traffic ✓)', () => {
436
+ const t = trace({
437
+ subject: { kind: 'Service', namespace: 'p', name: 'ext' },
438
+ downstream: [{ resource: { kind: 'ExternalName', name: 'db.example.com' }, edge: 'Service->ExternalName', findings: [] } as Hop],
439
+ routes: [route({ outcome: 'reached', confidence: 'indirect' })],
440
+ })
441
+ const v = reachVerdict(t, true)
442
+ expect(v.icon).toBe('')
443
+ expect(v.tone).toBe('neutral')
444
+ })
445
+ })
446
+
447
+ describe('reachVerdict - a confirmed host must not declare an untested sibling host confirmed (defect 11)', () => {
448
+ it('2xx on one host + a different declared host whose probe SKIPPED → neutral "one confirmed, another not tested" (no green ✓)', () => {
449
+ const t = ingressTrace([
450
+ probe({ layer: 'http', target: 'https://api.example.com/', ok: true, tone: 'healthy', detail: 'HTTP 200' }),
451
+ probe({ layer: 'dns', target: 'https://admin.internal/', ok: false, skipped: true, reason: "didn't resolve from here" }),
452
+ ], [route({ outcome: 'verified', confidence: 'real' })])
453
+ const v = reachVerdict(t, true)
454
+ expect(v.icon).not.toBe('✓')
455
+ expect(v.tone).toBe('neutral')
456
+ expect(v.text).toContain('not tested')
457
+ })
458
+
459
+ it('2xx and the only skipped probe is a LOWER LAYER of the SAME host → still a clean green ✓', () => {
460
+ const t = ingressTrace([
461
+ probe({ layer: 'http', target: 'https://api.example.com/', ok: true, tone: 'healthy', detail: 'HTTP 200' }),
462
+ probe({ layer: 'tls', target: 'https://api.example.com/', ok: true, skipped: true, reason: 'skipped' }),
463
+ ], [route({ outcome: 'verified', confidence: 'real' })])
464
+ const v = reachVerdict(t, true)
465
+ expect(v.icon).toBe('✓')
466
+ expect(v.text).toContain('confirmed')
467
+ })
468
+ })
469
+
470
+ describe('reachVerdict - cycle 5 honesty fixes', () => {
471
+ // Defect 6: a mixed 200 + 5xx front door must not read green "confirmed".
472
+ it('one host 200 + sibling host 5xx → ⚠ partial, never "confirmed from outside"', () => {
473
+ const t = ingressTrace(
474
+ [
475
+ probe({ target: 'https://a.example.com/', ok: true, tone: 'healthy' }),
476
+ probe({ target: 'https://b.example.com/', ok: true, tone: 'degraded', detail: 'HTTP 503 · reached, server error' }),
477
+ ],
478
+ [route({ outcome: 'verified', confidence: 'real' })],
479
+ )
480
+ const v = reachVerdict(t, true)
481
+ expect(v.icon).toBe('⚠')
482
+ expect(v.text).not.toContain('confirmed from outside')
483
+ })
484
+
485
+ // Defect 7: a proxy-only (indirect) unreachable must not be counted as a confirmed
486
+ // backend failure in the partial branch - it false-condemns an untested path.
487
+ it('one verified/real + one unreachable/indirect → never "1 unreachable" (the proxy-failed route was never tested)', () => {
488
+ const t = trace({
489
+ subject: { kind: 'Service', namespace: 'p', name: 's' },
490
+ routes: [
491
+ route({ route: ':80', outcome: 'verified', confidence: 'real' }),
492
+ route({ route: ':9090', outcome: 'unreachable', confidence: 'indirect' }),
493
+ ],
494
+ })
495
+ const v = reachVerdict(t, true)
496
+ expect(v.text).not.toContain('unreachable')
497
+ expect(v.tone).not.toBe('degraded')
498
+ })
499
+ })
500
+
501
+ describe('reachVerdict - a 2xx host must not declare a sibling 3xx/4xx host confirmed (cycle 11)', () => {
502
+ // The single-host path treats a 3xx/4xx as http-reached → neutral "route not
503
+ // verified". The multi-host 2xx branch must apply the SAME rule to a sibling host:
504
+ // a sibling that returned 3xx/4xx (tone 'reached') is reached, not confirmed - so
505
+ // the banner must NOT claim a blanket "Front door confirmed from outside".
506
+ it('2xx on one host + a sibling host that returned 3xx/4xx → neutral, names the unverified sibling (no green ✓)', () => {
507
+ const t = ingressTrace([
508
+ probe({ layer: 'http', target: 'https://api.example.com/', ok: true, tone: 'healthy', detail: 'HTTP 200' }),
509
+ probe({ layer: 'http', target: 'https://www.example.com/', ok: true, tone: 'reached', detail: 'HTTP 301' }),
510
+ ], [route({ outcome: 'verified', confidence: 'real' })])
511
+ const v = reachVerdict(t, true)
512
+ expect(v.icon).not.toBe('✓')
513
+ expect(v.tone).toBe('neutral')
514
+ expect(v.text).not.toContain('confirmed from outside')
515
+ expect(v.text).toContain('reached but not verified')
516
+ })
517
+ })
518
+
519
+ describe('reachVerdict - a broken-from-upstream verdict headlines the reason, not optimistic probe text', () => {
520
+ it('leads with the verdict reason when there is no static finding (entry paths unreachable)', () => {
521
+ // Healthy Service, but every entry path failed: verdict=broken with a reason
522
+ // and NO finding. The banner must lead with the reason and a red icon, never
523
+ // keep the optimistic "Reachable - verified" headline beside the red mark.
524
+ const v = reachVerdict(
525
+ trace({
526
+ verdict: 'broken',
527
+ reason: 'all entry paths into this resource are unreachable; the resource itself looks healthy',
528
+ headline: 'Reachable - verified',
529
+ routes: [route({ outcome: 'verified', confidence: 'real' })],
530
+ }),
531
+ true,
532
+ )
533
+ expect(v.icon).toBe('✗')
534
+ expect(v.text).toContain('all entry paths')
535
+ expect(v.text).not.toBe('Reachable - verified')
536
+ })
537
+ })