Skip to content

Commit d07ca14

Browse files
Add TLS Cert input (#1784)
* Make imports more consistent * Rough up adding TLS certs to silo create form * Add test for adding a silo cert * TSC onchange and remove unnecessary `readBlobAsText` * Ensure certificate name is unique * Don't validate file input on blur * don't need the onBlur noop if we're not validating on blur * use TextField directly for unique name, make it required, delete unused file * polish e2e tests: extract common chooseFile, wrap in test.step(), use getByRole where possible * code tweaks, mostly names * undo upgrade to playwright 1.39 for now. it break unrelated tests --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent 0a32ccf commit d07ca14

File tree

12 files changed

+240
-61
lines changed

12 files changed

+240
-61
lines changed

app/components/form/fields/TextField.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ export interface TextFieldProps<
5353
description?: string
5454
placeholder?: string
5555
units?: string
56-
// TODO: think about this doozy of a type
5756
validate?: Validate<FieldPathValue<TFieldValues, TName>, TFieldValues>
5857
control: Control<TFieldValues>
5958
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useState } from 'react'
9+
import type { Control } from 'react-hook-form'
10+
import { useController } from 'react-hook-form'
11+
import type { Merge } from 'type-fest'
12+
13+
import type { CertificateCreate } from '@oxide/api'
14+
import { Button, Error16Icon, FieldLabel, MiniTable, Modal } from '@oxide/ui'
15+
16+
import { DescriptionField, FileField, TextField, validateName } from 'app/components/form'
17+
import type { SiloCreateFormValues } from 'app/forms/silo-create'
18+
import { useForm } from 'app/hooks'
19+
20+
export function TlsCertsField({ control }: { control: Control<SiloCreateFormValues> }) {
21+
const [showAddCert, setShowAddCert] = useState(false)
22+
23+
const {
24+
field: { value: items, onChange },
25+
} = useController({ control, name: 'tlsCertificates' })
26+
27+
return (
28+
<>
29+
<div className="max-w-lg">
30+
<FieldLabel id="tls-certificates-label" className="mb-3">
31+
TLS Certificates
32+
</FieldLabel>
33+
{!!items.length && (
34+
<MiniTable.Table className="mb-4">
35+
<MiniTable.Header>
36+
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
37+
{/* For remove button */}
38+
<MiniTable.HeadCell className="w-12" />
39+
</MiniTable.Header>
40+
<MiniTable.Body>
41+
{items.map((item, index) => (
42+
<MiniTable.Row
43+
tabIndex={0}
44+
aria-rowindex={index + 1}
45+
aria-label={`Name: ${item.name}, Description: ${item.description}`}
46+
key={item.name}
47+
>
48+
<MiniTable.Cell>{item.name}</MiniTable.Cell>
49+
<MiniTable.Cell>
50+
<button
51+
onClick={() => onChange(items.filter((i) => i.name !== item.name))}
52+
>
53+
<Error16Icon title={`remove ${item.name}`} />
54+
</button>
55+
</MiniTable.Cell>
56+
</MiniTable.Row>
57+
))}
58+
</MiniTable.Body>
59+
</MiniTable.Table>
60+
)}
61+
62+
<Button size="sm" onClick={() => setShowAddCert(true)}>
63+
Add TLS certificate
64+
</Button>
65+
</div>
66+
67+
{showAddCert && (
68+
<AddCertModal
69+
onDismiss={() => setShowAddCert(false)}
70+
onSubmit={async (values) => {
71+
const certCreate: (typeof items)[number] = {
72+
...values,
73+
// cert and key are required fields. they will always be present if we get here
74+
cert: await values.cert!.text(),
75+
key: await values.key!.text(),
76+
}
77+
onChange([...items, certCreate])
78+
setShowAddCert(false)
79+
}}
80+
allNames={items.map((item) => item.name)}
81+
/>
82+
)}
83+
</>
84+
)
85+
}
86+
87+
export type CertFormValues = Merge<
88+
CertificateCreate,
89+
{ key: File | null; cert: File | null } // swap strings for Files
90+
>
91+
92+
const defaultValues: CertFormValues = {
93+
description: '',
94+
name: '',
95+
service: 'external_api',
96+
key: null,
97+
cert: null,
98+
}
99+
100+
type AddCertModalProps = {
101+
onDismiss: () => void
102+
onSubmit: (values: CertFormValues) => void
103+
allNames: string[]
104+
}
105+
106+
const AddCertModal = ({ onDismiss, onSubmit, allNames }: AddCertModalProps) => {
107+
const { control, handleSubmit } = useForm<CertFormValues>({ defaultValues })
108+
109+
return (
110+
<Modal isOpen onDismiss={onDismiss} title="Add TLS certificate">
111+
<Modal.Body>
112+
<form autoComplete="off" onSubmit={handleSubmit(onSubmit)}>
113+
<Modal.Section>
114+
<TextField
115+
name="name"
116+
control={control}
117+
required
118+
// this field is identical to NameField (which just does
119+
// validateName for you) except we also want to check that the
120+
// name is not in the list of certs you've already added
121+
validate={(name) => {
122+
if (allNames.includes(name)) {
123+
return 'A certificate with this name already exists'
124+
}
125+
return validateName(name, 'Name', true)
126+
}}
127+
/>
128+
<DescriptionField name="description" control={control} />
129+
<FileField
130+
id="cert-input"
131+
name="cert"
132+
label="Cert"
133+
required
134+
control={control}
135+
/>
136+
<FileField id="key-input" name="key" label="Key" required control={control} />
137+
</Modal.Section>
138+
</form>
139+
</Modal.Body>
140+
<Modal.Footer
141+
onDismiss={onDismiss}
142+
onAction={handleSubmit(onSubmit)}
143+
actionText="Add Certificate"
144+
/>
145+
</Modal>
146+
)
147+
}

