""" Tests pour POST /api/v1/auth/logout Couvre les AC 1-6 de la story 1.4 : Logout Utilisateur """ import pytest from pathlib import Path from fastapi.testclient import TestClient LOGOUT_URL = "/api/v1/auth/logout" LOGIN_URL = "/api/v1/auth/login" REGISTER_URL = "/api/v1/auth/register" VALID_USER = { "email": "logout@example.com", "password": "Password123!", "name": "Logout User", } VALID_CREDENTIALS = { "email": "logout@example.com", "password": "Password123!", } @pytest.fixture() def client(tmp_path: Path, monkeypatch): """TestClient avec stockage JSON isolé, blocklist réinitialisée et rate limiting désactivé.""" import services.auth_service as auth_svc monkeypatch.setattr(auth_svc, "USERS_FILE", tmp_path / "users.json") monkeypatch.setattr(auth_svc, "USE_DATABASE", False) monkeypatch.setattr(auth_svc, "DATABASE_AVAILABLE", False) monkeypatch.setattr(auth_svc, "_revoked_jtis", {}) from middleware.rate_limiting import RateLimitManager async def _allow(self, request): return True, "ok", "test" async def _allow_translation(self, request, file_size_mb=0): return True, "ok" monkeypatch.setattr(RateLimitManager, "check_request", _allow) monkeypatch.setattr(RateLimitManager, "check_translation", _allow_translation) from main import app return TestClient(app, raise_server_exceptions=True) @pytest.fixture() def tokens(client): """Enregistre un utilisateur et retourne ses tokens access + refresh.""" client.post(REGISTER_URL, json=VALID_USER) resp = client.post(LOGIN_URL, json=VALID_CREDENTIALS) assert resp.status_code == 200 data = resp.json()["data"] return data["access_token"], data["refresh_token"] # --------------------------------------------------------------------------- # AC1 & AC4 : Endpoint existe et retourne 200 avec message succès # --------------------------------------------------------------------------- class TestLogoutSuccess: """AC1 & AC4 : POST /logout valide → 200 + message Déconnexion réussie""" def test_returns_200_on_success(self, client, tokens): access_token, _ = tokens response = client.post( LOGOUT_URL, headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == 200 def test_response_structure(self, client, tokens): access_token, _ = tokens response = client.post( LOGOUT_URL, headers={"Authorization": f"Bearer {access_token}"}, ) body = response.json() assert "data" in body assert body["data"]["message"] == "Déconnexion réussie" assert "meta" in body assert body["meta"] == {} def test_logout_with_refresh_token_in_body(self, client, tokens): access_token, refresh_token = tokens response = client.post( LOGOUT_URL, headers={"Authorization": f"Bearer {access_token}"}, json={"refresh_token": refresh_token}, ) assert response.status_code == 200 # --------------------------------------------------------------------------- # AC3 : Après logout, l'access token est révoqué # --------------------------------------------------------------------------- class TestAccessTokenRevoked: """AC3 : access token révoqué après logout → 401 à la réutilisation""" def test_reusing_access_token_after_logout_returns_401(self, client, tokens): access_token, _ = tokens logout_resp = client.post( LOGOUT_URL, headers={"Authorization": f"Bearer {access_token}"}, ) assert logout_resp.status_code == 200 reuse_resp = client.post( LOGOUT_URL, headers={"Authorization": f"Bearer {access_token}"}, ) assert reuse_resp.status_code == 401 # --------------------------------------------------------------------------- # AC2 : Après logout, le refresh token est révoqué # --------------------------------------------------------------------------- class TestRefreshTokenRevoked: """AC2 : refresh token révoqué après logout → ne peut plus obtenir de nouvel access token""" def test_refresh_token_revoked_after_logout(self, client, tokens): access_token, refresh_token = tokens logout_resp = client.post( LOGOUT_URL, headers={"Authorization": f"Bearer {access_token}"}, json={"refresh_token": refresh_token}, ) assert logout_resp.status_code == 200 # AC2: utiliser le refresh token révoqué doit retourner 401 refresh_resp = client.post( "/api/v1/auth/refresh", json={"refresh_token": refresh_token}, ) assert refresh_resp.status_code == 401, ( "Le refresh token révoqué ne doit plus permettre d'obtenir un access token" ) # --------------------------------------------------------------------------- # AC5 : Token manquant → 401 TOKEN_MISSING # --------------------------------------------------------------------------- class TestTokenMissing: """AC5 : Appel sans Authorization header → 401 TOKEN_MISSING""" def test_no_auth_header_returns_401(self, client): response = client.post(LOGOUT_URL) assert response.status_code == 401 def test_no_auth_header_error_code(self, client): response = client.post(LOGOUT_URL) body = response.json() assert body["error"] == "TOKEN_MISSING" assert "Token d'authentification requis" in body["message"] def test_non_bearer_auth_returns_401(self, client): response = client.post( LOGOUT_URL, headers={"Authorization": "Basic dXNlcjpwYXNz"}, ) assert response.status_code == 401 assert response.json()["error"] == "TOKEN_MISSING" # --------------------------------------------------------------------------- # AC6 : Token invalide/expiré → 401 TOKEN_INVALID # --------------------------------------------------------------------------- class TestTokenInvalid: """AC6 : Token invalide ou malformé → 401 TOKEN_INVALID""" def test_malformed_token_returns_401(self, client): response = client.post( LOGOUT_URL, headers={"Authorization": "Bearer this.is.not.a.valid.jwt"}, ) assert response.status_code == 401 def test_malformed_token_error_code(self, client): response = client.post( LOGOUT_URL, headers={"Authorization": "Bearer invalid"}, ) body = response.json() assert body["error"] == "TOKEN_INVALID" assert "Token invalide ou expiré" in body["message"] def test_expired_token_returns_401(self, client, monkeypatch): """Un token créé avec une expiry passée doit retourner 401.""" import jwt as pyjwt import services.auth_service as auth_svc from datetime import datetime, timedelta, timezone expired_payload = { "sub": "some-user-id", "type": "access", "exp": datetime.now(timezone.utc) - timedelta(minutes=5), "jti": "expired-jti-123", } expired_token = pyjwt.encode( expired_payload, auth_svc.SECRET_KEY, algorithm=auth_svc.ALGORITHM ) response = client.post( LOGOUT_URL, headers={"Authorization": f"Bearer {expired_token}"}, ) assert response.status_code == 401 assert response.json()["error"] == "TOKEN_INVALID" # --------------------------------------------------------------------------- # Backward Compatibility : tokens sans JTI restent valides # --------------------------------------------------------------------------- class TestBackwardCompatibility: """Tokens créés sans JTI (avant cette story) ne sont pas révocables mais fonctionnent.""" def test_token_without_jti_is_valid(self, client): """Un token sans JTI doit être accepté (verify_token retourne le payload).""" import jwt as pyjwt import services.auth_service as auth_svc from datetime import datetime, timedelta, timezone payload_no_jti = { "sub": "some-user-id", "type": "access", "exp": datetime.now(timezone.utc) + timedelta(minutes=15), } token_no_jti = pyjwt.encode( payload_no_jti, auth_svc.SECRET_KEY, algorithm=auth_svc.ALGORITHM ) result = auth_svc.verify_token(token_no_jti) assert result is not None, "Token sans JTI doit rester valide" assert result.get("jti") is None