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