app/components/form/fields/index.ts

Lines changed: 0 additions & 10 deletions
This file was deleted.

app/components/form/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,5 @@ export * from './fields/NetworkInterfaceField'
2222
export * from './fields/RadioField'
2323
export * from './fields/SubnetListbox'
2424
export * from './fields/TextField'
25+
export * from './fields/TlsCertsField'
26+
export * from './fields/FileField'

app/forms/idp/create.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,13 @@ import { useNavigate } from 'react-router-dom'
99

1010
import { useApiMutation, useApiQueryClient } from '@oxide/api'
1111

12-
import { DescriptionField, NameField, SideModalForm, TextField } from 'app/components/form'
13-
import { FileField } from 'app/components/form/fields'
12+
import {
13+
DescriptionField,
14+
FileField,
15+
NameField,
16+
SideModalForm,
17+
TextField,
18+
} from 'app/components/form'
1419
import { useForm, useSiloSelector, useToast } from 'app/hooks'
1520
import { readBlobAsBase64 } from 'app/util/file'
1621
import { pb } from 'app/util/path-builder'

app/forms/idp/shared.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@ import type { Merge } from 'type-fest'
1111
import type { IdpMetadataSource, SamlIdentityProviderCreate } from '@oxide/api'
1212
import { Radio, RadioGroup } from '@oxide/ui'
1313

14-
import { TextField } from 'app/components/form'
15-
import { FileField } from 'app/components/form/fields'
14+
import { FileField, TextField } from 'app/components/form'
1615

1716
export type IdpCreateFormValues = { type: 'saml' } & Merge<
1817
SamlIdentityProviderCreate,

app/forms/image-upload.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ import { GiB, KiB, invariant } from '@oxide/util'
2727

2828
import {
2929
DescriptionField,
30+
FileField,
3031
NameField,
3132
RadioField,
3233
SideModalForm,
3334
TextField,
3435
} from 'app/components/form'
35-
import { FileField } from 'app/components/form/fields'
3636
import { useForm, useProjectSelector } from 'app/hooks'
3737
import { readBlobAsBase64 } from 'app/util/file'
3838
import { pb } from 'app/util/path-builder'

app/forms/silo-create.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useNavigate } from 'react-router-dom'
99

1010
import type { SiloCreate } from '@oxide/api'
1111
import { useApiMutation, useApiQueryClient } from '@oxide/api'
12+
import { FormDivider } from '@oxide/ui'
1213

