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:
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user