Skip to content

Commit

Permalink
Merge pull request #276 from gurock/feat/add-http-proxy
Browse files Browse the repository at this point in the history
Add http proxy support
  • Loading branch information
acuanico-tr-galt authored Oct 4, 2024
2 parents d2c789d + d377a06 commit e2fad05
Show file tree
Hide file tree
Showing 10 changed files with 309 additions and 12 deletions.
16 changes: 16 additions & 0 deletions CHANGELOG.MD
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ This project adheres to [Semantic Versioning](https://semver.org/). Version numb
- **MINOR**: New features that are backward-compatible.
- **PATCH**: Bug fixes or minor changes that do not affect backward compatibility.

## [1.9.8]

_released 10-04-2024

### Fixed
- Add Run description bug getting wiped; Fixes issue #250
### Added
- NEW HTTP Proxy feature facility!

## [1.9.7]

_released 09-02-2024

### Fixed
- Fix a dependency issue on pyserde, reverted back to previous version in the 0.12.* series with less stricter type enforcement. Fixes #266 and #267.

## [1.9.6]

_released 08-30-2024
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ trcli
```
You should get something like this:
```
TestRail CLI v1.9.7
TestRail CLI v1.9.8
Copyright 2024 Gurock Software GmbH - www.gurock.com
Supported and loaded modules:
- parse_junit: JUnit XML Files (& Similar)
Expand All @@ -45,7 +45,7 @@ CLI general reference
--------
```shell
$ trcli --help
TestRail CLI v1.9.7
TestRail CLI v1.9.8
Copyright 2024 Gurock Software GmbH - www.gurock.com
Usage: trcli [OPTIONS] COMMAND [ARGS]...

Expand All @@ -69,6 +69,12 @@ Options:
-y, --yes answer 'yes' to all prompts around auto-creation
-n, --no answer 'no' to all prompts around auto-creation
-s, --silent Silence stdout
--proxy Proxy address and port (e.g.,
http://proxy.example.com:8080).
--proxy-user Proxy username and password in the format
'username:password'.
--noproxy Comma-separated list of hostnames to bypass the proxy
(e.g., localhost,127.0.0.1).
--help Show this message and exit.

Commands:
Expand Down Expand Up @@ -258,7 +264,7 @@ will be used to upload all results into the same test run.
### Reference
```shell
$ trcli add_run --help
TestRail CLI v1.9.7
TestRail CLI v1.9.8
Copyright 2024 Gurock Software GmbH - www.gurock.com
Usage: trcli add_run [OPTIONS]
Expand Down Expand Up @@ -297,7 +303,7 @@ providing you with a solid base of test cases, which you can further expand on T
### Reference
```shell
$ trcli parse_openapi --help
TestRail CLI v1.9.7
TestRail CLI v1.9.8
Copyright 2024 Gurock Software GmbH - www.gurock.com
Usage: trcli parse_openapi [OPTIONS]
Expand Down
1 change: 1 addition & 0 deletions tests/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ markers =
api_handler: tests for api handler
data_provider: tests for data provider
project_based_client: mark a test as a project-based client test.
proxy: test for proxy feature

119 changes: 119 additions & 0 deletions tests/test_api_client_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import pytest
from trcli.constants import FAULT_MAPPING
from trcli.cli import Environment
from requests.exceptions import ProxyError
from trcli.api.api_client import APIClient
from tests.helpers.api_client_helpers import (
TEST_RAIL_URL,
create_url,
check_response,
check_calls_count,
)
from tests.test_data.proxy_test_data import FAKE_PROJECT_DATA, FAKE_PROXY, FAKE_PROXY_USER, PROXY_ERROR_MESSAGE


@pytest.fixture(scope="class")
def api_resources_maker():
def _make_api_resources(retries=3, environment=None, timeout=30, proxy=None, proxy_user=None, noproxy=None):
if environment is None:
environment = Environment()
environment.verbose = False
api_client = APIClient(
host_name=TEST_RAIL_URL,
verbose_logging_function=environment.vlog,
logging_function=environment.log,
retries=retries,
timeout=timeout,
proxy=proxy,
proxy_user=proxy_user,
noproxy=noproxy, # For bypassing proxy when using --noproxy
)
return api_client

return _make_api_resources


@pytest.fixture(scope="class")
def api_resources(api_resources_maker):
yield api_resources_maker()


class TestAPIClientProxy:
@pytest.mark.proxy
def test_send_get_with_proxy(self, api_resources_maker, requests_mock):
"""Test send_get works correctly with a proxy."""
requests_mock.get(create_url("get_projects"), status_code=200, json=FAKE_PROJECT_DATA)
api_client = api_resources_maker(proxy=FAKE_PROXY)

response = api_client.send_get("get_projects")

check_calls_count(requests_mock)
check_response(200, FAKE_PROJECT_DATA, "", response)

@pytest.mark.proxy
def test_send_post_with_proxy(self, api_resources_maker, requests_mock):
"""Test send_post works correctly with a proxy."""
requests_mock.post(create_url("add_project"), status_code=201, json=FAKE_PROJECT_DATA)
api_client = api_resources_maker(proxy=FAKE_PROXY)

response = api_client.send_post("add_project", FAKE_PROJECT_DATA)

check_calls_count(requests_mock)
check_response(201, FAKE_PROJECT_DATA, "", response)

@pytest.mark.proxy
def test_send_get_with_proxy_authentication(self, api_resources_maker, requests_mock, mocker):
"""Test proxy with authentication (proxy_user)."""
requests_mock.get(create_url("get_projects"), status_code=200, json=FAKE_PROJECT_DATA)
basic_auth_mock = mocker.patch("trcli.api.api_client.b64encode")

api_client = api_resources_maker(proxy=FAKE_PROXY, proxy_user=FAKE_PROXY_USER)
_ = api_client.send_get("get_projects")

basic_auth_mock.assert_called_once_with(FAKE_PROXY_USER.encode('utf-8'))

@pytest.mark.proxy
def test_send_get_proxy_error(self, api_resources_maker, requests_mock):
"""Test handling a proxy authentication failure."""
requests_mock.get(create_url("get_projects"), exc=ProxyError)

api_client = api_resources_maker(proxy=FAKE_PROXY)

response = api_client.send_get("get_projects")

check_calls_count(requests_mock)
check_response(-1, "", PROXY_ERROR_MESSAGE, response)

@pytest.mark.proxy
def test_send_get_no_proxy(self, api_resources_maker, requests_mock):
"""Test API request without a proxy (no --proxy provided)."""
requests_mock.get(create_url("get_projects"), status_code=200, json=FAKE_PROJECT_DATA)
api_client = api_resources_maker()

response = api_client.send_get("get_projects")

check_calls_count(requests_mock)
check_response(200, FAKE_PROJECT_DATA, "", response)

@pytest.mark.proxy
def test_send_get_bypass_proxy(self, api_resources_maker, requests_mock, mocker):
"""Test that proxy is bypassed for certain hosts using --noproxy option."""
requests_mock.get(create_url("get_projects"), status_code=200, json=FAKE_PROJECT_DATA)
proxy_bypass_mock = mocker.patch("trcli.api.api_client.APIClient._get_proxies_for_request", return_value=None)

api_client = api_resources_maker(proxy=FAKE_PROXY, noproxy="127.0.0.1")
_ = api_client.send_get("get_projects")

proxy_bypass_mock.assert_called_once()

@pytest.mark.proxy
def test_send_get_with_invalid_proxy_user(self, api_resources_maker, requests_mock, mocker):
"""Test handling invalid proxy authentication."""
requests_mock.get(create_url("get_projects"), exc=ProxyError)

api_client = api_resources_maker(proxy=FAKE_PROXY, proxy_user="invalid_user:invalid_password")

response = api_client.send_get("get_projects")

check_calls_count(requests_mock)
check_response(-1, "", PROXY_ERROR_MESSAGE, response)
21 changes: 21 additions & 0 deletions tests/test_data/proxy_test_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from trcli.settings import DEFAULT_API_CALL_TIMEOUT

FAKE_PROJECT_DATA = {"fake_project_data": "data"}
INVALID_TEST_CASE_ERROR = {"error": "Invalid or unknown test case"}
API_RATE_LIMIT_REACHED_ERROR = {"error": "API rate limit reached"}
NO_PERMISSION_PROJECT_ERROR = {
"error": "No permissions to add projects (requires admin rights)"
}
TIMEOUT_PARSE_ERROR = (
f"Warning. Could not convert provided 'timeout' to float. "
f"Please make sure that timeout format is correct. Setting to default: "
f"{DEFAULT_API_CALL_TIMEOUT}"
)

#proxy test data
FAKE_PROXY = "http://127.0.0.1:8080"
FAKE_PROXY_USER = "username:password"

PROXY_ERROR_MESSAGE = (
f"Failed to connect to the proxy server. Please check the proxy settings and ensure the server is available."
)
2 changes: 1 addition & 1 deletion trcli/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.9.7"
__version__ = "1.9.8"
100 changes: 96 additions & 4 deletions trcli/api/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import requests
from beartype.typing import Union, Callable, Dict, List
from time import sleep
from base64 import b64encode

import urllib3
from urllib.parse import urlparse
from requests.auth import HTTPBasicAuth
from json import JSONDecodeError
from requests.exceptions import RequestException, Timeout, ConnectionError
from requests.exceptions import RequestException, Timeout, ConnectionError, ProxyError, SSLError, InvalidProxyURL
from trcli.constants import FAULT_MAPPING
from trcli.settings import DEFAULT_API_CALL_TIMEOUT, DEFAULT_API_CALL_RETRIES
from dataclasses import dataclass
Expand Down Expand Up @@ -45,6 +47,9 @@ def __init__(
retries: int = DEFAULT_API_CALL_RETRIES,
timeout: int = DEFAULT_API_CALL_TIMEOUT,
verify: bool = True,
proxy: str = None, #added proxy params
proxy_user: str = None,
noproxy: str = None,
):
self.username = ""
self.password = ""
Expand All @@ -55,6 +60,10 @@ def __init__(
self.verbose_logging_function = verbose_logging_function
self.logging_function = logging_function
self.__validate_and_set_timeout(timeout)
self.proxy = proxy
self.proxy_user = proxy_user
self.noproxy = noproxy.split(',') if noproxy else []

if not host_name.endswith("/"):
host_name = host_name + "/"
self.__url = host_name + self.SUFFIX_API_V2_VERSION
Expand All @@ -71,7 +80,7 @@ def send_get(self, uri: str) -> APIClientResult:
"""
return self.__send_request("GET", uri, None)

def send_post(self, uri: str, payload: dict = None, files: {str: Path} = None) -> APIClientResult:
def send_post(self, uri: str, payload: dict = None, files: Dict[str, Path] = None) -> APIClientResult:
"""
Sends POST request to host specified by host_name.
Handles retries taking into consideration retries parameter. Retry will occur when one of the following happens:
Expand All @@ -81,17 +90,19 @@ def send_post(self, uri: str, payload: dict = None, files: {str: Path} = None) -
"""
return self.__send_request("POST", uri, payload, files)

def __send_request(self, method: str, uri: str, payload: dict, files: {str: Path} = None) -> APIClientResult:
def __send_request(self, method: str, uri: str, payload: dict, files: Dict[str, Path] = None) -> APIClientResult:
status_code = -1
response_text = ""
error_message = ""
url = self.__url + uri
password = self.__get_password()
auth = HTTPBasicAuth(username=self.username, password=password)
headers = {"User-Agent": self.USER_AGENT}
headers.update(self.__get_proxy_headers())
if files is None:
headers["Content-Type"] = "application/json"
verbose_log_message = ""
proxies = self._get_proxies_for_request(url)
for i in range(self.retries + 1):
error_message = ""
try:
Expand All @@ -107,11 +118,30 @@ def __send_request(self, method: str, uri: str, payload: dict, files: {str: Path
headers=headers,
verify=self.verify,
files=files,
proxies=proxies
)
else:
response = requests.get(
url=url, auth=auth, json=payload, timeout=self.timeout, verify=self.verify, headers=headers
url=url,
auth=auth,
json=payload,
timeout=self.timeout,
verify=self.verify,
headers=headers,
proxies=proxies
)
except InvalidProxyURL:
error_message = FAULT_MAPPING["proxy_invalid_configuration"]
self.verbose_logging_function(verbose_log_message)
break
except ProxyError:
error_message = FAULT_MAPPING["proxy_connection_error"]
self.verbose_logging_function(verbose_log_message)
break
except SSLError:
error_message = FAULT_MAPPING["ssl_error_on_proxy"]
self.verbose_logging_function(verbose_log_message)
break
except Timeout:
error_message = FAULT_MAPPING["no_response_from_host"]
self.verbose_logging_function(verbose_log_message)
Expand Down Expand Up @@ -158,6 +188,68 @@ def __send_request(self, method: str, uri: str, payload: dict, files: {str: Path

return APIClientResult(status_code, response_text, error_message)

def __get_proxy_headers(self) -> Dict[str, str]:
"""
Returns headers for proxy authentication using Basic Authentication if proxy_user is provided.
"""
headers = {}
if self.proxy_user:
user_pass_encoded = b64encode(self.proxy_user.encode('utf-8')).decode('utf-8')

# Add Proxy-Authorization header
headers["Proxy-Authorization"] = f"Basic {user_pass_encoded}"
print(f"Proxy authentication header added: {headers['Proxy-Authorization']}")

return headers

def _get_proxies_for_request(self, url: str) -> Dict[str, str]:
"""
Returns the appropriate proxy dictionary for a given request URL.
Will return None if the URL matches a proxy bypass host.
"""
parsed_url = urlparse(url)
scheme = parsed_url.scheme # The scheme of the target URL (http or https)
host = parsed_url.hostname

# If proxy or noproxy is None, return None, and requests will not use nor bypass a proxy server
if self.proxy is None:
return None

# Bypass the proxy if the host is in the noproxy list
if self.noproxy:
# Ensure noproxy is a list or tuple
if isinstance(self.noproxy, str):
self.noproxy = self.noproxy.split(',')
if host in self.noproxy:
print(f"Bypassing proxy for host: {host}")
return None

# Ensure proxy has a scheme (either http or https)
if self.proxy and not self.proxy.startswith("http://") and not self.proxy.startswith("https://"):
self.proxy = "http://" + self.proxy # Default to http if scheme is missing

#print(f"Parsed URL: {url}, Proxy: {self.proxy} , NoProxy: {self.noproxy}")

# Define the proxy dictionary
proxy_dict = {}
if self.proxy:
# Use HTTP proxy for both HTTP and HTTPS traffic
if self.proxy.startswith("http://"):
proxy_dict = {
"http": self.proxy, # Use HTTP proxy for HTTP traffic
"https": self.proxy # Also use HTTP proxy for HTTPS traffic
}
else:
# If the proxy is HTTPS, route accordingly
proxy_dict = {
scheme: self.proxy # Match the proxy scheme with the target URL scheme
}

#print(f"Using proxy: {proxy_dict}")
return proxy_dict

return None

def __get_password(self) -> str:
"""Based on what is set, choose to use api_key or password as authentication method"""
if self.api_key:
Expand Down
Loading

0 comments on commit e2fad05

Please sign in to comment.