""" Tests for all service clients — covers all 14 API endpoints (sync + async). Uses `responses` for sync mocking and `respx` for async mocking. """ from __future__ import annotations import pytest import responses as resp_lib import respx import httpx from sentryagent_idp.errors import AgentIdPError from sentryagent_idp.services.agents import AgentRegistryClient, AsyncAgentRegistryClient from sentryagent_idp.services.credentials import CredentialClient, AsyncCredentialClient from sentryagent_idp.services.token import TokenClient, AsyncTokenClient from sentryagent_idp.services.audit import AuditClient, AsyncAuditClient from sentryagent_idp.types import RegisterAgentRequest, UpdateAgentRequest BASE = "http://localhost:3000" TOKEN = "test-bearer-token" def get_token() -> str: return TOKEN async def async_get_token() -> str: return TOKEN # ─── Fixtures ───────────────────────────────────────────────────────────────── AGENT = { "agentId": "uuid-1", "email": "a@b.ai", "agentType": "screener", "version": "1.0.0", "capabilities": ["read"], "owner": "team", "deploymentEnv": "production", "status": "active", "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-01T00:00:00Z", } PAGINATED_AGENTS = {"data": [AGENT], "total": 1, "page": 1, "limit": 20} CRED = { "credentialId": "cred-1", "clientId": "uuid-1", "status": "active", "createdAt": "2026-01-01T00:00:00Z", "expiresAt": None, "revokedAt": None, } CRED_WITH_SECRET = {**CRED, "clientSecret": "sk_live_abc"} PAGINATED_CREDS = {"data": [CRED], "total": 1, "page": 1, "limit": 20} INTROSPECT_ACTIVE = {"active": True, "sub": "uuid-1", "exp": 9999999999} INTROSPECT_INACTIVE = {"active": False} AUDIT_EVENT = { "eventId": "ev-1", "agentId": "uuid-1", "action": "token.issued", "outcome": "success", "ipAddress": "1.2.3.4", "userAgent": "curl", "metadata": {}, "timestamp": "2026-01-01T00:00:00Z", } PAGINATED_AUDIT = {"data": [AUDIT_EVENT], "total": 1, "page": 1, "limit": 20} # ─── Agent Registry — Sync ──────────────────────────────────────────────────── @resp_lib.activate def test_register_agent() -> None: resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents", json=AGENT, status=201) client = AgentRegistryClient(BASE, get_token) agent = client.register_agent(RegisterAgentRequest( email="a@b.ai", agent_type="screener", version="1.0.0", capabilities=["read"], owner="team", deployment_env="production", )) assert agent.agent_id == "uuid-1" @resp_lib.activate def test_list_agents() -> None: resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents", json=PAGINATED_AGENTS, status=200) client = AgentRegistryClient(BASE, get_token) result = client.list_agents() assert result.total == 1 @resp_lib.activate def test_get_agent() -> None: resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents/uuid-1", json=AGENT, status=200) client = AgentRegistryClient(BASE, get_token) agent = client.get_agent("uuid-1") assert agent.agent_id == "uuid-1" @resp_lib.activate def test_update_agent() -> None: resp_lib.add(resp_lib.PATCH, f"{BASE}/api/v1/agents/uuid-1", json=AGENT, status=200) client = AgentRegistryClient(BASE, get_token) agent = client.update_agent("uuid-1", UpdateAgentRequest(version="2.0.0")) assert agent.agent_id == "uuid-1" @resp_lib.activate def test_decommission_agent() -> None: resp_lib.add(resp_lib.DELETE, f"{BASE}/api/v1/agents/uuid-1", status=204) client = AgentRegistryClient(BASE, get_token) result = client.decommission_agent("uuid-1") assert result is None @resp_lib.activate def test_agent_not_found_raises() -> None: resp_lib.add( resp_lib.GET, f"{BASE}/api/v1/agents/bad-id", json={"code": "AgentNotFoundError", "message": "Not found."}, status=404, ) client = AgentRegistryClient(BASE, get_token) with pytest.raises(AgentIdPError) as exc_info: client.get_agent("bad-id") assert exc_info.value.code == "AgentNotFoundError" assert exc_info.value.http_status == 404 # ─── Credentials — Sync ─────────────────────────────────────────────────────── @resp_lib.activate def test_generate_credential() -> None: resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents/uuid-1/credentials", json=CRED_WITH_SECRET, status=201) client = CredentialClient(BASE, get_token) cred = client.generate_credential("uuid-1") assert cred.client_secret == "sk_live_abc" @resp_lib.activate def test_list_credentials() -> None: resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/agents/uuid-1/credentials", json=PAGINATED_CREDS, status=200) client = CredentialClient(BASE, get_token) result = client.list_credentials("uuid-1") assert result.total == 1 @resp_lib.activate def test_rotate_credential() -> None: resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1/rotate", json=CRED_WITH_SECRET, status=200) client = CredentialClient(BASE, get_token) cred = client.rotate_credential("uuid-1", "cred-1") assert cred.client_secret == "sk_live_abc" @resp_lib.activate def test_revoke_credential() -> None: revoked = {**CRED, "status": "revoked", "revokedAt": "2026-01-02T00:00:00Z"} resp_lib.add(resp_lib.DELETE, f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1", json=revoked, status=200) client = CredentialClient(BASE, get_token) cred = client.revoke_credential("uuid-1", "cred-1") assert cred.status == "revoked" # ─── Token — Sync ───────────────────────────────────────────────────────────── @resp_lib.activate def test_introspect_token_active() -> None: resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/introspect", json=INTROSPECT_ACTIVE, status=200) client = TokenClient(BASE, get_token) result = client.introspect_token("some-token") assert result.active is True assert result.sub == "uuid-1" @resp_lib.activate def test_introspect_token_inactive() -> None: resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/introspect", json=INTROSPECT_INACTIVE, status=200) client = TokenClient(BASE, get_token) result = client.introspect_token("expired-token") assert result.active is False @resp_lib.activate def test_revoke_token() -> None: resp_lib.add(resp_lib.POST, f"{BASE}/api/v1/token/revoke", json={}, status=200) client = TokenClient(BASE, get_token) result = client.revoke_token("some-token") assert result is None # ─── Audit — Sync ───────────────────────────────────────────────────────────── @resp_lib.activate def test_query_audit_log() -> None: resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/audit", json=PAGINATED_AUDIT, status=200) client = AuditClient(BASE, get_token) result = client.query_audit_log(agent_id="uuid-1", action="token.issued") assert result.total == 1 assert result.data[0].event_id == "ev-1" @resp_lib.activate def test_get_audit_event() -> None: resp_lib.add(resp_lib.GET, f"{BASE}/api/v1/audit/ev-1", json=AUDIT_EVENT, status=200) client = AuditClient(BASE, get_token) event = client.get_audit_event("ev-1") assert event.event_id == "ev-1" # ─── Async — all 14 endpoints ───────────────────────────────────────────────── @pytest.mark.asyncio async def test_async_register_agent() -> None: with respx.mock: respx.post(f"{BASE}/api/v1/agents").mock(return_value=httpx.Response(201, json=AGENT)) client = AsyncAgentRegistryClient(BASE, async_get_token) agent = await client.register_agent(RegisterAgentRequest( email="a@b.ai", agent_type="screener", version="1.0.0", capabilities=["read"], owner="team", deployment_env="production", )) assert agent.agent_id == "uuid-1" @pytest.mark.asyncio async def test_async_list_agents() -> None: with respx.mock: respx.get(f"{BASE}/api/v1/agents").mock(return_value=httpx.Response(200, json=PAGINATED_AGENTS)) client = AsyncAgentRegistryClient(BASE, async_get_token) result = await client.list_agents() assert result.total == 1 @pytest.mark.asyncio async def test_async_get_agent() -> None: with respx.mock: respx.get(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(200, json=AGENT)) client = AsyncAgentRegistryClient(BASE, async_get_token) agent = await client.get_agent("uuid-1") assert agent.agent_id == "uuid-1" @pytest.mark.asyncio async def test_async_update_agent() -> None: with respx.mock: respx.patch(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(200, json=AGENT)) client = AsyncAgentRegistryClient(BASE, async_get_token) agent = await client.update_agent("uuid-1", UpdateAgentRequest(version="2.0.0")) assert agent.agent_id == "uuid-1" @pytest.mark.asyncio async def test_async_decommission_agent() -> None: with respx.mock: respx.delete(f"{BASE}/api/v1/agents/uuid-1").mock(return_value=httpx.Response(204)) client = AsyncAgentRegistryClient(BASE, async_get_token) result = await client.decommission_agent("uuid-1") assert result is None @pytest.mark.asyncio async def test_async_generate_credential() -> None: with respx.mock: respx.post(f"{BASE}/api/v1/agents/uuid-1/credentials").mock( return_value=httpx.Response(201, json=CRED_WITH_SECRET)) client = AsyncCredentialClient(BASE, async_get_token) cred = await client.generate_credential("uuid-1") assert cred.client_secret == "sk_live_abc" @pytest.mark.asyncio async def test_async_list_credentials() -> None: with respx.mock: respx.get(f"{BASE}/api/v1/agents/uuid-1/credentials").mock( return_value=httpx.Response(200, json=PAGINATED_CREDS)) client = AsyncCredentialClient(BASE, async_get_token) result = await client.list_credentials("uuid-1") assert result.total == 1 @pytest.mark.asyncio async def test_async_rotate_credential() -> None: with respx.mock: respx.post(f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1/rotate").mock( return_value=httpx.Response(200, json=CRED_WITH_SECRET)) client = AsyncCredentialClient(BASE, async_get_token) cred = await client.rotate_credential("uuid-1", "cred-1") assert cred.client_secret == "sk_live_abc" @pytest.mark.asyncio async def test_async_revoke_credential() -> None: revoked = {**CRED, "status": "revoked", "revokedAt": "2026-01-02T00:00:00Z"} with respx.mock: respx.delete(f"{BASE}/api/v1/agents/uuid-1/credentials/cred-1").mock( return_value=httpx.Response(200, json=revoked)) client = AsyncCredentialClient(BASE, async_get_token) cred = await client.revoke_credential("uuid-1", "cred-1") assert cred.status == "revoked" @pytest.mark.asyncio async def test_async_introspect_token() -> None: with respx.mock: respx.post(f"{BASE}/api/v1/token/introspect").mock( return_value=httpx.Response(200, json=INTROSPECT_ACTIVE)) client = AsyncTokenClient(BASE, async_get_token) result = await client.introspect_token("tok") assert result.active is True @pytest.mark.asyncio async def test_async_revoke_token() -> None: with respx.mock: respx.post(f"{BASE}/api/v1/token/revoke").mock(return_value=httpx.Response(200, json={})) client = AsyncTokenClient(BASE, async_get_token) await client.revoke_token("tok") @pytest.mark.asyncio async def test_async_query_audit_log() -> None: with respx.mock: respx.get(f"{BASE}/api/v1/audit").mock(return_value=httpx.Response(200, json=PAGINATED_AUDIT)) client = AsyncAuditClient(BASE, async_get_token) result = await client.query_audit_log() assert result.total == 1 @pytest.mark.asyncio async def test_async_get_audit_event() -> None: with respx.mock: respx.get(f"{BASE}/api/v1/audit/ev-1").mock(return_value=httpx.Response(200, json=AUDIT_EVENT)) client = AsyncAuditClient(BASE, async_get_token) event = await client.get_audit_event("ev-1") assert event.event_id == "ev-1" # ─── Error propagation ──────────────────────────────────────────────────────── @resp_lib.activate def test_api_error_propagated_from_service() -> None: resp_lib.add( resp_lib.GET, f"{BASE}/api/v1/agents/bad", json={"code": "AgentNotFoundError", "message": "Not found."}, status=404, ) client = AgentRegistryClient(BASE, get_token) with pytest.raises(AgentIdPError) as exc_info: client.get_agent("bad") assert exc_info.value.http_status == 404 @pytest.mark.asyncio async def test_async_api_error_propagated() -> None: with respx.mock: respx.get(f"{BASE}/api/v1/agents/bad").mock(return_value=httpx.Response( 404, json={"code": "AgentNotFoundError", "message": "Not found."} )) client = AsyncAgentRegistryClient(BASE, async_get_token) with pytest.raises(AgentIdPError) as exc_info: await client.get_agent("bad") assert exc_info.value.http_status == 404