From 25967462ede1ee471bfca4711c3bb69e037cf41a Mon Sep 17 00:00:00 2001 From: Valentyn Prysiazhniuk Date: Thu, 26 Feb 2026 20:39:28 +0200 Subject: [PATCH 1/2] added raise for InitializationError when jwt is incorrect and username/password not provided --- virl2_client/models/authentication.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/virl2_client/models/authentication.py b/virl2_client/models/authentication.py index 61a2196..c0a8fb3 100644 --- a/virl2_client/models/authentication.py +++ b/virl2_client/models/authentication.py @@ -28,7 +28,7 @@ import httpx -from ..exceptions import APIError +from ..exceptions import APIError, InitializationError _LOGGER = logging.getLogger(__name__) @@ -123,6 +123,12 @@ def auth_flow( if response.status_code == 401: _LOGGER.warning("re-auth called on 401 unauthorized") self.token = None + if not (self.client_library.username and self.client_library.password): + raise InitializationError( + "JWT token expired and automatic re-authentication is not " + "possible because username/password are not configured. " + "Set client.jwtoken, or initialize with username/password.", + ) request.headers["Authorization"] = f"Bearer {self.token}" response = yield request From 962323e733d034ddbfdd268a53d0e5c36871f7ca Mon Sep 17 00:00:00 2001 From: Tomas Mikuska Date: Mon, 2 Mar 2026 15:00:47 +0100 Subject: [PATCH 2/2] Switch to APIError and add tests --- tests/test_client_library.py | 92 +++++++++++++++++++++++++++ virl2_client/models/authentication.py | 6 +- 2 files changed, 96 insertions(+), 2 deletions(-) diff --git a/tests/test_client_library.py b/tests/test_client_library.py index d6c2fa4..485fa25 100644 --- a/tests/test_client_library.py +++ b/tests/test_client_library.py @@ -29,6 +29,7 @@ import pytest import respx +from virl2_client.exceptions import APIError from virl2_client.models import Lab from virl2_client.virl2_client import ( ClientLibrary, @@ -224,6 +225,97 @@ def initial_different_response( assert respx.calls.call_count == 6 +@respx.mock +def test_jwt_only_valid_token_does_not_call_password_auth( + client_library_server_current: MagicMock, +): + _ = client_library_server_current + + auth_route = respx.get(f"{FAKE_URL}api/v0/authentication").respond( + 200, + json={ + "username": "jwt_user", + "admin": False, + "id": "6c7dd461-1cbe-428f-bdd5-545a0d766ed7", + "token": "VALID_TOKEN", + "error": None, + }, + ) + password_auth_route = respx.post(f"{FAKE_URL}api/v0/authenticate").respond( + json="SHOULD_NOT_BE_USED" + ) + respx.get(f"{FAKE_URL}api/v0/labs").respond(json=[]) + + cl = ClientLibrary(url=FAKE_URL, jwtoken="VALID_TOKEN") + cl.all_labs() + + assert auth_route.called + assert not password_auth_route.called + + +@respx.mock +def test_jwt_expired_with_credentials_reauths_using_password_auth( + client_library_server_current: MagicMock, +): + _ = client_library_server_current + + auth_route = respx.get(f"{FAKE_URL}api/v0/authentication") + auth_route.side_effect = [ + httpx.Response(401), + httpx.Response( + 200, + json={ + "username": "test", + "admin": True, + "id": "6c7dd461-1cbe-428f-bdd5-545a0d766ed7", + "token": "REFRESHED_TOKEN", + "error": None, + }, + ), + ] + password_auth_route = respx.post(f"{FAKE_URL}api/v0/authenticate").respond( + json="REFRESHED_TOKEN" + ) + + ClientLibrary( + url=FAKE_URL, + username="test", + password="pa$$", + jwtoken="EXPIRED_TOKEN", + ) + + assert auth_route.called + assert auth_route.call_count == 2 + assert password_auth_route.called + assert json.loads(password_auth_route.calls[0].request.content) == { + "username": "test", + "password": "pa$$", + } + + +@respx.mock +def test_jwt_reauth_without_credentials_fails_cleanly( + client_library_server_current: MagicMock, + reset_env: None, +): + _ = client_library_server_current, reset_env + + auth_route = respx.get(f"{FAKE_URL}api/v0/authentication").respond(401) + password_auth_route = respx.post(f"{FAKE_URL}api/v0/authenticate").respond( + json="SHOULD_NOT_BE_USED" + ) + + with pytest.raises( + APIError, + match="JWT token expired and automatic re-authentication is not possible", + ): + ClientLibrary(url=FAKE_URL, jwtoken="EXPIRED_TOKEN") + + assert auth_route.called + assert auth_route.call_count == 1 + assert not password_auth_route.called + + @respx.mock def test_old_auth_url_used_with_cml_2_9( client_library_server_2_9_0: MagicMock, monkeypatch: pytest.MonkeyPatch diff --git a/virl2_client/models/authentication.py b/virl2_client/models/authentication.py index c0a8fb3..5d97251 100644 --- a/virl2_client/models/authentication.py +++ b/virl2_client/models/authentication.py @@ -28,7 +28,7 @@ import httpx -from ..exceptions import APIError, InitializationError +from ..exceptions import APIError _LOGGER = logging.getLogger(__name__) @@ -124,10 +124,12 @@ def auth_flow( _LOGGER.warning("re-auth called on 401 unauthorized") self.token = None if not (self.client_library.username and self.client_library.password): - raise InitializationError( + raise APIError( "JWT token expired and automatic re-authentication is not " "possible because username/password are not configured. " "Set client.jwtoken, or initialize with username/password.", + request=response.request, + response=response, ) request.headers["Authorization"] = f"Bearer {self.token}" response = yield request