Skip to content

Commit 67508fa

Browse files
Firewall rules create/edit modal (#630)
* stub out firewall rule form based on API (needs design pass) * msw endpoint for get rules * hack in vpcId on firewall rule in anticipation of omicron#663 * working but ugly targets subform * ports, hosts, and targets subforms actually work * we can PUT rules! * get rid of vestigial subnet copy, move name/description up * include all existing rules when creating one * be super sure we're not getting any extra nonsense in the update object * fucking epic test * update API to pull in vpc_id change from omicron main * automatically update packer-id * switch back to fireEvent, everything passes, get rid of yarn test:run * update clear button colors * lengthen timeout for CI. appalling * faster testing through science * explicit dep on @testing-lib/dom was actually messing me up * wait longer for the modal to close because CI * memoize tableData more goodly * update API for rules so we get list of rules instead of map * automatically update packer-id * sort firewall rules by name in msw, fix get to put transformer * prettier format * make editing work * test for edit modal Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 3539a6a commit 67508fa

34 files changed

+1345
-185
lines changed

.github/workflows/lintBuildTest.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
run: yarn lint
2929

3030
- name: Test
31-
run: yarn test:run
31+
run: yarn test run
3232

3333
- name: Build
3434
run: yarn build

.github/workflows/packer.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ jobs:
7575
CLOUDFLARE_TOKEN: ${{secrets.CLOUDFLARE_TOKEN}}
7676
SSL_CERT: ${{secrets.SSL_CERT}}
7777
SSL_KEY: ${{secrets.SSL_KEY}}
78-
API_VERSION: c4e76cb01fa791c4dc9722072800c8969397aa03
78+
API_VERSION: f615ee43803902fc8ed37a7e66a39677f885dbdb
7979

8080
# get the image information from gcloud
8181
- name: Get image information

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Using the script is strongly recommended, but if you really don't want to, make
118118

119119
| Command | Description |
120120
| --------------- | ---------------------------------------------------------------------------------- |
121-
| `yarn test:run` | Vitest tests |
121+
| `yarn test run` | Vitest tests |
122122
| `yarn test` | Vitest tests in watch mode |
123123
| `yarn lint` | ESLint |
124124
| `yarn tsc` | Check types |

app/pages/__tests__/InstanceCreatePage.spec.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
override,
44
renderAppAt,
55
screen,
6-
userEvent,
6+
typeByRole,
77
waitFor,
88
} from 'app/test-utils'
99
import { org, project, instance } from '@oxide/api-mocks'
@@ -30,8 +30,7 @@ describe('InstanceCreatePage', () => {
3030

3131
it('shows specific message for known server error code', async () => {
3232
renderAppAt(formUrl)
33-
const name = screen.getByRole('textbox', { name: 'Choose a name' })
34-
userEvent.type(name, instance.name) // already exists in db
33+
typeByRole('textbox', 'Choose a name', instance.name) // already exists in db
3534

3635
fireEvent.click(submitButton())
3736

@@ -59,10 +58,9 @@ describe('InstanceCreatePage', () => {
5958
const instancesPage = `/orgs/${org.name}/projects/${project.name}/instances`
6059
expect(window.location.pathname).not.toEqual(instancesPage)
6160

62-
const name = screen.getByRole('textbox', { name: 'Choose a name' })
63-
userEvent.type(name, 'new-instance')
64-
userEvent.click(screen.getByLabelText(/6 CPUs/))
65-
userEvent.click(submitButton())
61+
typeByRole('textbox', 'Choose a name', 'new-instance')
62+
fireEvent.click(screen.getByLabelText(/6 CPUs/))
63+
fireEvent.click(submitButton())
6664

6765
// nav to instances list
6866
await waitFor(() => expect(window.location.pathname).toEqual(instancesPage))

app/pages/project/instances/actions.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import type {
2-
ApiListMethods,
3-
Instance,
4-
ProjectInstancesGetParams,
5-
} from '@oxide/api'
1+
import type { Instance, ProjectInstancesGetParams } from '@oxide/api'
62
import { useApiMutation, useApiQueryClient } from '@oxide/api'
73
import type { MakeActions } from '@oxide/table'
84
import { Success16Icon } from '@oxide/ui'
@@ -28,7 +24,7 @@ const instanceCan: Record<string, (i: Instance) => boolean> = {
2824

2925
export const useInstanceActions = (
3026
params: ProjectInstancesGetParams
31-
): MakeActions<ApiListMethods, 'projectInstancesGet'> => {
27+
): MakeActions<Instance> => {
3228
const addToast = useToast()
3329
const queryClient = useApiQueryClient()
3430
const refetch = () =>
Lines changed: 176 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,209 @@
11
import {
2-
fireEvent,
2+
clickByRole,
33
renderAppAt,
44
screen,
5-
userEvent,
5+
typeByRole,
66
waitForElementToBeRemoved,
7+
userEvent,
8+
getByRole,
79
} from 'app/test-utils'
10+
import { defaultFirewallRules } from '@oxide/api-mocks'
811

912
describe('VpcPage', () => {
10-
describe('subnets tab', () => {
11-
it('creating a subnet works', async () => {
13+
describe('subnet', () => {
14+
it('create works', async () => {
1215
renderAppAt('/orgs/maze-war/projects/mock-project/vpcs/mock-vpc')
1316
screen.getByText('Subnets')
1417

1518
// wait for subnet to show up in the table
16-
await screen.findByRole('cell', { name: 'mock-subnet' })
17-
// second one is not there though
19+
await screen.findByText('mock-subnet')
20+
// the one we'll be adding is not there
1821
expect(screen.queryByRole('cell', { name: 'mock-subnet-2' })).toBeNull()
1922

2023
// modal is not already open
21-
expect(screen.queryByRole('dialog', { name: 'Create subnet' })).toBeNull()
24+
expect(screen.queryByTestId('create-vpc-subnet-modal')).toBeNull()
2225

2326
// click button to open modal
24-
fireEvent.click(screen.getByRole('button', { name: 'New subnet' }))
27+
await clickByRole('button', 'New subnet')
2528

2629
// modal is open
2730
screen.getByRole('dialog', { name: 'Create subnet' })
2831

29-
const ipv4 = screen.getByRole('textbox', { name: 'IPv4 block' })
30-
userEvent.type(ipv4, '1.1.1.2/24')
32+
typeByRole('textbox', 'IPv4 block', '1.1.1.2/24')
33+
34+
typeByRole('textbox', 'Name', 'mock-subnet-2')
35+
36+
// submit the form
37+
await clickByRole('button', 'Create subnet')
38+
39+
// wait for modal to close
40+
await waitForElementToBeRemoved(() =>
41+
screen.queryByTestId('create-vpc-subnet-modal')
42+
)
43+
44+
// table refetches and now includes second subnet
45+
await screen.findByText('mock-subnet')
46+
await screen.findByText('mock-subnet-2')
47+
}, 10000) // otherwise it flakes in CI
48+
})
49+
50+
describe('firewall rule', () => {
51+
it('create works', async () => {
52+
renderAppAt('/orgs/maze-war/projects/mock-project/vpcs/mock-vpc')
53+
await clickByRole('tab', 'Firewall Rules')
54+
55+
// default rules show up in the table
56+
for (const { name } of defaultFirewallRules) {
57+
await screen.findByText(name)
58+
}
59+
// the one we'll be adding is not there
60+
expect(screen.queryByRole('cell', { name: 'my-new-rule' })).toBeNull()
61+
62+
// modal is not already open
63+
expect(
64+
screen.queryByRole('dialog', { name: 'Create firewall rule' })
65+
).toBeNull()
66+
67+
// click button to open modal
68+
await clickByRole('button', 'New rule')
69+
70+
// modal is open
71+
screen.getByRole('dialog', { name: 'Create firewall rule' })
72+
73+
typeByRole('textbox', 'Name', 'my-new-rule')
74+
75+
await clickByRole('radio', 'Outgoing')
76+
77+
// input type="number" becomes spinbutton for some reason
78+
typeByRole('spinbutton', 'Priority', '5')
79+
80+
await clickByRole('button', 'Target type')
81+
await clickByRole('option', 'VPC')
82+
typeByRole('textbox', 'Target name', 'my-target-vpc')
83+
await clickByRole('button', 'Add target')
84+
85+
// target is added to targets table
86+
screen.getByRole('cell', { name: 'my-target-vpc' })
87+
88+
await clickByRole('button', 'Host type')
89+
await clickByRole('option', 'Instance')
90+
typeByRole('textbox', 'Value', 'host-filter-instance')
91+
await clickByRole('button', 'Add host filter')
92+
93+
// host is added to hosts table
94+
screen.getByRole('cell', { name: 'host-filter-instance' })
95+
96+
// TODO: test invalid port range once I put an error message in there
97+
typeByRole('textbox', 'Port filter', '123-456')
98+
await clickByRole('button', 'Add port filter')
99+
100+
// port range is added to port ranges table
101+
screen.getByRole('cell', { name: '123-456' })
31102

103+
await clickByRole('checkbox', 'UDP')
104+
105+
// submit the form
106+
await clickByRole('button', 'Create rule')
107+
108+
// wait for modal to close
109+
await waitForElementToBeRemoved(
110+
() => screen.queryByText('Create firewall rule'),
111+
// fails in CI without a longer timeout (default 1000). boo
112+
{ timeout: 2000 }
113+
)
114+
115+
// table refetches and now includes the new rule as well as the originals
116+
await screen.findByText('my-new-rule')
117+
screen.getByRole('cell', {
118+
name: 'instance host-filter-instance UDP 123-456',
119+
})
120+
121+
for (const { name } of defaultFirewallRules) {
122+
screen.getByText(name)
123+
}
124+
}, 15000)
125+
126+
it('edit works', async () => {
127+
renderAppAt('/orgs/maze-war/projects/mock-project/vpcs/mock-vpc')
128+
await clickByRole('tab', 'Firewall Rules')
129+
130+
// default rules show up in the table
131+
for (const { name } of defaultFirewallRules) {
132+
await screen.findByText(name)
133+
}
134+
expect(screen.getAllByRole('row').length).toEqual(5) // 4 + header
135+
136+
// the one we'll be adding is not there
137+
expect(screen.queryByRole('cell', { name: 'new-rule-name' })).toBeNull()
138+
139+
// modal is not already open
140+
expect(
141+
screen.queryByRole('dialog', { name: 'Edit firewall rule' })
142+
).toBeNull()
143+
144+
// click more button on allow-icmp row to get menu, then click Edit
145+
const allowIcmpRow = screen.getByRole('row', { name: /allow-icmp/ })
146+
const more = getByRole(allowIcmpRow, 'button', { name: 'More' })
147+
await userEvent.click(more)
148+
await clickByRole('menuitem', 'Edit')
149+
150+
// now the modal is open
151+
screen.getByRole('dialog', { name: 'Edit firewall rule' })
152+
153+
// name is populated
32154
const name = screen.getByRole('textbox', { name: 'Name' })
33-
userEvent.type(name, 'mock-subnet-2')
155+
expect(name).toHaveValue('allow-icmp')
156+
157+
// priority is populated
158+
const priority = screen.getByRole('spinbutton', { name: 'Priority' })
159+
expect(priority).toHaveValue(65534)
160+
161+
// protocol is populated
162+
expect(screen.getByRole('checkbox', { name: /ICMP/ })).toBeChecked()
163+
expect(screen.getByRole('checkbox', { name: /TCP/ })).not.toBeChecked()
164+
expect(screen.getByRole('checkbox', { name: /UDP/ })).not.toBeChecked()
165+
166+
// targets default vpc
167+
screen.getByRole('cell', { name: 'vpc' })
168+
screen.getByRole('cell', { name: 'default' })
169+
170+
// update name
171+
await userEvent.clear(name)
172+
await userEvent.type(name, 'new-rule-name')
173+
174+
// add host filter
175+
await clickByRole('button', 'Host type')
176+
await clickByRole('option', 'Instance')
177+
typeByRole('textbox', 'Value', 'edit-filter-instance')
178+
await clickByRole('button', 'Add host filter')
179+
180+
// host is added to hosts table
181+
screen.getByRole('cell', { name: 'edit-filter-instance' })
34182

35183
// submit the form
36-
fireEvent.click(screen.getByRole('button', { name: 'Create subnet' }))
184+
await clickByRole('button', 'Update rule')
37185

38186
// wait for modal to close
39187
await waitForElementToBeRemoved(
40-
() => screen.queryByRole('dialog', { name: 'Create subnet' }),
188+
() => screen.queryByText('Edit firewall rule'),
41189
// fails in CI without a longer timeout (default 1000). boo
42190
{ timeout: 2000 }
43191
)
44192

45-
// table refetches and now includes second subnet
46-
screen.getByRole('cell', { name: 'mock-subnet' })
47-
await screen.findByRole('cell', { name: 'mock-subnet-2' })
48-
}, 10000) // otherwise it flakes in CI
193+
// table refetches and now includes the updated rule name, not the old name
194+
await screen.findByText('new-rule-name')
195+
expect(screen.queryByRole('cell', { name: 'allow-icmp' })).toBeNull()
196+
expect(screen.getAllByRole('row').length).toEqual(5) // 4 + header
197+
198+
screen.getByRole('cell', {
199+
name: 'instance edit-filter-instance ICMP',
200+
})
201+
202+
// other 3 rules are still there
203+
const rest = defaultFirewallRules.filter((r) => r.name !== 'allow-icmp')
204+
for (const { name } of rest) {
205+
screen.getByRole('cell', { name })
206+
}
207+
}, 20000)
49208
})
50209
})

0 commit comments

Comments
 (0)