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.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user