From 2512c67893509952797639c9d91404aa0bcd48dd Mon Sep 17 00:00:00 2001 From: Malcolm Roberts Date: Fri, 2 Oct 2026 14:36:14 -0500 Subject: [PATCH] fix: cap request bodies, link login error, add book-form retry, guard double add Limit request bodies to 1 MiB (413 JSON), wire aria-describedby on the login form, add Retry and cancellation to the book form load, disable Add entry while a note POST is in flight, and poll pg_isready in README. Claude-Session: https://claude.ai/code/session_01M9MLit5Ko3X4s7rzC5Kv7X --- README.md | 2 +- backend/app.py | 3 +++ backend/tests/test_app.py | 5 +++++ src/auth/LoginForm.tsx | 6 +++++- src/auth/auth.test.tsx | 1 + src/books/BookForm.test.tsx | 11 +++++++++++ src/books/BookForm.tsx | 30 +++++++++++++++++++++--------- src/notes/NotesJournal.test.tsx | 20 +++++++++++++++++++- src/notes/NotesJournal.tsx | 7 ++++++- 9 files changed, 72 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7bc5f6f..8b0af9d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Optionally set `PASSWORD_PEPPER` (≥ 32 chars). Without it, a development peppe ```bash mise trust && mise install # Python 3.14, Node 24 docker compose up -d db # postgres:16 on 127.0.0.1:5432 -docker compose exec -T db pg_isready -U postgres # repeat until it reports "accepting connections" +until docker compose exec -T db pg_isready -U postgres; do sleep 1; done mise exec -- python -m venv .venv && .venv/bin/pip install -r backend/requirements.txt mise exec -- npm install .venv/bin/python backend/app.py # API on :5000 diff --git a/backend/app.py b/backend/app.py index d047c22..e05ae34 100644 --- a/backend/app.py +++ b/backend/app.py @@ -18,6 +18,9 @@ MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} def create_app() -> Flask: logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") app = Flask(__name__) + # Werkzeug buffers and parses the whole body before our validation runs, so unauthenticated + # callers could exhaust memory; 1 MiB is far above the largest legitimate payload (10,000-char note). + app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024 db.init_app(app) auth.init_app(app) app.register_blueprint(books.bp) diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 7aa36fa..b8b2945 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -30,3 +30,8 @@ class AppTests(ApiTestCase): response.get_json(), {"error": "Request body must be JSON with Content-Type: application/json"}, ) + + def test_oversized_body_is_rejected_before_parsing(self): + response = self.call("POST", "/api/auth/login", {"username": "a", "password": "x" * (2 * 1024 * 1024)}) + self.assertEqual(response.status_code, 413) + self.assertIn("error", response.get_json()) diff --git a/src/auth/LoginForm.tsx b/src/auth/LoginForm.tsx index 4377d66..f1ff558 100644 --- a/src/auth/LoginForm.tsx +++ b/src/auth/LoginForm.tsx @@ -45,6 +45,8 @@ export function LoginForm() { value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" + aria-invalid={error ? true : undefined} + aria-describedby={error ? 'login-error' : undefined} required /> @@ -57,11 +59,13 @@ export function LoginForm() { onChange={(e) => setPassword(e.target.value)} autoComplete={isLogin ? 'current-password' : 'new-password'} minLength={isLogin ? undefined : 12} + aria-invalid={error ? true : undefined} + aria-describedby={error ? 'login-error' : undefined} required /> {error && ( -

+

)} diff --git a/src/auth/auth.test.tsx b/src/auth/auth.test.tsx index c2b0860..3f51feb 100644 --- a/src/auth/auth.test.tsx +++ b/src/auth/auth.test.tsx @@ -35,6 +35,7 @@ describe('auth flow', () => { await user.type(screen.getByLabelText('Password'), 'wrong password!') await user.click(screen.getByRole('button', { name: 'Sign in' })) expect(await screen.findByRole('alert')).toHaveTextContent('Invalid username or password') + expect(screen.getByLabelText('Password')).toHaveAttribute('aria-describedby', 'login-error') }) it('registers a new account', async () => { diff --git a/src/books/BookForm.test.tsx b/src/books/BookForm.test.tsx index f7d0e7e..dcd99ca 100644 --- a/src/books/BookForm.test.tsx +++ b/src/books/BookForm.test.tsx @@ -62,4 +62,15 @@ describe('BookForm', () => { body: { title: 'Dune Messiah', author: 'Frank Herbert', genre_id: 5, total_pages: 380, current_page: 142 }, }) }) + + it('retries a failed load and shows the prefilled form', async () => { + let attempts = 0 + signedIn({ + 'GET /api/books/7': () => (++attempts === 1 ? [500, { error: 'Database unavailable' }] : [200, dune]), + }) + const user = renderApp('/books/7/edit') + expect(await screen.findByRole('alert')).toHaveTextContent('Database unavailable') + await user.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByLabelText('Title')).toHaveValue('Dune') + }) }) diff --git a/src/books/BookForm.tsx b/src/books/BookForm.tsx index 421deb9..2c301a9 100644 --- a/src/books/BookForm.tsx +++ b/src/books/BookForm.tsx @@ -19,11 +19,16 @@ export function BookForm() { const [loadError, setLoadError] = useState(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) + const [attempt, setAttempt] = useState(0) useEffect(() => { if (!editing) return + let cancelled = false + setLoadError(null) + setLoading(true) api('GET', `/books/${id}`).then( (book) => { + if (cancelled) return setFields({ title: book.title, author: book.author, @@ -34,11 +39,15 @@ export function BookForm() { setLoading(false) }, (err) => { + if (cancelled) return setLoadError(errorMessage(err)) setLoading(false) }, ) - }, [editing, id]) + return () => { + cancelled = true + } + }, [editing, id, attempt]) function set(name: keyof Fields) { return (event: { target: { value: string } }) => setFields((prev) => ({ ...prev, [name]: event.target.value })) @@ -72,13 +81,16 @@ export function BookForm() { if (loadError) { return ( -
-

- {loadError} -

- - ← Back to library - +
+

{loadError}

+
+ + + ← Back to library + +
) } @@ -106,7 +118,7 @@ export function BookForm() { ))} - {genresError &&

{`Could not load genres: ${genresError}`}

} + {genresError &&

{`Could not load genres: ${genresError}`}

}
- {addError && (