feat(phase-2): workstream 2 — Python SDK (sentryagent-idp)
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>
This commit is contained in:
0
sdk-python/tests/__init__.py
Normal file
0
sdk-python/tests/__init__.py
Normal file
BIN
sdk-python/tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
sdk-python/tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
52
sdk-python/tests/test_errors.py
Normal file
52
sdk-python/tests/test_errors.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Tests for AgentIdPError."""
|
||||
|
||||
from sentryagent_idp.errors import AgentIdPError
|
||||
|
||||
|
||||
def test_basic_construction() -> None:
|
||||
err = AgentIdPError("AgentNotFoundError", "Agent not found.", 404)
|
||||
assert err.code == "AgentNotFoundError"
|
||||
assert err.http_status == 404
|
||||
assert str(err) == "Agent not found."
|
||||
assert err.details is None
|
||||
|
||||
|
||||
def test_from_api_error_valid_body() -> None:
|
||||
body = {"code": "AgentNotFoundError", "message": "Not found.", "details": {"id": "x"}}
|
||||
err = AgentIdPError.from_api_error(body, 404)
|
||||
assert err.code == "AgentNotFoundError"
|
||||
assert err.http_status == 404
|
||||
assert err.details == {"id": "x"}
|
||||
|
||||
|
||||
def test_from_api_error_unknown_body() -> None:
|
||||
err = AgentIdPError.from_api_error("plain string", 500)
|
||||
assert err.code == "UNKNOWN_ERROR"
|
||||
assert err.http_status == 500
|
||||
|
||||
|
||||
def test_from_oauth2_error() -> None:
|
||||
body = {"error": "invalid_client", "error_description": "Bad credentials."}
|
||||
err = AgentIdPError.from_oauth2_error(body, 401)
|
||||
assert err.code == "invalid_client"
|
||||
assert str(err) == "Bad credentials."
|
||||
assert err.http_status == 401
|
||||
|
||||
|
||||
def test_from_oauth2_error_unknown() -> None:
|
||||
err = AgentIdPError.from_oauth2_error("garbage", 400)
|
||||
assert err.code == "unknown_error"
|
||||
|
||||
|
||||
def test_network_error() -> None:
|
||||
cause = ConnectionError("refused")
|
||||
err = AgentIdPError.network_error(cause)
|
||||
assert err.code == "NETWORK_ERROR"
|
||||
assert err.http_status == 0
|
||||
assert "refused" in str(err)
|
||||
|
||||
|
||||
def test_repr() -> None:
|
||||
err = AgentIdPError("CODE", "msg", 400)
|
||||
assert "AgentIdPError" in repr(err)
|
||||
assert "CODE" in repr(err)
|
||||
349
sdk-python/tests/test_services.py
Normal file
349
sdk-python/tests/test_services.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
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
|
||||
112
sdk-python/tests/test_token_manager.py
Normal file
112
sdk-python/tests/test_token_manager.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Tests for TokenManager (sync) and AsyncTokenManager."""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
import responses as resp_lib
|
||||
import respx
|
||||
import httpx
|
||||
|
||||
from sentryagent_idp.token_manager import TokenManager, REFRESH_BUFFER_SECONDS
|
||||
from sentryagent_idp.async_token_manager import AsyncTokenManager
|
||||
from sentryagent_idp.errors import AgentIdPError
|
||||
|
||||
BASE_URL = "http://localhost:3000"
|
||||
TOKEN_URL = f"{BASE_URL}/api/v1/token"
|
||||
TOKEN_RESP = {
|
||||
"access_token": "eyJ.abc.def",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"scope": "agents:read",
|
||||
}
|
||||
|
||||
|
||||
# ─── Sync TokenManager ────────────────────────────────────────────────────────
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_issues_token() -> None:
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
token = tm.get_token()
|
||||
assert token == "eyJ.abc.def"
|
||||
assert len(resp_lib.calls) == 1
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_caches_token() -> None:
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
tm.get_token()
|
||||
tm.get_token()
|
||||
# Only one HTTP call because second call uses cache
|
||||
assert len(resp_lib.calls) == 1
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_refreshes_near_expiry() -> None:
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json={**TOKEN_RESP, "expires_in": 30}, status=200)
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
tm.get_token()
|
||||
# Simulate cached token being nearly expired
|
||||
assert tm._cached is not None
|
||||
tm._cached.expires_at = time.time() + (REFRESH_BUFFER_SECONDS - 1)
|
||||
tm.get_token()
|
||||
assert len(resp_lib.calls) == 2
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_raises_on_auth_failure() -> None:
|
||||
resp_lib.add(
|
||||
resp_lib.POST, TOKEN_URL,
|
||||
json={"error": "invalid_client", "error_description": "Bad creds."},
|
||||
status=401,
|
||||
)
|
||||
tm = TokenManager(BASE_URL, "client-id", "bad-secret", "agents:read")
|
||||
with pytest.raises(AgentIdPError) as exc_info:
|
||||
tm.get_token()
|
||||
assert exc_info.value.code == "invalid_client"
|
||||
assert exc_info.value.http_status == 401
|
||||
|
||||
|
||||
@resp_lib.activate
|
||||
def test_token_manager_clear_cache() -> None:
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
resp_lib.add(resp_lib.POST, TOKEN_URL, json=TOKEN_RESP, status=200)
|
||||
tm = TokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
tm.get_token()
|
||||
tm.clear_cache()
|
||||
tm.get_token()
|
||||
assert len(resp_lib.calls) == 2
|
||||
|
||||
|
||||
# ─── Async TokenManager ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_token_manager_issues_token() -> None:
|
||||
with respx.mock:
|
||||
respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
|
||||
tm = AsyncTokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
token = await tm.get_token()
|
||||
assert token == "eyJ.abc.def"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_token_manager_caches_token() -> None:
|
||||
with respx.mock:
|
||||
route = respx.post(TOKEN_URL).mock(return_value=httpx.Response(200, json=TOKEN_RESP))
|
||||
tm = AsyncTokenManager(BASE_URL, "client-id", "secret", "agents:read")
|
||||
await tm.get_token()
|
||||
await tm.get_token()
|
||||
assert route.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_token_manager_raises_on_auth_failure() -> None:
|
||||
with respx.mock:
|
||||
respx.post(TOKEN_URL).mock(return_value=httpx.Response(
|
||||
401, json={"error": "invalid_client", "error_description": "Bad creds."}
|
||||
))
|
||||
tm = AsyncTokenManager(BASE_URL, "client-id", "bad-secret", "agents:read")
|
||||
with pytest.raises(AgentIdPError) as exc_info:
|
||||
await tm.get_token()
|
||||
assert exc_info.value.code == "invalid_client"
|
||||
133
sdk-python/tests/test_types.py
Normal file
133
sdk-python/tests/test_types.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Tests for dataclass deserialisation in types.py."""
|
||||
|
||||
from sentryagent_idp.types import (
|
||||
Agent,
|
||||
Credential,
|
||||
CredentialWithSecret,
|
||||
PaginatedAgents,
|
||||
PaginatedCredentials,
|
||||
TokenResponse,
|
||||
IntrospectResponse,
|
||||
AuditEvent,
|
||||
PaginatedAuditEvents,
|
||||
RegisterAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
)
|
||||
|
||||
|
||||
AGENT_DICT = {
|
||||
"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-02T00:00:00Z",
|
||||
}
|
||||
|
||||
CREDENTIAL_DICT = {
|
||||
"credentialId": "cred-1",
|
||||
"clientId": "uuid-1",
|
||||
"status": "active",
|
||||
"createdAt": "2026-01-01T00:00:00Z",
|
||||
"expiresAt": None,
|
||||
"revokedAt": None,
|
||||
}
|
||||
|
||||
|
||||
def test_agent_from_dict() -> None:
|
||||
agent = Agent.from_dict(AGENT_DICT)
|
||||
assert agent.agent_id == "uuid-1"
|
||||
assert agent.agent_type == "screener"
|
||||
assert agent.capabilities == ["read"]
|
||||
|
||||
|
||||
def test_register_agent_request_to_dict() -> None:
|
||||
req = RegisterAgentRequest(
|
||||
email="a@b.ai",
|
||||
agent_type="classifier",
|
||||
version="1.0.0",
|
||||
capabilities=["read"],
|
||||
owner="team",
|
||||
deployment_env="production",
|
||||
)
|
||||
d = req.to_dict()
|
||||
assert d["agentType"] == "classifier"
|
||||
assert d["deploymentEnv"] == "production"
|
||||
|
||||
|
||||
def test_update_agent_request_omits_none() -> None:
|
||||
req = UpdateAgentRequest(version="2.0.0")
|
||||
d = req.to_dict()
|
||||
assert "version" in d
|
||||
assert "agentType" not in d
|
||||
|
||||
|
||||
def test_paginated_agents_from_dict() -> None:
|
||||
result = PaginatedAgents.from_dict({"data": [AGENT_DICT], "total": 1, "page": 1, "limit": 20})
|
||||
assert result.total == 1
|
||||
assert result.data[0].agent_id == "uuid-1"
|
||||
|
||||
|
||||
def test_credential_from_dict() -> None:
|
||||
cred = Credential.from_dict(CREDENTIAL_DICT)
|
||||
assert cred.credential_id == "cred-1"
|
||||
assert cred.expires_at is None
|
||||
|
||||
|
||||
def test_credential_with_secret_from_dict() -> None:
|
||||
d = {**CREDENTIAL_DICT, "clientSecret": "sk_live_abc"}
|
||||
cred = CredentialWithSecret.from_dict(d)
|
||||
assert cred.client_secret == "sk_live_abc"
|
||||
assert cred.credential_id == "cred-1"
|
||||
|
||||
|
||||
def test_paginated_credentials_from_dict() -> None:
|
||||
result = PaginatedCredentials.from_dict(
|
||||
{"data": [CREDENTIAL_DICT], "total": 1, "page": 1, "limit": 20}
|
||||
)
|
||||
assert result.total == 1
|
||||
|
||||
|
||||
def test_token_response_from_dict() -> None:
|
||||
tr = TokenResponse.from_dict(
|
||||
{"access_token": "tok", "token_type": "Bearer", "expires_in": 3600, "scope": "agents:read"}
|
||||
)
|
||||
assert tr.access_token == "tok"
|
||||
assert tr.expires_in == 3600
|
||||
|
||||
|
||||
def test_introspect_response_active() -> None:
|
||||
ir = IntrospectResponse.from_dict({"active": True, "sub": "uuid-1", "exp": 9999999999})
|
||||
assert ir.active is True
|
||||
assert ir.sub == "uuid-1"
|
||||
|
||||
|
||||
def test_introspect_response_inactive() -> None:
|
||||
ir = IntrospectResponse.from_dict({"active": False})
|
||||
assert ir.active is False
|
||||
assert ir.sub is None
|
||||
|
||||
|
||||
def test_audit_event_from_dict() -> None:
|
||||
ev = AuditEvent.from_dict({
|
||||
"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",
|
||||
})
|
||||
assert ev.event_id == "ev-1"
|
||||
assert ev.action == "token.issued"
|
||||
|
||||
|
||||
def test_paginated_audit_events_from_dict() -> None:
|
||||
ev_dict = {
|
||||
"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",
|
||||
}
|
||||
result = PaginatedAuditEvents.from_dict({"data": [ev_dict], "total": 1, "page": 1, "limit": 20})
|
||||
assert result.total == 1
|
||||
assert result.data[0].event_id == "ev-1"
|
||||
Reference in New Issue
Block a user