Build and Push Docker Image / build-and-push (push) Successful in 10m22s
- Reader: chapter cross-reference map (M) from IQ Bible's reference graph - Admin: Health tab with disk use, snapshots (back up, download, restore), failure logs - Passkey sign-in (WebAuthn), signed-in device list, sign out others, optional sign-in alerts - Settings split into Account, Preferences, Notifications, Security, Your data tabs - ntfy server field: catch a topic typed into the server box Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
122 lines
6.4 KiB
React
122 lines
6.4 KiB
React
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||
import { render, screen, waitFor } from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
|
||
const app = { authUser: { id: 'u1', email: 'a@x.test', loginAlerts: false }, setAuthUser: vi.fn() };
|
||
vi.mock('../../context/AppContext.js', () => ({ useApp: () => app }));
|
||
const webauthn = vi.hoisted(() => ({ browserSupportsWebAuthn: vi.fn(() => true), startRegistration: vi.fn(), startAuthentication: vi.fn() }));
|
||
vi.mock('@simplewebauthn/browser', () => webauthn);
|
||
|
||
import PasskeysSection from './PasskeysSection.jsx';
|
||
import DevicesSection, { whenText } from './DevicesSection.jsx';
|
||
import { signInWithPasskey } from '../../utils/passkeys.js';
|
||
|
||
const routes = {};
|
||
beforeEach(() => {
|
||
Object.keys(routes).forEach((k) => delete routes[k]);
|
||
app.setAuthUser.mockClear(); webauthn.browserSupportsWebAuthn.mockReturnValue(true);
|
||
vi.stubGlobal('fetch', vi.fn(async (url, opts) => {
|
||
const key = `${opts?.method ?? 'GET'} ${String(url).replace(/^.*\/api/, '')}`;
|
||
const r = routes[key] ?? { status: 404, body: { error: `no route ${key}` } };
|
||
return { ok: (r.status ?? 200) < 400, status: r.status ?? 200, json: async () => r.body };
|
||
}));
|
||
});
|
||
afterEach(() => vi.unstubAllGlobals());
|
||
|
||
const passkey = { id: 'k1', name: 'My phone', createdAt: 1_700_000_000_000, lastUsedAt: null };
|
||
|
||
test('lists passkeys and adds one through the device prompt', async () => {
|
||
routes['GET /passkeys'] = { body: [passkey] };
|
||
routes['POST /passkeys/options'] = { body: { challenge: 'c' } };
|
||
routes['POST /passkeys'] = { body: [passkey, { ...passkey, id: 'k2', name: 'Laptop' }] };
|
||
webauthn.startRegistration.mockResolvedValue({ id: 'k2' });
|
||
const user = userEvent.setup();
|
||
render(<PasskeysSection />);
|
||
expect(await screen.findByText('My phone')).toBeInTheDocument();
|
||
expect(screen.getByText(/not used yet/)).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: /Add a passkey on this device/ }));
|
||
expect(await screen.findByText('Laptop')).toBeInTheDocument();
|
||
expect(webauthn.startRegistration).toHaveBeenCalledWith({ optionsJSON: { challenge: 'c' } });
|
||
});
|
||
|
||
test('says so when the device prompt is cancelled', async () => {
|
||
routes['GET /passkeys'] = { body: [] };
|
||
routes['POST /passkeys/options'] = { body: { challenge: 'c' } };
|
||
webauthn.startRegistration.mockRejectedValue(Object.assign(new Error('x'), { name: 'NotAllowedError' }));
|
||
const user = userEvent.setup();
|
||
render(<PasskeysSection />);
|
||
await user.click(await screen.findByRole('button', { name: /Add a passkey/ }));
|
||
expect(await screen.findByRole('alert')).toHaveTextContent('cancelled or timed out');
|
||
});
|
||
|
||
test('renames and removes a passkey', async () => {
|
||
routes['GET /passkeys'] = { body: [passkey] };
|
||
routes['PATCH /passkeys/k1'] = { body: [{ ...passkey, name: 'Pixel' }] };
|
||
routes['DELETE /passkeys/k1'] = { body: [] };
|
||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||
const user = userEvent.setup();
|
||
render(<PasskeysSection />);
|
||
await user.click(await screen.findByRole('button', { name: 'Rename' }));
|
||
const input = screen.getByLabelText('Passkey name');
|
||
await user.clear(input); await user.type(input, 'Pixel'); await user.click(screen.getByRole('button', { name: 'Save' }));
|
||
expect(await screen.findByText('Pixel')).toBeInTheDocument();
|
||
await user.click(screen.getByRole('button', { name: 'Remove' }));
|
||
await waitFor(() => expect(screen.queryByText('Pixel')).not.toBeInTheDocument());
|
||
});
|
||
|
||
test('hides the button where passkeys cannot work', async () => {
|
||
webauthn.browserSupportsWebAuthn.mockReturnValue(false);
|
||
routes['GET /passkeys'] = { body: [] };
|
||
render(<PasskeysSection />);
|
||
expect(await screen.findByText(/can’t use passkeys/)).toBeInTheDocument();
|
||
expect(screen.queryByRole('button', { name: /Add a passkey/ })).not.toBeInTheDocument();
|
||
});
|
||
|
||
test('sign-in with a passkey asks the server, then the device, then the server again', async () => {
|
||
routes['POST /auth/passkey/options'] = { body: { challenge: 'c' } };
|
||
routes['POST /auth/passkey/verify'] = { body: { id: 'u1', email: 'a@x.test' } };
|
||
webauthn.startAuthentication.mockResolvedValue({ id: 'k1' });
|
||
expect(await signInWithPasskey()).toMatchObject({ ok: true, data: { email: 'a@x.test' } });
|
||
webauthn.startAuthentication.mockRejectedValue(Object.assign(new Error('x'), { name: 'NotAllowedError' }));
|
||
expect(await signInWithPasskey()).toMatchObject({ ok: false, cancelled: true });
|
||
});
|
||
|
||
test('shows devices, signs one out, and signs out the rest', async () => {
|
||
const devices = [
|
||
{ id: 'd1', current: true, label: 'Safari on macOS', method: 'password', ip: '1.1.1.1', lastSeen: Date.now() },
|
||
{ id: 'd2', current: false, label: 'Chrome on Windows', method: 'passkey', ip: '2.2.2.2', lastSeen: Date.now() - 3 * 86400000 },
|
||
];
|
||
routes['GET /sessions'] = { body: devices };
|
||
routes['DELETE /sessions/d2'] = { body: { ok: true } };
|
||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||
const user = userEvent.setup();
|
||
render(<DevicesSection />);
|
||
expect(await screen.findByText('Chrome on Windows')).toBeInTheDocument();
|
||
expect(screen.getByText('This device')).toBeInTheDocument();
|
||
expect(screen.getByText(/signed in with passkey/)).toBeInTheDocument();
|
||
routes['GET /sessions'] = { body: [devices[0]] };
|
||
await user.click(screen.getByRole('button', { name: 'Sign out' }));
|
||
await waitFor(() => expect(screen.queryByText('Chrome on Windows')).not.toBeInTheDocument());
|
||
expect(screen.queryByRole('button', { name: /Sign out the other/ })).not.toBeInTheDocument();
|
||
});
|
||
|
||
test('the sign-in alert switch reports a server refusal', async () => {
|
||
routes['GET /sessions'] = { body: [] };
|
||
routes['PUT /auth/login-alerts'] = { status: 400, body: { error: 'Set up a notification topic first (under Notifications).' } };
|
||
const user = userEvent.setup();
|
||
render(<DevicesSection />);
|
||
await user.click(await screen.findByRole('checkbox'));
|
||
expect(await screen.findByRole('alert')).toHaveTextContent('notification topic');
|
||
expect(app.setAuthUser).not.toHaveBeenCalled();
|
||
routes['PUT /auth/login-alerts'] = { body: { loginAlerts: true } };
|
||
await user.click(screen.getByRole('checkbox'));
|
||
await waitFor(() => expect(app.setAuthUser).toHaveBeenCalledWith(expect.objectContaining({ loginAlerts: true })));
|
||
});
|
||
|
||
test('times read plainly', () => {
|
||
const now = 1_000_000_000_000;
|
||
expect(whenText(now - 30_000, now)).toBe('just now');
|
||
expect(whenText(now - 5 * 3600_000, now)).toBe('5 hours ago');
|
||
expect(whenText(now - 3 * 86400_000, now)).toBe('3 days ago');
|
||
});
|