feat: auth by default, theme toggle, CoderPad startup, review fixes

- Require a session on every route; public routes opt out with @allow_anonymous
- Split password hashing and pepper loading into passwords.py
- Add a system/light/dark theme toggle backed by light-dark() colors
- Ignore stale 401s from an earlier session, PATCH only changed book fields,
  and block overlapping journal-entry saves
- Add bin/start and CoderPad Vite server settings for the pad's start/restart
- Rewrite README as a mise onboarding guide; expand .gitignore
- Include review-round fixes and tests
This commit is contained in:
2026-10-02 16:49:14 -05:00
parent 9d7b0e805c
commit 7f5d034a1f
46 changed files with 1797 additions and 497 deletions
+14 -12
View File
@@ -3,7 +3,8 @@
import logging
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
import auth
@@ -28,18 +29,19 @@ def create_app() -> Flask:
app.register_blueprint(notes.bp)
@app.get("/api/health")
def health():
@auth.allow_anonymous
def health() -> ResponseReturnValue:
return {"status": "ok"}
@app.before_request
def require_json_for_mutations():
def require_json_for_mutations() -> None:
if request.method in MUTATING_METHODS and not request.is_json:
raise ApiError(
400, "Request body must be JSON with Content-Type: application/json"
)
@app.after_request
def log_request(response):
def log_request(response: Response) -> Response:
log.info(
"%s %s -> %s user=%s",
request.method,
@@ -50,18 +52,20 @@ def create_app() -> Flask:
return response
@app.errorhandler(ApiError)
def handle_api_error(error: ApiError):
def handle_api_error(error: ApiError) -> ResponseReturnValue:
body = {"error": error.message}
if error.field:
body["field"] = error.field
return jsonify(body), error.status
@app.errorhandler(HTTPException)
def handle_http_error(error: HTTPException):
return jsonify(error=error.description), error.code
def handle_http_error(error: HTTPException) -> ResponseReturnValue:
return jsonify(error=error.description), error.code or 500
@app.errorhandler(psycopg2.errors.CheckViolation)
def handle_check_violation(error: psycopg2.errors.CheckViolation):
def handle_check_violation(
error: psycopg2.errors.CheckViolation,
) -> ResponseReturnValue:
return (
jsonify(
error=f"Value breaks data rule '{error.diag.constraint_name}'; correct it and retry"
@@ -70,14 +74,12 @@ def create_app() -> Flask:
)
@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)
return jsonify(error="Unexpected server error"), 500
return app
app = create_app()
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
create_app().run(host="127.0.0.1", port=5000)
+81 -120
View File
@@ -1,135 +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 hmac
import logging
import os
import re
import secrets
from collections.abc import Callable
from pathlib import Path
from typing import ParamSpec, TypedDict, TypeVar, cast
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 validation import ApiError, json_body, require_utf8
from db import query, query_one, query_row
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")
ITERATIONS = 600_000
SALT_BYTES = 16
MIN_PEPPER_CHARS = 32
SESSION_COOKIE = "sid"
USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}")
MIN_PASSWORD, MAX_PASSWORD = 12, 1024
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:
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
_dummy_hash()
dummy_hash()
app.before_request(_require_session)
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 ------------------------------------------------------------------
def login_required(view):
@functools.wraps(view)
def wrapper(*args, **kwargs):
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"]
return view(*args, **kwargs)
def allow_anonymous(view: Callable[P, R]) -> Callable[P, R]:
view.allow_anonymous = True # type: ignore[attr-defined]
return view
return wrapper
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:
@@ -137,10 +92,11 @@ def _token_hash(token: str) -> 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)
query(
"DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],)
@@ -160,7 +116,7 @@ def _start_session(user: dict, status: int):
# --- Routes ----------------------------------------------------------------------
def _password(body: dict) -> str:
def _password(body: JsonObject) -> str:
password = body.get("password")
if not isinstance(password, str):
raise ApiError(400, "password is required and must be a string", "password")
@@ -173,7 +129,8 @@ def _password(body: dict) -> str:
@bp.post("/register")
def register():
@allow_anonymous
def register() -> Response:
body = json_body({"username", "password"})
username = body.get("username")
if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username):
@@ -188,7 +145,7 @@ def register():
400, f"password must be at least {MIN_PASSWORD} characters", "password"
)
try:
user = query_one(
user = query_row(
"INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username",
(username, hash_password(password, _pepper())),
)
@@ -196,22 +153,26 @@ def register():
raise ApiError(
409, "Username already taken; choose another", "username"
) from None
return _start_session(user, 201)
return _start_session(cast(UserRow, user), 201)
@bp.post("/login")
def login():
@allow_anonymous
def login() -> Response:
body = json_body({"username", "password"})
password = _password(body)
username = body.get("username")
user = None
if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username):
user = query_one(
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)",
(username,),
user = cast(
UserWithHash | None,
query_one(
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)",
(username,),
),
)
if user is None:
verify_password(password, _dummy_hash(), _pepper())
verify_password(password, dummy_hash(), _pepper())
raise ApiError(401, INVALID_LOGIN)
if not verify_password(password, user["password_hash"], _pepper()):
raise ApiError(401, INVALID_LOGIN)
@@ -224,7 +185,8 @@ def login():
@bp.post("/logout")
def logout():
@allow_anonymous
def logout() -> Response:
token = request.cookies.get(SESSION_COOKIE)
if token:
query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),))
@@ -236,8 +198,7 @@ def logout():
@bp.get("/me")
@login_required
def me():
def me() -> Response:
return jsonify(
query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,))
)
+57 -33
View File
@@ -1,13 +1,16 @@
"""Books slice: CRUD, progress, search/filter, and the genre list."""
import re
from collections.abc import Callable
from datetime import datetime
from typing import TypedDict, cast
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 query, query_one
from validation import ApiError, integer, json_body, text
from db import Params, Row, query, query_one, query_row
from validation import ApiError, JsonObject, integer, json_body, text
bp = Blueprint("books", __name__, url_prefix="/api")
@@ -17,7 +20,7 @@ MAX_GENRE_ID = 32_767
GENRE_ID_PATTERN = re.compile(r"[0-9]{1,5}")
REQUIRED_FIELDS = ("title", "author", "genre_id", "total_pages")
FIELDS = set(REQUIRED_FIELDS) | {"current_page"}
VALIDATORS = {
VALIDATORS: dict[str, Callable[[JsonObject], str | int]] = {
"title": lambda body: text(body, "title", MAX_TEXT),
"author": lambda body: text(body, "author", MAX_TEXT),
"genre_id": lambda body: integer(body, "genre_id", 1, MAX_GENRE_ID),
@@ -25,6 +28,22 @@ VALIDATORS = {
"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 = """
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
@@ -41,7 +60,7 @@ def reading_status(current_page: int, total_pages: int) -> str:
return "reading"
def to_json(row: dict) -> dict:
def to_json(row: BookRow) -> dict[str, object]:
return {
"id": row["id"],
"title": row["title"],
@@ -59,16 +78,19 @@ def escape_like(term: str) -> str:
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)
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:
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"]:
raise ApiError(
400,
@@ -78,9 +100,9 @@ def _check_progress(book: dict, field: str) -> None:
)
def _save(sql: str, params: tuple) -> dict | None:
def _save(sql: str, params: Params) -> Row:
try:
return query_one(sql, params)
return query_row(sql, params)
except psycopg2.errors.ForeignKeyViolation:
raise ApiError(
400, "Unknown genre_id; see GET /api/genres", "genre_id"
@@ -88,16 +110,14 @@ def _save(sql: str, params: tuple) -> dict | None:
@bp.get("/genres")
@login_required
def list_genres():
def list_genres() -> Response:
return jsonify(query("SELECT id, name FROM genres ORDER BY name"))
@bp.get("/books")
@login_required
def list_books():
def list_books() -> Response:
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()
if search:
if len(search) > MAX_TEXT or "\x00" in search:
@@ -116,16 +136,20 @@ def list_books():
sql += " AND b.genre_id = %s"
params.append(int(genre_id))
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")
@login_required
def create_book():
def create_book() -> ResponseReturnValue:
body = json_body(FIELDS)
book = {field: VALIDATORS[field](body) for field in REQUIRED_FIELDS}
book["current_page"] = (
VALIDATORS["current_page"](body) if "current_page" in body else 0
book = cast(
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")
row = _save(
@@ -147,27 +171,28 @@ def create_book():
@bp.get("/books/<int(max=2147483647):book_id>")
@login_required
def get_book(book_id: int):
def get_book(book_id: int) -> Response:
return jsonify(to_json(owned_book(book_id)))
@bp.patch("/books/<int(max=2147483647):book_id>")
@login_required
def update_book(book_id: int):
def update_book(book_id: int) -> Response:
body = json_body(FIELDS)
if not body:
raise ApiError(400, f"Provide at least one of: {', '.join(sorted(FIELDS))}")
current = owned_book(book_id)
book = {field: current[field] for field in FIELDS} | {
field: VALIDATORS[field](body) for field in body
}
current: Row = dict(owned_book(book_id, for_update=True))
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")
_save(
"""
UPDATE books
SET title = %s, author = %s, genre_id = %s, total_pages = %s, current_page = %s, updated_at = now()
WHERE id = %s AND user_id = %s
RETURNING id
""",
(
book["title"],
@@ -183,8 +208,7 @@ def update_book(book_id: int):
@bp.delete("/books/<int(max=2147483647):book_id>")
@login_required
def delete_book(book_id: int):
def delete_book(book_id: int) -> ResponseReturnValue:
if (
query_one(
"DELETE FROM books WHERE id = %s AND user_id = %s RETURNING id",
+39 -10
View File
@@ -1,15 +1,24 @@
"""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 logging
import os
from collections.abc import Sequence
from pathlib import Path
from typing import Any
import psycopg2.extensions
import psycopg2.extras
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"
SCHEMA = Path(__file__).with_name("schema.sql")
Row = dict[str, Any]
Params = tuple[object, ...]
def init_app(app: Flask) -> None:
pool = psycopg2.pool.ThreadedConnectionPool(
@@ -26,24 +35,34 @@ def init_app(app: Flask) -> None:
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:
cur.execute(sql, params)
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)
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:
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:
conn = g.get("db")
if conn is not None:
if response.status_code < 400:
@@ -53,8 +72,18 @@ def _finish_transaction(response):
return response
def _release_connection(_exc):
def _release_connection(_exc: BaseException | None) -> None:
conn = g.pop("db", None)
if conn is not None:
if conn is None:
return
pool = current_app.extensions["db_pool"]
try:
conn.rollback()
current_app.extensions["db_pool"].putconn(conn)
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)
+26 -18
View File
@@ -1,9 +1,12 @@
"""Notes slice: a per-book reading journal. Ownership is always derived through books.user_id."""
from flask import Blueprint, g, jsonify
from datetime import datetime
from typing import TypedDict, cast
from auth import login_required
from db import query, query_one
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
bp = Blueprint("notes", __name__, url_prefix="/api")
@@ -12,13 +15,22 @@ MAX_BODY = 10_000
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 {
"id": row["id"],
"book_id": row["book_id"],
"body": row["body"],
"created_at": row["created_at"].isoformat(),
"updated_at": row["updated_at"].isoformat(),
"id": note["id"],
"book_id": note["book_id"],
"body": note["body"],
"created_at": note["created_at"].isoformat(),
"updated_at": note["updated_at"].isoformat(),
}
@@ -33,8 +45,7 @@ def _require_book(book_id: int) -> None:
@bp.get("/books/<int(max=2147483647):book_id>/notes")
@login_required
def list_notes(book_id: int):
def list_notes(book_id: int) -> Response:
_require_book(book_id)
rows = query(
f"SELECT {NOTE_COLUMNS} FROM notes n WHERE n.book_id = %s ORDER BY n.created_at DESC, n.id DESC",
@@ -44,11 +55,10 @@ def list_notes(book_id: int):
@bp.post("/books/<int(max=2147483647):book_id>/notes")
@login_required
def add_note(book_id: int):
def add_note(book_id: int) -> ResponseReturnValue:
_require_book(book_id)
body = text(json_body({"body"}), "body", MAX_BODY)
row = query_one(
row = query_row(
f"INSERT INTO notes AS n (book_id, body) VALUES (%s, %s) RETURNING {NOTE_COLUMNS}",
(book_id, body),
)
@@ -56,8 +66,7 @@ def add_note(book_id: int):
@bp.patch("/notes/<int(max=2147483647):note_id>")
@login_required
def update_note(note_id: int):
def update_note(note_id: int) -> Response:
body = text(json_body({"body"}), "body", MAX_BODY)
row = query_one(
f"""
@@ -75,8 +84,7 @@ def update_note(note_id: int):
@bp.delete("/notes/<int(max=2147483647):note_id>")
@login_required
def delete_note(note_id: int):
def delete_note(note_id: int) -> ResponseReturnValue:
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",
(note_id, g.user_id),
+83
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
Flask>=3.1.3
psycopg2-binary>=2.9.9
+189 -2
View File
@@ -1,2 +1,189 @@
Flask>=3.0.0
psycopg2-binary>=2.9.9
# This file was autogenerated by uv via the following command:
# 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
+2 -1
View File
@@ -3,9 +3,10 @@ import unittest
import psycopg2
from app import app
from app import create_app
HTTPS = "https://localhost"
app = create_app()
def db_execute(sql: str, params: tuple = ()) -> list[tuple]:
+31 -1
View File
@@ -1,6 +1,10 @@
import os
import subprocess
import sys
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"
@@ -43,3 +47,29 @@ class AppTests(ApiTestCase):
)
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)
+31 -3
View File
@@ -1,9 +1,10 @@
import hashlib
import os
import re
from app import app
from auth import hash_password
from tests.support import ApiTestCase, db_execute
from app import create_app
from passwords import hash_password
from tests.support import ApiTestCase, app, db_execute
class AuthTests(ApiTestCase):
@@ -156,3 +157,30 @@ class AuthTests(ApiTestCase):
"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)
+54 -1
View File
@@ -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):
@@ -147,3 +153,50 @@ class BookIsolationTests(ApiTestCase):
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", 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))
+9 -3
View File
@@ -5,7 +5,13 @@ import unittest
from pathlib import Path
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
KNOWN_ANSWER = "pbkdf2_sha256$1000$AAAAAAAAAAAAAAAAAAAAAA==$9ZLLEusnEPU3Km8h+vnd4ue9fw7rHdm4QwuBX5ozynE="
@@ -54,9 +60,9 @@ class PepperTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp, mock.patch.dict(os.environ):
os.environ.pop("PASSWORD_PEPPER", None)
pepper_file = Path(tmp) / ".pepper"
with self.assertLogs("auth", level="WARNING"):
with self.assertLogs("passwords", level="WARNING"):
first = load_pepper(pepper_file)
with self.assertLogs("auth", level="WARNING"):
with self.assertLogs("passwords", level="WARNING"):
second = load_pepper(pepper_file)
self.assertEqual(first, second)
self.assertEqual(len(first), 64)
+7 -3
View File
@@ -1,7 +1,11 @@
"""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
JsonObject = dict[str, Any]
class ApiError(Exception):
def __init__(self, status: int, message: str, field: str | None = None):
@@ -11,7 +15,7 @@ class ApiError(Exception):
self.field = field
def json_body(allowed: set[str]) -> dict:
def json_body(allowed: set[str]) -> JsonObject:
body = request.get_json(silent=True)
if not isinstance(body, dict):
raise ApiError(400, "Request body must be a JSON object")
@@ -36,7 +40,7 @@ def require_utf8(value: str, field: str) -> None:
) 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)
if not isinstance(value, str) or not value.strip():
raise ApiError(400, f"{field} is required and must be non-blank text", field)
@@ -49,7 +53,7 @@ def text(body: dict, field: str, max_len: int) -> str:
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)
if isinstance(value, bool) or not isinstance(value, int):
raise ApiError(400, f"{field} must be a whole number", field)