Files
gatehouse-api/tests/integration/client/base.py
T
nexgen_mirrors 015c622016 test: add comprehensive integration test suite for IAM platform
Add 162 integration tests covering authentication flows, TOTP MFA,
SSH key/certificate management, organization workflows, multi-org
access, self-service features, admin operations, authorization,
security edge cases, department/principal management, CA management,
policy compliance, WebAuthn passkeys, and ZeroTier network access.

Includes:
- Reusable API client library with session management
- Test fixtures for users, organizations, memberships, and CAs
- Helper functions for SSH key generation and verification
- Documentation for running and writing tests

Also update test configuration to disable conflicting maas plugins
and configure WebAuthn/session settings for localhost testing.
2026-04-23 15:41:37 +09:30

190 lines
6.4 KiB
Python

"""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)