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:
2026-03-12 20:29:11 -07:00
parent ec7c966ad2
commit cbcb1a0533
11 changed files with 1938 additions and 255 deletions

View File

@@ -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),
],
)