Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/lintBuildTest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ jobs:
run: npm run test:browser -- run --browser.name=${{ matrix.vitest }}
- name: Run Playwright browser tests
run: npx playwright test --project=${{ matrix.playwright }}
- name: Run Playwright tests against production build
run: npm run e2e:preview -- --project=${{ matrix.playwright }}
- uses: actions/upload-artifact@v7
if: always()
with:
Expand Down
2 changes: 1 addition & 1 deletion OMICRON_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7e18e523687767bb8c75070945ac398a5b89d9fd
6d241a7d21d11cb7979122bde9a906d8d4837d09
2 changes: 1 addition & 1 deletion app/api/__generated__/OMICRON_VERSION

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

30 changes: 30 additions & 0 deletions app/api/__generated__/nexus-console.ts

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

15 changes: 13 additions & 2 deletions app/api/__tests__/safety.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import path from 'path'

import { expect, it } from 'vitest'

import vercelConfig from '../../../vercel.json'
import viteConfigFn from '../../../vite.config'
import { nexusCsp } from '../__generated__/nexus-console'

it('Generated API client version matches API version specified for deployment', () => {
const generatedVersion = fs
Expand Down Expand Up @@ -57,6 +59,15 @@ it('vite build target matches tsconfig target', () => {
expect(viteConfig.build?.target).toEqual(tsconfig.compilerOptions.target)
})

// Vercel preview deploys should run under the same CSP as Nexus. If this fails
// after a pin bump, copy the generated policy into vercel.json.
it('vercel.json CSP matches Nexus', () => {
const vercelCsp = vercelConfig.headers[0].headers.find(
(h) => h.key === 'content-security-policy'
)?.value
expect(vercelCsp).toEqual(nexusCsp)
})

const grepFiles = (s: string) =>
execSync(`git grep -l "${s}"`)
.toString()
Expand Down Expand Up @@ -110,9 +121,9 @@ const listFiles = (s: string) =>
execSync(`git ls-files | grep "${s}"`).toString().trim().split('\n')

// avoid accidentally making an e2e file in the wrong place
it('e2e tests are only in test/e2e or test/visual', () => {
it('e2e tests are only in test/e2e, test/preview, or test/visual', () => {
for (const file of listFiles('\\.e2e\\.')) {
expect(file).toMatch(/^test\/(e2e|visual)/)
expect(file).toMatch(/^test\/(e2e|preview|visual)/)
}
})

Expand Down
96 changes: 95 additions & 1 deletion app/util/path-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
*
* Copyright Oxide Computer Company
*/
import { matchRoutes } from 'react-router'
import { matchRoutes, type RouteObject } from 'react-router'
import * as R from 'remeda'
import { expect, test } from 'vitest'

import { nexusConsoleRoutes } from '~/api/__generated__/nexus-console'
import { matchesToCrumbs } from '~/hooks/use-crumbs'
import { routes } from '~/routes'

Expand Down Expand Up @@ -224,3 +225,96 @@ test('every page reachable by breadcrumb should have a self-referential breadcru
expect(dropFinalSlash(path)).toEqual(dropFinalSlash(last.path))
}
})

// Full path of every leaf route, with optional segments expanded both ways.
// Only leaves are pages: a route with children renders at its own path only
// through an index child, which is itself a leaf.
function routePaths(routes: RouteObject[], parent = ''): string[] {
return routes.flatMap((route) => {
const full = route.path?.startsWith('/')
? route.path
: [parent, route.path].filter(Boolean).join('/')
if (route.children) return routePaths(route.children, full)
return expandOptional(full.split('/').filter(Boolean)).map((s) => '/' + s.join('/'))
})
}

const expandOptional = (segments: string[]): string[][] =>
segments.reduce<string[][]>(
(acc, seg) =>
seg.endsWith('?')
? [...acc, ...acc.map((prefix) => [...prefix, seg.slice(0, -1)])]
: acc.map((prefix) => [...prefix, seg]),
[[]]
)

// Port of dropshot's lookup_route for a single template. Dropshot splits paths
// on `/` and drops empty segments, then walks a trie: `{name}` matches one
// segment and `{name:.*}` matches the rest, including nothing, so
// /projects/{path:.*} serves /projects. Checking templates one at a time gives
// the same answer as the trie because dropshot refuses to register a node with
// both literal and variable children, so a path matches at most one route.
// https://github.com/oxidecomputer/dropshot/blob/4ff9cb3/dropshot/src/router.rs#L438-L503
// https://github.com/oxidecomputer/dropshot/blob/4ff9cb3/dropshot/src/router.rs#L254-L270
function nexusServes(template: string, pathname: string) {
const want = template.split('/').filter(Boolean)
const have = pathname.split('/').filter(Boolean)
for (const [i, seg] of want.entries()) {
if (/^\{\w+:\.\*\}$/.test(seg)) return true
if (i >= have.length) return false
// a route param like :project only matches a Nexus param, not a literal
if (!/^\{\w+\}$/.test(seg) && seg !== have[i]) return false
}
return want.length === have.length
}

// The Nexus test below is only as good as nexusServes, so check that it matches
// paths the same way dropshot does. These are the cases from dropshot's router
// tests, plus the empty wildcard match, which dropshot doesn't test but the
// console relies on for paths like /projects.
// https://github.com/oxidecomputer/dropshot/blob/4ff9cb3/dropshot/src/router.rs#L1242-L1643
test.each([
['/', '/', true],
['/', '//', true],
['/foo', '/foo', true],
['/foo', '/foo/', true],
['/foo', '//foo//', true],
['/foo', '/', false],
['/not{a}variable', '/not{a}variable', true],
['/not{a}variable', '/not{b}variable', false],
['/projects/{project_id}', '/projects', false],
['/projects/{project_id}', '/projects/', false],
['/projects/{project_id}', '/projects/p12345', true],
['/projects/{project_id}', '/projects/p12345/', true],
['/projects/{project_id}', '/projects///p12345//', true],
['/projects/{project_id}', '/projects/p12345/child', false],
[
'/projects/{project_id}/instances/{instance_id}/fwrules/{fwrule_id}/info',
'/projects/p1/instances/i2/fwrules/fw3/info',
true,
],
['/projects/{project_id}/instances', '/projects/instances', false],
['/projects/{project_id}/instances', '/projects//instances', false],
['/projects/{project_id}/instances', '/projects/foo/instances', true],
['/console/{path:.*}', '/console/missiles/launch', true],
['/console/{path:.*}', '/console', true],
['/console/{path:.*}', '/', false],
// console route params only match Nexus params, not literals
['/projects/{project_id}', '/projects/:project', true],
['/projects/new', '/projects/:project', false],
])('nexusServes(%s, %s) is %s', (template, pathname, expected) => {
expect(nexusServes(template, pathname)).toBe(expected)
})

// Nexus only serves index.html on paths it knows about, so a console route
// outside them works on client-side navigation but 404s on reload or direct
// link. If this fails, add an endpoint for the new path in omicron next to the
// existing ones, then bump OMICRON_VERSION once it merges.
// https://github.com/oxidecomputer/omicron/blob/7e18e52/nexus/external-api/src/lib.rs#L9206
test('Nexus serves the console on every route', () => {
const unserved = R.unique(routePaths(routes))
// the catch-all is for unknown paths, which Nexus is right to 404
.filter((p) => !p.split('/').includes('*'))
.filter((p) => !nexusConsoleRoutes.some((t) => nexusServes(t, p)))
expect(unserved).toEqual([])
})
2 changes: 1 addition & 1 deletion docs/update-pinned-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Instructions

1. Update [`OMICRON_VERSION`](/OMICRON_VERSION) with new Omicron commit hash
1. Update the generated API client by running `npm run gen-api`. This will automatically check out the omicron commit specified in `OMICRON_VERSION`. It also snapshots timeseries descriptions and units from omicron's oximeter schema files, which the API doesn't return. If you forget this step, a safety test in `app/api` will fail.
1. Update the generated API client by running `npm run gen-api`. This will automatically check out the omicron commit specified in `OMICRON_VERSION`. It also snapshots timeseries descriptions and units from omicron's oximeter schema files, which the API doesn't return, and the paths Nexus serves the console on, which a test checks against the console's routes, and the Content-Security-Policy Nexus serves the console with, which the dev and preview servers use. If you forget this step, a safety test in `app/api` will fail.
1. Run `npm run tsc` and fix any type errors introduced by changes to the generated code. New endpoints must be added to the handler map in `mock-api/msw/handlers.ts`; use `NotImplemented` unless the UI needs them.
1. Commit and push to a branch

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"update-snapshots": "npm test run -- -u",
"e2e": "playwright test",
"e2ec": "playwright test --project=chrome",
"e2e:preview": "playwright test --config playwright.preview.config.ts",
"visual:baseline": "./tools/generate-visual-baseline.sh",
"visual:compare": "playwright test -c playwright.visual.config.ts",
"lint": "oxlint --type-aware",
Expand Down
27 changes: 27 additions & 0 deletions playwright.preview.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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 type { PlaywrightTestConfig } from '@playwright/test'

import baseConfig from './playwright.config'

/**
* Run test/preview against the production build served under the Nexus CSP.
* The main e2e suite runs against the dev server, which allows inline scripts
* by nonce, so it can't catch scripts the build leaves inline.
*/
export default {
...baseConfig,
testDir: './test/preview',
use: { ...baseConfig.use, baseURL: 'http://localhost:4010' },
webServer: {
command: 'npm run preview -- --port 4010 --strictPort',
port: 4010,
// includes a production build
timeout: 180_000,
},
} satisfies PlaywrightTestConfig
45 changes: 45 additions & 0 deletions test/preview/csp.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* 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 { expect, test, type Page } from '@playwright/test'

import { nexusCsp } from '../../app/api/__generated__/nexus-console'

/** Collect page errors, console errors, and CSP violations */
async function trackErrors(page: Page) {
const errors: string[] = []
page.on('pageerror', (error) => errors.push(error.message))
page.on('console', (message) => {
if (message.type() === 'error') errors.push(message.text())
})
// not every browser logs violations to the console
await page.addInitScript(() => {
document.addEventListener('securitypolicyviolation', (event) => {
console.error(`CSP violation: ${event.violatedDirective} ${event.blockedURI}`)
})
})
return errors
}

test('Production build runs under the Nexus CSP', async ({ page }) => {
const errors = await trackErrors(page)
try {
const response = await page.goto('/projects/mock-project/instances')
// make sure the test isn't passing because the server dropped the CSP
expect(response?.headers()['content-security-policy']).toBe(nexusCsp)

await expect(page.getByRole('heading', { name: 'Instances' })).toBeVisible()
// client-side navigation only works if the app hydrated
await page.getByRole('link', { name: 'db1' }).click()
await expect(page.getByRole('heading', { name: 'db1' })).toBeVisible()
await expect(page.getByRole('cell', { name: 'disk-1' })).toBeVisible()
} finally {
// a CSP violation usually means the page never renders, so report the
// errors instead of a missing heading
expect(errors).toEqual([])
}
})
Loading
Loading