From a5965668999a77fb31856c5a139e7043bfc45479 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Thu, 3 Apr 2025 13:42:53 +0200 Subject: [PATCH 01/18] Add: Support for the openvasd HTTP API A new subpackage for sending requests to HTTP APIs has been added which also includes the first version of the openvasd API. --- gvm/http/__init__.py | 3 + gvm/http/core/__init__.py | 3 + gvm/http/core/api.py | 28 ++ gvm/http/core/connector.py | 154 ++++++++++ gvm/http/core/headers.py | 53 ++++ gvm/http/core/response.py | 45 +++ gvm/http/openvasd/__init__.py | 3 + gvm/http/openvasd/openvasd1.py | 333 ++++++++++++++++++++++ poetry.lock | 16 +- pyproject.toml | 1 + tests/http/__init__.py | 3 + tests/http/core/__init__.py | 3 + tests/http/core/test_api.py | 23 ++ tests/http/core/test_connector.py | 394 ++++++++++++++++++++++++++ tests/http/core/test_headers.py | 40 +++ tests/http/core/test_response.py | 60 ++++ tests/http/openvasd/__init__.py | 3 + tests/http/openvasd/test_openvasd1.py | 344 ++++++++++++++++++++++ 18 files changed, 1501 insertions(+), 8 deletions(-) create mode 100644 gvm/http/__init__.py create mode 100644 gvm/http/core/__init__.py create mode 100644 gvm/http/core/api.py create mode 100644 gvm/http/core/connector.py create mode 100644 gvm/http/core/headers.py create mode 100644 gvm/http/core/response.py create mode 100644 gvm/http/openvasd/__init__.py create mode 100644 gvm/http/openvasd/openvasd1.py create mode 100644 tests/http/__init__.py create mode 100644 tests/http/core/__init__.py create mode 100644 tests/http/core/test_api.py create mode 100644 tests/http/core/test_connector.py create mode 100644 tests/http/core/test_headers.py create mode 100644 tests/http/core/test_response.py create mode 100644 tests/http/openvasd/__init__.py create mode 100644 tests/http/openvasd/test_openvasd1.py diff --git a/gvm/http/__init__.py b/gvm/http/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/gvm/http/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/gvm/http/core/__init__.py b/gvm/http/core/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/gvm/http/core/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/gvm/http/core/api.py b/gvm/http/core/api.py new file mode 100644 index 00000000..4ed4a15f --- /dev/null +++ b/gvm/http/core/api.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Optional + +from gvm.http.core.connector import HttpApiConnector + + +class GvmHttpApi: + """ + Base class for HTTP-based GVM APIs. + """ + + def __init__(self, connector: HttpApiConnector, *, api_key: Optional[str] = None): + """ + Create a new generic GVM HTTP API instance. + + Args: + connector: The connector handling the HTTP(S) connection + api_key: Optional API key for authentication + """ + + "The connector handling the HTTP(S) connection" + self._connector: HttpApiConnector = connector + + "Optional API key for authentication" + self._api_key: Optional[str] = api_key diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py new file mode 100644 index 00000000..20bf2270 --- /dev/null +++ b/gvm/http/core/connector.py @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import urllib.parse +from typing import Optional, Tuple, Dict, Any + +from requests import Session + +from gvm.http.core.response import HttpResponse + + +def url_join(base: str, rel_path: str) -> str: + """ + Combines a base URL and a relative path into one URL. + + Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it + ends with "/". + """ + if base.endswith("/"): + return urllib.parse.urljoin(base, rel_path) + else: + return urllib.parse.urljoin(base + "/", rel_path) + + +class HttpApiConnector: + """ + Class for connecting to HTTP based API servers, sending requests and receiving the responses. + """ + + @classmethod + def _new_session(cls): + """ + Creates a new session + """ + return Session() + + def __init__( + self, + base_url: str, + *, + server_ca_path: Optional[str] = None, + client_cert_paths: Optional[str | Tuple[str]] = None, + ): + """ + Create a new HTTP API Connector. + + Args: + base_url: The base server URL to which request-specific paths will be appended for the requests + server_ca_path: Optional path to a CA certificate for verifying the server. + If none is given, server verification is disabled. + client_cert_paths: Optional path to a client private key and certificate for authentication. + Can be a combined key and certificate file or a tuple containing separate files. + The key must not be encrypted. + """ + + self.base_url = base_url + "The base server URL to which request-specific paths will be appended for the requests" + + self._session = self._new_session() + "Internal session handling the HTTP requests" + if server_ca_path: + self._session.verify = server_ca_path + if client_cert_paths: + self._session.cert = client_cert_paths + + def update_headers(self, new_headers: Dict[str, str]) -> None: + """ + Updates the headers sent with each request, e.g. for passing an API key + + Args: + new_headers: Dict containing the new headers + """ + self._session.headers.update(new_headers) + + def delete( + self, + rel_path: str, + *, + raise_for_status: bool = True, + params: Optional[Dict[str,str]] = None, + headers: Optional[Dict[str,str]] = None, + ) -> HttpResponse: + """ + Sends a ``DELETE`` request and returns the response. + + Args: + rel_path: The relative path for the request + raise_for_status: Whether to raise an error if response has a non-success HTTP status code + params: Optional dict of URL-encoded parameters + headers: Optional additional headers added to the request + + Return: + The HTTP response. + """ + url = url_join(self.base_url, rel_path) + r = self._session.delete(url, params=params, headers=headers) + if raise_for_status: + r.raise_for_status() + return HttpResponse.from_requests_lib(r) + + def get( + self, + rel_path: str, + *, + raise_for_status: bool = True, + params: Optional[Dict[str,str]] = None, + headers: Optional[Dict[str,str]] = None, + ) -> HttpResponse: + """ + Sends a ``GET`` request and returns the response. + + Args: + rel_path: The relative path for the request + raise_for_status: Whether to raise an error if response has a non-success HTTP status code + params: Optional dict of URL-encoded parameters + headers: Optional additional headers added to the request + + Return: + The HTTP response. + """ + url = url_join(self.base_url, rel_path) + r = self._session.get(url, params=params, headers=headers) + if raise_for_status: + r.raise_for_status() + return HttpResponse.from_requests_lib(r) + + def post_json( + self, + rel_path: str, + json: Any, + *, + raise_for_status: bool = True, + params: Optional[Dict[str, str]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> HttpResponse: + """ + Sends a ``POST`` request, using the given JSON-compatible object as the request body, and returns the response. + + Args: + rel_path: The relative path for the request + json: The object to use as the request body. + raise_for_status: Whether to raise an error if response has a non-success HTTP status code + params: Optional dict of URL-encoded parameters + headers: Optional additional headers added to the request + + Return: + The HTTP response. + """ + url = url_join(self.base_url, rel_path) + r = self._session.post(url, json=json, params=params, headers=headers) + if raise_for_status: + r.raise_for_status() + return HttpResponse.from_requests_lib(r) diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py new file mode 100644 index 00000000..1b5993f2 --- /dev/null +++ b/gvm/http/core/headers.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +from dataclasses import dataclass +from typing import Self, Dict, Optional + + +@dataclass +class ContentType: + """ + Class representing the content type of a HTTP response. + """ + + media_type: str + "The MIME media type, e.g. \"application/json\"" + + params: Dict[str,str] + "Dictionary of parameters in the content type header" + + charset: Optional[str] + "The charset parameter in the content type header if it is set" + + @classmethod + def from_string( + cls, + header_string: str, + fallback_media_type: Optional[str] = "application/octet-stream" + ) -> Self: + """ + Parse the content of content type header into a ContentType object. + + Args: + header_string: The string to parse + fallback_media_type: The media type to use if the `header_string` is `None` or empty. + """ + media_type = fallback_media_type + params = {} + charset = None + + if header_string: + parts = header_string.split(";") + media_type = parts[0].strip() + for param in parts[1:]: + param = param.strip() + if "=" in param: + key, value = map(lambda x: x.strip(), param.split("=", 1)) + params[key] = value + if key == 'charset': + charset = value + else: + params[param] = True + + return ContentType(media_type=media_type, params=params, charset=charset) diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py new file mode 100644 index 00000000..ffb6da64 --- /dev/null +++ b/gvm/http/core/response.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +from dataclasses import dataclass +from typing import Any, Dict, Self, Optional +from requests import Request, Response + +from gvm.http.core.headers import ContentType + + +@dataclass +class HttpResponse: + """ + Class representing an HTTP response. + """ + body: Any + status: int + headers: Dict[str, str] + content_type: Optional[ContentType] + + @classmethod + def from_requests_lib(cls, r: Response) -> Self: + """ + Creates a new HTTP response object from a Request object created by the "Requests" library. + + Args: + r: The request object to convert. + + Return: + A HttpResponse object representing the response. + + An empty body is represented by None. + If the content-type header in the response is set to 'application/json'. + A non-empty body will be parsed accordingly. + """ + ct = ContentType.from_string(r.headers.get('content-type')) + body = r.content + + if r.content == b'': + body = None + elif ct is not None: + if ct.media_type.lower() == 'application/json': + body = r.json() + + return HttpResponse(body, r.status_code, r.headers, ct) diff --git a/gvm/http/openvasd/__init__.py b/gvm/http/openvasd/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/gvm/http/openvasd/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py new file mode 100644 index 00000000..c69cc957 --- /dev/null +++ b/gvm/http/openvasd/openvasd1.py @@ -0,0 +1,333 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import urllib.parse +from typing import Optional, Any + +from gvm.errors import InvalidArgumentType + +from gvm.http.core.api import GvmHttpApi +from gvm.http.core.connector import HttpApiConnector +from gvm.http.core.response import HttpResponse + + +class OpenvasdHttpApiV1(GvmHttpApi): + """ + Class for sending requests to a version 1 openvasd API. + """ + + def __init__( + self, + connector: HttpApiConnector, + *, + api_key: Optional[str] = None, + ): + """ + Create a new openvasd HTTP API instance. + + Args: + connector: The connector handling the HTTP(S) connection + api_key: Optional API key for authentication + default_raise_for_status: whether to raise an exception if HTTP status is not a success + """ + super().__init__(connector, api_key=api_key) + if api_key: + connector.update_headers({"X-API-KEY": api_key}) + + def get_health_alive(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the "alive" health status of the scanner. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /health/alive in the openvasd API documentation. + """ + return self._connector.get("/health/alive", raise_for_status=raise_for_status) + + def get_health_ready(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the "ready" health status of the scanner. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /health/ready in the openvasd API documentation. + """ + return self._connector.get("/health/ready", raise_for_status=raise_for_status) + + def get_health_started(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the "started" health status of the scanner. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /health/started in the openvasd API documentation. + """ + return self._connector.get("/health/started", raise_for_status=raise_for_status) + + def get_notus_os_list(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the list of operating systems available in Notus. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /notus in the openvasd API documentation. + """ + return self._connector.get("/notus", raise_for_status=raise_for_status) + + def run_notus_scan(self, os: str, package_list: list[str], *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the Notus results for a given operating system and list of packages. + + Args: + os: Name of the operating system as returned in the list returned by get_notus_os_products. + package_list: List of package names to check. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /notus/{os} in the openvasd API documentation. + """ + quoted_os = urllib.parse.quote(os) + return self._connector.post_json(f"/notus/{quoted_os}", package_list, raise_for_status=raise_for_status) + + def get_scan_preferences(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the list of available scan preferences. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans/preferences in the openvasd API documentation. + """ + return self._connector.get("/scans/preferences", raise_for_status=raise_for_status) + + def create_scan( + self, + target: dict[str, Any], + vt_selection: dict[str, Any], + scanner_params: Optional[dict[str, Any]] = None, + *, + raise_for_status: bool = False + ) -> HttpResponse: + """ + Creates a new scan without starting it. + + See POST /scans in the openvasd API documentation for the expected format of the parameters. + + Args: + target: The target definition for the scan. + vt_selection: The VT selection for the scan, including VT preferences. + scanner_params: The optional scanner parameters. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans in the openvasd API documentation. + """ + request_json: dict = { + "target": target, + "vts": vt_selection, + } + if scanner_params: + request_json["scan_preferences"] = scanner_params + return self._connector.post_json("/scans", request_json, raise_for_status=raise_for_status) + + def delete_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Deletes a scan with the given id. + + Args: + scan_id: The id of the scan to perform the action on. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + return self._connector.delete(f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status) + + def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the list of available scans. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans in the openvasd API documentation. + """ + return self._connector.get("/scans", raise_for_status=raise_for_status) + + def get_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets a scan with the given id. + + Args: + scan_id: The id of the scan to get. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans/{id} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + return self._connector.get(f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status) + + def get_scan_results( + self, + scan_id: str, + range_start: Optional[int] = None, + range_end: Optional[int] = None, + *, + raise_for_status: bool = False + ) -> HttpResponse: + """ + Gets results of a scan with the given id. + + Args: + scan_id: The id of the scan to get the results of. + range_start: Optional index of the first result to get. + range_end: Optional index of the last result to get. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans/{id}/results in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + params = {} + if range_start is not None: + if not isinstance(range_start, int): + raise InvalidArgumentType(argument="range_start", function=self.get_scan_results.__name__) + + if range_end is not None: + if not isinstance(range_end, int): + raise InvalidArgumentType(argument="range_end", function=self.get_scan_results.__name__) + params["range"] = f"{range_start}-{range_end}" + else: + params["range"] = str(range_start) + else: + if range_end is not None: + if not isinstance(range_end, int): + raise InvalidArgumentType(argument="range_end", function=self.get_scan_results.__name__) + params["range"] = f"0-{range_end}" + + return self._connector.get( + f"/scans/{quoted_scan_id}/results", + params=params, + raise_for_status=raise_for_status + ) + + def get_scan_result( + self, + scan_id: str, + result_id: str|int, + *, + raise_for_status: bool = False + ) -> HttpResponse: + """ + Gets a single result of a scan with the given id. + + Args: + scan_id: The id of the scan to get the results of. + result_id: The id of the result to get. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + quoted_result_id = urllib.parse.quote(str(result_id)) + + return self._connector.get( + f"/scans/{quoted_scan_id}/results/{quoted_result_id}", + raise_for_status=raise_for_status + ) + + def get_scan_status(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets a scan with the given id. + + Args: + scan_id: The id of the scan to get the status of. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + return self._connector.get(f"/scans/{quoted_scan_id}/status", raise_for_status=raise_for_status) + + def run_scan_action(self, scan_id: str, scan_action: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Performs an action like starting or stopping on a scan with the given id. + + Args: + scan_id: The id of the scan to perform the action on. + scan_action: The action to perform. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans/{id} in the openvasd API documentation. + """ + quoted_scan_id = urllib.parse.quote(scan_id) + action_json = { "action": scan_action } + return self._connector.post_json(f"/scans/{quoted_scan_id}", action_json, raise_for_status=raise_for_status) + + def start_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Starts a scan with the given id. + + Args: + scan_id: The id of the scan to perform the action on. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans/{id} in the openvasd API documentation. + """ + return self.run_scan_action(scan_id, "start", raise_for_status=raise_for_status) + + def stop_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Stops a scan with the given id. + + Args: + scan_id: The id of the scan to perform the action on. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See POST /scans/{id} in the openvasd API documentation. + """ + return self.run_scan_action(scan_id, "stop", raise_for_status=raise_for_status) + + def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets a list of available vulnerability tests (VTs) on the scanner. + + Args: + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See GET /vts in the openvasd API documentation. + """ + return self._connector.get("/vts", raise_for_status=raise_for_status) + + def get_vt(self, oid: str, *, raise_for_status: bool = False) -> HttpResponse: + """ + Gets the details of a vulnerability test (VT). + + Args: + oid: OID of the VT to get. + raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + + Return: + The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. + """ + quoted_oid = urllib.parse.quote(oid) + return self._connector.get(f"/vts/{quoted_oid}", raise_for_status=raise_for_status) diff --git a/poetry.lock b/poetry.lock index 3b708107..4d708212 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "alabaster" @@ -257,7 +257,7 @@ version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, @@ -350,7 +350,7 @@ version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, @@ -468,7 +468,7 @@ description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["dev"] -markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" or platform_system == \"Windows\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -795,7 +795,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1365,7 +1365,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -1747,7 +1747,7 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, @@ -1783,4 +1783,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9.2" -content-hash = "95612b9c0fab79c11650ea68c99433fb4927a139be5bd0202bd246979d70c785" +content-hash = "e2f128bc4bfefbf3dc7e3ab276dd24661b4caf22117449180830cdd8b258cfb0" diff --git a/pyproject.toml b/pyproject.toml index 2ff9c257..4779f4f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ packages = [{ include = "gvm" }, { include = "tests", format = "sdist" }] python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" +requests = "^2.32.3" [tool.poetry.group.dev.dependencies] coverage = ">=7.2" diff --git a/tests/http/__init__.py b/tests/http/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/tests/http/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/http/core/__init__.py b/tests/http/core/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/tests/http/core/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/http/core/test_api.py b/tests/http/core/test_api.py new file mode 100644 index 00000000..ef48c96a --- /dev/null +++ b/tests/http/core/test_api.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from unittest.mock import patch, MagicMock + +from gvm.http.core.api import GvmHttpApi + + +class GvmHttpApiTestCase(unittest.TestCase): + # pylint: disable=protected-access + + @patch('gvm.http.core.connector.HttpApiConnector') + def test_basic_init(self, connector_mock: MagicMock): + api = GvmHttpApi(connector_mock) + self.assertEqual(connector_mock, api._connector) + + @patch('gvm.http.core.connector.HttpApiConnector') + def test_init_with_key(self, connector_mock: MagicMock): + api = GvmHttpApi(connector_mock, api_key="my-api-key") + self.assertEqual(connector_mock, api._connector) + self.assertEqual("my-api-key", api._api_key) diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py new file mode 100644 index 00000000..3f2cfb28 --- /dev/null +++ b/tests/http/core/test_connector.py @@ -0,0 +1,394 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +import json +import unittest +from http import HTTPStatus +from typing import Optional, Any +from unittest.mock import patch, MagicMock, Mock +from requests.exceptions import HTTPError + +from gvm.http.core.connector import HttpApiConnector, url_join +import requests as requests_lib + +from gvm.http.core.headers import ContentType + + +TEST_JSON_HEADERS = { + "content-type": "application/json;charset=utf-8", + "x-example": "some-test-header" +} + +TEST_JSON_CONTENT_TYPE = ContentType( + media_type="application/json", + params={"charset": "utf-8"}, + charset="utf-8", +) + +TEST_EMPTY_CONTENT_TYPE = ContentType( + media_type="application/octet-stream", + params={}, + charset=None, +) + +TEST_JSON_RESPONSE_BODY = {"response_content": True} + +TEST_JSON_REQUEST_BODY = {"request_number": 5} + + +def new_mock_empty_response( + status: Optional[int | HTTPStatus] = None, +) -> requests_lib.Response: + # pylint: disable=protected-access + response = requests_lib.Response() + response._content = b'' + if status is None: + response.status_code = int(HTTPStatus.NO_CONTENT) + else: + response.status_code = int(status) + return response + + +def new_mock_json_response( + content: Optional[Any] = None, + status: Optional[int|HTTPStatus] = None, +)-> requests_lib.Response: + # pylint: disable=protected-access + response = requests_lib.Response() + response._content = json.dumps(content).encode() + + if status is None: + response.status_code = int(HTTPStatus.OK) + else: + response.status_code = int(status) + + response.headers.update(TEST_JSON_HEADERS) + return response + + +def new_mock_session( + *, + headers: Optional[dict] = None +) -> Mock: + mock = Mock(spec=requests_lib.Session) + mock.headers = headers if headers is not None else {} + return mock + + +class HttpApiConnectorTestCase(unittest.TestCase): + # pylint: disable=protected-access + + def test_url_join(self): + self.assertEqual( + "http://localhost/foo/bar/baz", + url_join("http://localhost/foo", "bar/baz") + ) + self.assertEqual( + "http://localhost/foo/bar/baz", + url_join("http://localhost/foo/", "bar/baz") + ) + self.assertEqual( + "http://localhost/foo/bar/baz", + url_join("http://localhost/foo", "./bar/baz") + ) + self.assertEqual( + "http://localhost/foo/bar/baz", + url_join("http://localhost/foo/", "./bar/baz") + ) + self.assertEqual( + "http://localhost/bar/baz", + url_join("http://localhost/foo", "../bar/baz") + ) + self.assertEqual( + "http://localhost/bar/baz", + url_join("http://localhost/foo", "../bar/baz") + ) + + def test_new_session(self): + new_session = HttpApiConnector._new_session() + self.assertIsInstance(new_session, requests_lib.Session) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_basic_init( + self, + new_session_mock: MagicMock + ): + mock_session = new_session_mock.return_value = new_mock_session() + + connector = HttpApiConnector("http://localhost") + + self.assertEqual("http://localhost", connector.base_url) + self.assertEqual(mock_session, connector._session) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_https_init(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + connector = HttpApiConnector( + "https://localhost", + server_ca_path="foo.crt", + client_cert_paths="bar.key" + ) + + self.assertEqual("https://localhost", connector.base_url) + self.assertEqual(mock_session, connector._session) + self.assertEqual("foo.crt", mock_session.verify) + self.assertEqual("bar.key", mock_session.cert) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_update_headers(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + connector = HttpApiConnector( + "http://localhost", + ) + connector.update_headers({"x-foo": "bar"}) + connector.update_headers({"x-baz": "123"}) + + self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_session.headers) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_delete(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.delete.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + connector = HttpApiConnector("https://localhost") + response = connector.delete("foo", params={"bar": "123"}, headers={"baz": "456"}) + + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.delete.assert_called_once_with( + 'https://localhost/foo', + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_minimal_delete(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.delete.return_value = new_mock_empty_response() + connector = HttpApiConnector("https://localhost") + response = connector.delete("foo") + + self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) + self.assertEqual(None, response.body) + self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) + self.assertEqual({}, response.headers) + + mock_session.delete.assert_called_once_with( + 'https://localhost/foo', params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_delete_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.delete.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) + connector = HttpApiConnector("https://localhost") + self.assertRaises( + HTTPError, + connector.delete, + "foo" + ) + + mock_session.delete.assert_called_once_with( + 'https://localhost/foo', params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.delete.return_value = new_mock_json_response( + content=TEST_JSON_RESPONSE_BODY, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + connector = HttpApiConnector("https://localhost") + response = connector.delete( + "foo", + params={"bar": "123"}, + headers={"baz": "456"}, + raise_for_status=False + ) + + self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.delete.assert_called_once_with( + 'https://localhost/foo', + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_get(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.get.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + connector = HttpApiConnector("https://localhost") + response = connector.get("foo", params={"bar": "123"}, headers={"baz": "456"}) + + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.get.assert_called_once_with( + 'https://localhost/foo', + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_minimal_get(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.get.return_value = new_mock_empty_response() + connector = HttpApiConnector("https://localhost") + response = connector.get("foo") + + self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) + self.assertEqual(None, response.body) + self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) + self.assertEqual({}, response.headers) + + mock_session.get.assert_called_once_with( + 'https://localhost/foo', params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_get_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.get.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) + connector = HttpApiConnector("https://localhost") + self.assertRaises( + HTTPError, + connector.get, + "foo" + ) + + mock_session.get.assert_called_once_with( + 'https://localhost/foo', params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_get_no_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.get.return_value = new_mock_json_response( + content=TEST_JSON_RESPONSE_BODY, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + connector = HttpApiConnector("https://localhost") + response = connector.get( + "foo", + params={"bar": "123"}, + headers={"baz": "456"}, + raise_for_status=False + ) + + self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.get.assert_called_once_with( + 'https://localhost/foo', + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_post_json(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.post.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + connector = HttpApiConnector("https://localhost") + response = connector.post_json( + "foo", + json={"number": 5}, + params={"bar": "123"}, + headers={"baz": "456"} + ) + + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.post.assert_called_once_with( + 'https://localhost/foo', + json={"number": 5}, + params={"bar": "123"}, + headers={"baz": "456"} + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_minimal_post_json(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.post.return_value = new_mock_empty_response() + connector = HttpApiConnector("https://localhost") + response = connector.post_json("foo", TEST_JSON_REQUEST_BODY) + + self.assertEqual(int(HTTPStatus.NO_CONTENT), response.status) + self.assertEqual(None, response.body) + self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) + self.assertEqual({}, response.headers) + + mock_session.post.assert_called_once_with( + 'https://localhost/foo', json=TEST_JSON_REQUEST_BODY, params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_post_json_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.post.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) + connector = HttpApiConnector("https://localhost") + self.assertRaises( + HTTPError, + connector.post_json, + "foo", + json=TEST_JSON_REQUEST_BODY + ) + + mock_session.post.assert_called_once_with( + 'https://localhost/foo', json=TEST_JSON_REQUEST_BODY, params=None, headers=None + ) + + @patch('gvm.http.core.connector.HttpApiConnector._new_session') + def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): + mock_session = new_session_mock.return_value = new_mock_session() + + mock_session.post.return_value = new_mock_json_response( + content=TEST_JSON_RESPONSE_BODY, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + connector = HttpApiConnector("https://localhost") + response = connector.post_json( + "foo", + json=TEST_JSON_REQUEST_BODY, + params={"bar": "123"}, + headers={"baz": "456"}, + raise_for_status=False + ) + + self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) + self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) + self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) + self.assertEqual(TEST_JSON_HEADERS, response.headers) + + mock_session.post.assert_called_once_with( + 'https://localhost/foo', + json=TEST_JSON_REQUEST_BODY, + params={"bar": "123"}, + headers={"baz": "456"} + ) \ No newline at end of file diff --git a/tests/http/core/test_headers.py b/tests/http/core/test_headers.py new file mode 100644 index 00000000..032a4301 --- /dev/null +++ b/tests/http/core/test_headers.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest + +from gvm.http.core.headers import ContentType + + +class ContentTypeTestCase(unittest.TestCase): + + def test_from_empty_string(self): + ct = ContentType.from_string("") + self.assertEqual("application/octet-stream", ct.media_type) + self.assertEqual({}, ct.params) + self.assertEqual(None, ct.charset) + + def test_from_basic_string(self): + ct = ContentType.from_string("text/html") + self.assertEqual("text/html", ct.media_type) + self.assertEqual({}, ct.params) + self.assertEqual(None, ct.charset) + + def test_from_string_with_charset(self): + ct = ContentType.from_string("text/html; charset=utf-32 ") + self.assertEqual("text/html", ct.media_type) + self.assertEqual({"charset": "utf-32"}, ct.params) + self.assertEqual("utf-32", ct.charset) + + def test_from_string_with_param(self): + ct = ContentType.from_string("multipart/form-data; boundary===boundary==; charset=utf-32 ") + self.assertEqual("multipart/form-data", ct.media_type) + self.assertEqual({"boundary": "==boundary==", "charset": "utf-32"}, ct.params) + self.assertEqual("utf-32", ct.charset) + + def test_from_string_with_valueless_param(self): + ct = ContentType.from_string("text/html; x-foo") + self.assertEqual("text/html", ct.media_type) + self.assertEqual({"x-foo": True}, ct.params) + self.assertEqual(None, ct.charset) diff --git a/tests/http/core/test_response.py b/tests/http/core/test_response.py new file mode 100644 index 00000000..8edf0dda --- /dev/null +++ b/tests/http/core/test_response.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later +import json +import unittest +from http import HTTPStatus + +import requests as requests_lib + +from gvm.http.core.response import HttpResponse + +class HttpResponseFromRequestsLibTestCase(unittest.TestCase): + def test_from_empty_response(self): + requests_response = requests_lib.Response() + requests_response.status_code = int(HTTPStatus.OK) + requests_response._content = b'' + + response = HttpResponse.from_requests_lib(requests_response) + + self.assertIsNone(response.body) + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual({}, response.headers) + + def test_from_plain_text_response(self): + requests_response = requests_lib.Response() + requests_response.status_code = int(HTTPStatus.OK) + requests_response.headers.update({"content-type": "text/plain"}) + requests_response._content = b'ABCDEF' + + response = HttpResponse.from_requests_lib(requests_response) + + self.assertEqual(b'ABCDEF', response.body) + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual({"content-type": "text/plain"}, response.headers) + + def test_from_json_response(self): + test_content = {"foo": ["bar", 12345], "baz": True} + requests_response = requests_lib.Response() + requests_response.status_code = int(HTTPStatus.OK) + requests_response.headers.update({"content-type": "application/json"}) + requests_response._content = json.dumps(test_content).encode() + + response = HttpResponse.from_requests_lib(requests_response) + + self.assertEqual(test_content, response.body) + self.assertEqual(int(HTTPStatus.OK), response.status) + self.assertEqual({"content-type": "application/json"}, response.headers) + + def test_from_error_json_response(self): + test_content = {"error": "Internal server error"} + requests_response = requests_lib.Response() + requests_response.status_code = int(HTTPStatus.INTERNAL_SERVER_ERROR) + requests_response.headers.update({"content-type": "application/json"}) + requests_response._content = json.dumps(test_content).encode() + + response = HttpResponse.from_requests_lib(requests_response) + + self.assertEqual(test_content, response.body) + self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) + self.assertEqual({"content-type": "application/json"}, response.headers) diff --git a/tests/http/openvasd/__init__.py b/tests/http/openvasd/__init__.py new file mode 100644 index 00000000..9c0a68e7 --- /dev/null +++ b/tests/http/openvasd/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py new file mode 100644 index 00000000..a791f8b4 --- /dev/null +++ b/tests/http/openvasd/test_openvasd1.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: 2025 Greenbone AG +# +# SPDX-License-Identifier: GPL-3.0-or-later + +import unittest +from http import HTTPStatus +from typing import Optional +from unittest.mock import Mock, patch + +from gvm.errors import InvalidArgumentType + +from gvm.http.core.headers import ContentType +from gvm.http.core.response import HttpResponse +from gvm.http.openvasd.openvasd1 import OpenvasdHttpApiV1 + + +def new_mock_empty_response( + status: Optional[int | HTTPStatus] = None, + headers: Optional[dict[str, str]] = None, +): + if status is None: + status = int(HTTPStatus.NO_CONTENT) + if headers is None: + headers = [] + content_type = ContentType.from_string(None) + return HttpResponse(body=None, status=status, headers=headers, content_type=content_type) + + +class OpenvasdHttpApiV1TestCase(unittest.TestCase): + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_init(self, mock_connector: Mock): + api = OpenvasdHttpApiV1(mock_connector) + mock_connector.update_headers.assert_not_called() + self.assertIsNotNone(api) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_init_with_api_key(self, mock_connector: Mock): + api = OpenvasdHttpApiV1(mock_connector, api_key="my-API-key") + mock_connector.update_headers.assert_called_once_with({"X-API-KEY": "my-API-key"}) + self.assertIsNotNone(api) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_health_alive(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_health_alive() + mock_connector.get.assert_called_once_with("/health/alive", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_health_ready(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_health_ready() + mock_connector.get.assert_called_once_with("/health/ready", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_health_started(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_health_started() + mock_connector.get.assert_called_once_with("/health/started", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_notus_os_list(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_notus_os_list() + mock_connector.get.assert_called_once_with("/notus", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_run_notus_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.run_notus_scan("Debian 11", ["foo-1.0", "bar-0.23"]) + mock_connector.post_json.assert_called_once_with( + "/notus/Debian%2011", + ["foo-1.0", "bar-0.23"], + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_preferences(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan_preferences() + mock_connector.get.assert_called_once_with("/scans/preferences", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_create_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + # minimal scan + response = api.create_scan( + {"hosts": "somehost"}, + ["some_vt", "another_vt"], + ) + mock_connector.post_json.assert_called_once_with( + "/scans", + { + "target": {"hosts": "somehost"}, + "vts": ["some_vt", "another_vt"], + }, + raise_for_status=False + ) + + # scan with all options + mock_connector.post_json.reset_mock() + response = api.create_scan( + {"hosts": "somehost"}, + ["some_vt", "another_vt"], + {"my_scanner_param": "abc"}, + ) + mock_connector.post_json.assert_called_once_with( + "/scans", + { + "target": {"hosts": "somehost"}, + "vts": ["some_vt", "another_vt"], + "scan_preferences": {"my_scanner_param": "abc"} + }, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_delete_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.delete.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.delete_scan("foo bar") + mock_connector.delete.assert_called_once_with("/scans/foo%20bar", raise_for_status=False) + self.assertEqual(expected_response, response) + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scans(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scans() + mock_connector.get.assert_called_once_with("/scans", raise_for_status=False) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan("foo bar") + mock_connector.get.assert_called_once_with("/scans/foo%20bar", raise_for_status=False) + self.assertEqual(expected_response, response) + + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_results(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan_results("foo bar") + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results", + params={}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_results_with_ranges(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + # range start only + response = api.get_scan_results("foo bar", 12) + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results", + params={"range": "12"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + # range start and end + mock_connector.get.reset_mock() + response = api.get_scan_results("foo bar", 12, 34) + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results", + params={"range": "12-34"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + # range end only + mock_connector.get.reset_mock() + response = api.get_scan_results("foo bar", range_end=23) + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results", + params={"range": "0-23"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + # range start + self.assertRaises( + InvalidArgumentType, + api.get_scan_results, + "foo bar", "invalid" + ) + + # range start and end + self.assertRaises( + InvalidArgumentType, + api.get_scan_results, + "foo bar", 12, "invalid" + ) + + # range end only + self.assertRaises( + InvalidArgumentType, + api.get_scan_results, + "foo bar", range_end="invalid" + ) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_result(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan_result("foo bar", "baz qux") + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/results/baz%20qux", + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_scan_status(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_scan_status("foo bar") + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar/status", + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_run_scan_action(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.run_scan_action("foo bar", "do-something") + mock_connector.post_json.assert_called_once_with( + "/scans/foo%20bar", + {"action": "do-something"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_start_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.start_scan("foo bar") + mock_connector.post_json.assert_called_once_with( + "/scans/foo%20bar", + {"action": "start"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_stop_scan(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.post_json.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.stop_scan("foo bar") + mock_connector.post_json.assert_called_once_with( + "/scans/foo%20bar", + {"action": "stop"}, + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_vts(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_vts() + mock_connector.get.assert_called_once_with( + "/vts", + raise_for_status=False + ) + self.assertEqual(expected_response, response) + + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + def test_get_vt(self, mock_connector: Mock): + expected_response = new_mock_empty_response() + mock_connector.get.return_value = expected_response + api = OpenvasdHttpApiV1(mock_connector) + + response = api.get_vt("foo bar") + mock_connector.get.assert_called_once_with( + "/vts/foo%20bar", + raise_for_status=False + ) + self.assertEqual(expected_response, response) From 048b51eab8e50ab6a0bd15c7c61f84bce8d35c30 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 08:32:09 +0200 Subject: [PATCH 02/18] Add documentation for HTTP APIs, small refactoring Sphinx documentation pages and some additional docstrings have been added. The "api" module in gvm.http.core is renamed to "_api" and url_join is now a class method of HttpApiConnector. --- docs/api/api.rst | 1 + docs/api/http.rst | 12 ++++++++++ docs/api/httpcore.rst | 28 +++++++++++++++++++++++ docs/api/openvasdv1.rst | 9 ++++++++ gvm/http/__init__.py | 8 +++++++ gvm/http/core/__init__.py | 4 ++++ gvm/http/core/{api.py => _api.py} | 4 ++++ gvm/http/core/connector.py | 37 +++++++++++++++++-------------- gvm/http/core/headers.py | 5 +++++ gvm/http/core/response.py | 12 ++++++++++ gvm/http/openvasd/__init__.py | 5 +++++ gvm/http/openvasd/openvasd1.py | 7 ++++-- tests/http/core/test_api.py | 2 +- tests/http/core/test_connector.py | 14 ++++++------ 14 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 docs/api/http.rst create mode 100644 docs/api/httpcore.rst create mode 100644 docs/api/openvasdv1.rst rename gvm/http/core/{api.py => _api.py} (94%) diff --git a/docs/api/api.rst b/docs/api/api.rst index a51b204c..8788f8d4 100644 --- a/docs/api/api.rst +++ b/docs/api/api.rst @@ -16,5 +16,6 @@ utilities and xml helpers. connections transforms protocols + http errors other diff --git a/docs/api/http.rst b/docs/api/http.rst new file mode 100644 index 00000000..50b52238 --- /dev/null +++ b/docs/api/http.rst @@ -0,0 +1,12 @@ +.. _http: + +HTTP APIs +--------- + +.. automodule:: gvm.http + +.. toctree:: + :maxdepth: 1 + + httpcore + openvasdv1 \ No newline at end of file diff --git a/docs/api/httpcore.rst b/docs/api/httpcore.rst new file mode 100644 index 00000000..f94fb4af --- /dev/null +++ b/docs/api/httpcore.rst @@ -0,0 +1,28 @@ +.. _httpcore: + +HTTP core classes +^^^^^^^^^^^^^^^^^ + +Connector +######### + +.. automodule:: gvm.http.core.connector + +.. autoclass:: HttpApiConnector + :members: + +Headers +####### + +.. automodule:: gvm.http.core.headers + +.. autoclass:: ContentType + :members: + +Response +######## + +.. automodule:: gvm.http.core.response + +.. autoclass:: HttpResponse + :members: \ No newline at end of file diff --git a/docs/api/openvasdv1.rst b/docs/api/openvasdv1.rst new file mode 100644 index 00000000..2a3699c9 --- /dev/null +++ b/docs/api/openvasdv1.rst @@ -0,0 +1,9 @@ +.. _openvasdv1: + +openvasd v1 +^^^^^^^^^^^ + +.. automodule:: gvm.http.openvasd.openvasd1 + +.. autoclass:: OpenvasdHttpApiV1 + :members: \ No newline at end of file diff --git a/gvm/http/__init__.py b/gvm/http/__init__.py index 9c0a68e7..6e5d909d 100644 --- a/gvm/http/__init__.py +++ b/gvm/http/__init__.py @@ -1,3 +1,11 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later +""" +Package for supported Greenbone HTTP APIs. + +Currently only `openvasd version 1`_ is supported. + +.. _openvasd version 1: + https://greenbone.github.io/scanner-api/#/ +""" \ No newline at end of file diff --git a/gvm/http/core/__init__.py b/gvm/http/core/__init__.py index 9c0a68e7..59c68f9b 100644 --- a/gvm/http/core/__init__.py +++ b/gvm/http/core/__init__.py @@ -1,3 +1,7 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + +""" +HTTP core classes +""" \ No newline at end of file diff --git a/gvm/http/core/api.py b/gvm/http/core/_api.py similarity index 94% rename from gvm/http/core/api.py rename to gvm/http/core/_api.py index 4ed4a15f..a068966d 100644 --- a/gvm/http/core/api.py +++ b/gvm/http/core/_api.py @@ -2,6 +2,10 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +""" +Base class module for GVM HTTP APIs +""" + from typing import Optional from gvm.http.core.connector import HttpApiConnector diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index 20bf2270..f45aa849 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -2,6 +2,10 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +""" +Module for handling GVM HTTP API connections +""" + import urllib.parse from typing import Optional, Tuple, Dict, Any @@ -9,20 +13,6 @@ from gvm.http.core.response import HttpResponse - -def url_join(base: str, rel_path: str) -> str: - """ - Combines a base URL and a relative path into one URL. - - Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it - ends with "/". - """ - if base.endswith("/"): - return urllib.parse.urljoin(base, rel_path) - else: - return urllib.parse.urljoin(base + "/", rel_path) - - class HttpApiConnector: """ Class for connecting to HTTP based API servers, sending requests and receiving the responses. @@ -35,6 +25,19 @@ def _new_session(cls): """ return Session() + @classmethod + def url_join(cls, base: str, rel_path: str) -> str: + """ + Combines a base URL and a relative path into one URL. + + Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it + ends with "/". + """ + if base.endswith("/"): + return urllib.parse.urljoin(base, rel_path) + else: + return urllib.parse.urljoin(base + "/", rel_path) + def __init__( self, base_url: str, @@ -93,7 +96,7 @@ def delete( Return: The HTTP response. """ - url = url_join(self.base_url, rel_path) + url = self.url_join(self.base_url, rel_path) r = self._session.delete(url, params=params, headers=headers) if raise_for_status: r.raise_for_status() @@ -119,7 +122,7 @@ def get( Return: The HTTP response. """ - url = url_join(self.base_url, rel_path) + url = self.url_join(self.base_url, rel_path) r = self._session.get(url, params=params, headers=headers) if raise_for_status: r.raise_for_status() @@ -147,7 +150,7 @@ def post_json( Return: The HTTP response. """ - url = url_join(self.base_url, rel_path) + url = self.url_join(self.base_url, rel_path) r = self._session.post(url, json=json, params=params, headers=headers) if raise_for_status: r.raise_for_status() diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 1b5993f2..3d8f28a3 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -1,6 +1,11 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + +""" +Module for handling special HTTP headers +""" + from dataclasses import dataclass from typing import Self, Dict, Optional diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index ffb6da64..81709e40 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -1,6 +1,11 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + +""" +Module for abstracting HTTP responses +""" + from dataclasses import dataclass from typing import Any, Dict, Self, Optional from requests import Request, Response @@ -14,9 +19,16 @@ class HttpResponse: Class representing an HTTP response. """ body: Any + "The body of the response" + status: int + "HTTP status code of the response" + headers: Dict[str, str] + "Dict containing the headers of the response" + content_type: Optional[ContentType] + "The content type of the response if it was included in the headers" @classmethod def from_requests_lib(cls, r: Response) -> Self: diff --git a/gvm/http/openvasd/__init__.py b/gvm/http/openvasd/__init__.py index 9c0a68e7..6769b5a9 100644 --- a/gvm/http/openvasd/__init__.py +++ b/gvm/http/openvasd/__init__.py @@ -1,3 +1,8 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later +""" +Package for sending openvasd and handling the responses of HTTP API requests. + +* :class:`OpenvasdHttpApiV1` - openvasd version 1 +""" \ No newline at end of file diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index c69cc957..e7b8e24d 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -2,12 +2,16 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +""" +openvasd HTTP API version 1 +""" + import urllib.parse from typing import Optional, Any from gvm.errors import InvalidArgumentType -from gvm.http.core.api import GvmHttpApi +from gvm.http.core._api import GvmHttpApi from gvm.http.core.connector import HttpApiConnector from gvm.http.core.response import HttpResponse @@ -29,7 +33,6 @@ def __init__( Args: connector: The connector handling the HTTP(S) connection api_key: Optional API key for authentication - default_raise_for_status: whether to raise an exception if HTTP status is not a success """ super().__init__(connector, api_key=api_key) if api_key: diff --git a/tests/http/core/test_api.py b/tests/http/core/test_api.py index ef48c96a..3cfaa2e6 100644 --- a/tests/http/core/test_api.py +++ b/tests/http/core/test_api.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch, MagicMock -from gvm.http.core.api import GvmHttpApi +from gvm.http.core._api import GvmHttpApi class GvmHttpApiTestCase(unittest.TestCase): diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index 3f2cfb28..83ba7adf 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -8,7 +8,7 @@ from unittest.mock import patch, MagicMock, Mock from requests.exceptions import HTTPError -from gvm.http.core.connector import HttpApiConnector, url_join +from gvm.http.core.connector import HttpApiConnector import requests as requests_lib from gvm.http.core.headers import ContentType @@ -81,27 +81,27 @@ class HttpApiConnectorTestCase(unittest.TestCase): def test_url_join(self): self.assertEqual( "http://localhost/foo/bar/baz", - url_join("http://localhost/foo", "bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "bar/baz") ) self.assertEqual( "http://localhost/foo/bar/baz", - url_join("http://localhost/foo/", "bar/baz") + HttpApiConnector.url_join("http://localhost/foo/", "bar/baz") ) self.assertEqual( "http://localhost/foo/bar/baz", - url_join("http://localhost/foo", "./bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "./bar/baz") ) self.assertEqual( "http://localhost/foo/bar/baz", - url_join("http://localhost/foo/", "./bar/baz") + HttpApiConnector.url_join("http://localhost/foo/", "./bar/baz") ) self.assertEqual( "http://localhost/bar/baz", - url_join("http://localhost/foo", "../bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "../bar/baz") ) self.assertEqual( "http://localhost/bar/baz", - url_join("http://localhost/foo", "../bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "../bar/baz") ) def test_new_session(self): From 320caeba0b1ab1cddf1fdff57bec7fa26d5339a2 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 08:41:55 +0200 Subject: [PATCH 03/18] Reformat HTTP package and tests --- gvm/http/__init__.py | 3 +- gvm/http/core/__init__.py | 2 +- gvm/http/core/_api.py | 4 +- gvm/http/core/connector.py | 19 +-- gvm/http/core/headers.py | 12 +- gvm/http/core/response.py | 7 +- gvm/http/openvasd/__init__.py | 3 +- gvm/http/openvasd/openvasd1.py | 141 ++++++++++++++++------ tests/http/core/test_api.py | 4 +- tests/http/core/test_connector.py | 167 +++++++++++++------------- tests/http/core/test_headers.py | 8 +- tests/http/core/test_response.py | 7 +- tests/http/openvasd/test_openvasd1.py | 97 ++++++++------- 13 files changed, 284 insertions(+), 190 deletions(-) diff --git a/gvm/http/__init__.py b/gvm/http/__init__.py index 6e5d909d..87e62d3f 100644 --- a/gvm/http/__init__.py +++ b/gvm/http/__init__.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + """ Package for supported Greenbone HTTP APIs. @@ -8,4 +9,4 @@ .. _openvasd version 1: https://greenbone.github.io/scanner-api/#/ -""" \ No newline at end of file +""" diff --git a/gvm/http/core/__init__.py b/gvm/http/core/__init__.py index 59c68f9b..d079cfd4 100644 --- a/gvm/http/core/__init__.py +++ b/gvm/http/core/__init__.py @@ -4,4 +4,4 @@ """ HTTP core classes -""" \ No newline at end of file +""" diff --git a/gvm/http/core/_api.py b/gvm/http/core/_api.py index a068966d..bd7a5c1f 100644 --- a/gvm/http/core/_api.py +++ b/gvm/http/core/_api.py @@ -16,7 +16,9 @@ class GvmHttpApi: Base class for HTTP-based GVM APIs. """ - def __init__(self, connector: HttpApiConnector, *, api_key: Optional[str] = None): + def __init__( + self, connector: HttpApiConnector, *, api_key: Optional[str] = None + ): """ Create a new generic GVM HTTP API instance. diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index f45aa849..e683edf7 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -13,6 +13,7 @@ from gvm.http.core.response import HttpResponse + class HttpApiConnector: """ Class for connecting to HTTP based API servers, sending requests and receiving the responses. @@ -39,11 +40,11 @@ def url_join(cls, base: str, rel_path: str) -> str: return urllib.parse.urljoin(base + "/", rel_path) def __init__( - self, - base_url: str, - *, - server_ca_path: Optional[str] = None, - client_cert_paths: Optional[str | Tuple[str]] = None, + self, + base_url: str, + *, + server_ca_path: Optional[str] = None, + client_cert_paths: Optional[str | Tuple[str]] = None, ): """ Create a new HTTP API Connector. @@ -81,8 +82,8 @@ def delete( rel_path: str, *, raise_for_status: bool = True, - params: Optional[Dict[str,str]] = None, - headers: Optional[Dict[str,str]] = None, + params: Optional[Dict[str, str]] = None, + headers: Optional[Dict[str, str]] = None, ) -> HttpResponse: """ Sends a ``DELETE`` request and returns the response. @@ -107,8 +108,8 @@ def get( rel_path: str, *, raise_for_status: bool = True, - params: Optional[Dict[str,str]] = None, - headers: Optional[Dict[str,str]] = None, + params: Optional[Dict[str, str]] = None, + headers: Optional[Dict[str, str]] = None, ) -> HttpResponse: """ Sends a ``GET`` request and returns the response. diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 3d8f28a3..6d384049 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -17,9 +17,9 @@ class ContentType: """ media_type: str - "The MIME media type, e.g. \"application/json\"" + 'The MIME media type, e.g. "application/json"' - params: Dict[str,str] + params: Dict[str, str] "Dictionary of parameters in the content type header" charset: Optional[str] @@ -29,7 +29,7 @@ class ContentType: def from_string( cls, header_string: str, - fallback_media_type: Optional[str] = "application/octet-stream" + fallback_media_type: Optional[str] = "application/octet-stream", ) -> Self: """ Parse the content of content type header into a ContentType object. @@ -50,9 +50,11 @@ def from_string( if "=" in param: key, value = map(lambda x: x.strip(), param.split("=", 1)) params[key] = value - if key == 'charset': + if key == "charset": charset = value else: params[param] = True - return ContentType(media_type=media_type, params=params, charset=charset) + return ContentType( + media_type=media_type, params=params, charset=charset + ) diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 81709e40..30b3fb97 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -18,6 +18,7 @@ class HttpResponse: """ Class representing an HTTP response. """ + body: Any "The body of the response" @@ -45,13 +46,13 @@ def from_requests_lib(cls, r: Response) -> Self: If the content-type header in the response is set to 'application/json'. A non-empty body will be parsed accordingly. """ - ct = ContentType.from_string(r.headers.get('content-type')) + ct = ContentType.from_string(r.headers.get("content-type")) body = r.content - if r.content == b'': + if r.content == b"": body = None elif ct is not None: - if ct.media_type.lower() == 'application/json': + if ct.media_type.lower() == "application/json": body = r.json() return HttpResponse(body, r.status_code, r.headers, ct) diff --git a/gvm/http/openvasd/__init__.py b/gvm/http/openvasd/__init__.py index 6769b5a9..251ddb41 100644 --- a/gvm/http/openvasd/__init__.py +++ b/gvm/http/openvasd/__init__.py @@ -1,8 +1,9 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + """ Package for sending openvasd and handling the responses of HTTP API requests. * :class:`OpenvasdHttpApiV1` - openvasd version 1 -""" \ No newline at end of file +""" diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index e7b8e24d..86526a72 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -38,7 +38,9 @@ def __init__( if api_key: connector.update_headers({"X-API-KEY": api_key}) - def get_health_alive(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_health_alive( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the "alive" health status of the scanner. @@ -48,9 +50,13 @@ def get_health_alive(self, *, raise_for_status: bool = False) -> HttpResponse: Return: The HTTP response. See GET /health/alive in the openvasd API documentation. """ - return self._connector.get("/health/alive", raise_for_status=raise_for_status) + return self._connector.get( + "/health/alive", raise_for_status=raise_for_status + ) - def get_health_ready(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_health_ready( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the "ready" health status of the scanner. @@ -60,9 +66,13 @@ def get_health_ready(self, *, raise_for_status: bool = False) -> HttpResponse: Return: The HTTP response. See GET /health/ready in the openvasd API documentation. """ - return self._connector.get("/health/ready", raise_for_status=raise_for_status) + return self._connector.get( + "/health/ready", raise_for_status=raise_for_status + ) - def get_health_started(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_health_started( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the "started" health status of the scanner. @@ -72,9 +82,13 @@ def get_health_started(self, *, raise_for_status: bool = False) -> HttpResponse: Return: The HTTP response. See GET /health/started in the openvasd API documentation. """ - return self._connector.get("/health/started", raise_for_status=raise_for_status) + return self._connector.get( + "/health/started", raise_for_status=raise_for_status + ) - def get_notus_os_list(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_notus_os_list( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the list of operating systems available in Notus. @@ -86,7 +100,13 @@ def get_notus_os_list(self, *, raise_for_status: bool = False) -> HttpResponse: """ return self._connector.get("/notus", raise_for_status=raise_for_status) - def run_notus_scan(self, os: str, package_list: list[str], *, raise_for_status: bool = False) -> HttpResponse: + def run_notus_scan( + self, + os: str, + package_list: list[str], + *, + raise_for_status: bool = False, + ) -> HttpResponse: """ Gets the Notus results for a given operating system and list of packages. @@ -99,9 +119,15 @@ def run_notus_scan(self, os: str, package_list: list[str], *, raise_for_status: The HTTP response. See POST /notus/{os} in the openvasd API documentation. """ quoted_os = urllib.parse.quote(os) - return self._connector.post_json(f"/notus/{quoted_os}", package_list, raise_for_status=raise_for_status) + return self._connector.post_json( + f"/notus/{quoted_os}", + package_list, + raise_for_status=raise_for_status, + ) - def get_scan_preferences(self, *, raise_for_status: bool = False) -> HttpResponse: + def get_scan_preferences( + self, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the list of available scan preferences. @@ -111,7 +137,9 @@ def get_scan_preferences(self, *, raise_for_status: bool = False) -> HttpRespons Return: The HTTP response. See POST /scans/preferences in the openvasd API documentation. """ - return self._connector.get("/scans/preferences", raise_for_status=raise_for_status) + return self._connector.get( + "/scans/preferences", raise_for_status=raise_for_status + ) def create_scan( self, @@ -119,7 +147,7 @@ def create_scan( vt_selection: dict[str, Any], scanner_params: Optional[dict[str, Any]] = None, *, - raise_for_status: bool = False + raise_for_status: bool = False, ) -> HttpResponse: """ Creates a new scan without starting it. @@ -141,9 +169,13 @@ def create_scan( } if scanner_params: request_json["scan_preferences"] = scanner_params - return self._connector.post_json("/scans", request_json, raise_for_status=raise_for_status) + return self._connector.post_json( + "/scans", request_json, raise_for_status=raise_for_status + ) - def delete_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def delete_scan( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Deletes a scan with the given id. @@ -155,7 +187,9 @@ def delete_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpRe The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. """ quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.delete(f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status) + return self._connector.delete( + f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status + ) def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: """ @@ -169,7 +203,9 @@ def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: """ return self._connector.get("/scans", raise_for_status=raise_for_status) - def get_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def get_scan( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets a scan with the given id. @@ -181,7 +217,9 @@ def get_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpRespo The HTTP response. See GET /scans/{id} in the openvasd API documentation. """ quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.get(f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status) + return self._connector.get( + f"/scans/{quoted_scan_id}", raise_for_status=raise_for_status + ) def get_scan_results( self, @@ -189,7 +227,7 @@ def get_scan_results( range_start: Optional[int] = None, range_end: Optional[int] = None, *, - raise_for_status: bool = False + raise_for_status: bool = False, ) -> HttpResponse: """ Gets results of a scan with the given id. @@ -207,32 +245,41 @@ def get_scan_results( params = {} if range_start is not None: if not isinstance(range_start, int): - raise InvalidArgumentType(argument="range_start", function=self.get_scan_results.__name__) + raise InvalidArgumentType( + argument="range_start", + function=self.get_scan_results.__name__, + ) if range_end is not None: if not isinstance(range_end, int): - raise InvalidArgumentType(argument="range_end", function=self.get_scan_results.__name__) + raise InvalidArgumentType( + argument="range_end", + function=self.get_scan_results.__name__, + ) params["range"] = f"{range_start}-{range_end}" else: params["range"] = str(range_start) else: if range_end is not None: if not isinstance(range_end, int): - raise InvalidArgumentType(argument="range_end", function=self.get_scan_results.__name__) + raise InvalidArgumentType( + argument="range_end", + function=self.get_scan_results.__name__, + ) params["range"] = f"0-{range_end}" return self._connector.get( f"/scans/{quoted_scan_id}/results", params=params, - raise_for_status=raise_for_status + raise_for_status=raise_for_status, ) def get_scan_result( self, scan_id: str, - result_id: str|int, + result_id: str | int, *, - raise_for_status: bool = False + raise_for_status: bool = False, ) -> HttpResponse: """ Gets a single result of a scan with the given id. @@ -250,10 +297,12 @@ def get_scan_result( return self._connector.get( f"/scans/{quoted_scan_id}/results/{quoted_result_id}", - raise_for_status=raise_for_status + raise_for_status=raise_for_status, ) - def get_scan_status(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def get_scan_status( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets a scan with the given id. @@ -265,9 +314,13 @@ def get_scan_status(self, scan_id: str, *, raise_for_status: bool = False) -> Ht The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. """ quoted_scan_id = urllib.parse.quote(scan_id) - return self._connector.get(f"/scans/{quoted_scan_id}/status", raise_for_status=raise_for_status) + return self._connector.get( + f"/scans/{quoted_scan_id}/status", raise_for_status=raise_for_status + ) - def run_scan_action(self, scan_id: str, scan_action: str, *, raise_for_status: bool = False) -> HttpResponse: + def run_scan_action( + self, scan_id: str, scan_action: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Performs an action like starting or stopping on a scan with the given id. @@ -280,10 +333,16 @@ def run_scan_action(self, scan_id: str, scan_action: str, *, raise_for_status: b The HTTP response. See POST /scans/{id} in the openvasd API documentation. """ quoted_scan_id = urllib.parse.quote(scan_id) - action_json = { "action": scan_action } - return self._connector.post_json(f"/scans/{quoted_scan_id}", action_json, raise_for_status=raise_for_status) + action_json = {"action": scan_action} + return self._connector.post_json( + f"/scans/{quoted_scan_id}", + action_json, + raise_for_status=raise_for_status, + ) - def start_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def start_scan( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Starts a scan with the given id. @@ -294,9 +353,13 @@ def start_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpRes Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. """ - return self.run_scan_action(scan_id, "start", raise_for_status=raise_for_status) + return self.run_scan_action( + scan_id, "start", raise_for_status=raise_for_status + ) - def stop_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResponse: + def stop_scan( + self, scan_id: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Stops a scan with the given id. @@ -307,7 +370,9 @@ def stop_scan(self, scan_id: str, *, raise_for_status: bool = False) -> HttpResp Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. """ - return self.run_scan_action(scan_id, "stop", raise_for_status=raise_for_status) + return self.run_scan_action( + scan_id, "stop", raise_for_status=raise_for_status + ) def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: """ @@ -321,7 +386,9 @@ def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: """ return self._connector.get("/vts", raise_for_status=raise_for_status) - def get_vt(self, oid: str, *, raise_for_status: bool = False) -> HttpResponse: + def get_vt( + self, oid: str, *, raise_for_status: bool = False + ) -> HttpResponse: """ Gets the details of a vulnerability test (VT). @@ -333,4 +400,6 @@ def get_vt(self, oid: str, *, raise_for_status: bool = False) -> HttpResponse: The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. """ quoted_oid = urllib.parse.quote(oid) - return self._connector.get(f"/vts/{quoted_oid}", raise_for_status=raise_for_status) + return self._connector.get( + f"/vts/{quoted_oid}", raise_for_status=raise_for_status + ) diff --git a/tests/http/core/test_api.py b/tests/http/core/test_api.py index 3cfaa2e6..a45dd7f2 100644 --- a/tests/http/core/test_api.py +++ b/tests/http/core/test_api.py @@ -11,12 +11,12 @@ class GvmHttpApiTestCase(unittest.TestCase): # pylint: disable=protected-access - @patch('gvm.http.core.connector.HttpApiConnector') + @patch("gvm.http.core.connector.HttpApiConnector") def test_basic_init(self, connector_mock: MagicMock): api = GvmHttpApi(connector_mock) self.assertEqual(connector_mock, api._connector) - @patch('gvm.http.core.connector.HttpApiConnector') + @patch("gvm.http.core.connector.HttpApiConnector") def test_init_with_key(self, connector_mock: MagicMock): api = GvmHttpApi(connector_mock, api_key="my-api-key") self.assertEqual(connector_mock, api._connector) diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index 83ba7adf..c2021652 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -16,7 +16,7 @@ TEST_JSON_HEADERS = { "content-type": "application/json;charset=utf-8", - "x-example": "some-test-header" + "x-example": "some-test-header", } TEST_JSON_CONTENT_TYPE = ContentType( @@ -37,11 +37,11 @@ def new_mock_empty_response( - status: Optional[int | HTTPStatus] = None, + status: Optional[int | HTTPStatus] = None, ) -> requests_lib.Response: # pylint: disable=protected-access response = requests_lib.Response() - response._content = b'' + response._content = b"" if status is None: response.status_code = int(HTTPStatus.NO_CONTENT) else: @@ -50,9 +50,9 @@ def new_mock_empty_response( def new_mock_json_response( - content: Optional[Any] = None, - status: Optional[int|HTTPStatus] = None, -)-> requests_lib.Response: + content: Optional[Any] = None, + status: Optional[int | HTTPStatus] = None, +) -> requests_lib.Response: # pylint: disable=protected-access response = requests_lib.Response() response._content = json.dumps(content).encode() @@ -66,10 +66,7 @@ def new_mock_json_response( return response -def new_mock_session( - *, - headers: Optional[dict] = None -) -> Mock: +def new_mock_session(*, headers: Optional[dict] = None) -> Mock: mock = Mock(spec=requests_lib.Session) mock.headers = headers if headers is not None else {} return mock @@ -81,38 +78,35 @@ class HttpApiConnectorTestCase(unittest.TestCase): def test_url_join(self): self.assertEqual( "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "bar/baz"), ) self.assertEqual( "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo/", "bar/baz") + HttpApiConnector.url_join("http://localhost/foo/", "bar/baz"), ) self.assertEqual( "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "./bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "./bar/baz"), ) self.assertEqual( "http://localhost/foo/bar/baz", - HttpApiConnector.url_join("http://localhost/foo/", "./bar/baz") + HttpApiConnector.url_join("http://localhost/foo/", "./bar/baz"), ) self.assertEqual( "http://localhost/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "../bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "../bar/baz"), ) self.assertEqual( "http://localhost/bar/baz", - HttpApiConnector.url_join("http://localhost/foo", "../bar/baz") + HttpApiConnector.url_join("http://localhost/foo", "../bar/baz"), ) def test_new_session(self): new_session = HttpApiConnector._new_session() self.assertIsInstance(new_session, requests_lib.Session) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') - def test_basic_init( - self, - new_session_mock: MagicMock - ): + @patch("gvm.http.core.connector.HttpApiConnector._new_session") + def test_basic_init(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() connector = HttpApiConnector("http://localhost") @@ -120,14 +114,14 @@ def test_basic_init( self.assertEqual("http://localhost", connector.base_url) self.assertEqual(mock_session, connector._session) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_https_init(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() connector = HttpApiConnector( "https://localhost", server_ca_path="foo.crt", - client_cert_paths="bar.key" + client_cert_paths="bar.key", ) self.assertEqual("https://localhost", connector.base_url) @@ -135,7 +129,7 @@ def test_https_init(self, new_session_mock: MagicMock): self.assertEqual("foo.crt", mock_session.verify) self.assertEqual("bar.key", mock_session.cert) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_update_headers(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -147,13 +141,17 @@ def test_update_headers(self, new_session_mock: MagicMock): self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_session.headers) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_delete(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.delete.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + mock_session.delete.return_value = new_mock_json_response( + TEST_JSON_RESPONSE_BODY + ) connector = HttpApiConnector("https://localhost") - response = connector.delete("foo", params={"bar": "123"}, headers={"baz": "456"}) + response = connector.delete( + "foo", params={"bar": "123"}, headers={"baz": "456"} + ) self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) @@ -161,12 +159,12 @@ def test_delete(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.delete.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_minimal_delete(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -180,26 +178,24 @@ def test_minimal_delete(self, new_session_mock: MagicMock): self.assertEqual({}, response.headers) mock_session.delete.assert_called_once_with( - 'https://localhost/foo', params=None, headers=None + "https://localhost/foo", params=None, headers=None ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_delete_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.delete.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) - connector = HttpApiConnector("https://localhost") - self.assertRaises( - HTTPError, - connector.delete, - "foo" + mock_session.delete.return_value = new_mock_empty_response( + HTTPStatus.INTERNAL_SERVER_ERROR ) + connector = HttpApiConnector("https://localhost") + self.assertRaises(HTTPError, connector.delete, "foo") mock_session.delete.assert_called_once_with( - 'https://localhost/foo', params=None, headers=None + "https://localhost/foo", params=None, headers=None ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -212,7 +208,7 @@ def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): "foo", params={"bar": "123"}, headers={"baz": "456"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) @@ -221,18 +217,22 @@ def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.delete.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_get(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.get.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + mock_session.get.return_value = new_mock_json_response( + TEST_JSON_RESPONSE_BODY + ) connector = HttpApiConnector("https://localhost") - response = connector.get("foo", params={"bar": "123"}, headers={"baz": "456"}) + response = connector.get( + "foo", params={"bar": "123"}, headers={"baz": "456"} + ) self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) @@ -240,12 +240,12 @@ def test_get(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.get.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_minimal_get(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -259,26 +259,24 @@ def test_minimal_get(self, new_session_mock: MagicMock): self.assertEqual({}, response.headers) mock_session.get.assert_called_once_with( - 'https://localhost/foo', params=None, headers=None + "https://localhost/foo", params=None, headers=None ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_get_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.get.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) - connector = HttpApiConnector("https://localhost") - self.assertRaises( - HTTPError, - connector.get, - "foo" + mock_session.get.return_value = new_mock_empty_response( + HTTPStatus.INTERNAL_SERVER_ERROR ) + connector = HttpApiConnector("https://localhost") + self.assertRaises(HTTPError, connector.get, "foo") mock_session.get.assert_called_once_with( - 'https://localhost/foo', params=None, headers=None + "https://localhost/foo", params=None, headers=None ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_get_no_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -291,7 +289,7 @@ def test_get_no_raise_on_status(self, new_session_mock: MagicMock): "foo", params={"bar": "123"}, headers={"baz": "456"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) @@ -300,22 +298,24 @@ def test_get_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.get.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_post_json(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.post.return_value = new_mock_json_response(TEST_JSON_RESPONSE_BODY) + mock_session.post.return_value = new_mock_json_response( + TEST_JSON_RESPONSE_BODY + ) connector = HttpApiConnector("https://localhost") response = connector.post_json( "foo", json={"number": 5}, params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) self.assertEqual(int(HTTPStatus.OK), response.status) @@ -324,13 +324,13 @@ def test_post_json(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.post.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", json={"number": 5}, params={"bar": "123"}, - headers={"baz": "456"} + headers={"baz": "456"}, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_minimal_post_json(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -344,27 +344,32 @@ def test_minimal_post_json(self, new_session_mock: MagicMock): self.assertEqual({}, response.headers) mock_session.post.assert_called_once_with( - 'https://localhost/foo', json=TEST_JSON_REQUEST_BODY, params=None, headers=None + "https://localhost/foo", + json=TEST_JSON_REQUEST_BODY, + params=None, + headers=None, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_post_json_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() - mock_session.post.return_value = new_mock_empty_response(HTTPStatus.INTERNAL_SERVER_ERROR) + mock_session.post.return_value = new_mock_empty_response( + HTTPStatus.INTERNAL_SERVER_ERROR + ) connector = HttpApiConnector("https://localhost") self.assertRaises( - HTTPError, - connector.post_json, - "foo", - json=TEST_JSON_REQUEST_BODY + HTTPError, connector.post_json, "foo", json=TEST_JSON_REQUEST_BODY ) mock_session.post.assert_called_once_with( - 'https://localhost/foo', json=TEST_JSON_REQUEST_BODY, params=None, headers=None + "https://localhost/foo", + json=TEST_JSON_REQUEST_BODY, + params=None, + headers=None, ) - @patch('gvm.http.core.connector.HttpApiConnector._new_session') + @patch("gvm.http.core.connector.HttpApiConnector._new_session") def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): mock_session = new_session_mock.return_value = new_mock_session() @@ -378,7 +383,7 @@ def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): json=TEST_JSON_REQUEST_BODY, params={"bar": "123"}, headers={"baz": "456"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) @@ -387,8 +392,8 @@ def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(TEST_JSON_HEADERS, response.headers) mock_session.post.assert_called_once_with( - 'https://localhost/foo', + "https://localhost/foo", json=TEST_JSON_REQUEST_BODY, params={"bar": "123"}, - headers={"baz": "456"} - ) \ No newline at end of file + headers={"baz": "456"}, + ) diff --git a/tests/http/core/test_headers.py b/tests/http/core/test_headers.py index 032a4301..07257d73 100644 --- a/tests/http/core/test_headers.py +++ b/tests/http/core/test_headers.py @@ -28,9 +28,13 @@ def test_from_string_with_charset(self): self.assertEqual("utf-32", ct.charset) def test_from_string_with_param(self): - ct = ContentType.from_string("multipart/form-data; boundary===boundary==; charset=utf-32 ") + ct = ContentType.from_string( + "multipart/form-data; boundary===boundary==; charset=utf-32 " + ) self.assertEqual("multipart/form-data", ct.media_type) - self.assertEqual({"boundary": "==boundary==", "charset": "utf-32"}, ct.params) + self.assertEqual( + {"boundary": "==boundary==", "charset": "utf-32"}, ct.params + ) self.assertEqual("utf-32", ct.charset) def test_from_string_with_valueless_param(self): diff --git a/tests/http/core/test_response.py b/tests/http/core/test_response.py index 8edf0dda..98657a19 100644 --- a/tests/http/core/test_response.py +++ b/tests/http/core/test_response.py @@ -9,11 +9,12 @@ from gvm.http.core.response import HttpResponse + class HttpResponseFromRequestsLibTestCase(unittest.TestCase): def test_from_empty_response(self): requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) - requests_response._content = b'' + requests_response._content = b"" response = HttpResponse.from_requests_lib(requests_response) @@ -25,11 +26,11 @@ def test_from_plain_text_response(self): requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) requests_response.headers.update({"content-type": "text/plain"}) - requests_response._content = b'ABCDEF' + requests_response._content = b"ABCDEF" response = HttpResponse.from_requests_lib(requests_response) - self.assertEqual(b'ABCDEF', response.body) + self.assertEqual(b"ABCDEF", response.body) self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual({"content-type": "text/plain"}, response.headers) diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index a791f8b4..09b3a5c8 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -15,15 +15,17 @@ def new_mock_empty_response( - status: Optional[int | HTTPStatus] = None, - headers: Optional[dict[str, str]] = None, + status: Optional[int | HTTPStatus] = None, + headers: Optional[dict[str, str]] = None, ): if status is None: status = int(HTTPStatus.NO_CONTENT) if headers is None: headers = [] content_type = ContentType.from_string(None) - return HttpResponse(body=None, status=status, headers=headers, content_type=content_type) + return HttpResponse( + body=None, status=status, headers=headers, content_type=content_type + ) class OpenvasdHttpApiV1TestCase(unittest.TestCase): @@ -37,7 +39,9 @@ def test_init(self, mock_connector: Mock): @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) def test_init_with_api_key(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector, api_key="my-API-key") - mock_connector.update_headers.assert_called_once_with({"X-API-KEY": "my-API-key"}) + mock_connector.update_headers.assert_called_once_with( + {"X-API-KEY": "my-API-key"} + ) self.assertIsNotNone(api) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -47,7 +51,9 @@ def test_get_health_alive(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_health_alive() - mock_connector.get.assert_called_once_with("/health/alive", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/health/alive", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -57,7 +63,9 @@ def test_get_health_ready(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_health_ready() - mock_connector.get.assert_called_once_with("/health/ready", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/health/ready", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -67,7 +75,9 @@ def test_get_health_started(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_health_started() - mock_connector.get.assert_called_once_with("/health/started", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/health/started", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -77,7 +87,9 @@ def test_get_notus_os_list(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_notus_os_list() - mock_connector.get.assert_called_once_with("/notus", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/notus", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -90,7 +102,7 @@ def test_run_notus_scan(self, mock_connector: Mock): mock_connector.post_json.assert_called_once_with( "/notus/Debian%2011", ["foo-1.0", "bar-0.23"], - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -101,7 +113,9 @@ def test_get_scan_preferences(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_scan_preferences() - mock_connector.get.assert_called_once_with("/scans/preferences", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/scans/preferences", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -121,7 +135,7 @@ def test_create_scan(self, mock_connector: Mock): "target": {"hosts": "somehost"}, "vts": ["some_vt", "another_vt"], }, - raise_for_status=False + raise_for_status=False, ) # scan with all options @@ -136,9 +150,9 @@ def test_create_scan(self, mock_connector: Mock): { "target": {"hosts": "somehost"}, "vts": ["some_vt", "another_vt"], - "scan_preferences": {"my_scanner_param": "abc"} + "scan_preferences": {"my_scanner_param": "abc"}, }, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) self.assertEqual(expected_response, response) @@ -150,8 +164,11 @@ def test_delete_scan(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.delete_scan("foo bar") - mock_connector.delete.assert_called_once_with("/scans/foo%20bar", raise_for_status=False) + mock_connector.delete.assert_called_once_with( + "/scans/foo%20bar", raise_for_status=False + ) self.assertEqual(expected_response, response) + @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) def test_get_scans(self, mock_connector: Mock): expected_response = new_mock_empty_response() @@ -159,7 +176,9 @@ def test_get_scans(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_scans() - mock_connector.get.assert_called_once_with("/scans", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/scans", raise_for_status=False + ) self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -169,10 +188,11 @@ def test_get_scan(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) response = api.get_scan("foo bar") - mock_connector.get.assert_called_once_with("/scans/foo%20bar", raise_for_status=False) + mock_connector.get.assert_called_once_with( + "/scans/foo%20bar", raise_for_status=False + ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_results(self, mock_connector: Mock): expected_response = new_mock_empty_response() @@ -181,9 +201,7 @@ def test_get_scan_results(self, mock_connector: Mock): response = api.get_scan_results("foo bar") mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results", - params={}, - raise_for_status=False + "/scans/foo%20bar/results", params={}, raise_for_status=False ) self.assertEqual(expected_response, response) @@ -198,7 +216,7 @@ def test_get_scan_results_with_ranges(self, mock_connector: Mock): mock_connector.get.assert_called_once_with( "/scans/foo%20bar/results", params={"range": "12"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -208,7 +226,7 @@ def test_get_scan_results_with_ranges(self, mock_connector: Mock): mock_connector.get.assert_called_once_with( "/scans/foo%20bar/results", params={"range": "12-34"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -218,7 +236,7 @@ def test_get_scan_results_with_ranges(self, mock_connector: Mock): mock_connector.get.assert_called_once_with( "/scans/foo%20bar/results", params={"range": "0-23"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -230,23 +248,20 @@ def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): # range start self.assertRaises( - InvalidArgumentType, - api.get_scan_results, - "foo bar", "invalid" + InvalidArgumentType, api.get_scan_results, "foo bar", "invalid" ) # range start and end self.assertRaises( - InvalidArgumentType, - api.get_scan_results, - "foo bar", 12, "invalid" + InvalidArgumentType, api.get_scan_results, "foo bar", 12, "invalid" ) # range end only self.assertRaises( InvalidArgumentType, api.get_scan_results, - "foo bar", range_end="invalid" + "foo bar", + range_end="invalid", ) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) @@ -257,8 +272,7 @@ def test_get_scan_result(self, mock_connector: Mock): response = api.get_scan_result("foo bar", "baz qux") mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/results/baz%20qux", - raise_for_status=False + "/scans/foo%20bar/results/baz%20qux", raise_for_status=False ) self.assertEqual(expected_response, response) @@ -270,8 +284,7 @@ def test_get_scan_status(self, mock_connector: Mock): response = api.get_scan_status("foo bar") mock_connector.get.assert_called_once_with( - "/scans/foo%20bar/status", - raise_for_status=False + "/scans/foo%20bar/status", raise_for_status=False ) self.assertEqual(expected_response, response) @@ -285,7 +298,7 @@ def test_run_scan_action(self, mock_connector: Mock): mock_connector.post_json.assert_called_once_with( "/scans/foo%20bar", {"action": "do-something"}, - raise_for_status=False + raise_for_status=False, ) self.assertEqual(expected_response, response) @@ -297,9 +310,7 @@ def test_start_scan(self, mock_connector: Mock): response = api.start_scan("foo bar") mock_connector.post_json.assert_called_once_with( - "/scans/foo%20bar", - {"action": "start"}, - raise_for_status=False + "/scans/foo%20bar", {"action": "start"}, raise_for_status=False ) self.assertEqual(expected_response, response) @@ -311,9 +322,7 @@ def test_stop_scan(self, mock_connector: Mock): response = api.stop_scan("foo bar") mock_connector.post_json.assert_called_once_with( - "/scans/foo%20bar", - {"action": "stop"}, - raise_for_status=False + "/scans/foo%20bar", {"action": "stop"}, raise_for_status=False ) self.assertEqual(expected_response, response) @@ -325,8 +334,7 @@ def test_get_vts(self, mock_connector: Mock): response = api.get_vts() mock_connector.get.assert_called_once_with( - "/vts", - raise_for_status=False + "/vts", raise_for_status=False ) self.assertEqual(expected_response, response) @@ -338,7 +346,6 @@ def test_get_vt(self, mock_connector: Mock): response = api.get_vt("foo bar") mock_connector.get.assert_called_once_with( - "/vts/foo%20bar", - raise_for_status=False + "/vts/foo%20bar", raise_for_status=False ) self.assertEqual(expected_response, response) From 89a14ab7c26ab86e9d6749c5104fd565cff15703 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 08:53:23 +0200 Subject: [PATCH 04/18] Address linter warnings --- gvm/http/core/_api.py | 5 +++-- gvm/http/core/connector.py | 6 +++--- gvm/http/core/headers.py | 6 +++--- gvm/http/core/response.py | 5 +++-- gvm/http/openvasd/openvasd1.py | 3 +-- tests/http/core/test_api.py | 2 +- tests/http/core/test_connector.py | 9 ++++----- tests/http/openvasd/test_openvasd1.py | 1 - 8 files changed, 18 insertions(+), 19 deletions(-) diff --git a/gvm/http/core/_api.py b/gvm/http/core/_api.py index bd7a5c1f..e8418d0f 100644 --- a/gvm/http/core/_api.py +++ b/gvm/http/core/_api.py @@ -27,8 +27,9 @@ def __init__( api_key: Optional API key for authentication """ - "The connector handling the HTTP(S) connection" + self._connector: HttpApiConnector = connector + "The connector handling the HTTP(S) connection" - "Optional API key for authentication" self._api_key: Optional[str] = api_key + "Optional API key for authentication" diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index e683edf7..f46135e0 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -7,7 +7,7 @@ """ import urllib.parse -from typing import Optional, Tuple, Dict, Any +from typing import Any, Dict, Optional, Tuple from requests import Session @@ -36,8 +36,8 @@ def url_join(cls, base: str, rel_path: str) -> str: """ if base.endswith("/"): return urllib.parse.urljoin(base, rel_path) - else: - return urllib.parse.urljoin(base + "/", rel_path) + + return urllib.parse.urljoin(base + "/", rel_path) def __init__( self, diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 6d384049..4c45ef41 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -7,7 +7,7 @@ """ from dataclasses import dataclass -from typing import Self, Dict, Optional +from typing import Dict, Optional, Self @dataclass @@ -45,8 +45,8 @@ def from_string( if header_string: parts = header_string.split(";") media_type = parts[0].strip() - for param in parts[1:]: - param = param.strip() + for part in parts[1:]: + param = part.strip() if "=" in param: key, value = map(lambda x: x.strip(), param.split("=", 1)) params[key] = value diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 30b3fb97..37c3c37e 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,8 +7,9 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Self, Optional -from requests import Request, Response +from typing import Any, Dict, Optional, Self + +from requests import Response from gvm.http.core.headers import ContentType diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index 86526a72..a15e9808 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -7,10 +7,9 @@ """ import urllib.parse -from typing import Optional, Any +from typing import Any, Optional from gvm.errors import InvalidArgumentType - from gvm.http.core._api import GvmHttpApi from gvm.http.core.connector import HttpApiConnector from gvm.http.core.response import HttpResponse diff --git a/tests/http/core/test_api.py b/tests/http/core/test_api.py index a45dd7f2..fe3876b1 100644 --- a/tests/http/core/test_api.py +++ b/tests/http/core/test_api.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: GPL-3.0-or-later import unittest -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch from gvm.http.core._api import GvmHttpApi diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index c2021652..22cfb2e0 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -4,16 +4,15 @@ import json import unittest from http import HTTPStatus -from typing import Optional, Any -from unittest.mock import patch, MagicMock, Mock -from requests.exceptions import HTTPError +from typing import Any, Optional +from unittest.mock import MagicMock, Mock, patch -from gvm.http.core.connector import HttpApiConnector import requests as requests_lib +from requests.exceptions import HTTPError +from gvm.http.core.connector import HttpApiConnector from gvm.http.core.headers import ContentType - TEST_JSON_HEADERS = { "content-type": "application/json;charset=utf-8", "x-example": "some-test-header", diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index 09b3a5c8..91c11b71 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -8,7 +8,6 @@ from unittest.mock import Mock, patch from gvm.errors import InvalidArgumentType - from gvm.http.core.headers import ContentType from gvm.http.core.response import HttpResponse from gvm.http.openvasd.openvasd1 import OpenvasdHttpApiV1 From a961bea1f0e11b880dff61a1a9f38127a3ee9cfd Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 09:24:32 +0200 Subject: [PATCH 05/18] Break some overlong docstring lines --- gvm/http/core/connector.py | 26 ++++++++++------ gvm/http/openvasd/openvasd1.py | 57 ++++++++++++++++++++++------------ 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index f46135e0..ff6bedf7 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -31,8 +31,8 @@ def url_join(cls, base: str, rel_path: str) -> str: """ Combines a base URL and a relative path into one URL. - Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it - ends with "/". + Unlike `urrlib.parse.urljoin` the base path will always be the parent of the + relative path as if it ends with "/". """ if base.endswith("/"): return urllib.parse.urljoin(base, rel_path) @@ -50,12 +50,14 @@ def __init__( Create a new HTTP API Connector. Args: - base_url: The base server URL to which request-specific paths will be appended for the requests + base_url: The base server URL to which request-specific paths will be appended + for the requests server_ca_path: Optional path to a CA certificate for verifying the server. If none is given, server verification is disabled. - client_cert_paths: Optional path to a client private key and certificate for authentication. - Can be a combined key and certificate file or a tuple containing separate files. - The key must not be encrypted. + client_cert_paths: Optional path to a client private key and certificate + for authentication. + Can be a combined key and certificate file or a tuple containing separate files. + The key must not be encrypted. """ self.base_url = base_url @@ -90,7 +92,8 @@ def delete( Args: rel_path: The relative path for the request - raise_for_status: Whether to raise an error if response has a non-success HTTP status code + raise_for_status: Whether to raise an error if response has a + non-success HTTP status code params: Optional dict of URL-encoded parameters headers: Optional additional headers added to the request @@ -116,7 +119,8 @@ def get( Args: rel_path: The relative path for the request - raise_for_status: Whether to raise an error if response has a non-success HTTP status code + raise_for_status: Whether to raise an error if response has a + non-success HTTP status code params: Optional dict of URL-encoded parameters headers: Optional additional headers added to the request @@ -139,12 +143,14 @@ def post_json( headers: Optional[Dict[str, str]] = None, ) -> HttpResponse: """ - Sends a ``POST`` request, using the given JSON-compatible object as the request body, and returns the response. + Sends a ``POST`` request, using the given JSON-compatible object as the + request body, and returns the response. Args: rel_path: The relative path for the request json: The object to use as the request body. - raise_for_status: Whether to raise an error if response has a non-success HTTP status code + raise_for_status: Whether to raise an error if response has a + non-success HTTP status code params: Optional dict of URL-encoded parameters headers: Optional additional headers added to the request diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index a15e9808..730fcb21 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -44,7 +44,8 @@ def get_health_alive( Gets the "alive" health status of the scanner. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /health/alive in the openvasd API documentation. @@ -60,7 +61,8 @@ def get_health_ready( Gets the "ready" health status of the scanner. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /health/ready in the openvasd API documentation. @@ -76,7 +78,8 @@ def get_health_started( Gets the "started" health status of the scanner. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /health/started in the openvasd API documentation. @@ -92,7 +95,8 @@ def get_notus_os_list( Gets the list of operating systems available in Notus. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /notus in the openvasd API documentation. @@ -110,9 +114,11 @@ def run_notus_scan( Gets the Notus results for a given operating system and list of packages. Args: - os: Name of the operating system as returned in the list returned by get_notus_os_products. + os: Name of the operating system as returned in the list returned by + get_notus_os_products. package_list: List of package names to check. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /notus/{os} in the openvasd API documentation. @@ -131,7 +137,8 @@ def get_scan_preferences( Gets the list of available scan preferences. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans/preferences in the openvasd API documentation. @@ -157,7 +164,8 @@ def create_scan( target: The target definition for the scan. vt_selection: The VT selection for the scan, including VT preferences. scanner_params: The optional scanner parameters. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans in the openvasd API documentation. @@ -180,7 +188,8 @@ def delete_scan( Args: scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. @@ -195,7 +204,8 @@ def get_scans(self, *, raise_for_status: bool = False) -> HttpResponse: Gets the list of available scans. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans in the openvasd API documentation. @@ -210,7 +220,8 @@ def get_scan( Args: scan_id: The id of the scan to get. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans/{id} in the openvasd API documentation. @@ -235,7 +246,8 @@ def get_scan_results( scan_id: The id of the scan to get the results of. range_start: Optional index of the first result to get. range_end: Optional index of the last result to get. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans/{id}/results in the openvasd API documentation. @@ -286,7 +298,8 @@ def get_scan_result( Args: scan_id: The id of the scan to get the results of. result_id: The id of the result to get. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. @@ -307,7 +320,8 @@ def get_scan_status( Args: scan_id: The id of the scan to get the status of. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /scans/{id}/{rid} in the openvasd API documentation. @@ -326,7 +340,8 @@ def run_scan_action( Args: scan_id: The id of the scan to perform the action on. scan_action: The action to perform. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. @@ -347,7 +362,8 @@ def start_scan( Args: scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. @@ -364,7 +380,8 @@ def stop_scan( Args: scan_id: The id of the scan to perform the action on. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See POST /scans/{id} in the openvasd API documentation. @@ -378,7 +395,8 @@ def get_vts(self, *, raise_for_status: bool = False) -> HttpResponse: Gets a list of available vulnerability tests (VTs) on the scanner. Args: - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See GET /vts in the openvasd API documentation. @@ -393,7 +411,8 @@ def get_vt( Args: oid: OID of the VT to get. - raise_for_status: Whether to raise an error if scanner responded with a non-success HTTP status code. + raise_for_status: Whether to raise an error if scanner responded with a + non-success HTTP status code. Return: The HTTP response. See DELETE /scans/{id} in the openvasd API documentation. From 2e2878874d444e688bc6418fa4cfe1985188e443 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 09:26:47 +0200 Subject: [PATCH 06/18] Address linter warnings in HTTP tests --- tests/http/core/test_response.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/http/core/test_response.py b/tests/http/core/test_response.py index 98657a19..5dbbcf67 100644 --- a/tests/http/core/test_response.py +++ b/tests/http/core/test_response.py @@ -12,6 +12,7 @@ class HttpResponseFromRequestsLibTestCase(unittest.TestCase): def test_from_empty_response(self): + # pylint: disable=protected-access requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) requests_response._content = b"" @@ -23,6 +24,7 @@ def test_from_empty_response(self): self.assertEqual({}, response.headers) def test_from_plain_text_response(self): + # pylint: disable=protected-access requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) requests_response.headers.update({"content-type": "text/plain"}) @@ -35,6 +37,7 @@ def test_from_plain_text_response(self): self.assertEqual({"content-type": "text/plain"}, response.headers) def test_from_json_response(self): + # pylint: disable=protected-access test_content = {"foo": ["bar", 12345], "baz": True} requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.OK) @@ -48,6 +51,7 @@ def test_from_json_response(self): self.assertEqual({"content-type": "application/json"}, response.headers) def test_from_error_json_response(self): + # pylint: disable=protected-access test_content = {"error": "Internal server error"} requests_response = requests_lib.Response() requests_response.status_code = int(HTTPStatus.INTERNAL_SERVER_ERROR) From 3636a339aec33352e0541b50c9cd60c80e32d2ad Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 09:56:01 +0200 Subject: [PATCH 07/18] Use workaround for typing.Self missing in older Python versions --- gvm/http/core/headers.py | 5 ++++- gvm/http/core/response.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 4c45ef41..21e9b89b 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -7,7 +7,10 @@ """ from dataclasses import dataclass -from typing import Dict, Optional, Self +from typing import Dict, Optional, TypeVar + + +Self = TypeVar("Self", bound="ContentType") @dataclass diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 37c3c37e..0e9af4ae 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,13 +7,16 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Optional, Self +from typing import Any, Dict, Optional, TypeVar from requests import Response from gvm.http.core.headers import ContentType +Self = TypeVar("Self", bound="HttpResponse") + + @dataclass class HttpResponse: """ From 73ffbbad764784f571b64f8c11b446d5b07ccc0c Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 09:58:57 +0200 Subject: [PATCH 08/18] Move misplaced assertion in test_create_scan to correct place --- tests/http/openvasd/test_openvasd1.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index 91c11b71..c8edc837 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -136,6 +136,7 @@ def test_create_scan(self, mock_connector: Mock): }, raise_for_status=False, ) + self.assertEqual(expected_response, response) # scan with all options mock_connector.post_json.reset_mock() @@ -154,7 +155,6 @@ def test_create_scan(self, mock_connector: Mock): raise_for_status=False, ) self.assertEqual(expected_response, response) - self.assertEqual(expected_response, response) @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) def test_delete_scan(self, mock_connector: Mock): From 44f67eabe332ab703a01dc7c62590e2324ae570d Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:36:45 +0200 Subject: [PATCH 09/18] Clean up type hints for HTTP APIs --- gvm/http/core/connector.py | 4 ++-- gvm/http/core/headers.py | 18 +++++++++--------- gvm/http/core/response.py | 7 ++++--- gvm/http/openvasd/openvasd1.py | 6 +++--- poetry.lock | 17 ++++++++++++++++- pyproject.toml | 1 + tests/http/openvasd/test_openvasd1.py | 6 +++--- 7 files changed, 38 insertions(+), 21 deletions(-) diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index ff6bedf7..7687a3b9 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -7,7 +7,7 @@ """ import urllib.parse -from typing import Any, Dict, Optional, Tuple +from typing import Any, Dict, Optional, Tuple, Union from requests import Session @@ -44,7 +44,7 @@ def __init__( base_url: str, *, server_ca_path: Optional[str] = None, - client_cert_paths: Optional[str | Tuple[str]] = None, + client_cert_paths: Optional[Union[str, Tuple[str]]] = None, ): """ Create a new HTTP API Connector. diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 21e9b89b..3ecec720 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -7,8 +7,7 @@ """ from dataclasses import dataclass -from typing import Dict, Optional, TypeVar - +from typing import Dict, Optional, TypeVar, Type, Union Self = TypeVar("Self", bound="ContentType") @@ -22,7 +21,7 @@ class ContentType: media_type: str 'The MIME media type, e.g. "application/json"' - params: Dict[str, str] + params: Dict[str, Union[bool, str]] "Dictionary of parameters in the content type header" charset: Optional[str] @@ -30,10 +29,10 @@ class ContentType: @classmethod def from_string( - cls, - header_string: str, - fallback_media_type: Optional[str] = "application/octet-stream", - ) -> Self: + cls: Type[Self], + header_string: Optional[str], + fallback_media_type: str = "application/octet-stream", + ) -> "ContentType": """ Parse the content of content type header into a ContentType object. @@ -42,12 +41,13 @@ def from_string( fallback_media_type: The media type to use if the `header_string` is `None` or empty. """ media_type = fallback_media_type - params = {} + params: Dict[str, Union[bool, str]] = {} charset = None if header_string: parts = header_string.split(";") - media_type = parts[0].strip() + if len(parts) > 0: + media_type = parts[0].strip() for part in parts[1:]: param = part.strip() if "=" in param: diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 0e9af4ae..2436bd1c 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,9 +7,10 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Optional, TypeVar +from typing import Any, Dict, Optional, TypeVar, Type, Union from requests import Response +from requests.structures import CaseInsensitiveDict from gvm.http.core.headers import ContentType @@ -29,14 +30,14 @@ class HttpResponse: status: int "HTTP status code of the response" - headers: Dict[str, str] + headers: Union[Dict[str, str], CaseInsensitiveDict[str]] "Dict containing the headers of the response" content_type: Optional[ContentType] "The content type of the response if it was included in the headers" @classmethod - def from_requests_lib(cls, r: Response) -> Self: + def from_requests_lib(cls: Type[Self], r: Response) -> "HttpResponse": """ Creates a new HTTP response object from a Request object created by the "Requests" library. diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/http/openvasd/openvasd1.py index 730fcb21..1255870c 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/http/openvasd/openvasd1.py @@ -7,7 +7,7 @@ """ import urllib.parse -from typing import Any, Optional +from typing import Any, Optional, Union from gvm.errors import InvalidArgumentType from gvm.http.core._api import GvmHttpApi @@ -150,7 +150,7 @@ def get_scan_preferences( def create_scan( self, target: dict[str, Any], - vt_selection: dict[str, Any], + vt_selection: list[dict[str, Any]], scanner_params: Optional[dict[str, Any]] = None, *, raise_for_status: bool = False, @@ -288,7 +288,7 @@ def get_scan_results( def get_scan_result( self, scan_id: str, - result_id: str | int, + result_id: Union[str, int], *, raise_for_status: bool = False, ) -> HttpResponse: diff --git a/poetry.lock b/poetry.lock index 4d708212..16583008 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1729,6 +1729,21 @@ files = [ [package.dependencies] cryptography = ">=37.0.0" +[[package]] +name = "types-requests" +version = "2.32.0.20250328" +description = "Typing stubs for requests" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "types_requests-2.32.0.20250328-py3-none-any.whl", hash = "sha256:72ff80f84b15eb3aa7a8e2625fffb6a93f2ad5a0c20215fc1dcfa61117bcb2a2"}, + {file = "types_requests-2.32.0.20250328.tar.gz", hash = "sha256:c9e67228ea103bd811c96984fac36ed2ae8da87a36a633964a21f199d60baf32"}, +] + +[package.dependencies] +urllib3 = ">=2" + [[package]] name = "typing-extensions" version = "4.13.1" @@ -1783,4 +1798,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9.2" -content-hash = "e2f128bc4bfefbf3dc7e3ab276dd24661b4caf22117449180830cdd8b258cfb0" +content-hash = "7682bd17565b00ef9008054b125043ba52eb2a55b64d3099282b60dc3b840ffd" diff --git a/pyproject.toml b/pyproject.toml index 4779f4f5..2ed306cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" requests = "^2.32.3" +types-requests = "^2.32.0.20250328" [tool.poetry.group.dev.dependencies] coverage = ">=7.2" diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index c8edc837..7403db7a 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -20,7 +20,7 @@ def new_mock_empty_response( if status is None: status = int(HTTPStatus.NO_CONTENT) if headers is None: - headers = [] + headers = {} content_type = ContentType.from_string(None) return HttpResponse( body=None, status=status, headers=headers, content_type=content_type @@ -126,7 +126,7 @@ def test_create_scan(self, mock_connector: Mock): # minimal scan response = api.create_scan( {"hosts": "somehost"}, - ["some_vt", "another_vt"], + [{"oid": "some_vt"}, {"oid": "another_vt"}], ) mock_connector.post_json.assert_called_once_with( "/scans", @@ -142,7 +142,7 @@ def test_create_scan(self, mock_connector: Mock): mock_connector.post_json.reset_mock() response = api.create_scan( {"hosts": "somehost"}, - ["some_vt", "another_vt"], + [{"oid": "some_vt"}, {"oid": "another_vt"}], {"my_scanner_param": "abc"}, ) mock_connector.post_json.assert_called_once_with( From 1c1ac5bbb3044a7c87d49c60c094a5fb2a24782a Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:43:46 +0200 Subject: [PATCH 10/18] Reformat gvm/http/core/_api.py --- gvm/http/core/_api.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gvm/http/core/_api.py b/gvm/http/core/_api.py index e8418d0f..beb25b73 100644 --- a/gvm/http/core/_api.py +++ b/gvm/http/core/_api.py @@ -27,7 +27,6 @@ def __init__( api_key: Optional API key for authentication """ - self._connector: HttpApiConnector = connector "The connector handling the HTTP(S) connection" From 0709d378cc22fc26bd4b98f0d30569126e8c62fb Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:47:47 +0200 Subject: [PATCH 11/18] Use correct vts list in test_create_scan assertions --- tests/http/openvasd/test_openvasd1.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index 7403db7a..d3677409 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -132,7 +132,7 @@ def test_create_scan(self, mock_connector: Mock): "/scans", { "target": {"hosts": "somehost"}, - "vts": ["some_vt", "another_vt"], + "vts": [{"oid": "some_vt"}, {"oid": "another_vt"}], }, raise_for_status=False, ) @@ -149,7 +149,7 @@ def test_create_scan(self, mock_connector: Mock): "/scans", { "target": {"hosts": "somehost"}, - "vts": ["some_vt", "another_vt"], + "vts": [{"oid": "some_vt"}, {"oid": "another_vt"}], "scan_preferences": {"my_scanner_param": "abc"}, }, raise_for_status=False, From 8a1bab3e3efbbddaba61536943553c0194aa019e Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:50:29 +0200 Subject: [PATCH 12/18] Reorganize imports --- gvm/http/core/headers.py | 2 +- gvm/http/core/response.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/gvm/http/core/headers.py b/gvm/http/core/headers.py index 3ecec720..1af22eaf 100644 --- a/gvm/http/core/headers.py +++ b/gvm/http/core/headers.py @@ -7,7 +7,7 @@ """ from dataclasses import dataclass -from typing import Dict, Optional, TypeVar, Type, Union +from typing import Dict, Optional, Type, TypeVar, Union Self = TypeVar("Self", bound="ContentType") diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index 2436bd1c..b1579d21 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,14 +7,13 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Optional, TypeVar, Type, Union +from typing import Any, Dict, Optional, Type, TypeVar, Union from requests import Response from requests.structures import CaseInsensitiveDict from gvm.http.core.headers import ContentType - Self = TypeVar("Self", bound="HttpResponse") From e11fd5fb87bfb2ce5ea2fb7d368e17ce5cf0ed7a Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 10:57:09 +0200 Subject: [PATCH 13/18] Make type hints for mock HTTP responses work in older Python --- tests/http/core/test_connector.py | 6 +++--- tests/http/openvasd/test_openvasd1.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index 22cfb2e0..bf5c4fb8 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -4,7 +4,7 @@ import json import unittest from http import HTTPStatus -from typing import Any, Optional +from typing import Any, Optional, Union from unittest.mock import MagicMock, Mock, patch import requests as requests_lib @@ -36,7 +36,7 @@ def new_mock_empty_response( - status: Optional[int | HTTPStatus] = None, + status: Optional[Union[int, HTTPStatus]] = None, ) -> requests_lib.Response: # pylint: disable=protected-access response = requests_lib.Response() @@ -50,7 +50,7 @@ def new_mock_empty_response( def new_mock_json_response( content: Optional[Any] = None, - status: Optional[int | HTTPStatus] = None, + status: Optional[Union[int, HTTPStatus]] = None, ) -> requests_lib.Response: # pylint: disable=protected-access response = requests_lib.Response() diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/http/openvasd/test_openvasd1.py index d3677409..c5bd90f5 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/http/openvasd/test_openvasd1.py @@ -4,7 +4,7 @@ import unittest from http import HTTPStatus -from typing import Optional +from typing import Optional, Union from unittest.mock import Mock, patch from gvm.errors import InvalidArgumentType @@ -14,7 +14,7 @@ def new_mock_empty_response( - status: Optional[int | HTTPStatus] = None, + status: Optional[Union[int, HTTPStatus]] = None, headers: Optional[dict[str, str]] = None, ): if status is None: From ec59911fd3af046a5138c635a517b67e658670fb Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 4 Apr 2025 13:45:48 +0200 Subject: [PATCH 14/18] Move types-requests to dev dependencies --- poetry.lock | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 16583008..d833c338 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1735,7 +1735,7 @@ version = "2.32.0.20250328" description = "Typing stubs for requests" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["dev"] files = [ {file = "types_requests-2.32.0.20250328-py3-none-any.whl", hash = "sha256:72ff80f84b15eb3aa7a8e2625fffb6a93f2ad5a0c20215fc1dcfa61117bcb2a2"}, {file = "types_requests-2.32.0.20250328.tar.gz", hash = "sha256:c9e67228ea103bd811c96984fac36ed2ae8da87a36a633964a21f199d60baf32"}, @@ -1798,4 +1798,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9.2" -content-hash = "7682bd17565b00ef9008054b125043ba52eb2a55b64d3099282b60dc3b840ffd" +content-hash = "3bf011b284722cf688f6a4094a25cb4a60a3dc986b64ee9325fd3e535a760915" diff --git a/pyproject.toml b/pyproject.toml index 2ed306cd..055e679e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" requests = "^2.32.3" -types-requests = "^2.32.0.20250328" [tool.poetry.group.dev.dependencies] coverage = ">=7.2" @@ -47,6 +46,7 @@ furo = ">=2022.6.21" lxml-stubs = "^0.5.1" types-paramiko = "^3.4.0.20240205" git-cliff = "^2.8.0" +types-requests = "^2.32.0.20250328" [tool.black] line-length = 80 From 9d3da00394dddd2087e56b49b20d308f83c3610d Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 11 Apr 2025 09:31:43 +0200 Subject: [PATCH 15/18] Use httpx library instead of requests --- gvm/http/core/connector.py | 54 ++++--- gvm/http/core/response.py | 7 +- poetry.lock | 23 +-- pyproject.toml | 2 +- tests/http/core/test_connector.py | 256 ++++++++++++++++-------------- 5 files changed, 186 insertions(+), 156 deletions(-) diff --git a/gvm/http/core/connector.py b/gvm/http/core/connector.py index 7687a3b9..962a0643 100644 --- a/gvm/http/core/connector.py +++ b/gvm/http/core/connector.py @@ -7,9 +7,9 @@ """ import urllib.parse -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, MutableMapping, Optional, Tuple, Union -from requests import Session +from httpx import Client from gvm.http.core.response import HttpResponse @@ -20,11 +20,18 @@ class HttpApiConnector: """ @classmethod - def _new_session(cls): + def _new_client( + cls, + server_ca_path: Optional[str] = None, + client_cert_paths: Optional[Union[str, Tuple[str]]] = None, + ): """ - Creates a new session + Creates a new httpx client """ - return Session() + return Client( + verify=server_ca_path if server_ca_path else False, + cert=client_cert_paths, + ) @classmethod def url_join(cls, base: str, rel_path: str) -> str: @@ -63,29 +70,26 @@ def __init__( self.base_url = base_url "The base server URL to which request-specific paths will be appended for the requests" - self._session = self._new_session() - "Internal session handling the HTTP requests" - if server_ca_path: - self._session.verify = server_ca_path - if client_cert_paths: - self._session.cert = client_cert_paths + self._client: Client = self._new_client( + server_ca_path, client_cert_paths + ) - def update_headers(self, new_headers: Dict[str, str]) -> None: + def update_headers(self, new_headers: MutableMapping[str, str]) -> None: """ Updates the headers sent with each request, e.g. for passing an API key Args: - new_headers: Dict containing the new headers + new_headers: MutableMapping, e.g. dict, containing the new headers """ - self._session.headers.update(new_headers) + self._client.headers.update(new_headers) def delete( self, rel_path: str, *, raise_for_status: bool = True, - params: Optional[Dict[str, str]] = None, - headers: Optional[Dict[str, str]] = None, + params: Optional[MutableMapping[str, str]] = None, + headers: Optional[MutableMapping[str, str]] = None, ) -> HttpResponse: """ Sends a ``DELETE`` request and returns the response. @@ -94,14 +98,14 @@ def delete( rel_path: The relative path for the request raise_for_status: Whether to raise an error if response has a non-success HTTP status code - params: Optional dict of URL-encoded parameters + params: Optional MutableMapping, e.g. dict of URL-encoded parameters headers: Optional additional headers added to the request Return: The HTTP response. """ url = self.url_join(self.base_url, rel_path) - r = self._session.delete(url, params=params, headers=headers) + r = self._client.delete(url, params=params, headers=headers) if raise_for_status: r.raise_for_status() return HttpResponse.from_requests_lib(r) @@ -111,8 +115,8 @@ def get( rel_path: str, *, raise_for_status: bool = True, - params: Optional[Dict[str, str]] = None, - headers: Optional[Dict[str, str]] = None, + params: Optional[MutableMapping[str, str]] = None, + headers: Optional[MutableMapping[str, str]] = None, ) -> HttpResponse: """ Sends a ``GET`` request and returns the response. @@ -128,7 +132,7 @@ def get( The HTTP response. """ url = self.url_join(self.base_url, rel_path) - r = self._session.get(url, params=params, headers=headers) + r = self._client.get(url, params=params, headers=headers) if raise_for_status: r.raise_for_status() return HttpResponse.from_requests_lib(r) @@ -139,8 +143,8 @@ def post_json( json: Any, *, raise_for_status: bool = True, - params: Optional[Dict[str, str]] = None, - headers: Optional[Dict[str, str]] = None, + params: Optional[MutableMapping[str, str]] = None, + headers: Optional[MutableMapping[str, str]] = None, ) -> HttpResponse: """ Sends a ``POST`` request, using the given JSON-compatible object as the @@ -151,14 +155,14 @@ def post_json( json: The object to use as the request body. raise_for_status: Whether to raise an error if response has a non-success HTTP status code - params: Optional dict of URL-encoded parameters + params: Optional MutableMapping, e.g. dict of URL-encoded parameters headers: Optional additional headers added to the request Return: The HTTP response. """ url = self.url_join(self.base_url, rel_path) - r = self._session.post(url, json=json, params=params, headers=headers) + r = self._client.post(url, json=json, params=params, headers=headers) if raise_for_status: r.raise_for_status() return HttpResponse.from_requests_lib(r) diff --git a/gvm/http/core/response.py b/gvm/http/core/response.py index b1579d21..34216063 100644 --- a/gvm/http/core/response.py +++ b/gvm/http/core/response.py @@ -7,10 +7,9 @@ """ from dataclasses import dataclass -from typing import Any, Dict, Optional, Type, TypeVar, Union +from typing import Any, MutableMapping, Optional, Type, TypeVar -from requests import Response -from requests.structures import CaseInsensitiveDict +from httpx import Response from gvm.http.core.headers import ContentType @@ -29,7 +28,7 @@ class HttpResponse: status: int "HTTP status code of the response" - headers: Union[Dict[str, str], CaseInsensitiveDict[str]] + headers: MutableMapping[str, str] "Dict containing the headers of the response" content_type: Optional[ContentType] diff --git a/poetry.lock b/poetry.lock index d833c338..24e5df1a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -18,7 +18,7 @@ version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, @@ -350,7 +350,7 @@ version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, @@ -641,7 +641,7 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, @@ -695,7 +695,7 @@ version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -735,7 +735,7 @@ version = "1.0.7" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, @@ -757,7 +757,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1365,7 +1365,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -1474,7 +1474,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1750,11 +1750,12 @@ version = "4.13.1" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69"}, {file = "typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff"}, ] +markers = {main = "python_version < \"3.13\""} [[package]] name = "urllib3" @@ -1762,7 +1763,7 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["dev"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, @@ -1798,4 +1799,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9.2" -content-hash = "3bf011b284722cf688f6a4094a25cb4a60a3dc986b64ee9325fd3e535a760915" +content-hash = "ce97f09f26daaf16ce1679b6ed4df11d0a2232c8dad936fc523593cba9247863" diff --git a/pyproject.toml b/pyproject.toml index 055e679e..c8d43b6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ packages = [{ include = "gvm" }, { include = "tests", format = "sdist" }] python = "^3.9.2" paramiko = ">=2.7.1" lxml = ">=4.5.0" -requests = "^2.32.3" +httpx = "^0.28.1" [tool.poetry.group.dev.dependencies] coverage = ">=7.2" diff --git a/tests/http/core/test_connector.py b/tests/http/core/test_connector.py index bf5c4fb8..c91af063 100644 --- a/tests/http/core/test_connector.py +++ b/tests/http/core/test_connector.py @@ -1,14 +1,15 @@ # SPDX-FileCopyrightText: 2025 Greenbone AG # # SPDX-License-Identifier: GPL-3.0-or-later + import json import unittest from http import HTTPStatus from typing import Any, Optional, Union from unittest.mock import MagicMock, Mock, patch -import requests as requests_lib -from requests.exceptions import HTTPError +import httpx +from httpx import HTTPError from gvm.http.core.connector import HttpApiConnector from gvm.http.core.headers import ContentType @@ -18,6 +19,8 @@ "x-example": "some-test-header", } +JSON_EXTRA_HEADERS = {"content-length": "26"} + TEST_JSON_CONTENT_TYPE = ContentType( media_type="application/json", params={"charset": "utf-8"}, @@ -35,44 +38,52 @@ TEST_JSON_REQUEST_BODY = {"request_number": 5} -def new_mock_empty_response( +def new_mock_empty_response_func( + method: str, status: Optional[Union[int, HTTPStatus]] = None, -) -> requests_lib.Response: - # pylint: disable=protected-access - response = requests_lib.Response() - response._content = b"" - if status is None: - response.status_code = int(HTTPStatus.NO_CONTENT) - else: - response.status_code = int(status) - return response +) -> httpx.Response: + def response_func(request_url, *_args, **_kwargs): + response = httpx.Response( + request=httpx.Request(method, request_url), + content=b"", + status_code=( + int(HTTPStatus.NO_CONTENT) if status is None else int(status) + ), + ) + return response + + return response_func -def new_mock_json_response( +def new_mock_json_response_func( + method: str, content: Optional[Any] = None, status: Optional[Union[int, HTTPStatus]] = None, -) -> requests_lib.Response: - # pylint: disable=protected-access - response = requests_lib.Response() - response._content = json.dumps(content).encode() - - if status is None: - response.status_code = int(HTTPStatus.OK) - else: - response.status_code = int(status) +) -> httpx.Response: + def response_func(request_url, *_args, **_kwargs): + response = httpx.Response( + request=httpx.Request(method, request_url), + content=json.dumps(content).encode(), + status_code=int(HTTPStatus.OK) if status is None else int(status), + headers=TEST_JSON_HEADERS, + ) + return response - response.headers.update(TEST_JSON_HEADERS) - return response + return response_func -def new_mock_session(*, headers: Optional[dict] = None) -> Mock: - mock = Mock(spec=requests_lib.Session) +def new_mock_client(*, headers: Optional[dict] = None) -> Mock: + mock = Mock(spec=httpx.Client) mock.headers = headers if headers is not None else {} return mock class HttpApiConnectorTestCase(unittest.TestCase): # pylint: disable=protected-access + def assertHasHeaders(self, expected_headers, actual_headers): + self.assertEqual( + expected_headers | dict(actual_headers), actual_headers + ) def test_url_join(self): self.assertEqual( @@ -101,21 +112,22 @@ def test_url_join(self): ) def test_new_session(self): - new_session = HttpApiConnector._new_session() - self.assertIsInstance(new_session, requests_lib.Session) + new_client = HttpApiConnector._new_client() + self.assertIsInstance(new_client, httpx.Client) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_basic_init(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_basic_init(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() connector = HttpApiConnector("http://localhost") self.assertEqual("http://localhost", connector.base_url) - self.assertEqual(mock_session, connector._session) + new_client_mock.assert_called_once_with(None, None) + self.assertEqual({}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_https_init(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_https_init(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() connector = HttpApiConnector( "https://localhost", @@ -124,13 +136,12 @@ def test_https_init(self, new_session_mock: MagicMock): ) self.assertEqual("https://localhost", connector.base_url) - self.assertEqual(mock_session, connector._session) - self.assertEqual("foo.crt", mock_session.verify) - self.assertEqual("bar.key", mock_session.cert) + new_client_mock.assert_called_once_with("foo.crt", "bar.key") + self.assertEqual({}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_update_headers(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_update_headers(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() connector = HttpApiConnector( "http://localhost", @@ -138,14 +149,14 @@ def test_update_headers(self, new_session_mock: MagicMock): connector.update_headers({"x-foo": "bar"}) connector.update_headers({"x-baz": "123"}) - self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_session.headers) + self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_delete(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_delete(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.delete.return_value = new_mock_json_response( - TEST_JSON_RESPONSE_BODY + mock_client.delete.side_effect = new_mock_json_response_func( + "DELETE", TEST_JSON_RESPONSE_BODY ) connector = HttpApiConnector("https://localhost") response = connector.delete( @@ -155,19 +166,21 @@ def test_delete(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.delete.assert_called_once_with( + mock_client.delete.assert_called_once_with( "https://localhost/foo", params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_minimal_delete(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_minimal_delete(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.delete.return_value = new_mock_empty_response() + mock_client.delete.side_effect = new_mock_empty_response_func("DELETE") connector = HttpApiConnector("https://localhost") response = connector.delete("foo") @@ -176,29 +189,30 @@ def test_minimal_delete(self, new_session_mock: MagicMock): self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) self.assertEqual({}, response.headers) - mock_session.delete.assert_called_once_with( + mock_client.delete.assert_called_once_with( "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_delete_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_delete_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.delete.return_value = new_mock_empty_response( - HTTPStatus.INTERNAL_SERVER_ERROR + mock_client.delete.side_effect = new_mock_empty_response_func( + "DELETE", HTTPStatus.INTERNAL_SERVER_ERROR ) connector = HttpApiConnector("https://localhost") - self.assertRaises(HTTPError, connector.delete, "foo") + self.assertRaises(httpx.HTTPError, connector.delete, "foo") - mock_session.delete.assert_called_once_with( + mock_client.delete.assert_called_once_with( "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_delete_no_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.delete.return_value = new_mock_json_response( + mock_client.delete.side_effect = new_mock_json_response_func( + "DELETE", content=TEST_JSON_RESPONSE_BODY, status=HTTPStatus.INTERNAL_SERVER_ERROR, ) @@ -213,20 +227,22 @@ def test_delete_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.delete.assert_called_once_with( + mock_client.delete.assert_called_once_with( "https://localhost/foo", params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_get(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_get(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.get.return_value = new_mock_json_response( - TEST_JSON_RESPONSE_BODY + mock_client.get.side_effect = new_mock_json_response_func( + "GET", TEST_JSON_RESPONSE_BODY ) connector = HttpApiConnector("https://localhost") response = connector.get( @@ -236,19 +252,21 @@ def test_get(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.get.assert_called_once_with( + mock_client.get.assert_called_once_with( "https://localhost/foo", params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_minimal_get(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_minimal_get(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.get.return_value = new_mock_empty_response() + mock_client.get.side_effect = new_mock_empty_response_func("GET") connector = HttpApiConnector("https://localhost") response = connector.get("foo") @@ -257,29 +275,30 @@ def test_minimal_get(self, new_session_mock: MagicMock): self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) self.assertEqual({}, response.headers) - mock_session.get.assert_called_once_with( + mock_client.get.assert_called_once_with( "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_get_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_get_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.get.return_value = new_mock_empty_response( - HTTPStatus.INTERNAL_SERVER_ERROR + mock_client.get.side_effect = new_mock_empty_response_func( + "GET", HTTPStatus.INTERNAL_SERVER_ERROR ) connector = HttpApiConnector("https://localhost") self.assertRaises(HTTPError, connector.get, "foo") - mock_session.get.assert_called_once_with( + mock_client.get.assert_called_once_with( "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_get_no_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_get_no_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.get.return_value = new_mock_json_response( + mock_client.get.side_effect = new_mock_json_response_func( + "GET", content=TEST_JSON_RESPONSE_BODY, status=HTTPStatus.INTERNAL_SERVER_ERROR, ) @@ -294,20 +313,22 @@ def test_get_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.get.assert_called_once_with( + mock_client.get.assert_called_once_with( "https://localhost/foo", params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_post_json(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_post_json(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.post.return_value = new_mock_json_response( - TEST_JSON_RESPONSE_BODY + mock_client.post.side_effect = new_mock_json_response_func( + "POST", TEST_JSON_RESPONSE_BODY ) connector = HttpApiConnector("https://localhost") response = connector.post_json( @@ -320,20 +341,22 @@ def test_post_json(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.OK), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.post.assert_called_once_with( + mock_client.post.assert_called_once_with( "https://localhost/foo", json={"number": 5}, params={"bar": "123"}, headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_minimal_post_json(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_minimal_post_json(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.post.return_value = new_mock_empty_response() + mock_client.post.side_effect = new_mock_empty_response_func("POST") connector = HttpApiConnector("https://localhost") response = connector.post_json("foo", TEST_JSON_REQUEST_BODY) @@ -342,37 +365,38 @@ def test_minimal_post_json(self, new_session_mock: MagicMock): self.assertEqual(TEST_EMPTY_CONTENT_TYPE, response.content_type) self.assertEqual({}, response.headers) - mock_session.post.assert_called_once_with( + mock_client.post.assert_called_once_with( "https://localhost/foo", json=TEST_JSON_REQUEST_BODY, params=None, headers=None, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_post_json_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_post_json_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.post.return_value = new_mock_empty_response( - HTTPStatus.INTERNAL_SERVER_ERROR + mock_client.post.side_effect = new_mock_empty_response_func( + "POST", HTTPStatus.INTERNAL_SERVER_ERROR ) connector = HttpApiConnector("https://localhost") self.assertRaises( HTTPError, connector.post_json, "foo", json=TEST_JSON_REQUEST_BODY ) - mock_session.post.assert_called_once_with( + mock_client.post.assert_called_once_with( "https://localhost/foo", json=TEST_JSON_REQUEST_BODY, params=None, headers=None, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_session") - def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): - mock_session = new_session_mock.return_value = new_mock_session() + @patch("gvm.http.core.connector.HttpApiConnector._new_client") + def test_post_json_no_raise_on_status(self, new_client_mock: MagicMock): + mock_client = new_client_mock.return_value = new_mock_client() - mock_session.post.return_value = new_mock_json_response( + mock_client.post.side_effect = new_mock_json_response_func( + "POST", content=TEST_JSON_RESPONSE_BODY, status=HTTPStatus.INTERNAL_SERVER_ERROR, ) @@ -388,9 +412,11 @@ def test_post_json_no_raise_on_status(self, new_session_mock: MagicMock): self.assertEqual(int(HTTPStatus.INTERNAL_SERVER_ERROR), response.status) self.assertEqual(TEST_JSON_RESPONSE_BODY, response.body) self.assertEqual(TEST_JSON_CONTENT_TYPE, response.content_type) - self.assertEqual(TEST_JSON_HEADERS, response.headers) + self.assertEqual( + TEST_JSON_HEADERS | JSON_EXTRA_HEADERS, response.headers + ) - mock_session.post.assert_called_once_with( + mock_client.post.assert_called_once_with( "https://localhost/foo", json=TEST_JSON_REQUEST_BODY, params={"bar": "123"}, From 3bafb5a4337b7743bbaa6c3544fee48717a13683 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 11 Apr 2025 11:42:34 +0200 Subject: [PATCH 16/18] Move http package into protocols --- docs/api/api.rst | 1 - docs/api/http.rst | 2 +- docs/api/httpcore.rst | 6 +-- docs/api/openvasdv1.rst | 2 +- docs/api/protocols.rst | 1 + gvm/{ => protocols}/http/__init__.py | 0 gvm/{ => protocols}/http/core/__init__.py | 0 gvm/{ => protocols}/http/core/_api.py | 2 +- gvm/{ => protocols}/http/core/connector.py | 2 +- gvm/{ => protocols}/http/core/headers.py | 0 gvm/{ => protocols}/http/core/response.py | 2 +- gvm/{ => protocols}/http/openvasd/__init__.py | 0 .../http/openvasd/openvasd1.py | 6 +-- tests/{ => protocols}/http/__init__.py | 0 tests/{ => protocols}/http/core/__init__.py | 0 tests/{ => protocols}/http/core/test_api.py | 2 +- .../http/core/test_connector.py | 4 +- .../{ => protocols}/http/core/test_headers.py | 2 +- .../http/core/test_response.py | 2 +- .../{ => protocols}/http/openvasd/__init__.py | 0 .../http/openvasd/test_openvasd1.py | 50 +++++++++---------- 21 files changed, 42 insertions(+), 42 deletions(-) rename gvm/{ => protocols}/http/__init__.py (100%) rename gvm/{ => protocols}/http/core/__init__.py (100%) rename gvm/{ => protocols}/http/core/_api.py (92%) rename gvm/{ => protocols}/http/core/connector.py (98%) rename gvm/{ => protocols}/http/core/headers.py (100%) rename gvm/{ => protocols}/http/core/response.py (96%) rename gvm/{ => protocols}/http/openvasd/__init__.py (100%) rename gvm/{ => protocols}/http/openvasd/openvasd1.py (98%) rename tests/{ => protocols}/http/__init__.py (100%) rename tests/{ => protocols}/http/core/__init__.py (100%) rename tests/{ => protocols}/http/core/test_api.py (93%) rename tests/{ => protocols}/http/core/test_connector.py (99%) rename tests/{ => protocols}/http/core/test_headers.py (96%) rename tests/{ => protocols}/http/core/test_response.py (97%) rename tests/{ => protocols}/http/openvasd/__init__.py (100%) rename tests/{ => protocols}/http/openvasd/test_openvasd1.py (86%) diff --git a/docs/api/api.rst b/docs/api/api.rst index 8788f8d4..a51b204c 100644 --- a/docs/api/api.rst +++ b/docs/api/api.rst @@ -16,6 +16,5 @@ utilities and xml helpers. connections transforms protocols - http errors other diff --git a/docs/api/http.rst b/docs/api/http.rst index 50b52238..5f14d5dd 100644 --- a/docs/api/http.rst +++ b/docs/api/http.rst @@ -3,7 +3,7 @@ HTTP APIs --------- -.. automodule:: gvm.http +.. automodule:: gvm.protocols.http .. toctree:: :maxdepth: 1 diff --git a/docs/api/httpcore.rst b/docs/api/httpcore.rst index f94fb4af..c06b9049 100644 --- a/docs/api/httpcore.rst +++ b/docs/api/httpcore.rst @@ -6,7 +6,7 @@ HTTP core classes Connector ######### -.. automodule:: gvm.http.core.connector +.. automodule:: gvm.protocols.http.core.connector .. autoclass:: HttpApiConnector :members: @@ -14,7 +14,7 @@ Connector Headers ####### -.. automodule:: gvm.http.core.headers +.. automodule:: gvm.protocols.http.core.headers .. autoclass:: ContentType :members: @@ -22,7 +22,7 @@ Headers Response ######## -.. automodule:: gvm.http.core.response +.. automodule:: gvm.protocols.http.core.response .. autoclass:: HttpResponse :members: \ No newline at end of file diff --git a/docs/api/openvasdv1.rst b/docs/api/openvasdv1.rst index 2a3699c9..2b061f8a 100644 --- a/docs/api/openvasdv1.rst +++ b/docs/api/openvasdv1.rst @@ -3,7 +3,7 @@ openvasd v1 ^^^^^^^^^^^ -.. automodule:: gvm.http.openvasd.openvasd1 +.. automodule:: gvm.protocols.http.openvasd.openvasd1 .. autoclass:: OpenvasdHttpApiV1 :members: \ No newline at end of file diff --git a/docs/api/protocols.rst b/docs/api/protocols.rst index 7e1458ce..7bff0fa5 100644 --- a/docs/api/protocols.rst +++ b/docs/api/protocols.rst @@ -8,6 +8,7 @@ Protocols .. toctree:: :maxdepth: 1 + http gmp ospv1 diff --git a/gvm/http/__init__.py b/gvm/protocols/http/__init__.py similarity index 100% rename from gvm/http/__init__.py rename to gvm/protocols/http/__init__.py diff --git a/gvm/http/core/__init__.py b/gvm/protocols/http/core/__init__.py similarity index 100% rename from gvm/http/core/__init__.py rename to gvm/protocols/http/core/__init__.py diff --git a/gvm/http/core/_api.py b/gvm/protocols/http/core/_api.py similarity index 92% rename from gvm/http/core/_api.py rename to gvm/protocols/http/core/_api.py index beb25b73..008fc101 100644 --- a/gvm/http/core/_api.py +++ b/gvm/protocols/http/core/_api.py @@ -8,7 +8,7 @@ from typing import Optional -from gvm.http.core.connector import HttpApiConnector +from gvm.protocols.http.core.connector import HttpApiConnector class GvmHttpApi: diff --git a/gvm/http/core/connector.py b/gvm/protocols/http/core/connector.py similarity index 98% rename from gvm/http/core/connector.py rename to gvm/protocols/http/core/connector.py index 962a0643..e3274575 100644 --- a/gvm/http/core/connector.py +++ b/gvm/protocols/http/core/connector.py @@ -11,7 +11,7 @@ from httpx import Client -from gvm.http.core.response import HttpResponse +from gvm.protocols.http.core.response import HttpResponse class HttpApiConnector: diff --git a/gvm/http/core/headers.py b/gvm/protocols/http/core/headers.py similarity index 100% rename from gvm/http/core/headers.py rename to gvm/protocols/http/core/headers.py diff --git a/gvm/http/core/response.py b/gvm/protocols/http/core/response.py similarity index 96% rename from gvm/http/core/response.py rename to gvm/protocols/http/core/response.py index 34216063..154462f0 100644 --- a/gvm/http/core/response.py +++ b/gvm/protocols/http/core/response.py @@ -11,7 +11,7 @@ from httpx import Response -from gvm.http.core.headers import ContentType +from gvm.protocols.http.core.headers import ContentType Self = TypeVar("Self", bound="HttpResponse") diff --git a/gvm/http/openvasd/__init__.py b/gvm/protocols/http/openvasd/__init__.py similarity index 100% rename from gvm/http/openvasd/__init__.py rename to gvm/protocols/http/openvasd/__init__.py diff --git a/gvm/http/openvasd/openvasd1.py b/gvm/protocols/http/openvasd/openvasd1.py similarity index 98% rename from gvm/http/openvasd/openvasd1.py rename to gvm/protocols/http/openvasd/openvasd1.py index 1255870c..14dd22bb 100644 --- a/gvm/http/openvasd/openvasd1.py +++ b/gvm/protocols/http/openvasd/openvasd1.py @@ -10,9 +10,9 @@ from typing import Any, Optional, Union from gvm.errors import InvalidArgumentType -from gvm.http.core._api import GvmHttpApi -from gvm.http.core.connector import HttpApiConnector -from gvm.http.core.response import HttpResponse +from gvm.protocols.http.core._api import GvmHttpApi +from gvm.protocols.http.core.connector import HttpApiConnector +from gvm.protocols.http.core.response import HttpResponse class OpenvasdHttpApiV1(GvmHttpApi): diff --git a/tests/http/__init__.py b/tests/protocols/http/__init__.py similarity index 100% rename from tests/http/__init__.py rename to tests/protocols/http/__init__.py diff --git a/tests/http/core/__init__.py b/tests/protocols/http/core/__init__.py similarity index 100% rename from tests/http/core/__init__.py rename to tests/protocols/http/core/__init__.py diff --git a/tests/http/core/test_api.py b/tests/protocols/http/core/test_api.py similarity index 93% rename from tests/http/core/test_api.py rename to tests/protocols/http/core/test_api.py index fe3876b1..b9834578 100644 --- a/tests/http/core/test_api.py +++ b/tests/protocols/http/core/test_api.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import MagicMock, patch -from gvm.http.core._api import GvmHttpApi +from gvm.protocols.http.core._api import GvmHttpApi class GvmHttpApiTestCase(unittest.TestCase): diff --git a/tests/http/core/test_connector.py b/tests/protocols/http/core/test_connector.py similarity index 99% rename from tests/http/core/test_connector.py rename to tests/protocols/http/core/test_connector.py index c91af063..129cdb3e 100644 --- a/tests/http/core/test_connector.py +++ b/tests/protocols/http/core/test_connector.py @@ -11,8 +11,8 @@ import httpx from httpx import HTTPError -from gvm.http.core.connector import HttpApiConnector -from gvm.http.core.headers import ContentType +from gvm.protocols.http.core.connector import HttpApiConnector +from gvm.protocols.http.core import ContentType TEST_JSON_HEADERS = { "content-type": "application/json;charset=utf-8", diff --git a/tests/http/core/test_headers.py b/tests/protocols/http/core/test_headers.py similarity index 96% rename from tests/http/core/test_headers.py rename to tests/protocols/http/core/test_headers.py index 07257d73..586856b4 100644 --- a/tests/http/core/test_headers.py +++ b/tests/protocols/http/core/test_headers.py @@ -4,7 +4,7 @@ import unittest -from gvm.http.core.headers import ContentType +from gvm.protocols.http.core import ContentType class ContentTypeTestCase(unittest.TestCase): diff --git a/tests/http/core/test_response.py b/tests/protocols/http/core/test_response.py similarity index 97% rename from tests/http/core/test_response.py rename to tests/protocols/http/core/test_response.py index 5dbbcf67..287a618d 100644 --- a/tests/http/core/test_response.py +++ b/tests/protocols/http/core/test_response.py @@ -7,7 +7,7 @@ import requests as requests_lib -from gvm.http.core.response import HttpResponse +from gvm.protocols.http.core.response import HttpResponse class HttpResponseFromRequestsLibTestCase(unittest.TestCase): diff --git a/tests/http/openvasd/__init__.py b/tests/protocols/http/openvasd/__init__.py similarity index 100% rename from tests/http/openvasd/__init__.py rename to tests/protocols/http/openvasd/__init__.py diff --git a/tests/http/openvasd/test_openvasd1.py b/tests/protocols/http/openvasd/test_openvasd1.py similarity index 86% rename from tests/http/openvasd/test_openvasd1.py rename to tests/protocols/http/openvasd/test_openvasd1.py index c5bd90f5..3b523a26 100644 --- a/tests/http/openvasd/test_openvasd1.py +++ b/tests/protocols/http/openvasd/test_openvasd1.py @@ -8,9 +8,9 @@ from unittest.mock import Mock, patch from gvm.errors import InvalidArgumentType -from gvm.http.core.headers import ContentType -from gvm.http.core.response import HttpResponse -from gvm.http.openvasd.openvasd1 import OpenvasdHttpApiV1 +from gvm.protocols.http.core.headers import ContentType +from gvm.protocols.http.core.response import HttpResponse +from gvm.protocols.http.openvasd.openvasd1 import OpenvasdHttpApiV1 def new_mock_empty_response( @@ -29,13 +29,13 @@ def new_mock_empty_response( class OpenvasdHttpApiV1TestCase(unittest.TestCase): - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_init(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector) mock_connector.update_headers.assert_not_called() self.assertIsNotNone(api) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_init_with_api_key(self, mock_connector: Mock): api = OpenvasdHttpApiV1(mock_connector, api_key="my-API-key") mock_connector.update_headers.assert_called_once_with( @@ -43,7 +43,7 @@ def test_init_with_api_key(self, mock_connector: Mock): ) self.assertIsNotNone(api) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_health_alive(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -55,7 +55,7 @@ def test_get_health_alive(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_health_ready(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -67,7 +67,7 @@ def test_get_health_ready(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_health_started(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -79,7 +79,7 @@ def test_get_health_started(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_notus_os_list(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -91,7 +91,7 @@ def test_get_notus_os_list(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_run_notus_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -105,7 +105,7 @@ def test_run_notus_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_preferences(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -117,7 +117,7 @@ def test_get_scan_preferences(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_create_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -156,7 +156,7 @@ def test_create_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_delete_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.delete.return_value = expected_response @@ -168,7 +168,7 @@ def test_delete_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scans(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -180,7 +180,7 @@ def test_get_scans(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -192,7 +192,7 @@ def test_get_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_results(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -204,7 +204,7 @@ def test_get_scan_results(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_results_with_ranges(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -239,7 +239,7 @@ def test_get_scan_results_with_ranges(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -263,7 +263,7 @@ def test_get_scan_results_with_invalid_ranges(self, mock_connector: Mock): range_end="invalid", ) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_result(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -275,7 +275,7 @@ def test_get_scan_result(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_scan_status(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -287,7 +287,7 @@ def test_get_scan_status(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_run_scan_action(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -301,7 +301,7 @@ def test_run_scan_action(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_start_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -313,7 +313,7 @@ def test_start_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_stop_scan(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.post_json.return_value = expected_response @@ -325,7 +325,7 @@ def test_stop_scan(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_vts(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response @@ -337,7 +337,7 @@ def test_get_vts(self, mock_connector: Mock): ) self.assertEqual(expected_response, response) - @patch("gvm.http.core.connector.HttpApiConnector", autospec=True) + @patch("gvm.protocols.http.core.connector.HttpApiConnector", autospec=True) def test_get_vt(self, mock_connector: Mock): expected_response = new_mock_empty_response() mock_connector.get.return_value = expected_response From cf62121f97e3925b3505ba210075375fba8e009e Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 11 Apr 2025 11:52:47 +0200 Subject: [PATCH 17/18] Fix type hints for change to httpx --- gvm/protocols/http/core/connector.py | 4 ++-- gvm/protocols/http/core/response.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gvm/protocols/http/core/connector.py b/gvm/protocols/http/core/connector.py index e3274575..dfae6c06 100644 --- a/gvm/protocols/http/core/connector.py +++ b/gvm/protocols/http/core/connector.py @@ -23,7 +23,7 @@ class HttpApiConnector: def _new_client( cls, server_ca_path: Optional[str] = None, - client_cert_paths: Optional[Union[str, Tuple[str]]] = None, + client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, ): """ Creates a new httpx client @@ -51,7 +51,7 @@ def __init__( base_url: str, *, server_ca_path: Optional[str] = None, - client_cert_paths: Optional[Union[str, Tuple[str]]] = None, + client_cert_paths: Optional[Union[str, Tuple[str, str]]] = None, ): """ Create a new HTTP API Connector. diff --git a/gvm/protocols/http/core/response.py b/gvm/protocols/http/core/response.py index 154462f0..0ac7a95a 100644 --- a/gvm/protocols/http/core/response.py +++ b/gvm/protocols/http/core/response.py @@ -50,7 +50,7 @@ def from_requests_lib(cls: Type[Self], r: Response) -> "HttpResponse": A non-empty body will be parsed accordingly. """ ct = ContentType.from_string(r.headers.get("content-type")) - body = r.content + body: Optional[bytes] = r.content if r.content == b"": body = None From 6dbe31d9efc25f599415058b05ab35697f531185 Mon Sep 17 00:00:00 2001 From: Timo Pollmeier Date: Fri, 11 Apr 2025 11:56:55 +0200 Subject: [PATCH 18/18] Move http module in imports and mock patches --- tests/protocols/http/core/test_api.py | 4 +-- tests/protocols/http/core/test_connector.py | 32 ++++++++++----------- tests/protocols/http/core/test_headers.py | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/protocols/http/core/test_api.py b/tests/protocols/http/core/test_api.py index b9834578..150d4be6 100644 --- a/tests/protocols/http/core/test_api.py +++ b/tests/protocols/http/core/test_api.py @@ -11,12 +11,12 @@ class GvmHttpApiTestCase(unittest.TestCase): # pylint: disable=protected-access - @patch("gvm.http.core.connector.HttpApiConnector") + @patch("gvm.protocols.http.core.connector.HttpApiConnector") def test_basic_init(self, connector_mock: MagicMock): api = GvmHttpApi(connector_mock) self.assertEqual(connector_mock, api._connector) - @patch("gvm.http.core.connector.HttpApiConnector") + @patch("gvm.protocols.http.core.connector.HttpApiConnector") def test_init_with_key(self, connector_mock: MagicMock): api = GvmHttpApi(connector_mock, api_key="my-api-key") self.assertEqual(connector_mock, api._connector) diff --git a/tests/protocols/http/core/test_connector.py b/tests/protocols/http/core/test_connector.py index 129cdb3e..140ac347 100644 --- a/tests/protocols/http/core/test_connector.py +++ b/tests/protocols/http/core/test_connector.py @@ -12,7 +12,7 @@ from httpx import HTTPError from gvm.protocols.http.core.connector import HttpApiConnector -from gvm.protocols.http.core import ContentType +from gvm.protocols.http.core.headers import ContentType TEST_JSON_HEADERS = { "content-type": "application/json;charset=utf-8", @@ -115,7 +115,7 @@ def test_new_session(self): new_client = HttpApiConnector._new_client() self.assertIsInstance(new_client, httpx.Client) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_basic_init(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -125,7 +125,7 @@ def test_basic_init(self, new_client_mock: MagicMock): new_client_mock.assert_called_once_with(None, None) self.assertEqual({}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_https_init(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -139,7 +139,7 @@ def test_https_init(self, new_client_mock: MagicMock): new_client_mock.assert_called_once_with("foo.crt", "bar.key") self.assertEqual({}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_update_headers(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -151,7 +151,7 @@ def test_update_headers(self, new_client_mock: MagicMock): self.assertEqual({"x-foo": "bar", "x-baz": "123"}, mock_client.headers) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_delete(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -176,7 +176,7 @@ def test_delete(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_minimal_delete(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -193,7 +193,7 @@ def test_minimal_delete(self, new_client_mock: MagicMock): "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_delete_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -207,7 +207,7 @@ def test_delete_raise_on_status(self, new_client_mock: MagicMock): "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_delete_no_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -237,7 +237,7 @@ def test_delete_no_raise_on_status(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_get(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -262,7 +262,7 @@ def test_get(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_minimal_get(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -279,7 +279,7 @@ def test_minimal_get(self, new_client_mock: MagicMock): "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_get_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -293,7 +293,7 @@ def test_get_raise_on_status(self, new_client_mock: MagicMock): "https://localhost/foo", params=None, headers=None ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_get_no_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -323,7 +323,7 @@ def test_get_no_raise_on_status(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_post_json(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -352,7 +352,7 @@ def test_post_json(self, new_client_mock: MagicMock): headers={"baz": "456"}, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_minimal_post_json(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -372,7 +372,7 @@ def test_minimal_post_json(self, new_client_mock: MagicMock): headers=None, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_post_json_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() @@ -391,7 +391,7 @@ def test_post_json_raise_on_status(self, new_client_mock: MagicMock): headers=None, ) - @patch("gvm.http.core.connector.HttpApiConnector._new_client") + @patch("gvm.protocols.http.core.connector.HttpApiConnector._new_client") def test_post_json_no_raise_on_status(self, new_client_mock: MagicMock): mock_client = new_client_mock.return_value = new_mock_client() diff --git a/tests/protocols/http/core/test_headers.py b/tests/protocols/http/core/test_headers.py index 586856b4..6e1581f0 100644 --- a/tests/protocols/http/core/test_headers.py +++ b/tests/protocols/http/core/test_headers.py @@ -4,7 +4,7 @@ import unittest -from gvm.protocols.http.core import ContentType +from gvm.protocols.http.core.headers import ContentType class ContentTypeTestCase(unittest.TestCase):