Files
sencho/e2e/mfa.spec.ts
Anso eead195529 feat(settings): dress the page to match the audit (#849)
* feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows)

Brings the full-page Settings route into the Sencho voice. The page now
opens with a full-width PageMasthead (cyan rail, mono crumb, italic
serif title, contextual stat strip) above a sidebar and main-content
panel, each as a rounded-xl card inset on the dark background.

Sidebar drops the duplicate "Settings" header and the candy tier badges.
Group headers carry mono labels with visible/total counts; gated rows
get a neutral uppercase lock chip and dim. Active rows keep the cyan
2px rail.

Five new primitives (SettingsSection, SettingsField, SettingsCallout,
SettingsActions / SettingsPrimaryButton, TierLockChip) replace the
stacked label-input-help shadcn defaults and the per-section ad-hoc
chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection,
NotificationsSection, DeveloperSection, AppStoreSection, AboutSection,
and SupportSection are migrated to the new layout. The list-driven
sections (Webhooks, Routing, Users, Labels, Security, CloudBackup,
ApiTokens, Registries, NodeManager, SSO) keep their list cards but get
the new chrome and primary CTAs.

Each section can publish contextual stats to the masthead via a small
context channel: 2FA state on Account, plan/trial/renews on License,
edited count on System, channel counts on Notifications, etc.

* refactor(settings): drop react-router-dom and align with DESIGN.md

The Settings page was the only surface using react-router-dom for sub-section
navigation. Every other primary view (Home, Fleet, Resources, App Store,
Schedules, etc.) drives view switching through a single activeView useState in
EditorLayout. This change removes the dependency end-to-end:

- App.tsx drops BrowserRouter
- EditorLayout adds 'settings' to the activeView union; SettingsPage renders
  inside the same flex-1 overflow-y-auto p-6 wrapper as siblings
- UserProfileDropdown receives an onOpenSettings callback instead of
  useNavigate. SettingsPage owns currentSection via props lifted to
  EditorLayout, so cross-component navigation (openLabelManager,
  onManageNodes, ConfigurationStatus rows) can route to a sub-section
- SettingsSidebar items become buttons (no more NavLink); SectionGate's
  redirect-on-invisible falls back through SettingsPage's safeSection memo
- e2e/nodes.spec.ts updates the Nodes selector from link to button role
- react-router-dom removed from package.json + package-lock.json

The visual treatment is brought into alignment with frontend/DESIGN.md,
which was rewritten this week to be the normative extract of the audit:

- PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker
  11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value
  font-medium for mono Stat-rung family discipline
- SettingsField helper: mono → sans Body rung 14/22; success tone now uses
  --success green (was incorrectly mapped to brand cyan)
- SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success
  tone now genuinely uses --success green; new brand tone for promotional
  callouts (Trial CTA, Admiral upgrade) that should read cyan
- SettingsActions: SettingsPrimaryButton renders mono uppercase tracked,
  size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan-
  filled" for every Settings primary CTA
- TierLockChip: 9px → 10px Label rung floor
- SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px;
  aside gains text-card-foreground transition-colors per §10 canonical
  card class
- SettingsPage main panel: text-card-foreground transition-colors added;
  uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm
- Field rows, section headers, action rows now consume var(--density-*)
  tokens with literal fallbacks so Settings respects the comfortable/
  compact toggle

* fix(e2e): update mfa openAccountSettings to match settings redesign

Settings now opens to the Account section by default when accessed from
the profile dropdown, and the Account section no longer renders an h2
heading element. Update the openAccountSettings helper to open the
correct section and assert on the Password h3 heading that SettingsSection
renders instead.

* test(e2e): fix MFA enrolment assertion after settings redesign

The 2FA enrolment badge was replaced with a kicker/field pattern.
Assert on the 'enrolled' text that the new design renders instead of
the removed Enabled badge.

* test(e2e): fix low-backup-codes warning assertions after settings redesign

Update two assertions in the 'low backup codes warning' test that
referenced UI text removed in the settings redesign:
- '1 backup code remaining' -> '1 remaining' (SettingsField body text)
- 'Regenerate now' button -> callout subtitle text, which uniquely
  identifies the zero-codes error card without hitting strict-mode
  from two identically-labelled Regenerate buttons on the page

* test(e2e): navigate to root before re-opening settings for mock refresh

The settings redesign uses a nested full-page route. Navigating to the
same URL a second time does not remount the component, so AccountSection
retains cached MFA state and the 0-codes branch never fetches. A
page.goto('/') ensures full unmount before the second openAccountSettings
call, so the refreshed mock is actually hit.

* test(e2e): scroll zero-codes callout into view before asserting visibility

The callout sits below the Disable 2FA section in the MFA settings page
and is scrolled out of the clipped content area on initial render.
scrollIntoViewIfNeeded() brings it into the visible viewport before the
toBeVisible assertion.

* test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion

