Files
Johan LEROY a8f59e6e76 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.
2026-09-15 14:30:05 +02:00

75 lines
2.4 KiB
Python

import os
from collections.abc import AsyncIterator, Callable, Iterator
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.db.session import get_engine, get_session, get_session_factory
from app.main import create_app
from tests.factories import FakeSession
# Piège : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne
# pose pas ici, c'est le .env du poste qui les décide, et les assertions avec.
@pytest.fixture(autouse=True, scope="session")
def environment() -> Iterator[None]:
os.environ.update(
{
"APP_ENV": "local",
"APP_DEBUG": "false",
"APP_LOG_LEVEL": "WARNING",
"APP_CORS_ORIGINS": "",
"APP_SECRET_KEY": "secret-de-test-assez-long-pour-le-validateur",
}
)
os.environ.setdefault(
"DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test"
)
get_settings.cache_clear()
yield
get_settings.cache_clear()
# Piège : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce
# recyclage, le 2e test touchant vraiment la base hériterait d'une boucle morte.
@pytest.fixture(autouse=True)
async def engine_per_test() -> AsyncIterator[None]:
yield
if get_engine.cache_info().currsize:
await get_engine().dispose()
get_engine.cache_clear()
get_session_factory.cache_clear()
@pytest.fixture
def app() -> FastAPI:
return create_app()
@pytest.fixture
async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as async_client:
yield async_client
@pytest.fixture
def fake_session(app: FastAPI) -> Callable[..., None]:
def install(result: object = None, failure: Exception | None = None) -> None:
async def override() -> AsyncIterator[FakeSession]:
yield FakeSession(result=result, failure=failure)
app.dependency_overrides[get_session] = override
return install
# Contrainte : ouvre une vraie connexion, donc réservée aux tests `integration`.
@pytest.fixture
async def session() -> AsyncIterator[AsyncSession]:
async with get_session_factory()() as async_session:
yield async_session