feat(backend): ajoute les comptes applicatifs et l'amorçage du premier admin

Table `app_user`, son dépôt, et la commande `create-admin`. Le nom évite
`user`, mot réservé de PostgreSQL, et rappelle qu'il s'agit d'un compte
applicatif, par opposition au rôle PostgreSQL qui portera le
cantonnement des accès ETL et ML.

`credentials_changed_at` couvre à elle seule le changement de mot de
passe, le changement de rôle et la désactivation : tout jeton émis avant
cet instant sera refusé, sans attendre son expiration.

La configuration refuse désormais de démarrer sur cinq erreurs
silencieuses : secret trop court ou laissé à sa valeur d'exemple, `debug`
en production, joker CORS, origines vides hors local, et cookie
`SameSite=None` sans `Secure`. Les fixtures de test et les deux
`.env.example` suivent, sans quoi rien ne démarrerait.

Le mot de passe de l'admin ne transite jamais par `argv`, visible de tout
`ps` : il est saisi par `getpass` ou tiré au sort. Une révision Alembic
qui insérerait ce compte graverait son empreinte dans Git pour toujours.
This commit is contained in:
Johan LEROY
2026-09-15 14:30:05 +02:00
parent 53af7a76d8
commit a8f59e6e76
14 changed files with 615 additions and 21 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ def environment() -> Iterator[None]:
"APP_DEBUG": "false",
"APP_LOG_LEVEL": "WARNING",
"APP_CORS_ORIGINS": "",
"APP_SECRET_KEY": "secret-de-test",
"APP_SECRET_KEY": "secret-de-test-assez-long-pour-le-validateur",
}
)
os.environ.setdefault(
+78
View File
@@ -0,0 +1,78 @@
import pytest
from pydantic import ValidationError
from tests.factories import make_settings
SECRET_VALIDE = "un-secret-de-test-de-plus-de-trente-deux-caracteres"
@pytest.mark.parametrize(
"surcharges",
[
{"secret_key": "trop-court"},
{"secret_key": "change_me"},
{"env": "prod", "debug": True, "cors_origins": "https://enervision.fr"},
{"cors_origins": "*"},
{"env": "prod", "cors_origins": ""},
{"cookie_samesite": "none", "cookie_secure": False},
],
ids=[
"secret_trop_court",
"secret_sentinelle",
"debug_en_production",
"joker_dans_les_origines",
"origines_vides_hors_local",
"samesite_none_sans_secure",
],
)
def test_settings_refuses_to_build_when_the_configuration_is_unsafe(
surcharges: dict[str, object],
) -> None:
with pytest.raises(ValidationError):
make_settings(**surcharges)
def test_settings_accepts_debug_in_local_environment() -> None:
settings = make_settings(env="local", debug=True)
assert settings.debug is True
@pytest.mark.parametrize(
("env", "attendu"),
[("local", False), ("dev", True), ("staging", True), ("prod", True)],
ids=["local", "dev", "staging", "production"],
)
def test_cookies_are_secure_follows_the_environment(env: str, attendu: bool) -> None:
settings = make_settings(env=env, cors_origins="https://enervision.fr")
assert settings.cookies_are_secure is attendu
def test_cookies_are_secure_honours_an_explicit_override() -> None:
settings = make_settings(env="prod", cors_origins="https://enervision.fr", cookie_secure=False)
assert settings.cookies_are_secure is False
@pytest.mark.parametrize(
("env", "attendu"),
[("local", True), ("dev", True), ("staging", False), ("prod", False)],
ids=["local", "dev", "staging", "production"],
)
def test_api_docs_are_exposed_closes_staging_and_production(env: str, attendu: bool) -> None:
settings = make_settings(env=env, cors_origins="https://enervision.fr")
assert settings.api_docs_are_exposed is attendu
def test_api_docs_are_exposed_honours_an_explicit_override() -> None:
settings = make_settings(env="prod", cors_origins="https://enervision.fr", expose_api_docs=True)
assert settings.api_docs_are_exposed is True
def test_allowed_origins_splits_and_trims_the_list() -> None:
settings = make_settings(cors_origins=" http://localhost:4200 , https://enervision.fr ")
assert settings.allowed_origins == ["http://localhost:4200", "https://enervision.fr"]
+58
View File
@@ -0,0 +1,58 @@
from app.core.cookies import RefreshCookie, cookie_name
from tests.factories import make_settings
def test_build_marks_the_cookie_http_only_and_scopes_it_to_the_auth_routes() -> None:
settings = make_settings(env="local")
cookie = RefreshCookie.build(settings, "un-secret-opaque")
assert cookie.httponly is True
assert cookie.samesite == "strict"
assert cookie.path == "/api/v1/auth"
assert cookie.max_age == settings.refresh_token_ttl_seconds
def test_build_prefixes_and_secures_the_cookie_outside_local() -> None:
settings = make_settings(env="prod", cors_origins="https://enervision.fr")
cookie = RefreshCookie.build(settings, "un-secret-opaque")
assert cookie.secure is True
assert cookie.key.startswith("__Secure-")
def test_build_leaves_the_cookie_unprefixed_in_local() -> None:
settings = make_settings(env="local")
cookie = RefreshCookie.build(settings, "un-secret-opaque")
assert cookie.key == "ev_refresh"
def test_expired_reuses_the_exact_name_and_path_of_the_posted_cookie() -> None:
settings = make_settings(env="prod", cors_origins="https://enervision.fr")
pose = RefreshCookie.build(settings, "un-secret-opaque")
suppression = RefreshCookie.expired(settings)
assert suppression.key == pose.key
assert suppression.path == pose.path
assert suppression.secure == pose.secure
assert suppression.samesite == pose.samesite
assert suppression.max_age == 0
assert suppression.value == ""
def test_as_kwargs_matches_the_starlette_set_cookie_signature() -> None:
settings = make_settings(env="local")
arguments = RefreshCookie.build(settings, "un-secret-opaque").as_kwargs()
assert set(arguments) == {"key", "value", "max_age", "path", "secure", "httponly", "samesite"}
def test_cookie_name_follows_the_configured_name() -> None:
settings = make_settings(env="local", refresh_cookie_name="autre_nom")
assert cookie_name(settings) == "autre_nom"
+1 -1
View File
@@ -7,7 +7,7 @@ SETTINGS_DE_TEST: dict[str, Any] = {
"debug": False,
"log_level": "WARNING",
"cors_origins": "",
"secret_key": "secret-de-test",
"secret_key": "secret-de-test-assez-long-pour-le-validateur",
"database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test",
}