@skyhook-io/k8s-ui 1.12.0 → 1.13.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 (103) hide show
  1. package/package.json +4 -4
  2. package/src/components/issues/severity.ts +1 -0
  3. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  4. package/src/components/resources/ResourcesView.tsx +410 -5
  5. package/src/components/resources/index.ts +27 -0
  6. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  7. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  8. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  9. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  10. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  11. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  12. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  13. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  14. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  15. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  16. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  17. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  18. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  19. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  20. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  23. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  24. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  25. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  26. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  27. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  28. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  29. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  30. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  31. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  32. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  33. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  34. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  35. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  36. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  37. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  38. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  39. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  40. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  41. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  42. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  43. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  44. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  45. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  46. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  47. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  48. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  49. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  50. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  51. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  52. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  53. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  54. package/src/components/resources/renderers/index.ts +16 -1
  55. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  56. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  57. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  58. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  59. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  60. package/src/components/resources/resource-utils-calico.ts +261 -0
  61. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  62. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  63. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  64. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  65. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  66. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  67. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  68. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  69. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  70. package/src/components/resources/resource-utils-velero.ts +68 -0
  71. package/src/components/resources/resource-utils.ts +98 -0
  72. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  73. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  74. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  75. package/src/components/shared/index.ts +1 -1
  76. package/src/components/shared/revision-role-badges.test.ts +120 -0
  77. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  78. package/src/components/topology/K8sResourceNode.tsx +22 -0
  79. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  80. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  81. package/src/components/topology/TopologyGraph.tsx +6 -6
  82. package/src/components/topology/TopologySearch.tsx +7 -0
  83. package/src/components/topology/layout.ts +3 -0
  84. package/src/components/trace/ReachabilityView.tsx +1 -0
  85. package/src/components/trace/TracePanel.tsx +12 -2
  86. package/src/components/trace/reachFixtures.ts +26 -1
  87. package/src/components/trace/reachGraphModel.test.ts +20 -0
  88. package/src/components/trace/reachGraphModel.ts +8 -1
  89. package/src/components/trace/reachOrigins.test.ts +31 -0
  90. package/src/components/trace/reachOrigins.ts +11 -1
  91. package/src/components/ui/Badge.tsx +10 -0
  92. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  93. package/src/components/ui/ScheduleValue.tsx +85 -0
  94. package/src/components/ui/drawer-components.test.tsx +17 -1
  95. package/src/components/ui/drawer-components.tsx +9 -2
  96. package/src/components/ui/index.ts +2 -0
  97. package/src/components/workload/WorkloadView.tsx +3 -0
  98. package/src/types/core.ts +29 -0
  99. package/src/types/policy.ts +153 -2
  100. package/src/utils/navigation.test.ts +5 -0
  101. package/src/utils/navigation.ts +5 -0
  102. package/src/utils/resource-icons.test.ts +49 -2
  103. package/src/utils/resource-icons.ts +29 -2
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { LookupFailureNote } from './LookupFailureNote'
4
+
5
+ function err(message: string, status?: number): Error {
6
+ return Object.assign(new Error(message), status === undefined ? {} : { status })
7
+ }
8
+
9
+ describe('LookupFailureNote', () => {
10
+ it('says nothing when every lookup came back', () => {
11
+ expect(renderToString(<LookupFailureNote errors={[]} what="x" />)).toBe('')
12
+ expect(renderToString(<LookupFailureNote errors={[null, undefined]} what="x" />)).toBe('')
13
+ })
14
+
15
+ // Being unable to see something is expected and not actionable; a fault is
16
+ // neither. The two must not read alike.
17
+ it('renders a permission answer as a calm note, not a red error', () => {
18
+ const html = renderToString(
19
+ <LookupFailureNote errors={[err('forbidden', 403)]} what="which clusters use this store" />,
20
+ )
21
+ expect(html).toContain('permission')
22
+ expect(html).not.toContain('text-red-400')
23
+ })
24
+
25
+ it('keeps a genuine fault loud, with the reason', () => {
26
+ const html = renderToString(
27
+ <LookupFailureNote errors={[err('Failed to fetch')]} what="what replicates here" />,
28
+ )
29
+ expect(html).toContain('text-red-400')
30
+ expect(html).toContain('Failed to fetch')
31
+ })
32
+
33
+ // A fault is actionable and a permission answer is not, so the actionable one
34
+ // is the one worth showing when both happened.
35
+ it('prefers the fault when a lookup was both forbidden and broken', () => {
36
+ const html = renderToString(
37
+ <LookupFailureNote errors={[err('forbidden', 403), err('boom', 500)]} what="x" />,
38
+ )
39
+ expect(html).toContain('boom')
40
+ expect(html).toContain('text-red-400')
41
+ })
42
+
43
+ // The case that loses counts: rows are on screen, so the failure means the
44
+ // list is short — not that nothing was found.
45
+ it('says the list is incomplete rather than unchecked when rows are showing', () => {
46
+ const html = renderToString(
47
+ <LookupFailureNote errors={[err('boom', 500)]} what="x" incomplete />,
48
+ )
49
+ expect(html).toContain('This may be incomplete')
50
+ expect(html).toContain('boom')
51
+ })
52
+
53
+ it('does the same for a partial permission answer', () => {
54
+ const html = renderToString(
55
+ <LookupFailureNote errors={[err('forbidden', 403)]} what="x" incomplete />,
56
+ )
57
+ expect(html).toContain('may be incomplete')
58
+ expect(html).not.toContain('text-red-400')
59
+ })
60
+ })
@@ -0,0 +1,42 @@
1
+ import { isForbiddenError } from '../../../types/fetch-error'
2
+
3
+ interface LookupFailureNoteProps {
4
+ /** Nulls are ignored, so callers can pass `[a.error, b.error]` from their hooks. */
5
+ errors: unknown[]
6
+ /** Tail of a sentence: "which clusters use this store". */
7
+ what: string
8
+ /** Rows are already on screen, so the failure shortened the list rather than
9
+ * emptying it. */
10
+ incomplete?: boolean
11
+ }
12
+
13
+ /**
14
+ * A reverse lookup that did not come back, told apart from one that found
15
+ * nothing. Same split as RBACErrorSection and sharing its predicate: a denial is
16
+ * expected and reads calm, a fault stays loud.
17
+ */
18
+ export function LookupFailureNote({ errors, what, incomplete }: LookupFailureNoteProps) {
19
+ const real = errors.filter(Boolean)
20
+ if (real.length === 0) return null
21
+
22
+ // A fault outranks a denial: only one of the two is actionable.
23
+ const fault = real.find((e) => !isForbiddenError(e))
24
+ if (!fault) {
25
+ return (
26
+ <div className="text-xs text-theme-text-tertiary">
27
+ {incomplete
28
+ ? `This may be incomplete: you don’t have permission to check ${what}.`
29
+ : `You don’t have permission to check ${what}.`}
30
+ </div>
31
+ )
32
+ }
33
+
34
+ const message = fault instanceof Error ? fault.message : String(fault)
35
+ return (
36
+ <div className="text-xs text-red-400">
37
+ {incomplete
38
+ ? `This may be incomplete — could not check ${what}: ${message}`
39
+ : `Could not check ${what}: ${message}`}
40
+ </div>
41
+ )
42
+ }
@@ -1,8 +1,15 @@
1
1
  import { clsx } from 'clsx'
