- 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>
455 lines
16 KiB
Python
455 lines
16 KiB
Python
"""Tests for the MCIAS Python client using respx to mock httpx."""
|
|
from __future__ import annotations
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from mcias_client import (
|
|
Client,
|
|
MciasAuthError,
|
|
MciasConflictError,
|
|
MciasError,
|
|
MciasForbiddenError,
|
|
MciasInputError,
|
|
MciasNotFoundError,
|
|
MciasRateLimitError,
|
|
MciasServerError,
|
|
)
|
|
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": "human",
|
|
"status": "active",
|
|
"created_at": "2024-01-01T00:00:00Z",
|
|
"updated_at": "2024-01-01T00:00:00Z",
|
|
"totp_enabled": False,
|
|
}
|
|
SAMPLE_PK: dict[str, object] = {
|
|
"kty": "OKP",
|
|
"crv": "Ed25519",
|
|
"x": "base64urlpublickey",
|
|
"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)
|
|
@pytest.fixture
|
|
def admin_client() -> Client:
|
|
return Client(BASE_URL, token="admin-token")
|
|
def test_client_init() -> None:
|
|
c = Client(BASE_URL)
|
|
assert c.token is None
|
|
assert c._base_url == BASE_URL
|
|
c.close()
|
|
def test_client_strips_trailing_slash() -> None:
|
|
c = Client(BASE_URL + "/")
|
|
assert c._base_url == BASE_URL
|
|
c.close()
|
|
def test_client_init_with_token() -> None:
|
|
c = Client(BASE_URL, token="mytoken")
|
|
assert c.token == "mytoken"
|
|
c.close()
|
|
@respx.mock
|
|
def test_health_ok(client: Client) -> None:
|
|
respx.get(f"{BASE_URL}/v1/health").mock(return_value=httpx.Response(200))
|
|
client.health() # should not raise
|
|
@respx.mock
|
|
def test_health_server_error(client: Client) -> None:
|
|
respx.get(f"{BASE_URL}/v1/health").mock(
|
|
return_value=httpx.Response(503, json={"error": "service unavailable"})
|
|
)
|
|
with pytest.raises(MciasServerError) as exc_info:
|
|
client.health()
|
|
assert exc_info.value.status_code == 503
|
|
@respx.mock
|
|
def test_get_public_key(client: Client) -> None:
|
|
respx.get(f"{BASE_URL}/v1/keys/public").mock(
|
|
return_value=httpx.Response(200, json=SAMPLE_PK)
|
|
)
|
|
pk = client.get_public_key()
|
|
assert isinstance(pk, PublicKey)
|
|
assert pk.kty == "OKP"
|
|
assert pk.crv == "Ed25519"
|
|
assert pk.alg == "EdDSA"
|
|
@respx.mock
|
|
def test_login_success(client: Client) -> None:
|
|
respx.post(f"{BASE_URL}/v1/auth/login").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"token": "jwt-token-abc", "expires_at": "2099-01-01T00:00:00Z"},
|
|
)
|
|
)
|
|
token, expires_at = client.login("alice", "s3cr3t")
|
|
assert token == "jwt-token-abc"
|
|
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(
|
|
401, json={"error": "invalid credentials"}
|
|
)
|
|
)
|
|
with pytest.raises(MciasAuthError) as exc_info:
|
|
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)
|
|
)
|
|
assert admin_client.token == "admin-token"
|
|
admin_client.logout()
|
|
assert admin_client.token is None
|
|
@respx.mock
|
|
def test_renew_token(admin_client: Client) -> None:
|
|
respx.post(f"{BASE_URL}/v1/auth/renew").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"token": "new-jwt-token", "expires_at": "2099-06-01T00:00:00Z"},
|
|
)
|
|
)
|
|
token, expires_at = admin_client.renew_token()
|
|
assert token == "new-jwt-token"
|
|
assert expires_at == "2099-06-01T00:00:00Z"
|
|
assert admin_client.token == "new-jwt-token"
|
|
@respx.mock
|
|
def test_validate_token_valid(admin_client: Client) -> None:
|
|
respx.post(f"{BASE_URL}/v1/token/validate").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"valid": True,
|
|
"sub": "acc-001",
|
|
"roles": ["admin"],
|
|
"expires_at": "2099-01-01T00:00:00Z",
|
|
},
|
|
)
|
|
)
|
|
claims = admin_client.validate_token("some-token")
|
|
assert isinstance(claims, TokenClaims)
|
|
assert claims.valid is True
|
|
assert claims.sub == "acc-001"
|
|
assert claims.roles == ["admin"]
|
|
@respx.mock
|
|
def test_validate_token_invalid(admin_client: Client) -> None:
|
|
"""valid=False in the response body is NOT an exception — just a falsy claim."""
|
|
respx.post(f"{BASE_URL}/v1/token/validate").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={"valid": False, "sub": "", "roles": []},
|
|
)
|
|
)
|
|
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", "human", password="pass123")
|
|
assert isinstance(acc, Account)
|
|
assert acc.id == "acc-001"
|
|
assert acc.username == "alice"
|
|
@respx.mock
|
|
def test_create_account_conflict(admin_client: Client) -> None:
|
|
respx.post(f"{BASE_URL}/v1/accounts").mock(
|
|
return_value=httpx.Response(409, json={"error": "username already exists"})
|
|
)
|
|
with pytest.raises(MciasConflictError) as exc_info:
|
|
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=[SAMPLE_ACCOUNT, second])
|
|
)
|
|
accounts = admin_client.list_accounts()
|
|
assert len(accounts) == 2
|
|
assert all(isinstance(a, Account) for a in accounts)
|
|
@respx.mock
|
|
def test_get_account(admin_client: Client) -> None:
|
|
respx.get(f"{BASE_URL}/v1/accounts/acc-001").mock(
|
|
return_value=httpx.Response(200, json=SAMPLE_ACCOUNT)
|
|
)
|
|
acc = admin_client.get_account("acc-001")
|
|
assert acc.id == "acc-001"
|
|
@respx.mock
|
|
def test_update_account(admin_client: Client) -> None:
|
|
# PATCH /v1/accounts/{id} returns 204 No Content
|
|
respx.patch(f"{BASE_URL}/v1/accounts/acc-001").mock(
|
|
return_value=httpx.Response(204)
|
|
)
|
|
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(
|
|
return_value=httpx.Response(204)
|
|
)
|
|
admin_client.delete_account("acc-001") # should not raise
|
|
@respx.mock
|
|
def test_get_roles(admin_client: Client) -> None:
|
|
respx.get(f"{BASE_URL}/v1/accounts/acc-001/roles").mock(
|
|
return_value=httpx.Response(200, json={"roles": ["admin", "viewer"]})
|
|
)
|
|
roles = admin_client.get_roles("acc-001")
|
|
assert roles == ["admin", "viewer"]
|
|
@respx.mock
|
|
def test_set_roles(admin_client: Client) -> None:
|
|
respx.put(f"{BASE_URL}/v1/accounts/acc-001/roles").mock(
|
|
return_value=httpx.Response(204)
|
|
)
|
|
admin_client.set_roles("acc-001", ["viewer"]) # should not raise
|
|
@respx.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.admin_set_password("acc-001", "new-secure-password") # should not raise
|
|
SAMPLE_PG_CREDS: dict[str, object] = {
|
|
"host": "db.example.com",
|
|
"port": 5432,
|
|
"database": "myapp",
|
|
"username": "appuser",
|
|
"password": "s3cr3t",
|
|
}
|
|
@respx.mock
|
|
def test_get_pg_creds(admin_client: Client) -> None:
|
|
respx.get(f"{BASE_URL}/v1/accounts/acc-001/pgcreds").mock(
|
|
return_value=httpx.Response(200, json=SAMPLE_PG_CREDS)
|
|
)
|
|
creds = admin_client.get_pg_creds("acc-001")
|
|
assert isinstance(creds, PGCreds)
|
|
assert creds.host == "db.example.com"
|
|
assert creds.port == 5432
|
|
assert creds.database == "myapp"
|
|
@respx.mock
|
|
def test_set_pg_creds(admin_client: Client) -> None:
|
|
respx.put(f"{BASE_URL}/v1/accounts/acc-001/pgcreds").mock(
|
|
return_value=httpx.Response(204)
|
|
)
|
|
admin_client.set_pg_creds(
|
|
"acc-001",
|
|
host="db.example.com",
|
|
port=5432,
|
|
database="myapp",
|
|
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"),
|
|
[
|
|
(400, MciasInputError),
|
|
(401, MciasAuthError),
|
|
(403, MciasForbiddenError),
|
|
(404, MciasNotFoundError),
|
|
(409, MciasConflictError),
|
|
(429, MciasRateLimitError),
|
|
(500, MciasServerError),
|
|
],
|
|
)
|
|
@respx.mock
|
|
def test_error_types(
|
|
client: Client,
|
|
status_code: int,
|
|
exc_class: type,
|
|
) -> None:
|
|
respx.get(f"{BASE_URL}/v1/health").mock(
|
|
return_value=httpx.Response(
|
|
status_code, json={"error": "test error"}
|
|
)
|
|
)
|
|
with pytest.raises(exc_class) as exc_info:
|
|
client.health()
|
|
err = exc_info.value
|
|
assert isinstance(err, MciasError)
|
|
assert err.status_code == status_code
|
|
@respx.mock
|
|
def test_context_manager() -> None:
|
|
respx.get(f"{BASE_URL}/v1/health").mock(return_value=httpx.Response(200))
|
|
with Client(BASE_URL) as c:
|
|
c.health()
|
|
assert c._http.is_closed
|
|
@respx.mock
|
|
def test_integration_login_validate_logout() -> None:
|
|
"""Full flow: login, validate the issued token, then logout."""
|
|
login_resp = httpx.Response(
|
|
200,
|
|
json={"token": "flow-token-abc", "expires_at": "2099-01-01T00:00:00Z"},
|
|
)
|
|
validate_resp = httpx.Response(
|
|
200,
|
|
json={
|
|
"valid": True,
|
|
"sub": "acc-001",
|
|
"roles": ["admin"],
|
|
"expires_at": "2099-01-01T00:00:00Z",
|
|
},
|
|
)
|
|
logout_resp = httpx.Response(204)
|
|
respx.post(f"{BASE_URL}/v1/auth/login").mock(return_value=login_resp)
|
|
respx.post(f"{BASE_URL}/v1/token/validate").mock(return_value=validate_resp)
|
|
respx.post(f"{BASE_URL}/v1/auth/logout").mock(return_value=logout_resp)
|
|
with Client(BASE_URL) as c:
|
|
token, _ = c.login("alice", "password")
|
|
assert token == "flow-token-abc"
|
|
assert c.token == "flow-token-abc"
|
|
claims = c.validate_token(token)
|
|
assert claims.valid is True
|
|
assert "admin" in claims.roles
|
|
c.logout()
|
|
assert c.token is None
|