mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
0bf061a745
* feat(settings): group sections, add ⌘K search, scope breadcrumb Restructures the Settings Hub sidebar into four labelled groups (Identity, System, Alerts, Advanced), adds a ⌘K command palette for section search, and surfaces the active scope (global vs node-scoped) in the content breadcrumb. - New `settings/registry.ts` centralises group/item metadata, tier gates, glyph assignments, visibility rules, and keyword hints consumed by both the sidebar and the command palette - Cyan 2px left rail + gradient on the active sidebar item; mono-uppercase group headers; tier chips inline for locked items - Scoped ⌘K handler via onKeyDownCapture on DialogContent so the hub no longer hijacks the global sidebar shortcut while open - ScrollArea gains an opt-in `block` prop so the Nodes management table can overflow horizontally without Radix's default `display: table` wrapper clipping action buttons - Docs reference updated with the grouped sidebar, scope breadcrumb, and ⌘K walkthrough plus refreshed screenshots * refactor(settings): drop duplicate section headers, redesign system limits, always-visible tier chips - Remove redundant section titles in every settings page; the dialog header now owns the title and description - Rework System Limits into a compact row panel with inline-edit chips (warn state, focus ring) and an ON/OFF toggle pill - Show tier chips on sidebar and command palette whether locked or unlocked, so Skipper/Admiral scope is always legible - Keep right-aligned action buttons on pages that had a title+button header (Users, Labels, Nodes, API Tokens, Registries) * fix(settings): seed NumberChip draft on edit instead of via effect ESLint rule react-hooks/set-state-in-effect flagged the sync effect that mirrored the external value into local draft state. Replace it with a startEdit handler that seeds draft from value at click time, so the button path always reads value directly and no cascading render is triggered on prop change. * fix(settings): restore heading role and clean sidebar accessible names - Wrap the settings dialog title in an h2 so screen readers and E2E locators see a heading again after the in-section headers were removed - Mark the sidebar glyph aria-hidden so the button's accessible name is just the item label (fixes anchored name matchers) - Align the MFA E2E helper with the renamed Account section heading
257 lines
12 KiB
TypeScript
257 lines
12 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/i }).click();
|
|
await expect(page.getByRole('heading', { name: /^Account$/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) -> Next
|
|
await page.getByRole('button', { name: /^Next$/ }).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: /saved these/i }).click();
|
|
|
|
// Card now shows the Enabled badge.
|
|
await expect(page.getByText(/^Enabled$/)).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: /Two-factor authentication/i })).toBeVisible();
|
|
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
|
|
await page.locator('#mfa-code').fill(backupCodes[5]);
|
|
await page.getByRole('button', { name: /Verify and sign in/i }).click();
|
|
await expect.poll(async () => isDashboard(page), { timeout: 10_000 }).toBe(true);
|
|
|
|
await openAccountSettings(page);
|
|
await expect(page.getByText(/1 backup code remaining/i)).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 }),
|
|
});
|
|
});
|
|
|
|
// Re-open the account section so it refetches status with the new mock.
|
|
await page.keyboard.press('Escape').catch(() => {});
|
|
await openAccountSettings(page);
|
|
await expect(page.getByText(/No backup codes left/i)).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /Regenerate now/i })).toBeVisible();
|
|
|
|
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: /Two-factor authentication/i })).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-code').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: /Two-factor authentication/i })).toBeVisible();
|
|
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
|
|
await page.locator('#mfa-code').fill(raw);
|
|
await page.getByRole('button', { name: /Verify and sign in/i }).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: /Two-factor authentication/i })).toBeVisible();
|
|
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
|
|
await page.locator('#mfa-code').fill(code);
|
|
await page.getByRole('button', { name: /Verify and sign in/i }).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: /Two-factor authentication/i })).toBeVisible();
|
|
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
|
|
await page.locator('#mfa-code').fill(code);
|
|
await page.getByRole('button', { name: /Verify and sign in/i }).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-code').clear();
|
|
await page.locator('#mfa-code').fill(backupCodes[1]);
|
|
await page.getByRole('button', { name: /Verify and sign in/i }).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: /Two-factor authentication/i })).toBeVisible();
|
|
await page.getByRole('button', { name: /Use a backup code instead/i }).click();
|
|
await page.locator('#mfa-code').fill(backupCodes[2]);
|
|
await page.getByRole('button', { name: /Verify and sign in/i }).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 a backup code instead/i }).click();
|
|
await page.locator('#mfa-disable-code').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);
|
|
});
|
|
});
|