"""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