Files
davideandClaude Opus 5 25f4a1c6b6 Refuse to start half-configured, and keep failures machine-readable
Startup validation (B-15). An empty JWT_SECRET makes PyJWT raise InvalidKeyError
on every login and an empty XPRV_ENCRYPTION_KEY makes Fernet fail on the first
key derivation — either way the container came up looking healthy and broke the
moment a real user touched it. validate_runtime_secrets() reports every problem
at once and is called from the lifespan (wired in the next commit).

Deviation from the plan in BUGS.md, which proposed a Pydantic field_validator:
Settings is constructed at import time by every module that reads config,
including the test suite, which has no .env and no business holding real secrets
— a validator there would fail a fresh clone at collection. At startup the
guarantee that matters is unchanged (the server won't serve traffic
half-configured) without coupling imports to a gitignored file. An empty
ADMIN_TOKEN is deliberately non-fatal: require_admin already denies everything,
so the effect is a locked panel, not an open one.

Unhandled errors answer the documented shape (B-24). The catch-all returned a
bare-string `detail` while app/api/errors.py documents
{"code", "message", "params"}, leaving clients to special-case exactly the
responses they understand least. It now returns internal_error in that shape,
with the exception text staying in logs/app.log and out of the response body.

GET /guida no longer crashes (B-16). It reads docs/guida-utente.md, which the
Dockerfile doesn't ship, so in every real deployment that navbar link was a 500 —
confirmed in the deployed log, which holds two of them from earlier today ending
in "RuntimeError: File at path docs/guida-utente.md does not exist." It now
checks the file and answers a structured 404 (guide_unavailable) with an error
logged. Shipping docs/ in the image was written and then reverted on request: the
guide is being reworked first, so /guida answers 404 in Docker for now, which is
an accepted state rather than an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 00:34:33 +02:00

52 lines
1.9 KiB
Python

"""B-15: secrets that would only break at first use must stop the app at startup."""
import pytest
from app.config import MIN_JWT_SECRET_LENGTH, ConfigError, Settings, validate_runtime_secrets
def _settings(**overrides) -> Settings:
base = {
"jwt_secret": "x" * MIN_JWT_SECRET_LENGTH,
"xprv_encryption_key": "a-fernet-key",
}
base.update(overrides)
# _env_file=None so a developer's real .env can't make this test pass or fail.
return Settings(_env_file=None, **base)
def test_valid_secrets_pass():
validate_runtime_secrets(_settings())
def test_empty_jwt_secret_is_refused():
"""An empty JWT_SECRET makes PyJWT raise InvalidKeyError on every single login —
a 500 with no hint about the real cause, on a container that started up healthy."""
with pytest.raises(ConfigError, match="JWT_SECRET"):
validate_runtime_secrets(_settings(jwt_secret=""))
def test_short_jwt_secret_is_refused():
with pytest.raises(ConfigError, match="JWT_SECRET"):
validate_runtime_secrets(_settings(jwt_secret="x" * (MIN_JWT_SECRET_LENGTH - 1)))
def test_empty_xprv_encryption_key_is_refused():
"""Without it Fernet fails on the first key derivation — i.e. the first time
anyone registers or a transaction needs signing."""
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY"):
validate_runtime_secrets(_settings(xprv_encryption_key=" "))
def test_all_problems_are_reported_at_once():
with pytest.raises(ConfigError) as exc_info:
validate_runtime_secrets(_settings(jwt_secret="", xprv_encryption_key=""))
message = str(exc_info.value)
assert "JWT_SECRET" in message and "XPRV_ENCRYPTION_KEY" in message
def test_an_empty_admin_token_is_not_fatal():
"""require_admin already denies every request when it's unset, so the effect is a
locked admin panel rather than an open one — no reason to refuse to boot."""
validate_runtime_secrets(_settings(admin_token=""))