feat(backend): ouvre l'administration des comptes et le changement de mot de passe
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`.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.models.refresh_token import RevocationReason
|
||||
from app.services.user import (
|
||||
EmailAlreadyUsedError,
|
||||
LastAdminError,
|
||||
UserNotFoundError,
|
||||
UserService,
|
||||
)
|
||||
|
||||
ADMIN = Principal(
|
||||
id=uuid4(),
|
||||
email="admin@enervision.fr",
|
||||
role=Role.ADMIN,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=False,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxCompte:
|
||||
id: UUID = field(default_factory=uuid4)
|
||||
email: str = "lecteur@enervision.fr"
|
||||
password_hash: str = "$argon2id$factice"
|
||||
role: str = "lecteur"
|
||||
kind: str = "human"
|
||||
is_active: bool = True
|
||||
must_change_password: bool = False
|
||||
full_name: str | None = None
|
||||
last_login_at: datetime | None = None
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class FauxDepotComptes:
|
||||
def __init__(
|
||||
self, compte: FauxCompte | None = None, *, admins_actifs: int = 2, existe: bool = False
|
||||
) -> None:
|
||||
self.compte = compte
|
||||
self.admins_actifs = admins_actifs
|
||||
self.existe = existe
|
||||
self.crees: list[str] = []
|
||||
self.roles_poses: list[tuple[UUID, str]] = []
|
||||
self.activations: list[tuple[UUID, bool]] = []
|
||||
self.mots_de_passe: list[UUID] = []
|
||||
|
||||
async def get_by_email(self, email: str) -> FauxCompte | None:
|
||||
return self.compte if self.existe else None
|
||||
|
||||
async def get_by_id(self, user_id: UUID) -> FauxCompte | None:
|
||||
return self.compte
|
||||
|
||||
async def count_active_admins(self) -> int:
|
||||
return self.admins_actifs
|
||||
|
||||
async def create(self, *, email: str, **_: object) -> FauxCompte:
|
||||
self.crees.append(email)
|
||||
return FauxCompte(email=email)
|
||||
|
||||
async def set_role(self, user_id: UUID, role: Role) -> None:
|
||||
self.roles_poses.append((user_id, role.value))
|
||||
|
||||
async def set_active(self, user_id: UUID, *, is_active: bool) -> None:
|
||||
self.activations.append((user_id, is_active))
|
||||
|
||||
async def update_password(self, user_id: UUID, password_hash: str, **_: object) -> None:
|
||||
self.mots_de_passe.append(user_id)
|
||||
|
||||
|
||||
class FauxDepotJetons:
|
||||
def __init__(self) -> None:
|
||||
self.revocations: list[tuple[UUID, str]] = []
|
||||
|
||||
async def revoke_all_for_user(self, user_id: UUID, reason: RevocationReason) -> int:
|
||||
self.revocations.append((user_id, reason.value))
|
||||
return 2
|
||||
|
||||
|
||||
class FauxDepotAudit:
|
||||
def __init__(self) -> None:
|
||||
self.lignes: list[tuple[str, Any]] = []
|
||||
|
||||
async def record(self, *, action: object, detail: Any = None, **_: object) -> None:
|
||||
self.lignes.append((str(action), detail))
|
||||
|
||||
|
||||
class FauxHacheur:
|
||||
async def hash(self, password: str) -> str:
|
||||
return "$argon2id$nouvelle"
|
||||
|
||||
|
||||
class FausseTransaction:
|
||||
async def commit(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attirail:
|
||||
service: UserService
|
||||
comptes: FauxDepotComptes
|
||||
jetons: FauxDepotJetons
|
||||
audit: FauxDepotAudit
|
||||
|
||||
|
||||
def fabrique(
|
||||
compte: FauxCompte | None = None, *, admins_actifs: int = 2, existe: bool = False
|
||||
) -> Attirail:
|
||||
comptes = FauxDepotComptes(compte, admins_actifs=admins_actifs, existe=existe)
|
||||
jetons = FauxDepotJetons()
|
||||
audit = FauxDepotAudit()
|
||||
service = UserService(
|
||||
users=comptes, # type: ignore[arg-type]
|
||||
refresh_tokens=jetons, # type: ignore[arg-type]
|
||||
audit=audit, # type: ignore[arg-type]
|
||||
hasher=FauxHacheur(), # type: ignore[arg-type]
|
||||
transaction=FausseTransaction(),
|
||||
)
|
||||
return Attirail(service, comptes, jetons, audit)
|
||||
|
||||
|
||||
async def test_create_returns_a_temporary_password_shown_once() -> None:
|
||||
attirail = fabrique()
|
||||
|
||||
cree = await attirail.service.create(
|
||||
actor=ADMIN, email="nouveau@enervision.fr", role=Role.LECTEUR, full_name=None
|
||||
)
|
||||
|
||||
assert len(cree.temporary_password) >= 18
|
||||
assert attirail.comptes.crees == ["nouveau@enervision.fr"]
|
||||
assert "user.created" in attirail.audit.lignes[0][0]
|
||||
|
||||
|
||||
async def test_create_refuses_an_address_already_taken() -> None:
|
||||
attirail = fabrique(FauxCompte(), existe=True)
|
||||
|
||||
with pytest.raises(EmailAlreadyUsedError):
|
||||
await attirail.service.create(
|
||||
actor=ADMIN, email="lecteur@enervision.fr", role=Role.LECTEUR, full_name=None
|
||||
)
|
||||
|
||||
|
||||
async def test_change_role_revokes_every_session_of_the_target() -> None:
|
||||
cible = FauxCompte()
|
||||
attirail = fabrique(cible)
|
||||
|
||||
await attirail.service.change_role(actor=ADMIN, user_id=cible.id, role=Role.OPERATEUR)
|
||||
|
||||
assert attirail.comptes.roles_poses == [(cible.id, "operateur")]
|
||||
assert attirail.jetons.revocations == [(cible.id, RevocationReason.ADMINISTRATION.value)]
|
||||
|
||||
|
||||
async def test_change_role_does_nothing_when_the_role_is_already_the_right_one() -> None:
|
||||
cible = FauxCompte(role="operateur")
|
||||
attirail = fabrique(cible)
|
||||
|
||||
await attirail.service.change_role(actor=ADMIN, user_id=cible.id, role=Role.OPERATEUR)
|
||||
|
||||
assert attirail.comptes.roles_poses == []
|
||||
assert attirail.jetons.revocations == []
|
||||
|
||||
|
||||
async def test_change_role_refuses_to_demote_the_last_active_administrator() -> None:
|
||||
dernier = FauxCompte(role="admin")
|
||||
attirail = fabrique(dernier, admins_actifs=1)
|
||||
|
||||
with pytest.raises(LastAdminError):
|
||||
await attirail.service.change_role(actor=ADMIN, user_id=dernier.id, role=Role.LECTEUR)
|
||||
|
||||
|
||||
async def test_change_role_accepts_a_demotion_when_another_administrator_remains() -> None:
|
||||
admin = FauxCompte(role="admin")
|
||||
attirail = fabrique(admin, admins_actifs=2)
|
||||
|
||||
await attirail.service.change_role(actor=ADMIN, user_id=admin.id, role=Role.LECTEUR)
|
||||
|
||||
assert attirail.comptes.roles_poses == [(admin.id, "lecteur")]
|
||||
|
||||
|
||||
async def test_set_active_refuses_to_disable_the_last_active_administrator() -> None:
|
||||
dernier = FauxCompte(role="admin")
|
||||
attirail = fabrique(dernier, admins_actifs=1)
|
||||
|
||||
with pytest.raises(LastAdminError):
|
||||
await attirail.service.set_active(actor=ADMIN, user_id=dernier.id, is_active=False)
|
||||
|
||||
|
||||
async def test_set_active_revokes_the_sessions_when_disabling() -> None:
|
||||
cible = FauxCompte()
|
||||
attirail = fabrique(cible)
|
||||
|
||||
await attirail.service.set_active(actor=ADMIN, user_id=cible.id, is_active=False)
|
||||
|
||||
assert attirail.comptes.activations == [(cible.id, False)]
|
||||
assert attirail.jetons.revocations == [(cible.id, RevocationReason.ADMINISTRATION.value)]
|
||||
|
||||
|
||||
async def test_set_active_leaves_the_sessions_alone_when_enabling() -> None:
|
||||
cible = FauxCompte(is_active=False)
|
||||
attirail = fabrique(cible)
|
||||
|
||||
await attirail.service.set_active(actor=ADMIN, user_id=cible.id, is_active=True)
|
||||
|
||||
assert attirail.jetons.revocations == []
|
||||
|
||||
|
||||
async def test_reset_password_closes_every_session_and_forces_a_change() -> None:
|
||||
cible = FauxCompte()
|
||||
attirail = fabrique(cible)
|
||||
|
||||
reinitialise = await attirail.service.reset_password(actor=ADMIN, user_id=cible.id)
|
||||
|
||||
assert len(reinitialise.temporary_password) >= 18
|
||||
assert attirail.comptes.mots_de_passe == [cible.id]
|
||||
assert attirail.jetons.revocations == [
|
||||
(cible.id, RevocationReason.CHANGEMENT_MOT_DE_PASSE.value)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"action",
|
||||
["change_role", "set_active", "reset_password"],
|
||||
ids=["changement_de_role", "activation", "reinitialisation"],
|
||||
)
|
||||
async def test_every_operation_refuses_an_unknown_account(action: str) -> None:
|
||||
attirail = fabrique(None)
|
||||
arguments: dict[str, Any] = {"actor": ADMIN, "user_id": uuid4()}
|
||||
if action == "change_role":
|
||||
arguments["role"] = Role.ADMIN
|
||||
if action == "set_active":
|
||||
arguments["is_active"] = False
|
||||
|
||||
with pytest.raises(UserNotFoundError):
|
||||
await getattr(attirail.service, action)(**arguments)
|
||||
Reference in New Issue
Block a user