2
+ import { Badge } from '../../ui/Badge'
2
3
  import { Tooltip } from '../../ui/Tooltip'
4
+ import {
5
+ NETWORK_POLICY_PEER_DOTS,
6
+ NETWORK_POLICY_PEER_STYLES,
7
+ type NetworkPolicyPeerType,
8
+ } from './network-policy-peer-styles'
3
9
 
4
10
  interface NetworkPolicyDiagramProps {
5
11
  spec: any
12
+ staged?: boolean
6
13
  }
7
14
 
8
15
  /**
@@ -11,7 +18,7 @@ interface NetworkPolicyDiagramProps {
11
18
  * Each rule is a horizontal band. Peers within a rule are OR'd (stacked).
12
19
  * Pure CSS/SVG — no ReactFlow needed for this static diagram.
13
20
  */
14
- export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
21
+ export function NetworkPolicyDiagram({ spec, staged = false }: NetworkPolicyDiagramProps) {
15
22
  const podSelector = spec.podSelector || {}
16
23
  const matchLabels = podSelector.matchLabels || {}
17
24
  const policyTypes: string[] = spec.policyTypes || []
@@ -29,6 +36,13 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
29
36
 
30
37
  return (
31
38
  <div className="card-inner-lg space-y-3">
39
+ {staged && (
40
+ <div className="flex items-center gap-2 text-[11px] text-theme-text-tertiary">
41
+ <Badge severity="warning" size="sm">Staged preview</Badge>
42
+ <span>Dashed paths are evaluated but not enforced</span>
43
+ </div>
44
+ )}
45
+
32
46
  {/* Ingress flows */}
33
47
  {hasIngress && (
34
48
  <div className="space-y-2">
@@ -43,6 +57,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
43
57
  ports={[]}
44
58
  direction="ingress"
45
59
  denied
60
+ staged={staged}
46
61
  />
47
62
  ) : (
48
63
  ingress?.map((rule, i) => (
@@ -52,6 +67,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
52
67
  target={targetLabel}
53
68
  ports={extractPorts(rule.ports)}
54
69
  direction="ingress"
70
+ staged={staged}
55
71
  />
56
72
  ))
57
73
  )}
@@ -72,6 +88,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
72
88
  ports={[]}
73
89
  direction="egress"
74
90
  denied
91
+ staged={staged}
75
92
  />
76
93
  ) : (
77
94
  egress?.map((rule, i) => (
@@ -81,6 +98,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
81
98
  target={targetLabel}
82
99
  ports={extractPorts(rule.ports)}
83
100
  direction="egress"
101
+ staged={staged}
84
102
  />
85
103
  ))
86
104
  )}
