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
24 changes: 20 additions & 4 deletions app/pages/system/UpdatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,26 @@ export default function UpdatePage() {
}),
modalTitle: 'Set target release',
modalContent: (
<p>
Are you sure you want to set <HL>{repo.systemVersion}</HL>{' '}
as the target release?
</p>
<div className="space-y-4">
{status.contactSupport && (
<Message
variant="notice"
// title="Support required"
content={
<>
The system has detected known conditions that
require Oxide support to resolve. Starting an update
before talking to support is{' '}
<HL>strongly discouraged</HL>.
</>
}
/>
)}
<p>
Are you sure you want to set <HL>{repo.systemVersion}</HL>{' '}
as the target release?
</p>
</div>
),
errorTitle: `Error setting target release to ${repo.systemVersion}`,
})
Expand Down
6 changes: 5 additions & 1 deletion mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
internalError,
invalidRequest,
ipRangeLen,
mockFlags,
NotImplemented,
paginated,
randomHex,
Expand Down Expand Up @@ -2121,7 +2122,10 @@ export const handlers = makeHandlers({
},
systemUpdateStatus: ({ cookies }) => {
requireFleetViewer(cookies)
return db.updateStatus
return {
...db.updateStatus,
contact_support: db.updateStatus.contact_support || mockFlags(cookies).contactSupport,
}
},
targetReleaseUpdate: ({ body, cookies }) => {
requireFleetAdmin(cookies)
Expand Down
25 changes: 25 additions & 0 deletions mock-api/msw/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { differenceInSeconds, subHours } from 'date-fns'
// Works without the .js for dev server and prod build in MSW mode, but
// playwright wants the .js. No idea why, let's just add the .js.
import { IPv4, IPv6 } from 'ip-num/IPNumber.js'
import * as R from 'remeda'
import { match } from 'ts-pattern'

import {
Expand Down Expand Up @@ -334,6 +335,30 @@ export function handleMetrics({ path: { metricName }, query }: MetricParams) {
}

export const MSW_USER_COOKIE = 'msw-user'
export const MSW_FLAGS_COOKIE = 'msw-flags'

/**
* Test-only fleet-state overrides, serialized into the `msw-flags` cookie as a
* comma-separated list of the enabled keys. Some server-computed signals (e.g.
* update status's `contact_support`) have no operator UI to flip, so there's no
* user-controlled request input to drive them through the real UI. Rather than
* reach for `page.route`, a test enables a flag and the relevant handler ORs it
* in. Inert in normal use (cookie unset), and reproducible in the dev server
* via `document.cookie = 'msw-flags=contactSupport'`.
*
* This array is the single source of truth for valid flag names; both the e2e
* helper that sets the cookie and `mockFlags` that reads it derive their types
* from it, so a typo in a handler or test is a type error.
*/
export const MOCK_FLAGS = [
'contactSupport', // db.updateStatus.contact_support = true
] as const
export type MockFlag = (typeof MOCK_FLAGS)[number]

export function mockFlags(cookies: Record<string, string>): Record<MockFlag, boolean> {
const present = (cookies[MSW_FLAGS_COOKIE] ?? '').split(',')
return R.fromKeys(MOCK_FLAGS, (flag) => present.includes(flag))
}

/**
* Look up user by display name in cookie. If cookie is empty, return the first
Expand Down
5 changes: 4 additions & 1 deletion mock-api/system-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ export const updateStatus: Json<UpdateStatus> = {
'17.0.0': 12,
'16.0.0': 5,
},
contact_support: true,
// Default to false so the normal "set target release" confirmation is the
// default path. The scary "support required" path is exercised in e2e via the
// contactSupport mock flag.
contact_support: false,
suspended: false,
target_release: {
version: '17.0.0',
Expand Down
26 changes: 26 additions & 0 deletions test/e2e/system-update.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ test('Set target release', async ({ page }) => {
await expect(
modal.getByText('Are you sure you want to set 18.0.0 as the target release?')
).toBeVisible()
// no support-required warning when contact_support is false
await expect(modal.getByText('strongly discouraged')).toBeHidden()

await page.getByRole('button', { name: 'Confirm' }).click()

Expand Down Expand Up @@ -112,6 +114,30 @@ test('Cannot downgrade to older release', async ({ page }) => {
await expect(release16.getByText('Target')).toBeHidden()
})

test('Support required warning in set target confirmation', async ({ browser }) => {
// The contact-support flag makes systemUpdateStatus report support is needed
// (see mockFlags). Hannah Arendt is a fleet admin, so she can also open the
// set-target confirmation.
const page = await getPageAsUser(browser, 'Hannah Arendt', ['contactSupport'])
await page.goto('/system/update')

// the support-required banner is shown on the page
await expect(page.getByText('Support required')).toBeVisible()

// opening the set-target confirmation surfaces the strong warning
await page.getByRole('button', { name: '18.0.0 actions' }).click()
await page.getByRole('menuitem', { name: 'Set as target release' }).click()

const modal = page.getByRole('dialog', { name: 'Set target release' })
await expect(modal).toBeVisible()
await expect(modal.getByText(/require Oxide support to resolve/)).toBeVisible()
await expect(modal.getByText('strongly discouraged')).toBeVisible()

// dismiss without setting the target
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(modal).toBeHidden()
})

test('Fleet viewer cannot set target release', async ({ browser }) => {
const page = await getPageAsUser(browser, 'Jane Austen')
await page.goto('/system/update')
Expand Down
21 changes: 16 additions & 5 deletions test/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { expect, test, type Browser, type Locator, type Page } from '@playwright

import { MiB } from '~/util/units'

import { MSW_USER_COOKIE } from '../../mock-api/msw/util'
import { MSW_FLAGS_COOKIE, MSW_USER_COOKIE, type MockFlag } from '../../mock-api/msw/util'

export * from '@playwright/test'

Expand Down Expand Up @@ -221,11 +221,22 @@ export async function selectOption(
}
}

export async function getPageAsUser(browser: Browser, user: string): Promise<Page> {
function cookie(name: string, value: string) {
return { name, value, domain: 'localhost', path: '/' }
}

export async function getPageAsUser(
browser: Browser,
user: string,
// fleet-level overrides; see mockFlags in mock-api/msw/util.ts
flags: MockFlag[] = []
): Promise<Page> {
const browserContext = await browser.newContext()
await browserContext.addCookies([
{ name: MSW_USER_COOKIE, value: user, domain: 'localhost', path: '/' },
])
const cookies = [cookie(MSW_USER_COOKIE, user)]
if (flags.length) {
cookies.push(cookie(MSW_FLAGS_COOKIE, flags.join(',')))
}
await browserContext.addCookies(cookies)
return await browserContext.newPage()
}

Expand Down
Loading