195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
# Le premier test de ce fichier est le seul endroit où l'atomicité de la rotation se démontre :
|
|
# sur un double, deux appels concurrents réussiraient tous les deux.
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.roles import Role
|
|
from app.core.security import fingerprint_refresh, generate_refresh_secret
|
|
from app.models.refresh_token import RevocationReason
|
|
from app.repositories.refresh_token import RefreshTokenRepository
|
|
from app.repositories.user import UserRepository
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
DUREE = timedelta(days=7)
|
|
|
|
|
|
async def un_compte(session: AsyncSession) -> uuid.UUID:
|
|
compte = await UserRepository(session).create(
|
|
email=f"jeton-{uuid.uuid4().hex[:12]}@enervision.fr",
|
|
password_hash="$argon2id$x",
|
|
role=Role.LECTEUR,
|
|
)
|
|
return compte.id
|
|
|
|
|
|
async def un_jeton(
|
|
depot: RefreshTokenRepository,
|
|
user_id: uuid.UUID,
|
|
*,
|
|
family_id: uuid.UUID | None = None,
|
|
duree: timedelta = DUREE,
|
|
) -> tuple[str, uuid.UUID]:
|
|
secret = generate_refresh_secret()
|
|
jeton = await depot.create(
|
|
user_id=user_id,
|
|
family_id=family_id or uuid.uuid4(),
|
|
token_hash=fingerprint_refresh(secret),
|
|
expires_at=datetime.now(UTC) + duree,
|
|
client_ip="203.0.113.10",
|
|
user_agent="pytest",
|
|
)
|
|
return secret, jeton.family_id
|
|
|
|
|
|
async def test_claim_for_rotation_only_succeeds_once(session: AsyncSession) -> None:
|
|
depot = RefreshTokenRepository(session)
|
|
secret, _ = await un_jeton(depot, await un_compte(session))
|
|
|
|
premier = await depot.claim_for_rotation(fingerprint_refresh(secret))
|
|
second = await depot.claim_for_rotation(fingerprint_refresh(secret))
|
|
await session.rollback()
|
|
|
|
assert premier is not None
|
|
assert second is None
|
|
|
|
|
|
async def test_claim_for_rotation_refuses_an_expired_token(session: AsyncSession) -> None:
|
|
depot = RefreshTokenRepository(session)
|
|
secret, _ = await un_jeton(depot, await un_compte(session), duree=-timedelta(minutes=1))
|
|
|
|
revendique = await depot.claim_for_rotation(fingerprint_refresh(secret))
|
|
await session.rollback()
|
|
|
|
assert revendique is None
|
|
|
|
|
|
async def test_claim_for_rotation_returns_nothing_for_an_unknown_fingerprint(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
revendique = await RefreshTokenRepository(session).claim_for_rotation(
|
|
fingerprint_refresh(generate_refresh_secret())
|
|
)
|
|
|
|
assert revendique is None
|
|
|
|
|
|
async def test_inspect_finds_a_token_that_rotation_already_refused(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
depot = RefreshTokenRepository(session)
|
|
secret, _ = await un_jeton(depot, await un_compte(session))
|
|
await depot.claim_for_rotation(fingerprint_refresh(secret))
|
|
|
|
ligne = await depot.inspect(fingerprint_refresh(secret))
|
|
rotation, motif = (ligne.rotated_at, ligne.revoked_reason) if ligne else (None, None)
|
|
await session.rollback()
|
|
|
|
assert rotation is not None
|
|
assert motif == RevocationReason.ROTATION.value
|
|
|
|
|
|
async def test_revoke_family_touches_every_living_token_of_that_family_only(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
depot = RefreshTokenRepository(session)
|
|
compte = await un_compte(session)
|
|
famille = uuid.uuid4()
|
|
await un_jeton(depot, compte, family_id=famille)
|
|
await un_jeton(depot, compte, family_id=famille)
|
|
autre_secret, _ = await un_jeton(depot, compte)
|
|
|
|
revoquees = await depot.revoke_family(famille, RevocationReason.REUTILISATION)
|
|
intacte = await depot.claim_for_rotation(fingerprint_refresh(autre_secret))
|
|
await session.rollback()
|
|
|
|
assert revoquees == 2
|
|
assert intacte is not None
|
|
|
|
|
|
async def test_revoke_family_is_idempotent(session: AsyncSession) -> None:
|
|
depot = RefreshTokenRepository(session)
|
|
compte = await un_compte(session)
|
|
famille = uuid.uuid4()
|
|
await un_jeton(depot, compte, family_id=famille)
|
|
|
|
premier = await depot.revoke_family(famille, RevocationReason.DECONNEXION)
|
|
second = await depot.revoke_family(famille, RevocationReason.DECONNEXION)
|
|
await session.rollback()
|
|
|
|
assert premier == 1
|
|
assert second == 0
|
|
|
|
|
|
async def test_revoke_all_for_user_closes_every_family_at_once(session: AsyncSession) -> None:
|
|
depot = RefreshTokenRepository(session)
|
|
compte = await un_compte(session)
|
|
await un_jeton(depot, compte)
|
|
await un_jeton(depot, compte)
|
|
await un_jeton(depot, compte)
|
|
|
|
revoquees = await depot.revoke_all_for_user(compte, RevocationReason.CHANGEMENT_MOT_DE_PASSE)
|
|
await session.rollback()
|
|
|
|
assert revoquees == 3
|
|
|
|
|
|
async def test_link_replacement_records_the_successor(session: AsyncSession) -> None:
|
|
depot = RefreshTokenRepository(session)
|
|
compte = await un_compte(session)
|
|
ancien_secret, famille = await un_jeton(depot, compte)
|
|
revendique = await depot.claim_for_rotation(fingerprint_refresh(ancien_secret))
|
|
assert revendique is not None
|
|
nouveau_secret = generate_refresh_secret()
|
|
nouveau = await depot.create(
|
|
user_id=compte,
|
|
family_id=famille,
|
|
token_hash=fingerprint_refresh(nouveau_secret),
|
|
expires_at=revendique.expires_at,
|
|
client_ip=None,
|
|
user_agent=None,
|
|
)
|
|
|
|
await depot.link_replacement(revendique.id, nouveau.id)
|
|
ligne = await depot.inspect(fingerprint_refresh(ancien_secret))
|
|
successeur = ligne.replaced_by if ligne else None
|
|
await session.rollback()
|
|
|
|
assert successeur == nouveau.id
|
|
|
|
|
|
async def test_the_database_refuses_two_tokens_sharing_a_fingerprint(
|
|
session: AsyncSession,
|
|
) -> None:
|
|
depot = RefreshTokenRepository(session)
|
|
compte = await un_compte(session)
|
|
secret = generate_refresh_secret()
|
|
await depot.create(
|
|
user_id=compte,
|
|
family_id=uuid.uuid4(),
|
|
token_hash=fingerprint_refresh(secret),
|
|
expires_at=datetime.now(UTC) + DUREE,
|
|
client_ip=None,
|
|
user_agent=None,
|
|
)
|
|
|
|
famille = uuid.uuid4()
|
|
empreinte = fingerprint_refresh(secret)
|
|
expiration = datetime.now(UTC) + DUREE
|
|
|
|
with pytest.raises(IntegrityError):
|
|
await depot.create(
|
|
user_id=compte,
|
|
family_id=famille,
|
|
token_hash=empreinte,
|
|
expires_at=expiration,
|
|
client_ip=None,
|
|
user_agent=None,
|
|
)
|
|
await session.rollback()
|