Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Upgrade to MSW 2.0 #1809

Merged
merged 11 commits into from
Nov 3, 2023
16 changes: 11 additions & 5 deletions app/msw-mock-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,22 @@ const sleep = async (ms: number) => new Promise((res) => setTimeout(res, ms))
export async function startMockAPI() {
// dynamic imports to make extremely sure none of this code ends up in the prod bundle
const { handlers } = await import('@oxide/api-mocks')
const { setupWorker, rest, compose } = await import('msw')
const { http, HttpResponse } = await import('msw')
const { setupWorker } = await import('msw/browser')

// defined in here because it depends on the dynamic import
const interceptAll = rest.all('/v1/*', async (_req, res, ctx) => {
const interceptAll = http.all('/v1/*', async () => {
// random delay on all requests to simulate a real API
await sleep(randInt(200, 400))

if (shouldFail(chaos)) {
// special header lets client indicate chaos failures so we don't get confused
return res(compose(ctx.status(randomStatus()), ctx.set('X-Chaos', '')))
return new HttpResponse(null, {
status: randomStatus(),
headers: {
'X-Chaos': '',
},
})
}
// don't return anything means fall through to the real handlers
})
Expand All @@ -77,7 +83,7 @@ export async function startMockAPI() {
// custom handler only to make logging less noisy. unhandled requests still
// pass through to the server
onUnhandledRequest(req) {
const path = req.url.pathname
const path = new URL(req.url).pathname
const ignore = [
path.includes('libs/ui/assets'), // assets obviously loaded from file system
path.startsWith('/forms/'), // lazy loaded forms
Expand All @@ -86,7 +92,7 @@ export async function startMockAPI() {
// message format copied from MSW source
console.warn(`[MSW] Warning: captured an API request without a matching request handler:

• ${req.method} ${req.url.pathname}
• ${req.method} ${path}

If you want to intercept this unhandled request, create a request handler for it.`)
}
Expand Down
18 changes: 10 additions & 8 deletions app/test/unit/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*
* Copyright Oxide Computer Company
*/
import { rest } from 'msw'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

import { handlers } from '@oxide/api-mocks'
Expand All @@ -20,18 +20,20 @@ export const server = setupServer(

// Override request handlers in order to test special cases
export function overrideOnce(
method: keyof typeof rest,
method: keyof typeof http,
path: string,
status: number,
body: string | Record<string, unknown>
) {
server.use(
rest[method](path, (_req, res, ctx) =>
// https://mswjs.io/docs/api/response/once
res.once(
ctx.status(status),
typeof body === 'string' ? ctx.text(body) : ctx.json(body)
)
http[method](
path,
() =>
// https://mswjs.io/docs/api/response/once
typeof body === 'string'
? new HttpResponse(body, { status })
: HttpResponse.json(body, { status }),
{ once: true }
)
)
}
3 changes: 2 additions & 1 deletion libs/api-mocks/msw/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import { json } from './util'

const notFoundBody = { error_code: 'ObjectNotFound' } as const
export type NotFound = typeof notFoundBody
export const notFoundErr = json({ error_code: 'ObjectNotFound' } as const, { status: 404 })
export const notFoundErr = () =>
json({ error_code: 'ObjectNotFound' } as const, { status: 404 })

export const lookupById = <T extends { id: string }>(table: T[], id: string) => {
const item = table.find((i) => i.id === id)
Expand Down
81 changes: 41 additions & 40 deletions libs/api-mocks/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*
* Copyright Oxide Computer Company
*/
import { delay } from 'msw'
import { v4 as uuid } from 'uuid'

import {
Expand Down Expand Up @@ -256,10 +257,10 @@ export const handlers = makeHandlers({
return json(newImage, { status: 201 })
},
imageView: ({ path, query }) => lookup.image({ ...path, ...query }),
imageDelete({ path, query, req }) {
imageDelete({ path, query, cookies }) {
// if it's a silo image, you need silo write to delete it
if (!query.project) {
requireRole(req, 'silo', defaultSilo.id, 'collaborator')
requireRole(cookies, 'silo', defaultSilo.id, 'collaborator')
}

const image = lookup.image({ ...path, ...query })
Expand Down Expand Up @@ -532,9 +533,9 @@ export const handlers = makeHandlers({

return json(instance, { status: 202 })
},
instanceSerialConsole(_params) {
// TODO: Add support for params
return json(serial, { delay: 3000 })
async instanceSerialConsole(_params) {
await delay(3000)
return json(serial)
},
instanceStart({ path, query }) {
const instance = lookup.instance({ ...path, ...query })
Expand Down Expand Up @@ -831,14 +832,14 @@ export const handlers = makeHandlers({
const nics = db.networkInterfaces.filter((n) => n.subnet_id === subnet.id)
return paginated(query, nics)
},
sledPhysicalDiskList({ path, query, req }) {
requireFleetViewer(req)
sledPhysicalDiskList({ path, query, cookies }) {
requireFleetViewer(cookies)
const sled = lookup.sled(path)
const disks = db.physicalDisks.filter((n) => n.sled_id === sled.id)
return paginated(query, disks)
},
physicalDiskList({ query, req }) {
requireFleetViewer(req)
physicalDiskList({ query, cookies }) {
requireFleetViewer(cookies)
return paginated(query, db.physicalDisks)
},
policyView() {
Expand Down Expand Up @@ -866,27 +867,27 @@ export const handlers = makeHandlers({

return body
},
rackList: ({ query, req }) => {
requireFleetViewer(req)
rackList: ({ query, cookies }) => {
requireFleetViewer(cookies)
return paginated(query, db.racks)
},
currentUserView({ req }) {
return { ...currentUser(req), silo_name: defaultSilo.name }
currentUserView({ cookies }) {
return { ...currentUser(cookies), silo_name: defaultSilo.name }
},
currentUserGroups({ req }) {
const user = currentUser(req)
currentUserGroups({ cookies }) {
const user = currentUser(cookies)
const memberships = db.groupMemberships.filter((gm) => gm.userId === user.id)
const groupIds = new Set(memberships.map((gm) => gm.groupId))
const groups = db.userGroups.filter((g) => groupIds.has(g.id))
return { items: groups }
},
currentUserSshKeyList({ query, req }) {
const user = currentUser(req)
currentUserSshKeyList({ query, cookies }) {
const user = currentUser(cookies)
const keys = db.sshKeys.filter((k) => k.silo_user_id === user.id)
return paginated(query, keys)
},
currentUserSshKeyCreate({ body, req }) {
const user = currentUser(req)
currentUserSshKeyCreate({ body, cookies }) {
const user = currentUser(cookies)
errIfExists(db.sshKeys, { silo_user_id: user.id, name: body.name })

const newSshKey: Json<Api.SshKey> = {
Expand All @@ -904,16 +905,16 @@ export const handlers = makeHandlers({
db.sshKeys = db.sshKeys.filter((i) => i.id !== sshKey.id)
return 204
},
sledView({ path, req }) {
requireFleetViewer(req)
sledView({ path, cookies }) {
requireFleetViewer(cookies)
return lookup.sled(path)
},
sledList({ query, req }) {
requireFleetViewer(req)
sledList({ query, cookies }) {
requireFleetViewer(cookies)
return paginated(query, db.sleds)
},
sledInstanceList({ query, path, req }) {
requireFleetViewer(req)
sledInstanceList({ query, path, cookies }) {
requireFleetViewer(cookies)
const sled = lookupById(db.sleds, path.sledId)
return paginated(
query,
Expand All @@ -929,12 +930,12 @@ export const handlers = makeHandlers({
})
)
},
siloList({ query, req }) {
requireFleetViewer(req)
siloList({ query, cookies }) {
requireFleetViewer(cookies)
return paginated(query, db.silos)
},
siloCreate({ body, req }) {
requireFleetViewer(req)
siloCreate({ body, cookies }) {
requireFleetViewer(cookies)
errIfExists(db.silos, { name: body.name })
const newSilo: Json<Api.Silo> = {
id: uuid(),
Expand All @@ -945,25 +946,25 @@ export const handlers = makeHandlers({
db.silos.push(newSilo)
return json(newSilo, { status: 201 })
},
siloView({ path, req }) {
requireFleetViewer(req)
siloView({ path, cookies }) {
requireFleetViewer(cookies)
return lookup.silo(path)
},
siloDelete({ path, req }) {
requireFleetViewer(req)
siloDelete({ path, cookies }) {
requireFleetViewer(cookies)
const silo = lookup.silo(path)
db.silos = db.silos.filter((i) => i.id !== silo.id)
return 204
},
siloIdentityProviderList({ query, req }) {
requireFleetViewer(req)
siloIdentityProviderList({ query, cookies }) {
requireFleetViewer(cookies)
const silo = lookup.silo(query)
const idps = db.identityProviders.filter(({ siloId }) => siloId === silo.id).map(toIdp)
return { items: idps }
},

samlIdentityProviderCreate({ query, body, req }) {
requireFleetViewer(req)
samlIdentityProviderCreate({ query, body, cookies }) {
requireFleetViewer(cookies)
const silo = lookup.silo(query)

// this is a bit silly, but errIfExists doesn't handle nested keys like
Expand Down Expand Up @@ -1019,8 +1020,8 @@ export const handlers = makeHandlers({
return paginated(query, db.users)
},

systemPolicyView({ req }) {
requireFleetViewer(req)
systemPolicyView({ cookies }) {
requireFleetViewer(cookies)

const role_assignments = db.roleAssignments
.filter((r) => r.resource_type === 'fleet' && r.resource_id === FLEET_ID)
Expand All @@ -1029,7 +1030,7 @@ export const handlers = makeHandlers({
return { role_assignments }
},
systemMetric(params) {
requireFleetViewer(params.req)
requireFleetViewer(params.cookies)
return handleMetrics(params)
},
siloMetric: handleMetrics,
Expand Down
18 changes: 10 additions & 8 deletions libs/api-mocks/msw/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Copyright Oxide Computer Company
*/
import { differenceInSeconds, subHours } from 'date-fns'
import type { RestRequest } from 'msw'

import {
FLEET_ID,
Expand Down Expand Up @@ -88,9 +87,12 @@ export function getTimestamps() {
return { time_created: now, time_modified: now }
}

export const unavailableErr = json({ error_code: 'ServiceUnavailable' }, { status: 503 })
export const unavailableErr = () =>
json({ error_code: 'ServiceUnavailable' }, { status: 503 })

export const NotImplemented = () => {
// This doesn't just return the response because it broadens the type to be usable
// directly as a handler
throw json({ error_code: 'NotImplemented' }, { status: 501 })
}

Expand Down Expand Up @@ -292,8 +294,8 @@ export const MSW_USER_COOKIE = 'msw-user'
* If cookie is empty or name is not found, return the first user in the list,
* who has admin on everything.
*/
export function currentUser(req: RestRequest): Json<User> {
const name = req.cookies[MSW_USER_COOKIE]
export function currentUser(cookies: Record<string, string>): Json<User> {
const name = cookies[MSW_USER_COOKIE]
return db.users.find((u) => u.display_name === name) ?? db.users[0]
}

Expand Down Expand Up @@ -347,8 +349,8 @@ export function userHasRole(
* fleet roles for the user as well as for the user's groups. Do nothing if yes,
* throw 403 if no.
*/
export function requireFleetViewer(req: RestRequest) {
requireRole(req, 'fleet', FLEET_ID, 'viewer')
export function requireFleetViewer(cookies: Record<string, string>) {
requireRole(cookies, 'fleet', FLEET_ID, 'viewer')
}

/**
Expand All @@ -357,12 +359,12 @@ export function requireFleetViewer(req: RestRequest) {
* if no.
*/
export function requireRole(
req: RestRequest,
cookies: Record<string, string>,
resourceType: DbRoleAssignmentResourceType,
resourceId: string,
role: RoleKey
) {
const user = currentUser(req)
const user = currentUser(cookies)
// should it 404? I think the API is a mix
if (!userHasRole(user, resourceType, resourceId, role)) throw 403
}
1 change: 1 addition & 0 deletions libs/api/__generated__/Api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions libs/api/__generated__/http-client.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading