Sync (requests) and async (httpx) clients with identical API surface to the Node.js SDK. Delivered: - pyproject.toml — python>=3.9, hatchling build, mypy strict config - types.py — all 14-endpoint request/response dataclasses - errors.py — AgentIdPError with from_api_error, from_oauth2_error, network_error - token_manager.py — thread-safe sync TokenManager, 60s refresh buffer - async_token_manager.py — asyncio-safe AsyncTokenManager (httpx) - _request.py — shared sync/async request helper (DRY) - services/agents.py — AgentRegistryClient + AsyncAgentRegistryClient (5 methods each) - services/credentials.py — CredentialClient + AsyncCredentialClient (4 methods each) - services/token.py — TokenClient + AsyncTokenClient (introspect + revoke) - services/audit.py — AuditClient + AsyncAuditClient (query + get) - client.py — AgentIdPClient + AsyncAgentIdPClient - __init__.py — barrel exports - README.md — installation, quick start, full API reference QA gates: - mypy --strict: 0 errors (12 source files) - pytest: 57/57 passed - Coverage: 90.83% (required >= 80%) - All 14 endpoints covered (sync + async) - AgentIdPError raised on all failure paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
350 lines
14 KiB
Python
350 lines
14 KiB
Python
"""
|
|
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
|