Skip to content

Commit dab60d9

Browse files
authored
Error on firewall rule name conflicts (#2358)
* error on firewall rule name conflicts * do it the right way, hooking into the validate prop on NameField * on rule update success, do onDismiss nav before invalidating rules view query
1 parent 8e3314f commit dab60d9

File tree

4 files changed

+94
-22
lines changed

4 files changed

+94
-22
lines changed

app/components/form/fields/NameField.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ export function NameField<
1818
required = true,
1919
name,
2020
label = capitalize(name),
21+
validate,
2122
...textFieldProps
22-
}: Omit<TextFieldProps<TFieldValues, TName>, 'validate'> & { label?: string }) {
23+
}: TextFieldProps<TFieldValues, TName> & { label?: string }) {
2324
return (
2425
<TextField
25-
validate={(name) => validateName(name, label, required)}
26+
// always check the name rules first, then the other checks if present
27+
validate={(name, formValues) =>
28+
validateName(name, label, required) || validate?.(name, formValues)
29+
}
2630
required={required}
2731
label={label}
2832
name={name}

app/forms/firewall-rules-create.tsx

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ const targetDefaultValues: TargetFormValues = {
104104
type CommonFieldsProps = {
105105
error: ApiError | null
106106
control: Control<FirewallRuleValues>
107+
nameTaken: (name: string) => boolean
107108
}
108109

109110
function getFilterValueProps(hostType: VpcFirewallRuleHostFilter['type']) {
@@ -155,7 +156,7 @@ const DocsLinkMessage = () => (
155156
/>
156157
)
157158

158-
export const CommonFields = ({ error, control }: CommonFieldsProps) => {
159+
export const CommonFields = ({ error, control, nameTaken }: CommonFieldsProps) => {
159160
const portRangeForm = useForm({ defaultValues: portRangeDefaultValues })
160161
const ports = useController({ name: 'ports', control }).field
161162
const submitPortRange = portRangeForm.handleSubmit(({ portRange }) => {
@@ -199,7 +200,16 @@ export const CommonFields = ({ error, control }: CommonFieldsProps) => {
199200
<CheckboxField name="enabled" control={control}>
200201
Enabled
201202
</CheckboxField>
202-
<NameField name="name" control={control} />
203+
<NameField
204+
name="name"
205+
control={control}
206+
validate={(name) => {
207+
if (nameTaken(name)) {
208+
// TODO: might be worth mentioning that the names are unique per VPC as opposed to globally
209+
return 'Name taken. To update an existing rule, edit it directly.'
210+
}
211+
}}
212+
/>
203213
<DescriptionField name="description" control={control} />
204214

205215
<RadioField
@@ -589,23 +599,29 @@ export function CreateFirewallRuleForm() {
589599
title="Add firewall rule"
590600
onDismiss={onDismiss}
591601
onSubmit={(values) => {
592-
// TODO: this silently overwrites existing rules with the current name.
593-
// we should probably at least warn and confirm, if not reject as invalid
594-
const otherRules = existingRules
595-
.filter((r) => r.name !== values.name)
596-
.map(firewallRuleGetToPut)
597602
updateRules.mutate({
598603
query: vpcSelector,
599604
body: {
600-
rules: [...otherRules, valuesToRuleUpdate(values)],
605+
rules: [...existingRules.map(firewallRuleGetToPut), valuesToRuleUpdate(values)],
601606
},
602607
})
603608
}}
604609
loading={updateRules.isPending}
605610
submitError={updateRules.error}
606611
submitLabel="Add rule"
607612
>
608-
<CommonFields error={updateRules.error} control={form.control} />
613+
<CommonFields
614+
error={updateRules.error}
615+
control={form.control}
616+
// error if name is already in use
617+
nameTaken={(name) => !!data.rules.find((r) => r.name === name)}
618+
619+
// TODO: there should also be a form-level error so if the name is off
620+
// screen, it doesn't look like the submit button isn't working. Maybe
621+
// instead of setting a root error, it would be more robust to show a
622+
// top level error if any errors are found in the form. We might want to
623+
// expand the submitError prop on SideModalForm for this
624+
/>
609625
</SideModalForm>
610626
)
611627
}

app/forms/firewall-rules-edit.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,12 @@ export function EditFirewallRuleForm() {
6161

6262
const updateRules = useApiMutation('vpcFirewallRulesUpdate', {
6363
onSuccess() {
64-
queryClient.invalidateQueries('vpcFirewallRulesView')
64+
// Nav before the invalidate because I once saw the above invariant fail
65+
// briefly after successful edit (error page flashed but then we land
66+
// on the rules list ok) and I think it was a race condition where the
67+
// invalidate managed to complete while the modal was still open.
6568
onDismiss()
69+
queryClient.invalidateQueries('vpcFirewallRulesView')
6670
},
6771
})
6872

@@ -87,32 +91,35 @@ export function EditFirewallRuleForm() {
8791
// TODO: uhhhh how can this happen
8892
if (Object.keys(originalRule).length === 0) return null
8993

94+
// note different filter logic from create: filter out the rule with the
95+
// *original* name because we need to overwrite that rule
96+
const otherRules = data.rules.filter((r) => r.name !== originalRule.name)
97+
9098
return (
9199
<SideModalForm
92100
form={form}
93101
formType="edit"
94102
resourceName="rule"
95103
onDismiss={onDismiss}
96-
onSubmit={(values) => {
97-
// note different filter logic from create: filter out the rule with the
98-
// *original* name because we need to overwrite that rule
99-
const otherRules = data.rules
100-
.filter((r) => r.name !== originalRule.name)
101-
.map(firewallRuleGetToPut)
102-
104+
onSubmit={(values) =>
103105
updateRules.mutate({
104106
query: vpcSelector,
105107
body: {
106-
rules: [...otherRules, valuesToRuleUpdate(values)],
108+
rules: [...otherRules.map(firewallRuleGetToPut), valuesToRuleUpdate(values)],
107109
},
108110
})
109-
}}
111+
}
110112
// validationSchema={validationSchema}
111113
// validateOnBlur
112114
loading={updateRules.isPending}
113115
submitError={updateRules.error}
114116
>
115-
<CommonFields error={updateRules.error} control={form.control} />
117+
<CommonFields
118+
error={updateRules.error}
119+
control={form.control}
120+
// error if name is being changed to something that conflicts with some other rule
121+
nameTaken={(name) => !!otherRules.find((r) => r.name === name)}
122+
/>
116123
</SideModalForm>
117124
)
118125
}

test/e2e/firewall-rules.e2e.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,3 +371,48 @@ test('404s on edit non-existent rule', async ({ page }) => {
371371
await page.goto(rulePath.replace('icmp', 'boop'))
372372
await expect(page.getByText('Page not found')).toBeVisible()
373373
})
374+
375+
// when creating a rule, giving it the same name as an existing rule is an
376+
// error. if you want to overwrite a rule, you need to edit it
377+
test('name conflict error on create', async ({ page }) => {
378+
await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules-new')
379+
380+
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('allow-ssh')
381+
382+
const error = page.getByText('Name taken').first()
383+
await expect(error).toBeHidden()
384+
385+
await page.getByRole('button', { name: 'Add rule' }).click()
386+
await expect(error).toBeVisible()
387+
})
388+
389+
// when editing a rule, on the other hand, we only check for conflicts against rules
390+
// other than the one being edited, because of course its name can stay the same
391+
test('name conflict error on edit', async ({ page }) => {
392+
await page.goto('/projects/mock-project/vpcs/mock-vpc/firewall-rules/allow-icmp/edit')
393+
394+
// changing the name to one taken by another rule is an error
395+
const nameField = page.getByRole('textbox', { name: 'Name', exact: true })
396+
await nameField.fill('allow-ssh')
397+
398+
const error = page.getByText('Name taken').first()
399+
await expect(error).toBeHidden()
400+
401+
await page.getByRole('button', { name: 'Update rule' }).click()
402+
await expect(error).toBeVisible()
403+
404+
// change name back
405+
await nameField.fill('allow-icmp')
406+
407+
// changing a value _without_ changing the name is allowed
408+
await page.getByRole('textbox', { name: 'Priority' }).fill('37')
409+
await page.getByRole('button', { name: 'Update rule' }).click()
410+
await expect(error).toBeHidden()
411+
await expectRowVisible(page.getByRole('table'), { Name: 'allow-icmp', Priority: '37' })
412+
413+
// changing the name to a non-conflicting name is allowed
414+
await page.getByRole('link', { name: 'allow-icmp' }).click()
415+
await nameField.fill('allow-icmp2')
416+
await page.getByRole('button', { name: 'Update rule' }).click()
417+
await expectRowVisible(page.getByRole('table'), { Name: 'allow-icmp2', Priority: '37' })
418+
})

0 commit comments

Comments
 (0)