Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
Sync and data safety - Autosave flushes on leaving a study or the tab; storage-full errors are reported - Unsynced studies, deletes and ink are queued and retried when back online - Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect - Server assigns study versions (no client-clock conflicts); "Combine both" merges a conflicted study chunk by chunk - Reading-plan progress made offline is merged, not overwritten; plans, ink and marks are cleared from the device on sign-out Security - Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and account; constant-time login; TOTP replay protection; input validation; SESSION_SECRET required in production; admin user delete fixed - Forgot password: one-time code sent to the account's ntfy topic Features - IQ Bible proxy (route allow-list, text-only caching per its terms): more commentaries, lexicons, study aids, daily verse and devotional, plan calendar export, narrations, podcast background - Study templates, reading-plan catch-up, account export, daily database snapshots Tests and tooling - ESLint config; App tests split into src/test; tests for sync queues, library hook, merge, templates, IQ Bible UI and reset form Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
@@ -0,0 +1,51 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({ requestPasswordReset: vi.fn(), resetPassword: vi.fn() }));
|
||||
import ResetPasswordForm from './ResetPasswordForm.jsx';
|
||||
import { requestPasswordReset, resetPassword } from '../syncService.js';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
requestPasswordReset.mockResolvedValue({ ok: true, data: { message: 'A code was sent if you have a topic.' } });
|
||||
resetPassword.mockResolvedValue({ ok: true, data: { ok: true } });
|
||||
});
|
||||
|
||||
test('asks for a code, then sets the new password with it', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onDone = vi.fn();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={onDone} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /send me a code/i }));
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith('me@x.co');
|
||||
expect(await screen.findByText(/A code was sent/)).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(resetPassword).toHaveBeenCalledWith({ email: 'me@x.co', code: 'ABCD2345', newPassword: 'brand-new-pass', token: undefined });
|
||||
await user.click(await screen.findByRole('button', { name: /back to sign in/i }));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a wrong code shows the server\'s message and stays on the form', async () => {
|
||||
resetPassword.mockResolvedValue({ ok: false, error: 'That code is wrong or has expired. Ask for a new one.' });
|
||||
const user = userEvent.setup();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
|
||||
await user.type(screen.getByLabelText(/^Code/), 'WRONG123');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/wrong or has expired/);
|
||||
});
|
||||
|
||||
test('an authenticator code is passed along when one is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResetPasswordForm initialEmail="me@x.co" onDone={() => {}} onBack={() => {}} />);
|
||||
await user.click(screen.getByRole('button', { name: /i already have a code/i }));
|
||||
await user.type(screen.getByLabelText(/^Code/), 'ABCD2345');
|
||||
await user.type(screen.getByLabelText(/^New password/), 'brand-new-pass');
|
||||
await user.type(screen.getByLabelText(/Authenticator code/), '123456');
|
||||
await user.click(screen.getByRole('button', { name: /change password/i }));
|
||||
expect(resetPassword).toHaveBeenCalledWith(expect.objectContaining({ token: '123456' }));
|
||||
});
|
||||
Reference in new issue
Block a user