Compare commits
10
Commits
f0efa25f23
...
7f5d034a1f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f5d034a1f | ||
|
|
9d7b0e805c | ||
|
|
d9b824851a | ||
|
|
0801a69cc0 | ||
|
|
63c9991421 | ||
|
|
2512c67893 | ||
|
|
35ad0b13e2 | ||
|
|
9e247b6f06 | ||
|
|
18bc74f8f9 | ||
|
|
da6c5c2de5 |
+25
-1
@@ -1,6 +1,30 @@
|
|||||||
|
# Python
|
||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
backend/.pepper
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
backend/.pepper
|
||||||
|
|
||||||
|
# Editors and OS
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# My Library
|
||||||
|
|
||||||
|
A personal book tracker. You sign up, add the books you own, track how far you are in each one, and keep a
|
||||||
|
reading journal for every book. You can search your library by title or author and filter it by genre. Each
|
||||||
|
user sees only their own books.
|
||||||
|
|
||||||
|
It is a Flask (Python) API with a React (TypeScript) frontend, backed by PostgreSQL.
|
||||||
|
|
||||||
|
## What you need
|
||||||
|
|
||||||
|
- [mise](https://mise.jdx.dev) – installs the right versions of Python, Node and the other tools for you
|
||||||
|
- [Docker](https://www.docker.com) – runs the PostgreSQL database
|
||||||
|
|
||||||
|
## First-time setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise trust # allow mise to use this project's mise.toml
|
||||||
|
mise install # install Python, Node, uv, ruff and biome
|
||||||
|
mise run setup # create the Python virtual environment and install all dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts the database, the API (port 5000) and the web app. Open http://localhost:5173 and create an
|
||||||
|
account. Press `Ctrl+C` to stop it.
|
||||||
|
|
||||||
|
You can also start the parts on their own: `mise run db`, `mise run dev:api` and `mise run dev:web`.
|
||||||
|
|
||||||
|
To stop the database when you are done, run `docker compose down`.
|
||||||
|
|
||||||
|
## Run the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run test # everything
|
||||||
|
mise run test:py # backend only
|
||||||
|
mise run test:ts # frontend only, plus the type check
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend tests use a real PostgreSQL database called `books_test`. It is created for you, so the database
|
||||||
|
from `mise run db` just needs to be running (the test task starts it).
|
||||||
|
|
||||||
|
## Lint and format
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run format # fix formatting and import order (writes changes)
|
||||||
|
mise run lint # check linting, formatting and types without changing files
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `mise run lint` and `mise run test` before you push.
|
||||||
|
|
||||||
|
## Change backend dependencies
|
||||||
|
|
||||||
|
Edit `backend/requirements.in`, then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise run lock # rebuild the pinned backend/requirements.txt
|
||||||
|
mise run setup # install the new versions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
- `DATABASE_URL` – the PostgreSQL connection string. It defaults to the local Docker database.
|
||||||
|
- `PASSWORD_PEPPER` – a secret of at least 32 characters used when storing passwords. If you do not set it,
|
||||||
|
a development value is created in `backend/.pepper` and a warning is logged. Always set it outside local
|
||||||
|
development.
|
||||||
|
|
||||||
|
Run `mise tasks` to see every available task.
|
||||||
+35
-18
@@ -1,8 +1,10 @@
|
|||||||
"""Application entry point: wires slices, cross-cutting request rules, and JSON error handling."""
|
"""Application entry point: wires slices, cross-cutting request rules, and JSON error handling."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import psycopg2.errors
|
import psycopg2.errors
|
||||||
from flask import Flask, g, jsonify, request
|
from flask import Flask, Response, g, jsonify, request
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
import auth
|
import auth
|
||||||
@@ -16,53 +18,68 @@ MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||||
|
)
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = 1024 * 1024
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
auth.init_app(app)
|
auth.init_app(app)
|
||||||
app.register_blueprint(books.bp)
|
app.register_blueprint(books.bp)
|
||||||
app.register_blueprint(notes.bp)
|
app.register_blueprint(notes.bp)
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
def health():
|
@auth.allow_anonymous
|
||||||
|
def health() -> ResponseReturnValue:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def require_json_for_mutations():
|
def require_json_for_mutations() -> None:
|
||||||
# CSRF defense (ADR-0002): cross-site requests can only send this content type after a
|
|
||||||
# CORS preflight, and this API never grants CORS.
|
|
||||||
if request.method in MUTATING_METHODS and not request.is_json:
|
if request.method in MUTATING_METHODS and not request.is_json:
|
||||||
raise ApiError(400, "Request body must be JSON with Content-Type: application/json")
|
raise ApiError(
|
||||||
|
400, "Request body must be JSON with Content-Type: application/json"
|
||||||
|
)
|
||||||
|
|
||||||
@app.after_request
|
@app.after_request
|
||||||
def log_request(response):
|
def log_request(response: Response) -> Response:
|
||||||
log.info("%s %s -> %s user=%s", request.method, request.path, response.status_code, g.get("user_id"))
|
log.info(
|
||||||
|
"%s %s -> %s user=%s",
|
||||||
|
request.method,
|
||||||
|
request.path,
|
||||||
|
response.status_code,
|
||||||
|
g.get("user_id"),
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@app.errorhandler(ApiError)
|
@app.errorhandler(ApiError)
|
||||||
def handle_api_error(error: ApiError):
|
def handle_api_error(error: ApiError) -> ResponseReturnValue:
|
||||||
body = {"error": error.message}
|
body = {"error": error.message}
|
||||||
if error.field:
|
if error.field:
|
||||||
body["field"] = error.field
|
body["field"] = error.field
|
||||||
return jsonify(body), error.status
|
return jsonify(body), error.status
|
||||||
|
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def handle_http_error(error: HTTPException):
|
def handle_http_error(error: HTTPException) -> ResponseReturnValue:
|
||||||
return jsonify(error=error.description), error.code
|
return jsonify(error=error.description), error.code or 500
|
||||||
|
|
||||||
@app.errorhandler(psycopg2.errors.CheckViolation)
|
@app.errorhandler(psycopg2.errors.CheckViolation)
|
||||||
def handle_check_violation(error: psycopg2.errors.CheckViolation):
|
def handle_check_violation(
|
||||||
return jsonify(error=f"Value breaks data rule '{error.diag.constraint_name}'; correct it and retry"), 400
|
error: psycopg2.errors.CheckViolation,
|
||||||
|
) -> ResponseReturnValue:
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
error=f"Value breaks data rule '{error.diag.constraint_name}'; correct it and retry"
|
||||||
|
),
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
@app.errorhandler(Exception)
|
@app.errorhandler(Exception)
|
||||||
def handle_unexpected(_error: Exception):
|
def handle_unexpected(_error: Exception) -> ResponseReturnValue:
|
||||||
log.exception("Unhandled error on %s %s", request.method, request.path)
|
log.exception("Unhandled error on %s %s", request.method, request.path)
|
||||||
return jsonify(error="Unexpected server error"), 500
|
return jsonify(error="Unexpected server error"), 500
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="127.0.0.1", port=5000)
|
create_app().run(host="127.0.0.1", port=5000)
|
||||||
|
|||||||
+114
-123
@@ -1,128 +1,90 @@
|
|||||||
"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes."""
|
"""Auth slice: server-side sessions (ADR-0002) and auth routes."""
|
||||||
import base64
|
|
||||||
import functools
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import ParamSpec, TypedDict, TypeVar, cast
|
||||||
|
|
||||||
import psycopg2.errors
|
import psycopg2.errors
|
||||||
from flask import Blueprint, Flask, current_app, g, jsonify, make_response, request
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
Flask,
|
||||||
|
Response,
|
||||||
|
current_app,
|
||||||
|
g,
|
||||||
|
jsonify,
|
||||||
|
make_response,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
|
||||||
from db import query, query_one
|
from db import query, query_one, query_row
|
||||||
from validation import ApiError, json_body, require_utf8
|
from passwords import (
|
||||||
|
dummy_hash,
|
||||||
|
hash_password,
|
||||||
|
load_pepper,
|
||||||
|
needs_rehash,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from validation import ApiError, JsonObject, json_body, require_utf8
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
|
||||||
|
|
||||||
ITERATIONS = 600_000
|
|
||||||
SALT_BYTES = 16
|
|
||||||
MIN_PEPPER_CHARS = 32
|
|
||||||
SESSION_COOKIE = "sid"
|
SESSION_COOKIE = "sid"
|
||||||
USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}")
|
USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}")
|
||||||
MIN_PASSWORD, MAX_PASSWORD = 12, 1024
|
MIN_PASSWORD, MAX_PASSWORD = 12, 1024
|
||||||
INVALID_LOGIN = "Invalid username or password"
|
INVALID_LOGIN = "Invalid username or password"
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
R = TypeVar("R")
|
||||||
|
|
||||||
|
|
||||||
|
class UserRow(TypedDict):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserWithHash(UserRow):
|
||||||
|
password_hash: str
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask) -> None:
|
def init_app(app: Flask) -> None:
|
||||||
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
|
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
|
||||||
_dummy_hash() # pay the one-time cost now so the first unknown-user login isn't measurably slower
|
dummy_hash()
|
||||||
|
app.before_request(_require_session)
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
|
||||||
|
|
||||||
# --- Passwords -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def hash_password(password: str, pepper: bytes, *, salt: bytes | None = None, iterations: int = ITERATIONS) -> str:
|
|
||||||
salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt
|
|
||||||
derived = _derive(password, pepper, salt, iterations)
|
|
||||||
return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(password: str, stored: str, pepper: bytes) -> bool:
|
|
||||||
_, iterations, salt, expected = stored.split("$")
|
|
||||||
derived = _derive(password, pepper, base64.b64decode(salt), int(iterations))
|
|
||||||
return hmac.compare_digest(derived, base64.b64decode(expected))
|
|
||||||
|
|
||||||
|
|
||||||
def needs_rehash(stored: str) -> bool:
|
|
||||||
return int(stored.split("$")[1]) < ITERATIONS
|
|
||||||
|
|
||||||
|
|
||||||
def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes:
|
|
||||||
peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest()
|
|
||||||
return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations)
|
|
||||||
|
|
||||||
|
|
||||||
def _b64(raw: bytes) -> str:
|
|
||||||
return base64.b64encode(raw).decode()
|
|
||||||
|
|
||||||
|
|
||||||
@functools.cache
|
|
||||||
def _dummy_hash() -> str:
|
|
||||||
# Verified against for unknown usernames so their response time matches a real account.
|
|
||||||
return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS)
|
|
||||||
|
|
||||||
|
|
||||||
def load_pepper(pepper_file: Path) -> bytes:
|
|
||||||
from_env = os.environ.get("PASSWORD_PEPPER")
|
|
||||||
if from_env is not None:
|
|
||||||
if len(from_env) < MIN_PEPPER_CHARS:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. "
|
|
||||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
|
||||||
)
|
|
||||||
return from_env.encode()
|
|
||||||
try:
|
|
||||||
fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
||||||
except FileExistsError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
with os.fdopen(fd, "w") as f:
|
|
||||||
f.write(secrets.token_hex(32))
|
|
||||||
log.warning(
|
|
||||||
"PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper "
|
|
||||||
"from a secrets manager.",
|
|
||||||
pepper_file,
|
|
||||||
)
|
|
||||||
pepper = pepper_file.read_text().strip()
|
|
||||||
if len(pepper) < MIN_PEPPER_CHARS:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete "
|
|
||||||
"the file to regenerate it (existing passwords will stop verifying)."
|
|
||||||
)
|
|
||||||
return pepper.encode()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Sessions ------------------------------------------------------------------
|
# --- Sessions ------------------------------------------------------------------
|
||||||
|
|
||||||
def login_required(view):
|
|
||||||
@functools.wraps(view)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
token = request.cookies.get(SESSION_COOKIE)
|
|
||||||
row = None
|
|
||||||
if token:
|
|
||||||
# One statement validates and slides the idle window. The created_at predicate enforces
|
|
||||||
# the 12-hour cap even while expires_at is still in the future (ADR-0002).
|
|
||||||
row = query_one(
|
|
||||||
"""
|
|
||||||
UPDATE sessions
|
|
||||||
SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours')
|
|
||||||
WHERE token_hash = %s
|
|
||||||
AND expires_at > now()
|
|
||||||
AND created_at > now() - interval '12 hours'
|
|
||||||
RETURNING user_id
|
|
||||||
""",
|
|
||||||
(_token_hash(token),),
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
raise ApiError(401, "Not signed in or session expired; log in again")
|
|
||||||
g.user_id = row["user_id"]
|
|
||||||
return view(*args, **kwargs)
|
|
||||||
|
|
||||||
return wrapper
|
def allow_anonymous(view: Callable[P, R]) -> Callable[P, R]:
|
||||||
|
view.allow_anonymous = True # type: ignore[attr-defined]
|
||||||
|
return view
|
||||||
|
|
||||||
|
|
||||||
|
def _require_session() -> None:
|
||||||
|
view = current_app.view_functions.get(request.endpoint or "")
|
||||||
|
if view is None or getattr(view, "allow_anonymous", False):
|
||||||
|
return
|
||||||
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
|
row = None
|
||||||
|
if token:
|
||||||
|
row = query_one(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours')
|
||||||
|
WHERE token_hash = %s
|
||||||
|
AND expires_at > now()
|
||||||
|
AND created_at > now() - interval '12 hours'
|
||||||
|
RETURNING user_id
|
||||||
|
""",
|
||||||
|
(_token_hash(token),),
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
raise ApiError(401, "Not signed in or session expired; log in again")
|
||||||
|
g.user_id = row["user_id"]
|
||||||
|
|
||||||
|
|
||||||
def _token_hash(token: str) -> bytes:
|
def _token_hash(token: str) -> bytes:
|
||||||
@@ -130,84 +92,113 @@ def _token_hash(token: str) -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def _pepper() -> bytes:
|
def _pepper() -> bytes:
|
||||||
return current_app.extensions["password_pepper"]
|
pepper: bytes = current_app.extensions["password_pepper"]
|
||||||
|
return pepper
|
||||||
|
|
||||||
|
|
||||||
def _start_session(user: dict, status: int):
|
def _start_session(user: UserRow, status: int) -> Response:
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
query("DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],))
|
query(
|
||||||
|
"DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],)
|
||||||
|
)
|
||||||
query(
|
query(
|
||||||
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (%s, %s, now() + interval '30 minutes')",
|
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (%s, %s, now() + interval '30 minutes')",
|
||||||
(_token_hash(token), user["id"]),
|
(_token_hash(token), user["id"]),
|
||||||
)
|
)
|
||||||
response = jsonify(id=user["id"], username=user["username"])
|
response = jsonify(id=user["id"], username=user["username"])
|
||||||
response.status_code = status
|
response.status_code = status
|
||||||
response.set_cookie(SESSION_COOKIE, token, httponly=True, secure=True, samesite="Lax", path="/api")
|
response.set_cookie(
|
||||||
|
SESSION_COOKIE, token, httponly=True, secure=True, samesite="Lax", path="/api"
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# --- Routes ----------------------------------------------------------------------
|
# --- Routes ----------------------------------------------------------------------
|
||||||
|
|
||||||
def _password(body: dict) -> str:
|
|
||||||
|
def _password(body: JsonObject) -> str:
|
||||||
password = body.get("password")
|
password = body.get("password")
|
||||||
if not isinstance(password, str):
|
if not isinstance(password, str):
|
||||||
raise ApiError(400, "password is required and must be a string", "password")
|
raise ApiError(400, "password is required and must be a string", "password")
|
||||||
if len(password) > MAX_PASSWORD:
|
if len(password) > MAX_PASSWORD:
|
||||||
raise ApiError(400, f"password must be at most {MAX_PASSWORD} characters", "password")
|
raise ApiError(
|
||||||
|
400, f"password must be at most {MAX_PASSWORD} characters", "password"
|
||||||
|
)
|
||||||
require_utf8(password, "password")
|
require_utf8(password, "password")
|
||||||
return password
|
return password
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/register")
|
@bp.post("/register")
|
||||||
def register():
|
@allow_anonymous
|
||||||
|
def register() -> Response:
|
||||||
body = json_body({"username", "password"})
|
body = json_body({"username", "password"})
|
||||||
username = body.get("username")
|
username = body.get("username")
|
||||||
if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username):
|
if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username):
|
||||||
raise ApiError(400, "username must be 3-64 characters: letters, digits, '.', '_' or '-'", "username")
|
raise ApiError(
|
||||||
|
400,
|
||||||
|
"username must be 3-64 characters: letters, digits, '.', '_' or '-'",
|
||||||
|
"username",
|
||||||
|
)
|
||||||
password = _password(body)
|
password = _password(body)
|
||||||
if len(password) < MIN_PASSWORD:
|
if len(password) < MIN_PASSWORD:
|
||||||
raise ApiError(400, f"password must be at least {MIN_PASSWORD} characters", "password")
|
raise ApiError(
|
||||||
|
400, f"password must be at least {MIN_PASSWORD} characters", "password"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
user = query_one(
|
user = query_row(
|
||||||
"INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username",
|
"INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username",
|
||||||
(username, hash_password(password, _pepper())),
|
(username, hash_password(password, _pepper())),
|
||||||
)
|
)
|
||||||
except psycopg2.errors.UniqueViolation:
|
except psycopg2.errors.UniqueViolation:
|
||||||
raise ApiError(409, "Username already taken; choose another", "username") from None
|
raise ApiError(
|
||||||
return _start_session(user, 201)
|
409, "Username already taken; choose another", "username"
|
||||||
|
) from None
|
||||||
|
return _start_session(cast(UserRow, user), 201)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/login")
|
@bp.post("/login")
|
||||||
def login():
|
@allow_anonymous
|
||||||
|
def login() -> Response:
|
||||||
body = json_body({"username", "password"})
|
body = json_body({"username", "password"})
|
||||||
password = _password(body)
|
password = _password(body)
|
||||||
username = body.get("username")
|
username = body.get("username")
|
||||||
user = None
|
user = None
|
||||||
if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username):
|
if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username):
|
||||||
user = query_one(
|
user = cast(
|
||||||
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)", (username,)
|
UserWithHash | None,
|
||||||
|
query_one(
|
||||||
|
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)",
|
||||||
|
(username,),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if user is None:
|
if user is None:
|
||||||
verify_password(password, _dummy_hash(), _pepper())
|
verify_password(password, dummy_hash(), _pepper())
|
||||||
raise ApiError(401, INVALID_LOGIN)
|
raise ApiError(401, INVALID_LOGIN)
|
||||||
if not verify_password(password, user["password_hash"], _pepper()):
|
if not verify_password(password, user["password_hash"], _pepper()):
|
||||||
raise ApiError(401, INVALID_LOGIN)
|
raise ApiError(401, INVALID_LOGIN)
|
||||||
if needs_rehash(user["password_hash"]):
|
if needs_rehash(user["password_hash"]):
|
||||||
query("UPDATE users SET password_hash = %s WHERE id = %s", (hash_password(password, _pepper()), user["id"]))
|
query(
|
||||||
|
"UPDATE users SET password_hash = %s WHERE id = %s",
|
||||||
|
(hash_password(password, _pepper()), user["id"]),
|
||||||
|
)
|
||||||
return _start_session(user, 200)
|
return _start_session(user, 200)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/logout")
|
@bp.post("/logout")
|
||||||
def logout():
|
@allow_anonymous
|
||||||
|
def logout() -> Response:
|
||||||
token = request.cookies.get(SESSION_COOKIE)
|
token = request.cookies.get(SESSION_COOKIE)
|
||||||
if token:
|
if token:
|
||||||
query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),))
|
query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),))
|
||||||
response = make_response("", 204)
|
response = make_response("", 204)
|
||||||
response.delete_cookie(SESSION_COOKIE, path="/api", secure=True, httponly=True, samesite="Lax")
|
response.delete_cookie(
|
||||||
|
SESSION_COOKIE, path="/api", secure=True, httponly=True, samesite="Lax"
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/me")
|
@bp.get("/me")
|
||||||
@login_required
|
def me() -> Response:
|
||||||
def me():
|
return jsonify(
|
||||||
return jsonify(query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,)))
|
query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,))
|
||||||
|
)
|
||||||
|
|||||||
+95
-40
@@ -1,22 +1,26 @@
|
|||||||
"""Books slice: CRUD, progress, search/filter, and the genre list."""
|
"""Books slice: CRUD, progress, search/filter, and the genre list."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TypedDict, cast
|
||||||
|
|
||||||
import psycopg2.errors
|
import psycopg2.errors
|
||||||
from flask import Blueprint, g, jsonify, request
|
from flask import Blueprint, Response, g, jsonify, request
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
from auth import login_required
|
from db import Params, Row, query, query_one, query_row
|
||||||
from db import query, query_one
|
from validation import ApiError, JsonObject, integer, json_body, text
|
||||||
from validation import ApiError, integer, json_body, text
|
|
||||||
|
|
||||||
bp = Blueprint("books", __name__, url_prefix="/api")
|
bp = Blueprint("books", __name__, url_prefix="/api")
|
||||||
|
|
||||||
MAX_TEXT = 500
|
MAX_TEXT = 500
|
||||||
MAX_PAGES = 100_000
|
MAX_PAGES = 100_000
|
||||||
MAX_GENRE_ID = 32_767 # SMALLINT
|
MAX_GENRE_ID = 32_767
|
||||||
GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}")
|
GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}")
|
||||||
REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages")
|
REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages")
|
||||||
FIELDS = set(REQUIRED_FIELDS) | {"current_page"}
|
FIELDS = set(REQUIRED_FIELDS) | {"current_page"}
|
||||||
VALIDATORS = {
|
VALIDATORS: dict[str, Callable[[JsonObject], str | int]] = {
|
||||||
"title": lambda body: text(body, "title", MAX_TEXT),
|
"title": lambda body: text(body, "title", MAX_TEXT),
|
||||||
"author": lambda body: text(body, "author", MAX_TEXT),
|
"author": lambda body: text(body, "author", MAX_TEXT),
|
||||||
"genre_id": lambda body: integer(body, "genre_id", 1, MAX_GENRE_ID),
|
"genre_id": lambda body: integer(body, "genre_id", 1, MAX_GENRE_ID),
|
||||||
@@ -24,6 +28,22 @@ VALIDATORS = {
|
|||||||
"current_page": lambda body: integer(body, "current_page", 0, MAX_PAGES),
|
"current_page": lambda body: integer(body, "current_page", 0, MAX_PAGES),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BookFields(TypedDict):
|
||||||
|
title: str
|
||||||
|
author: str
|
||||||
|
genre_id: int
|
||||||
|
total_pages: int
|
||||||
|
current_page: int
|
||||||
|
|
||||||
|
|
||||||
|
class BookRow(BookFields):
|
||||||
|
id: int
|
||||||
|
genre_name: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
BOOK_SELECT = """
|
BOOK_SELECT = """
|
||||||
SELECT b.id, b.title, b.author, b.genre_id, gn.name AS genre_name,
|
SELECT b.id, b.title, b.author, b.genre_id, gn.name AS genre_name,
|
||||||
b.total_pages, b.current_page, b.created_at, b.updated_at
|
b.total_pages, b.current_page, b.created_at, b.updated_at
|
||||||
@@ -40,7 +60,7 @@ def reading_status(current_page: int, total_pages: int) -> str:
|
|||||||
return "reading"
|
return "reading"
|
||||||
|
|
||||||
|
|
||||||
def to_json(row: dict) -> dict:
|
def to_json(row: BookRow) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
@@ -55,18 +75,22 @@ def to_json(row: dict) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def escape_like(term: str) -> str:
|
def escape_like(term: str) -> str:
|
||||||
# Backslash is Postgres's default LIKE escape character.
|
|
||||||
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
def owned_book(book_id: int) -> dict:
|
def owned_book(book_id: int, for_update: bool = False) -> BookRow:
|
||||||
row = query_one(BOOK_SELECT + " WHERE b.id = %s AND b.user_id = %s", (book_id, g.user_id))
|
row = query_one(
|
||||||
|
BOOK_SELECT
|
||||||
|
+ " WHERE b.id = %s AND b.user_id = %s"
|
||||||
|
+ (" FOR UPDATE OF b" if for_update else ""),
|
||||||
|
(book_id, g.user_id),
|
||||||
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ApiError(404, "Book not found")
|
raise ApiError(404, "Book not found")
|
||||||
return row
|
return cast(BookRow, row)
|
||||||
|
|
||||||
|
|
||||||
def _check_progress(book: dict, field: str) -> None:
|
def _check_progress(book: BookFields, field: str) -> None:
|
||||||
if book["current_page"] > book["total_pages"]:
|
if book["current_page"] > book["total_pages"]:
|
||||||
raise ApiError(
|
raise ApiError(
|
||||||
400,
|
400,
|
||||||
@@ -76,47 +100,57 @@ def _check_progress(book: dict, field: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _save(sql: str, params: tuple) -> dict | None:
|
def _save(sql: str, params: Params) -> Row:
|
||||||
try:
|
try:
|
||||||
return query_one(sql, params)
|
return query_row(sql, params)
|
||||||
except psycopg2.errors.ForeignKeyViolation:
|
except psycopg2.errors.ForeignKeyViolation:
|
||||||
raise ApiError(400, "Unknown genre_id; see GET /api/genres", "genre_id") from None
|
raise ApiError(
|
||||||
|
400, "Unknown genre_id; see GET /api/genres", "genre_id"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/genres")
|
@bp.get("/genres")
|
||||||
@login_required
|
def list_genres() -> Response:
|
||||||
def list_genres():
|
|
||||||
return jsonify(query("SELECT id, name FROM genres ORDER BY name"))
|
return jsonify(query("SELECT id, name FROM genres ORDER BY name"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/books")
|
@bp.get("/books")
|
||||||
@login_required
|
def list_books() -> Response:
|
||||||
def list_books():
|
|
||||||
sql = BOOK_SELECT + " WHERE b.user_id = %s"
|
sql = BOOK_SELECT + " WHERE b.user_id = %s"
|
||||||
params: list = [g.user_id]
|
params: list[object] = [g.user_id]
|
||||||
search = request.args.get("q", "").strip()
|
search = request.args.get("q", "").strip()
|
||||||
if search:
|
if search:
|
||||||
if len(search) > MAX_TEXT or "\x00" in search:
|
if len(search) > MAX_TEXT or "\x00" in search:
|
||||||
raise ApiError(400, f"q must be plain text of at most {MAX_TEXT} characters", "q")
|
raise ApiError(
|
||||||
|
400, f"q must be plain text of at most {MAX_TEXT} characters", "q"
|
||||||
|
)
|
||||||
pattern = f"%{escape_like(search)}%"
|
pattern = f"%{escape_like(search)}%"
|
||||||
sql += " AND (b.title ILIKE %s OR b.author ILIKE %s)"
|
sql += " AND (b.title ILIKE %s OR b.author ILIKE %s)"
|
||||||
params += [pattern, pattern]
|
params += [pattern, pattern]
|
||||||
genre_id = request.args.get("genre_id", "")
|
genre_id = request.args.get("genre_id", "")
|
||||||
if genre_id:
|
if genre_id:
|
||||||
if not GENRE_ID_PATTERN.fullmatch(genre_id) or int(genre_id) > MAX_GENRE_ID:
|
if not GENRE_ID_PATTERN.fullmatch(genre_id) or int(genre_id) > MAX_GENRE_ID:
|
||||||
raise ApiError(400, "genre_id must be an id from GET /api/genres", "genre_id")
|
raise ApiError(
|
||||||
|
400, "genre_id must be an id from GET /api/genres", "genre_id"
|
||||||
|
)
|
||||||
sql += " AND b.genre_id = %s"
|
sql += " AND b.genre_id = %s"
|
||||||
params.append(int(genre_id))
|
params.append(int(genre_id))
|
||||||
sql += " ORDER BY b.updated_at DESC, b.id DESC"
|
sql += " ORDER BY b.updated_at DESC, b.id DESC"
|
||||||
return jsonify([to_json(row) for row in query(sql, tuple(params))])
|
return jsonify([to_json(cast(BookRow, row)) for row in query(sql, tuple(params))])
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/books")
|
@bp.post("/books")
|
||||||
@login_required
|
def create_book() -> ResponseReturnValue:
|
||||||
def create_book():
|
|
||||||
body = json_body(FIELDS)
|
body = json_body(FIELDS)
|
||||||
book = {field: VALIDATORS[field](body) for field in REQUIRED_FIELDS}
|
book = cast(
|
||||||
book["current_page"] = VALIDATORS["current_page"](body) if "current_page" in body else 0
|
BookFields,
|
||||||
|
{field: VALIDATORS[field](body) for field in REQUIRED_FIELDS}
|
||||||
|
| {
|
||||||
|
"current_page": (
|
||||||
|
VALIDATORS["current_page"](body) if "current_page" in body else 0
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
_check_progress(book, "current_page")
|
_check_progress(book, "current_page")
|
||||||
row = _save(
|
row = _save(
|
||||||
"""
|
"""
|
||||||
@@ -124,42 +158,63 @@ def create_book():
|
|||||||
VALUES (%s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(g.user_id, book["title"], book["author"], book["genre_id"], book["total_pages"], book["current_page"]),
|
(
|
||||||
|
g.user_id,
|
||||||
|
book["title"],
|
||||||
|
book["author"],
|
||||||
|
book["genre_id"],
|
||||||
|
book["total_pages"],
|
||||||
|
book["current_page"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return jsonify(to_json(owned_book(row["id"]))), 201
|
return jsonify(to_json(owned_book(row["id"]))), 201
|
||||||
|
|
||||||
|
|
||||||
# int(max=...) keeps ids within Postgres INT range: larger values 404 instead of erroring in SQL.
|
|
||||||
@bp.get("/books/<int(max=2147483647):book_id>")
|
@bp.get("/books/<int(max=2147483647):book_id>")
|
||||||
@login_required
|
def get_book(book_id: int) -> Response:
|
||||||
def get_book(book_id: int):
|
|
||||||
return jsonify(to_json(owned_book(book_id)))
|
return jsonify(to_json(owned_book(book_id)))
|
||||||
|
|
||||||
|
|
||||||
@bp.patch("/books/<int(max=2147483647):book_id>")
|
@bp.patch("/books/<int(max=2147483647):book_id>")
|
||||||
@login_required
|
def update_book(book_id: int) -> Response:
|
||||||
def update_book(book_id: int):
|
|
||||||
body = json_body(FIELDS)
|
body = json_body(FIELDS)
|
||||||
if not body:
|
if not body:
|
||||||
raise ApiError(400, f"Provide at least one of: {', '.join(sorted(FIELDS))}")
|
raise ApiError(400, f"Provide at least one of: {', '.join(sorted(FIELDS))}")
|
||||||
current = owned_book(book_id)
|
current: Row = dict(owned_book(book_id, for_update=True))
|
||||||
book = {field: current[field] for field in FIELDS} | {field: VALIDATORS[field](body) for field in body}
|
book = cast(
|
||||||
|
BookFields,
|
||||||
|
{field: current[field] for field in FIELDS}
|
||||||
|
| {field: VALIDATORS[field](body) for field in body},
|
||||||
|
)
|
||||||
_check_progress(book, "total_pages" if "total_pages" in body else "current_page")
|
_check_progress(book, "total_pages" if "total_pages" in body else "current_page")
|
||||||
_save(
|
_save(
|
||||||
"""
|
"""
|
||||||
UPDATE books
|
UPDATE books
|
||||||
SET title = %s, author = %s, genre_id = %s, total_pages = %s, current_page = %s, updated_at = now()
|
SET title = %s, author = %s, genre_id = %s, total_pages = %s, current_page = %s, updated_at = now()
|
||||||
WHERE id = %s AND user_id = %s
|
WHERE id = %s AND user_id = %s
|
||||||
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
(book["title"], book["author"], book["genre_id"], book["total_pages"], book["current_page"],
|
(
|
||||||
book_id, g.user_id),
|
book["title"],
|
||||||
|
book["author"],
|
||||||
|
book["genre_id"],
|
||||||
|
book["total_pages"],
|
||||||
|
book["current_page"],
|
||||||
|
book_id,
|
||||||
|
g.user_id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return jsonify(to_json(owned_book(book_id)))
|
return jsonify(to_json(owned_book(book_id)))
|
||||||
|
|
||||||
|
|
||||||
@bp.delete("/books/<int(max=2147483647):book_id>")
|
@bp.delete("/books/<int(max=2147483647):book_id>")
|
||||||
@login_required
|
def delete_book(book_id: int) -> ResponseReturnValue:
|
||||||
def delete_book(book_id: int):
|
if (
|
||||||
if query_one("DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id", (book_id, g.user_id)) is None:
|
query_one(
|
||||||
|
"DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id",
|
||||||
|
(book_id, g.user_id),
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
):
|
||||||
raise ApiError(404, "Book not found")
|
raise ApiError(404, "Book not found")
|
||||||
return "", 204
|
return "", 204
|
||||||
|
|||||||
+46
-15
@@ -1,17 +1,29 @@
|
|||||||
"""PostgreSQL access: a connection pool, one connection per request, and two query helpers."""
|
"""PostgreSQL access: a connection pool, one connection per request, and query helpers."""
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg2.extensions
|
||||||
import psycopg2.extras
|
import psycopg2.extras
|
||||||
import psycopg2.pool
|
import psycopg2.pool
|
||||||
from flask import Flask, current_app, g
|
from flask import Flask, Response, current_app, g
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_DATABASE_URL = "postgresql://postgres:[email protected]:5432/postgres"
|
DEFAULT_DATABASE_URL = "postgresql://postgres:[email protected]:5432/postgres"
|
||||||
SCHEMA = Path(__file__).with_name("schema.sql")
|
SCHEMA = Path(__file__).with_name("schema.sql")
|
||||||
|
|
||||||
|
Row = dict[str, Any]
|
||||||
|
Params = tuple[object, ...]
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask) -> None:
|
def init_app(app: Flask) -> None:
|
||||||
pool = psycopg2.pool.ThreadedConnectionPool(1, 5, os.environ.get("DATABASE_URL", DEFAULT_DATABASE_URL))
|
pool = psycopg2.pool.ThreadedConnectionPool(
|
||||||
|
1, 5, os.environ.get("DATABASE_URL", DEFAULT_DATABASE_URL)
|
||||||
|
)
|
||||||
conn = pool.getconn()
|
conn = pool.getconn()
|
||||||
try:
|
try:
|
||||||
with conn, conn.cursor() as cur:
|
with conn, conn.cursor() as cur:
|
||||||
@@ -23,25 +35,34 @@ def init_app(app: Flask) -> None:
|
|||||||
app.teardown_appcontext(_release_connection)
|
app.teardown_appcontext(_release_connection)
|
||||||
|
|
||||||
|
|
||||||
def query(sql: str, params: tuple = ()) -> list[dict]:
|
def query(sql: str, params: Params = ()) -> Sequence[Row]:
|
||||||
with _connection().cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
with _connection().cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
cur.execute(sql, params)
|
cur.execute(sql, params)
|
||||||
return cur.fetchall() if cur.description else []
|
return cur.fetchall() if cur.description else []
|
||||||
|
|
||||||
|
|
||||||
def query_one(sql: str, params: tuple = ()) -> dict | None:
|
def query_one(sql: str, params: Params = ()) -> Row | None:
|
||||||
rows = query(sql, params)
|
rows = query(sql, params)
|
||||||
return rows[0] if rows else None
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
def _connection():
|
def query_row(sql: str, params: Params = ()) -> Row:
|
||||||
|
rows = query(sql, params)
|
||||||
|
if len(rows) != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Expected exactly one row but got {len(rows)}; the statement must return one row: {sql.strip()}"
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _connection() -> psycopg2.extensions.connection:
|
||||||
if "db" not in g:
|
if "db" not in g:
|
||||||
g.db = current_app.extensions["db_pool"].getconn()
|
g.db = current_app.extensions["db_pool"].getconn()
|
||||||
return g.db
|
conn: psycopg2.extensions.connection = g.db
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def _finish_transaction(response):
|
def _finish_transaction(response: Response) -> Response:
|
||||||
# Commit only successful responses so a 4xx/5xx never leaves partial writes behind.
|
|
||||||
conn = g.get("db")
|
conn = g.get("db")
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
if response.status_code < 400:
|
if response.status_code < 400:
|
||||||
@@ -51,8 +72,18 @@ def _finish_transaction(response):
|
|||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _release_connection(_exc):
|
def _release_connection(_exc: BaseException | None) -> None:
|
||||||
conn = g.pop("db", None)
|
conn = g.pop("db", None)
|
||||||
if conn is not None:
|
if conn is None:
|
||||||
conn.rollback() # no-op after commit; discards work if after_request never ran
|
return
|
||||||
current_app.extensions["db_pool"].putconn(conn)
|
pool = current_app.extensions["db_pool"]
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except psycopg2.Error:
|
||||||
|
log.warning(
|
||||||
|
"Discarding a database connection that failed to roll back; the pool will open a new one",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
pool.putconn(conn, close=True)
|
||||||
|
else:
|
||||||
|
pool.putconn(conn)
|
||||||
|
|||||||
+36
-19
@@ -1,8 +1,12 @@
|
|||||||
"""Notes slice: a per-book reading journal. Ownership is always derived through books.user_id."""
|
"""Notes slice: a per-book reading journal. Ownership is always derived through books.user_id."""
|
||||||
from flask import Blueprint, g, jsonify
|
|
||||||
|
|
||||||
from auth import login_required
|
from datetime import datetime
|
||||||
from db import query, query_one
|
from typing import TypedDict, cast
|
||||||
|
|
||||||
|
from flask import Blueprint, Response, g, jsonify
|
||||||
|
from flask.typing import ResponseReturnValue
|
||||||
|
|
||||||
|
from db import Row, query, query_one, query_row
|
||||||
from validation import ApiError, json_body, text
|
from validation import ApiError, json_body, text
|
||||||
|
|
||||||
bp = Blueprint("notes", __name__, url_prefix="/api")
|
bp = Blueprint("notes", __name__, url_prefix="/api")
|
||||||
@@ -11,24 +15,37 @@ MAX_BODY = 10_000
|
|||||||
NOTE_COLUMNS = "n.id, n.book_id, n.body, n.created_at, n.updated_at"
|
NOTE_COLUMNS = "n.id, n.book_id, n.body, n.created_at, n.updated_at"
|
||||||
|
|
||||||
|
|
||||||
def to_json(row: dict) -> dict:
|
class NoteRow(TypedDict):
|
||||||
|
id: int
|
||||||
|
book_id: int
|
||||||
|
body: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def to_json(row: Row) -> dict[str, object]:
|
||||||
|
note = cast(NoteRow, row)
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": note["id"],
|
||||||
"book_id": row["book_id"],
|
"book_id": note["book_id"],
|
||||||
"body": row["body"],
|
"body": note["body"],
|
||||||
"created_at": row["created_at"].isoformat(),
|
"created_at": note["created_at"].isoformat(),
|
||||||
"updated_at": row["updated_at"].isoformat(),
|
"updated_at": note["updated_at"].isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _require_book(book_id: int) -> None:
|
def _require_book(book_id: int) -> None:
|
||||||
if query_one("SELECT 1 FROM books WHERE id = %s AND user_id = %s", (book_id, g.user_id)) is None:
|
if (
|
||||||
|
query_one(
|
||||||
|
"SELECT 1 FROM books WHERE id = %s AND user_id = %s", (book_id, g.user_id)
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
):
|
||||||
raise ApiError(404, "Book not found")
|
raise ApiError(404, "Book not found")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/books/<int(max=2147483647):book_id>/notes")
|
@bp.get("/books/<int(max=2147483647):book_id>/notes")
|
||||||
@login_required
|
def list_notes(book_id: int) -> Response:
|
||||||
def list_notes(book_id: int):
|
|
||||||
_require_book(book_id)
|
_require_book(book_id)
|
||||||
rows = query(
|
rows = query(
|
||||||
f"SELECT {NOTE_COLUMNS} FROM notes n WHERE n.book_id = %s ORDER BY n.created_at DESC, n.id DESC",
|
f"SELECT {NOTE_COLUMNS} FROM notes n WHERE n.book_id = %s ORDER BY n.created_at DESC, n.id DESC",
|
||||||
@@ -38,17 +55,18 @@ def list_notes(book_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.post("/books/<int(max=2147483647):book_id>/notes")
|
@bp.post("/books/<int(max=2147483647):book_id>/notes")
|
||||||
@login_required
|
def add_note(book_id: int) -> ResponseReturnValue:
|
||||||
def add_note(book_id: int):
|
|
||||||
_require_book(book_id)
|
_require_book(book_id)
|
||||||
body = text(json_body({"body"}), "body", MAX_BODY)
|
body = text(json_body({"body"}), "body", MAX_BODY)
|
||||||
row = query_one(f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}", (book_id, body))
|
row = query_row(
|
||||||
|
f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}",
|
||||||
|
(book_id, body),
|
||||||
|
)
|
||||||
return jsonify(to_json(row)), 201
|
return jsonify(to_json(row)), 201
|
||||||
|
|
||||||
|
|
||||||
@bp.patch("/notes/<int(max=2147483647):note_id>")
|
@bp.patch("/notes/<int(max=2147483647):note_id>")
|
||||||
@login_required
|
def update_note(note_id: int) -> Response:
|
||||||
def update_note(note_id: int):
|
|
||||||
body = text(json_body({"body"}), "body", MAX_BODY)
|
body = text(json_body({"body"}), "body", MAX_BODY)
|
||||||
row = query_one(
|
row = query_one(
|
||||||
f"""
|
f"""
|
||||||
@@ -66,8 +84,7 @@ def update_note(note_id: int):
|
|||||||
|
|
||||||
|
|
||||||
@bp.delete("/notes/<int(max=2147483647):note_id>")
|
@bp.delete("/notes/<int(max=2147483647):note_id>")
|
||||||
@login_required
|
def delete_note(note_id: int) -> ResponseReturnValue:
|
||||||
def delete_note(note_id: int):
|
|
||||||
row = query_one(
|
row = query_one(
|
||||||
"DELETE FROM notes n USING books b WHERE n.id = %s AND b.id = n.book_id AND b.user_id = %s RETURNING n.id",
|
"DELETE FROM notes n USING books b WHERE n.id = %s AND b.id = n.book_id AND b.user_id = %s RETURNING n.id",
|
||||||
(note_id, g.user_id),
|
(note_id, g.user_id),
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Password hashing (ADR-0001): PBKDF2-SHA256 over an HMAC pepper, plus loading that pepper."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import functools
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ITERATIONS = 600_000
|
||||||
|
SALT_BYTES = 16
|
||||||
|
MIN_PEPPER_CHARS = 32
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(
|
||||||
|
password: str,
|
||||||
|
pepper: bytes,
|
||||||
|
*,
|
||||||
|
salt: bytes | None = None,
|
||||||
|
iterations: int = ITERATIONS,
|
||||||
|
) -> str:
|
||||||
|
salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt
|
||||||
|
derived = _derive(password, pepper, salt, iterations)
|
||||||
|
return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, stored: str, pepper: bytes) -> bool:
|
||||||
|
_, iterations, salt, expected = stored.split("$")
|
||||||
|
derived = _derive(password, pepper, base64.b64decode(salt), int(iterations))
|
||||||
|
return hmac.compare_digest(derived, base64.b64decode(expected))
|
||||||
|
|
||||||
|
|
||||||
|
def needs_rehash(stored: str) -> bool:
|
||||||
|
return int(stored.split("$")[1]) < ITERATIONS
|
||||||
|
|
||||||
|
|
||||||
|
def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes:
|
||||||
|
peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations)
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(raw: bytes) -> str:
|
||||||
|
return base64.b64encode(raw).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def dummy_hash() -> str:
|
||||||
|
# Verified against for unknown usernames so their response time matches a real account.
|
||||||
|
return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
def load_pepper(pepper_file: Path) -> bytes:
|
||||||
|
from_env = os.environ.get("PASSWORD_PEPPER")
|
||||||
|
if from_env is not None:
|
||||||
|
if len(from_env) < MIN_PEPPER_CHARS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. "
|
||||||
|
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
||||||
|
)
|
||||||
|
return from_env.encode()
|
||||||
|
try:
|
||||||
|
fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
f.write(secrets.token_hex(32))
|
||||||
|
log.warning(
|
||||||
|
"PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper "
|
||||||
|
"from a secrets manager.",
|
||||||
|
pepper_file,
|
||||||
|
)
|
||||||
|
pepper = pepper_file.read_text().strip()
|
||||||
|
if len(pepper) < MIN_PEPPER_CHARS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete "
|
||||||
|
"the file to regenerate it (existing passwords will stop verifying)."
|
||||||
|
)
|
||||||
|
return pepper.encode()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask>=3.1.3
|
||||||
|
psycopg2-binary>=2.9.9
|
||||||
+189
-2
@@ -1,2 +1,189 @@
|
|||||||
Flask>=3.0.0
|
# This file was autogenerated by uv via the following command:
|
||||||
psycopg2-binary>=2.9.9
|
# uv pip compile --universal --generate-hashes backend/requirements.in -o backend/requirements.txt
|
||||||
|
blinker==1.9.0 \
|
||||||
|
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
|
||||||
|
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
|
||||||
|
# via flask
|
||||||
|
click==8.5.0 \
|
||||||
|
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
|
||||||
|
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
|
||||||
|
# via flask
|
||||||
|
flask==3.1.3 \
|
||||||
|
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
|
||||||
|
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
|
||||||
|
# via -r backend/requirements.in
|
||||||
|
itsdangerous==2.2.0 \
|
||||||
|
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
|
||||||
|
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
|
||||||
|
# via flask
|
||||||
|
jinja2==3.1.6 \
|
||||||
|
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
|
||||||
|
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
|
||||||
|
# via flask
|
||||||
|
markupsafe==3.0.3 \
|
||||||
|
--hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \
|
||||||
|
--hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \
|
||||||
|
--hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \
|
||||||
|
--hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \
|
||||||
|
--hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \
|
||||||
|
--hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \
|
||||||
|
--hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \
|
||||||
|
--hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \
|
||||||
|
--hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \
|
||||||
|
--hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \
|
||||||
|
--hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \
|
||||||
|
--hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \
|
||||||
|
--hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \
|
||||||
|
--hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \
|
||||||
|
--hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \
|
||||||
|
--hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \
|
||||||
|
--hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \
|
||||||
|
--hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \
|
||||||
|
--hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \
|
||||||
|
--hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \
|
||||||
|
--hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \
|
||||||
|
--hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \
|
||||||
|
--hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \
|
||||||
|
--hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \
|
||||||
|
--hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \
|
||||||
|
--hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \
|
||||||
|
--hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \
|
||||||
|
--hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \
|
||||||
|
--hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \
|
||||||
|
--hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \
|
||||||
|
--hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \
|
||||||
|
--hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \
|
||||||
|
--hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \
|
||||||
|
--hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \
|
||||||
|
--hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \
|
||||||
|
--hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \
|
||||||
|
--hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \
|
||||||
|
--hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \
|
||||||
|
--hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \
|
||||||
|
--hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \
|
||||||
|
--hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \
|
||||||
|
--hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \
|
||||||
|
--hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \
|
||||||
|
--hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \
|
||||||
|
--hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \
|
||||||
|
--hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \
|
||||||
|
--hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \
|
||||||
|
--hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \
|
||||||
|
--hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \
|
||||||
|
--hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \
|
||||||
|
--hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \
|
||||||
|
--hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \
|
||||||
|
--hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \
|
||||||
|
--hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \
|
||||||
|
--hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \
|
||||||
|
--hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \
|
||||||
|
--hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \
|
||||||
|
--hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \
|
||||||
|
--hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \
|
||||||
|
--hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \
|
||||||
|
--hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \
|
||||||
|
--hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \
|
||||||
|
--hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \
|
||||||
|
--hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \
|
||||||
|
--hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \
|
||||||
|
--hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \
|
||||||
|
--hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \
|
||||||
|
--hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \
|
||||||
|
--hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \
|
||||||
|
--hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \
|
||||||
|
--hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \
|
||||||
|
--hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \
|
||||||
|
--hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \
|
||||||
|
--hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \
|
||||||
|
--hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \
|
||||||
|
--hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \
|
||||||
|
--hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \
|
||||||
|
--hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \
|
||||||
|
--hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \
|
||||||
|
--hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \
|
||||||
|
--hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \
|
||||||
|
--hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \
|
||||||
|
--hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \
|
||||||
|
--hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \
|
||||||
|
--hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \
|
||||||
|
--hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \
|
||||||
|
--hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \
|
||||||
|
--hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \
|
||||||
|
--hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50
|
||||||
|
# via
|
||||||
|
# flask
|
||||||
|
# jinja2
|
||||||
|
# werkzeug
|
||||||
|
psycopg2-binary==2.9.13 \
|
||||||
|
--hash=sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528 \
|
||||||
|
--hash=sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce \
|
||||||
|
--hash=sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf \
|
||||||
|
--hash=sha256:086659ab083119f7ee87a779e31b94211cf162b708fc9a6bec771f75c73ac3e6 \
|
||||||
|
--hash=sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf \
|
||||||
|
--hash=sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964 \
|
||||||
|
--hash=sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5 \
|
||||||
|
--hash=sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c \
|
||||||
|
--hash=sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba \
|
||||||
|
--hash=sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076 \
|
||||||
|
--hash=sha256:1f4c7bdbafdf9dc018efbc29213b73f8308332888ba76a4cf503f560bfd21705 \
|
||||||
|
--hash=sha256:202dedd5cadb3e5dfd4d0415ab2fc5d5b44f4208de5308938e3e74ae222b638e \
|
||||||
|
--hash=sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed \
|
||||||
|
--hash=sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24 \
|
||||||
|
--hash=sha256:28eb30bf4a52c1117406f45771038faa96f882fdeeeb0ce43b960a1dbc6c1fd2 \
|
||||||
|
--hash=sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26 \
|
||||||
|
--hash=sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798 \
|
||||||
|
--hash=sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98 \
|
||||||
|
--hash=sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0 \
|
||||||
|
--hash=sha256:33a6d3c47f9655b481b2cdc1b4bf71c235e054e55663d3066036b6ce5fbe5165 \
|
||||||
|
--hash=sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008 \
|
||||||
|
--hash=sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50 \
|
||||||
|
--hash=sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29 \
|
||||||
|
--hash=sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a \
|
||||||
|
--hash=sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b \
|
||||||
|
--hash=sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69 \
|
||||||
|
--hash=sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955 \
|
||||||
|
--hash=sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720 \
|
||||||
|
--hash=sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c \
|
||||||
|
--hash=sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c \
|
||||||
|
--hash=sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff \
|
||||||
|
--hash=sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916 \
|
||||||
|
--hash=sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8 \
|
||||||
|
--hash=sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3 \
|
||||||
|
--hash=sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f \
|
||||||
|
--hash=sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22 \
|
||||||
|
--hash=sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8 \
|
||||||
|
--hash=sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6 \
|
||||||
|
--hash=sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b \
|
||||||
|
--hash=sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020 \
|
||||||
|
--hash=sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7 \
|
||||||
|
--hash=sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2 \
|
||||||
|
--hash=sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7 \
|
||||||
|
--hash=sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac \
|
||||||
|
--hash=sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b \
|
||||||
|
--hash=sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292 \
|
||||||
|
--hash=sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2 \
|
||||||
|
--hash=sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c \
|
||||||
|
--hash=sha256:c24c98fe1a113db287dfb1958771eafca97b7db812f23b7897c2a12b6b904c22 \
|
||||||
|
--hash=sha256:c519e406287085f43aa0d3061936edf1ba51286093532f215315c6ab8ba92c3b \
|
||||||
|
--hash=sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9 \
|
||||||
|
--hash=sha256:d2fc9342aad969b9a28490a4c3eaba94b35beb2d26e9a39b31d1430378aa71b2 \
|
||||||
|
--hash=sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0 \
|
||||||
|
--hash=sha256:db31cf7f617a51625f1473d8a66fc35dac159af8b28e80bc014ed3ee994a9fbf \
|
||||||
|
--hash=sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9 \
|
||||||
|
--hash=sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1 \
|
||||||
|
--hash=sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8 \
|
||||||
|
--hash=sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373 \
|
||||||
|
--hash=sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60 \
|
||||||
|
--hash=sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e \
|
||||||
|
--hash=sha256:f124954a32640dfb5c000d33028f48053930d7ff226bc74cde5fb316f9c6fcb6 \
|
||||||
|
--hash=sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee \
|
||||||
|
--hash=sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0 \
|
||||||
|
--hash=sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8 \
|
||||||
|
--hash=sha256:f4cdfe41149dcc5583a3b7a2f0ad433f75bb3afd1c7a7332e63df89b05e34666 \
|
||||||
|
--hash=sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1 \
|
||||||
|
--hash=sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389
|
||||||
|
# via -r backend/requirements.in
|
||||||
|
werkzeug==3.1.9 \
|
||||||
|
--hash=sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060 \
|
||||||
|
--hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab
|
||||||
|
# via flask
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ CREATE TABLE IF NOT EXISTS genres (
|
|||||||
name TEXT NOT NULL UNIQUE
|
name TEXT NOT NULL UNIQUE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- WHERE NOT EXISTS instead of ON CONFLICT: ON CONFLICT consumes a sequence value per row
|
|
||||||
-- on every startup, which would eventually overflow SMALLSERIAL.
|
|
||||||
INSERT INTO genres (name)
|
INSERT INTO genres (name)
|
||||||
SELECT seed.name
|
SELECT seed.name
|
||||||
FROM (VALUES ('Fiction'), ('Non-Fiction'), ('Mystery'), ('Thriller'), ('Science Fiction'),
|
FROM (VALUES ('Fiction'), ('Non-Fiction'), ('Mystery'), ('Thriller'), ('Science Fiction'),
|
||||||
|
|||||||
@@ -1,26 +1,32 @@
|
|||||||
"""Point the app at a dedicated test database before any test module imports it."""
|
"""Point the app at a dedicated test database before any test module imports it."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2 import sql
|
from psycopg2 import sql
|
||||||
|
|
||||||
ADMIN_URL = os.environ.get("TEST_ADMIN_DATABASE_URL", "postgresql://postgres:[email protected]:5432/postgres")
|
ADMIN_URL = os.environ.get(
|
||||||
|
"TEST_ADMIN_DATABASE_URL", "postgresql://postgres:[email protected]:5432/postgres"
|
||||||
|
)
|
||||||
TEST_DATABASE = "books_test"
|
TEST_DATABASE = "books_test"
|
||||||
|
|
||||||
|
|
||||||
def _ensure_test_database() -> None:
|
def _ensure_test_database() -> None:
|
||||||
conn = psycopg2.connect(ADMIN_URL)
|
conn = psycopg2.connect(ADMIN_URL)
|
||||||
conn.autocommit = True # CREATE DATABASE cannot run inside a transaction
|
conn.autocommit = True
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (TEST_DATABASE,))
|
cur.execute(
|
||||||
|
"SELECT 1 FROM pg_database WHERE datname = %s", (TEST_DATABASE,)
|
||||||
|
)
|
||||||
if cur.fetchone() is None:
|
if cur.fetchone() is None:
|
||||||
cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(TEST_DATABASE)))
|
cur.execute(
|
||||||
|
sql.SQL("CREATE DATABASE {}").format(sql.Identifier(TEST_DATABASE))
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
_ensure_test_database()
|
_ensure_test_database()
|
||||||
# Assigned, never defaulted: tests TRUNCATE tables, so they must not inherit a real DATABASE_URL.
|
|
||||||
os.environ["DATABASE_URL"] = ADMIN_URL.rsplit("/", 1)[0] + "/" + TEST_DATABASE
|
os.environ["DATABASE_URL"] = ADMIN_URL.rsplit("/", 1)[0] + "/" + TEST_DATABASE
|
||||||
os.environ["PASSWORD_PEPPER"] = "test-pepper-0123456789abcdef0123456789abcdef"
|
os.environ["PASSWORD_PEPPER"] = "test-pepper-0123456789abcdef0123456789abcdef"
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import unittest
|
|||||||
|
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
|
||||||
from app import app
|
from app import create_app
|
||||||
|
|
||||||
HTTPS = "https://localhost" # the session cookie is Secure
|
HTTPS = "https://localhost"
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
def db_execute(sql: str, params: tuple = ()) -> list[tuple]:
|
def db_execute(sql: str, params: tuple = ()) -> list[tuple]:
|
||||||
@@ -34,7 +35,12 @@ class ApiTestCase(unittest.TestCase):
|
|||||||
return (client or self.client).open(path, **kwargs)
|
return (client or self.client).open(path, **kwargs)
|
||||||
|
|
||||||
def register(self, username="alice", password="correct horse battery", client=None):
|
def register(self, username="alice", password="correct horse battery", client=None):
|
||||||
response = self.call("POST", "/api/auth/register", {"username": username, "password": password}, client)
|
response = self.call(
|
||||||
|
"POST",
|
||||||
|
"/api/auth/register",
|
||||||
|
{"username": username, "password": password},
|
||||||
|
client,
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 201, response.get_json())
|
self.assertEqual(response.status_code, 201, response.get_json())
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tests.support import HTTPS, ApiTestCase, db_execute
|
import db
|
||||||
|
from tests.support import HTTPS, ApiTestCase, app, db_execute
|
||||||
|
|
||||||
SCHEMA = Path(__file__).parent.parent / "schema.sql"
|
SCHEMA = Path(__file__).parent.parent / "schema.sql"
|
||||||
|
|
||||||
@@ -16,7 +20,9 @@ class AppTests(ApiTestCase):
|
|||||||
db_execute(SCHEMA.read_text())
|
db_execute(SCHEMA.read_text())
|
||||||
db_execute(SCHEMA.read_text())
|
db_execute(SCHEMA.read_text())
|
||||||
self.assertEqual(db_execute("SELECT count(*) FROM genres")[0][0], 16)
|
self.assertEqual(db_execute("SELECT count(*) FROM genres")[0][0], 16)
|
||||||
self.assertEqual(db_execute("SELECT last_value FROM genres_id_seq")[0][0], before)
|
self.assertEqual(
|
||||||
|
db_execute("SELECT last_value FROM genres_id_seq")[0][0], before
|
||||||
|
)
|
||||||
|
|
||||||
def test_unknown_route_returns_json_404(self):
|
def test_unknown_route_returns_json_404(self):
|
||||||
response = self.call("GET", "/api/nope")
|
response = self.call("GET", "/api/nope")
|
||||||
@@ -24,9 +30,46 @@ class AppTests(ApiTestCase):
|
|||||||
self.assertIn("error", response.get_json())
|
self.assertIn("error", response.get_json())
|
||||||
|
|
||||||
def test_mutation_without_json_content_type_is_rejected(self):
|
def test_mutation_without_json_content_type_is_rejected(self):
|
||||||
response = self.client.post("/api/health", data="x", content_type="text/plain", base_url=HTTPS)
|
response = self.client.post(
|
||||||
|
"/api/health", data="x", content_type="text/plain", base_url=HTTPS
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
response.get_json(),
|
response.get_json(),
|
||||||
{"error": "Request body must be JSON with Content-Type: application/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())
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionReleaseTests(ApiTestCase):
|
||||||
|
def test_closed_connection_is_discarded_instead_of_leaking_a_pool_slot(self):
|
||||||
|
pool = app.extensions["db_pool"]
|
||||||
|
with self.assertLogs("db", "WARNING"):
|
||||||
|
for _ in range(pool.maxconn + 1):
|
||||||
|
with app.app_context():
|
||||||
|
db.query("SELECT 1")
|
||||||
|
db._connection().close()
|
||||||
|
with app.app_context():
|
||||||
|
self.assertEqual(db.query("SELECT 1 AS one"), [{"one": 1}])
|
||||||
|
|
||||||
|
|
||||||
|
class AppFactoryTests(ApiTestCase):
|
||||||
|
def test_importing_the_app_module_does_not_connect_to_the_database(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-c", "import app"],
|
||||||
|
cwd=Path(__file__).parent.parent,
|
||||||
|
env=os.environ | {"DATABASE_URL": "postgresql://[email protected]:9/none"},
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|||||||
+97
-18
@@ -1,14 +1,19 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from app import app
|
from app import create_app
|
||||||
from auth import hash_password
|
from passwords import hash_password
|
||||||
from tests.support import ApiTestCase, db_execute
|
from tests.support import ApiTestCase, app, db_execute
|
||||||
|
|
||||||
|
|
||||||
class AuthTests(ApiTestCase):
|
class AuthTests(ApiTestCase):
|
||||||
def session_token(self, client=None) -> str:
|
def session_token(self, client=None) -> str:
|
||||||
return (client or self.client).get_cookie("sid", domain="localhost", path="/api").value
|
return (
|
||||||
|
(client or self.client)
|
||||||
|
.get_cookie("sid", domain="localhost", path="/api")
|
||||||
|
.value
|
||||||
|
)
|
||||||
|
|
||||||
def test_register_logs_in_and_me_returns_user(self):
|
def test_register_logs_in_and_me_returns_user(self):
|
||||||
response = self.register("Alice")
|
response = self.register("Alice")
|
||||||
@@ -25,11 +30,20 @@ class AuthTests(ApiTestCase):
|
|||||||
def test_session_token_is_stored_hashed(self):
|
def test_session_token_is_stored_hashed(self):
|
||||||
self.register()
|
self.register()
|
||||||
digest = hashlib.sha256(self.session_token().encode()).digest()
|
digest = hashlib.sha256(self.session_token().encode()).digest()
|
||||||
self.assertEqual(db_execute("SELECT count(*) FROM sessions WHERE token_hash = %s", (digest,))[0][0], 1)
|
self.assertEqual(
|
||||||
|
db_execute(
|
||||||
|
"SELECT count(*) FROM sessions WHERE token_hash = %s", (digest,)
|
||||||
|
)[0][0],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
def test_duplicate_username_is_case_insensitive(self):
|
def test_duplicate_username_is_case_insensitive(self):
|
||||||
self.register("Alice")
|
self.register("Alice")
|
||||||
response = self.call("POST", "/api/auth/register", {"username": "alice", "password": "another long password"})
|
response = self.call(
|
||||||
|
"POST",
|
||||||
|
"/api/auth/register",
|
||||||
|
{"username": "alice", "password": "another long password"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 409)
|
self.assertEqual(response.status_code, 409)
|
||||||
self.assertEqual(response.get_json()["field"], "username")
|
self.assertEqual(response.get_json()["field"], "username")
|
||||||
|
|
||||||
@@ -40,7 +54,14 @@ class AuthTests(ApiTestCase):
|
|||||||
({"username": "alice", "password": "short"}, "password"),
|
({"username": "alice", "password": "short"}, "password"),
|
||||||
({"username": "alice", "password": "x" * 1025}, "password"),
|
({"username": "alice", "password": "x" * 1025}, "password"),
|
||||||
({"username": "alice", "password": "\ud800" * 12}, "password"),
|
({"username": "alice", "password": "\ud800" * 12}, "password"),
|
||||||
({"username": "alice", "password": "long enough password", "is_admin": True}, "is_admin"),
|
(
|
||||||
|
{
|
||||||
|
"username": "alice",
|
||||||
|
"password": "long enough password",
|
||||||
|
"is_admin": True,
|
||||||
|
},
|
||||||
|
"is_admin",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
for payload, field in cases:
|
for payload, field in cases:
|
||||||
with self.subTest(payload=payload):
|
with self.subTest(payload=payload):
|
||||||
@@ -50,34 +71,53 @@ class AuthTests(ApiTestCase):
|
|||||||
|
|
||||||
def test_login_is_case_insensitive_on_username(self):
|
def test_login_is_case_insensitive_on_username(self):
|
||||||
self.register("Alice")
|
self.register("Alice")
|
||||||
self.client = app.test_client() # fresh cookie jar: signed out
|
self.client = app.test_client()
|
||||||
response = self.call("POST", "/api/auth/login", {"username": "ALICE", "password": "correct horse battery"})
|
response = self.call(
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
{"username": "ALICE", "password": "correct horse battery"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.get_json(), {"id": 1, "username": "Alice"})
|
self.assertEqual(response.get_json(), {"id": 1, "username": "Alice"})
|
||||||
|
|
||||||
def test_wrong_password_and_unknown_user_are_indistinguishable(self):
|
def test_wrong_password_and_unknown_user_are_indistinguishable(self):
|
||||||
self.register()
|
self.register()
|
||||||
wrong = self.call("POST", "/api/auth/login", {"username": "alice", "password": "not the password"})
|
wrong = self.call(
|
||||||
unknown = self.call("POST", "/api/auth/login", {"username": "nobody", "password": "not the password"})
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
{"username": "alice", "password": "not the password"},
|
||||||
|
)
|
||||||
|
unknown = self.call(
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
{"username": "nobody", "password": "not the password"},
|
||||||
|
)
|
||||||
self.assertEqual(wrong.status_code, 401)
|
self.assertEqual(wrong.status_code, 401)
|
||||||
self.assertEqual(unknown.status_code, 401)
|
self.assertEqual(unknown.status_code, 401)
|
||||||
self.assertEqual(wrong.get_json(), unknown.get_json())
|
self.assertEqual(wrong.get_json(), unknown.get_json())
|
||||||
self.assertEqual(wrong.get_json(), {"error": "Invalid username or password"})
|
self.assertEqual(wrong.get_json(), {"error": "Invalid username or password"})
|
||||||
|
|
||||||
def test_login_with_malformed_username_is_401_not_500(self):
|
def test_login_with_malformed_username_is_401_not_500(self):
|
||||||
response = self.call("POST", "/api/auth/login", {"username": "a\x00b", "password": "whatever password"})
|
response = self.call(
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
{"username": "a\x00b", "password": "whatever password"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|
||||||
def test_me_without_session_is_401(self):
|
def test_me_without_session_is_401(self):
|
||||||
response = self.call("GET", "/api/auth/me")
|
response = self.call("GET", "/api/auth/me")
|
||||||
self.assertEqual(response.status_code, 401)
|
self.assertEqual(response.status_code, 401)
|
||||||
self.assertEqual(response.get_json(), {"error": "Not signed in or session expired; log in again"})
|
self.assertEqual(
|
||||||
|
response.get_json(),
|
||||||
|
{"error": "Not signed in or session expired; log in again"},
|
||||||
|
)
|
||||||
|
|
||||||
def test_logout_revokes_token_server_side(self):
|
def test_logout_revokes_token_server_side(self):
|
||||||
self.register()
|
self.register()
|
||||||
token = self.session_token()
|
token = self.session_token()
|
||||||
self.assertEqual(self.call("POST", "/api/auth/logout").status_code, 204)
|
self.assertEqual(self.call("POST", "/api/auth/logout").status_code, 204)
|
||||||
self.client.set_cookie("sid", token, domain="localhost", path="/api") # replay the stolen cookie
|
self.client.set_cookie("sid", token, domain="localhost", path="/api")
|
||||||
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401)
|
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401)
|
||||||
|
|
||||||
def test_idle_session_expires(self):
|
def test_idle_session_expires(self):
|
||||||
@@ -87,14 +127,18 @@ class AuthTests(ApiTestCase):
|
|||||||
|
|
||||||
def test_absolute_cap_applies_even_inside_idle_window(self):
|
def test_absolute_cap_applies_even_inside_idle_window(self):
|
||||||
self.register()
|
self.register()
|
||||||
db_execute("UPDATE sessions SET created_at = now() - interval '12 hours 1 second'")
|
db_execute(
|
||||||
|
"UPDATE sessions SET created_at = now() - interval '12 hours 1 second'"
|
||||||
|
)
|
||||||
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401)
|
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 401)
|
||||||
|
|
||||||
def test_activity_slides_idle_expiry(self):
|
def test_activity_slides_idle_expiry(self):
|
||||||
self.register()
|
self.register()
|
||||||
db_execute("UPDATE sessions SET expires_at = now() + interval '1 minute'")
|
db_execute("UPDATE sessions SET expires_at = now() + interval '1 minute'")
|
||||||
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 200)
|
self.assertEqual(self.call("GET", "/api/auth/me").status_code, 200)
|
||||||
remaining = db_execute("SELECT expires_at - now() > interval '29 minutes' FROM sessions")[0][0]
|
remaining = db_execute(
|
||||||
|
"SELECT expires_at - now() > interval '29 minutes' FROM sessions"
|
||||||
|
)[0][0]
|
||||||
self.assertTrue(remaining)
|
self.assertTrue(remaining)
|
||||||
|
|
||||||
def test_old_hash_is_upgraded_on_login(self):
|
def test_old_hash_is_upgraded_on_login(self):
|
||||||
@@ -102,6 +146,41 @@ class AuthTests(ApiTestCase):
|
|||||||
pepper = os.environ["PASSWORD_PEPPER"].encode()
|
pepper = os.environ["PASSWORD_PEPPER"].encode()
|
||||||
old = hash_password("correct horse battery", pepper, iterations=1000)
|
old = hash_password("correct horse battery", pepper, iterations=1000)
|
||||||
db_execute("UPDATE users SET password_hash = %s", (old,))
|
db_execute("UPDATE users SET password_hash = %s", (old,))
|
||||||
response = self.call("POST", "/api/auth/login", {"username": "alice", "password": "correct horse battery"})
|
response = self.call(
|
||||||
|
"POST",
|
||||||
|
"/api/auth/login",
|
||||||
|
{"username": "alice", "password": "correct horse battery"},
|
||||||
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue(db_execute("SELECT password_hash FROM users")[0][0].startswith("pbkdf2_sha256$600000$"))
|
self.assertTrue(
|
||||||
|
db_execute("SELECT password_hash FROM users")[0][0].startswith(
|
||||||
|
"pbkdf2_sha256$600000$"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthByDefaultTests(ApiTestCase):
|
||||||
|
PUBLIC = frozenset({"health", "auth.register", "auth.login", "auth.logout"})
|
||||||
|
|
||||||
|
def test_only_the_expected_endpoints_allow_anonymous_access(self):
|
||||||
|
public = {
|
||||||
|
endpoint
|
||||||
|
for endpoint, view in app.view_functions.items()
|
||||||
|
if getattr(view, "allow_anonymous", False)
|
||||||
|
}
|
||||||
|
self.assertEqual(public, self.PUBLIC)
|
||||||
|
|
||||||
|
def test_every_other_route_rejects_anonymous_requests(self):
|
||||||
|
for rule in app.url_map.iter_rules():
|
||||||
|
if rule.endpoint in self.PUBLIC or rule.endpoint == "static":
|
||||||
|
continue
|
||||||
|
path = re.sub(r"<[^>]+>", "1", rule.rule)
|
||||||
|
for method in rule.methods - {"HEAD", "OPTIONS"}:
|
||||||
|
with self.subTest(method=method, path=path):
|
||||||
|
self.assertEqual(self.call(method, path).status_code, 401)
|
||||||
|
|
||||||
|
def test_a_new_route_requires_login_without_any_decorator(self):
|
||||||
|
fresh = create_app()
|
||||||
|
fresh.add_url_rule("/api/new-thing", "new_thing", lambda: {"ok": True})
|
||||||
|
response = self.call("GET", "/api/new-thing", client=fresh.test_client())
|
||||||
|
self.assertEqual(response.status_code, 401)
|
||||||
|
|||||||
+103
-15
@@ -1,4 +1,10 @@
|
|||||||
from tests.support import ApiTestCase
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
from tests.support import ApiTestCase, db_execute
|
||||||
|
|
||||||
|
|
||||||
class BookTests(ApiTestCase):
|
class BookTests(ApiTestCase):
|
||||||
@@ -9,9 +15,15 @@ class BookTests(ApiTestCase):
|
|||||||
def test_create_returns_full_shape(self):
|
def test_create_returns_full_shape(self):
|
||||||
book = self.create_book()
|
book = self.create_book()
|
||||||
self.assertEqual(book["title"], "Dune")
|
self.assertEqual(book["title"], "Dune")
|
||||||
self.assertEqual(book["genre"], {"id": self.genre_id("Science Fiction"), "name": "Science Fiction"})
|
self.assertEqual(
|
||||||
self.assertEqual((book["current_page"], book["total_pages"], book["status"]), (0, 412, "not_started"))
|
book["genre"],
|
||||||
self.assertIn("T", book["created_at"]) # ISO 8601
|
{"id": self.genre_id("Science Fiction"), "name": "Science Fiction"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(book["current_page"], book["total_pages"], book["status"]),
|
||||||
|
(0, 412, "not_started"),
|
||||||
|
)
|
||||||
|
self.assertIn("T", book["created_at"])
|
||||||
|
|
||||||
def test_create_trims_text(self):
|
def test_create_trims_text(self):
|
||||||
book = self.create_book(title=" Dune ")
|
book = self.create_book(title=" Dune ")
|
||||||
@@ -19,25 +31,44 @@ class BookTests(ApiTestCase):
|
|||||||
|
|
||||||
def test_progress_updates_status(self):
|
def test_progress_updates_status(self):
|
||||||
book = self.create_book()
|
book = self.create_book()
|
||||||
reading = self.call("PATCH", f"/api/books/{book['id']}", {"current_page": 100}).get_json()
|
reading = self.call(
|
||||||
|
"PATCH", f"/api/books/{book['id']}", {"current_page": 100}
|
||||||
|
).get_json()
|
||||||
self.assertEqual((reading["current_page"], reading["status"]), (100, "reading"))
|
self.assertEqual((reading["current_page"], reading["status"]), (100, "reading"))
|
||||||
finished = self.call("PATCH", f"/api/books/{book['id']}", {"current_page": 412}).get_json()
|
finished = self.call(
|
||||||
|
"PATCH", f"/api/books/{book['id']}", {"current_page": 412}
|
||||||
|
).get_json()
|
||||||
self.assertEqual(finished["status"], "finished")
|
self.assertEqual(finished["status"], "finished")
|
||||||
self.assertEqual(self.call("GET", f"/api/books/{book['id']}").get_json()["current_page"], 412)
|
self.assertEqual(
|
||||||
|
self.call("GET", f"/api/books/{book['id']}").get_json()["current_page"], 412
|
||||||
|
)
|
||||||
|
|
||||||
def test_edit_details(self):
|
def test_edit_details(self):
|
||||||
book = self.create_book()
|
book = self.create_book()
|
||||||
response = self.call(
|
response = self.call(
|
||||||
"PATCH", f"/api/books/{book['id']}",
|
"PATCH",
|
||||||
{"title": "Dune Messiah", "genre_id": self.genre_id("Fantasy"), "total_pages": 256},
|
f"/api/books/{book['id']}",
|
||||||
|
{
|
||||||
|
"title": "Dune Messiah",
|
||||||
|
"genre_id": self.genre_id("Fantasy"),
|
||||||
|
"total_pages": 256,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
updated = response.get_json()
|
updated = response.get_json()
|
||||||
self.assertEqual((updated["title"], updated["genre"]["name"], updated["total_pages"]), ("Dune Messiah", "Fantasy", 256))
|
self.assertEqual(
|
||||||
|
(updated["title"], updated["genre"]["name"], updated["total_pages"]),
|
||||||
|
("Dune Messiah", "Fantasy", 256),
|
||||||
|
)
|
||||||
self.assertGreater(updated["updated_at"], book["updated_at"])
|
self.assertGreater(updated["updated_at"], book["updated_at"])
|
||||||
|
|
||||||
def test_create_validation(self):
|
def test_create_validation(self):
|
||||||
valid = {"title": "Dune", "author": "Frank Herbert", "genre_id": self.genre_id("Fiction"), "total_pages": 412}
|
valid = {
|
||||||
|
"title": "Dune",
|
||||||
|
"author": "Frank Herbert",
|
||||||
|
"genre_id": self.genre_id("Fiction"),
|
||||||
|
"total_pages": 412,
|
||||||
|
}
|
||||||
cases = [
|
cases = [
|
||||||
({**valid, "title": " "}, "title"),
|
({**valid, "title": " "}, "title"),
|
||||||
({k: v for k, v in valid.items() if k != "author"}, "author"),
|
({k: v for k, v in valid.items() if k != "author"}, "author"),
|
||||||
@@ -69,13 +100,19 @@ class BookTests(ApiTestCase):
|
|||||||
|
|
||||||
def test_empty_patch_is_rejected(self):
|
def test_empty_patch_is_rejected(self):
|
||||||
book = self.create_book()
|
book = self.create_book()
|
||||||
self.assertEqual(self.call("PATCH", f"/api/books/{book['id']}", {}).status_code, 400)
|
self.assertEqual(
|
||||||
|
self.call("PATCH", f"/api/books/{book['id']}", {}).status_code, 400
|
||||||
|
)
|
||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
book = self.create_book()
|
book = self.create_book()
|
||||||
self.assertEqual(self.call("DELETE", f"/api/books/{book['id']}").status_code, 204)
|
self.assertEqual(
|
||||||
|
self.call("DELETE", f"/api/books/{book['id']}").status_code, 204
|
||||||
|
)
|
||||||
self.assertEqual(self.call("GET", f"/api/books/{book['id']}").status_code, 404)
|
self.assertEqual(self.call("GET", f"/api/books/{book['id']}").status_code, 404)
|
||||||
self.assertEqual(self.call("DELETE", f"/api/books/{book['id']}").status_code, 404)
|
self.assertEqual(
|
||||||
|
self.call("DELETE", f"/api/books/{book['id']}").status_code, 404
|
||||||
|
)
|
||||||
|
|
||||||
def test_out_of_range_id_is_404(self):
|
def test_out_of_range_id_is_404(self):
|
||||||
self.assertEqual(self.call("GET", "/api/books/99999999999").status_code, 404)
|
self.assertEqual(self.call("GET", "/api/books/99999999999").status_code, 404)
|
||||||
@@ -105,10 +142,61 @@ class BookIsolationTests(ApiTestCase):
|
|||||||
book = self.create_book()
|
book = self.create_book()
|
||||||
bob = self.other_user("bob")
|
bob = self.other_user("bob")
|
||||||
path = f"/api/books/{book['id']}"
|
path = f"/api/books/{book['id']}"
|
||||||
for method, payload in (("GET", None), ("PATCH", {"title": "Hacked"}), ("DELETE", None)):
|
for method, payload in (
|
||||||
|
("GET", None),
|
||||||
|
("PATCH", {"title": "Hacked"}),
|
||||||
|
("DELETE", None),
|
||||||
|
):
|
||||||
with self.subTest(method=method):
|
with self.subTest(method=method):
|
||||||
response = self.call(method, path, payload, client=bob)
|
response = self.call(method, path, payload, client=bob)
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
self.assertEqual(response.get_json(), {"error": "Book not found"})
|
self.assertEqual(response.get_json(), {"error": "Book not found"})
|
||||||
self.assertEqual(self.call("GET", "/api/books", client=bob).get_json(), [])
|
self.assertEqual(self.call("GET", "/api/books", client=bob).get_json(), [])
|
||||||
self.assertEqual(self.call("GET", path).get_json()["title"], "Dune")
|
self.assertEqual(self.call("GET", path).get_json()["title"], "Dune")
|
||||||
|
|
||||||
|
|
||||||
|
class ConcurrentUpdateTests(ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.register()
|
||||||
|
|
||||||
|
def wait_for_lock_wait(self):
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
waiting = db_execute(
|
||||||
|
"SELECT count(*) FROM pg_stat_activity"
|
||||||
|
" WHERE datname = current_database() AND wait_event_type = 'Lock'"
|
||||||
|
)[0][0]
|
||||||
|
if waiting:
|
||||||
|
return
|
||||||
|
time.sleep(0.02)
|
||||||
|
self.fail("PATCH never waited on the locked book row")
|
||||||
|
|
||||||
|
def test_concurrent_updates_to_different_fields_both_persist(self):
|
||||||
|
book = self.create_book()
|
||||||
|
other_session = psycopg2.connect(os.environ["DATABASE_URL"])
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
with other_session.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE books SET title = 'Dune Messiah' WHERE id = %s",
|
||||||
|
(book["id"],),
|
||||||
|
)
|
||||||
|
patch = threading.Thread(
|
||||||
|
target=lambda: result.update(
|
||||||
|
response=self.call(
|
||||||
|
"PATCH", f"/api/books/{book['id']}", {"current_page": 100}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
patch.start()
|
||||||
|
self.wait_for_lock_wait()
|
||||||
|
other_session.commit()
|
||||||
|
patch.join(timeout=10)
|
||||||
|
finally:
|
||||||
|
other_session.close()
|
||||||
|
|
||||||
|
self.assertFalse(patch.is_alive(), "PATCH did not finish")
|
||||||
|
self.assertEqual(result["response"].status_code, 200)
|
||||||
|
saved = self.call("GET", f"/api/books/{book['id']}").get_json()
|
||||||
|
self.assertEqual((saved["title"], saved["current_page"]), ("Dune Messiah", 100))
|
||||||
|
|||||||
@@ -29,19 +29,30 @@ class NoteTests(ApiTestCase):
|
|||||||
|
|
||||||
def test_delete(self):
|
def test_delete(self):
|
||||||
note = self.add("Temporary").get_json()
|
note = self.add("Temporary").get_json()
|
||||||
self.assertEqual(self.call("DELETE", f"/api/notes/{note['id']}").status_code, 204)
|
self.assertEqual(
|
||||||
|
self.call("DELETE", f"/api/notes/{note['id']}").status_code, 204
|
||||||
|
)
|
||||||
self.assertEqual(self.call("GET", self.notes_path).get_json(), [])
|
self.assertEqual(self.call("GET", self.notes_path).get_json(), [])
|
||||||
self.assertEqual(self.call("DELETE", f"/api/notes/{note['id']}").status_code, 404)
|
self.assertEqual(
|
||||||
|
self.call("DELETE", f"/api/notes/{note['id']}").status_code, 404
|
||||||
|
)
|
||||||
|
|
||||||
def test_validation(self):
|
def test_validation(self):
|
||||||
for body, status in ((" ", 400), ("x" * 10_001, 400), ("a\x00b", 400)):
|
for body, status in ((" ", 400), ("x" * 10_001, 400), ("a\x00b", 400)):
|
||||||
with self.subTest(length=len(body)):
|
with self.subTest(length=len(body)):
|
||||||
self.assertEqual(self.add(body).status_code, status)
|
self.assertEqual(self.add(body).status_code, status)
|
||||||
self.assertEqual(self.call("POST", self.notes_path, {"body": "ok", "book_id": 2}).status_code, 400)
|
self.assertEqual(
|
||||||
|
self.call(
|
||||||
|
"POST", self.notes_path, {"body": "ok", "book_id": 2}
|
||||||
|
).status_code,
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
|
||||||
def test_missing_book_and_out_of_range_ids_are_404(self):
|
def test_missing_book_and_out_of_range_ids_are_404(self):
|
||||||
self.assertEqual(self.call("GET", "/api/books/999/notes").status_code, 404)
|
self.assertEqual(self.call("GET", "/api/books/999/notes").status_code, 404)
|
||||||
self.assertEqual(self.call("PATCH", "/api/notes/99999999999", {"body": "x"}).status_code, 404)
|
self.assertEqual(
|
||||||
|
self.call("PATCH", "/api/notes/99999999999", {"body": "x"}).status_code, 404
|
||||||
|
)
|
||||||
|
|
||||||
def test_deleting_book_deletes_its_notes(self):
|
def test_deleting_book_deletes_its_notes(self):
|
||||||
self.add("Will be gone")
|
self.add("Will be gone")
|
||||||
@@ -53,7 +64,9 @@ class NoteIsolationTests(ApiTestCase):
|
|||||||
def test_other_user_cannot_read_add_edit_or_delete_notes(self):
|
def test_other_user_cannot_read_add_edit_or_delete_notes(self):
|
||||||
self.register("alice")
|
self.register("alice")
|
||||||
book = self.create_book()
|
book = self.create_book()
|
||||||
note = self.call("POST", f"/api/books/{book['id']}/notes", {"body": "Private"}).get_json()
|
note = self.call(
|
||||||
|
"POST", f"/api/books/{book['id']}/notes", {"body": "Private"}
|
||||||
|
).get_json()
|
||||||
bob = self.other_user("bob")
|
bob = self.other_user("bob")
|
||||||
attempts = (
|
attempts = (
|
||||||
("GET", f"/api/books/{book['id']}/notes", None),
|
("GET", f"/api/books/{book['id']}/notes", None),
|
||||||
@@ -63,6 +76,11 @@ class NoteIsolationTests(ApiTestCase):
|
|||||||
)
|
)
|
||||||
for method, path, payload in attempts:
|
for method, path, payload in attempts:
|
||||||
with self.subTest(method=method, path=path):
|
with self.subTest(method=method, path=path):
|
||||||
self.assertEqual(self.call(method, path, payload, client=bob).status_code, 404)
|
self.assertEqual(
|
||||||
bodies = [n["body"] for n in self.call("GET", f"/api/books/{book['id']}/notes").get_json()]
|
self.call(method, path, payload, client=bob).status_code, 404
|
||||||
|
)
|
||||||
|
bodies = [
|
||||||
|
n["body"]
|
||||||
|
for n in self.call("GET", f"/api/books/{book['id']}/notes").get_json()
|
||||||
|
]
|
||||||
self.assertEqual(bodies, ["Private"])
|
self.assertEqual(bodies, ["Private"])
|
||||||
|
|||||||
@@ -5,16 +5,23 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from auth import ITERATIONS, hash_password, load_pepper, needs_rehash, verify_password
|
from passwords import (
|
||||||
|
ITERATIONS,
|
||||||
|
hash_password,
|
||||||
|
load_pepper,
|
||||||
|
needs_rehash,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
|
||||||
PEPPER = b"p" * 32
|
PEPPER = b"p" * 32
|
||||||
# Independently computed: HMAC-SHA256(PEPPER, password) -> PBKDF2-SHA256, 1000 iterations, 16 zero-byte salt.
|
|
||||||
KNOWN_ANSWER = "pbkdf2_sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$9ZLLEusnEPU3Km8h+vnd4ue9fw7rHdm4QwuBX5ozynE="
|
KNOWN_ANSWER = "pbkdf2_sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$9ZLLEusnEPU3Km8h+vnd4ue9fw7rHdm4QwuBX5ozynE="
|
||||||
|
|
||||||
|
|
||||||
class PasswordHashTests(unittest.TestCase):
|
class PasswordHashTests(unittest.TestCase):
|
||||||
def test_known_answer(self):
|
def test_known_answer(self):
|
||||||
stored = hash_password("correct horse battery staple", PEPPER, salt=bytes(16), iterations=1000)
|
stored = hash_password(
|
||||||
|
"correct horse battery staple", PEPPER, salt=bytes(16), iterations=1000
|
||||||
|
)
|
||||||
self.assertEqual(stored, KNOWN_ANSWER)
|
self.assertEqual(stored, KNOWN_ANSWER)
|
||||||
|
|
||||||
def test_round_trip_uses_current_iterations_and_random_salt(self):
|
def test_round_trip_uses_current_iterations_and_random_salt(self):
|
||||||
@@ -28,7 +35,9 @@ class PasswordHashTests(unittest.TestCase):
|
|||||||
self.assertFalse(verify_password("wrong password!!", KNOWN_ANSWER, PEPPER))
|
self.assertFalse(verify_password("wrong password!!", KNOWN_ANSWER, PEPPER))
|
||||||
|
|
||||||
def test_wrong_pepper_fails(self):
|
def test_wrong_pepper_fails(self):
|
||||||
self.assertFalse(verify_password("correct horse battery staple", KNOWN_ANSWER, b"q" * 32))
|
self.assertFalse(
|
||||||
|
verify_password("correct horse battery staple", KNOWN_ANSWER, b"q" * 32)
|
||||||
|
)
|
||||||
|
|
||||||
def test_needs_rehash_below_current_iterations(self):
|
def test_needs_rehash_below_current_iterations(self):
|
||||||
self.assertTrue(needs_rehash(KNOWN_ANSWER))
|
self.assertTrue(needs_rehash(KNOWN_ANSWER))
|
||||||
@@ -37,9 +46,11 @@ class PasswordHashTests(unittest.TestCase):
|
|||||||
|
|
||||||
class PepperTests(unittest.TestCase):
|
class PepperTests(unittest.TestCase):
|
||||||
def test_env_pepper_must_be_long_enough(self):
|
def test_env_pepper_must_be_long_enough(self):
|
||||||
with mock.patch.dict(os.environ, {"PASSWORD_PEPPER": "short"}):
|
with (
|
||||||
with self.assertRaisesRegex(RuntimeError, "at least 32 characters"):
|
mock.patch.dict(os.environ, {"PASSWORD_PEPPER": "short"}),
|
||||||
load_pepper(Path("/nonexistent"))
|
self.assertRaisesRegex(RuntimeError, "at least 32 characters"),
|
||||||
|
):
|
||||||
|
load_pepper(Path("/nonexistent"))
|
||||||
|
|
||||||
def test_env_pepper_is_used_when_set(self):
|
def test_env_pepper_is_used_when_set(self):
|
||||||
with mock.patch.dict(os.environ, {"PASSWORD_PEPPER": "e" * 40}):
|
with mock.patch.dict(os.environ, {"PASSWORD_PEPPER": "e" * 40}):
|
||||||
@@ -49,9 +60,9 @@ class PepperTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ):
|
with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ):
|
||||||
os.environ.pop("PASSWORD_PEPPER", None)
|
os.environ.pop("PASSWORD_PEPPER", None)
|
||||||
pepper_file = Path(tmp) / ".pepper"
|
pepper_file = Path(tmp) / ".pepper"
|
||||||
with self.assertLogs("auth", level="WARNING"):
|
with self.assertLogs("passwords", level="WARNING"):
|
||||||
first = load_pepper(pepper_file)
|
first = load_pepper(pepper_file)
|
||||||
with self.assertLogs("auth", level="WARNING"):
|
with self.assertLogs("passwords", level="WARNING"):
|
||||||
second = load_pepper(pepper_file)
|
second = load_pepper(pepper_file)
|
||||||
self.assertEqual(first, second)
|
self.assertEqual(first, second)
|
||||||
self.assertEqual(len(first), 64)
|
self.assertEqual(len(first), 64)
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ class SearchTests(ApiTestCase):
|
|||||||
self.scifi = self.genre_id("Science Fiction")
|
self.scifi = self.genre_id("Science Fiction")
|
||||||
self.fantasy = self.genre_id("Fantasy")
|
self.fantasy = self.genre_id("Fantasy")
|
||||||
self.create_book(title="Dune", author="Frank Herbert", genre_id=self.scifi)
|
self.create_book(title="Dune", author="Frank Herbert", genre_id=self.scifi)
|
||||||
self.create_book(title="The Hobbit", author="J.R.R. Tolkien", genre_id=self.fantasy)
|
self.create_book(
|
||||||
|
title="The Hobbit", author="J.R.R. Tolkien", genre_id=self.fantasy
|
||||||
|
)
|
||||||
self.create_book(title="100% Pure", author="Jane_Doe", genre_id=self.fantasy)
|
self.create_book(title="100% Pure", author="Jane_Doe", genre_id=self.fantasy)
|
||||||
self.create_book(title="1000 Pages", author="Back\\slash", genre_id=self.scifi)
|
self.create_book(title="1000 Pages", author="Back\\slash", genre_id=self.scifi)
|
||||||
|
|
||||||
@@ -22,20 +24,26 @@ class SearchTests(ApiTestCase):
|
|||||||
self.assertEqual(self.titles("q=tolkien"), ["The Hobbit"])
|
self.assertEqual(self.titles("q=tolkien"), ["The Hobbit"])
|
||||||
|
|
||||||
def test_wildcards_match_literally(self):
|
def test_wildcards_match_literally(self):
|
||||||
self.assertEqual(self.titles("q=100%25"), ["100% Pure"]) # %25 is "%"
|
self.assertEqual(self.titles("q=100%25"), ["100% Pure"])
|
||||||
self.assertEqual(self.titles("q=e_D"), ["100% Pure"]) # literal "_" in Jane_Doe
|
self.assertEqual(self.titles("q=e_D"), ["100% Pure"])
|
||||||
self.assertEqual(self.titles("q=k_s"), []) # unescaped "_" would match "Back\\slash"
|
self.assertEqual(self.titles("q=k_s"), [])
|
||||||
self.assertEqual(self.titles("q=k%5Cs"), ["1000 Pages"]) # %5C is "\"
|
self.assertEqual(self.titles("q=k%5Cs"), ["1000 Pages"])
|
||||||
|
|
||||||
def test_genre_filter_and_combination(self):
|
def test_genre_filter_and_combination(self):
|
||||||
self.assertEqual(self.titles(f"genre_id={self.fantasy}"), ["100% Pure", "The Hobbit"])
|
self.assertEqual(
|
||||||
|
self.titles(f"genre_id={self.fantasy}"), ["100% Pure", "The Hobbit"]
|
||||||
|
)
|
||||||
self.assertEqual(self.titles(f"q=100&genre_id={self.scifi}"), ["1000 Pages"])
|
self.assertEqual(self.titles(f"q=100&genre_id={self.scifi}"), ["1000 Pages"])
|
||||||
|
|
||||||
def test_blank_query_returns_everything(self):
|
def test_blank_query_returns_everything(self):
|
||||||
self.assertEqual(len(self.titles("q=%20%20")), 4)
|
self.assertEqual(len(self.titles("q=%20%20")), 4)
|
||||||
|
|
||||||
def test_invalid_parameters(self):
|
def test_invalid_parameters(self):
|
||||||
for query, field in (("genre_id=abc", "genre_id"), ("genre_id=99999", "genre_id"), ("q=a%00b", "q")):
|
for query, field in (
|
||||||
|
("genre_id=abc", "genre_id"),
|
||||||
|
("genre_id=99999", "genre_id"),
|
||||||
|
("q=a%00b", "q"),
|
||||||
|
):
|
||||||
with self.subTest(query=query):
|
with self.subTest(query=query):
|
||||||
response = self.call("GET", f"/api/books?{query}")
|
response = self.call("GET", f"/api/books?{query}")
|
||||||
self.assertEqual(response.status_code, 400)
|
self.assertEqual(response.status_code, 400)
|
||||||
@@ -43,4 +51,6 @@ class SearchTests(ApiTestCase):
|
|||||||
|
|
||||||
def test_search_never_returns_other_users_books(self):
|
def test_search_never_returns_other_users_books(self):
|
||||||
bob = self.other_user("bob")
|
bob = self.other_user("bob")
|
||||||
self.assertEqual(self.call("GET", "/api/books?q=dune", client=bob).get_json(), [])
|
self.assertEqual(
|
||||||
|
self.call("GET", "/api/books?q=dune", client=bob).get_json(), []
|
||||||
|
)
|
||||||
|
|||||||
+13
-5
@@ -1,6 +1,11 @@
|
|||||||
"""Request validation shared by every slice. Errors carry a status, a fix-it message, and the field."""
|
"""Request validation shared by every slice. Errors carry a status, a fix-it message, and the field."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
|
JsonObject = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class ApiError(Exception):
|
class ApiError(Exception):
|
||||||
def __init__(self, status: int, message: str, field: str | None = None):
|
def __init__(self, status: int, message: str, field: str | None = None):
|
||||||
@@ -10,7 +15,7 @@ class ApiError(Exception):
|
|||||||
self.field = field
|
self.field = field
|
||||||
|
|
||||||
|
|
||||||
def json_body(allowed: set[str]) -> dict:
|
def json_body(allowed: set[str]) -> JsonObject:
|
||||||
body = request.get_json(silent=True)
|
body = request.get_json(silent=True)
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
raise ApiError(400, "Request body must be a JSON object")
|
raise ApiError(400, "Request body must be a JSON object")
|
||||||
@@ -28,10 +33,14 @@ def require_utf8(value: str, field: str) -> None:
|
|||||||
try:
|
try:
|
||||||
value.encode("utf-8")
|
value.encode("utf-8")
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
raise ApiError(400, f"{field} contains unpaired surrogate characters; send valid UTF-8 text", field) from None
|
raise ApiError(
|
||||||
|
400,
|
||||||
|
f"{field} contains unpaired surrogate characters; send valid UTF-8 text",
|
||||||
|
field,
|
||||||
|
) from None
|
||||||
|
|
||||||
|
|
||||||
def text(body: dict, field: str, max_len: int) -> str:
|
def text(body: JsonObject, field: str, max_len: int) -> str:
|
||||||
value = body.get(field)
|
value = body.get(field)
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
raise ApiError(400, f"{field} is required and must be non-blank text", field)
|
raise ApiError(400, f"{field} is required and must be non-blank text", field)
|
||||||
@@ -44,9 +53,8 @@ def text(body: dict, field: str, max_len: int) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def integer(body: dict, field: str, low: int, high: int) -> int:
|
def integer(body: JsonObject, field: str, low: int, high: int) -> int:
|
||||||
value = body.get(field)
|
value = body.get(field)
|
||||||
# bool is a subclass of int in Python; JSON true must not count as 1.
|
|
||||||
if isinstance(value, bool) or not isinstance(value, int):
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
raise ApiError(400, f"{field} must be a whole number", field)
|
raise ApiError(400, f"{field} must be a whole number", field)
|
||||||
if not low <= value <= high:
|
if not low <= value <= high:
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# The pad template's node_modules lacks this app's extra packages (router, Tailwind); a no-op once installed.
|
||||||
|
npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
# Locally the deps live in the mise venv; the pad's system python3 has Flask and psycopg2 preinstalled.
|
||||||
|
PYTHON=python3
|
||||||
|
[ -x .venv/bin/python ] && PYTHON=.venv/bin/python
|
||||||
|
|
||||||
|
"$PYTHON" backend/app.py &
|
||||||
|
FLASK_PID=$!
|
||||||
|
|
||||||
|
until curl -s -o /dev/null http://localhost:5000/api/health; do
|
||||||
|
if ! kill -0 "$FLASK_PID" 2>/dev/null; then
|
||||||
|
echo "Flask exited during startup; fix the error above (often a missing dependency or an unreachable PostgreSQL) and restart." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
exec node_modules/.bin/vite
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.5.15/schema.json",
|
||||||
|
"formatter": {
|
||||||
|
"indentStyle": "space"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": {
|
||||||
|
"level": "error",
|
||||||
|
"options": { "reportUnnecessaryDependencies": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"parser": {
|
||||||
|
"tailwindDirectives": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>My Library</title>
|
<title>My Library</title>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
const theme = localStorage.getItem("theme");
|
||||||
|
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,3 +1,81 @@
|
|||||||
[tools]
|
[tools]
|
||||||
python = "3.14"
|
python = "3.14"
|
||||||
node = "24"
|
node = "24"
|
||||||
|
ruff = "0.16.10"
|
||||||
|
biome = "2.5.15"
|
||||||
|
uv = "0.12.21"
|
||||||
|
|
||||||
|
[tasks.lock]
|
||||||
|
description = "Re-resolve backend/requirements.in into the pinned, hashed backend/requirements.txt"
|
||||||
|
run = "uv pip compile --quiet --universal --generate-hashes backend/requirements.in -o backend/requirements.txt"
|
||||||
|
|
||||||
|
[tasks.setup]
|
||||||
|
description = "Create the venv and install backend and frontend dependencies"
|
||||||
|
run = [
|
||||||
|
"python -m venv .venv",
|
||||||
|
".venv/bin/pip install -q --disable-pip-version-check -r backend/requirements.txt",
|
||||||
|
"npm install --no-fund --no-audit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tasks.db]
|
||||||
|
description = "Start local Postgres and wait until it accepts connections"
|
||||||
|
run = [
|
||||||
|
"docker compose up -d db",
|
||||||
|
"until docker compose exec -T db pg_isready -q -U postgres; do sleep 1; done",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tasks."dev:api"]
|
||||||
|
description = "Flask API on :5000"
|
||||||
|
depends = ["setup", "db"]
|
||||||
|
run = ".venv/bin/python backend/app.py"
|
||||||
|
|
||||||
|
[tasks."dev:web"]
|
||||||
|
description = "Vite UI; /api is proxied to Flask"
|
||||||
|
depends = ["setup"]
|
||||||
|
run = "npm run dev"
|
||||||
|
|
||||||
|
[tasks.dev]
|
||||||
|
description = "Start the app for local testing (API + UI)"
|
||||||
|
depends = ["dev:api", "dev:web"]
|
||||||
|
|
||||||
|
[tasks."format:py"]
|
||||||
|
description = "Sort imports and format the backend"
|
||||||
|
run = ["ruff check --select I --fix backend", "ruff format backend"]
|
||||||
|
|
||||||
|
[tasks."format:ts"]
|
||||||
|
description = "Sort imports and format the frontend"
|
||||||
|
run = "biome check --write --linter-enabled=false src vite.config.ts"
|
||||||
|
|
||||||
|
[tasks.format]
|
||||||
|
description = "Format and auto-fix Python and TypeScript"
|
||||||
|
depends = ["format:py", "format:ts"]
|
||||||
|
|
||||||
|
[tasks."lint:py"]
|
||||||
|
description = "Lint, type-check and verify formatting of the backend (no writes)"
|
||||||
|
run = [
|
||||||
|
"ruff check backend",
|
||||||
|
"ruff format --check backend",
|
||||||
|
"uvx --quiet --with-requirements backend/requirements.txt --with types-psycopg2==2.9.21.20260911 [email protected] --strict backend --exclude backend/tests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tasks."lint:ts"]
|
||||||
|
description = "Lint the frontend and verify formatting (no writes)"
|
||||||
|
run = "biome check src vite.config.ts"
|
||||||
|
|
||||||
|
[tasks.lint]
|
||||||
|
description = "Lint and format-check Python and TypeScript (CI-safe, no writes)"
|
||||||
|
depends = ["lint:py", "lint:ts"]
|
||||||
|
|
||||||
|
[tasks."test:py"]
|
||||||
|
description = "Backend integration tests against real Postgres (books_test)"
|
||||||
|
depends = ["setup", "db"]
|
||||||
|
run = ".venv/bin/python -W error -m unittest discover -s backend/tests -t backend"
|
||||||
|
|
||||||
|
[tasks."test:ts"]
|
||||||
|
description = "Frontend component tests and typecheck"
|
||||||
|
depends = ["setup"]
|
||||||
|
run = ["npm test", "npm run typecheck"]
|
||||||
|
|
||||||
|
[tasks.test]
|
||||||
|
description = "Run backend and frontend tests"
|
||||||
|
depends = ["test:py", "test:ts"]
|
||||||
|
|||||||
Generated
+20
@@ -8,6 +8,8 @@
|
|||||||
"name": "flask-react-template",
|
"name": "flask-react-template",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/newsreader": "^5.3.0",
|
||||||
|
"@fontsource/atkinson-hyperlegible": "^5.3.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router": "^7.18.4"
|
"react-router": "^7.18.4"
|
||||||
@@ -990,6 +992,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/newsreader": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/newsreader/-/newsreader-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-rrzYi43qMpbzwuFtf9OkWH8sxAPVPcQQQEwXpPtwaKYeJ8yVg5aLs5kawmo1f2Q1t1M38TLmEKCkGVDsYwgdFw==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@fontsource/atkinson-hyperlegible": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-Sp8Ve8+rQmENY2Lv02VH7sLpntzTTvnncnJMTbRE7DX6TLxGraN4ykZfzaUWIoCFmaomwkvNiqmlPkrKBuDoSA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/newsreader": "^5.3.0",
|
||||||
|
"@fontsource/atkinson-hyperlegible": "^5.3.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router": "^7.18.4"
|
"react-router": "^7.18.4"
|
||||||
|
|||||||
+69
-22
@@ -1,11 +1,23 @@
|
|||||||
import { useState } from 'react'
|
import { Fragment, type ReactNode, useState } from "react";
|
||||||
import { BrowserRouter, Link, Navigate, Outlet, Route, Routes } from 'react-router'
|
import {
|
||||||
import { errorMessage } from './api'
|
BrowserRouter,
|
||||||
import { AuthProvider, useAuth } from './auth/AuthContext'
|
Link,
|
||||||
import { LoginForm } from './auth/LoginForm'
|
Navigate,
|
||||||
import { RequireAuth } from './auth/RequireAuth'
|
Outlet,
|
||||||
import { BookList } from './books/BookList'
|
Route,
|
||||||
import { secondaryButton } from './ui'
|
Routes,
|
||||||
|
useParams,
|
||||||
|
} from "react-router";
|
||||||
|
import { errorMessage } from "./api";
|
||||||
|
import { AuthProvider, useAuth } from "./auth/AuthContext";
|
||||||
|
import { LoginForm } from "./auth/LoginForm";
|
||||||
|
import { RequireAuth } from "./auth/RequireAuth";
|
||||||
|
import { BookDetail } from "./books/BookDetail";
|
||||||
|
import { BookForm } from "./books/BookForm";
|
||||||
|
import { BookList } from "./books/BookList";
|
||||||
|
import { AlertIcon, BookIcon } from "./icons";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
import { errorText, secondaryButton } from "./ui";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -14,7 +26,7 @@ export function App() {
|
|||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
@@ -24,43 +36,78 @@ export function AppRoutes() {
|
|||||||
<Route element={<RequireAuth />}>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<Shell />}>
|
<Route element={<Shell />}>
|
||||||
<Route path="/books" element={<BookList />} />
|
<Route path="/books" element={<BookList />} />
|
||||||
|
<Route path="/books/new" element={<BookForm />} />
|
||||||
|
<Route
|
||||||
|
path="/books/:id/edit"
|
||||||
|
element={
|
||||||
|
<KeyedByBookId>
|
||||||
|
<BookForm />
|
||||||
|
</KeyedByBookId>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/books/:id"
|
||||||
|
element={
|
||||||
|
<KeyedByBookId>
|
||||||
|
<BookDetail />
|
||||||
|
</KeyedByBookId>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/books" replace />} />
|
<Route path="*" element={<Navigate to="/books" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyedByBookId({ children }: { children: ReactNode }) {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Fragment key={id}>{children}</Fragment>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Shell() {
|
function Shell() {
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth();
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen">
|
||||||
<header className="border-b border-slate-200 bg-white">
|
<header className="border-b border-line">
|
||||||
<div className="mx-auto flex max-w-4xl items-center justify-between p-4">
|
<div className="mx-auto flex max-w-4xl flex-wrap items-center justify-between gap-3 px-6 py-3.5">
|
||||||
<Link to="/books" className="text-lg font-semibold">
|
<Link
|
||||||
|
to="/books"
|
||||||
|
className="flex items-center gap-2.5 rounded-lg font-serif text-2xl font-semibold focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<span className="text-accent">
|
||||||
|
<BookIcon />
|
||||||
|
</span>
|
||||||
My Library
|
My Library
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-3 text-sm">
|
<div className="flex items-center gap-3">
|
||||||
<span>{user?.username}</span>
|
<ThemeToggle />
|
||||||
|
<span className="text-muted">{user?.username}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={secondaryButton}
|
className={secondaryButton}
|
||||||
onClick={() => logout().catch((err) => setError(errorMessage(err)))}
|
onClick={() =>
|
||||||
|
logout().catch((err) => setError(errorMessage(err)))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="mx-auto max-w-4xl px-4 pb-2 text-sm text-red-700">
|
<p
|
||||||
|
role="alert"
|
||||||
|
className={`mx-auto max-w-4xl px-6 pb-3 ${errorText}`}
|
||||||
|
>
|
||||||
|
<AlertIcon />
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-4xl p-4">
|
<main className="mx-auto max-w-4xl px-6 pt-11 pb-18">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
const stored = new Map<string, string>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Vitest's jsdom on Node 24 exposes no localStorage, so give it a minimal one.
|
||||||
|
vi.stubGlobal("localStorage", {
|
||||||
|
getItem: (key: string) => stored.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => stored.set(key, value),
|
||||||
|
removeItem: (key: string) => stored.delete(key),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete root.dataset.theme;
|
||||||
|
stored.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ThemeToggle", () => {
|
||||||
|
it("cycles system, light, dark and back to following the system", async () => {
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
const button = screen.getByRole("button", { name: /^Theme: System/ });
|
||||||
|
expect(root.dataset.theme).toBeUndefined();
|
||||||
|
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(button).toHaveAccessibleName("Theme: Light. Switch to Dark");
|
||||||
|
expect(root.dataset.theme).toBe("light");
|
||||||
|
expect(localStorage.getItem("theme")).toBe("light");
|
||||||
|
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(root.dataset.theme).toBe("dark");
|
||||||
|
expect(localStorage.getItem("theme")).toBe("dark");
|
||||||
|
|
||||||
|
await userEvent.click(button);
|
||||||
|
expect(root.dataset.theme).toBeUndefined();
|
||||||
|
expect(localStorage.getItem("theme")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts from the theme index.html applied before first paint", () => {
|
||||||
|
root.dataset.theme = "dark";
|
||||||
|
render(<ThemeToggle />);
|
||||||
|
expect(screen.getByRole("button", { name: /^Theme: Dark/ })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { MoonIcon, SunIcon, SystemThemeIcon } from "./icons";
|
||||||
|
|
||||||
|
const THEMES = {
|
||||||
|
system: { label: "System", Icon: SystemThemeIcon, next: "light" },
|
||||||
|
light: { label: "Light", Icon: SunIcon, next: "dark" },
|
||||||
|
dark: { label: "Dark", Icon: MoonIcon, next: "system" },
|
||||||
|
} as const;
|
||||||
|
type Theme = keyof typeof THEMES;
|
||||||
|
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (root.dataset.theme as Theme | undefined) ?? "system",
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme === "system") delete root.dataset.theme;
|
||||||
|
else root.dataset.theme = theme;
|
||||||
|
try {
|
||||||
|
if (theme === "system") localStorage.removeItem("theme");
|
||||||
|
else localStorage.setItem("theme", theme);
|
||||||
|
} catch {
|
||||||
|
// Storage can be blocked (private mode); the choice still applies for this visit.
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const { label, Icon, next } = THEMES[theme];
|
||||||
|
const hint = `Theme: ${label}. Switch to ${THEMES[next].label}`;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={hint}
|
||||||
|
title={hint}
|
||||||
|
onClick={() => setTheme(next)}
|
||||||
|
className="inline-flex size-11 items-center justify-center rounded-lg text-muted hover:bg-line hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+62
-33
@@ -1,54 +1,83 @@
|
|||||||
export type User = { id: number; username: string }
|
export type User = { id: number; username: string };
|
||||||
export type Genre = { id: number; name: string }
|
export type Genre = { id: number; name: string };
|
||||||
export type ReadingStatus = 'not_started' | 'reading' | 'finished'
|
export type ReadingStatus = "not_started" | "reading" | "finished";
|
||||||
export type Book = {
|
export type Book = {
|
||||||
id: number
|
id: number;
|
||||||
title: string
|
title: string;
|
||||||
author: string
|
author: string;
|
||||||
genre: Genre
|
genre: Genre;
|
||||||
total_pages: number
|
total_pages: number;
|
||||||
current_page: number
|
current_page: number;
|
||||||
status: ReadingStatus
|
status: ReadingStatus;
|
||||||
created_at: string
|
created_at: string;
|
||||||
updated_at: string
|
updated_at: string;
|
||||||
}
|
};
|
||||||
export type Note = { id: number; book_id: number; body: string; created_at: string; updated_at: string }
|
export type Note = {
|
||||||
|
id: number;
|
||||||
|
book_id: number;
|
||||||
|
body: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
readonly status: number
|
readonly status: number;
|
||||||
readonly field?: string
|
readonly field?: string;
|
||||||
|
|
||||||
constructor(status: number, message: string, field?: string) {
|
constructor(status: number, message: string, field?: string) {
|
||||||
super(message)
|
super(message);
|
||||||
this.status = status
|
this.status = status;
|
||||||
this.field = field
|
this.field = field;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let handleUnauthorized: () => void = () => {}
|
let handleUnauthorized: () => void = () => {};
|
||||||
|
let authEpoch = 0;
|
||||||
|
|
||||||
|
// A 401 only means "signed out" for the session that sent the request; one started before
|
||||||
|
// a later login, logout or expiry must not end the session that replaced it.
|
||||||
|
export function startAuthEpoch() {
|
||||||
|
authEpoch += 1;
|
||||||
|
}
|
||||||
|
|
||||||
export function setUnauthorizedHandler(handler: () => void) {
|
export function setUnauthorizedHandler(handler: () => void) {
|
||||||
handleUnauthorized = handler
|
handleUnauthorized = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function api<T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<T> {
|
export async function api<T>(
|
||||||
|
method: "GET" | "POST" | "PATCH" | "DELETE",
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const epoch = authEpoch;
|
||||||
const response = await fetch(`/api${path}`, {
|
const response = await fetch(`/api${path}`, {
|
||||||
method,
|
method,
|
||||||
credentials: 'same-origin',
|
credentials: "same-origin",
|
||||||
// Always JSON: the server rejects mutations without it (CSRF defense, ADR-0002).
|
headers: { "Content-Type": "application/json" },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
})
|
});
|
||||||
if (response.status === 204) return undefined as T
|
if (response.status === 204) return undefined as T;
|
||||||
const data = await response.json().catch(() => null)
|
const data = await response.json().catch(() => null);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// 401s from /auth/* are expected answers (bad login, not signed in yet), not an expired session.
|
if (
|
||||||
if (response.status === 401 && !path.startsWith('/auth/')) handleUnauthorized()
|
response.status === 401 &&
|
||||||
throw new ApiError(response.status, data?.error ?? `Request failed with status ${response.status}`, data?.field)
|
!path.startsWith("/auth/") &&
|
||||||
|
epoch === authEpoch
|
||||||
|
) {
|
||||||
|
startAuthEpoch();
|
||||||
|
handleUnauthorized();
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
response.status,
|
||||||
|
data?.error ?? `Request failed with status ${response.status}`,
|
||||||
|
data?.field,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return data as T
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorMessage(error: unknown): string {
|
export function errorMessage(error: unknown): string {
|
||||||
return error instanceof ApiError ? error.message : 'Could not reach the server; check your connection and try again'
|
return error instanceof ApiError
|
||||||
|
? error.message
|
||||||
|
: "Could not reach the server; check your connection and try again";
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-25
@@ -1,42 +1,69 @@
|
|||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import {
|
||||||
import { api, setUnauthorizedHandler, type User } from '../api'
|
createContext,
|
||||||
|
type ReactNode,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { api, setUnauthorizedHandler, startAuthEpoch, type User } from "../api";
|
||||||
|
|
||||||
type AuthValue = {
|
type AuthValue = {
|
||||||
user: User | null
|
user: User | null;
|
||||||
loading: boolean
|
loading: boolean;
|
||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<void>;
|
||||||
register: (username: string, password: string) => Promise<void>
|
register: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>;
|
||||||
}
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthValue | null>(null)
|
const AuthContext = createContext<AuthValue | null>(null);
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true);
|
||||||
|
const authChanged = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUnauthorizedHandler(() => setUser(null))
|
setUnauthorizedHandler(() => setUser(null));
|
||||||
api<User>('GET', '/auth/me')
|
api<User>("GET", "/auth/me")
|
||||||
.then(setUser, () => setUser(null))
|
.then(
|
||||||
.finally(() => setLoading(false))
|
(me) => {
|
||||||
}, [])
|
if (!authChanged.current) setUser(me);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (!authChanged.current) setUser(null);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function changeUser(next: User | null) {
|
||||||
|
authChanged.current = true;
|
||||||
|
startAuthEpoch();
|
||||||
|
setUser(next);
|
||||||
|
}
|
||||||
|
|
||||||
const value: AuthValue = {
|
const value: AuthValue = {
|
||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
login: async (username, password) => setUser(await api<User>('POST', '/auth/login', { username, password })),
|
login: async (username, password) =>
|
||||||
register: async (username, password) => setUser(await api<User>('POST', '/auth/register', { username, password })),
|
changeUser(
|
||||||
|
await api<User>("POST", "/auth/login", { username, password }),
|
||||||
|
),
|
||||||
|
register: async (username, password) =>
|
||||||
|
changeUser(
|
||||||
|
await api<User>("POST", "/auth/register", { username, password }),
|
||||||
|
),
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await api('POST', '/auth/logout')
|
await api("POST", "/auth/logout");
|
||||||
setUser(null)
|
changeUser(null);
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
return <AuthContext value={value}>{children}</AuthContext>
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth(): AuthValue {
|
export function useAuth(): AuthValue {
|
||||||
const value = useContext(AuthContext)
|
const value = useContext(AuthContext);
|
||||||
if (!value) throw new Error('useAuth must be used inside <AuthProvider>')
|
if (!value) throw new Error("useAuth must be used inside <AuthProvider>");
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-35
@@ -1,43 +1,65 @@
|
|||||||
import { useState, type FormEvent } from 'react'
|
import { type SubmitEvent, useState } from "react";
|
||||||
import { Navigate, useLocation, type Location } from 'react-router'
|
import { type Location, Navigate, useLocation } from "react-router";
|
||||||
import { errorMessage } from '../api'
|
import { errorMessage } from "../api";
|
||||||
import { card, inputClass, labelText, primaryButton } from '../ui'
|
import { AlertIcon, BookIcon } from "../icons";
|
||||||
import { useAuth } from './AuthContext'
|
import { ThemeToggle } from "../ThemeToggle";
|
||||||
|
import {
|
||||||
|
card,
|
||||||
|
errorText,
|
||||||
|
inputClass,
|
||||||
|
labelText,
|
||||||
|
linkText,
|
||||||
|
primaryButton,
|
||||||
|
} from "../ui";
|
||||||
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const { user, login, register } = useAuth()
|
const { user, login, register } = useAuth();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
const from = (location.state as { from?: Location } | null)?.from
|
const from = (location.state as { from?: Location } | null)?.from;
|
||||||
return <Navigate to={from ? from.pathname + from.search : '/books'} replace />
|
return (
|
||||||
|
<Navigate to={from ? from.pathname + from.search : "/books"} replace />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLogin = mode === 'login'
|
const isLogin = mode === "login";
|
||||||
|
|
||||||
async function submit(event: FormEvent) {
|
async function submit(event: SubmitEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
setError(null)
|
setError(null);
|
||||||
setBusy(true)
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await (isLogin ? login : register)(username, password)
|
await (isLogin ? login : register)(username, password);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(errorMessage(err))
|
setError(errorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 px-4 pt-16">
|
<div className="min-h-screen px-4 pt-4 pb-16">
|
||||||
<main className={`mx-auto max-w-sm ${card}`}>
|
<div className="mb-8 flex justify-end">
|
||||||
<h1 className="mb-4 text-2xl font-semibold">{isLogin ? 'Sign in' : 'Create account'}</h1>
|
<ThemeToggle />
|
||||||
<form onSubmit={submit} className="space-y-4">
|
</div>
|
||||||
|
<p className="mb-6 flex items-center justify-center gap-2.5 font-serif text-3xl font-semibold">
|
||||||
|
<span className="text-accent">
|
||||||
|
<BookIcon />
|
||||||
|
</span>
|
||||||
|
My Library
|
||||||
|
</p>
|
||||||
|
<main className={`mx-auto max-w-sm ${card} sm:p-8`}>
|
||||||
|
<h1 className="mb-5 font-serif text-3xl font-medium">
|
||||||
|
{isLogin ? "Sign in" : "Create account"}
|
||||||
|
</h1>
|
||||||
|
<form onSubmit={submit} className="space-y-5">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className={labelText}>Username</span>
|
<span className={labelText}>Username</span>
|
||||||
<input
|
<input
|
||||||
@@ -45,6 +67,8 @@ export function LoginForm() {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-describedby={error ? "login-error" : undefined}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -55,31 +79,38 @@ export function LoginForm() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
autoComplete={isLogin ? "current-password" : "new-password"}
|
||||||
minLength={isLogin ? undefined : 12}
|
minLength={isLogin ? undefined : 12}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-describedby={error ? "login-error" : undefined}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="text-sm text-red-700">
|
<p id="login-error" role="alert" className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button type="submit" disabled={busy} className={`${primaryButton} w-full`}>
|
<button
|
||||||
{isLogin ? 'Sign in' : 'Create account'}
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className={`${primaryButton} w-full`}
|
||||||
|
>
|
||||||
|
{isLogin ? "Sign in" : "Create account"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-4 text-sm text-indigo-700 underline"
|
className={`mt-5 min-h-11 ${linkText}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode(isLogin ? 'register' : 'login')
|
setMode(isLogin ? "register" : "login");
|
||||||
setError(null)
|
setError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isLogin ? 'Need an account? Register' : 'Have an account? Sign in'}
|
{isLogin ? "Need an account? Register" : "Have an account? Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Navigate, Outlet, useLocation } from 'react-router'
|
import { Navigate, Outlet, useLocation } from "react-router";
|
||||||
import { useAuth } from './AuthContext'
|
import { useAuth } from "./AuthContext";
|
||||||
|
|
||||||
export function RequireAuth() {
|
export function RequireAuth() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth();
|
||||||
const location = useLocation()
|
const location = useLocation();
|
||||||
if (loading) return <p className="p-6 text-slate-500">Loading…</p>
|
if (loading) return <p className="p-6 text-slate-500">Loading…</p>;
|
||||||
if (!user) return <Navigate to="/login" replace state={{ from: location }} />
|
if (!user) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
return <Outlet />
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
+162
-58
@@ -1,69 +1,173 @@
|
|||||||
import { screen } from '@testing-library/react'
|
import { act, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import { alice, dune, genres, mockApi, renderApp, signedIn } from '../test/helpers'
|
import {
|
||||||
|
alice,
|
||||||
|
dune,
|
||||||
|
genres,
|
||||||
|
held,
|
||||||
|
mockApi,
|
||||||
|
renderApp,
|
||||||
|
signedIn,
|
||||||
|
} from "../test/helpers";
|
||||||
|
|
||||||
describe('auth flow', () => {
|
describe("auth flow", () => {
|
||||||
it('sends signed-out users to sign in, then back to the page they wanted', async () => {
|
it("sends signed-out users to sign in, then back to the page they wanted", async () => {
|
||||||
const calls = mockApi({
|
const calls = mockApi({
|
||||||
'GET /api/auth/me': () => [401, { error: 'Not signed in or session expired; log in again' }],
|
"GET /api/auth/me": () => [
|
||||||
'POST /api/auth/login': () => [200, alice],
|
401,
|
||||||
'GET /api/genres': () => [200, genres],
|
{ error: "Not signed in or session expired; log in again" },
|
||||||
'GET /api/books': () => [200, [dune]],
|
],
|
||||||
})
|
"POST /api/auth/login": () => [200, alice],
|
||||||
const user = renderApp('/books?q=dune')
|
"GET /api/genres": () => [200, genres],
|
||||||
|
"GET /api/books": () => [200, [dune]],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books?q=dune");
|
||||||
|
|
||||||
await screen.findByRole('heading', { name: 'Sign in' })
|
await screen.findByRole("heading", { name: "Sign in" });
|
||||||
await user.type(screen.getByLabelText('Username'), 'alice')
|
await user.type(screen.getByLabelText("Username"), "alice");
|
||||||
await user.type(screen.getByLabelText('Password'), 'correct horse battery')
|
await user.type(screen.getByLabelText("Password"), "correct horse battery");
|
||||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
|
||||||
expect(await screen.findByLabelText('Search title or author')).toHaveValue('dune')
|
expect(await screen.findByLabelText("Search title or author")).toHaveValue(
|
||||||
|
"dune",
|
||||||
|
);
|
||||||
expect(calls).toContainEqual({
|
expect(calls).toContainEqual({
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
path: '/api/auth/login',
|
path: "/api/auth/login",
|
||||||
body: { username: 'alice', password: 'correct horse battery' },
|
body: { username: "alice", password: "correct horse battery" },
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|
||||||
it('shows the server error for a bad login', async () => {
|
it("shows the server error for a bad login", async () => {
|
||||||
mockApi({
|
mockApi({
|
||||||
'GET /api/auth/me': () => [401, { error: 'Not signed in' }],
|
"GET /api/auth/me": () => [401, { error: "Not signed in" }],
|
||||||
'POST /api/auth/login': () => [401, { error: 'Invalid username or password' }],
|
"POST /api/auth/login": () => [
|
||||||
})
|
401,
|
||||||
const user = renderApp('/login')
|
{ error: "Invalid username or password" },
|
||||||
await user.type(await screen.findByLabelText('Username'), 'alice')
|
],
|
||||||
await user.type(screen.getByLabelText('Password'), 'wrong password!')
|
});
|
||||||
await user.click(screen.getByRole('button', { name: 'Sign in' }))
|
const user = renderApp("/login");
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid username or password')
|
await user.type(await screen.findByLabelText("Username"), "alice");
|
||||||
})
|
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 () => {
|
it("registers a new account", async () => {
|
||||||
const calls = mockApi({
|
const calls = mockApi({
|
||||||
'GET /api/auth/me': () => [401, { error: 'Not signed in' }],
|
"GET /api/auth/me": () => [401, { error: "Not signed in" }],
|
||||||
'POST /api/auth/register': () => [201, alice],
|
"POST /api/auth/register": () => [201, alice],
|
||||||
'GET /api/genres': () => [200, genres],
|
"GET /api/genres": () => [200, genres],
|
||||||
'GET /api/books': () => [200, []],
|
"GET /api/books": () => [200, []],
|
||||||
})
|
});
|
||||||
const user = renderApp('/login')
|
const user = renderApp("/login");
|
||||||
await user.click(await screen.findByRole('button', { name: 'Need an account? Register' }))
|
await user.click(
|
||||||
await user.type(screen.getByLabelText('Username'), 'alice')
|
await screen.findByRole("button", { name: "Need an account? Register" }),
|
||||||
await user.type(screen.getByLabelText('Password'), 'correct horse battery')
|
);
|
||||||
await user.click(screen.getByRole('button', { name: 'Create account' }))
|
await user.type(screen.getByLabelText("Username"), "alice");
|
||||||
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
|
await user.type(screen.getByLabelText("Password"), "correct horse battery");
|
||||||
expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/register')).toBe(true)
|
await user.click(screen.getByRole("button", { name: "Create account" }));
|
||||||
})
|
expect(
|
||||||
|
await screen.findByText("No books yet. Add your first one."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
calls.some((c) => c.method === "POST" && c.path === "/api/auth/register"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns to sign in when the session expires mid-use', async () => {
|
it("returns to sign in when the session expires mid-use", async () => {
|
||||||
signedIn({ 'GET /api/books': () => [401, { error: 'Not signed in or session expired; log in again' }] })
|
signedIn({
|
||||||
renderApp('/books')
|
"GET /api/books": () => [
|
||||||
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
401,
|
||||||
})
|
{ error: "Not signed in or session expired; log in again" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderApp("/books");
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Sign in" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('logs out', async () => {
|
it("logs out", async () => {
|
||||||
const calls = signedIn({ 'GET /api/books': () => [200, []], 'POST /api/auth/logout': () => [204] })
|
const calls = signedIn({
|
||||||
const user = renderApp('/books')
|
"GET /api/books": () => [200, []],
|
||||||
await user.click(await screen.findByRole('button', { name: 'Log out' }))
|
"POST /api/auth/logout": () => [204],
|
||||||
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument()
|
});
|
||||||
expect(calls.some((c) => c.method === 'POST' && c.path === '/api/auth/logout')).toBe(true)
|
const user = renderApp("/books");
|
||||||
})
|
await user.click(await screen.findByRole("button", { name: "Log out" }));
|
||||||
})
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Sign in" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
calls.some((c) => c.method === "POST" && c.path === "/api/auth/logout"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial session check", () => {
|
||||||
|
it("does not sign out a user who logged in while /auth/me was pending", async () => {
|
||||||
|
const me = held();
|
||||||
|
mockApi({
|
||||||
|
"GET /api/auth/me": () => me.reply,
|
||||||
|
"POST /api/auth/login": () => [200, alice],
|
||||||
|
"GET /api/genres": () => [200, genres],
|
||||||
|
"GET /api/books": () => [200, []],
|
||||||
|
});
|
||||||
|
const user = renderApp("/login");
|
||||||
|
await user.type(screen.getByLabelText("Username"), "alice");
|
||||||
|
await user.type(screen.getByLabelText("Password"), "correct horse battery");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Sign in" }),
|
||||||
|
).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
me.release([
|
||||||
|
401,
|
||||||
|
{ error: "Not signed in or session expired; log in again" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("No books yet. Add your first one."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("session expiry", () => {
|
||||||
|
it("keeps a fresh login when an earlier session's request returns a late 401", async () => {
|
||||||
|
const expiredGenres = held();
|
||||||
|
let genreRequests = 0;
|
||||||
|
let bookRequests = 0;
|
||||||
|
mockApi({
|
||||||
|
"GET /api/auth/me": () => [200, alice],
|
||||||
|
"POST /api/auth/login": () => [200, alice],
|
||||||
|
"GET /api/genres": () =>
|
||||||
|
++genreRequests === 1 ? expiredGenres.reply : [200, genres],
|
||||||
|
"GET /api/books": () =>
|
||||||
|
++bookRequests === 1 ? [401, { error: "Session expired" }] : [200, []],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books");
|
||||||
|
await screen.findByRole("heading", { name: "Sign in" });
|
||||||
|
await user.type(screen.getByLabelText("Username"), "alice");
|
||||||
|
await user.type(screen.getByLabelText("Password"), "correct horse battery");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Sign in" }));
|
||||||
|
await screen.findByText("No books yet. Add your first one.");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
expiredGenres.release([401, { error: "Previous session expired" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Sign in" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Log out" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Link, MemoryRouter } from "react-router";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { AppRoutes } from "../App";
|
||||||
|
import type { Book } from "../api";
|
||||||
|
import { AuthProvider } from "../auth/AuthContext";
|
||||||
|
import { dune, held, renderApp, signedIn } from "../test/helpers";
|
||||||
|
|
||||||
|
function withProgress(current_page: number): Book {
|
||||||
|
const status =
|
||||||
|
current_page === 0
|
||||||
|
? "not_started"
|
||||||
|
: current_page === dune.total_pages
|
||||||
|
? "finished"
|
||||||
|
: "reading";
|
||||||
|
return { ...dune, current_page, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BookDetail", () => {
|
||||||
|
it("updates progress and reflects it in the bar", async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"PATCH /api/books/7": ({ body }) => [
|
||||||
|
200,
|
||||||
|
withProgress((body as { current_page: number }).current_page),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Page 142 of 380 · 37%"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const input = screen.getByLabelText("Current page");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "200");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save progress" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Page 200 of 380 · 53%"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/api/books/7",
|
||||||
|
body: { current_page: 200 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a book finished", async () => {
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"PATCH /api/books/7": () => [200, withProgress(380)],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "Mark finished" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Page 380 of 380 · 100%"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Finished")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the server message when a progress update is rejected", async () => {
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"PATCH /api/books/7": () => [
|
||||||
|
400,
|
||||||
|
{
|
||||||
|
error: "current_page must be between 0 and 100000",
|
||||||
|
field: "current_page",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "Save progress" }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await screen.findByText("current_page must be between 0 and 100000"),
|
||||||
|
).toHaveAttribute("role", "alert");
|
||||||
|
expect(screen.getByLabelText("Current page")).toHaveAttribute(
|
||||||
|
"aria-invalid",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows not found for a missing or foreign book", async () => {
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/999": () => [404, { error: "Book not found" }],
|
||||||
|
});
|
||||||
|
renderApp("/books/999");
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Book not found" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("link", { name: /back to library/i }),
|
||||||
|
).toHaveAttribute("href", "/books");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes after confirmation and returns to the list", async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"DELETE /api/books/7": () => [204],
|
||||||
|
"GET /api/books": () => [200, []],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "Delete book" }),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("dialog")).toHaveTextContent("Delete “Dune”?");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Yes, delete" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText("No books yet. Add your first one."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
calls.some((c) => c.method === "DELETE" && c.path === "/api/books/7"),
|
||||||
|
).toBe(true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BookDetail progress saves", () => {
|
||||||
|
it("allows one progress save at a time so a late response cannot revert newer progress", async () => {
|
||||||
|
const first = held();
|
||||||
|
const calls = signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
|
"PATCH /api/books/7": () => first.reply,
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
const input = await screen.findByLabelText("Current page");
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, "10");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save progress" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Save progress" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Mark finished" }),
|
||||||
|
).toBeDisabled();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Mark finished" }));
|
||||||
|
expect(calls.filter((c) => c.method === "PATCH")).toHaveLength(1);
|
||||||
|
|
||||||
|
first.release([200, withProgress(10)]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Page 10 of 380 · 3%")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Save progress" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BookDetail navigation", () => {
|
||||||
|
it("does not keep the previous book actionable while the next one loads", async () => {
|
||||||
|
const next = held();
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
|
"GET /api/books/8": () => next.reply,
|
||||||
|
"GET /api/books/8/notes": () => [200, []],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/books/7"]}>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
<Link to="/books/8">Next book</Link>
|
||||||
|
</AuthProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
await screen.findByRole("heading", { name: "Dune", level: 1 });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("link", { name: "Next book" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("heading", { name: "Dune" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Save progress" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
next.release([200, { ...dune, id: 8, title: "Dune Messiah" }]);
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { type SubmitEvent, useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
import { ApiError, api, type Book, errorMessage } from "../api";
|
||||||
|
import { AlertIcon, TrashIcon } from "../icons";
|
||||||
|
import { NotesJournal } from "../notes/NotesJournal";
|
||||||
|
import {
|
||||||
|
card,
|
||||||
|
dangerButton,
|
||||||
|
errorText,
|
||||||
|
inputClass,
|
||||||
|
labelText,
|
||||||
|
linkText,
|
||||||
|
pageTitle,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
} from "../ui";
|
||||||
|
import { ProgressBar, StatusBadge } from "./Progress";
|
||||||
|
|
||||||
|
export function BookDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [book, setBook] = useState<Book | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<{
|
||||||
|
notFound: boolean;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [page, setPage] = useState("");
|
||||||
|
const [progressError, setProgressError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadError(null);
|
||||||
|
api<Book>("GET", `/books/${id}`).then(
|
||||||
|
(found) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setBook(found);
|
||||||
|
setPage(String(found.current_page));
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (!cancelled)
|
||||||
|
setLoadError({
|
||||||
|
notFound: err instanceof ApiError && err.status === 404,
|
||||||
|
message: errorMessage(err),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id, attempt]);
|
||||||
|
|
||||||
|
if (loadError?.notFound) {
|
||||||
|
return (
|
||||||
|
<div className={card}>
|
||||||
|
<h1 className={pageTitle}>Book not found</h1>
|
||||||
|
<Link to="/books" className={`mt-3 inline-block ${linkText}`}>
|
||||||
|
← Back to library
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div role="alert" className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
|
{loadError.message}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setAttempt((n) => n + 1)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!book) return <p className="text-muted">Loading…</p>;
|
||||||
|
const current = book;
|
||||||
|
|
||||||
|
async function saveProgress(currentPage: number) {
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
setProgressError(null);
|
||||||
|
try {
|
||||||
|
const updated = await api<Book>("PATCH", `/books/${current.id}`, {
|
||||||
|
current_page: currentPage,
|
||||||
|
});
|
||||||
|
setBook(updated);
|
||||||
|
setPage(String(updated.current_page));
|
||||||
|
} catch (err) {
|
||||||
|
setProgressError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBook() {
|
||||||
|
setDeleteError(null);
|
||||||
|
try {
|
||||||
|
await api("DELETE", `/books/${current.id}`);
|
||||||
|
navigate("/books", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setDeleteError(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitProgress(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
void saveProgress(Number(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="space-y-7">
|
||||||
|
<Link
|
||||||
|
to="/books"
|
||||||
|
className={`inline-flex min-h-11 items-center ${linkText}`}
|
||||||
|
>
|
||||||
|
← Back to library
|
||||||
|
</Link>
|
||||||
|
<header className={`${card} space-y-5 sm:p-7`}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-serif text-4xl leading-tight font-medium">
|
||||||
|
{book.title}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-lg text-muted">{`${book.author} · ${book.genre.name}`}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={book.status} />
|
||||||
|
</div>
|
||||||
|
<ProgressBar book={book} />
|
||||||
|
<form
|
||||||
|
onSubmit={submitProgress}
|
||||||
|
className="flex flex-wrap items-end gap-3"
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
<span className={labelText}>Current page</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={`${inputClass} w-32`}
|
||||||
|
min={0}
|
||||||
|
max={book.total_pages}
|
||||||
|
value={page}
|
||||||
|
onChange={(e) => setPage(e.target.value)}
|
||||||
|
required
|
||||||
|
aria-invalid={progressError ? true : undefined}
|
||||||
|
aria-describedby={progressError ? "progress-error" : undefined}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={saving} className={primaryButton}>
|
||||||
|
Save progress
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
disabled={saving || book.current_page === book.total_pages}
|
||||||
|
onClick={() => void saveProgress(book.total_pages)}
|
||||||
|
>
|
||||||
|
Mark finished
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{progressError && (
|
||||||
|
<p id="progress-error" role="alert" className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
|
{progressError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3 border-t border-line pt-5">
|
||||||
|
<Link to={`/books/${book.id}/edit`} className={secondaryButton}>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={dangerButton}
|
||||||
|
onClick={() => setConfirmingDelete(true)}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
Delete book
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{confirmingDelete && (
|
||||||
|
<dialog
|
||||||
|
open
|
||||||
|
aria-labelledby="delete-prompt"
|
||||||
|
className="relative rounded-xl border-2 border-danger bg-surface p-6 text-ink"
|
||||||
|
>
|
||||||
|
<p id="delete-prompt">{`Delete “${book.title}”? This also deletes its reading journal.`}</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={dangerButton}
|
||||||
|
onClick={() => void deleteBook()}
|
||||||
|
>
|
||||||
|
Yes, delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setConfirmingDelete(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{deleteError && (
|
||||||
|
<p role="alert" className={`mt-3 ${errorText}`}>
|
||||||
|
<AlertIcon />
|
||||||
|
{deleteError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</dialog>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<NotesJournal bookId={book.id} />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { dune, renderApp, signedIn } from "../test/helpers";
|
||||||
|
|
||||||
|
const fresh = { ...dune, current_page: 0, status: "not_started" as const };
|
||||||
|
|
||||||
|
describe("BookForm", () => {
|
||||||
|
it("adds a book and opens it", async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
"POST /api/books": () => [201, fresh],
|
||||||
|
"GET /api/books/7": () => [200, fresh],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/new");
|
||||||
|
await screen.findByRole("option", { name: "Science Fiction" });
|
||||||
|
await user.type(screen.getByLabelText("Title"), "Dune");
|
||||||
|
await user.type(screen.getByLabelText("Author"), "Frank Herbert");
|
||||||
|
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
|
||||||
|
await user.type(screen.getByLabelText("Total pages"), "380");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add book" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Dune", level: 1 }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/books",
|
||||||
|
body: {
|
||||||
|
title: "Dune",
|
||||||
|
author: "Frank Herbert",
|
||||||
|
genre_id: 5,
|
||||||
|
total_pages: 380,
|
||||||
|
current_page: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a server validation error against the field", async () => {
|
||||||
|
signedIn({
|
||||||
|
"POST /api/books": () => [
|
||||||
|
400,
|
||||||
|
{ error: "Unknown genre_id; see GET /api/genres", field: "genre_id" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/new");
|
||||||
|
await screen.findByRole("option", { name: "Science Fiction" });
|
||||||
|
await user.type(screen.getByLabelText("Title"), "Dune");
|
||||||
|
await user.type(screen.getByLabelText("Author"), "Frank Herbert");
|
||||||
|
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
|
||||||
|
await user.type(screen.getByLabelText("Total pages"), "380");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add book" }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"Unknown genre_id",
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Genre")).toHaveAttribute(
|
||||||
|
"aria-invalid",
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits an existing book", async () => {
|
||||||
|
let saved = dune;
|
||||||
|
const calls = signedIn({
|
||||||
|
"GET /api/books/7": () => [200, saved],
|
||||||
|
"PATCH /api/books/7": ({ body }) => {
|
||||||
|
saved = { ...saved, ...(body as object) };
|
||||||
|
return [200, saved];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7/edit");
|
||||||
|
const title = await screen.findByDisplayValue("Dune");
|
||||||
|
await user.clear(title);
|
||||||
|
await user.type(title, "Dune Messiah");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/api/books/7",
|
||||||
|
body: { title: "Dune Messiah" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving without changes returns to the book without a request", async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7/edit");
|
||||||
|
await screen.findByDisplayValue("Dune");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Dune", level: 1 });
|
||||||
|
expect(calls.some((call) => call.method === "PATCH")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves progress saved in another session when only the title changes", async () => {
|
||||||
|
let saved = { ...dune };
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, { ...saved }],
|
||||||
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
|
"PATCH /api/books/7": ({ body }) => {
|
||||||
|
saved = { ...saved, ...(body as object) };
|
||||||
|
return [200, saved];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7/edit");
|
||||||
|
const title = await screen.findByDisplayValue("Dune");
|
||||||
|
await screen.findByRole("option", { name: "Science Fiction" });
|
||||||
|
|
||||||
|
// Another session saves progress after this form loads its snapshot.
|
||||||
|
saved = { ...saved, current_page: 300 };
|
||||||
|
await user.clear(title);
|
||||||
|
await user.type(title, "Dune Messiah");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||||
|
|
||||||
|
await screen.findByRole("heading", { name: "Dune Messiah", level: 1 });
|
||||||
|
expect(screen.getByRole("progressbar")).toHaveAttribute("value", "300");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { type SubmitEvent, useEffect, useState } from "react";
|
||||||
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
import { ApiError, api, type Book, errorMessage } from "../api";
|
||||||
|
import { AlertIcon } from "../icons";
|
||||||
|
import {
|
||||||
|
card,
|
||||||
|
errorText,
|
||||||
|
inputClass,
|
||||||
|
labelText,
|
||||||
|
linkText,
|
||||||
|
pageTitle,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
} from "../ui";
|
||||||
|
import { useGenres } from "./useGenres";
|
||||||
|
|
||||||
|
type Fields = {
|
||||||
|
title: string;
|
||||||
|
author: string;
|
||||||
|
genre_id: string;
|
||||||
|
total_pages: string;
|
||||||
|
current_page: string;
|
||||||
|
};
|
||||||
|
type FormError = { message: string; field?: string };
|
||||||
|
|
||||||
|
const EMPTY: Fields = {
|
||||||
|
title: "",
|
||||||
|
author: "",
|
||||||
|
genre_id: "",
|
||||||
|
total_pages: "",
|
||||||
|
current_page: "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BookForm() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const editing = id !== undefined;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { genres, error: genresError } = useGenres();
|
||||||
|
const [fields, setFields] = useState<Fields>(EMPTY);
|
||||||
|
const [loaded, setLoaded] = useState<Fields>(EMPTY);
|
||||||
|
const [loading, setLoading] = useState(editing);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<FormError | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadError(null);
|
||||||
|
setLoading(true);
|
||||||
|
api<Book>("GET", `/books/${id}`).then(
|
||||||
|
(book) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const current = {
|
||||||
|
title: book.title,
|
||||||
|
author: book.author,
|
||||||
|
genre_id: String(book.genre.id),
|
||||||
|
total_pages: String(book.total_pages),
|
||||||
|
current_page: String(book.current_page),
|
||||||
|
};
|
||||||
|
setFields(current);
|
||||||
|
setLoaded(current);
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoadError(errorMessage(err));
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [editing, id, attempt]);
|
||||||
|
|
||||||
|
function set(name: keyof Fields) {
|
||||||
|
return (event: { target: { value: string } }) =>
|
||||||
|
setFields((prev) => ({ ...prev, [name]: event.target.value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(name: keyof Fields) {
|
||||||
|
return error?.field === name
|
||||||
|
? { "aria-invalid": true, "aria-describedby": "form-error" }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const payload = {
|
||||||
|
title: fields.title,
|
||||||
|
author: fields.author,
|
||||||
|
genre_id: Number(fields.genre_id),
|
||||||
|
total_pages: Number(fields.total_pages),
|
||||||
|
current_page: Number(fields.current_page),
|
||||||
|
};
|
||||||
|
// An edit sends only what the user changed, so it cannot overwrite fields
|
||||||
|
// (such as progress) saved elsewhere after this form loaded.
|
||||||
|
const changes = Object.fromEntries(
|
||||||
|
Object.entries(payload).filter(
|
||||||
|
([name]) =>
|
||||||
|
fields[name as keyof Fields] !== loaded[name as keyof Fields],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (editing && Object.keys(changes).length === 0) {
|
||||||
|
navigate(`/books/${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const saved = editing
|
||||||
|
? await api<Book>("PATCH", `/books/${id}`, changes)
|
||||||
|
: await api<Book>("POST", "/books", payload);
|
||||||
|
navigate(`/books/${saved.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError({
|
||||||
|
message: errorMessage(err),
|
||||||
|
field: err instanceof ApiError ? err.field : undefined,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div role="alert" className={card}>
|
||||||
|
<p className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
|
{loadError}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setAttempt((n) => n + 1)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<Link to="/books" className={linkText}>
|
||||||
|
← Back to library
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loading) return <p className="text-muted">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className={`mx-auto max-w-xl space-y-5 ${card}`}>
|
||||||
|
<h1 className={pageTitle}>{editing ? "Edit book" : "Add a book"}</h1>
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelText}>Title</span>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={fields.title}
|
||||||
|
onChange={set("title")}
|
||||||
|
maxLength={500}
|
||||||
|
required
|
||||||
|
{...invalid("title")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelText}>Author</span>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={fields.author}
|
||||||
|
onChange={set("author")}
|
||||||
|
maxLength={500}
|
||||||
|
required
|
||||||
|
{...invalid("author")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelText}>Genre</span>
|
||||||
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={fields.genre_id}
|
||||||
|
onChange={set("genre_id")}
|
||||||
|
required
|
||||||
|
{...invalid("genre_id")}
|
||||||
|
>
|
||||||
|
<option value="">Choose a genre</option>
|
||||||
|
{genres.map((g) => (
|
||||||
|
<option key={g.id} value={String(g.id)}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{genresError && (
|
||||||
|
<p role="alert" className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
|
{`Could not load genres: ${genresError}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<label className="block min-w-36 flex-1">
|
||||||
|
<span className={labelText}>Total pages</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={inputClass}
|
||||||
|
min={1}
|
||||||
|
max={100000}
|
||||||
|
value={fields.total_pages}
|
||||||
|
onChange={set("total_pages")}
|
||||||
|
required
|
||||||
|
{...invalid("total_pages")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block min-w-36 flex-1">
|
||||||
|
<span className={labelText}>Current page</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className={inputClass}
|
||||||
|
min={0}
|
||||||
|
max={fields.total_pages || undefined}
|
||||||
|
value={fields.current_page}
|
||||||
|
onChange={set("current_page")}
|
||||||
|
required
|
||||||
|
{...invalid("current_page")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p id="form-error" role="alert" className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3 pt-1">
|
||||||
|
<button type="submit" disabled={busy} className={primaryButton}>
|
||||||
|
{editing ? "Save changes" : "Add book"}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={editing ? `/books/${id}` : "/books"}
|
||||||
|
className={secondaryButton}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
+80
-52
@@ -1,58 +1,86 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react'
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import { dune, renderApp, signedIn } from '../test/helpers'
|
import { dune, renderApp, signedIn } from "../test/helpers";
|
||||||
|
|
||||||
describe('BookList', () => {
|
describe("BookList", () => {
|
||||||
it('shows each book with author, genre, status, and progress', async () => {
|
it("shows each book with author, genre, status, and progress", async () => {
|
||||||
signedIn({ 'GET /api/books': () => [200, [dune]] })
|
signedIn({ "GET /api/books": () => [200, [dune]] });
|
||||||
renderApp('/books')
|
renderApp("/books");
|
||||||
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
|
expect(
|
||||||
expect(screen.getByText('Frank Herbert · Science Fiction')).toBeInTheDocument()
|
await screen.findByRole("heading", { name: "Dune" }),
|
||||||
expect(screen.getByText('Reading')).toBeInTheDocument()
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText('142 / 380 (37%)')).toBeInTheDocument()
|
expect(
|
||||||
expect(screen.getByRole('link', { name: /Dune/ })).toHaveAttribute('href', '/books/7')
|
screen.getByText("Frank Herbert · Science Fiction"),
|
||||||
})
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Reading")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("37%")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: /Dune/ })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/books/7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('distinguishes an empty library from no matches', async () => {
|
it("distinguishes an empty library from no matches", async () => {
|
||||||
signedIn({ 'GET /api/books': () => [200, []] })
|
signedIn({ "GET /api/books": () => [200, []] });
|
||||||
const user = renderApp('/books')
|
const user = renderApp("/books");
|
||||||
expect(await screen.findByText('No books yet. Add your first one.')).toBeInTheDocument()
|
expect(
|
||||||
await user.type(screen.getByLabelText('Search title or author'), 'zzz')
|
await screen.findByText("No books yet. Add your first one."),
|
||||||
expect(await screen.findByText('No matches. Try a different search or genre.')).toBeInTheDocument()
|
).toBeInTheDocument();
|
||||||
})
|
await user.type(screen.getByLabelText("Search title or author"), "zzz");
|
||||||
|
expect(
|
||||||
|
await screen.findByText("No matches. Try a different search or genre."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('debounces search into the URL and the request', async () => {
|
it("debounces search into the URL and the request", async () => {
|
||||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
|
||||||
const user = renderApp('/books')
|
const user = renderApp("/books");
|
||||||
await screen.findByRole('heading', { name: 'Dune' })
|
await screen.findByRole("heading", { name: "Dune" });
|
||||||
await user.type(screen.getByLabelText('Search title or author'), 'herbert')
|
await user.type(screen.getByLabelText("Search title or author"), "herbert");
|
||||||
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?q=herbert'))
|
await waitFor(() =>
|
||||||
expect(calls.filter((c) => c.path.startsWith('/api/books?q=h')).map((c) => c.path)).toEqual(['/api/books?q=herbert'])
|
expect(calls.map((c) => c.path)).toContain("/api/books?q=herbert"),
|
||||||
})
|
);
|
||||||
|
expect(
|
||||||
|
calls
|
||||||
|
.filter((c) => c.path.startsWith("/api/books?q=h"))
|
||||||
|
.map((c) => c.path),
|
||||||
|
).toEqual(["/api/books?q=herbert"]);
|
||||||
|
});
|
||||||
|
|
||||||
it('filters by genre', async () => {
|
it("filters by genre", async () => {
|
||||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
|
||||||
const user = renderApp('/books')
|
const user = renderApp("/books");
|
||||||
await screen.findByRole('option', { name: 'Science Fiction' })
|
await screen.findByRole("option", { name: "Science Fiction" });
|
||||||
await user.selectOptions(screen.getByLabelText('Genre'), 'Science Fiction')
|
await user.selectOptions(screen.getByLabelText("Genre"), "Science Fiction");
|
||||||
await waitFor(() => expect(calls.map((c) => c.path)).toContain('/api/books?genre_id=5'))
|
await waitFor(() =>
|
||||||
})
|
expect(calls.map((c) => c.path)).toContain("/api/books?genre_id=5"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('restores search and genre from the URL', async () => {
|
it("restores search and genre from the URL", async () => {
|
||||||
const calls = signedIn({ 'GET /api/books': () => [200, [dune]] })
|
const calls = signedIn({ "GET /api/books": () => [200, [dune]] });
|
||||||
renderApp('/books?q=dune&genre=5')
|
renderApp("/books?q=dune&genre=5");
|
||||||
await screen.findByRole('option', { name: 'Science Fiction' })
|
await screen.findByRole("option", { name: "Science Fiction" });
|
||||||
expect(screen.getByLabelText('Search title or author')).toHaveValue('dune')
|
expect(screen.getByLabelText("Search title or author")).toHaveValue("dune");
|
||||||
expect(screen.getByLabelText('Genre')).toHaveValue('5')
|
expect(screen.getByLabelText("Genre")).toHaveValue("5");
|
||||||
expect(calls.map((c) => c.path)).toContain('/api/books?q=dune&genre_id=5')
|
expect(calls.map((c) => c.path)).toContain("/api/books?q=dune&genre_id=5");
|
||||||
})
|
});
|
||||||
|
|
||||||
it('offers retry when loading fails', async () => {
|
it("offers retry when loading fails", async () => {
|
||||||
let attempts = 0
|
let attempts = 0;
|
||||||
signedIn({ 'GET /api/books': () => (++attempts === 1 ? [500, { error: 'Unexpected server error' }] : [200, [dune]]) })
|
signedIn({
|
||||||
const user = renderApp('/books')
|
"GET /api/books": () =>
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Unexpected server error')
|
++attempts === 1
|
||||||
await user.click(screen.getByRole('button', { name: 'Retry' }))
|
? [500, { error: "Unexpected server error" }]
|
||||||
expect(await screen.findByRole('heading', { name: 'Dune' })).toBeInTheDocument()
|
: [200, [dune]],
|
||||||
})
|
});
|
||||||
})
|
const user = renderApp("/books");
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"Unexpected server error",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("heading", { name: "Dune" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+112
-70
@@ -1,73 +1,106 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Link, useSearchParams } from 'react-router'
|
import { Link, useSearchParams } from "react-router";
|
||||||
import { api, errorMessage, type Book } from '../api'
|
import { api, type Book, errorMessage } from "../api";
|
||||||
import { card, inputClass, labelText, primaryButton, secondaryButton } from '../ui'
|
import { AlertIcon, PlusIcon, SearchIcon } from "../icons";
|
||||||
import { ProgressBar, StatusBadge } from './Progress'
|
import {
|
||||||
import { useGenres } from './useGenres'
|
errorText,
|
||||||
|
inputClass,
|
||||||
|
labelText,
|
||||||
|
pageTitle,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
} from "../ui";
|
||||||
|
import { ProgressBar, StatusBadge } from "./Progress";
|
||||||
|
import { useGenres } from "./useGenres";
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 250
|
const SEARCH_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
export function BookList() {
|
export function BookList() {
|
||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams();
|
||||||
const q = params.get('q') ?? ''
|
const q = params.get("q") ?? "";
|
||||||
const genre = params.get('genre') ?? ''
|
const genre = params.get("genre") ?? "";
|
||||||
const [search, setSearch] = useState(q)
|
const [search, setSearch] = useState(q);
|
||||||
const [books, setBooks] = useState<Book[] | null>(null)
|
const [books, setBooks] = useState<Book[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [attempt, setAttempt] = useState(0)
|
const [attempt, setAttempt] = useState(0);
|
||||||
const { genres, error: genresError } = useGenres()
|
const { genres, error: genresError } = useGenres();
|
||||||
|
|
||||||
function updateParam(key: string, value: string) {
|
const updateParam = useCallback(
|
||||||
setParams(
|
(key: string, value: string) =>
|
||||||
(prev) => {
|
setParams(
|
||||||
const next = new URLSearchParams(prev)
|
(prev) => {
|
||||||
if (value) next.set(key, value)
|
const next = new URLSearchParams(prev);
|
||||||
else next.delete(key)
|
if (value) next.set(key, value);
|
||||||
return next
|
else next.delete(key);
|
||||||
},
|
return next;
|
||||||
{ replace: true },
|
},
|
||||||
)
|
{ replace: true },
|
||||||
}
|
),
|
||||||
|
[setParams],
|
||||||
|
);
|
||||||
|
|
||||||
// Keep the input in sync when the URL changes from outside (back/forward navigation).
|
useEffect(() => setSearch(q), [q]);
|
||||||
useEffect(() => setSearch(q), [q])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (search === q) return
|
if (search === q) return;
|
||||||
const timer = setTimeout(() => updateParam('q', search), SEARCH_DEBOUNCE_MS)
|
const timer = setTimeout(
|
||||||
return () => clearTimeout(timer)
|
() => updateParam("q", search),
|
||||||
}, [search, q])
|
SEARCH_DEBOUNCE_MS,
|
||||||
|
);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search, q, updateParam]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false;
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams();
|
||||||
if (q.trim()) query.set('q', q.trim())
|
if (q.trim()) query.set("q", q.trim());
|
||||||
if (genre) query.set('genre_id', genre)
|
if (genre) query.set("genre_id", genre);
|
||||||
api<Book[]>('GET', `/books?${query}`).then(
|
api<Book[]>("GET", `/books?${query}`).then(
|
||||||
(found) => {
|
(found) => {
|
||||||
if (cancelled) return
|
if (cancelled) return;
|
||||||
setBooks(found)
|
setBooks(found);
|
||||||
setError(null)
|
setError(null);
|
||||||
},
|
},
|
||||||
(err) => {
|
(err) => {
|
||||||
if (!cancelled) setError(errorMessage(err))
|
if (!cancelled) setError(errorMessage(err));
|
||||||
},
|
},
|
||||||
)
|
);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true;
|
||||||
}
|
};
|
||||||
}, [q, genre, attempt])
|
}, [q, genre, attempt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="space-y-5">
|
||||||
<div className="mb-4 flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<label className="min-w-48 grow">
|
<h1 className={pageTitle}>Your library</h1>
|
||||||
|
<Link to="/books/new" className={primaryButton}>
|
||||||
|
<PlusIcon />
|
||||||
|
Add book
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="min-w-0 flex-[1_1_280px]">
|
||||||
<span className={labelText}>Search title or author</span>
|
<span className={labelText}>Search title or author</span>
|
||||||
<input type="search" className={inputClass} value={search} onChange={(e) => setSearch(e.target.value)} />
|
<span className="relative block">
|
||||||
|
<span className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-muted">
|
||||||
|
<SearchIcon />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className={`${inputClass} pl-11`}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label className="flex-[0_1_220px]">
|
||||||
<span className={labelText}>Genre</span>
|
<span className={labelText}>Genre</span>
|
||||||
<select className={inputClass} value={genre} onChange={(e) => updateParam('genre', e.target.value)}>
|
<select
|
||||||
|
className={inputClass}
|
||||||
|
value={genre}
|
||||||
|
onChange={(e) => updateParam("genre", e.target.value)}
|
||||||
|
>
|
||||||
<option value="">All genres</option>
|
<option value="">All genres</option>
|
||||||
{genres.map((g) => (
|
{genres.map((g) => (
|
||||||
<option key={g.id} value={String(g.id)}>
|
<option key={g.id} value={String(g.id)}>
|
||||||
@@ -76,47 +109,56 @@ export function BookList() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<Link to="/books/new" className={primaryButton}>
|
|
||||||
Add book
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
{genresError && (
|
{genresError && (
|
||||||
<p role="alert" className="mb-3 text-sm text-red-700">
|
<p role="alert" className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
{`Could not load genres: ${genresError}`}
|
{`Could not load genres: ${genresError}`}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error ? (
|
{error ? (
|
||||||
<div role="alert" className="flex items-center gap-3 text-red-700">
|
<div role="alert" className="flex flex-wrap items-center gap-3">
|
||||||
<span>{error}</span>
|
<span className={errorText}>
|
||||||
<button type="button" className={secondaryButton} onClick={() => setAttempt((n) => n + 1)}>
|
<AlertIcon />
|
||||||
|
{error}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setAttempt((n) => n + 1)}
|
||||||
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : books === null ? (
|
) : books === null ? (
|
||||||
<p className="text-slate-500">Loading…</p>
|
<p className="text-muted">Loading…</p>
|
||||||
) : books.length === 0 ? (
|
) : books.length === 0 ? (
|
||||||
<p className="text-slate-600">
|
<p className="text-muted">
|
||||||
{q.trim() || genre ? 'No matches. Try a different search or genre.' : 'No books yet. Add your first one.'}
|
{q.trim() || genre
|
||||||
|
? "No matches. Try a different search or genre."
|
||||||
|
: "No books yet. Add your first one."}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid gap-3 sm:grid-cols-2">
|
<ul className="overflow-hidden rounded-xl border border-line bg-surface">
|
||||||
{books.map((book) => (
|
{books.map((book) => (
|
||||||
<li key={book.id}>
|
<li key={book.id} className="border-b border-line last:border-b-0">
|
||||||
<Link
|
<Link
|
||||||
to={`/books/${book.id}`}
|
to={`/books/${book.id}`}
|
||||||
className={`block ${card} hover:ring-2 hover:ring-indigo-300 focus-visible:outline-2 focus-visible:outline-indigo-600`}
|
className="flex flex-wrap items-center gap-x-6 gap-y-3 px-5 py-4 hover:bg-line/50 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="min-w-0 flex-[1_1_260px]">
|
||||||
<h2 className="font-semibold">{book.title}</h2>
|
<h2 className="font-serif text-xl leading-snug font-semibold">
|
||||||
<StatusBadge status={book.status} />
|
{book.title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted">{`${book.author} · ${book.genre.name}`}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-600">{`${book.author} · ${book.genre.name}`}</p>
|
<StatusBadge status={book.status} />
|
||||||
<ProgressBar book={book} />
|
<ProgressBar book={book} compact />
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ReadingStatus } from "../api";
|
||||||
|
import { StatusBadge } from "./Progress";
|
||||||
|
|
||||||
|
describe("StatusBadge", () => {
|
||||||
|
it.each<[ReadingStatus, string]>([
|
||||||
|
["not_started", "Not started"],
|
||||||
|
["reading", "Reading"],
|
||||||
|
["finished", "Finished"],
|
||||||
|
])("shows %s as a text label with a decorative icon", (status, label) => {
|
||||||
|
render(<StatusBadge status={status} />);
|
||||||
|
const badge = screen.getByText(label);
|
||||||
|
expect(badge.querySelector("svg[aria-hidden='true']")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+46
-14
@@ -1,30 +1,62 @@
|
|||||||
import type { Book, ReadingStatus } from '../api'
|
import type { ReactNode } from "react";
|
||||||
|
import type { Book, ReadingStatus } from "../api";
|
||||||
|
import { FinishedIcon, NotStartedIcon, ReadingIcon } from "../icons";
|
||||||
|
|
||||||
const STATUS: Record<ReadingStatus, { label: string; style: string }> = {
|
const STATUS: Record<
|
||||||
not_started: { label: 'Not started', style: 'bg-slate-100 text-slate-700' },
|
ReadingStatus,
|
||||||
reading: { label: 'Reading', style: 'bg-amber-100 text-amber-800' },
|
{ label: string; style: string; icon: ReactNode }
|
||||||
finished: { label: 'Finished', style: 'bg-emerald-100 text-emerald-800' },
|
> = {
|
||||||
}
|
not_started: {
|
||||||
|
label: "Not started",
|
||||||
|
style: "bg-idle-bg text-idle-fg",
|
||||||
|
icon: <NotStartedIcon />,
|
||||||
|
},
|
||||||
|
reading: {
|
||||||
|
label: "Reading",
|
||||||
|
style: "bg-reading-bg text-reading-fg",
|
||||||
|
icon: <ReadingIcon />,
|
||||||
|
},
|
||||||
|
finished: {
|
||||||
|
label: "Finished",
|
||||||
|
style: "bg-finished-bg text-finished-fg",
|
||||||
|
icon: <FinishedIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: ReadingStatus }) {
|
export function StatusBadge({ status }: { status: ReadingStatus }) {
|
||||||
return (
|
return (
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap ${STATUS[status].style}`}>
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-sm font-bold whitespace-nowrap ${STATUS[status].style}`}
|
||||||
|
>
|
||||||
|
{STATUS[status].icon}
|
||||||
{STATUS[status].label}
|
{STATUS[status].label}
|
||||||
</span>
|
</span>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressBar({ book }: { book: Book }) {
|
export function ProgressBar({
|
||||||
const percent = Math.round((book.current_page / book.total_pages) * 100)
|
book,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
book: Book;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const percent = Math.round((book.current_page / book.total_pages) * 100);
|
||||||
return (
|
return (
|
||||||
<div className="mt-3">
|
<div className={compact ? "flex flex-[0_1_200px] items-center gap-3" : ""}>
|
||||||
<progress
|
<progress
|
||||||
className="h-2 w-full accent-indigo-600"
|
className={`block w-full appearance-none overflow-hidden rounded-full bg-track ${compact ? "h-1.5 flex-1" : "h-2"} [&::-moz-progress-bar]:bg-accent [&::-webkit-progress-bar]:bg-track [&::-webkit-progress-value]:bg-accent`}
|
||||||
value={book.current_page}
|
value={book.current_page}
|
||||||
max={book.total_pages}
|
max={book.total_pages}
|
||||||
aria-label={`Reading progress for ${book.title}`}
|
aria-label={`Reading progress for ${book.title}`}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-slate-600">{`${book.current_page} / ${book.total_pages} (${percent}%)`}</p>
|
<p
|
||||||
|
className={`text-sm text-muted tabular-nums ${compact ? "min-w-10 text-right" : "mt-1.5"}`}
|
||||||
|
>
|
||||||
|
{compact
|
||||||
|
? `${percent}%`
|
||||||
|
: `Page ${book.current_page} of ${book.total_pages} · ${percent}%`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from "react";
|
||||||
import { api, errorMessage, type Genre } from '../api'
|
import { api, errorMessage, type Genre } from "../api";
|
||||||
|
|
||||||
export function useGenres(): { genres: Genre[]; error: string | null } {
|
export function useGenres(): { genres: Genre[]; error: string | null } {
|
||||||
const [genres, setGenres] = useState<Genre[]>([])
|
const [genres, setGenres] = useState<Genre[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<Genre[]>('GET', '/genres').then(setGenres, (err) => setError(errorMessage(err)))
|
api<Genre[]>("GET", "/genres").then(setGenres, (err) =>
|
||||||
}, [])
|
setError(errorMessage(err)),
|
||||||
return { genres, error }
|
);
|
||||||
|
}, []);
|
||||||
|
return { genres, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
function Icon({ size = 18, children }: { size?: number; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BookIcon = () => (
|
||||||
|
<Icon size={26}>
|
||||||
|
<path d="M12 6.5C10 5 7.5 4.5 4 4.5v13c3.5 0 6 .5 8 2 2-1.5 4.5-2 8-2v-13c-3.5 0-6 .5-8 2Z" />
|
||||||
|
<path d="M12 6.5v13" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlusIcon = () => (
|
||||||
|
<Icon>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AlertIcon = () => (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 7.5v5.5M12 16.5v.01" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TrashIcon = () => (
|
||||||
|
<Icon>
|
||||||
|
<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SearchIcon = () => (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="11" cy="11" r="6.5" />
|
||||||
|
<path d="m16 16 4 4" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const NotStartedIcon = () => (
|
||||||
|
<Icon size={14}>
|
||||||
|
<circle cx="12" cy="12" r="9" strokeDasharray="3 3" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ReadingIcon = () => (
|
||||||
|
<Icon size={14}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 3a9 9 0 0 1 0 18Z" fill="currentColor" stroke="none" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const FinishedIcon = () => (
|
||||||
|
<Icon size={14}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="m8 12.5 2.5 2.5L16 9.5" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SystemThemeIcon = () => (
|
||||||
|
<Icon>
|
||||||
|
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||||
|
<path d="M8 20h8M12 16v4" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SunIcon = () => (
|
||||||
|
<Icon>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MoonIcon = () => (
|
||||||
|
<Icon>
|
||||||
|
<path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5Z" />
|
||||||
|
</Icon>
|
||||||
|
);
|
||||||
@@ -1 +1,58 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: light-dark(#f7f3ec, #181512);
|
||||||
|
--surface: light-dark(#fffdf8, #231f1b);
|
||||||
|
--field: light-dark(#fffdf8, #1e1a17);
|
||||||
|
--line: light-dark(#e4dccd, #3a332c);
|
||||||
|
--control: light-dark(#8c7f6b, #7d7266);
|
||||||
|
--ink: light-dark(#2b2620, #eee7dd);
|
||||||
|
--muted: light-dark(#5e5548, #b9ae9f);
|
||||||
|
--accent: light-dark(#2f5d50, #e3a857);
|
||||||
|
--accent-hover: light-dark(#21443a, #edbb73);
|
||||||
|
--on-accent: light-dark(#ffffff, #1a1612);
|
||||||
|
--danger: light-dark(#a3341f, #f29c86);
|
||||||
|
--track: light-dark(#e4dccd, #3a332c);
|
||||||
|
--idle-bg: light-dark(#ece6db, #2e2924);
|
||||||
|
--idle-fg: light-dark(#4a4237, #cfc5b8);
|
||||||
|
--reading-bg: light-dark(#f6e6cf, #3d2a12);
|
||||||
|
--reading-fg: light-dark(#7a4310, #f2c17e);
|
||||||
|
--finished-bg: light-dark(#dce7f3, #1d3047);
|
||||||
|
--finished-fg: light-dark(#234b78, #a9cdf2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-bg: var(--bg);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-field: var(--field);
|
||||||
|
--color-line: var(--line);
|
||||||
|
--color-control: var(--control);
|
||||||
|
--color-ink: var(--ink);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-hover: var(--accent-hover);
|
||||||
|
--color-on-accent: var(--on-accent);
|
||||||
|
--color-danger: var(--danger);
|
||||||
|
--color-track: var(--track);
|
||||||
|
--color-idle-bg: var(--idle-bg);
|
||||||
|
--color-idle-fg: var(--idle-fg);
|
||||||
|
--color-reading-bg: var(--reading-bg);
|
||||||
|
--color-reading-fg: var(--reading-fg);
|
||||||
|
--color-finished-bg: var(--finished-bg);
|
||||||
|
--color-finished-fg: var(--finished-fg);
|
||||||
|
--font-sans: "Atkinson Hyperlegible", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-serif: "Newsreader Variable", Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-bg font-sans text-ink;
|
||||||
|
}
|
||||||
|
|||||||
+16
-6
@@ -1,10 +1,20 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from './App'
|
import { App } from "./App";
|
||||||
import './index.css'
|
import "@fontsource/atkinson-hyperlegible/400.css";
|
||||||
|
import "@fontsource/atkinson-hyperlegible/700.css";
|
||||||
|
import "@fontsource-variable/newsreader";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
const root = document.getElementById("root");
|
||||||
|
if (!root) {
|
||||||
|
throw new Error(
|
||||||
|
'index.html has no element with id="root", so the app has nowhere to mount',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(root).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { act, fireEvent, screen, within } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Note } from "../api";
|
||||||
|
import { dune, held, renderApp, signedIn } from "../test/helpers";
|
||||||
|
|
||||||
|
const older: Note = {
|
||||||
|
id: 11,
|
||||||
|
book_id: 7,
|
||||||
|
body: "Started reading",
|
||||||
|
created_at: "2026-10-01T10:00:00+00:00",
|
||||||
|
updated_at: "2026-10-01T10:00:00+00:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("NotesJournal", () => {
|
||||||
|
it("lists entries and adds a new one at the top", async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, [older]],
|
||||||
|
"POST /api/books/7/notes": ({ body }) => [
|
||||||
|
201,
|
||||||
|
{
|
||||||
|
...older,
|
||||||
|
id: 12,
|
||||||
|
body: (body as { body: string }).body,
|
||||||
|
created_at: "2026-10-02T10:00:00+00:00",
|
||||||
|
updated_at: "2026-10-02T10:00:00+00:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
expect(await screen.findByText("Started reading")).toBeInTheDocument();
|
||||||
|
await user.type(
|
||||||
|
screen.getByLabelText("New journal entry"),
|
||||||
|
"Loved chapter 3",
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add entry" }));
|
||||||
|
|
||||||
|
await screen.findByText("Loved chapter 3");
|
||||||
|
const items = screen.getAllByRole("listitem");
|
||||||
|
expect(items[0]).toHaveTextContent("Loved chapter 3");
|
||||||
|
expect(items[1]).toHaveTextContent("Started reading");
|
||||||
|
expect(screen.getByLabelText("New journal entry")).toHaveValue("");
|
||||||
|
expect(calls).toContainEqual({
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/books/7/notes",
|
||||||
|
body: { body: "Loved chapter 3" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits an entry and marks it edited", async () => {
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, [older]],
|
||||||
|
"PATCH /api/notes/11": ({ body }) => [
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
...older,
|
||||||
|
body: (body as { body: string }).body,
|
||||||
|
updated_at: "2026-10-02T10:00:00+00:00",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
const item = (await screen.findByText("Started reading")).closest("li");
|
||||||
|
if (!item) throw new Error("Note text is not rendered inside a list item");
|
||||||
|
await user.click(within(item).getByRole("button", { name: "Edit entry" }));
|
||||||
|
const editor = within(item).getByLabelText("Edit entry");
|
||||||
|
await user.clear(editor);
|
||||||
|
await user.type(editor, "Finished part one");
|
||||||
|
await user.click(within(item).getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(item).findByText("Finished part one"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(item).getByText(/edited/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes an entry", async () => {
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, [older]],
|
||||||
|
"DELETE /api/notes/11": () => [204],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "Delete entry" }),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("No entries yet.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the server error when adding fails", async () => {
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
|
"POST /api/books/7/notes": () => [
|
||||||
|
400,
|
||||||
|
{ error: "body must be at most 10000 characters", field: "body" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
await user.type(await screen.findByLabelText("New journal entry"), "x");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add entry" }));
|
||||||
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||||
|
"body must be at most 10000 characters",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a double-clicked entry only once", async () => {
|
||||||
|
const calls = signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
|
"POST /api/books/7/notes": ({ body }) => [
|
||||||
|
201,
|
||||||
|
{ ...older, id: 12, body: (body as { body: string }).body },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
await screen.findByText("No entries yet.");
|
||||||
|
await user.type(screen.getByLabelText("New journal entry"), "Once only");
|
||||||
|
const add = screen.getByRole("button", { name: "Add entry" });
|
||||||
|
fireEvent.click(add);
|
||||||
|
fireEvent.click(add);
|
||||||
|
|
||||||
|
await screen.findByText("Once only");
|
||||||
|
expect(calls.filter((c) => c.method === "POST")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NotesJournal request races", () => {
|
||||||
|
it("does not allow adding an entry until the journal has loaded", async () => {
|
||||||
|
const notes = held();
|
||||||
|
const calls = signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => notes.reply,
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
await user.type(await screen.findByLabelText("New journal entry"), "Early");
|
||||||
|
expect(screen.getByRole("button", { name: "Add entry" })).toBeDisabled();
|
||||||
|
|
||||||
|
notes.release([200, [older]]);
|
||||||
|
|
||||||
|
await screen.findByText("Started reading");
|
||||||
|
expect(screen.getByRole("button", { name: "Add entry" })).toBeEnabled();
|
||||||
|
expect(calls.some((c) => c.method === "POST")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let typing during a save be lost when the save completes", async () => {
|
||||||
|
const saved = held();
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, []],
|
||||||
|
"POST /api/books/7/notes": () => saved.reply,
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
const draft = await screen.findByLabelText("New journal entry");
|
||||||
|
await screen.findByText("No entries yet.");
|
||||||
|
await user.type(draft, "First thought");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add entry" }));
|
||||||
|
await user.type(draft, " and more");
|
||||||
|
expect(draft).toHaveValue("First thought");
|
||||||
|
|
||||||
|
saved.release([201, { ...older, id: 12, body: "First thought" }]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("First thought")).toBeInTheDocument();
|
||||||
|
expect(draft).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents overlapping saves of an existing entry", async () => {
|
||||||
|
const saved = held();
|
||||||
|
signedIn({
|
||||||
|
"GET /api/books/7": () => [200, dune],
|
||||||
|
"GET /api/books/7/notes": () => [200, [older]],
|
||||||
|
"PATCH /api/notes/11": () => saved.reply,
|
||||||
|
});
|
||||||
|
const user = renderApp("/books/7");
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Edit entry" }));
|
||||||
|
const editor = screen.getByLabelText("Edit entry");
|
||||||
|
await user.clear(editor);
|
||||||
|
await user.type(editor, "First edit");
|
||||||
|
const save = screen.getByRole("button", { name: "Save" });
|
||||||
|
await user.click(save);
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(save).toBeDisabled();
|
||||||
|
await user.type(editor, " and more");
|
||||||
|
expect(editor).toHaveValue("First edit");
|
||||||
|
} finally {
|
||||||
|
await act(async () => {
|
||||||
|
saved.release([200, { ...older, body: "First edit" }]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await screen.findByText("First edit")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { type SubmitEvent, useEffect, useState } from "react";
|
||||||
|
import { api, errorMessage, type Note } from "../api";
|
||||||
|
import { AlertIcon, TrashIcon } from "../icons";
|
||||||
|
import {
|
||||||
|
card,
|
||||||
|
dangerButton,
|
||||||
|
errorText,
|
||||||
|
inputClass,
|
||||||
|
labelText,
|
||||||
|
primaryButton,
|
||||||
|
secondaryButton,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
|
const MAX_NOTE = 10000;
|
||||||
|
|
||||||
|
export function NotesJournal({ bookId }: { bookId: number }) {
|
||||||
|
const [notes, setNotes] = useState<Note[] | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [attempt, setAttempt] = useState(0);
|
||||||
|
const [draft, setDraft] = useState("");
|
||||||
|
const [addError, setAddError] = useState<string | null>(null);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadError(null);
|
||||||
|
api<Note[]>("GET", `/books/${bookId}/notes`).then(
|
||||||
|
(found) => {
|
||||||
|
if (!cancelled) setNotes(found);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
if (!cancelled) setLoadError(errorMessage(err));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [bookId, attempt]);
|
||||||
|
|
||||||
|
async function addNote(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (adding || notes === null) return;
|
||||||
|
setAdding(true);
|
||||||
|
setAddError(null);
|
||||||
|
try {
|
||||||
|
const note = await api<Note>("POST", `/books/${bookId}/notes`, {
|
||||||
|
body: draft,
|
||||||
|
});
|
||||||
|
setNotes((prev) => [note, ...(prev ?? [])]);
|
||||||
|
setDraft("");
|
||||||
|
} catch (err) {
|
||||||
|
setAddError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="journal-heading" className="space-y-4">
|
||||||
|
<h2 id="journal-heading" className="font-serif text-3xl font-medium">
|
||||||
|
Reading journal
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={addNote} className="space-y-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className={labelText}>New journal entry</span>
|
||||||
|
<textarea
|
||||||
|
className={inputClass}
|
||||||
|
rows={3}
|
||||||
|
maxLength={MAX_NOTE}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
readOnly={adding}
|
||||||
|
required
|
||||||
|
aria-describedby={addError ? "add-note-error" : undefined}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={adding || notes === null}
|
||||||
|
className={primaryButton}
|
||||||
|
>
|
||||||
|
Add entry
|
||||||
|
</button>
|
||||||
|
{addError && (
|
||||||
|
<p id="add-note-error" role="alert" className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
|
{addError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
{loadError ? (
|
||||||
|
<div role="alert" className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className={errorText}>
|
||||||
|
<AlertIcon />
|
||||||
|
{loadError}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setAttempt((n) => n + 1)}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : notes === null ? (
|
||||||
|
<p className="text-muted">Loading…</p>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<p className="text-muted">No entries yet.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<NoteItem
|
||||||
|
key={note.id}
|
||||||
|
note={note}
|
||||||
|
onSaved={(saved) =>
|
||||||
|
setNotes(
|
||||||
|
(prev) =>
|
||||||
|
prev?.map((n) => (n.id === saved.id ? saved : n)) ?? null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDeleted={() =>
|
||||||
|
setNotes(
|
||||||
|
(prev) => prev?.filter((n) => n.id !== note.id) ?? null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NoteItem({
|
||||||
|
note,
|
||||||
|
onSaved,
|
||||||
|
onDeleted,
|
||||||
|
}: {
|
||||||
|
note: Note;
|
||||||
|
onSaved: (note: Note) => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [text, setText] = useState(note.body);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const errorId = `note-${note.id}-error`;
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (saving) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
onSaved(await api<Note>("PATCH", `/notes/${note.id}`, { body: text }));
|
||||||
|
setEditing(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await api("DELETE", `/notes/${note.id}`);
|
||||||
|
onDeleted();
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={`${card} px-6 py-5`}>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
<time dateTime={note.created_at}>
|
||||||
|
{new Date(note.created_at).toLocaleString()}
|
||||||
|
</time>
|
||||||
|
{note.updated_at !== note.created_at && " · edited"}
|
||||||
|
</p>
|
||||||
|
{editing ? (
|
||||||
|
<form onSubmit={save} className="mt-2 space-y-3">
|
||||||
|
<label className="block">
|
||||||
|
<span className="sr-only">Edit entry</span>
|
||||||
|
<textarea
|
||||||
|
className={inputClass}
|
||||||
|
rows={3}
|
||||||
|
maxLength={MAX_NOTE}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
readOnly={saving}
|
||||||
|
required
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={saving} className={primaryButton}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(false);
|
||||||
|
setText(note.body);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="mt-2 font-serif text-lg leading-relaxed whitespace-pre-wrap">
|
||||||
|
{note.body}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryButton}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
>
|
||||||
|
Edit entry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={dangerButton}
|
||||||
|
onClick={() => void remove()}
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
Delete entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} role="alert" className={`mt-3 ${errorText}`}>
|
||||||
|
<AlertIcon />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
+52
-38
@@ -1,65 +1,79 @@
|
|||||||
import { render } from '@testing-library/react'
|
import { render } from "@testing-library/react";
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from 'react-router'
|
import { MemoryRouter } from "react-router";
|
||||||
import { vi } from 'vitest'
|
import { vi } from "vitest";
|
||||||
import type { Book, Genre, User } from '../api'
|
import { AppRoutes } from "../App";
|
||||||
import { AppRoutes } from '../App'
|
import type { Book, Genre, User } from "../api";
|
||||||
import { AuthProvider } from '../auth/AuthContext'
|
import { AuthProvider } from "../auth/AuthContext";
|
||||||
|
|
||||||
export const alice: User = { id: 1, username: 'alice' }
|
export const alice: User = { id: 1, username: "alice" };
|
||||||
export const genres: Genre[] = [
|
export const genres: Genre[] = [
|
||||||
{ id: 3, name: 'Fantasy' },
|
{ id: 3, name: "Fantasy" },
|
||||||
{ id: 5, name: 'Science Fiction' },
|
{ id: 5, name: "Science Fiction" },
|
||||||
]
|
];
|
||||||
export const dune: Book = {
|
export const dune: Book = {
|
||||||
id: 7,
|
id: 7,
|
||||||
title: 'Dune',
|
title: "Dune",
|
||||||
author: 'Frank Herbert',
|
author: "Frank Herbert",
|
||||||
genre: { id: 5, name: 'Science Fiction' },
|
genre: { id: 5, name: "Science Fiction" },
|
||||||
total_pages: 380,
|
total_pages: 380,
|
||||||
current_page: 142,
|
current_page: 142,
|
||||||
status: 'reading',
|
status: "reading",
|
||||||
created_at: '2026-10-01T10:00:00+00:00',
|
created_at: "2026-10-01T10:00:00+00:00",
|
||||||
updated_at: '2026-10-01T10:00:00+00:00',
|
updated_at: "2026-10-01T10:00:00+00:00",
|
||||||
|
};
|
||||||
|
|
||||||
|
type Reply = [status: number, body?: unknown];
|
||||||
|
type Handler = (request: { body: unknown; url: URL }) => Reply | Promise<Reply>;
|
||||||
|
export type Call = { method: string; path: string; body: unknown };
|
||||||
|
|
||||||
|
export function held() {
|
||||||
|
let release!: (reply: Reply) => void;
|
||||||
|
const reply = new Promise<Reply>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
return { reply, release };
|
||||||
}
|
}
|
||||||
|
|
||||||
type Reply = [status: number, body?: unknown]
|
|
||||||
type Handler = (request: { body: unknown; url: URL }) => Reply
|
|
||||||
export type Call = { method: string; path: string; body: unknown }
|
|
||||||
|
|
||||||
/** Replaces fetch (the network boundary) with route handlers keyed by "METHOD /api/path". */
|
|
||||||
export function mockApi(routes: Record<string, Handler>): Call[] {
|
export function mockApi(routes: Record<string, Handler>): Call[] {
|
||||||
const calls: Call[] = []
|
const calls: Call[] = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
"fetch",
|
||||||
vi.fn(async (input: string, init: RequestInit = {}) => {
|
vi.fn(async (input: string, init: RequestInit = {}) => {
|
||||||
const url = new URL(input, 'http://localhost')
|
const url = new URL(input, "http://localhost");
|
||||||
const method = init.method ?? 'GET'
|
const method = init.method ?? "GET";
|
||||||
const body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined
|
const body =
|
||||||
calls.push({ method, path: url.pathname + url.search, body })
|
typeof init.body === "string" ? JSON.parse(init.body) : undefined;
|
||||||
const handler = routes[`${method} ${url.pathname}`]
|
calls.push({ method, path: url.pathname + url.search, body });
|
||||||
const [status, data] = handler ? handler({ body, url }) : [500, { error: `No mock for ${method} ${url.pathname}` }]
|
const handler = routes[`${method} ${url.pathname}`];
|
||||||
|
const [status, data] = handler
|
||||||
|
? await handler({ body, url })
|
||||||
|
: [500, { error: `No mock for ${method} ${url.pathname}` }];
|
||||||
return new Response(status === 204 ? null : JSON.stringify(data), {
|
return new Response(status === 204 ? null : JSON.stringify(data), {
|
||||||
status,
|
status,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
})
|
});
|
||||||
}),
|
}),
|
||||||
)
|
);
|
||||||
return calls
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function signedIn(routes: Record<string, Handler> = {}): Call[] {
|
export function signedIn(routes: Record<string, Handler> = {}): Call[] {
|
||||||
return mockApi({ 'GET /api/auth/me': () => [200, alice], 'GET /api/genres': () => [200, genres], ...routes })
|
return mockApi({
|
||||||
|
"GET /api/auth/me": () => [200, alice],
|
||||||
|
"GET /api/genres": () => [200, genres],
|
||||||
|
...routes,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderApp(url: string) {
|
export function renderApp(url: string) {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={[url]}>
|
<MemoryRouter initialEntries={[url]}>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
)
|
);
|
||||||
return user
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -1,6 +1,5 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach } from 'vitest'
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
// Vitest globals are off, so Testing Library cannot register its own cleanup.
|
afterEach(() => cleanup());
|
||||||
afterEach(() => cleanup())
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
export const card = 'rounded-lg bg-white p-4 shadow'
|
export const card = "rounded-xl border border-line bg-surface p-6";
|
||||||
export const labelText = 'mb-1 block text-sm font-medium text-slate-700'
|
export const pageTitle = "font-serif text-4xl font-medium tracking-tight";
|
||||||
|
export const labelText = "mb-1.5 block text-sm font-bold";
|
||||||
export const inputClass =
|
export const inputClass =
|
||||||
'block w-full rounded-md border border-slate-300 px-3 py-2 focus-visible:outline-2 focus-visible:outline-indigo-600 aria-invalid:border-red-600'
|
"block min-h-11 w-full rounded-lg border border-control bg-field px-3.5 py-2 text-ink placeholder:text-muted focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent aria-invalid:border-2 aria-invalid:border-danger";
|
||||||
|
export const linkText =
|
||||||
|
"font-bold text-accent underline underline-offset-3 hover:text-accent-hover";
|
||||||
|
export const errorText =
|
||||||
|
"flex items-center gap-2 text-sm font-bold text-danger";
|
||||||
const button =
|
const button =
|
||||||
'inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50'
|
"inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-5 font-bold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50";
|
||||||
export const primaryButton = `${button} bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:outline-indigo-600`
|
export const primaryButton = `${button} bg-accent text-on-accent hover:bg-accent-hover`;
|
||||||
export const secondaryButton = `${button} border border-slate-300 bg-white text-slate-800 hover:bg-slate-100 focus-visible:outline-indigo-600`
|
export const secondaryButton = `${button} border border-control text-ink hover:bg-line`;
|
||||||
export const dangerButton = `${button} bg-red-600 text-white hover:bg-red-700 focus-visible:outline-red-600`
|
export const dangerButton = `${button} border border-danger text-danger hover:bg-danger/10`;
|
||||||
|
|||||||
+24
-7
@@ -1,14 +1,31 @@
|
|||||||
/// <reference types="vitest/config" />
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { createLogger, defineConfig } from "vite";
|
||||||
|
|
||||||
|
// Set only inside a CoderPad pad, whose preview proxies port 3000 and the HMR websocket.
|
||||||
|
const padHost = process.env.PROJECTS_BASE_HOST;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: { proxy: { '/api': 'http://127.0.0.1:5000' } },
|
server: {
|
||||||
|
proxy: { "/api": { target: "http://127.0.0.1:5000", changeOrigin: true } },
|
||||||
|
...(padHost
|
||||||
|
? {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 3000,
|
||||||
|
hmr: { clientPort: Number(process.env.WEBSOCKET_PORT) },
|
||||||
|
allowedHosts: [`.${padHost}`],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
...(padHost
|
||||||
|
? { customLogger: createLogger("info", { prefix: "[coderpad]" }) }
|
||||||
|
: {}),
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: "jsdom",
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
unstubGlobals: true,
|
unstubGlobals: true,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user