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:
SentryAgent.ai Developer
2026-03-28 15:11:27 +00:00
parent 90a4addb21
commit c93562e685
38 changed files with 2645 additions and 13 deletions

View File

Binary file not shown.

View 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)

View 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

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

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