feat(backend): verse le contrat OpenAPI au dépôt et le garde honnête
`make openapi` écrit apps/backend/openapi.json, et un test compare le fichier versionné au schéma généré. Une route qui change son contrat public le montre donc dans la diff d'une pull request, et une PR qui oublie de régénérer échoue en CI : le fichier vit sous apps/backend, que le filtre de chemins de backend.yml couvre. Le schéma exporté ne lit ni le .env du poste ni les variables APP_ : tout ce qui l'atteint est posé par settings_du_contrat(), sans quoi le fichier changerait de machine en machine. main() réclamait un mot de passe avant de lire la commande. Le branchement passe devant, sinon l'export serait resté bloqué sur getpass.
This commit is contained in:
@@ -7,18 +7,25 @@
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import sys
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.hashing import build_hasher
|
||||
from app.core.roles import Role
|
||||
from app.db.session import get_session_factory
|
||||
from app.main import create_app
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
LONGUEUR_MOT_DE_PASSE_GENERE = 24
|
||||
LONGUEUR_MINIMALE = 12
|
||||
CHEMIN_CONTRAT = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||
|
||||
|
||||
async def create_admin(
|
||||
@@ -55,6 +62,35 @@ async def create_admin(
|
||||
)
|
||||
|
||||
|
||||
# Piège : le schéma ne doit dépendre ni du `.env` du poste ni des variables `APP_*`, sinon le
|
||||
# fichier versionné changerait de machine en machine et le test de dérive deviendrait un oracle
|
||||
# de configuration locale. Tout ce qui atteint le schéma est donc posé ici, `_env_file` compris.
|
||||
def settings_du_contrat() -> Settings:
|
||||
return Settings(
|
||||
_env_file=None,
|
||||
name="EnerVision API",
|
||||
version="0.1.0",
|
||||
env="local",
|
||||
api_prefix="/api/v1",
|
||||
secret_key=SecretStr("contrat-openapi-sans-effet-sur-le-schema"),
|
||||
database_url="postgresql+asyncpg://openapi:contrat@localhost:5432/enervision",
|
||||
)
|
||||
|
||||
|
||||
def schema_du_contrat() -> dict[str, Any]:
|
||||
schema: dict[str, Any] = create_app(settings_du_contrat()).openapi()
|
||||
return schema
|
||||
|
||||
|
||||
def rend_le_contrat() -> str:
|
||||
return json.dumps(schema_du_contrat(), indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def export_openapi(destination: Path) -> str:
|
||||
destination.write_text(rend_le_contrat(), encoding="utf-8")
|
||||
return f"Contrat OpenAPI écrit dans {destination}"
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="python -m app.cli", description="Outils EnerVision")
|
||||
sous_commandes = parser.add_subparsers(dest="commande", required=True)
|
||||
@@ -67,6 +103,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
admin.add_argument(
|
||||
"--force", action="store_true", help="Crée le compte même si un administrateur existe"
|
||||
)
|
||||
|
||||
contrat = sous_commandes.add_parser(
|
||||
"export-openapi", help="Écrit le contrat OpenAPI sur disque"
|
||||
)
|
||||
contrat.add_argument("--output", default=str(CHEMIN_CONTRAT))
|
||||
return parser
|
||||
|
||||
|
||||
@@ -86,6 +127,11 @@ def read_password(*, generate: bool) -> str:
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = build_parser().parse_args(argv)
|
||||
|
||||
if arguments.commande == "export-openapi":
|
||||
print(export_openapi(Path(arguments.output)))
|
||||
return 0
|
||||
|
||||
mot_de_passe = read_password(generate=arguments.generate)
|
||||
|
||||
succes, message = asyncio.run(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
# Pourquoi : `openapi.json` est versionné, donc une route qui change son contrat public le montre
|
||||
# dans la diff d'une pull request. `test_the_committed_contract_matches_the_generated_one` est ce
|
||||
# qui empêche le fichier de dériver du code sans que personne ne le voie.
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app import cli
|
||||
|
||||
METHODES = {"get", "post", "patch", "put", "delete"}
|
||||
|
||||
# `/auth/logout` lit le cookie mais ne le réclame pas : sans session elle répond 204, et un 401
|
||||
# documenté y serait faux.
|
||||
SANS_REFUS = {("POST", "/api/v1/auth/logout")}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema() -> dict[str, Any]:
|
||||
return cli.schema_du_contrat()
|
||||
|
||||
|
||||
def operations(schema: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]:
|
||||
return [
|
||||
(methode.upper(), chemin, operation)
|
||||
for chemin, operations_du_chemin in schema["paths"].items()
|
||||
for methode, operation in operations_du_chemin.items()
|
||||
if methode in METHODES
|
||||
]
|
||||
|
||||
|
||||
def test_the_committed_contract_matches_the_generated_one(schema: dict[str, Any]) -> None:
|
||||
publie = json.loads(cli.CHEMIN_CONTRAT.read_text(encoding="utf-8"))
|
||||
|
||||
assert publie == schema, "lancer `make openapi` et versionner le fichier obtenu"
|
||||
|
||||
|
||||
def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str, Any]) -> None:
|
||||
muettes = [
|
||||
(methode, chemin)
|
||||
for methode, chemin, operation in operations(schema)
|
||||
if operation.get("security")
|
||||
and (methode, chemin) not in SANS_REFUS
|
||||
and "401" not in operation["responses"]
|
||||
]
|
||||
|
||||
assert muettes == []
|
||||
|
||||
|
||||
def test_every_administration_route_documents_the_role_refusal(schema: dict[str, Any]) -> None:
|
||||
sans_403 = [
|
||||
(methode, chemin)
|
||||
for methode, chemin, operation in operations(schema)
|
||||
if "users" in operation.get("tags", []) and "403" not in operation["responses"]
|
||||
]
|
||||
|
||||
assert sans_403 == []
|
||||
|
||||
|
||||
def test_the_validation_model_matches_what_the_handler_returns(schema: dict[str, Any]) -> None:
|
||||
modeles = {
|
||||
operation["responses"]["422"]["content"]["application/json"]["schema"]["$ref"]
|
||||
for _, _, operation in operations(schema)
|
||||
if "422" in operation["responses"]
|
||||
}
|
||||
|
||||
assert modeles == {"#/components/schemas/ValidationErrorResponse"}
|
||||
assert "HTTPValidationError" not in schema["components"]["schemas"]
|
||||
|
||||
|
||||
def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> None:
|
||||
trop_de_tentatives = schema["paths"]["/api/v1/auth/login"]["post"]["responses"]["429"]
|
||||
|
||||
assert "Retry-After" in trop_de_tentatives["headers"]
|
||||
|
||||
|
||||
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
|
||||
schemes = schema["components"]["securitySchemes"]
|
||||
|
||||
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
|
||||
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
|
||||
|
||||
|
||||
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
|
||||
decrits = {tag["name"] for tag in schema["tags"]}
|
||||
|
||||
for methode, chemin, operation in operations(schema):
|
||||
poses = operation.get("tags", [])
|
||||
assert len(poses) == len(set(poses)), f"tag en double sur {methode} {chemin}"
|
||||
assert set(poses) <= decrits, f"tag non décrit sur {methode} {chemin}"
|
||||
@@ -1,3 +1,6 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import cli
|
||||
@@ -55,3 +58,52 @@ def test_read_password_refuses_two_different_entries(monkeypatch: pytest.MonkeyP
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
cli.read_password(generate=False)
|
||||
|
||||
|
||||
def test_build_parser_reads_the_export_openapi_arguments() -> None:
|
||||
arguments = cli.build_parser().parse_args(
|
||||
["export-openapi", "--output", "ailleurs/contrat.json"]
|
||||
)
|
||||
|
||||
assert arguments.commande == "export-openapi"
|
||||
assert arguments.output == "ailleurs/contrat.json"
|
||||
|
||||
|
||||
def test_build_parser_defaults_the_export_to_the_versioned_contract() -> None:
|
||||
arguments = cli.build_parser().parse_args(["export-openapi"])
|
||||
|
||||
assert arguments.output == str(cli.CHEMIN_CONTRAT)
|
||||
|
||||
|
||||
def test_settings_of_the_contract_ignore_the_local_environment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("APP_API_PREFIX", "/api/v9")
|
||||
monkeypatch.setenv("APP_NAME", "API du poste de Johan")
|
||||
|
||||
settings = cli.settings_du_contrat()
|
||||
|
||||
assert settings.api_prefix == "/api/v1"
|
||||
assert settings.name == "EnerVision API"
|
||||
|
||||
|
||||
def test_export_openapi_writes_a_readable_schema_where_asked(tmp_path: Path) -> None:
|
||||
destination = tmp_path / "contrat.json"
|
||||
|
||||
cli.export_openapi(destination)
|
||||
|
||||
assert json.loads(destination.read_text(encoding="utf-8"))["openapi"].startswith("3.")
|
||||
|
||||
|
||||
# Piège : `main()` réclamait un mot de passe avant de lire la commande. Sans le branchement,
|
||||
# l'export resterait bloqué sur `getpass` et aucune CI ne pourrait le rejouer.
|
||||
def test_main_exports_the_contract_without_asking_for_a_password(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
destination = tmp_path / "contrat.json"
|
||||
|
||||
code = cli.main(["export-openapi", "--output", str(destination)])
|
||||
|
||||
assert code == 0
|
||||
assert destination.exists()
|
||||
assert str(destination) in capsys.readouterr().out
|
||||
|
||||
Reference in New Issue
Block a user