"""Security hardening: refuse the default signing key in prod; throttle /chat.""" from __future__ import annotations from types import SimpleNamespace import pytest from app.main import _check_security from app.routes import chat def _settings(**over): base = dict(testing=False, admin_token="tok", secret_key="a-real-strong-key") base.update(over) return SimpleNamespace(**base) def test_default_secret_key_refuses_to_boot_in_prod(): with pytest.raises(RuntimeError, match="SECRET_KEY"): _check_security(_settings(secret_key="dev-insecure-change-me")) def test_empty_admin_token_refuses_to_boot_in_prod(): """#27: was a silent warning — a misconfigured deploy would break the admin API and the outreach automation (authenticates as admin) with no loud signal.""" with pytest.raises(RuntimeError, match="ADMIN_TOKEN"): _check_security(_settings(admin_token="")) def test_empty_admin_token_skipped_under_testing(): _check_security(_settings(testing=True, admin_token="")) # no raise # ---- 404-on-state-changing-request logging (CRITICAL incident follow-up) ---- async def test_state_changing_404_is_logged_loudly(app_client, caplog): """A state-changing (POST/PUT/PATCH/DELETE) request that 404s never reaches our own route handlers -- Starlette rejects it at the routing layer, so it was invisible to ordinary logging. This is exactly how a real client/server URL mismatch silently dropped every contact-form submission from a standalone site (found live: a real lead AND the founder's own test both vanished, with only a generic access-log line as any trace). Must be logged LOUDLY going forward, and the response itself must stay UNCHANGED.""" import logging _app, client = app_client with caplog.at_level(logging.WARNING, logger="app.main"): r = await client.post("/this/route/does/not/exist") assert r.status_code == 404 assert r.json() == {"detail": "Not Found"} # default FastAPI body, unchanged assert any("404 on POST" in rec.message for rec in caplog.records) async def test_get_404_is_not_logged_as_warning(app_client, caplog): """GET 404s are dominated by benign bot/scanner noise -- logging every one at WARNING would drown the real signal, so only state-changing methods are logged.""" import logging _app, client = app_client with caplog.at_level(logging.WARNING, logger="app.main"): r = await client.get("/this/route/does/not/exist") assert r.status_code == 404 assert not any("404 on" in rec.message for rec in caplog.records) def test_strong_secret_key_boots(): _check_security(_settings()) # no raise def test_testing_mode_skips_the_guard(): # tests run with the default key; testing=True must never crash them _check_security(_settings(testing=True, secret_key="dev-insecure-change-me")) # ---- /chat rate limit ---- def _req(ip="1.2.3.4"): return SimpleNamespace(headers={"x-forwarded-for": ip}, client=SimpleNamespace(host=ip)) def test_chat_rate_limit_blocks_after_burst(monkeypatch): # fresh limiter so other tests don't pollute the window from app.ratelimit import RateLimiter monkeypatch.setattr(chat, "_chat_limiter", RateLimiter(chat._CHAT_PER_IP_PER_MIN, 60)) req = _req("9.9.9.9") for _ in range(chat._CHAT_PER_IP_PER_MIN): chat._enforce_chat_rate(req) # allowed with pytest.raises(Exception) as ei: chat._enforce_chat_rate(req) # one over -> 429 assert getattr(ei.value, "status_code", None) == 429 def test_chat_rate_limit_is_per_ip(monkeypatch): from app.ratelimit import RateLimiter monkeypatch.setattr(chat, "_chat_limiter", RateLimiter(2, 60)) chat._enforce_chat_rate(_req("1.1.1.1")) chat._enforce_chat_rate(_req("1.1.1.1")) # a different IP still has its own fresh budget chat._enforce_chat_rate(_req("2.2.2.2"))