import importlib from datetime import UTC, datetime, timedelta from app.core.security import AuthContext, get_auth_context from app.models.auth import AccessTokenPayload, AuthUserDocument auth_router_module = importlib.import_module("app.api.auth.router") def test_login_success_returns_bearer_token(client, monkeypatch: object): user = AuthUserDocument(username="admin", password_hash="stored-hash") async def fake_get_user_by_username(username: str) -> AuthUserDocument | None: assert username == "admin" return user def fake_verify_password(plain_password: str, password_hash: str) -> bool: return plain_password == "secret" and password_hash == "stored-hash" def fake_create_access_token(*, subject: str) -> str: assert subject == "admin" return "token-123" monkeypatch.setattr( auth_router_module, "get_user_by_username", fake_get_user_by_username, ) monkeypatch.setattr(auth_router_module, "verify_password", fake_verify_password) monkeypatch.setattr( auth_router_module, "create_access_token", fake_create_access_token, ) response = client.post( "/auth/login", json={"username": "admin", "password": "secret"}, ) assert response.status_code == 200 assert response.json() == {"access_token": "token-123", "token_type": "bearer"} def test_login_invalid_credentials_returns_401(client, monkeypatch: object): async def fake_get_user_by_username(_: str) -> None: return None monkeypatch.setattr( auth_router_module, "get_user_by_username", fake_get_user_by_username, ) response = client.post( "/auth/login", json={"username": "admin", "password": "wrong"}, ) assert response.status_code == 401 assert response.json()["detail"] == "Invalid username or password" assert response.headers["www-authenticate"] == "Bearer" def test_logout_revokes_active_token(client, monkeypatch: object): captured: dict[str, object] = {} async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None: captured["jti"] = jti captured["expires_at"] = expires_at monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token) response = client.post("/auth/logout") assert response.status_code == 204 assert captured["jti"] == "test-jti" assert isinstance(captured["expires_at"], datetime) def test_logout_skips_revocation_when_token_is_expired( client, fake_user: AuthUserDocument, monkeypatch: object, ): state = {"called": False} async def fake_revoke_token(*, jti: str, expires_at: datetime) -> None: state["called"] = True monkeypatch.setattr(auth_router_module, "revoke_token", fake_revoke_token) expired_payload = AccessTokenPayload( sub=fake_user.username, jti="expired-jti", exp=int((datetime.now(UTC) - timedelta(minutes=1)).timestamp()), ) expired_context = AuthContext( token="expired-token", user=fake_user, payload=expired_payload, ) async def override_auth_context() -> AuthContext: return expired_context client.app.dependency_overrides[get_auth_context] = override_auth_context response = client.post("/auth/logout") assert response.status_code == 204 assert state["called"] is False