@skyhook-io/k8s-ui 1.5.10 → 1.5.12
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.
- package/package.json +1 -1
- package/src/components/audit/AuditFindingsTable.tsx +66 -30
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +65 -12
- package/src/components/dock/BottomDock.tsx +23 -12
- package/src/components/gitops/ManagedResourcesList.tsx +4 -2
- package/src/components/logs/LogCore.tsx +109 -14
- package/src/components/resources/ResourcesView.tsx +19 -2
- package/src/components/resources/get-pod-phase-display.test.ts +254 -0
- package/src/components/resources/renderers/AWSManagedMachinePoolRenderer.tsx +2 -4
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +3 -2
- package/src/components/resources/renderers/AzureManagedMachinePoolRenderer.tsx +3 -8
- package/src/components/resources/renderers/ChallengeRenderer.tsx +2 -1
- package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +2 -1
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +2 -1
- package/src/components/resources/renderers/GatewayRenderer.tsx +5 -4
- package/src/components/resources/renderers/IngressClassRenderer.tsx +2 -1
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +2 -1
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +3 -2
- package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +2 -1
- package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +2 -2
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/KarpenterNodePoolRenderer.tsx +5 -5
- package/src/components/resources/renderers/OrderRenderer.tsx +2 -1
- package/src/components/resources/renderers/PodRenderer.tsx +23 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +3 -2
- package/src/components/resources/renderers/RolloutRenderer.tsx +2 -1
- package/src/components/resources/renderers/SecretRenderer.tsx +15 -6
- package/src/components/resources/renderers/aws-capi-cells.tsx +2 -4
- package/src/components/resources/renderers/azure-capi-cells.tsx +3 -8
- package/src/components/resources/renderers/istio-cells.tsx +3 -2
- package/src/components/resources/renderers/karpenter-cells.tsx +1 -1
- package/src/components/resources/resource-utils-karpenter.ts +0 -6
- package/src/components/resources/resource-utils.ts +110 -0
- package/src/components/resources/resources-search-sidebar-hint.test.ts +85 -0
- package/src/components/timeline/shared.tsx +5 -6
- package/src/components/topology/TopologySearch.tsx +49 -16
- package/src/components/topology/topology-search-match.test.ts +112 -0
- package/src/components/ui/Tooltip.tsx +73 -40
- package/src/components/ui/tooltip-position.test.ts +132 -0
- package/src/components/ui/tooltip-position.ts +86 -0
- package/src/components/workload/ResourceDetailDrawer.tsx +3 -3
- package/src/components/workload/WorkloadView.tsx +6 -6
- package/src/types/core.ts +13 -1
- package/src/utils/badge-colors.ts +19 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/validators.test.ts +207 -0
- package/src/utils/validators.ts +178 -0
- package/src/utils/view-transition.test.ts +81 -0
- package/src/utils/view-transition.ts +62 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
validateRFC1123Label,
|
|
4
|
+
validateRFC1123Subdomain,
|
|
5
|
+
validateHelmReleaseName,
|
|
6
|
+
validatePort,
|
|
7
|
+
} from './validators'
|
|
8
|
+
|
|
9
|
+
// Validation in user-facing forms is a silent-correctness-or-loud-error
|
|
10
|
+
// boundary: a regression here either lets bad input through to the API
|
|
11
|
+
// (helm install fails with a server-side 422 the user can't connect to
|
|
12
|
+
// their action) or rejects valid input (user is locked out of a
|
|
13
|
+
// legitimate name). Pin the canonical k8s / Helm rules so any drift
|
|
14
|
+
// fails the test instead of the user.
|
|
15
|
+
|
|
16
|
+
describe('validateRFC1123Label', () => {
|
|
17
|
+
it('accepts simple lowercase labels', () => {
|
|
18
|
+
expect(validateRFC1123Label('default').valid).toBe(true)
|
|
19
|
+
expect(validateRFC1123Label('my-app').valid).toBe(true)
|
|
20
|
+
expect(validateRFC1123Label('a').valid).toBe(true)
|
|
21
|
+
expect(validateRFC1123Label('a1').valid).toBe(true)
|
|
22
|
+
expect(validateRFC1123Label('1a').valid).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('rejects empty input', () => {
|
|
26
|
+
const r = validateRFC1123Label('')
|
|
27
|
+
expect(r.valid).toBe(false)
|
|
28
|
+
if (!r.valid) expect(r.error).toMatch(/empty/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('rejects uppercase characters and special chars', () => {
|
|
32
|
+
const r = validateRFC1123Label('INVALID_NAME!@#')
|
|
33
|
+
expect(r.valid).toBe(false)
|
|
34
|
+
if (!r.valid) expect(r.error).toMatch(/lowercase/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects underscores, spaces, and other special characters', () => {
|
|
38
|
+
expect(validateRFC1123Label('my_app').valid).toBe(false)
|
|
39
|
+
expect(validateRFC1123Label('my app').valid).toBe(false)
|
|
40
|
+
expect(validateRFC1123Label('my.app').valid).toBe(false)
|
|
41
|
+
expect(validateRFC1123Label('my!app').valid).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('rejects names with leading or trailing hyphens', () => {
|
|
45
|
+
expect(validateRFC1123Label('-app').valid).toBe(false)
|
|
46
|
+
expect(validateRFC1123Label('app-').valid).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('rejects names longer than 63 characters', () => {
|
|
50
|
+
const r = validateRFC1123Label('a'.repeat(64))
|
|
51
|
+
expect(r.valid).toBe(false)
|
|
52
|
+
if (!r.valid) expect(r.error).toMatch(/63/)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('accepts a name exactly 63 characters long (boundary)', () => {
|
|
56
|
+
expect(validateRFC1123Label('a'.repeat(63)).valid).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('validateRFC1123Subdomain', () => {
|
|
61
|
+
it('accepts dotted subdomains', () => {
|
|
62
|
+
expect(validateRFC1123Subdomain('foo.bar').valid).toBe(true)
|
|
63
|
+
expect(validateRFC1123Subdomain('foo.bar.baz').valid).toBe(true)
|
|
64
|
+
expect(validateRFC1123Subdomain('foo').valid).toBe(true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('rejects names with consecutive dots or trailing dots', () => {
|
|
68
|
+
expect(validateRFC1123Subdomain('foo..bar').valid).toBe(false)
|
|
69
|
+
expect(validateRFC1123Subdomain('foo.').valid).toBe(false)
|
|
70
|
+
expect(validateRFC1123Subdomain('.foo').valid).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('rejects uppercase', () => {
|
|
74
|
+
expect(validateRFC1123Subdomain('Foo.bar').valid).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('rejects names longer than 253 characters', () => {
|
|
78
|
+
const seg = 'a'.repeat(60)
|
|
79
|
+
const tooLong = `${seg}.${seg}.${seg}.${seg}.${seg}` // 60*5 + 4 dots = 304
|
|
80
|
+
const r = validateRFC1123Subdomain(tooLong)
|
|
81
|
+
expect(r.valid).toBe(false)
|
|
82
|
+
if (!r.valid) expect(r.error).toMatch(/253/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('rejects single labels longer than 63 chars even when total is under 253', () => {
|
|
86
|
+
// K8s' IsDNS1123Subdomain enforces ≤ 63 chars per dot-
|
|
87
|
+
// separated label on top of the 253-char total. Without the
|
|
88
|
+
// per-label check, a single 200-char label slipped through
|
|
89
|
+
// client-side and was rejected only server-side.
|
|
90
|
+
const huge = 'a'.repeat(200)
|
|
91
|
+
const r = validateRFC1123Subdomain(huge)
|
|
92
|
+
expect(r.valid).toBe(false)
|
|
93
|
+
if (!r.valid) expect(r.error).toMatch(/63/)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('accepts a 63-char label at the boundary', () => {
|
|
97
|
+
expect(validateRFC1123Subdomain('a'.repeat(63)).valid).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('rejects only the offending label when other labels are fine', () => {
|
|
101
|
+
const r = validateRFC1123Subdomain(`ok.${'a'.repeat(64)}.also-ok`)
|
|
102
|
+
expect(r.valid).toBe(false)
|
|
103
|
+
if (!r.valid) expect(r.error).toMatch(/63/)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('validateHelmReleaseName', () => {
|
|
108
|
+
it('rejects names with spaces and special characters', () => {
|
|
109
|
+
const r = validateHelmReleaseName('Invalid Name With Spaces!')
|
|
110
|
+
expect(r.valid).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('accepts legal release names', () => {
|
|
114
|
+
expect(validateHelmReleaseName('nginx').valid).toBe(true)
|
|
115
|
+
expect(validateHelmReleaseName('my-nginx-1').valid).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('rejects release names longer than 53 characters', () => {
|
|
119
|
+
const r = validateHelmReleaseName('a'.repeat(54))
|
|
120
|
+
expect(r.valid).toBe(false)
|
|
121
|
+
if (!r.valid) expect(r.error).toMatch(/53/)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('accepts a name exactly 53 characters long (boundary)', () => {
|
|
125
|
+
expect(validateHelmReleaseName('a'.repeat(53)).valid).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('error string has no trailing period (call-site composition convention)', () => {
|
|
129
|
+
// ValidationResult contract: error strings have no trailing
|
|
130
|
+
// period — the caller composes them into sentences and adds
|
|
131
|
+
// its own. InstallWizard renders `Release name {error}.` so a
|
|
132
|
+
// trailing period in this error string produced "...limit.." in
|
|
133
|
+
// the UI when a user exceeded the cap.
|
|
134
|
+
const r = validateHelmReleaseName('a'.repeat(54))
|
|
135
|
+
if (r.valid) throw new Error('expected invalid')
|
|
136
|
+
expect(r.error.endsWith('.')).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('rejects dotted names (generated resource names must be DNS-1123 labels)', () => {
|
|
140
|
+
// Helm itself permits dots, but generated resource names like
|
|
141
|
+
// `<release>-<chart>-<hash>` must be valid labels. Catch the dot
|
|
142
|
+
// up front instead of letting K8s reject the apply server-side.
|
|
143
|
+
expect(validateHelmReleaseName('my.app').valid).toBe(false)
|
|
144
|
+
expect(validateHelmReleaseName('foo.bar.baz').valid).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('validatePort', () => {
|
|
149
|
+
it('accepts ports in [1, 65535]', () => {
|
|
150
|
+
for (const p of [1, 80, 443, 8080, 65535]) {
|
|
151
|
+
const r = validatePort(p)
|
|
152
|
+
expect(r.valid).toBe(true)
|
|
153
|
+
if (r.valid) expect(r.value).toBe(p)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('accepts string ports', () => {
|
|
158
|
+
const r = validatePort('8080')
|
|
159
|
+
expect(r.valid).toBe(true)
|
|
160
|
+
if (r.valid) expect(r.value).toBe(8080)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('rejects 0', () => {
|
|
164
|
+
const r = validatePort('0')
|
|
165
|
+
expect(r.valid).toBe(false)
|
|
166
|
+
if (!r.valid) expect(r.error).toMatch(/0 is reserved|between 1/)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('rejects negative numbers', () => {
|
|
170
|
+
const r = validatePort('-1')
|
|
171
|
+
expect(r.valid).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('rejects ports above 65535', () => {
|
|
175
|
+
const r = validatePort('70000')
|
|
176
|
+
expect(r.valid).toBe(false)
|
|
177
|
+
if (!r.valid) expect(r.error).toMatch(/65535|between/)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('rejects empty input', () => {
|
|
181
|
+
expect(validatePort('').valid).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('rejects non-integer formats (decimals, scientific, signed, padded)', () => {
|
|
185
|
+
expect(validatePort('1.0').valid).toBe(false)
|
|
186
|
+
expect(validatePort('1e3').valid).toBe(false)
|
|
187
|
+
expect(validatePort('+1').valid).toBe(false)
|
|
188
|
+
expect(validatePort(' 80 ').valid).toBe(false)
|
|
189
|
+
expect(validatePort('80abc').valid).toBe(false)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('does not silently truncate (the "70000 → 7000" maxlength bug)', () => {
|
|
193
|
+
// The browser's number input was effectively dropping the 5th digit
|
|
194
|
+
// on certain triple-select+type sequences. Our validator must NOT
|
|
195
|
+
// accept the truncated value silently — it must reject the full
|
|
196
|
+
// string the user typed.
|
|
197
|
+
const r = validatePort('70000')
|
|
198
|
+
expect(r.valid).toBe(false)
|
|
199
|
+
// Make sure we don't accidentally massage the value into 7000 just
|
|
200
|
+
// because that happens to be a valid port.
|
|
201
|
+
if (!r.valid) {
|
|
202
|
+
// No `value` field on the failure shape — guarantees we never
|
|
203
|
+
// pretend the user typed something else.
|
|
204
|
+
expect((r as unknown as { value?: number }).value).toBeUndefined()
|
|
205
|
+
}
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Pure validators for user-provided form inputs.
|
|
2
|
+
//
|
|
3
|
+
// Centralised here so the same rules apply to every form (Helm install
|
|
4
|
+
// release name, Audit Settings ignored namespaces, Port Forward local
|
|
5
|
+
// port, anything else that lands later). Reasons live next to the
|
|
6
|
+
// constraints so reviewers can sanity-check us against the underlying
|
|
7
|
+
// Kubernetes / Helm specs instead of folklore.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Result shape for all validators. `valid: true` is success; otherwise
|
|
11
|
+
* `error` is a short, user-readable explanation suitable for inline
|
|
12
|
+
* form feedback (no leading capital, no trailing period — call sites
|
|
13
|
+
* compose them into sentences).
|
|
14
|
+
*/
|
|
15
|
+
export type ValidationResult =
|
|
16
|
+
| { valid: true }
|
|
17
|
+
| { valid: false; error: string }
|
|
18
|
+
|
|
19
|
+
const RFC1123_LABEL_MAX = 63
|
|
20
|
+
// Kubernetes namespace / pod / service names are RFC 1123 labels:
|
|
21
|
+
// lowercase alphanumeric or '-', start and end alphanumeric, ≤63 chars.
|
|
22
|
+
// Source: k8s.io/apimachinery/pkg/util/validation.IsDNS1123Label.
|
|
23
|
+
const RFC1123_LABEL_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/
|
|
24
|
+
|
|
25
|
+
const RFC1123_SUBDOMAIN_MAX = 253
|
|
26
|
+
// RFC 1123 subdomain = one or more labels joined by '.', total ≤253.
|
|
27
|
+
const RFC1123_SUBDOMAIN_RE =
|
|
28
|
+
/^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/
|
|
29
|
+
|
|
30
|
+
const HELM_RELEASE_NAME_MAX = 53
|
|
31
|
+
// Helm release names: DNS subdomain plus the additional limit of 53
|
|
32
|
+
// chars so the generated resource names (`<release>-<chart>-<hash>`)
|
|
33
|
+
// fit under the 63-char label cap. Source:
|
|
34
|
+
// https://helm.sh/docs/chart_best_practices/conventions/#chart-names
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Validates a Kubernetes RFC 1123 label. Used for namespace names,
|
|
38
|
+
* pod / service / configmap / secret names — anything Kubernetes
|
|
39
|
+
* accepts as a single-segment identifier.
|
|
40
|
+
*/
|
|
41
|
+
export function validateRFC1123Label(input: string): ValidationResult {
|
|
42
|
+
if (input.length === 0) {
|
|
43
|
+
return { valid: false, error: 'must not be empty' }
|
|
44
|
+
}
|
|
45
|
+
if (input.length > RFC1123_LABEL_MAX) {
|
|
46
|
+
return {
|
|
47
|
+
valid: false,
|
|
48
|
+
error: `must be at most ${RFC1123_LABEL_MAX} characters (got ${input.length})`,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (input !== input.toLowerCase()) {
|
|
52
|
+
return {
|
|
53
|
+
valid: false,
|
|
54
|
+
error: 'must be lowercase (Kubernetes names are case-sensitive and only allow [a-z0-9-])',
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!RFC1123_LABEL_RE.test(input)) {
|
|
58
|
+
return {
|
|
59
|
+
valid: false,
|
|
60
|
+
error: 'must contain only lowercase letters, numbers, and hyphens, and start/end with a letter or number',
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { valid: true }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validates a Kubernetes RFC 1123 subdomain (labels joined by dots).
|
|
68
|
+
* Used for Helm chart names, image registries — anything that allows
|
|
69
|
+
* dotted segments. NOT for pod/namespace names; use the label form.
|
|
70
|
+
*/
|
|
71
|
+
export function validateRFC1123Subdomain(input: string): ValidationResult {
|
|
72
|
+
if (input.length === 0) {
|
|
73
|
+
return { valid: false, error: 'must not be empty' }
|
|
74
|
+
}
|
|
75
|
+
if (input.length > RFC1123_SUBDOMAIN_MAX) {
|
|
76
|
+
return {
|
|
77
|
+
valid: false,
|
|
78
|
+
error: `must be at most ${RFC1123_SUBDOMAIN_MAX} characters (got ${input.length})`,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (input !== input.toLowerCase()) {
|
|
82
|
+
return {
|
|
83
|
+
valid: false,
|
|
84
|
+
error: 'must be lowercase',
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!RFC1123_SUBDOMAIN_RE.test(input)) {
|
|
88
|
+
return {
|
|
89
|
+
valid: false,
|
|
90
|
+
error: 'must contain only lowercase letters, numbers, hyphens, and dots, and each segment must start/end with a letter or number',
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Per-label cap: K8s' IsDNS1123Subdomain enforces ≤ 63 chars
|
|
94
|
+
// PER dot-separated label on top of the 253-char total. The
|
|
95
|
+
// regex above doesn't catch that; without this check a single
|
|
96
|
+
// 200-char label passes here and only fails server-side.
|
|
97
|
+
for (const label of input.split('.')) {
|
|
98
|
+
if (label.length > RFC1123_LABEL_MAX) {
|
|
99
|
+
return {
|
|
100
|
+
valid: false,
|
|
101
|
+
error: `each dot-separated label must be at most ${RFC1123_LABEL_MAX} characters (got ${label.length} for "${label}")`,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { valid: true }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validates a Helm release name. Same shape as an RFC 1123 label
|
|
110
|
+
* (no dots) capped at 53 chars (Helm's hard limit; longer names
|
|
111
|
+
* produce resources that exceed K8s' 63-char label limit).
|
|
112
|
+
*
|
|
113
|
+
* Why label and not subdomain: Helm itself permits dots in release
|
|
114
|
+
* names, but most charts compose resource names as
|
|
115
|
+
* `<release>-<chart>-<hash>` which must be valid DNS-1123 labels.
|
|
116
|
+
* A dotted release name like `my.app` produces resources whose
|
|
117
|
+
* names fail K8s label validation server-side. Reject the dot up
|
|
118
|
+
* front so the user sees a clear inline error instead of a server
|
|
119
|
+
* 422 after submit.
|
|
120
|
+
*/
|
|
121
|
+
export function validateHelmReleaseName(input: string): ValidationResult {
|
|
122
|
+
if (input.length === 0) {
|
|
123
|
+
return { valid: false, error: 'must not be empty' }
|
|
124
|
+
}
|
|
125
|
+
if (input.length > HELM_RELEASE_NAME_MAX) {
|
|
126
|
+
return {
|
|
127
|
+
valid: false,
|
|
128
|
+
// No trailing period — call sites compose this into a sentence
|
|
129
|
+
// and append their own.
|
|
130
|
+
error: `must be at most ${HELM_RELEASE_NAME_MAX} characters (got ${input.length}); Helm caps release names so derived resource names fit under K8s' 63-char limit`,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return validateRFC1123Label(input)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Validates a TCP/UDP port number provided as a free-text input.
|
|
138
|
+
*
|
|
139
|
+
* Accepts:
|
|
140
|
+
* - a number-like string with no whitespace, no leading zeros, no '+'
|
|
141
|
+
* - in [1, 65535]
|
|
142
|
+
*
|
|
143
|
+
* Rejects '0' (reserved), negative numbers, fractional numbers,
|
|
144
|
+
* scientific notation, and anything containing non-digit characters.
|
|
145
|
+
*
|
|
146
|
+
* Returns the parsed integer alongside the validation result so call
|
|
147
|
+
* sites don't have to re-parse.
|
|
148
|
+
*/
|
|
149
|
+
export type PortValidationResult =
|
|
150
|
+
| { valid: true; value: number }
|
|
151
|
+
| { valid: false; error: string }
|
|
152
|
+
|
|
153
|
+
export function validatePort(input: string | number): PortValidationResult {
|
|
154
|
+
// Coerce a number argument to its decimal string form so the same
|
|
155
|
+
// strict rules (no fractional, no NaN) apply regardless of how the
|
|
156
|
+
// value reached us. We then re-parse to integer.
|
|
157
|
+
const raw = typeof input === 'number' ? String(input) : input
|
|
158
|
+
if (raw === '' || raw == null) {
|
|
159
|
+
return { valid: false, error: 'port is required' }
|
|
160
|
+
}
|
|
161
|
+
// Reject anything other than digits — '+1', '1.0', '1e3', ' 80 '
|
|
162
|
+
// all fail here. Catching them as parse failures gives clearer
|
|
163
|
+
// feedback than silently truncating via Number().
|
|
164
|
+
if (!/^\d+$/.test(raw)) {
|
|
165
|
+
return {
|
|
166
|
+
valid: false,
|
|
167
|
+
error: 'port must be a whole number between 1 and 65535',
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const n = Number(raw)
|
|
171
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
172
|
+
return {
|
|
173
|
+
valid: false,
|
|
174
|
+
error: 'port must be between 1 and 65535 (0 is reserved)',
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { valid: true, value: n }
|
|
178
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { isInvalidStateError, startViewTransitionSafe } from './view-transition'
|
|
3
|
+
|
|
4
|
+
// SKY-833 bug 49: rapid tab / route switches surfaced
|
|
5
|
+
// "InvalidStateError: Transition was aborted because of invalid state"
|
|
6
|
+
// as a console exception. The View Transitions API rejects the
|
|
7
|
+
// `finished` promise with that error whenever a new transition
|
|
8
|
+
// supersedes an in-flight one — which is the user's intent (they
|
|
9
|
+
// clicked again), not a bug. These tests pin the rejection-
|
|
10
|
+
// classification helper and the fallback path that's used when the
|
|
11
|
+
// API isn't available (Firefox, older Safari, jsdom).
|
|
12
|
+
|
|
13
|
+
describe('isInvalidStateError', () => {
|
|
14
|
+
it('returns true for objects whose name is "InvalidStateError"', () => {
|
|
15
|
+
expect(isInvalidStateError(new DOMException('boom', 'InvalidStateError'))).toBe(true)
|
|
16
|
+
expect(isInvalidStateError({ name: 'InvalidStateError', message: 'x' })).toBe(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns false for any other shape', () => {
|
|
20
|
+
expect(isInvalidStateError(new Error('boom'))).toBe(false)
|
|
21
|
+
expect(isInvalidStateError({ name: 'TypeError' })).toBe(false)
|
|
22
|
+
expect(isInvalidStateError({})).toBe(false)
|
|
23
|
+
expect(isInvalidStateError('InvalidStateError')).toBe(false)
|
|
24
|
+
expect(isInvalidStateError(null)).toBe(false)
|
|
25
|
+
expect(isInvalidStateError(undefined)).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('startViewTransitionSafe', () => {
|
|
30
|
+
it('falls back to invoking update directly when the API is missing', () => {
|
|
31
|
+
const originalDoc = (globalThis as { document?: unknown }).document
|
|
32
|
+
;(globalThis as { document: object }).document = {} // no startViewTransition
|
|
33
|
+
|
|
34
|
+
const update = vi.fn()
|
|
35
|
+
startViewTransitionSafe(update)
|
|
36
|
+
expect(update).toHaveBeenCalledTimes(1)
|
|
37
|
+
|
|
38
|
+
;(globalThis as { document?: unknown }).document = originalDoc
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('uses the API when available and swallows InvalidStateError on the finished promise', async () => {
|
|
42
|
+
const originalDoc = (globalThis as { document?: unknown }).document
|
|
43
|
+
|
|
44
|
+
const update = vi.fn()
|
|
45
|
+
const startSpy = vi.fn((cb: () => void) => {
|
|
46
|
+
void cb // the real API invokes this; test doesn't need to
|
|
47
|
+
return {
|
|
48
|
+
finished: Promise.reject(new DOMException('superseded', 'InvalidStateError')),
|
|
49
|
+
ready: Promise.resolve(),
|
|
50
|
+
updateCallbackDone: Promise.resolve(),
|
|
51
|
+
skipTransition: () => {},
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
;(globalThis as { document: object }).document = { startViewTransition: startSpy }
|
|
55
|
+
|
|
56
|
+
startViewTransitionSafe(update)
|
|
57
|
+
expect(startSpy).toHaveBeenCalledTimes(1)
|
|
58
|
+
// Argument is the update callback (the API itself invokes it).
|
|
59
|
+
expect(startSpy).toHaveBeenCalledWith(update)
|
|
60
|
+
|
|
61
|
+
// The unhandled rejection would surface here if we hadn't caught
|
|
62
|
+
// it. Awaiting a microtask yield is enough to flush the promise
|
|
63
|
+
// chain inside startViewTransitionSafe.
|
|
64
|
+
await Promise.resolve()
|
|
65
|
+
await Promise.resolve()
|
|
66
|
+
|
|
67
|
+
;(globalThis as { document?: unknown }).document = originalDoc
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('only swallows InvalidStateError (verified via the classifier)', () => {
|
|
71
|
+
// Rather than triggering an actual unhandled rejection (which
|
|
72
|
+
// vitest treats as a test failure), pin the contract that
|
|
73
|
+
// startViewTransitionSafe relies on: InvalidStateError is the
|
|
74
|
+
// single name we suppress. Anything else returns false from the
|
|
75
|
+
// classifier and therefore propagates through the promise chain.
|
|
76
|
+
expect(isInvalidStateError(new DOMException('x', 'InvalidStateError'))).toBe(true)
|
|
77
|
+
expect(isInvalidStateError(new Error('something else broke'))).toBe(false)
|
|
78
|
+
expect(isInvalidStateError(new DOMException('x', 'AbortError'))).toBe(false)
|
|
79
|
+
expect(isInvalidStateError(new TypeError('y'))).toBe(false)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper around `document.startViewTransition` that:
|
|
3
|
+
*
|
|
4
|
+
* 1. Falls back to a synchronous call to `update` when the View
|
|
5
|
+
* Transitions API isn't available (older Safari, Firefox without
|
|
6
|
+
* the flag, jsdom in tests).
|
|
7
|
+
* 2. Swallows the `InvalidStateError` that the API rejects with
|
|
8
|
+
* whenever a new transition starts while a previous one is still
|
|
9
|
+
* pending. This is a normal symptom of impatient users (clicking
|
|
10
|
+
* tabs faster than 200ms) — the new transition has already
|
|
11
|
+
* replaced the in-flight one, so there is nothing for the caller
|
|
12
|
+
* to do. SKY-833 bug 49 surfaced this as a console exception
|
|
13
|
+
* stack on rapid Secrets / drawer-tab navigation.
|
|
14
|
+
*
|
|
15
|
+
* Other rejection types (programming errors, unsupported configs)
|
|
16
|
+
* are re-thrown so we don't accidentally hide real bugs.
|
|
17
|
+
*/
|
|
18
|
+
export function startViewTransitionSafe(update: () => void): void {
|
|
19
|
+
// Some lib.dom.d.ts versions don't yet expose `startViewTransition`
|
|
20
|
+
// on Document. Narrow once via a typed cast.
|
|
21
|
+
const doc = document as Document & {
|
|
22
|
+
startViewTransition?: (cb: () => void) => {
|
|
23
|
+
finished: Promise<void>
|
|
24
|
+
ready: Promise<void>
|
|
25
|
+
updateCallbackDone: Promise<void>
|
|
26
|
+
skipTransition: () => void
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof doc.startViewTransition !== 'function') {
|
|
31
|
+
update()
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const transition = doc.startViewTransition(update)
|
|
36
|
+
|
|
37
|
+
// The `finished` promise rejects with InvalidStateError when a new
|
|
38
|
+
// transition supersedes this one. That's not an error condition for
|
|
39
|
+
// the user — they got what they wanted (the new state). Swallow it
|
|
40
|
+
// so it doesn't become a noisy console exception.
|
|
41
|
+
transition.finished.catch((err: unknown) => {
|
|
42
|
+
if (isInvalidStateError(err)) return
|
|
43
|
+
// Anything else really is unexpected — let it surface.
|
|
44
|
+
throw err
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Predicate that classifies the rejection thrown by the View
|
|
50
|
+
* Transitions API when a new transition supersedes an in-flight one.
|
|
51
|
+
* Used by `startViewTransitionSafe` to know what to swallow, and
|
|
52
|
+
* exported so tests (and any future call site that wraps the API
|
|
53
|
+
* differently) can pin the exact name we treat as expected.
|
|
54
|
+
*/
|
|
55
|
+
export function isInvalidStateError(err: unknown): boolean {
|
|
56
|
+
return (
|
|
57
|
+
!!err &&
|
|
58
|
+
typeof err === 'object' &&
|
|
59
|
+
'name' in err &&
|
|
60
|
+
(err as { name: unknown }).name === 'InvalidStateError'
|
|
61
|
+
)
|
|
62
|
+
}
|