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
+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)