From d3e2e356ffac69a0a4b907ac4d3260273ce08d8e Mon Sep 17 00:00:00 2001 From: Fernando Aureliano <145799342+fsilvamaia@users.noreply.github.com> Date: Tue, 24 Oct 2023 11:35:27 -0300 Subject: [PATCH] create http client class (#135) --- .gitignore | 1 + .../functional/test_http_client_functional.py | 90 +++++++++ tests/unit/test_aws.py | 20 +- tests/unit/test_http_client.py | 157 ++++++++++++++++ tests/unit/test_okta.py | 123 ++++++------ tests/unit/test_user.py | 23 --- tokendito/__init__.py | 2 +- tokendito/aws.py | 11 +- tokendito/http_client.py | 79 ++++++++ tokendito/okta.py | 175 +++++++++++------- tokendito/tool.py | 5 +- tokendito/user.py | 67 +++---- 12 files changed, 549 insertions(+), 204 deletions(-) create mode 100644 tests/functional/test_http_client_functional.py create mode 100644 tests/unit/test_http_client.py create mode 100644 tokendito/http_client.py diff --git a/.gitignore b/.gitignore index 2954c5dd..2ebe9cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.vscode # Spyder project settings .spyderproject diff --git a/tests/functional/test_http_client_functional.py b/tests/functional/test_http_client_functional.py new file mode 100644 index 00000000..d199b861 --- /dev/null +++ b/tests/functional/test_http_client_functional.py @@ -0,0 +1,90 @@ +"""This module contains unit tests for the HTTPClient class.""" +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +import pytest +from requests import RequestException +from tokendito import __title__ +from tokendito import __version__ +from tokendito.http_client import HTTPClient + + +@pytest.fixture +def client(): + """Fixture to create and return an HTTPClient instance.""" + client = HTTPClient() + client.session.headers.update({"User-Agent": f"{__title__}/{__version__}"}) + return client + + +def test_get_request(client): + """Test the GET request functionality of HTTPClient.""" + # Make a GET request to the /get endpoint of httpbin which reflects the sent request data + response = client.get("https://httpbin.org/get") + json_data = response.json() + + # Assert that the request was successful and the returned User-Agent matches the one we set + assert response.status_code == 200 + assert json_data["headers"]["User-Agent"] == f"{__title__}/{__version__}" + + +def test_post_request(client): + """Test the POST request functionality of HTTPClient.""" + # Make a POST request to the /post endpoint of httpbin with sample data + response = client.post("https://httpbin.org/post", json={"key": "value"}) + json_data = response.json() + + # Assert that the request was successful and the returned json data matches the data we sent + assert response.status_code == 200 + assert json_data["json"] == {"key": "value"} + + +def test_set_cookies(client): + """Test the ability to set cookies using HTTPClient.""" + # Set a test cookie for the client + client.set_cookies({"test_cookie": "cookie_value"}) + + # Make a request to the /cookies endpoint of httpbin which returns set cookies + response = client.get("https://httpbin.org/cookies") + json_data = response.json() + + # Assert that the cookie we set is correctly returned by the server + assert json_data["cookies"] == {"test_cookie": "cookie_value"} + + +def test_custom_header(client): + """Test the ability to send custom headers using HTTPClient.""" + # Make a GET request with a custom header + response = client.get("https://httpbin.org/get", headers={"X-Test-Header": "TestValue"}) + json_data = response.json() + + # Assert that the custom header was correctly sent + assert json_data["headers"]["X-Test-Header"] == "TestValue" + + +def test_bad_get_request(client, mocker): + """Test GET request failure scenario.""" + mocker.patch("requests.Session.get", side_effect=RequestException("An error occurred")) + with pytest.raises(SystemExit): + client.get("https://httpbin.org/get") + + +def test_bad_post_request(client, mocker): + """Test POST request failure scenario.""" + mocker.patch("requests.Session.post", side_effect=RequestException("An error occurred")) + with pytest.raises(SystemExit): + client.post("https://httpbin.org/post", json={"key": "value"}) + + +def test_reset_session(client): + """Test the reset method to ensure session is reset.""" + # Set a test cookie for the client + client.set_cookies({"test_cookie": "cookie_value"}) + # Reset the session + client.reset() + + # Make a request to the /cookies endpoint of httpbin which returns set cookies + response = client.get("https://httpbin.org/cookies") + json_data = response.json() + + # Assert that the cookies have been cleared + assert json_data["cookies"] == {} diff --git a/tests/unit/test_aws.py b/tests/unit/test_aws.py index 34064fc2..6f214c0b 100644 --- a/tests/unit/test_aws.py +++ b/tests/unit/test_aws.py @@ -1,6 +1,8 @@ # vim: set filetype=python ts=4 sw=4 # -*- coding: utf-8 -*- """Unit tests, and local fixtures for AWS module.""" +from unittest.mock import Mock + import pytest @@ -91,10 +93,18 @@ def test_select_assumeable_role_no_tiles(): @pytest.mark.parametrize("status_code", [(400), (401), (404), (500), (503)]) def test_authenticate_to_roles(status_code, monkeypatch): """Test if function return correct response.""" - import requests from tokendito.aws import authenticate_to_roles + import tokendito.http_client as http_client - mock_get = {"status_code": status_code, "text": "response"} - monkeypatch.setattr(requests, "get", mock_get) - with pytest.raises(SystemExit) as error: - assert authenticate_to_roles([("http://test.url.com", "")], "secret_session_token") == error + # Create a mock response object + mock_response = Mock() + mock_response.status_code = status_code + mock_response.text = "response" + + # Use monkeypatch to replace the HTTP_client.get method with the mock + monkeypatch.setattr(http_client.HTTP_client, "get", lambda *args, **kwargs: mock_response) + + cookies = {"some_cookie": "some_value"} + + with pytest.raises(SystemExit): + authenticate_to_roles([("http://test.url.com", "")], cookies) diff --git a/tests/unit/test_http_client.py b/tests/unit/test_http_client.py new file mode 100644 index 00000000..aa13927d --- /dev/null +++ b/tests/unit/test_http_client.py @@ -0,0 +1,157 @@ +"""Unit tests for the HTTPClient class.""" +# vim: set filetype=python ts=4 sw=4 +# -*- coding: utf-8 -*- +import pytest +import requests +from tokendito import __title__ +from tokendito import __version__ +from tokendito.http_client import HTTPClient + +# Unit test class for the HTTPClient. + + +@pytest.fixture +def client(): + """Fixture for setting up an HTTPClient instance.""" + # Initializing HTTPClient instance without the 'user_agent' parameter + return HTTPClient() + + +def test_init(client): + """Test initialization of HTTPClient instance.""" + # Check if the session property of the client is an instance of requests.Session + assert isinstance(client.session, requests.Session) + + # Check if the User-Agent header was set correctly during initialization + expected_user_agent = f"{__title__}/{__version__}" + assert client.session.headers["User-Agent"] == expected_user_agent + + +def test_set_cookies(client): + """Test setting cookies in the session.""" + cookies = {"test_cookie": "cookie_value"} + client.set_cookies(cookies) + # Check if the provided cookie is set correctly in the session + assert client.session.cookies.get_dict() == cookies + + +def test_get(client, mocker): + """Test GET request method.""" + mock_get = mocker.patch("requests.Session.get") + mock_resp = mocker.Mock() + mock_resp.status_code = 200 + mock_resp.text = "OK" + mock_get.return_value = mock_resp + + response = client.get("http://test.com") + # Check if the response status code and text match the expected values + assert response.status_code == 200 + assert response.text == "OK" + + +def test_post(client, mocker): + """Test POST request method.""" + mock_post = mocker.patch("requests.Session.post") + mock_resp = mocker.Mock() + mock_resp.status_code = 201 + mock_resp.text = "Created" + mock_post.return_value = mock_resp + + response = client.post("http://test.com", json={"key": "value"}) + # Check if the response status code and text match the expected values + assert response.status_code == 201 + assert response.text == "Created" + + +def test_get_failure(client, mocker): + """Test GET request failure scenario.""" + mock_get = mocker.patch("requests.Session.get") + mock_get.side_effect = requests.RequestException("Failed to connect") + + with pytest.raises(SystemExit): + client.get("http://test.com") + + +def test_post_failure(client, mocker): + """Test POST request failure scenario.""" + mock_post = mocker.patch("requests.Session.post") + mock_post.side_effect = requests.RequestException("Failed to connect") + + with pytest.raises(SystemExit): + client.post("http://test.com", json={"key": "value"}) + + +def test_post_with_return_json(client, mocker): + """Test POST request with return_json=True.""" + mock_post = mocker.patch("requests.Session.post") + mock_resp = mocker.Mock() + mock_resp.status_code = 201 + mock_resp.json.return_value = {"status": "Created"} + mock_post.return_value = mock_resp + + response = client.post("http://test.com", json={"key": "value"}, return_json=True) + assert response == {"status": "Created"} + + +def test_reset(client): + """Test the reset method.""" + # Updating the session headers to check if they are reset later + client.session.headers.update({"Test-Header": "Test-Value"}) + + client.reset() + + expected_user_agent = f"{__title__}/{__version__}" + assert "Test-Header" not in client.session.headers + assert client.session.headers["User-Agent"] == expected_user_agent + + +def test_get_generic_exception(client, mocker): + """Test GET request with generic exception.""" + mock_get = mocker.patch("requests.Session.get") + mock_get.side_effect = Exception("Some Exception") + + with pytest.raises(SystemExit): + client.get("http://test.com") + + +def test_post_generic_exception(client, mocker): + """Test POST request with generic exception.""" + mock_post = mocker.patch("requests.Session.post") + mock_post.side_effect = Exception("Some Exception") + + with pytest.raises(SystemExit): + client.post("http://test.com", json={"key": "value"}) + + +def test_post_json_exception(client, mocker): + """Test POST request when json() method raises an exception.""" + mock_post = mocker.patch("requests.Session.post") + mock_resp = mocker.Mock() + mock_resp.status_code = 201 + mock_resp.json.side_effect = Exception("JSON Exception") + mock_post.return_value = mock_resp + + with pytest.raises(SystemExit): + client.post("http://test.com", json={"key": "value"}, return_json=True) + + +def test_get_logging_on_exception(client, mocker): + """Test if logging occurs during exception in GET request.""" + mock_get = mocker.patch("requests.Session.get") + mock_get.side_effect = requests.RequestException("Failed to connect") + mock_logger = mocker.patch("logging.Logger.error") + + with pytest.raises(SystemExit): + client.get("http://test.com") + mock_logger.assert_called() + + +def test_post_logging_on_exception(client, mocker): + """Test if logging occurs during exception in POST request.""" + mock_post = mocker.patch("requests.Session.post") + mock_post.side_effect = requests.RequestException("Failed to connect") + mock_logger = mocker.patch("logging.Logger.error") + + with pytest.raises(SystemExit): + client.post("http://test.com", json={"key": "value"}) + mock_logger.assert_called() diff --git a/tests/unit/test_okta.py b/tests/unit/test_okta.py index 85f69cf4..b2769b22 100644 --- a/tests/unit/test_okta.py +++ b/tests/unit/test_okta.py @@ -4,7 +4,8 @@ from unittest.mock import Mock import pytest -import requests_mock +from tokendito.config import Config +from tokendito.http_client import HTTP_client @pytest.fixture @@ -64,7 +65,12 @@ def test_bad_session_token(mocker, sample_json_response, sample_headers): "mfa_provider, session_token, selected_factor, expected", [ ("DUO", 123, {"_embedded": {}}, 123), - ("OKTA", 345, {"_embedded": {"factor": {"factorType": "push"}}}, 345), + ( + "OKTA", + 345, + {"_embedded": {"factor": {"factorType": "push"}}}, + 345, + ), # Changed expected value to 2 ("GOOGLE", 456, {"_embedded": {"factor": {"factorType": "sms"}}}, 456), ], ) @@ -77,9 +83,14 @@ def test_mfa_provider_type( sample_headers, ): """Test whether function return key on specific MFA provider.""" - from tokendito.config import Config + from tokendito.http_client import HTTP_client from tokendito.okta import mfa_provider_type + mock_response = {"sessionToken": session_token} + mocker.patch.object(HTTP_client, "post", return_value=mock_response) + + mocker.patch("tokendito.duo.duo_api_post", return_value=None) + payload = {"x": "y", "t": "z"} callback_url = "https://www.acme.org" selected_mfa_option = 1 @@ -87,15 +98,13 @@ def test_mfa_provider_type( primary_auth = 1 pytest_config = Config() - mfa_verify = {"sessionToken": session_token} mocker.patch( "tokendito.duo.authenticate_duo", return_value=(payload, sample_headers, callback_url), ) - mocker.patch("tokendito.okta.api_wrapper", return_value=mfa_verify) - mocker.patch("tokendito.okta.push_approval", return_value=mfa_verify) - mocker.patch("tokendito.okta.totp_approval", return_value=mfa_verify) - mocker.patch("tokendito.duo.duo_api_post") + mocker.patch("tokendito.okta.push_approval", return_value={"sessionToken": session_token}) + mocker.patch("tokendito.okta.totp_approval", return_value={"sessionToken": session_token}) + assert ( mfa_provider_type( pytest_config, @@ -114,6 +123,7 @@ def test_mfa_provider_type( def test_bad_mfa_provider_type(mocker, sample_headers): """Test whether function return key on specific MFA provider.""" from tokendito.config import Config + from tokendito.http_client import HTTP_client from tokendito.okta import mfa_provider_type pytest_config = Config() @@ -126,11 +136,15 @@ def test_bad_mfa_provider_type(mocker, sample_headers): mfa_verify = {"sessionToken": "pytest_session_token"} mfa_bad_provider = "bad_provider" + + mock_response = Mock() + mock_response.json.return_value = mfa_verify + mocker.patch( "tokendito.duo.authenticate_duo", return_value=(payload, sample_headers, callback_url), ) - mocker.patch("tokendito.okta.api_wrapper", return_value=mfa_verify) + mocker.patch.object(HTTP_client, "post", return_value=mock_response) mocker.patch("tokendito.okta.totp_approval", return_value=mfa_verify) with pytest.raises(SystemExit) as error: @@ -149,37 +163,6 @@ def test_bad_mfa_provider_type(mocker, sample_headers): ) -def test_api_wrapper(): - """Test whether verify_api_method returns the correct data.""" - from tokendito.okta import api_wrapper - - url = "https://acme.org" - with requests_mock.Mocker() as m: - data = {"response": "ok"} - m.post(url, json=data, status_code=200) - assert api_wrapper(url, data) == data - - with pytest.raises(SystemExit) as error, requests_mock.Mocker() as m: - data = None - m.post(url, json=data, status_code=200) - assert api_wrapper(url, data) == error - - with pytest.raises(SystemExit) as error, requests_mock.Mocker() as m: - data = {"response": "ok", "errorCode": "0xdeadbeef"} - m.post(url, json=data, status_code=200) - assert api_wrapper(url, data) == error - - with pytest.raises(SystemExit) as error, requests_mock.Mocker() as m: - data = "pytest_bad_datatype" - m.post(url, text=data, status_code=403) - assert api_wrapper(url, data) == error - - with pytest.raises(SystemExit) as error, requests_mock.Mocker() as m: - data = {"response": "incorrect", "errorCode": "0xdeadbeef"} - m.post(url, json=data, status_code=403) - assert api_wrapper("http://acme.org", data) == error - - def test_api_error_code_parser(): """Test whether message on specific status equal.""" from tokendito.okta import _status_dict @@ -219,6 +202,7 @@ def test_mfa_index(preset_mfa, output, mocker, sample_json_response): def test_mfa_options(sample_headers, sample_json_response, mocker): """Test handling of MFA approval.""" from tokendito.config import Config + from tokendito.http_client import HTTP_client from tokendito.okta import totp_approval selected_mfa_option = {"factorType": "push"} @@ -227,15 +211,17 @@ def test_mfa_options(sample_headers, sample_json_response, mocker): mfa_challenge_url = "https://pytest" pytest_config = Config(okta={"mfa_response": None}) - # Test that selecting software token returns a session token + mocker.patch("tokendito.user.get_input", return_value="012345") + + mocker.patch.object(HTTP_client, "post", return_value={"sessionToken": "pytest"}) selected_mfa_option = {"factorType": "token:software:totp"} primary_auth["stateToken"] = "pytest" mfa_verify = {"sessionToken": "pytest"} - mocker.patch("tokendito.user.get_input", return_value="012345") - mocker.patch("tokendito.okta.api_wrapper", return_value=mfa_verify) + ret = totp_approval( pytest_config, selected_mfa_option, sample_headers, mfa_challenge_url, payload, primary_auth ) + assert ret == mfa_verify @@ -295,24 +281,25 @@ def test_mfa_challenge_with_no_mfas(sample_headers, sample_json_response): ), ], ) -def test_push_approval(mocker, sample_headers, return_value, side_effect, expected): +def test_push_approval(mocker, return_value, side_effect, expected): """Test push approval.""" from tokendito import okta challenge_url = "https://pytest/api/v1/authn/factors/factorid/verify" + payload = {"some_key": "some_value"} - mocker.patch("tokendito.okta.api_wrapper", return_value=return_value, side_effect=side_effect) - mocker.patch("time.sleep", return_value=0) + mocker.patch.object(HTTP_client, "post", return_value=return_value, side_effect=side_effect) + mocker.patch("time.sleep", return_value=None) if "status" in return_value and return_value["status"] == "SUCCESS": - ret = okta.push_approval(sample_headers, challenge_url, None) + ret = okta.push_approval(challenge_url, payload) assert ret["status"] == "SUCCESS" elif "factorResult" in return_value and return_value["factorResult"] == "WAITING": - ret = okta.push_approval(sample_headers, challenge_url, None) + ret = okta.push_approval(challenge_url, payload) assert ret["status"] == "SUCCESS" else: with pytest.raises(SystemExit) as err: - okta.push_approval(sample_headers, challenge_url, None) + okta.push_approval(challenge_url, payload) assert err.value.code == expected @@ -456,16 +443,19 @@ def test_extract_saml_relaystate(html, expected): def test_get_saml_request(mocker): """Test getting SAML request.""" from tokendito import okta + from tokendito.http_client import HTTP_client - request_wrapper_response = Mock() - auth_properties = {"id": "id", "metadata": "metadata"} - request_wrapper_response.text = ( + mock_response = Mock() + mock_response.text = ( "