diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 37d08c0..8f30560 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -12,6 +12,9 @@ jobs: matrix: os: [ ubuntu-latest, macos-latest, windows-latest ] python-version: [ 3.8, 3.12 ] + # test with robot without and with Secret support? Not sure if + # it is worth it? + robot-version: [ 7.3.2, 7.4b1 ] steps: - uses: actions/checkout@v4 - name: Set up Python @@ -23,6 +26,7 @@ jobs: - name: Install dependencies run: | python -m pip install -e .[test] + python -m pip install robotframework==${{ matrix.robot-version }} - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names @@ -59,5 +63,5 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v4 with: - name: rf-tests-report-${{ matrix.os }}-${{ matrix.python-version }} + name: rf-tests-report-${{ matrix.os }}-${{ matrix.python-version }}-${{ matrix.robot-version }} path: ./tests-report diff --git a/.gitignore b/.gitignore index 8c500ab..955b6f1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ env/* # ignore http server log atests/http_server/http_server.log +.claude/ diff --git a/atests/secretvar.py b/atests/secretvar.py new file mode 100644 index 0000000..9ec8e4d --- /dev/null +++ b/atests/secretvar.py @@ -0,0 +1,7 @@ +# inject secret into robot suite.. doing this via python +# to ensure this can also run in older robot versions +try: + from robot.api.types import Secret + SECRET_PASSWORD = Secret("passwd") +except (ImportError, ModuleNotFoundError): + SECRET_PASSWORD = "not-supported" diff --git a/atests/test_authentication.robot b/atests/test_authentication.robot index 8bc7e4e..56613fd 100644 --- a/atests/test_authentication.robot +++ b/atests/test_authentication.robot @@ -1,7 +1,7 @@ *** Settings *** Library RequestsLibrary Library customAuthenticator.py - +Variables secretvar.py *** Test Cases *** Get With Auth @@ -32,3 +32,47 @@ Get With Digest Auth ${resp}= GET On Session httpbin /digest-auth/auth/user/pass Should Be Equal As Strings ${resp.status_code} 200 Should Be Equal As Strings ${resp.json()['authenticated']} True + +Get With Auth with Robot Secrets + [Tags] robot-74 get get-cert + Skip If $SECRET_PASSWORD == "not-supported" + ... msg=robot version does not support secrets + ${auth}= Create List user ${SECRET_PASSWORD} + Create Session httpbin https://httpbin.org auth=${auth} verify=${CURDIR}${/}cacert.pem + ${resp}= GET On Session httpbin /basic-auth/user/passwd + Should Be Equal As Strings ${resp.status_code} 200 + Should Be Equal As Strings ${resp.json()['authenticated']} True + +Get With Digest Auth with Robot Secrets + [Tags] robot-74 get get-cert + Skip If $SECRET_PASSWORD == "not-supported" + ... msg=robot version does not support secrets + ${auth}= Create List user ${SECRET_PASSWORD} + Create Digest Session + ... httpbin + ... https://httpbin.org + ... auth=${auth} + ... debug=3 + ... verify=${CURDIR}${/}cacert.pem + ${resp}= GET On Session httpbin /digest-auth/auth/user/passwd + Should Be Equal As Strings ${resp.status_code} 200 + Should Be Equal As Strings ${resp.json()['authenticated']} True + +Session-less GET With Auth with Robot Secrets + [Tags] robot-74 get get-cert session-less + Skip If $SECRET_PASSWORD == "not-supported" + ... msg=robot version does not support secrets + ${auth}= Create List user ${SECRET_PASSWORD} + ${resp}= GET https://httpbin.org/basic-auth/user/passwd auth=${auth} verify=${CURDIR}${/}cacert.pem + Should Be Equal As Strings ${resp.status_code} 200 + Should Be Equal As Strings ${resp.json()['authenticated']} True + +Session-less POST With Auth with Robot Secrets + [Tags] robot-74 post post-cert session-less + Skip If $SECRET_PASSWORD == "not-supported" + ... msg=robot version does not support secrets + ${auth}= Create List user ${SECRET_PASSWORD} + ${data}= Create Dictionary test=data + ${resp}= POST https://httpbin.org/post json=${data} auth=${auth} verify=${CURDIR}${/}cacert.pem + Should Be Equal As Strings ${resp.status_code} 200 + Should Be Equal As Strings ${resp.json()['json']['test']} data diff --git a/src/RequestsLibrary/RequestsKeywords.py b/src/RequestsLibrary/RequestsKeywords.py index c74bf83..5ef6ed6 100644 --- a/src/RequestsLibrary/RequestsKeywords.py +++ b/src/RequestsLibrary/RequestsKeywords.py @@ -8,6 +8,7 @@ from RequestsLibrary.utils import ( is_list_or_tuple, is_file_descriptor, + process_secrets, warn_if_equal_symbol_in_url_session_less, ) @@ -30,6 +31,11 @@ def _common_request(self, method, session, uri, **kwargs): else: request_function = getattr(requests, "request") + # Process robot's Secret types included in auth + auth = kwargs.get("auth") + if auth is not None and isinstance(auth, (list, tuple)): + kwargs["auth"] = process_secrets(auth) + self._capture_output() resp = request_function( @@ -59,7 +65,7 @@ def _close_file_descriptors(files, data): """ Helper method that closes any open file descriptors. """ - + if is_list_or_tuple(files): files_descriptor_to_close = filter( is_file_descriptor, [file[1][1] for file in files] + [data] @@ -68,10 +74,10 @@ def _close_file_descriptors(files, data): files_descriptor_to_close = filter( is_file_descriptor, list(files.values()) + [data] ) - + for file_descriptor in files_descriptor_to_close: file_descriptor.close() - + @staticmethod def _merge_url(session, uri): """ diff --git a/src/RequestsLibrary/SessionKeywords.py b/src/RequestsLibrary/SessionKeywords.py index 0b19cb7..be8c472 100644 --- a/src/RequestsLibrary/SessionKeywords.py +++ b/src/RequestsLibrary/SessionKeywords.py @@ -12,7 +12,7 @@ from RequestsLibrary import utils from RequestsLibrary.compat import RetryAdapter, httplib from RequestsLibrary.exceptions import InvalidExpectedStatus, InvalidResponse -from RequestsLibrary.utils import is_string_type +from RequestsLibrary.utils import is_string_type, process_secrets from .RequestsKeywords import RequestsKeywords @@ -172,7 +172,7 @@ def create_session( Note that max_retries must be greater than 0. """ - auth = requests.auth.HTTPBasicAuth(*auth) if auth else None + auth = requests.auth.HTTPBasicAuth(*process_secrets(auth)) if auth else None logger.info( "Creating Session using : alias=%s, url=%s, headers=%s, \ @@ -262,7 +262,7 @@ def create_client_cert_session( eg. set to [502, 503] to retry requests if those status are returned. Note that max_retries must be greater than 0. """ - auth = requests.auth.HTTPBasicAuth(*auth) if auth else None + auth = requests.auth.HTTPBasicAuth(*process_secrets(auth)) if auth else None logger.info( "Creating Session using : alias=%s, url=%s, headers=%s, \ @@ -452,7 +452,7 @@ def create_digest_session( eg. set to [502, 503] to retry requests if those status are returned. Note that max_retries must be greater than 0. """ - digest_auth = requests.auth.HTTPDigestAuth(*auth) if auth else None + digest_auth = requests.auth.HTTPDigestAuth(*process_secrets(auth)) if auth else None return self._create_session( alias=alias, @@ -543,6 +543,7 @@ def create_ntlm_session( " - expected 3, got {}".format(len(auth)) ) else: + auth = process_secrets(auth) ntlm_auth = HttpNtlmAuth("{}\\{}".format(auth[0], auth[1]), auth[2]) logger.info( "Creating NTLM Session using : alias=%s, url=%s, \ diff --git a/src/RequestsLibrary/utils.py b/src/RequestsLibrary/utils.py index 78242b0..af3a165 100644 --- a/src/RequestsLibrary/utils.py +++ b/src/RequestsLibrary/utils.py @@ -5,6 +5,10 @@ from requests.status_codes import codes from requests.structures import CaseInsensitiveDict from robot.api import logger +try: + from robot.api.types import Secret +except (ImportError, ModuleNotFoundError): + pass from RequestsLibrary.compat import urlencode from RequestsLibrary.exceptions import UnknownStatusError @@ -74,9 +78,27 @@ def is_string_type(data): def is_file_descriptor(fd): return isinstance(fd, io.IOBase) + def is_list_or_tuple(data): return isinstance(data, (list, tuple)) + +def process_secrets(auth): + """ + Process robot's Secret types in auth tuples by extracting their values. + """ + try: + Secret + except NameError: + new_auth = auth + else: + new_auth = tuple( + a.value if isinstance(a, Secret) else a + for a in auth + ) + return new_auth + + def utf8_urlencode(data): if is_string_type(data): return data.encode("utf-8") diff --git a/utests/test_utils.py b/utests/test_utils.py index 0ae280d..1042677 100644 --- a/utests/test_utils.py +++ b/utests/test_utils.py @@ -4,10 +4,16 @@ from requests import Session from RequestsLibrary import RequestsLibrary -from RequestsLibrary.utils import is_file_descriptor, merge_headers +from RequestsLibrary.utils import is_file_descriptor, merge_headers, process_secrets from utests import SCRIPT_DIR from utests import mock +try: + from robot.api.types import Secret + secret_type_supported = True +except (ImportError, ModuleNotFoundError): + secret_type_supported = False + def test_none(): assert is_file_descriptor(None) is False @@ -72,3 +78,29 @@ def test_warn_that_url_is_missing(mocked_logger, mocked_keywords): except TypeError: pass mocked_logger.warn.assert_called() + + +def test_process_secrets_with_no_secrets(): + auth = ('user', 'password') + result = process_secrets(auth) + assert result == ('user', 'password') + + +@pytest.mark.skipif(not secret_type_supported, reason="Running on pre-7.4 robot") +def test_process_secrets_with_secrets(): + secret_password = Secret('mypassword') + auth = ('user', secret_password) + result = process_secrets(auth) + assert result == ('user', 'mypassword') + assert not isinstance(result[1], Secret) + + +@pytest.mark.skipif(not secret_type_supported, reason="Running on pre-7.4 robot") +def test_process_secrets_with_mixed_secrets(): + secret_user = Secret('myuser') + secret_password = Secret('mypassword') + auth = (secret_user, secret_password) + result = process_secrets(auth) + assert result == ('myuser', 'mypassword') + assert not isinstance(result[0], Secret) + assert not isinstance(result[1], Secret)