Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
- Store API response objects in the mock tables when possible so state persists across calls.
- Enforce role checks with `requireFleetViewer`/`requireFleetCollab`/`requireFleetAdmin`, and return realistic errors (e.g. downgrade guard in `systemUpdateStatus`).
- All UUIDs in `mock-api/` must be valid RFC 4122 (a safety test enforces this). Use `uuidgen` to generate them—do not hand-write UUIDs.
- MSW starts fresh with a new db on every page load, so in E2E tests, use client-side navigation (click links/breadcrumbs) after mutations instead of `page.goto` to preserve db state within a test.

# Routing

Expand Down
3 changes: 2 additions & 1 deletion app/forms/ip-pool-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import { useForm } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
import * as R from 'remeda'

import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api'

Expand Down Expand Up @@ -40,7 +41,7 @@ export default function EditIpPoolSideModalForm() {

const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector))

const form = useForm({ defaultValues: pool })
const form = useForm({ defaultValues: R.pick(pool, ['name', 'description']) })

const editPool = useApiMutation(api.systemIpPoolUpdate, {
onSuccess(updatedPool) {
Expand Down
80 changes: 80 additions & 0 deletions app/forms/subnet-pool-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router'

import { api, queryClient, useApiMutation, type SubnetPoolCreate } from '@oxide/api'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { NameField } from '~/components/form/fields/NameField'
import { RadioField } from '~/components/form/fields/RadioField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { titleCrumb } from '~/hooks/use-crumbs'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'

const defaultValues: SubnetPoolCreate = {
name: '',
description: '',
ipVersion: 'v4',
}

export const handle = titleCrumb('New subnet pool')

export default function CreateSubnetPoolSideModalForm() {
const navigate = useNavigate()

const onDismiss = () => navigate(pb.subnetPools())

const createPool = useApiMutation(api.systemSubnetPoolCreate, {
onSuccess(_pool) {
queryClient.invalidateEndpoint('systemSubnetPoolList')
// prettier-ignore
addToast(<>Subnet pool <HL>{_pool.name}</HL> created</>)
navigate(pb.subnetPools())
},
})

const form = useForm<SubnetPoolCreate>({ defaultValues })

return (
<SideModalForm
form={form}
formType="create"
resourceName="subnet pool"
onDismiss={onDismiss}
onSubmit={({ name, description, ipVersion }) => {
createPool.mutate({ body: { name, description, ipVersion } })
}}
loading={createPool.isPending}
submitError={createPool.error}
>
<Message
variant="info"
content="Users in linked silos will use subnet pool names and descriptions to help them choose a pool when allocating external subnets."
/>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<RadioField
name="ipVersion"
label="IP version"
column
control={form.control}
items={[
{ value: 'v4', label: 'v4' },
{ value: 'v6', label: 'v6' },
]}
/>
<SideModalFormDocs docs={[docLinks.subnetPools]} />
</SideModalForm>
)
}
83 changes: 83 additions & 0 deletions app/forms/subnet-pool-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { useForm } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
import * as R from 'remeda'

import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { NameField } from '~/components/form/fields/NameField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { makeCrumb } from '~/hooks/use-crumbs'
import { getSubnetPoolSelector, useSubnetPoolSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'

const subnetPoolView = ({ subnetPool }: PP.SubnetPool) =>
q(api.systemSubnetPoolView, { path: { pool: subnetPool } })

export async function clientLoader({ params }: LoaderFunctionArgs) {
const selector = getSubnetPoolSelector(params)
await queryClient.prefetchQuery(subnetPoolView(selector))
return null
}

export const handle = makeCrumb('Edit subnet pool')

export default function EditSubnetPoolSideModalForm() {
const navigate = useNavigate()
const poolSelector = useSubnetPoolSelector()

const { data: pool } = usePrefetchedQuery(subnetPoolView(poolSelector))

const form = useForm({ defaultValues: R.pick(pool, ['name', 'description']) })

const editPool = useApiMutation(api.systemSubnetPoolUpdate, {
onSuccess(updatedPool) {
queryClient.invalidateEndpoint('systemSubnetPoolList')
navigate(pb.subnetPool({ subnetPool: updatedPool.name }))
// prettier-ignore
addToast(<>Subnet pool <HL>{updatedPool.name}</HL> updated</>)

if (pool.name === updatedPool.name) {
queryClient.invalidateEndpoint('systemSubnetPoolView')
}
},
})

return (
<SideModalForm
form={form}
formType="edit"
resourceName="subnet pool"
onDismiss={() => navigate(pb.subnetPool({ subnetPool: poolSelector.subnetPool }))}
onSubmit={({ name, description }) => {
editPool.mutate({
path: { pool: poolSelector.subnetPool },
body: { name, description },
})
}}
loading={editPool.isPending}
submitError={editPool.error}
>
<Message
variant="info"
content="Users in linked silos will use subnet pool names and descriptions to help them choose a pool when allocating external subnets."
/>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<SideModalFormDocs docs={[docLinks.subnetPools]} />
</SideModalForm>
)
}
101 changes: 101 additions & 0 deletions app/forms/subnet-pool-member-add.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { describe, expect, it } from 'vitest'

import { createResolver } from './subnet-pool-member-add'

const resolve = createResolver('v4')
const resolve6 = createResolver('v6')

const valid = { subnet: '10.0.0.0/16', minPrefixLength: 20, maxPrefixLength: 28 }

type Result = ReturnType<typeof resolve>

function errMsg(result: Result, field: keyof Result['errors']) {
return result.errors[field]?.message
}

describe('createResolver', () => {
it('accepts valid v4 input', () => {
expect(Object.keys(resolve(valid).errors)).toEqual([])
})

it('accepts valid v6 input', () => {
const result = resolve6({
subnet: 'fd00:1000::/32',
minPrefixLength: 48,
maxPrefixLength: 64,
})
expect(Object.keys(result.errors)).toEqual([])
})

it('accepts omitted prefix lengths', () => {
const result = resolve({
subnet: '10.0.0.0/16',
minPrefixLength: NaN,
maxPrefixLength: NaN,
})
expect(Object.keys(result.errors)).toEqual([])
})

it('rejects invalid CIDR', () => {
const result = resolve({ ...valid, subnet: 'not-a-cidr' })
expect(errMsg(result, 'subnet')).toMatch(/IP address/)
})

it('rejects v6 subnet in v4 pool', () => {
const result = resolve({ ...valid, subnet: 'fd00::/32' })
expect(errMsg(result, 'subnet')).toBe('IPv6 subnet not allowed in IPv4 pool')
})

it('rejects v4 subnet in v6 pool', () => {
const result = resolve6({ ...valid, subnet: '10.0.0.0/16' })
expect(errMsg(result, 'subnet')).toBe('IPv4 subnet not allowed in IPv6 pool')
})

it('rejects min > max prefix length', () => {
const result = resolve({ ...valid, minPrefixLength: 28, maxPrefixLength: 20 })
expect(errMsg(result, 'minPrefixLength')).toMatch(/≤/)
})

it('rejects min prefix length < subnet width', () => {
const result = resolve({ ...valid, minPrefixLength: 8 })
expect(errMsg(result, 'minPrefixLength')).toMatch(/≥ subnet prefix length \(16\)/)
})

it('rejects max prefix length < subnet width', () => {
const result = resolve({ ...valid, maxPrefixLength: 8 })
expect(errMsg(result, 'maxPrefixLength')).toMatch(/≥ subnet prefix length \(16\)/)
})

it('rejects prefix length above max bound (v4: 32)', () => {
const result = resolve({ ...valid, minPrefixLength: 33 })
expect(errMsg(result, 'minPrefixLength')).toBe('Must be between 0 and 32')
})

it('rejects prefix length below 0', () => {
const result = resolve({ ...valid, maxPrefixLength: -1 })
expect(errMsg(result, 'maxPrefixLength')).toBe('Must be between 0 and 32')
})

it('shows min-≤-max error even when min is also below subnet width', () => {
// min(12) > max(10) AND min(12) < subnetWidth(16): the min-≤-max error
// should take priority over the subnet-width error
const result = resolve({ ...valid, minPrefixLength: 12, maxPrefixLength: 10 })
expect(errMsg(result, 'minPrefixLength')).toMatch(/≤/)
})

it('rejects prefix length above max bound (v6: 128)', () => {
const result = resolve6({
subnet: 'fd00::/32',
minPrefixLength: 48,
maxPrefixLength: 200,
})
expect(errMsg(result, 'maxPrefixLength')).toBe('Must be between 0 and 128')
})
})
Loading
Loading