015c622016
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.
190 lines
6.4 KiB
Python
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)
|