diff --git a/app/pages/system/UpdatePage.tsx b/app/pages/system/UpdatePage.tsx index f3dccf67d..c5f490909 100644 --- a/app/pages/system/UpdatePage.tsx +++ b/app/pages/system/UpdatePage.tsx @@ -280,10 +280,26 @@ export default function UpdatePage() { }), modalTitle: 'Set target release', modalContent: ( -

- Are you sure you want to set {repo.systemVersion}{' '} - as the target release? -

+
+ {status.contactSupport && ( + + The system has detected known conditions that + require Oxide support to resolve. Starting an update + before talking to support is{' '} + strongly discouraged. + + } + /> + )} +

+ Are you sure you want to set {repo.systemVersion}{' '} + as the target release? +

+
), errorTitle: `Error setting target release to ${repo.systemVersion}`, }) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index d5515b895..170d96b3b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -58,6 +58,7 @@ import { internalError, invalidRequest, ipRangeLen, + mockFlags, NotImplemented, paginated, randomHex, @@ -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) diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index f3f88a2f1..cca972ede 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -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 { @@ -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): Record { + 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 diff --git a/mock-api/system-update.ts b/mock-api/system-update.ts index fd127164a..ec88544c2 100644 --- a/mock-api/system-update.ts +++ b/mock-api/system-update.ts @@ -36,7 +36,10 @@ export const updateStatus: Json = { '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', diff --git a/test/e2e/system-update.e2e.ts b/test/e2e/system-update.e2e.ts index b68f1ddd0..66fb3222d 100644 --- a/test/e2e/system-update.e2e.ts +++ b/test/e2e/system-update.e2e.ts @@ -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() @@ -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') diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index 0ee45106a..a0d6edfe5 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -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' @@ -221,11 +221,22 @@ export async function selectOption( } } -export async function getPageAsUser(browser: Browser, user: string): Promise { +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 { 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() }