"""Unit tests for ImpersonationOwsClient.""" import json from typing import Any from unittest.mock import MagicMock, patch import httpx import pytest from _pytest.monkeypatch import MonkeyPatch from owsclient.impersonation_client import ImpersonationOwsClient from owsclient.m2m.impersonation import ImpersonationM2MTokenManager from owsclient.test.mock import OwsClientMock TEST_SERVICE_NAME = "ows-permissions" TEST_PATH = "/hello/" TEST_IMPERSONATED_IDENTITY_UUID = "f94b0c5a-b520-486b-ac17-e59e9888b8bd" @pytest.fixture(autouse=True) def setup_service_mapping(monkeypatch: MonkeyPatch) -> None: """Fixture for service map.""" monkeypatch.setenv( "OWSREQUEST_SERVICE_MAP", json.dumps({TEST_SERVICE_NAME: "http://ows-permission.test/"}), ) @pytest.fixture def mock_impersonation_m2m_token_manager() -> MagicMock: """Fixture for ImpersonationM2MTokenManager.""" manager = MagicMock(spec=ImpersonationM2MTokenManager) manager.get_token_string.return_value = "test-token-123" return manager @pytest.fixture def impersonation_client( mock_impersonation_m2m_token_manager: MagicMock, ) -> ImpersonationOwsClient: """Fixture for ImpersonationOwsClient.""" return ImpersonationOwsClient( environment="test", service_name="ows-test", m2m_token_manager=mock_impersonation_m2m_token_manager, ) def test_impersonation_client_raises_type_error_with_wrong_manager() -> None: """Test ImpersonationOwsClient raises TypeError with non-ImpersonationM2MTokenManager.""" with pytest.raises( TypeError, match="must be an instance of ImpersonationM2MTokenManager" ): ImpersonationOwsClient( environment="test", service_name="ows-test", m2m_token_manager=MagicMock(), # Wrong type ) def test_impersonation_client_request( ows_client_mock: OwsClientMock, impersonation_client: ImpersonationOwsClient ) -> None: """Test request is made using ImpersonationOwsClient.""" path = "/test/" response_status = 200 response_json = {"status": "ok"} ows_client_mock.request(TEST_SERVICE_NAME, method="GET", path=path).mock( return_value=httpx.Response(status_code=response_status, json=response_json) ) response = impersonation_client.request( TEST_SERVICE_NAME, method="GET", path=path, impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, ) assert response.status_code == response_status assert response.json() == response_json @pytest.mark.parametrize( ( "test_description", "additional_client_kwargs", "expected_additional_httpx_client_kwargs", ), [ ( "default timeout", {}, {"timeout": httpx.Timeout(5.0)}, ), ( "custom timeout", {"timeout": httpx.Timeout(5.0, read=45.0)}, {"timeout": httpx.Timeout(5.0, read=45.0)}, ), ], ) @patch("owsclient.impersonation_client.httpx.Client") def test_impersonation_client_kwargs_passed_to_httpx_client( httpx_client_mock: MagicMock, mock_impersonation_m2m_token_manager: MagicMock, test_description: str, additional_client_kwargs: dict[str, Any], expected_additional_httpx_client_kwargs: dict[str, Any], ) -> None: """Test ImpersonationOwsClient kwargs passed to httpx.Client.""" client = ImpersonationOwsClient( environment="test", service_name="ows-test", m2m_token_manager=mock_impersonation_m2m_token_manager, **additional_client_kwargs, ) client.get( TEST_SERVICE_NAME, path="/test/", impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, ) assert all( httpx_client_mock.call_args.kwargs[key] == value for key, value in expected_additional_httpx_client_kwargs.items() ) @patch("owsclient.impersonation_client.httpx.Client.send") def test_impersonation_client_request_timeout_overrides_default( mock_send: MagicMock, mock_impersonation_m2m_token_manager: MagicMock ) -> None: """Test specifying timeout in ImpersonationOwsClient request overrides default timeout.""" default_timeout = 1 request_timeout = default_timeout + 1 client = ImpersonationOwsClient( environment="test", service_name="ows-test", m2m_token_manager=mock_impersonation_m2m_token_manager, timeout=httpx.Timeout(default_timeout), ) client.get( TEST_SERVICE_NAME, path="/test/", impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, ) client.get( TEST_SERVICE_NAME, path="/test/", impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, timeout=httpx.Timeout(request_timeout), ) default_timeout_request, request_timeout_request = mock_send.call_args_list assert ( default_timeout_request.args[0].extensions["timeout"] == httpx.Timeout(default_timeout).as_dict() ) assert ( request_timeout_request.args[0].extensions["timeout"] == httpx.Timeout(request_timeout).as_dict() ) @pytest.mark.parametrize("method", ["HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"]) def test_impersonation_client_method( method: str, ows_client_mock: OwsClientMock, impersonation_client: ImpersonationOwsClient, ) -> None: """Test ImpersonationOwsClient request is made with different request methods.""" path = "/test/" response_status = 200 response_json = {"status": "ok"} ows_client_mock_request = getattr(ows_client_mock, method.lower()) ows_client_mock_request(TEST_SERVICE_NAME, path=path).mock( return_value=httpx.Response(status_code=response_status, json=response_json) ) client_request = getattr(impersonation_client, method.lower()) response = client_request( TEST_SERVICE_NAME, path=path, impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, ) assert response.status_code == response_status assert response.json() == response_json def test_impersonation_client_passes_token_to_authorization_header( impersonation_client: ImpersonationOwsClient, mock_impersonation_m2m_token_manager: MagicMock, ) -> None: """Test ImpersonationOwsClient passes impersonation token to authorization header.""" with patch("owsclient.impersonation_client.httpx.Client.request") as mock_request: mock_request.return_value = httpx.Response(200) impersonation_client.get( TEST_SERVICE_NAME, path="/test/", impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, ) mock_impersonation_m2m_token_manager.get_token_string.assert_called_once_with( impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID ) call_kwargs = mock_request.call_args.kwargs assert "authorization" in call_kwargs["headers"] assert call_kwargs["headers"]["authorization"] == "Bearer test-token-123" def test_impersonation_client_handles_token_generation_failure( mock_impersonation_m2m_token_manager: MagicMock, ) -> None: """Test ImpersonationOwsClient handles token generation failure gracefully.""" mock_impersonation_m2m_token_manager.get_token_string.side_effect = Exception( "Token generation failed" ) client = ImpersonationOwsClient( environment="test", service_name="ows-test", m2m_token_manager=mock_impersonation_m2m_token_manager, ) with patch("owsclient.impersonation_client.httpx.Client.request") as mock_request: mock_request.return_value = httpx.Response(200) client.get( TEST_SERVICE_NAME, path="/test/", impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, ) call_kwargs = mock_request.call_args.kwargs assert "authorization" not in call_kwargs["headers"] @pytest.mark.parametrize( "input_headers", [ pytest.param( {}, id="Input headers is empty. It should call both helper methods.", ), pytest.param( {"custom-header": "value"}, id="Input non-empty but doesn't have an auth header. It should call both helper methods", ), pytest.param( {"authorization": "Bearer existing-token"}, id="Existing authorization header is not overridden. It should not call `pass_authorization_header`", ), ], ) def test_prepare_headers( impersonation_client: ImpersonationOwsClient, input_headers: dict[str, str], ) -> None: """Test prepare_headers with different scenarios.""" # Mock `pass_correlation_id_header` and `pass_authorization_header` methods. with patch.object( impersonation_client, "pass_correlation_id_header", return_value=input_headers, ) as mock_pass_corr, patch.object( impersonation_client, "pass_authorization_header", return_value=input_headers, ) as mock_pass_auth: _ = impersonation_client.prepare_headers( impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, headers=input_headers, ) # It should always fetch a correlation id mock_pass_corr.assert_called_once_with(input_headers, correlation_id=None) if "authorization" in input_headers: # It should not fetch the impersonation m2m jwt if the caller # includes an authorization header. mock_pass_auth.assert_not_called() else: # It should fetch the impersonation m2m jwt if the caller # did not include an authorization header. mock_pass_auth.assert_called_once_with( headers=input_headers, impersonated_identity_uuid=TEST_IMPERSONATED_IDENTITY_UUID, ) def test_graphql_query( ows_client_mock: OwsClientMock, impersonation_client: ImpersonationOwsClient, ) -> None: """Test graphql_query fn.""" graphql_service = "graphql-router" operation_name = "SomeOperation" variables = {"dogs": "rule"} ows_client_mock.post( graphql_service, path="/graphql", headers={ "cats": "rule", "Orchard-Identity-Id": "you you you", "Orchard-Profile-Type": "PetProfile", "Orchard-Profile-Id": "123", "Correlation-Id": "trace-me-1234", "authorization": "Bearer test-token-123", }, json={ "operationName": operation_name, "query": "graphql query", "variables": variables, }, ).mock(return_value=httpx.Response(status_code=200, json={"status": "ok"})) response = impersonation_client.graphql_query( service_name=graphql_service, query=" graphql query ", impersonated_identity_uuid="not you", operation_name=operation_name, variables={"dogs": "rule"}, headers={"cats": "rule"}, identity_id="you you you", profile_id=123, profile_type="PetProfile", correlation_id="trace-me-1234", ) assert response.status_code == 200 assert response.json() == {"status": "ok"}