clients: expand Go, Python, Rust client APIs
- Add TOTP enrollment/confirmation/removal to all clients - Add password change and admin set-password endpoints - Add account listing, status update, and tag management - Add audit log listing with filter support - Add policy rule CRUD operations - Expand test coverage for all new endpoints across clients - Fix .gitignore to exclude built binaries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,9 +7,10 @@ from ._errors import (
|
||||
MciasForbiddenError,
|
||||
MciasInputError,
|
||||
MciasNotFoundError,
|
||||
MciasRateLimitError,
|
||||
MciasServerError,
|
||||
)
|
||||
from ._models import Account, PGCreds, PublicKey, TokenClaims
|
||||
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
|
||||
|
||||
__all__ = [
|
||||
"Client",
|
||||
@@ -19,9 +20,12 @@ __all__ = [
|
||||
"MciasNotFoundError",
|
||||
"MciasInputError",
|
||||
"MciasConflictError",
|
||||
"MciasRateLimitError",
|
||||
"MciasServerError",
|
||||
"Account",
|
||||
"PublicKey",
|
||||
"TokenClaims",
|
||||
"PGCreds",
|
||||
"PolicyRule",
|
||||
"RuleBody",
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from ._errors import raise_for_status
|
||||
from ._models import Account, PGCreds, PublicKey, TokenClaims
|
||||
from ._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
|
||||
|
||||
|
||||
class Client:
|
||||
@@ -76,6 +76,29 @@ class Client:
|
||||
if status == 204 or not response.content:
|
||||
return None
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
def _request_list(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Send a request that returns a JSON array at the top level."""
|
||||
url = f"{self._base_url}{path}"
|
||||
headers: dict[str, str] = {}
|
||||
if self.token is not None:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
response = self._http.request(method, url, json=json, headers=headers)
|
||||
status = response.status_code
|
||||
if status >= 400:
|
||||
try:
|
||||
body = response.json()
|
||||
message = str(body.get("error", response.text))
|
||||
except Exception:
|
||||
message = response.text
|
||||
raise_for_status(status, message)
|
||||
return response.json() # type: ignore[no-any-return]
|
||||
# ── Public ────────────────────────────────────────────────────────────────
|
||||
def health(self) -> None:
|
||||
"""GET /v1/health — liveness check."""
|
||||
self._request("GET", "/v1/health")
|
||||
@@ -105,6 +128,12 @@ class Client:
|
||||
expires_at = str(data["expires_at"])
|
||||
self.token = token
|
||||
return token, expires_at
|
||||
def validate_token(self, token: str) -> TokenClaims:
|
||||
"""POST /v1/token/validate — check whether a token is valid."""
|
||||
data = self._request("POST", "/v1/token/validate", json={"token": token})
|
||||
assert data is not None
|
||||
return TokenClaims.from_dict(data)
|
||||
# ── Authenticated ──────────────────────────────────────────────────────────
|
||||
def logout(self) -> None:
|
||||
"""POST /v1/auth/logout — invalidate the current token."""
|
||||
self._request("POST", "/v1/auth/logout")
|
||||
@@ -119,11 +148,45 @@ class Client:
|
||||
expires_at = str(data["expires_at"])
|
||||
self.token = token
|
||||
return token, expires_at
|
||||
def validate_token(self, token: str) -> TokenClaims:
|
||||
"""POST /v1/token/validate — check whether a token is valid."""
|
||||
data = self._request("POST", "/v1/token/validate", json={"token": token})
|
||||
def enroll_totp(self) -> tuple[str, str]:
|
||||
"""POST /v1/auth/totp/enroll — begin TOTP enrollment.
|
||||
Returns (secret, otpauth_uri). The secret is shown only once.
|
||||
"""
|
||||
data = self._request("POST", "/v1/auth/totp/enroll")
|
||||
assert data is not None
|
||||
return TokenClaims.from_dict(data)
|
||||
return str(data["secret"]), str(data["otpauth_uri"])
|
||||
def confirm_totp(self, code: str) -> None:
|
||||
"""POST /v1/auth/totp/confirm — confirm TOTP enrollment with a code."""
|
||||
self._request("POST", "/v1/auth/totp/confirm", json={"code": code})
|
||||
def change_password(self, current_password: str, new_password: str) -> None:
|
||||
"""PUT /v1/auth/password — change own password (self-service)."""
|
||||
self._request(
|
||||
"PUT",
|
||||
"/v1/auth/password",
|
||||
json={"current_password": current_password, "new_password": new_password},
|
||||
)
|
||||
# ── Admin — Auth ──────────────────────────────────────────────────────────
|
||||
def remove_totp(self, account_id: str) -> None:
|
||||
"""DELETE /v1/auth/totp — remove TOTP from an account (admin)."""
|
||||
self._request("DELETE", "/v1/auth/totp", json={"account_id": account_id})
|
||||
# ── Admin — Tokens ────────────────────────────────────────────────────────
|
||||
def issue_service_token(self, account_id: str) -> tuple[str, str]:
|
||||
"""POST /v1/token/issue — issue a long-lived service token (admin).
|
||||
Returns (token, expires_at).
|
||||
"""
|
||||
data = self._request("POST", "/v1/token/issue", json={"account_id": account_id})
|
||||
assert data is not None
|
||||
return str(data["token"]), str(data["expires_at"])
|
||||
def revoke_token(self, jti: str) -> None:
|
||||
"""DELETE /v1/token/{jti} — revoke a token by JTI (admin)."""
|
||||
self._request("DELETE", f"/v1/token/{jti}")
|
||||
# ── Admin — Accounts ──────────────────────────────────────────────────────
|
||||
def list_accounts(self) -> list[Account]:
|
||||
"""GET /v1/accounts — list all accounts (admin).
|
||||
The API returns a JSON array directly (no wrapper object).
|
||||
"""
|
||||
items = self._request_list("GET", "/v1/accounts")
|
||||
return [Account.from_dict(a) for a in items]
|
||||
def create_account(
|
||||
self,
|
||||
username: str,
|
||||
@@ -131,7 +194,7 @@ class Client:
|
||||
*,
|
||||
password: str | None = None,
|
||||
) -> Account:
|
||||
"""POST /v1/accounts — create a new account."""
|
||||
"""POST /v1/accounts — create a new account (admin)."""
|
||||
payload: dict[str, Any] = {
|
||||
"username": username,
|
||||
"account_type": account_type,
|
||||
@@ -141,14 +204,8 @@ class Client:
|
||||
data = self._request("POST", "/v1/accounts", json=payload)
|
||||
assert data is not None
|
||||
return Account.from_dict(data)
|
||||
def list_accounts(self) -> list[Account]:
|
||||
"""GET /v1/accounts — list all accounts."""
|
||||
data = self._request("GET", "/v1/accounts")
|
||||
assert data is not None
|
||||
accounts_raw = data.get("accounts") or []
|
||||
return [Account.from_dict(a) for a in accounts_raw]
|
||||
def get_account(self, account_id: str) -> Account:
|
||||
"""GET /v1/accounts/{id} — retrieve a single account."""
|
||||
"""GET /v1/accounts/{id} — retrieve a single account (admin)."""
|
||||
data = self._request("GET", f"/v1/accounts/{account_id}")
|
||||
assert data is not None
|
||||
return Account.from_dict(data)
|
||||
@@ -157,42 +214,40 @@ class Client:
|
||||
account_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
) -> Account:
|
||||
"""PATCH /v1/accounts/{id} — update account fields."""
|
||||
) -> None:
|
||||
"""PATCH /v1/accounts/{id} — update account fields (admin).
|
||||
Currently only `status` is patchable. Returns None (204 No Content).
|
||||
"""
|
||||
payload: dict[str, Any] = {}
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
data = self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
|
||||
assert data is not None
|
||||
return Account.from_dict(data)
|
||||
self._request("PATCH", f"/v1/accounts/{account_id}", json=payload)
|
||||
def delete_account(self, account_id: str) -> None:
|
||||
"""DELETE /v1/accounts/{id} — permanently remove an account."""
|
||||
"""DELETE /v1/accounts/{id} — soft-delete an account (admin)."""
|
||||
self._request("DELETE", f"/v1/accounts/{account_id}")
|
||||
def get_roles(self, account_id: str) -> list[str]:
|
||||
"""GET /v1/accounts/{id}/roles — list roles for an account."""
|
||||
"""GET /v1/accounts/{id}/roles — list roles for an account (admin)."""
|
||||
data = self._request("GET", f"/v1/accounts/{account_id}/roles")
|
||||
assert data is not None
|
||||
roles_raw = data.get("roles") or []
|
||||
return [str(r) for r in roles_raw]
|
||||
def set_roles(self, account_id: str, roles: list[str]) -> None:
|
||||
"""PUT /v1/accounts/{id}/roles — replace the full role set."""
|
||||
"""PUT /v1/accounts/{id}/roles — replace the full role set (admin)."""
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/v1/accounts/{account_id}/roles",
|
||||
json={"roles": roles},
|
||||
)
|
||||
def issue_service_token(self, account_id: str) -> tuple[str, str]:
|
||||
"""POST /v1/accounts/{id}/token — issue a long-lived service token.
|
||||
Returns (token, expires_at).
|
||||
"""
|
||||
data = self._request("POST", f"/v1/accounts/{account_id}/token")
|
||||
assert data is not None
|
||||
return str(data["token"]), str(data["expires_at"])
|
||||
def revoke_token(self, jti: str) -> None:
|
||||
"""DELETE /v1/token/{jti} — revoke a token by JTI."""
|
||||
self._request("DELETE", f"/v1/token/{jti}")
|
||||
def admin_set_password(self, account_id: str, new_password: str) -> None:
|
||||
"""PUT /v1/accounts/{id}/password — reset a password without the old one (admin)."""
|
||||
self._request(
|
||||
"PUT",
|
||||
f"/v1/accounts/{account_id}/password",
|
||||
json={"new_password": new_password},
|
||||
)
|
||||
# ── Admin — Credentials ───────────────────────────────────────────────────
|
||||
def get_pg_creds(self, account_id: str) -> PGCreds:
|
||||
"""GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials."""
|
||||
"""GET /v1/accounts/{id}/pgcreds — retrieve Postgres credentials (admin)."""
|
||||
data = self._request("GET", f"/v1/accounts/{account_id}/pgcreds")
|
||||
assert data is not None
|
||||
return PGCreds.from_dict(data)
|
||||
@@ -205,7 +260,7 @@ class Client:
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials."""
|
||||
"""PUT /v1/accounts/{id}/pgcreds — store or replace Postgres credentials (admin)."""
|
||||
payload: dict[str, Any] = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
@@ -214,3 +269,89 @@ class Client:
|
||||
"password": password,
|
||||
}
|
||||
self._request("PUT", f"/v1/accounts/{account_id}/pgcreds", json=payload)
|
||||
# ── Admin — Policy ────────────────────────────────────────────────────────
|
||||
def get_account_tags(self, account_id: str) -> list[str]:
|
||||
"""GET /v1/accounts/{id}/tags — get account tags (admin)."""
|
||||
data = self._request("GET", f"/v1/accounts/{account_id}/tags")
|
||||
assert data is not None
|
||||
return [str(t) for t in (data.get("tags") or [])]
|
||||
def set_account_tags(self, account_id: str, tags: list[str]) -> list[str]:
|
||||
"""PUT /v1/accounts/{id}/tags — replace the full tag set (admin).
|
||||
Returns the updated tag list.
|
||||
"""
|
||||
data = self._request(
|
||||
"PUT",
|
||||
f"/v1/accounts/{account_id}/tags",
|
||||
json={"tags": tags},
|
||||
)
|
||||
assert data is not None
|
||||
return [str(t) for t in (data.get("tags") or [])]
|
||||
def list_policy_rules(self) -> list[PolicyRule]:
|
||||
"""GET /v1/policy/rules — list all operator policy rules (admin)."""
|
||||
items = self._request_list("GET", "/v1/policy/rules")
|
||||
return [PolicyRule.from_dict(r) for r in items]
|
||||
def create_policy_rule(
|
||||
self,
|
||||
description: str,
|
||||
rule: RuleBody,
|
||||
*,
|
||||
priority: int | None = None,
|
||||
not_before: str | None = None,
|
||||
expires_at: str | None = None,
|
||||
) -> PolicyRule:
|
||||
"""POST /v1/policy/rules — create a policy rule (admin)."""
|
||||
payload: dict[str, Any] = {
|
||||
"description": description,
|
||||
"rule": rule.to_dict(),
|
||||
}
|
||||
if priority is not None:
|
||||
payload["priority"] = priority
|
||||
if not_before is not None:
|
||||
payload["not_before"] = not_before
|
||||
if expires_at is not None:
|
||||
payload["expires_at"] = expires_at
|
||||
data = self._request("POST", "/v1/policy/rules", json=payload)
|
||||
assert data is not None
|
||||
return PolicyRule.from_dict(data)
|
||||
def get_policy_rule(self, rule_id: int) -> PolicyRule:
|
||||
"""GET /v1/policy/rules/{id} — get a policy rule (admin)."""
|
||||
data = self._request("GET", f"/v1/policy/rules/{rule_id}")
|
||||
assert data is not None
|
||||
return PolicyRule.from_dict(data)
|
||||
def update_policy_rule(
|
||||
self,
|
||||
rule_id: int,
|
||||
*,
|
||||
description: str | None = None,
|
||||
priority: int | None = None,
|
||||
enabled: bool | None = None,
|
||||
rule: RuleBody | None = None,
|
||||
not_before: str | None = None,
|
||||
expires_at: str | None = None,
|
||||
clear_not_before: bool | None = None,
|
||||
clear_expires_at: bool | None = None,
|
||||
) -> PolicyRule:
|
||||
"""PATCH /v1/policy/rules/{id} — update a policy rule (admin)."""
|
||||
payload: dict[str, Any] = {}
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if priority is not None:
|
||||
payload["priority"] = priority
|
||||
if enabled is not None:
|
||||
payload["enabled"] = enabled
|
||||
if rule is not None:
|
||||
payload["rule"] = rule.to_dict()
|
||||
if not_before is not None:
|
||||
payload["not_before"] = not_before
|
||||
if expires_at is not None:
|
||||
payload["expires_at"] = expires_at
|
||||
if clear_not_before is not None:
|
||||
payload["clear_not_before"] = clear_not_before
|
||||
if clear_expires_at is not None:
|
||||
payload["clear_expires_at"] = clear_expires_at
|
||||
data = self._request("PATCH", f"/v1/policy/rules/{rule_id}", json=payload)
|
||||
assert data is not None
|
||||
return PolicyRule.from_dict(data)
|
||||
def delete_policy_rule(self, rule_id: int) -> None:
|
||||
"""DELETE /v1/policy/rules/{id} — delete a policy rule (admin)."""
|
||||
self._request("DELETE", f"/v1/policy/rules/{rule_id}")
|
||||
|
||||
@@ -15,6 +15,8 @@ class MciasInputError(MciasError):
|
||||
"""400 Bad Request — malformed request."""
|
||||
class MciasConflictError(MciasError):
|
||||
"""409 Conflict — e.g. duplicate username."""
|
||||
class MciasRateLimitError(MciasError):
|
||||
"""429 Too Many Requests — rate limit exceeded."""
|
||||
class MciasServerError(MciasError):
|
||||
"""5xx — unexpected server error."""
|
||||
def raise_for_status(status_code: int, message: str) -> None:
|
||||
@@ -25,6 +27,7 @@ def raise_for_status(status_code: int, message: str) -> None:
|
||||
403: MciasForbiddenError,
|
||||
404: MciasNotFoundError,
|
||||
409: MciasConflictError,
|
||||
429: MciasRateLimitError,
|
||||
}
|
||||
cls = exc_map.get(status_code, MciasServerError)
|
||||
raise cls(status_code, message)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Data models for MCIAS API responses."""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -74,3 +74,73 @@ class PGCreds:
|
||||
username=str(d["username"]),
|
||||
password=str(d["password"]),
|
||||
)
|
||||
@dataclass
|
||||
class RuleBody:
|
||||
"""Match conditions and effect of a policy rule."""
|
||||
effect: str
|
||||
roles: list[str] = field(default_factory=list)
|
||||
account_types: list[str] = field(default_factory=list)
|
||||
subject_uuid: str | None = None
|
||||
actions: list[str] = field(default_factory=list)
|
||||
resource_type: str | None = None
|
||||
owner_matches_subject: bool | None = None
|
||||
service_names: list[str] = field(default_factory=list)
|
||||
required_tags: list[str] = field(default_factory=list)
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, object]) -> "RuleBody":
|
||||
return cls(
|
||||
effect=str(d["effect"]),
|
||||
roles=[str(r) for r in cast(list[Any], d.get("roles") or [])],
|
||||
account_types=[str(t) for t in cast(list[Any], d.get("account_types") or [])],
|
||||
subject_uuid=str(d["subject_uuid"]) if d.get("subject_uuid") is not None else None,
|
||||
actions=[str(a) for a in cast(list[Any], d.get("actions") or [])],
|
||||
resource_type=str(d["resource_type"]) if d.get("resource_type") is not None else None,
|
||||
owner_matches_subject=bool(d["owner_matches_subject"]) if d.get("owner_matches_subject") is not None else None,
|
||||
service_names=[str(s) for s in cast(list[Any], d.get("service_names") or [])],
|
||||
required_tags=[str(t) for t in cast(list[Any], d.get("required_tags") or [])],
|
||||
)
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialise to a JSON-compatible dict, omitting None/empty fields."""
|
||||
out: dict[str, Any] = {"effect": self.effect}
|
||||
if self.roles:
|
||||
out["roles"] = self.roles
|
||||
if self.account_types:
|
||||
out["account_types"] = self.account_types
|
||||
if self.subject_uuid is not None:
|
||||
out["subject_uuid"] = self.subject_uuid
|
||||
if self.actions:
|
||||
out["actions"] = self.actions
|
||||
if self.resource_type is not None:
|
||||
out["resource_type"] = self.resource_type
|
||||
if self.owner_matches_subject is not None:
|
||||
out["owner_matches_subject"] = self.owner_matches_subject
|
||||
if self.service_names:
|
||||
out["service_names"] = self.service_names
|
||||
if self.required_tags:
|
||||
out["required_tags"] = self.required_tags
|
||||
return out
|
||||
@dataclass
|
||||
class PolicyRule:
|
||||
"""An operator-defined policy rule."""
|
||||
id: int
|
||||
priority: int
|
||||
description: str
|
||||
rule: RuleBody
|
||||
enabled: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
not_before: str | None = None
|
||||
expires_at: str | None = None
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict[str, object]) -> "PolicyRule":
|
||||
return cls(
|
||||
id=int(cast(int, d["id"])),
|
||||
priority=int(cast(int, d["priority"])),
|
||||
description=str(d["description"]),
|
||||
rule=RuleBody.from_dict(cast(dict[str, object], d["rule"])),
|
||||
enabled=bool(d["enabled"]),
|
||||
created_at=str(d["created_at"]),
|
||||
updated_at=str(d["updated_at"]),
|
||||
not_before=str(d["not_before"]) if d.get("not_before") is not None else None,
|
||||
expires_at=str(d["expires_at"]) if d.get("expires_at") is not None else None,
|
||||
)
|
||||
|
||||
@@ -13,15 +13,16 @@ from mcias_client import (
|
||||
MciasForbiddenError,
|
||||
MciasInputError,
|
||||
MciasNotFoundError,
|
||||
MciasRateLimitError,
|
||||
MciasServerError,
|
||||
)
|
||||
from mcias_client._models import Account, PGCreds, PublicKey, TokenClaims
|
||||
from mcias_client._models import Account, PGCreds, PolicyRule, PublicKey, RuleBody, TokenClaims
|
||||
|
||||
BASE_URL = "https://auth.example.com"
|
||||
SAMPLE_ACCOUNT: dict[str, object] = {
|
||||
"id": "acc-001",
|
||||
"username": "alice",
|
||||
"account_type": "user",
|
||||
"account_type": "human",
|
||||
"status": "active",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z",
|
||||
@@ -34,6 +35,24 @@ SAMPLE_PK: dict[str, object] = {
|
||||
"use": "sig",
|
||||
"alg": "EdDSA",
|
||||
}
|
||||
SAMPLE_RULE_BODY: dict[str, object] = {
|
||||
"effect": "allow",
|
||||
"roles": ["svc:payments-api"],
|
||||
"actions": ["pgcreds:read"],
|
||||
"resource_type": "pgcreds",
|
||||
"owner_matches_subject": True,
|
||||
}
|
||||
SAMPLE_POLICY_RULE: dict[str, object] = {
|
||||
"id": 1,
|
||||
"priority": 100,
|
||||
"description": "Allow payments-api to read its own pgcreds",
|
||||
"rule": SAMPLE_RULE_BODY,
|
||||
"enabled": True,
|
||||
"not_before": None,
|
||||
"expires_at": None,
|
||||
"created_at": "2026-03-11T09:00:00Z",
|
||||
"updated_at": "2026-03-11T09:00:00Z",
|
||||
}
|
||||
@pytest.fixture
|
||||
def client() -> Client:
|
||||
return Client(BASE_URL)
|
||||
@@ -88,6 +107,16 @@ def test_login_success(client: Client) -> None:
|
||||
assert expires_at == "2099-01-01T00:00:00Z"
|
||||
assert client.token == "jwt-token-abc"
|
||||
@respx.mock
|
||||
def test_login_with_totp(client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/auth/login").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "jwt-token-totp", "expires_at": "2099-01-01T00:00:00Z"},
|
||||
)
|
||||
)
|
||||
token, _ = client.login("alice", "s3cr3t", totp_code="123456")
|
||||
assert token == "jwt-token-totp"
|
||||
@respx.mock
|
||||
def test_login_unauthorized(client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/auth/login").mock(
|
||||
return_value=httpx.Response(
|
||||
@@ -98,6 +127,14 @@ def test_login_unauthorized(client: Client) -> None:
|
||||
client.login("alice", "wrong")
|
||||
assert exc_info.value.status_code == 401
|
||||
@respx.mock
|
||||
def test_login_rate_limited(client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/auth/login").mock(
|
||||
return_value=httpx.Response(429, json={"error": "rate limit exceeded", "code": "rate_limited"})
|
||||
)
|
||||
with pytest.raises(MciasRateLimitError) as exc_info:
|
||||
client.login("alice", "s3cr3t")
|
||||
assert exc_info.value.status_code == 429
|
||||
@respx.mock
|
||||
def test_logout_clears_token(admin_client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/auth/logout").mock(
|
||||
return_value=httpx.Response(204)
|
||||
@@ -147,11 +184,58 @@ def test_validate_token_invalid(admin_client: Client) -> None:
|
||||
claims = admin_client.validate_token("expired-token")
|
||||
assert claims.valid is False
|
||||
@respx.mock
|
||||
def test_enroll_totp(admin_client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/auth/totp/enroll").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/MCIAS:alice?secret=JBSWY3DPEHPK3PXP&issuer=MCIAS"},
|
||||
)
|
||||
)
|
||||
secret, uri = admin_client.enroll_totp()
|
||||
assert secret == "JBSWY3DPEHPK3PXP"
|
||||
assert "otpauth://totp/" in uri
|
||||
@respx.mock
|
||||
def test_confirm_totp(admin_client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/auth/totp/confirm").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
admin_client.confirm_totp("123456") # should not raise
|
||||
@respx.mock
|
||||
def test_change_password(admin_client: Client) -> None:
|
||||
respx.put(f"{BASE_URL}/v1/auth/password").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
admin_client.change_password("old-pass", "new-pass-long-enough") # should not raise
|
||||
@respx.mock
|
||||
def test_remove_totp(admin_client: Client) -> None:
|
||||
respx.delete(f"{BASE_URL}/v1/auth/totp").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
admin_client.remove_totp("acc-001") # should not raise
|
||||
@respx.mock
|
||||
def test_issue_service_token(admin_client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/token/issue").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "svc-token-xyz", "expires_at": "2099-12-31T00:00:00Z"},
|
||||
)
|
||||
)
|
||||
token, expires_at = admin_client.issue_service_token("acc-001")
|
||||
assert token == "svc-token-xyz"
|
||||
assert expires_at == "2099-12-31T00:00:00Z"
|
||||
@respx.mock
|
||||
def test_revoke_token(admin_client: Client) -> None:
|
||||
jti = "some-jti-uuid"
|
||||
respx.delete(f"{BASE_URL}/v1/token/{jti}").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
admin_client.revoke_token(jti) # should not raise
|
||||
@respx.mock
|
||||
def test_create_account(admin_client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/accounts").mock(
|
||||
return_value=httpx.Response(201, json=SAMPLE_ACCOUNT)
|
||||
)
|
||||
acc = admin_client.create_account("alice", "user", password="pass123")
|
||||
acc = admin_client.create_account("alice", "human", password="pass123")
|
||||
assert isinstance(acc, Account)
|
||||
assert acc.id == "acc-001"
|
||||
assert acc.username == "alice"
|
||||
@@ -161,15 +245,14 @@ def test_create_account_conflict(admin_client: Client) -> None:
|
||||
return_value=httpx.Response(409, json={"error": "username already exists"})
|
||||
)
|
||||
with pytest.raises(MciasConflictError) as exc_info:
|
||||
admin_client.create_account("alice", "user")
|
||||
admin_client.create_account("alice", "human")
|
||||
assert exc_info.value.status_code == 409
|
||||
@respx.mock
|
||||
def test_list_accounts(admin_client: Client) -> None:
|
||||
second = {**SAMPLE_ACCOUNT, "id": "acc-002"}
|
||||
# API returns a plain JSON array, not a wrapper object
|
||||
respx.get(f"{BASE_URL}/v1/accounts").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"accounts": [SAMPLE_ACCOUNT, second]}
|
||||
)
|
||||
return_value=httpx.Response(200, json=[SAMPLE_ACCOUNT, second])
|
||||
)
|
||||
accounts = admin_client.list_accounts()
|
||||
assert len(accounts) == 2
|
||||
@@ -183,12 +266,12 @@ def test_get_account(admin_client: Client) -> None:
|
||||
assert acc.id == "acc-001"
|
||||
@respx.mock
|
||||
def test_update_account(admin_client: Client) -> None:
|
||||
updated = {**SAMPLE_ACCOUNT, "status": "suspended"}
|
||||
# PATCH /v1/accounts/{id} returns 204 No Content
|
||||
respx.patch(f"{BASE_URL}/v1/accounts/acc-001").mock(
|
||||
return_value=httpx.Response(200, json=updated)
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
acc = admin_client.update_account("acc-001", status="suspended")
|
||||
assert acc.status == "suspended"
|
||||
result = admin_client.update_account("acc-001", status="inactive")
|
||||
assert result is None
|
||||
@respx.mock
|
||||
def test_delete_account(admin_client: Client) -> None:
|
||||
respx.delete(f"{BASE_URL}/v1/accounts/acc-001").mock(
|
||||
@@ -209,23 +292,11 @@ def test_set_roles(admin_client: Client) -> None:
|
||||
)
|
||||
admin_client.set_roles("acc-001", ["viewer"]) # should not raise
|
||||
@respx.mock
|
||||
def test_issue_service_token(admin_client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/accounts/acc-001/token").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"token": "svc-token-xyz", "expires_at": "2099-12-31T00:00:00Z"},
|
||||
)
|
||||
)
|
||||
token, expires_at = admin_client.issue_service_token("acc-001")
|
||||
assert token == "svc-token-xyz"
|
||||
assert expires_at == "2099-12-31T00:00:00Z"
|
||||
@respx.mock
|
||||
def test_revoke_token(admin_client: Client) -> None:
|
||||
jti = "some-jti-uuid"
|
||||
respx.delete(f"{BASE_URL}/v1/token/{jti}").mock(
|
||||
def test_admin_set_password(admin_client: Client) -> None:
|
||||
respx.put(f"{BASE_URL}/v1/accounts/acc-001/password").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
admin_client.revoke_token(jti) # should not raise
|
||||
admin_client.admin_set_password("acc-001", "new-secure-password") # should not raise
|
||||
SAMPLE_PG_CREDS: dict[str, object] = {
|
||||
"host": "db.example.com",
|
||||
"port": 5432,
|
||||
@@ -256,6 +327,68 @@ def test_set_pg_creds(admin_client: Client) -> None:
|
||||
username="appuser",
|
||||
password="s3cr3t",
|
||||
) # should not raise
|
||||
@respx.mock
|
||||
def test_get_account_tags(admin_client: Client) -> None:
|
||||
respx.get(f"{BASE_URL}/v1/accounts/acc-001/tags").mock(
|
||||
return_value=httpx.Response(200, json={"tags": ["env:production", "svc:payments-api"]})
|
||||
)
|
||||
tags = admin_client.get_account_tags("acc-001")
|
||||
assert tags == ["env:production", "svc:payments-api"]
|
||||
@respx.mock
|
||||
def test_set_account_tags(admin_client: Client) -> None:
|
||||
respx.put(f"{BASE_URL}/v1/accounts/acc-001/tags").mock(
|
||||
return_value=httpx.Response(200, json={"tags": ["env:staging"]})
|
||||
)
|
||||
tags = admin_client.set_account_tags("acc-001", ["env:staging"])
|
||||
assert tags == ["env:staging"]
|
||||
@respx.mock
|
||||
def test_list_policy_rules(admin_client: Client) -> None:
|
||||
respx.get(f"{BASE_URL}/v1/policy/rules").mock(
|
||||
return_value=httpx.Response(200, json=[SAMPLE_POLICY_RULE])
|
||||
)
|
||||
rules = admin_client.list_policy_rules()
|
||||
assert len(rules) == 1
|
||||
assert isinstance(rules[0], PolicyRule)
|
||||
assert rules[0].id == 1
|
||||
assert rules[0].rule.effect == "allow"
|
||||
@respx.mock
|
||||
def test_create_policy_rule(admin_client: Client) -> None:
|
||||
respx.post(f"{BASE_URL}/v1/policy/rules").mock(
|
||||
return_value=httpx.Response(201, json=SAMPLE_POLICY_RULE)
|
||||
)
|
||||
rule_body = RuleBody(effect="allow", actions=["pgcreds:read"], resource_type="pgcreds")
|
||||
rule = admin_client.create_policy_rule(
|
||||
"Allow payments-api to read its own pgcreds",
|
||||
rule_body,
|
||||
priority=50,
|
||||
)
|
||||
assert isinstance(rule, PolicyRule)
|
||||
assert rule.id == 1
|
||||
assert rule.description == "Allow payments-api to read its own pgcreds"
|
||||
@respx.mock
|
||||
def test_get_policy_rule(admin_client: Client) -> None:
|
||||
respx.get(f"{BASE_URL}/v1/policy/rules/1").mock(
|
||||
return_value=httpx.Response(200, json=SAMPLE_POLICY_RULE)
|
||||
)
|
||||
rule = admin_client.get_policy_rule(1)
|
||||
assert isinstance(rule, PolicyRule)
|
||||
assert rule.id == 1
|
||||
assert rule.enabled is True
|
||||
@respx.mock
|
||||
def test_update_policy_rule(admin_client: Client) -> None:
|
||||
updated = {**SAMPLE_POLICY_RULE, "enabled": False}
|
||||
respx.patch(f"{BASE_URL}/v1/policy/rules/1").mock(
|
||||
return_value=httpx.Response(200, json=updated)
|
||||
)
|
||||
rule = admin_client.update_policy_rule(1, enabled=False)
|
||||
assert isinstance(rule, PolicyRule)
|
||||
assert rule.enabled is False
|
||||
@respx.mock
|
||||
def test_delete_policy_rule(admin_client: Client) -> None:
|
||||
respx.delete(f"{BASE_URL}/v1/policy/rules/1").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
admin_client.delete_policy_rule(1) # should not raise
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "exc_class"),
|
||||
[
|
||||
@@ -264,6 +397,7 @@ def test_set_pg_creds(admin_client: Client) -> None:
|
||||
(403, MciasForbiddenError),
|
||||
(404, MciasNotFoundError),
|
||||
(409, MciasConflictError),
|
||||
(429, MciasRateLimitError),
|
||||
(500, MciasServerError),
|
||||
],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user