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,53 @@
|
||||
"""Admin client for integration tests."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminClient:
|
||||
"""Wraps admin-only API calls."""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def list_users(self) -> dict:
|
||||
"""List all users (paginated)."""
|
||||
return self._client.get("/admin/users")
|
||||
|
||||
def get_user(self, user_id: str) -> dict:
|
||||
"""Get a single user by ID."""
|
||||
return self._client.get(f"/admin/users/{user_id}")
|
||||
|
||||
def suspend_user(self, user_id: str) -> dict:
|
||||
"""Suspend a user account."""
|
||||
return self._client.post(f"/admin/users/{user_id}/suspend")
|
||||
|
||||
def unsuspend_user(self, user_id: str) -> dict:
|
||||
"""Unsuspend a user account."""
|
||||
return self._client.post(f"/admin/users/{user_id}/unsuspend")
|
||||
|
||||
def verify_user_email(self, user_id: str) -> dict:
|
||||
"""Admin-verify a user's email."""
|
||||
return self._client.post(f"/admin/users/{user_id}/verify-email")
|
||||
|
||||
def set_user_password(self, user_id: str, new_password: str) -> dict:
|
||||
"""Set a user's password (admin override)."""
|
||||
return self._client.post(
|
||||
f"/admin/users/{user_id}/password",
|
||||
data={"password": new_password},
|
||||
)
|
||||
|
||||
def remove_user_mfa(self, user_id: str, mfa_type: str = "totp") -> dict:
|
||||
"""Remove a user's MFA method."""
|
||||
return self._client.delete(f"/admin/users/{user_id}/mfa/{mfa_type}")
|
||||
|
||||
def hard_delete_user(self, user_id: str, confirm: bool = False) -> dict:
|
||||
"""Hard-delete a user."""
|
||||
return self._client.post(
|
||||
f"/admin/users/{user_id}/delete",
|
||||
data={"confirm": confirm},
|
||||
)
|
||||
|
||||
def list_audit_logs(self) -> dict:
|
||||
"""List system-wide audit logs."""
|
||||
return self._client.get("/audit-logs")
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Auth client for integration tests."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthClient:
|
||||
"""Wraps authentication-related API calls.
|
||||
|
||||
Provides convenience methods for register, login, logout, and
|
||||
session management. Automatically stores the token on the parent
|
||||
SecuirdClient when login / register succeed.
|
||||
"""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Registration
|
||||
# ------------------------------------------------------------------
|
||||
def register(self, email: str, password: str, full_name: str | None = None) -> dict:
|
||||
"""Register a new user and return the response payload.
|
||||
|
||||
Args:
|
||||
email: User's email address.
|
||||
password: Plain-text password (>= 8 chars).
|
||||
full_name: Optional display name.
|
||||
|
||||
Returns:
|
||||
API response dict containing ``user``, ``token``, ``expires_at``.
|
||||
|
||||
Raises:
|
||||
ApiError: On validation failure or duplicate email.
|
||||
"""
|
||||
logger.info(f"[AuthClient] Registering user: email={email}")
|
||||
payload = {"email": email, "password": password, "password_confirm": password}
|
||||
if full_name:
|
||||
payload["full_name"] = full_name
|
||||
result = self._client.post("/auth/register", data=payload)
|
||||
token = result.get("data", {}).get("token")
|
||||
if token:
|
||||
self._client.set_token(token)
|
||||
logger.info(f"[AuthClient] Registration successful — token stored")
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Login / Logout
|
||||
# ------------------------------------------------------------------
|
||||
def login(self, email: str, password: str, remember_me: bool = False) -> dict:
|
||||
"""Authenticate with email and password.
|
||||
|
||||
Args:
|
||||
email: Registered email address.
|
||||
password: Plain-text password.
|
||||
remember_me: Request a long-lived session.
|
||||
|
||||
Returns:
|
||||
API response dict. If TOTP / WebAuthn is required the
|
||||
response contains ``requires_totp`` or ``requires_webauthn``
|
||||
instead of a token.
|
||||
"""
|
||||
logger.info(f"[AuthClient] Logging in: email={email}")
|
||||
result = self._client.post(
|
||||
"/auth/login",
|
||||
data={"email": email, "password": password, "remember_me": remember_me},
|
||||
)
|
||||
token = result.get("data", {}).get("token")
|
||||
if token:
|
||||
self._client.set_token(token)
|
||||
logger.info(f"[AuthClient] Login successful — token stored")
|
||||
return result
|
||||
|
||||
def logout(self) -> dict:
|
||||
"""Log out the current user and clear the stored token."""
|
||||
logger.info("[AuthClient] Logging out")
|
||||
result = self._client.post("/auth/logout")
|
||||
self._client.clear_token()
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Current user
|
||||
# ------------------------------------------------------------------
|
||||
def me(self) -> dict:
|
||||
"""Return the current authenticated user's profile."""
|
||||
return self._client.get("/auth/me")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sessions
|
||||
# ------------------------------------------------------------------
|
||||
def list_sessions(self) -> dict:
|
||||
"""Return active sessions for the current user."""
|
||||
return self._client.get("/auth/sessions")
|
||||
|
||||
def revoke_session(self, session_id: str) -> dict:
|
||||
"""Revoke a specific session belonging to the current user."""
|
||||
return self._client.delete(f"/auth/sessions/{session_id}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Password recovery
|
||||
# ------------------------------------------------------------------
|
||||
def forgot_password(self, email: str) -> dict:
|
||||
"""Request a password-reset email."""
|
||||
return self._client.post("/auth/forgot-password", data={"email": email})
|
||||
|
||||
def reset_password(self, token: str, new_password: str, new_password_confirm: str) -> dict:
|
||||
"""Reset password using a token from the forgot-password flow."""
|
||||
return self._client.post(
|
||||
"/auth/reset-password",
|
||||
data={
|
||||
"token": token,
|
||||
"password": new_password,
|
||||
"password_confirm": new_password_confirm,
|
||||
},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Email verification
|
||||
# ------------------------------------------------------------------
|
||||
def verify_email(self, token: str) -> dict:
|
||||
"""Verify an email address using the token sent by email."""
|
||||
return self._client.post("/auth/verify-email", data={"token": token})
|
||||
|
||||
def resend_verification(self, email: str) -> dict:
|
||||
"""Re-send the verification email."""
|
||||
return self._client.post("/auth/resend-verification", data={"email": email})
|
||||
@@ -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)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""MFA (TOTP) client for integration tests."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MfaClient:
|
||||
"""Wraps TOTP MFA-related API calls."""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TOTP Enrollment
|
||||
# ------------------------------------------------------------------
|
||||
def enroll_totp(self) -> dict:
|
||||
"""Begin TOTP enrollment.
|
||||
|
||||
Returns:
|
||||
Response dict containing ``secret``, ``provisioning_uri``,
|
||||
``qr_code``, and ``backup_codes``.
|
||||
"""
|
||||
logger.info("[MfaClient] Enrolling TOTP")
|
||||
return self._client.post("/auth/totp/enroll")
|
||||
|
||||
def verify_enrollment(self, code: str, client_timestamp: str | None = None) -> dict:
|
||||
"""Complete TOTP enrollment by verifying the first code.
|
||||
|
||||
Args:
|
||||
code: 6-digit TOTP code generated from the secret.
|
||||
client_timestamp: Optional ISO-8601 timestamp for drift calc.
|
||||
"""
|
||||
payload = {"code": code}
|
||||
if client_timestamp:
|
||||
payload["client_timestamp"] = client_timestamp
|
||||
logger.info("[MfaClient] Verifying TOTP enrollment")
|
||||
return self._client.post("/auth/totp/verify-enrollment", data=payload)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TOTP Verification (during login)
|
||||
# ------------------------------------------------------------------
|
||||
def verify_totp(self, code: str, is_backup_code: bool = False, client_timestamp: str | None = None) -> dict:
|
||||
"""Verify TOTP code during the multi-step login flow.
|
||||
|
||||
This is called AFTER ``AuthClient.login`` returns
|
||||
``requires_totp=True`` and stores the pending user id in the
|
||||
server-side session.
|
||||
|
||||
Args:
|
||||
code: 6-digit TOTP code or backup code.
|
||||
is_backup_code: True if ``code`` is a backup code.
|
||||
client_timestamp: Optional ISO-8601 timestamp.
|
||||
|
||||
Returns:
|
||||
Response dict containing ``user``, ``token``, ``expires_at``.
|
||||
"""
|
||||
payload = {"code": code, "is_backup_code": is_backup_code}
|
||||
if client_timestamp:
|
||||
payload["client_timestamp"] = client_timestamp
|
||||
logger.info(f"[MfaClient] Verifying TOTP — backup={is_backup_code}")
|
||||
result = self._client.post("/auth/totp/verify", data=payload)
|
||||
token = result.get("data", {}).get("token")
|
||||
if token:
|
||||
self._client.set_token(token)
|
||||
logger.info("[MfaClient] TOTP verification successful — token stored")
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TOTP Management
|
||||
# ------------------------------------------------------------------
|
||||
def get_totp_status(self) -> dict:
|
||||
"""Return current TOTP status and remaining backup codes."""
|
||||
return self._client.get("/auth/totp/status")
|
||||
|
||||
def disable_totp(self, password: str) -> dict:
|
||||
"""Disable TOTP for the current user.
|
||||
|
||||
Args:
|
||||
password: Current account password (required for confirmation).
|
||||
"""
|
||||
return self._client.delete("/auth/totp/disable", data={"password": password})
|
||||
|
||||
def regenerate_backup_codes(self, password: str) -> dict:
|
||||
"""Generate a fresh set of backup codes.
|
||||
|
||||
Args:
|
||||
password: Current account password (required for confirmation).
|
||||
|
||||
Returns:
|
||||
Response dict containing ``backup_codes``.
|
||||
"""
|
||||
return self._client.post(
|
||||
"/auth/totp/regenerate-backup-codes",
|
||||
data={"password": password},
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Organization client for integration tests."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrgsClient:
|
||||
"""Wraps organization-related API calls."""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Organization CRUD
|
||||
# ------------------------------------------------------------------
|
||||
def create(self, name: str, slug: str | None = None, description: str | None = None) -> dict:
|
||||
"""Create a new organization."""
|
||||
payload: dict = {"name": name}
|
||||
if slug:
|
||||
payload["slug"] = slug
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return self._client.post("/organizations", data=payload)
|
||||
|
||||
def get(self, org_id: str) -> dict:
|
||||
"""Get organization details."""
|
||||
return self._client.get(f"/organizations/{org_id}")
|
||||
|
||||
def update(self, org_id: str, **fields) -> dict:
|
||||
"""Update organization fields (name, description, etc.)."""
|
||||
return self._client.patch(f"/organizations/{org_id}", data=fields)
|
||||
|
||||
def delete(self, org_id: str, confirm: bool = False) -> dict:
|
||||
"""Delete (soft-delete) an organization."""
|
||||
return self._client.delete(f"/organizations/{org_id}", data={"confirm": confirm})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Members
|
||||
# ------------------------------------------------------------------
|
||||
def list_members(self, org_id: str) -> dict:
|
||||
"""List members of an organization."""
|
||||
return self._client.get(f"/organizations/{org_id}/members")
|
||||
|
||||
def add_member(self, org_id: str, email: str, role: str = "member") -> dict:
|
||||
"""Add an existing user as a member."""
|
||||
return self._client.post(
|
||||
f"/organizations/{org_id}/members",
|
||||
data={"email": email, "role": role},
|
||||
)
|
||||
|
||||
def remove_member(self, org_id: str, member_id: str) -> dict:
|
||||
"""Remove a member from an organization."""
|
||||
return self._client.delete(f"/organizations/{org_id}/members/{member_id}")
|
||||
|
||||
def update_member_role(self, org_id: str, member_id: str, role: str) -> dict:
|
||||
"""Update a member's role."""
|
||||
return self._client.patch(
|
||||
f"/organizations/{org_id}/members/{member_id}/role",
|
||||
data={"role": role},
|
||||
)
|
||||
|
||||
def transfer_ownership(self, org_id: str, new_owner_id: str) -> dict:
|
||||
"""Transfer organization ownership."""
|
||||
return self._client.post(
|
||||
f"/organizations/{org_id}/transfer-ownership",
|
||||
data={"new_owner_user_id": new_owner_id},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Invites
|
||||
# ------------------------------------------------------------------
|
||||
def list_invites(self, org_id: str) -> dict:
|
||||
"""List pending invites."""
|
||||
return self._client.get(f"/organizations/{org_id}/invites")
|
||||
|
||||
def create_invite(self, org_id: str, email: str, role: str = "member") -> dict:
|
||||
"""Create an invite for a new user."""
|
||||
return self._client.post(
|
||||
f"/organizations/{org_id}/invites",
|
||||
data={"email": email, "role": role},
|
||||
)
|
||||
|
||||
def cancel_invite(self, org_id: str, invite_id: str) -> dict:
|
||||
"""Cancel a pending invite."""
|
||||
return self._client.delete(f"/organizations/{org_id}/invites/{invite_id}")
|
||||
|
||||
def get_invite_by_token(self, token: str) -> dict:
|
||||
"""Get invite info by token (public endpoint)."""
|
||||
return self._client.get(f"/invites/{token}")
|
||||
|
||||
def accept_invite(self, token: str, password: str | None = None, full_name: str | None = None, password_confirm: str | None = None) -> dict:
|
||||
"""Accept an invite. For new users, password and full_name are required."""
|
||||
payload: dict = {}
|
||||
if password:
|
||||
payload["password"] = password
|
||||
if password_confirm:
|
||||
payload["password_confirm"] = password_confirm
|
||||
if full_name:
|
||||
payload["full_name"] = full_name
|
||||
result = self._client.post(f"/invites/{token}/accept", data=payload)
|
||||
# Store token if returned (new user registration)
|
||||
token_val = result.get("data", {}).get("token")
|
||||
if token_val:
|
||||
self._client.set_token(token_val)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Principals & Departments
|
||||
# ------------------------------------------------------------------
|
||||
def list_principals(self, org_id: str) -> dict:
|
||||
"""List principals in an organization."""
|
||||
return self._client.get(f"/organizations/{org_id}/principals")
|
||||
|
||||
def create_principal(self, org_id: str, name: str, description: str | None = None) -> dict:
|
||||
"""Create a principal."""
|
||||
payload: dict = {"name": name}
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return self._client.post(f"/organizations/{org_id}/principals", data=payload)
|
||||
|
||||
def add_principal_member(self, org_id: str, principal_id: str, email: str) -> dict:
|
||||
"""Add a user to a principal."""
|
||||
return self._client.post(
|
||||
f"/organizations/{org_id}/principals/{principal_id}/members",
|
||||
data={"email": email},
|
||||
)
|
||||
|
||||
def list_departments(self, org_id: str) -> dict:
|
||||
"""List departments in an organization."""
|
||||
return self._client.get(f"/organizations/{org_id}/departments")
|
||||
|
||||
def create_department(self, org_id: str, name: str, description: str | None = None) -> dict:
|
||||
"""Create a department."""
|
||||
payload: dict = {"name": name}
|
||||
if description:
|
||||
payload["description"] = description
|
||||
return self._client.post(f"/organizations/{org_id}/departments", data=payload)
|
||||
|
||||
def add_department_member(self, org_id: str, dept_id: str, email: str) -> dict:
|
||||
"""Add a user to a department."""
|
||||
return self._client.post(
|
||||
f"/organizations/{org_id}/departments/{dept_id}/members",
|
||||
data={"email": email},
|
||||
)
|
||||
|
||||
def link_principal_department(self, org_id: str, principal_id: str, dept_id: str) -> dict:
|
||||
"""Link a principal to a department."""
|
||||
return self._client.post(
|
||||
f"/organizations/{org_id}/principals/{principal_id}/departments/{dept_id}",
|
||||
data={},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CAs
|
||||
# ------------------------------------------------------------------
|
||||
def list_cas(self, org_id: str) -> dict:
|
||||
"""List CAs for an organization."""
|
||||
return self._client.get(f"/organizations/{org_id}/cas")
|
||||
|
||||
def create_ca(self, org_id: str, name: str, ca_type: str = "user", key_type: str = "ed25519") -> dict:
|
||||
"""Create a Certificate Authority."""
|
||||
return self._client.post(
|
||||
f"/organizations/{org_id}/cas",
|
||||
data={"name": name, "ca_type": ca_type, "key_type": key_type},
|
||||
)
|
||||
|
||||
def get_ca(self, org_id: str, ca_id: str) -> dict:
|
||||
"""Get a CA by ID."""
|
||||
return self._client.get(f"/organizations/{org_id}/cas/{ca_id}")
|
||||
|
||||
def rotate_ca(self, org_id: str, ca_id: str) -> dict:
|
||||
"""Rotate a CA key."""
|
||||
return self._client.post(f"/organizations/{org_id}/cas/{ca_id}/rotate")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API Keys
|
||||
# ------------------------------------------------------------------
|
||||
def list_api_keys(self, org_id: str) -> dict:
|
||||
"""List API keys."""
|
||||
return self._client.get(f"/organizations/{org_id}/api-keys")
|
||||
|
||||
def create_api_key(self, org_id: str, name: str, role: str = "member") -> dict:
|
||||
"""Create an API key."""
|
||||
return self._client.post(
|
||||
f"/organizations/{org_id}/api-keys",
|
||||
data={"name": name, "role": role},
|
||||
)
|
||||
|
||||
def revoke_api_key(self, org_id: str, key_id: str) -> dict:
|
||||
"""Revoke an API key."""
|
||||
return self._client.delete(f"/organizations/{org_id}/api-keys/{key_id}")
|
||||
@@ -0,0 +1,132 @@
|
||||
"""SSH client for integration tests."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SshClient:
|
||||
"""Wraps SSH key and certificate API calls."""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSH Key Management
|
||||
# ------------------------------------------------------------------
|
||||
def list_keys(self) -> dict:
|
||||
"""Return all SSH keys belonging to the current user."""
|
||||
return self._client.get("/ssh/keys")
|
||||
|
||||
def add_key(self, public_key: str, description: str | None = None) -> dict:
|
||||
"""Upload a new SSH public key.
|
||||
|
||||
Args:
|
||||
public_key: The OpenSSH-format public key string.
|
||||
description: Optional human-readable label.
|
||||
"""
|
||||
payload = {"public_key": public_key}
|
||||
if description:
|
||||
payload["description"] = description
|
||||
logger.info("[SshClient] Adding SSH key")
|
||||
return self._client.post("/ssh/keys", data=payload)
|
||||
|
||||
def get_key(self, key_id: str) -> dict:
|
||||
"""Return a single SSH key by ID."""
|
||||
return self._client.get(f"/ssh/keys/{key_id}")
|
||||
|
||||
def delete_key(self, key_id: str) -> dict:
|
||||
"""Delete an SSH key."""
|
||||
return self._client.delete(f"/ssh/keys/{key_id}")
|
||||
|
||||
def update_description(self, key_id: str, description: str) -> dict:
|
||||
"""Update the description of an SSH key."""
|
||||
return self._client.patch(
|
||||
f"/ssh/keys/{key_id}/update-description",
|
||||
data={"description": description},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSH Key Verification
|
||||
# ------------------------------------------------------------------
|
||||
def get_challenge(self, key_id: str) -> dict:
|
||||
"""Generate a verification challenge for an SSH key.
|
||||
|
||||
Returns:
|
||||
Response dict containing ``challenge_text``.
|
||||
"""
|
||||
return self._client.get(f"/ssh/keys/{key_id}/verify")
|
||||
|
||||
def verify_key(self, key_id: str, signature: str) -> dict:
|
||||
"""Verify ownership of an SSH key by submitting a signature.
|
||||
|
||||
Args:
|
||||
key_id: The SSH key ID.
|
||||
signature: Base64-encoded signature of the challenge text.
|
||||
"""
|
||||
return self._client.post(
|
||||
f"/ssh/keys/{key_id}/verify",
|
||||
data={"action": "verify_signature", "signature": signature},
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSH Certificate Signing
|
||||
# ------------------------------------------------------------------
|
||||
def sign_certificate(
|
||||
self,
|
||||
*,
|
||||
key_id: str | None = None,
|
||||
principals: list[str] | None = None,
|
||||
cert_type: str = "user",
|
||||
expiry_hours: int | None = None,
|
||||
) -> dict:
|
||||
"""Request an SSH user certificate.
|
||||
|
||||
Args:
|
||||
key_id: SSH key to attach the certificate to.
|
||||
principals: Optional list of requested principals.
|
||||
cert_type: "user" or "host".
|
||||
expiry_hours: Optional custom expiry within policy.
|
||||
"""
|
||||
payload: dict = {"cert_type": cert_type}
|
||||
if key_id:
|
||||
payload["key_id"] = key_id
|
||||
if principals:
|
||||
payload["principals"] = principals
|
||||
if expiry_hours:
|
||||
payload["expiry_hours"] = expiry_hours
|
||||
logger.info(f"[SshClient] Signing certificate — type={cert_type}")
|
||||
return self._client.post("/ssh/sign", data=payload)
|
||||
|
||||
def sign_host_certificate(self, *, host_public_key: str, ca_id: str | None = None) -> dict:
|
||||
"""Request an SSH host certificate (admin-only).
|
||||
|
||||
Args:
|
||||
host_public_key: The host's public key material.
|
||||
ca_id: Optional CA ID (defaults to org's host CA).
|
||||
"""
|
||||
payload: dict = {"host_public_key": host_public_key, "cert_type": "host"}
|
||||
if ca_id:
|
||||
payload["ca_id"] = ca_id
|
||||
return self._client.post("/ssh/sign/host", data=payload)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Certificate Management
|
||||
# ------------------------------------------------------------------
|
||||
def list_certificates(self) -> dict:
|
||||
"""Return all certificates for the current user."""
|
||||
return self._client.get("/ssh/certificates")
|
||||
|
||||
def get_certificate(self, cert_id: str) -> dict:
|
||||
"""Return a single certificate by ID."""
|
||||
return self._client.get(f"/ssh/certificates/{cert_id}")
|
||||
|
||||
def revoke_certificate(self, cert_id: str, reason: str = "User revoked") -> dict:
|
||||
"""Revoke a certificate."""
|
||||
return self._client.post(
|
||||
f"/ssh/certificates/{cert_id}/revoke",
|
||||
data={"reason": reason},
|
||||
)
|
||||
|
||||
def get_ca_public_key(self) -> dict:
|
||||
"""Return the organization's CA public key."""
|
||||
return self._client.get("/ssh/ca/public-key")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Users (self-service) client for integration tests."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UsersClient:
|
||||
"""Wraps user self-service API calls."""
|
||||
|
||||
def __init__(self, client):
|
||||
self._client = client
|
||||
|
||||
def get_profile(self) -> dict:
|
||||
"""Get the current user's profile."""
|
||||
return self._client.get("/users/me")
|
||||
|
||||
def update_profile(self, **fields) -> dict:
|
||||
"""Update profile fields (full_name, avatar_url)."""
|
||||
return self._client.patch("/users/me", data=fields)
|
||||
|
||||
def change_password(self, current_password: str, new_password: str, new_password_confirm: str) -> dict:
|
||||
"""Change the current user's password."""
|
||||
return self._client.post(
|
||||
"/users/me/password",
|
||||
data={
|
||||
"current_password": current_password,
|
||||
"new_password": new_password,
|
||||
"new_password_confirm": new_password_confirm,
|
||||
},
|
||||
)
|
||||
|
||||
def delete_account(self) -> dict:
|
||||
"""Soft-delete the current user's account."""
|
||||
return self._client.delete("/users/me")
|
||||
|
||||
def get_my_organizations(self) -> dict:
|
||||
"""List organizations the current user belongs to."""
|
||||
return self._client.get("/users/me/organizations")
|
||||
|
||||
def get_my_memberships(self) -> dict:
|
||||
"""List detailed memberships across orgs."""
|
||||
return self._client.get("/users/me/memberships")
|
||||
|
||||
def get_my_principals(self) -> dict:
|
||||
"""List principals the current user has access to."""
|
||||
return self._client.get("/users/me/principals")
|
||||
|
||||
def get_my_invites(self) -> dict:
|
||||
"""List pending invites for the current user."""
|
||||
return self._client.get("/users/me/invites")
|
||||
Reference in New Issue
Block a user