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 && ( -
+
{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- {loadError} -
- - ← Back to library - +{loadError}
+{`Could not load genres: ${genresError}`}
} + {genresError &&{`Could not load genres: ${genresError}`}
}