Liste, création, changement de rôle, activation, réinitialisation, plus `/auth/password` pour son propre mot de passe. Les schémas de lecture et d'écriture sont séparés : un modèle unique laisserait passer `role` ou `is_active` depuis un corps de requête et renverrait `password_hash` en réponse, soit l'attribution de masse, API3 du top 10 API. Un test envoie ces deux champs et vérifie qu'ils sont ignorés. Le service refuse de rétrograder ou de désactiver le dernier administrateur actif. Sans cette garde, un administrateur peut se verrouiller lui-même dehors et il ne reste que `psql` pour rentrer. Tout changement de rôle ou désactivation révoque les sessions de la cible, et `credentials_changed_at` rend le jeton d'accès encore valide inutilisable dès la requête suivante. La promesse de révocation immédiate ne tient que si les deux sont faits. Le changement de son propre mot de passe révoque toutes les familles puis en rouvre une : l'appareil courant reste connecté, tous les autres sont déconnectés. Il faut le coder explicitement pour l'obtenir. Les mots de passe provisoires sont tirés au sort et affichés une seule fois, sous `Cache-Control: no-store`.
209 lines
6.3 KiB
Python
209 lines
6.3 KiB
Python
from collections.abc import Callable, Iterator
|
|
from datetime import UTC, datetime
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import AsyncClient
|
|
|
|
from app.api.deps import get_current_principal, get_user_service
|
|
from app.core.principal import Principal
|
|
from app.core.roles import AccountKind, Role
|
|
from app.services.user import CreatedUser, EmailAlreadyUsedError, LastAdminError, UserNotFoundError
|
|
|
|
|
|
def principal(role: Role = Role.ADMIN) -> Principal:
|
|
return Principal(
|
|
id=uuid4(),
|
|
email=f"{role.value}@enervision.fr",
|
|
role=role,
|
|
kind=AccountKind.HUMAIN,
|
|
must_change_password=False,
|
|
)
|
|
|
|
|
|
class FauxCompte:
|
|
def __init__(self, role: Role = Role.LECTEUR) -> None:
|
|
self.id = uuid4()
|
|
self.email = "cible@enervision.fr"
|
|
self.role = role.value
|
|
self.kind = "human"
|
|
self.is_active = True
|
|
self.must_change_password = True
|
|
self.full_name = None
|
|
self.last_login_at: datetime | None = None
|
|
self.created_at = datetime.now(UTC)
|
|
|
|
|
|
class FauxService:
|
|
def __init__(self, erreur: Exception | None = None) -> None:
|
|
self._erreur = erreur
|
|
self.compte = FauxCompte()
|
|
|
|
def _leve(self) -> None:
|
|
if self._erreur is not None:
|
|
raise self._erreur
|
|
|
|
async def list_all(self) -> list[FauxCompte]:
|
|
return [self.compte]
|
|
|
|
async def create(self, **_: object) -> CreatedUser:
|
|
self._leve()
|
|
return CreatedUser(user=self.compte, temporary_password="mot-de-passe-provisoire") # type: ignore[arg-type]
|
|
|
|
async def change_role(self, **_: object) -> FauxCompte:
|
|
self._leve()
|
|
return self.compte
|
|
|
|
async def set_active(self, **_: object) -> FauxCompte:
|
|
self._leve()
|
|
return self.compte
|
|
|
|
async def reset_password(self, **_: object) -> CreatedUser:
|
|
self._leve()
|
|
return CreatedUser(user=self.compte, temporary_password="mot-de-passe-provisoire") # type: ignore[arg-type]
|
|
|
|
|
|
@pytest.fixture
|
|
def administre(app: FastAPI) -> Iterator[Callable[[Exception | None], FauxService]]:
|
|
services: list[FauxService] = []
|
|
|
|
def installe(erreur: Exception | None = None) -> FauxService:
|
|
service = FauxService(erreur)
|
|
services.append(service)
|
|
app.dependency_overrides[get_user_service] = lambda: service
|
|
app.dependency_overrides[get_current_principal] = lambda: principal()
|
|
return service
|
|
|
|
yield installe
|
|
app.dependency_overrides.pop(get_user_service, None)
|
|
app.dependency_overrides.pop(get_current_principal, None)
|
|
|
|
|
|
@pytest.fixture
|
|
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
|
app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR)
|
|
yield
|
|
app.dependency_overrides.pop(get_current_principal, None)
|
|
|
|
|
|
async def test_list_users_returns_the_accounts_without_their_digest(
|
|
administre: Callable[..., FauxService], client: AsyncClient
|
|
) -> None:
|
|
administre()
|
|
|
|
response = await client.get("/api/v1/users")
|
|
|
|
assert response.status_code == 200
|
|
corps = response.json()
|
|
assert "password_hash" not in corps[0]
|
|
assert corps[0]["email"] == "cible@enervision.fr"
|
|
|
|
|
|
async def test_create_user_returns_the_temporary_password_once(
|
|
administre: Callable[..., FauxService], client: AsyncClient
|
|
) -> None:
|
|
administre()
|
|
|
|
response = await client.post(
|
|
"/api/v1/users", json={"email": "nouveau@enervision.fr", "role": "operateur"}
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
assert response.json()["temporary_password"] == "mot-de-passe-provisoire"
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
|
|
async def test_create_user_refuses_an_address_already_taken(
|
|
administre: Callable[..., FauxService], client: AsyncClient
|
|
) -> None:
|
|
administre(EmailAlreadyUsedError("cible@enervision.fr"))
|
|
|
|
response = await client.post(
|
|
"/api/v1/users", json={"email": "cible@enervision.fr", "role": "lecteur"}
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
|
|
|
|
async def test_create_user_never_accepts_a_caller_chosen_digest(
|
|
administre: Callable[..., FauxService], client: AsyncClient
|
|
) -> None:
|
|
administre()
|
|
|
|
response = await client.post(
|
|
"/api/v1/users",
|
|
json={
|
|
"email": "nouveau@enervision.fr",
|
|
"role": "lecteur",
|
|
"password_hash": "$argon2id$force",
|
|
"is_active": False,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
|
|
|
|
async def test_update_user_refuses_to_strand_the_last_administrator(
|
|
administre: Callable[..., FauxService], client: AsyncClient
|
|
) -> None:
|
|
administre(LastAdminError("x"))
|
|
|
|
response = await client.patch(f"/api/v1/users/{uuid4()}", json={"is_active": False})
|
|
|
|
assert response.status_code == 409
|
|
|
|
|
|
async def test_update_user_returns_404_for_an_unknown_account(
|
|
administre: Callable[..., FauxService], client: AsyncClient
|
|
) -> None:
|
|
administre(UserNotFoundError("x"))
|
|
|
|
response = await client.patch(f"/api/v1/users/{uuid4()}", json={"role": "admin"})
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
async def test_update_user_refuses_an_empty_body(
|
|
administre: Callable[..., FauxService], client: AsyncClient
|
|
) -> None:
|
|
administre()
|
|
|
|
response = await client.patch(f"/api/v1/users/{uuid4()}", json={})
|
|
|
|
assert response.status_code == 400
|
|
|
|
|
|
async def test_reset_password_returns_a_new_temporary_password(
|
|
administre: Callable[..., FauxService], client: AsyncClient
|
|
) -> None:
|
|
administre()
|
|
|
|
response = await client.post(f"/api/v1/users/{uuid4()}/password-reset")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["temporary_password"] == "mot-de-passe-provisoire"
|
|
assert response.headers["cache-control"] == "no-store"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("methode", "chemin"),
|
|
[
|
|
("GET", "/api/v1/users"),
|
|
("POST", "/api/v1/users"),
|
|
("PATCH", "/api/v1/users/{identifiant}"),
|
|
("POST", "/api/v1/users/{identifiant}/password-reset"),
|
|
],
|
|
ids=["liste", "creation", "modification", "reinitialisation"],
|
|
)
|
|
async def test_every_administration_route_refuses_a_reader(
|
|
lecteur_connecte: None, client: AsyncClient, methode: str, chemin: str
|
|
) -> None:
|
|
identifiant: UUID = uuid4()
|
|
|
|
response = await client.request(
|
|
methode, chemin.format(identifiant=identifiant), json={"role": "admin"}
|
|
)
|
|
|
|
assert response.status_code == 403
|