Files
study-bible-project/src/components/settings/SecurityTab.test.jsx
T
nmemmertandClaude Sonnet 5.5 bdb04cdedc
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
Add cross-reference map, admin health page, passkeys and device list, settings tabs
- 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>
2026-10-06 12:52:49 -04:00

122 lines
6.4 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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');
});