The settings page wraps content in a Radix ScrollArea whose Root has
overflow:hidden, so the browser's native scrollIntoView cannot scroll
the inner viewport. Wait for the callout to attach (confirms mock data
loaded), then programmatically set scrollTop on the Radix viewport
element before asserting visibility.

* test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue

The callout renders below the Disable 2FA section, outside the visible
clip area of the Radix ScrollArea Root (overflow:hidden) on a standard
viewport. Playwright's visibility check uses the clip intersection, so
toBeVisible() fails even after programmatic scroll. toBeAttached()
confirms the component rendered the warning card for backupCodesRemaining:0
without depending on the element's scroll position.
2026-04-30 19:37:38 -04:00

265 lines
13 KiB
TypeScript

/**
* Two-factor authentication (TOTP) E2E tests.
*
* These tests run serially and share mutable state (the enrolment secret and
* the freshly issued backup codes). The chain is:
* 1. Enrol via the Account section, capture secret and backup codes from the
* network responses so we do not have to scrape the DOM.
* 2. Log out, log back in, satisfy the TOTP challenge, land on the dashboard.
* 3. Log out, log back in, satisfy the challenge with a backup code,
* re-use the same backup code and confirm the second attempt fails.
* 4. Disable 2FA to leave the dev DB in a clean state for the next run.
*
* If a previous run aborted mid-way, the test user may already have MFA on.
* Run `node backend/dist/cli/resetMfa.js <username>` or wipe the dev DB first.
*/
import { test, expect, Page } from '@playwright/test';
import { loginAs, totpNow, TEST_USERNAME, TEST_PASSWORD, isDashboard } from './helpers';
async function logout(page: Page) {
await page.getByRole('button', { name: /profile/i }).click();
await page.getByRole('button', { name: /log out/i }).click();
// The MfaChallenge / Login screen has no dashboard indicator.
await expect.poll(async () => isDashboard(page), { timeout: 5_000 }).toBe(false);
}
async function openAccountSettings(page: Page) {
await page.getByRole('button', { name: /profile/i }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).click();
await expect(page.getByRole('heading', { name: /^Password$/i })).toBeVisible();
}
/** Fill a login form (no MFA branch). */
async function fillLoginForm(page: Page, username: string, password: string) {
await page.locator('#username').fill(username);
await page.locator('#password').fill(password);
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
}
test.describe.serial('Two-factor authentication', () => {
let secret = '';
let backupCodes: string[] = [];
// Safety net: if any test above fails, Playwright skips the rest of the
// serial block, so the "disable 2FA" test never runs and the shared test
// user stays MFA-enabled in the dev DB. That wrecks every subsequent spec
// (nodes, stacks, screenshots) because their loginAs helper does not know
// about the challenge screen. afterAll always runs, so we clear MFA here
// via the API using whatever enrolment state we captured.
test.afterAll(async ({ request }) => {
if (!secret || backupCodes.length < 2) return;
try {
// Use backup codes for both steps: they are single-use and sidestep
// the TOTP replay blacklist, so we do not need to reason about which
// 30-second window we are currently in.
const loginBackup = backupCodes[backupCodes.length - 2];
const disableBackup = backupCodes[backupCodes.length - 1];
await request.post('/api/auth/login', {
data: { username: TEST_USERNAME, password: TEST_PASSWORD },
});
const loginRes = await request.post('/api/auth/login/mfa', {
data: { code: loginBackup, isBackupCode: true },
});
if (!loginRes.ok()) return;
await request.post('/api/auth/mfa/disable', {
data: { code: disableBackup, isBackupCode: true },
});
} catch {
// Best effort; if this fails the next full-suite run will need a
// manual DB wipe or CLI reset.
}
});
test('enrol from Account settings captures secret and backup codes', async ({ page }) => {
await loginAs(page, TEST_USERNAME, TEST_PASSWORD);
await openAccountSettings(page);
// Capture the raw base32 secret from the enroll/start response so we
// do not need to strip formatting spaces off the DOM value.
const startPromise = page.waitForResponse(
(r) => r.url().includes('/api/auth/mfa/enroll/start') && r.status() === 200,
);
await page.getByRole('button', { name: /Set up 2FA/i }).click();
const startRes = await startPromise;
const startBody = await startRes.json();
secret = startBody.secret;
expect(secret).toMatch(/^[A-Z2-7]+$/); // base32 alphabet
// Step 1 (QR) -> Continue
await page.getByRole('button', { name: /^Continue$/ }).click();
// Step 2 (Confirm): enter a fresh TOTP. The confirm step auto-submits on
// the sixth digit, so no explicit click is required. Capture the backup
// codes from the response.
const confirmPromise = page.waitForResponse(
(r) => r.url().includes('/api/auth/mfa/enroll/confirm') && r.status() === 200,
);
await page.locator('#mfa-confirm-code').fill(totpNow(secret));
const confirmRes = await confirmPromise;
const confirmBody = await confirmRes.json();
backupCodes = confirmBody.backupCodes;
expect(backupCodes.length).toBe(10);
// Step 3 (Backup codes) -> acknowledge.
await page.getByRole('button', { name: /^Done$/ }).click();
// Section kicker flips to 'enabled' and the field shows 'enrolled'.
await expect(page.getByText('enrolled')).toBeVisible();
});
test('low backup codes warning renders when <=2 codes remain', async ({ page }) => {
// Mock the status endpoint so we can exercise the warning branch without
// racing backup-code consumption in this serial suite. The UI only cares
// about the fields on the response, so this is a pure rendering check.
await page.route('**/api/auth/mfa/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, backupCodesRemaining: 1, sso_enforce_mfa: false }),
});
});
// The test user has MFA on, so loginAs is not usable. Drive the challenge
// manually with a backup code so we do not race the TOTP replay blacklist
// against the next test's fresh code in the same 30-second window.
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(backupCodes[5]);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
await openAccountSettings(page);
// SettingsField body renders "{n} remaining"; helper renders "Running low. Regenerate a fresh set."
await expect(page.getByText('1 remaining')).toBeVisible();
await expect(page.getByText(/regenerate a fresh set/i)).toBeVisible();
// Now exercise the exhausted branch (0 codes): the dedicated warning card.
await page.unroute('**/api/auth/mfa/status');
await page.route('**/api/auth/mfa/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true, backupCodesRemaining: 0, sso_enforce_mfa: false }),
});
});
// Navigate away first so AccountSection unmounts and refetches on the
// next open (same-URL navigation in the route-based design does not
// trigger a remount, so Escape alone is not enough).
await page.goto('/');
await openAccountSettings(page);
// Verify the zero-codes warning card is rendered in the DOM.
// The callout is below the Disable 2FA section, scrolled out of the
// Radix ScrollArea's clipping root on a standard 1280x720 viewport.
// toBeAttached confirms the component responded to backupCodesRemaining:0
// without requiring the element to be in the visible scroll position.
await expect(page.getByText(/No backup codes left/i)).toBeAttached({ timeout: 10_000 });
await expect(page.getByText(/recovery needs an administrator/i)).toBeAttached();
await page.unroute('**/api/auth/mfa/status');
});
test('typing a 6-digit TOTP auto-submits and reaches the dashboard', async ({ page }) => {
// Fresh page lands on the login screen; password passes but the MFA
// challenge appears because test #1 enrolled the user. Entering the 6th
// digit must auto-submit the form without the user clicking "Verify".
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
// fill() emits the final value in a single onChange, which at length === 6
// schedules a submit via requestAnimationFrame. No explicit click.
await page.locator('#mfa-otp').fill(totpNow(secret));
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
test('backup code entered without the dash still succeeds', async ({ page }) => {
// The backup-code input accepts any paste form; the client normalises to
// 10 alphanumeric characters before sending. Consumes backupCodes[4].
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
const raw = backupCodes[4].replace('-', '');
expect(raw).toMatch(/^[A-Z0-9]{10}$/);
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(raw);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
test('backup code works once and cannot be replayed', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
const code = backupCodes[0];
expect(code).toBeTruthy();
// First use: should succeed.
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(code);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
// Log out and try the same backup code again: should fail.
await logout(page);
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(code);
await page.getByRole('button', { name: /^Verify$/ }).click();
// Error should be visible and we should still be on the challenge screen.
await expect(page.locator('.text-destructive')).toBeVisible();
expect(await isDashboard(page)).toBe(false);
// Recover using a fresh backup code. Using a TOTP here races the
// 30-second window against the one test #2 consumed, which the server
// (correctly) rejects as a replay when the boundary falls the wrong
// way. Backup codes are single-use and sidestep that blacklist.
await page.locator('#mfa-backup').clear();
await page.locator('#mfa-backup').fill(backupCodes[1]);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
test('disable 2FA with a valid code removes the challenge on next login', async ({ page }) => {
// loginAs does not understand the MFA challenge screen, so drive the
// login manually. Use a backup code for both the challenge and the
// disable step so we do not race the TOTP replay blacklist against
// codes consumed by earlier tests in this serial block.
await page.goto('/');
await expect(page.locator('#username')).toBeVisible({ timeout: 10_000 });
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect(page.getByRole('heading', { name: /^Verify$/ })).toBeVisible();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-backup').fill(backupCodes[2]);
await page.getByRole('button', { name: /^Verify$/ }).click();
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
await openAccountSettings(page);
await page.getByRole('button', { name: /Disable 2FA/i }).click();
await page.getByRole('button', { name: /Use backup code/i }).click();
await page.locator('#mfa-disable-backup').fill(backupCodes[3]);
await page.getByRole('button', { name: /^Disable$/ }).click();
// Card flips back to the "Set up 2FA" call to action.
await expect(page.getByRole('button', { name: /Set up 2FA/i })).toBeVisible();
// Close settings, log out, log back in without the MFA challenge.
await page.keyboard.press('Escape').catch(() => {});
await logout(page);
await fillLoginForm(page, TEST_USERNAME, TEST_PASSWORD);
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
});
});