Add cross-reference map, admin health page, passkeys and device list, settings tabs
Build and Push Docker Image / build-and-push (push) Successful in 10m22s
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>
This commit is contained in:
1 parent
207bfcb919
commit
bdb04cdedc
35 files changed
+1860
-22
No files matched your search
@@ -0,0 +1,121 @@
|
||||
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');
|
||||
});
|
||||
Reference in new issue
Block a user