"""Tests for authentication endpoints.""" import uuid from datetime import datetime, timedelta, timezone import jwt import pytest from app.config import settings def _make_token(username: str, *, expired_seconds_ago: int | None = None) -> str: """Build a raw JWT mirroring ``create_access_token`` with a controllable exp.""" if expired_seconds_ago is None: expire = datetime.now(timezone.utc) + timedelta(minutes=30) else: expire = datetime.now(timezone.utc) - timedelta(seconds=expired_seconds_ago) payload = {"sub": username, "exp": expire, "jti": str(uuid.uuid4())} return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) def test_login_success(client, admin_user): """Test successful login returns a token.""" response = client.post( "/api/v1/auth/login", data={"username": "admin@test.com", "password": "admin123"}, ) assert response.status_code == 200 data = response.json() assert "access_token" in data assert data["token_type"] == "bearer" def test_login_wrong_password(client, admin_user): """Test login with wrong password returns 400.""" response = client.post( "/api/v1/auth/login", data={"username": "admin@test.com", "password": "wrongpassword"}, ) assert response.status_code == 400 def test_login_nonexistent_user(client): """Test login with non-existent user returns 400.""" response = client.post( "/api/v1/auth/login", data={"username": "nobody", "password": "password"}, ) assert response.status_code == 400 def test_login_inactive_user(client, db): """Test login with inactive user returns 400.""" from app.auth import hash_password from app.models.user import User user = User( username="inactive", email="inactive@test.com", hashed_password=hash_password("password"), role="viewer", is_active=False, ) db.add(user) db.commit() response = client.post( "/api/v1/auth/login", data={"username": "inactive@test.com", "password": "password"}, ) assert response.status_code == 403 def test_get_me_with_token(client, admin_user, admin_token): """Test /auth/me returns current user with valid token.""" response = client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 200 data = response.json() assert data["username"] == "admin" assert data["role"] == "admin" def test_get_me_without_token(client): """Test /auth/me returns 401 without token.""" response = client.get("/api/v1/auth/me") assert response.status_code == 401 def test_get_me_invalid_token(client): """Test /auth/me returns 401 with invalid token.""" response = client.get( "/api/v1/auth/me", headers={"Authorization": "Bearer invalidtoken"}, ) assert response.status_code == 401 def test_logout_revokes_token(client, admin_user): """After logout, the same JWT must be rejected (Redis blacklist). Prefer Authorization over the HttpOnly cookie so logout blacklists the same token the client sends on the next request; clear cookies to avoid stale jar state across calls. """ login = client.post( "/api/v1/auth/login", data={"username": "admin@test.com", "password": "admin123"}, ) assert login.status_code == 200 token = login.json()["access_token"] client.cookies.clear() out = client.post( "/api/v1/auth/logout", headers={"Authorization": f"Bearer {token}"}, ) assert out.status_code == 200 import jwt from app.config import settings from app.infrastructure.redis_client import get_redis_blacklist payload = jwt.decode( token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM], ) jti = payload.get("jti") assert jti assert get_redis_blacklist().exists(f"blacklist:{jti}") client.cookies.clear() assert not len(client.cookies), "cookie jar must be empty to force Authorization Bearer" me = client.get( "/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}, ) assert me.status_code == 401 assert me.json()["detail"] == "Token has been revoked" def test_refresh_without_cookie_fails(client): response = client.post("/api/v1/auth/refresh") assert response.status_code == 403 def test_refresh_valid_token_succeeds(client, admin_user): token = _make_token("admin") client.cookies.set("aegis_token", token) response = client.post("/api/v1/auth/refresh") assert response.status_code == 200 assert "access_token" in response.json() def test_refresh_recently_expired_token_succeeds_within_grace(client, admin_user): """A token that expired moments ago must still be refreshable. This is the core of the refresh-token bug fix: without a grace window, `/auth/refresh` decodes with the same strict expiry check as every other endpoint, so a 401-triggered refresh attempt always also fails. """ token = _make_token("admin", expired_seconds_ago=60) client.cookies.set("aegis_token", token) response = client.post("/api/v1/auth/refresh") assert response.status_code == 200 assert "access_token" in response.json() def test_refresh_long_expired_token_fails(client, admin_user): token = _make_token("admin", expired_seconds_ago=60 * 60) client.cookies.set("aegis_token", token) response = client.post("/api/v1/auth/refresh") assert response.status_code == 403 def test_refresh_blacklisted_token_fails_even_if_not_expired(client, admin_user): from app.auth import blacklist_token token = _make_token("admin") payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) blacklist_token(payload["jti"], float(payload["exp"])) client.cookies.set("aegis_token", token) response = client.post("/api/v1/auth/refresh") assert response.status_code == 403