1314
import {
1415
CheckboxField,
@@ -17,16 +18,17 @@ import {
1718
RadioField,
1819
SideModalForm,
1920
TextField,
21+
TlsCertsField,
2022
} from 'app/components/form'
2123
import { useForm, useToast } from 'app/hooks'
2224
import { pb } from 'app/util/path-builder'
2325

24-
type FormValues = Omit<SiloCreate, 'mappedFleetRoles'> & {
26+
export type SiloCreateFormValues = Omit<SiloCreate, 'mappedFleetRoles'> & {
2527
siloAdminGetsFleetAdmin: boolean
2628
siloViewerGetsFleetViewer: boolean
2729
}
2830

29-
const defaultValues: FormValues = {
31+
const defaultValues: SiloCreateFormValues = {
3032
name: '',
3133
description: '',
3234
discoverable: true,
@@ -117,6 +119,8 @@ export function CreateSiloSideModalForm() {
117119
Grant fleet viewer role to silo viewers
118120
</CheckboxField>
119121
</div>
122+
<FormDivider />
123+
<TlsCertsField control={form.control} />
120124
</SideModalForm>
121125
)
122126
}

app/test/e2e/image-upload.e2e.ts

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,13 @@
88
import type { Page } from '@playwright/test'
99
import { expect, test } from '@playwright/test'
1010

11-
import { MiB } from '@oxide/util'
12-
13-
import { expectNotVisible, expectRowVisible, expectVisible, sleep } from './utils'
14-
15-
async function chooseFile(page: Page, size = 15 * MiB) {
16-
const fileChooserPromise = page.waitForEvent('filechooser')
17-
await page.getByText('Image file', { exact: true }).click()
18-
const fileChooser = await fileChooserPromise
19-
await fileChooser.setFiles({
20-
name: 'my-image.iso',
21-
mimeType: 'application/octet-stream',
22-
// fill with nonzero content, otherwise we'll skip the whole thing, which
23-
// makes the test too fast for playwright to catch anything
24-
buffer: Buffer.alloc(size, 'a'),
25-
})
26-
}
11+
import {
12+
chooseFile,
13+
expectNotVisible,
14+
expectRowVisible,
15+
expectVisible,
16+
sleep,
17+
} from './utils'
2718

2819
// playwright isn't quick enough to catch each step going from ready to running
2920
// to complete in time, so we just assert that they all start out ready and end
@@ -56,7 +47,7 @@ async function fillForm(page: Page, name: string) {
5647
await page.fill('role=textbox[name="Description"]', 'image description')
5748
await page.fill('role=textbox[name="OS"]', 'Ubuntu')
5849
await page.fill('role=textbox[name="Version"]', 'Dapper Drake')
59-
await chooseFile(page)
50+
await chooseFile(page, page.getByLabel('Image file'))
6051
}
6152

6253
test.describe('Image upload', () => {
@@ -117,7 +108,7 @@ test.describe('Image upload', () => {
117108
await expectNotVisible(page, [nameRequired])
118109

119110
// now set the file, clear it, and submit again
120-
await chooseFile(page)
111+
await chooseFile(page, page.getByLabel('Image file'))
121112
await expectNotVisible(page, [fileRequired])
122113

123114
await page.click('role=button[name="Clear file"]')

app/test/e2e/silos.e2e.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
*/
88
import { expect, test } from '@playwright/test'
99

10-
import { expectNotVisible, expectRowVisible, expectVisible } from './utils'
10+
import { MiB } from '@oxide/util'
1111

12-
test('Silos page', async ({ page }) => {
12+
import { chooseFile, expectNotVisible, expectRowVisible, expectVisible } from './utils'
13+
14+
test('Create silo', async ({ page }) => {
1315
await page.goto('/system/silos')
1416

1517
await expectVisible(page, ['role=heading[name*="Silos"]'])
@@ -31,6 +33,55 @@ test('Silos page', async ({ page }) => {
3133
await page.click('role=radio[name="Local only"]')
3234
await page.fill('role=textbox[name="Admin group name"]', 'admins')
3335
await page.click('role=checkbox[name="Grant fleet admin role to silo admins"]')
36+
37+
// Add a TLS cert
38+
const openCertModalButton = page.getByRole('button', { name: 'Add TLS certificate' })
39+
await openCertModalButton.click()
40+
41+
const certDialog = page.getByRole('dialog', { name: 'Add TLS certificate' })
42+
43+
const certRequired = certDialog.getByText('Cert is required')
44+
const keyRequired = certDialog.getByText('Key is required')
45+
const nameRequired = certDialog.getByText('Name is required')
46+
await expectNotVisible(page, [certRequired, keyRequired, nameRequired])
47+
48+
const certSubmit = page.getByRole('button', { name: 'Add Certificate' })
49+
await certSubmit.click()
50+
51+
// Validation error for missing name + key and cert files
52+
await expectVisible(page, [certRequired, keyRequired, nameRequired])
53+
54+
await chooseFile(page, page.getByLabel('Cert', { exact: true }), 0.1 * MiB)
55+
await chooseFile(page, page.getByLabel('Key'), 0.1 * MiB)
56+
const certName = certDialog.getByRole('textbox', { name: 'Name' })
57+
await certName.fill('test-cert')
58+
59+
await certSubmit.click()
60+
61+
// Check cert appears in the mini-table
62+
const certCell = page.getByRole('cell', { name: 'test-cert', exact: true })
63+
await expect(certCell).toBeVisible()
64+
65+
// check unique name validation
66+
await openCertModalButton.click()
67+
await certName.fill('test-cert')
68+
await certSubmit.click()
69+
await expect(
70+
certDialog.getByText('A certificate with this name already exists')
71+
).toBeVisible()
72+
73+
// Change the name so it's unique
74+
await certName.fill('test-cert-2')
75+
await chooseFile(page, page.getByLabel('Cert', { exact: true }), 0.1 * MiB)
76+
await chooseFile(page, page.getByLabel('Key'), 0.1 * MiB)
77+
await certSubmit.click()
78+
await expect(page.getByRole('cell', { name: 'test-cert-2', exact: true })).toBeVisible()
79+
80+
// now delete the first
81+
await page.getByRole('button', { name: 'remove test-cert', exact: true }).click()
82+
// Cert should not appear after it has been deleted
83+
await expect(certCell).toBeHidden()
84+
3485
await page.click('role=button[name="Create silo"]')
3586

3687
// it's there in the table

0 commit comments

Comments
 (0)