@@ -97,7 +115,7 @@ export function NetworkPolicyDiagram({ spec }: NetworkPolicyDiagramProps) {
97
115
  interface PeerInfo {
98
116
  label: string
99
117
  sublabel?: string
100
- type: 'pod' | 'namespace' | 'cidr' | 'all' | 'deny' | 'combined'
118
+ type: NetworkPolicyPeerType
101
119
  }
102
120
 
103
121
  function extractPeers(peers: any[] | undefined): PeerInfo[] {
@@ -155,37 +173,20 @@ function formatSelector(selector: any): string {
155
173
  return Object.entries(labels).map(([k, v]) => `${k}=${v}`).join(', ')
156
174
  }
157
175
 
158
- // Color coding for peer types
159
- const PEER_STYLES = {
160
- pod: 'border-emerald-500/30 bg-emerald-500/8',
161
- namespace: 'border-sky-500/30 bg-sky-500/8',
162
- cidr: 'border-amber-500/30 bg-amber-500/8',
163
- combined: 'border-emerald-500/30 bg-emerald-500/8',
164
- all: 'border-theme-border bg-theme-elevated/50',
165
- deny: 'border-red-500/30 bg-red-500/8',
166
- } as const
167
-
168
- const PEER_DOT = {
169
- pod: 'bg-emerald-500',
170
- namespace: 'bg-sky-500',
171
- cidr: 'bg-amber-500',
172
- combined: 'bg-emerald-500',
173
- all: 'bg-theme-text-tertiary',
174
- deny: 'bg-red-500',
175
- } as const
176
-
177
176
  function FlowRow({
178
177
  sources,
179
178
  target,
180
179
  ports,
181
180
  direction,
182
181
  denied = false,
182
+ staged = false,
183
183
  }: {
184
184
  sources: PeerInfo[]
185
185
  target: string
186
186
  ports: string[]
187
187
  direction: 'ingress' | 'egress'
188
188
  denied?: boolean
189
+ staged?: boolean
189
190
  }) {
190
191
  const isIngress = direction === 'ingress'
191
192
 
@@ -200,11 +201,11 @@ function FlowRow({
200
201
  <div
201
202
  className={clsx(
202
203
  'rounded-md border px-2 py-1.5 min-w-0 overflow-hidden',
203
- PEER_STYLES[peer.type],
204
+ NETWORK_POLICY_PEER_STYLES[peer.type],
204
205
  )}
205
206
  >
206
207
  <div className="flex items-center gap-1.5 min-w-0">
207
- <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', PEER_DOT[peer.type])} />
208
+ <span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', NETWORK_POLICY_PEER_DOTS[peer.type])} />
208
209
  <span className={clsx('text-[11px] font-medium truncate min-w-0', denied && 'line-through text-red-400')}>
209
210
  {peer.label}
210
211
  </span>
@@ -245,14 +246,14 @@ function FlowRow({
245
246
  x1="2" y1="10" x2="32" y2="10"
246
247
  stroke={denied ? '#ef4444' : direction === 'ingress' ? '#3b82f6' : '#a855f7'}
247
248
  strokeWidth="1.5"
248
- strokeDasharray={denied ? '3 2' : undefined}
249
+ strokeDasharray={staged ? '4 3' : denied ? '3 2' : undefined}
249
250
  markerEnd={`url(#arrow-${direction}-${denied ? 'denied' : 'ok'})`}
250
251
  />
251
252
  </svg>
252
253
  {ports.length > 0 && (
253
- <div className="flex flex-wrap justify-center gap-0.5 mt-0.5">
254
+ <div className="flex flex-col items-center gap-0.5 mt-0.5">
254
255
  {ports.map((p, i) => (
255
- <span key={i} className="text-[8px] text-theme-text-tertiary font-mono leading-none">{p}</span>
256
+ <span key={i} className="whitespace-nowrap text-[8px] text-theme-text-tertiary font-mono leading-none">{p}</span>
256
257
  ))}
257
258
  </div>
258
259
  )}
@@ -5,9 +5,10 @@ import { NetworkPolicyDiagram } from './NetworkPolicyDiagram'
5
5
 
6
6
  interface NetworkPolicyRendererProps {
7
7
  data: any
8
+ staged?: boolean
8
9
  }
9
10
 
10
- export function NetworkPolicyRenderer({ data }: NetworkPolicyRendererProps) {
11
+ export function NetworkPolicyRenderer({ data, staged = false }: NetworkPolicyRendererProps) {
11
12
  const spec = data.spec || {}
12
13
  const podSelector = spec.podSelector || {}
13
14
  const policyTypes: string[] = spec.policyTypes || []
@@ -23,7 +24,7 @@ export function NetworkPolicyRenderer({ data }: NetworkPolicyRendererProps) {
23
24
  <>
24
25
  {hasDiagramContent && (
25
26
  <Section title="Policy Flow" icon={GitFork} defaultExpanded>
26
- <NetworkPolicyDiagram spec={spec} />
27
+ <NetworkPolicyDiagram spec={spec} staged={staged} />
27
28
  </Section>
28
29
  )}
29
30
 
@@ -125,7 +126,9 @@ function IngressEgressRuleCard({
125
126
  )}
126
127
 
127
128
  {peers.length === 0 && ports.length === 0 && (
128
- <div className="text-xs text-theme-text-tertiary">Allow all</div>
129
+ <div className="text-xs text-theme-text-tertiary">
130
+ {direction === 'from' ? 'All sources' : 'All destinations'}
131
+ </div>
129
132
  )}
130
133
  </div>
131
134
  )
@@ -0,0 +1,348 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { PolicyCoverageSection } from './PolicyCoverageSection'
4
+ import type { PolicyCoverageResponse, PolicyCoverageRule, PolicyCoverageSubject } from '../../../types/policy'
5
+
6
+ const vpol = (spec: any = {}) => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'ValidatingPolicy', spec })
7
+ const mpol = () => ({ apiVersion: 'policies.kyverno.io/v1', kind: 'MutatingPolicy', spec: {} })
8
+ const legacy = (spec: any) => ({ apiVersion: 'kyverno.io/v1', kind: 'ClusterPolicy', spec })
9
+
10
+ const counts = (c: Partial<PolicyCoverageResponse['counts']> = {}) =>
11
+ ({ pass: 0, fail: 0, warn: 0, error: 0, skip: 0, ...c })
12
+
13
+ const subject = (name: string, result: string, namespace = 'app'): PolicyCoverageSubject =>
14
+ ({ kind: 'Pod', namespace, name, result })
15
+
16
+ const rule = (r: Partial<PolicyCoverageRule> = {}): PolicyCoverageRule =>
17
+ ({ rule: 'check', counts: counts(), subjects: [], total: 0, ...r })
18
+
19
+ const coverage = (o: Partial<PolicyCoverageResponse> = {}): PolicyCoverageResponse => ({
20
+ evaluated: true,
21
+ status: 'ready',
22
+ liveUpdates: true,
23
+ policy: 'p',
24
+ counts: counts(),
25
+ scopeNamespaces: 1,
26
+ withheldNamespaces: 0,
27
+ examined: 0,
28
+ rules: [],
29
+ ...o,
30
+ })
31
+
32
+ const render = (data: PolicyCoverageResponse, resource: any = vpol(), extra = {}) =>
33
+ renderToString(<PolicyCoverageSection resource={resource} data={data} {...extra} />)
34
+
35
+ /**
36
+ * The wording is the feature here, so these assert on what the section actually
37
+ * renders. Kyverno reports the same `result: fail` for every family and does not
38
+ * mean the same thing by it, and several of the sentences claim more than the
39
+ * data behind them if they are built from the wrong number.
40
+ */
41
+ describe('what the section says a policy did', () => {
42
+ it('states the consequence of enforcing without claiming what exists is blocked', () => {
43
+ const html = render(
44
+ coverage({ examined: 7, subjects: 7, subjectsFailing: 7, counts: counts({ fail: 7 }),
45
+ rules: [rule({ counts: counts({ fail: 7 }), subjects: [subject('a', 'fail')], total: 7 })] }),
46
+ vpol({ validationActions: ['Deny'] }),
47
+ )
48
+ expect(html).toContain('already exist')
49
+ expect(html).toContain('will be rejected')
50
+ expect(html).not.toMatch(/would be blocked|blocks these/)
51
+ })
52
+
53
+ it('frames an audit policy as a dry run, not a current block', () => {
54
+ const html = render(
55
+ coverage({ examined: 3, subjects: 3, subjectsFailing: 3, counts: counts({ fail: 3 }),
56
+ rules: [rule({ counts: counts({ fail: 3 }), subjects: [subject('a', 'fail')], total: 3 })] }),
57
+ vpol({ validationActions: ['Audit'] }),
58
+ )
59
+ expect(html).toContain('Nothing is rejected today')
60
+ })
61
+
62
+ // A mutating policy's fail carries "mutation is not applied" — nothing
63
+ // violated anything, so calling it a failure misreports compliance.
64
+ it('does not call an unapplied mutation a failure', () => {
65
+ const html = render(
66
+ coverage({ examined: 2, subjects: 2, counts: counts({ fail: 2 }),
67
+ rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
68
+ mpol(),
69
+ )
70
+ expect(html).toContain('not mutated')
71
+ expect(html).not.toContain('Fail')
72
+ })
73
+
74
+ // The subject of a generate result is the trigger, never the object produced.
75
+ it('calls a generate rule’s pass a trigger, not a generated object', () => {
76
+ const html = render(
77
+ coverage({ examined: 1, subjects: 1, counts: counts({ pass: 1 }),
78
+ rules: [rule({ rule: 'gen', counts: counts({ pass: 1 }), subjects: [subject('cm', 'pass')], total: 1 })] }),
79
+ legacy({ rules: [{ name: 'gen', generate: {} }] }),
80
+ )
81
+ expect(html).toContain('triggered generation')
82
+ expect(html).not.toContain('Generated')
83
+ })
84
+
85
+ it('withholds the consequence when the policy’s rules disagree on blocking', () => {
86
+ const html = render(
87
+ coverage({ examined: 2, subjects: 2, subjectsFailing: 2, counts: counts({ fail: 2 }),
88
+ rules: [rule({ counts: counts({ fail: 2 }), subjects: [subject('a', 'fail')], total: 2 })] }),
89
+ legacy({ rules: [
90
+ { name: 'a', validate: { failureAction: 'Enforce' } },
91
+ { name: 'b', validate: { failureAction: 'Audit' } },
92
+ ] }),
93
+ )
94
+ expect(html).not.toContain('will be rejected')
95
+ expect(html).not.toContain('already exist')
96
+ })
97
+ })
98
+
99
+ describe('counts describe the cluster, lists describe the view', () => {
100
+ it('separates checks from resources when one resource was checked twice', () => {
101
+ const html = render(coverage({
102
+ examined: 2, subjects: 1, counts: counts({ pass: 1, fail: 1 }),
103
+ rules: [rule({ counts: counts({ fail: 1 }), subjects: [subject('only-one', 'fail')], total: 1 })],
104
+ }))
105
+ expect(html).toContain('2 checks on 1 resource')
106
+ expect(html).not.toContain('2 resources examined')
107
+ })
108
+
109
+ it('says resources when each was checked once', () => {
110
+ const html = render(coverage({
111
+ examined: 34, subjects: 34, counts: counts({ pass: 34 }),
112
+ rules: [rule({ counts: counts({ pass: 34 }), subjects: [subject('a', 'pass')], total: 34 })],
113
+ }))
114
+ expect(html).toContain('34 resources examined')
115
+ })
116
+
117
+ it('claims "all" passing only when nothing is outside the view', () => {
118
+ const all = render(coverage({
119
+ examined: 3, subjects: 3, counts: counts({ pass: 3 }),
120
+ rules: [rule({ counts: counts({ pass: 3 }), subjects: [subject('a', 'pass')], total: 3 })],
121
+ }))
122
+ expect(all).toContain('All 3 resources passing')
123
+
124
+ const filtered = render(coverage({
125
+ examined: 34, subjects: 34, counts: counts({ pass: 34 }),
126
+ rules: [rule({ counts: counts({ pass: 34 }), subjects: [subject('a', 'pass')], total: 34, hiddenByFilter: 27 })],
127
+ }))
128
+ expect(filtered).toContain('7 resources passing in view')
129
+ expect(filtered).not.toContain('All 34')
130
+ expect(filtered).toContain('27 hidden by your namespace filter')
131
+ })
132
+
133
+ // Only non-passing subjects are listed, so a hidden pass leaves nothing to
134
+ // claim — "All 0 resources passing" is not a statement worth making.
135
+ it('makes no passing claim when the filter hid the only subject', () => {
136
+ const html = render(coverage({
137
+ examined: 1, subjects: 1, counts: counts({ pass: 1 }),
138
+ rules: [rule({ counts: counts({ pass: 1 }), subjects: [], total: 1, hiddenByFilter: 1 })],
139
+ }))
140
+ expect(html).not.toContain('All 0')
141
+ expect(html).toContain('1 hidden by your namespace filter')
142
+ })
143
+
144
+ // The counts exclude what a family or a namespace held back, so "All" would be
145
+ // quantifying over a set the page itself goes on to say was short.
146
+ it('does not claim all passed when a report family was withheld', () => {
147
+ const html = render(coverage({
148
+ examined: 5, subjects: 5, counts: counts({ pass: 5 }),
149
+ withheldByFamily: 2, unreadableFamilies: ['openreports.io'],
150
+ rules: [rule({ counts: counts({ pass: 5 }), subjects: [], total: 5 })],
151
+ }))
152
+ expect(html).toContain('5 resources passing')
153
+ expect(html).not.toContain('All 5 resources passing')
154
+ })
155
+
156
+ it('keeps the plain claim when nothing was held back', () => {
157
+ const html = render(coverage({
158
+ examined: 5, subjects: 5, counts: counts({ pass: 5 }),
159
+ rules: [rule({ counts: counts({ pass: 5 }), subjects: [], total: 5 })],
160
+ }))
161
+ expect(html).toContain('All 5 resources passing')
162
+ })
163
+
164
+ it('names the hidden rows that were failing', () => {
165
+ const html = render(coverage({
166
+ examined: 1, subjects: 1, counts: counts({ fail: 1 }),
167
+ rules: [rule({ counts: counts({ fail: 1 }), subjects: [], total: 1, hiddenByFilter: 1, hiddenNotable: 1 })],
168
+ }))
169
+ expect(html).toContain('1 of them failing')
170
+ })
171
+
172
+ // Kyverno files engine errors under a rule of their own. Calling those hidden
173
+ // rows by the family's fail word made the footers claim more failures than the
174
+ // summary above them had counted.
175
+ it('does not call a hidden engine error a failure of the rule', () => {
176
+ const html = render(coverage({
177
+ examined: 18, subjects: 18, counts: counts({ error: 18 }),
178
+ rules: [rule({
179
+ rule: 'evaluation', counts: counts({ error: 18 }), subjects: [], total: 18,
180
+ hiddenByFilter: 14, hiddenNotable: 14,
181
+ })],
182
+ }), mpol())
183
+ expect(html).toContain('14 of them could not be evaluated')
184
+ expect(html).not.toContain('14 of them not mutated')
185
+ })
186
+
187
+ it('still uses the family word when the hidden rows really did fail', () => {
188
+ const html = render(coverage({
189
+ examined: 4, subjects: 4, counts: counts({ fail: 3, error: 1 }),
190
+ rules: [rule({
191
+ counts: counts({ fail: 3, error: 1 }), subjects: [], total: 4,
192
+ hiddenByFilter: 3, hiddenNotable: 3,
193
+ })],
194
+ }), mpol())
195
+ expect(html).toContain('3 of them not mutated')
196
+ })
197
+ })
198
+
199
+ describe('a bounded list is not a dead end', () => {
200
+ const many = (n: number, result: string) =>
201
+ Array.from({ length: n }, (_, i) => subject(`s${i}`, result))
202
+
203
+ it('offers to load the rest when the cap removed listable rows', () => {
204
+ const html = render(
205
+ coverage({ examined: 251, subjects: 251, counts: counts({ fail: 251 }),
206
+ rules: [rule({ counts: counts({ fail: 251 }), subjects: many(200, 'fail'), total: 251, truncated: true })] }),
207
+ vpol(), { onLoadMore: () => {} },
208
+ )
209
+ expect(html).toContain('only the first 200 are available here')
210
+ expect(html).toContain('Load the rest')
211
+ expect(html).toContain('Show 200 of 251')
212
+ })
213
+
214
+ // A rule where everything passes is capped just as readily, and its list
215
+ // carries the same offer. Only the label is assertable here: the rows and the
216
+ // control sit behind a disclosure this renderer opens on click, and the repo
217
+ // tests renderers with renderToString, which cannot open one.
218
+ it('summarises a capped passing list as a disclosure rather than a total', () => {
219
+ const html = render(
220
+ coverage({ examined: 251, subjects: 251, counts: counts({ pass: 251 }),
221
+ rules: [rule({ counts: counts({ pass: 251 }), subjects: many(199, 'pass'), total: 251, truncated: true })] }),
222
+ vpol(), { onLoadMore: () => {} },
223
+ )
224
+ expect(html).toContain('All 251 resources passing')
225
+ expect(html).toContain('<button')
226
+ })
227
+
228
+ // Rows the filter withheld are not the cap's doing, and no higher limit
229
+ // returns them.
230
+ it('does not blame the cap for what the namespace filter withheld', () => {
231
+ const html = render(coverage({
232
+ examined: 1, subjects: 1, counts: counts({ fail: 1 }),
233
+ rules: [rule({ counts: counts({ fail: 1 }), subjects: [], total: 1, hiddenByFilter: 1, hiddenNotable: 1 })],
234
+ }), vpol(), { onLoadMore: () => {} })
235
+ expect(html).not.toContain('Load the rest')
236
+ expect(html).not.toContain('only the first')
237
+ })
238
+
239
+ it('does not announce a cap that only removed passing rows', () => {
240
+ const html = render(
241
+ coverage({ examined: 251, subjects: 251, counts: counts({ pass: 250, fail: 1 }),
242
+ rules: [rule({ counts: counts({ pass: 250, fail: 1 }), subjects: [subject('bad', 'fail')], total: 251, truncated: true })] }),
243
+ vpol(), { onLoadMore: () => {} },
244
+ )
245
+ expect(html).not.toContain('only the first 1 are available')
246
+ })
247
+ })
248
+
249
+ describe('an engine error is not a verdict', () => {
250
+ const allErrors = coverage({
251
+ examined: 1, subjects: 1, subjectsFailing: 0, counts: counts({ error: 1 }),
252
+ rules: [rule({ rule: 'evaluation', counts: counts({ error: 1 }), subjects: [subject('a', 'error')], total: 1 })],
253
+ })
254
+
255
+ it('does not promise rejection for rules that never ran', () => {
256
+ const html = render(allErrors, vpol({ validationActions: ['Deny'] }))
257
+ expect(html).not.toContain('will be rejected')
258
+ expect(html).toContain('could not be evaluated')
259
+ })
260
+
261
+ // The policy has to declare Ignore to reach this wording at all: an absent
262
+ // failurePolicy means Fail per the CRD, so the default is the fail-closed
263
+ // sentence, not this one.
264
+ it('agrees in number', () => {
265
+ const ignores = vpol({ failurePolicy: 'Ignore' })
266
+ expect(render(allErrors, ignores)).toContain('1 could not be evaluated and is being allowed through unchecked')
267
+ const two = coverage({
268
+ examined: 2, subjects: 2, counts: counts({ error: 2 }),
269
+ rules: [rule({ rule: 'evaluation', counts: counts({ error: 2 }), subjects: [subject('a', 'error')], total: 2 })],
270
+ })
271
+ expect(render(two, ignores)).toContain('2 could not be evaluated and are being allowed through unchecked')
272
+ })
273
+
274
+ it('says a fail-closed policy rejects on the error instead', () => {
275
+ const html = render(allErrors, vpol({ failurePolicy: 'Fail', validationActions: ['Deny'] }))
276
+ expect(html).toContain('fails closed')
277
+ expect(html).toContain('that admission is')
278
+ })
279
+
280
+ // `evaluation` is Kyverno's own marker, not a rule the policy declares.
281
+ it('does not present the engine’s marker as a rule the policy contains', () => {
282
+ const html = render(allErrors)
283
+ expect(html).toContain('Engine errors')
284
+ expect(html).not.toMatch(/>evaluation</)
285
+ })
286
+ })
287
+
288
+ describe('absence is only claimed when the whole answer arrived', () => {
289
+ it('says nothing has been checked when nothing was withheld', () => {
290
+ expect(render(coverage())).toContain('Nothing has been checked against this policy yet')
291
+ })
292
+
293
+ it('does not claim that when results were withheld', () => {
294
+ const html = render(coverage({ withheldNamespaces: 2 }))
295
+ expect(html).not.toContain('Nothing has been checked')
296
+ expect(html).toContain('No results to show here')
297
+ expect(html).toContain('2 namespaces are not shown')
298
+ })
299
+
300
+ it('attributes a family the caller cannot read to their permissions', () => {
301
+ const html = render(coverage({ withheldByFamily: 3, unreadableFamilies: ['openreports.io'] }))
302
+ expect(html).toContain('openreports.io')
303
+ expect(html).toContain('3 results were')
304
+ })
305
+
306
+ it('names another engine rather than presenting its findings as this policy’s', () => {
307
+ const html = render(coverage({
308
+ examined: 1, subjects: 1, counts: counts({ fail: 1 }), engines: ['kyverno', 'trivy'],
309
+ rules: [rule({ counts: counts({ fail: 1 }), subjects: [subject('a', 'fail')], total: 1 })],
310
+ }))
311
+ expect(html).toContain('trivy')
312
+ })
313
+ })
314
+
315
+ /**
316
+ * An engine error is either rejected or waved through, and which one is the
317
+ * whole point of the sentence. `spec.failurePolicy` defaults to Fail per the
318
+ * published CRD and most policies never set it, so reading absent as anything
319
+ * else states the opposite of what the cluster does.
320
+ */
321
+ describe('what an engine error costs', () => {
322
+ const errored = coverage({
323
+ examined: 2, subjects: 2, counts: counts({ error: 2 }),
324
+ rules: [rule({ counts: counts({ error: 2 }), subjects: [], total: 2 })],
325
+ })
326
+
327
+ it('treats an unset failurePolicy as fail-closed, like the CRD does', () => {
328
+ const html = render(errored, vpol({}))
329
+ expect(html).toContain('rejected for an engine error')
330
+ expect(html).not.toContain('allowed through unchecked')
331
+ })
332
+
333
+ it('says so plainly when the policy declares Ignore', () => {
334
+ const html = render(errored, vpol({ failurePolicy: 'Ignore' }))
335
+ expect(html).toContain('allowed through unchecked')
336
+ })
337
+
338
+ // The legacy family keeps the field somewhere else entirely.
339
+ it('honours a legacy policy that opted out via webhookConfiguration', () => {
340
+ const html = render(errored, legacy({ webhookConfiguration: { failurePolicy: 'Ignore' }, rules: [] }))
341
+ expect(html).toContain('allowed through unchecked')
342
+ expect(html).not.toContain('fails closed')
343
+ })
344
+
345
+ it('agrees with an explicit Fail', () => {
346
+ expect(render(errored, vpol({ failurePolicy: 'Fail' }))).toContain('rejected for an engine error')
347
+ })
348
+ })