"""Base HTTP client for integration testing.""" import json import logging logger = logging.getLogger(__name__) class ApiError(Exception): """Detailed exception for API call failures. Attributes: message: Human-readable error message from the API. status_code: HTTP status code returned. error_type: Machine-readable error type string (e.g. VALIDATION_ERROR). error_details: Optional dict with field-level validation errors. url: The full API route that was called. method: The HTTP method used. response_data: The complete parsed JSON response body. """ def __init__( self, *, message: str, status_code: int, error_type: str, error_details: dict | None, url: str, method: str, response_data: dict, ): self.message = message self.status_code = status_code self.error_type = error_type self.error_details = error_details or {} self.url = url self.method = method self.response_data = response_data super().__init__(self._build_message()) def _build_message(self) -> str: lines = [ f"", f"{'='*60}", f" API ERROR: {self.method.upper()} {self.url}", f" Status: {self.status_code}", f" Error Type: {self.error_type}", f" Message: {self.message}", ] if self.error_details: lines.append(f" Details: {self.error_details}") lines.append(f" Full Response: {self.response_data}") lines.append(f"{'='*60}") return "\n".join(lines) def __str__(self) -> str: return self._build_message() class SecuirdClient: """Stateful CLI-style test client for Secuird API. Wraps Flask's ``test_client`` and manages auth tokens, JSON serialization, and detailed error reporting so tests fail with actionable output. """ def __init__(self, flask_test_client): self._client = flask_test_client self._token: str | None = None logger.debug("[SecuirdClient] Initialized") # Attach domain-specific sub-clients from tests.integration.client.auth import AuthClient from tests.integration.client.mfa import MfaClient from tests.integration.client.ssh import SshClient from tests.integration.client.orgs import OrgsClient from tests.integration.client.admin import AdminClient from tests.integration.client.users import UsersClient self.auth = AuthClient(self) self.mfa = MfaClient(self) self.ssh = SshClient(self) self.orgs = OrgsClient(self) self.admin = AdminClient(self) self.users = UsersClient(self) def set_token(self, token: str) -> None: """Store a Bearer token for subsequent requests.""" self._token = token logger.debug(f"[SecuirdClient] Token set: {token[:12]}...") def clear_token(self) -> None: """Remove the stored Bearer token.""" self._token = None logger.debug("[SecuirdClient] Token cleared") def _url(self, path: str) -> str: """Ensure the path starts with /api/v1.""" if path.startswith("http"): return path if not path.startswith("/api/v1"): path = f"/api/v1{path}" return path def _headers(self) -> dict: """Build request headers including auth if available.""" headers = {"Accept": "application/json"} if self._token: headers["Authorization"] = f"Bearer {self._token}" return headers def _request(self, method: str, path: str, data: dict | None = None) -> dict: """Execute an HTTP request and handle the response. Args: method: HTTP method (get, post, patch, delete). path: API path (e.g. /auth/register). data: Optional JSON-serializable payload. Returns: The parsed JSON response body. Raises: ApiError: If the response status code is >= 400. """ url = self._url(path) headers = self._headers() kwargs = {"headers": headers, "follow_redirects": True} if data is not None and method in ("post", "patch", "delete"): headers["Content-Type"] = "application/json" kwargs["data"] = json.dumps(data) logger.debug(f"[SecuirdClient] {method.upper()} {url} — data={data}") response = getattr(self._client, method)(url, **kwargs) try: body = response.get_json() except Exception: body = {"_raw": response.data.decode("utf-8", errors="replace")} logger.debug(f"[SecuirdClient] {method.upper()} {url} — status={response.status_code}") if response.status_code >= 400: # The API may return error info nested under `error` or flat at top level error_block = body.get("error") if isinstance(body.get("error"), dict) else {} error_type = ( error_block.get("type") or body.get("error_type", "UNKNOWN_ERROR") if body else "UNKNOWN_ERROR" ) error_details = ( error_block.get("details") or body.get("error_details") if body else None ) message = body.get("message", "No message provided") if body else "No message provided" raise ApiError( message=message, status_code=response.status_code, error_type=error_type, error_details=error_details, url=url, method=method.upper(), response_data=body or {}, ) return body or {} def get(self, path: str) -> dict: """Execute a GET request.""" return self._request("get", path) def post(self, path: str, data: dict | None = None) -> dict: """Execute a POST request.""" return self._request("post", path, data) def patch(self, path: str, data: dict | None = None) -> dict: """Execute a PATCH request.""" return self._request("patch", path, data) def put(self, path: str, data: dict | None = None) -> dict: """Execute a PUT request.""" return self._request("put", path, data) def delete(self, path: str, data: dict | None = None) -> dict: """Execute a DELETE request.""" return self._request("delete", path, data)