Skip to content

Commit d98c9c3

Browse files
authored
test(errors,transport): regression coverage + CVE-2026-4539 fix (#122)
## Summary Consolidates PR #111 and PR #116 into a single, clean PR with correct authorship: - **test(errors)**: Regression tests for remote JSON-RPC error mapping — metadata parsing, recoverable vs fatal classification, taxonomy fallback, and code slot normalization - **test(transport)**: ASAP stream version-negotiation coverage — comma-separated `ASAP-Version` header support and fail-closed `VERSION_INCOMPATIBLE` behavior - **build(deps)**: Override `pygments>=2.20.0` for CVE-2026-4539 and pin `pymdown-extensions>=10.21` for compatibility ## Supersedes - Closes #111 - Closes #116 ## Test plan - [x] `ruff check .` — no issues - [x] `ruff format --check .` — all formatted - [x] `mypy src/ scripts/ tests/` — no issues (342 files) - [x] Full test suite: **2906 passed**, 7 skipped, **90.61% coverage**
2 parents ef61d2a + 6230c23 commit d98c9c3

File tree

4 files changed

+234
-10
lines changed

4 files changed

+234
-10
lines changed

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ docs = [
6363
"ghp-import>=2.1.0",
6464
"mkdocs-material>=9.5",
6565
"mkdocstrings[python]>=0.27",
66+
"pymdown-extensions>=10.21",
6667
]
6768
dns-sd = [
6869
"zeroconf>=0.80",
@@ -110,6 +111,8 @@ Issues = "https://github.com/adriannoes/asap-protocol/issues"
110111
# - pillow>=12.2.0 for CVE-2026-40192 (transitive, e.g. pdf/vision stacks)
111112
# - pytest>=9.0.3 for CVE-2025-71176 (dev)
112113
# - python-multipart>=0.0.26 for CVE-2026-40347 (FastAPI stack)
114+
# - pygments>=2.20.0 for CVE-2026-4539
115+
# - langsmith>=0.7.31 for GHSA-rr7j-v2q5-chgv
113116
[tool.uv]
114117
override-dependencies = [
115118
"pyjwt>=2.12.0",
@@ -120,6 +123,8 @@ override-dependencies = [
120123
"pillow>=12.2.0",
121124
"pytest>=9.0.3",
122125
"python-multipart>=0.0.26",
126+
"pygments>=2.20.0",
127+
"langsmith>=0.7.31",
123128
]
124129

125130
[project.scripts]

tests/test_errors.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@
22

33
from asap.errors import (
44
ASAPError,
5+
RPC_CONNECTION_ERROR,
56
InvalidTransitionError,
67
MalformedEnvelopeError,
78
RPC_REMOTE_GENERIC,
89
RecoverableError,
10+
RemoteFatalRPCError,
11+
RemoteRecoverableRPCError,
912
TaskNotFoundError,
1013
TaskAlreadyCompletedError,
1114
ThreadPoolExhaustedError,
1215
ASAPConnectionError,
16+
is_asap_json_rpc_code,
17+
jsonrpc_error_data_for_asap_exception,
18+
remote_rpc_error_from_json,
1319
)
1420

1521

@@ -294,3 +300,101 @@ def test_thread_pool_exhausted_error_to_dict(self) -> None:
294300
assert result["details"]["max_threads"] == 15
295301
assert result["details"]["active_threads"] == 15
296302
assert result.get("retry_after_ms") is not None
303+
304+
305+
class TestJsonRpcErrorInterop:
306+
"""Tests for JSON-RPC interop and metadata shaping."""
307+
308+
def test_asap_json_rpc_code_boundaries(self) -> None:
309+
"""ASAP JSON-RPC helper accepts only reserved code range."""
310+
assert is_asap_json_rpc_code(-32059) is True
311+
assert is_asap_json_rpc_code(-32000) is True
312+
assert is_asap_json_rpc_code(-31999) is False
313+
assert is_asap_json_rpc_code(-32060) is False
314+
315+
def test_asap_error_rejects_out_of_range_rpc_code(self) -> None:
316+
"""Base ASAPError rejects JSON-RPC codes outside ASAP reserved slot."""
317+
import pytest
318+
319+
with pytest.raises(ValueError, match="rpc_code must be in"):
320+
ASAPError(code="asap:test/error", message="bad rpc", rpc_code=-32603)
321+
322+
def test_remote_rpc_error_from_json_returns_recoverable_and_strips_meta(self) -> None:
323+
"""Recoverable remote payload is typed and metadata fields are normalized."""
324+
err = remote_rpc_error_from_json(
325+
rpc_code=-32033,
326+
message="remote timeout",
327+
data={
328+
"recoverable": True,
329+
"retry_after_ms": 250,
330+
"alternative_agents": ["urn:asap:agent:secondary"],
331+
"fallback_action": "retry_with_backoff",
332+
"asap_taxonomy_code": "asap:transport/timeout",
333+
"rpc_code": -32033,
334+
"upstream": "worker-1",
335+
},
336+
)
337+
338+
assert isinstance(err, RemoteRecoverableRPCError)
339+
assert err.rpc_code == -32033
340+
assert err.json_rpc_code == -32033
341+
assert err.code == "asap:transport/timeout"
342+
assert err.retry_after_ms == 250
343+
assert err.alternative_agents == ["urn:asap:agent:secondary"]
344+
assert err.fallback_action == "retry_with_backoff"
345+
assert err.details == {"upstream": "worker-1"}
346+
347+
def test_remote_rpc_error_from_json_uses_code_field_fallback(self) -> None:
348+
"""A taxonomy-like `code` field in data overrides default taxonomy mapping."""
349+
err = remote_rpc_error_from_json(
350+
rpc_code=-32040,
351+
message="resource exhausted",
352+
data={
353+
"recoverable": False,
354+
"code": "asap:resource/exhausted",
355+
"queue_depth": 11,
356+
},
357+
)
358+
359+
assert isinstance(err, RemoteFatalRPCError)
360+
assert err.code == "asap:resource/exhausted"
361+
assert err.details == {"queue_depth": 11}
362+
363+
def test_remote_rpc_error_preserves_wire_code_but_normalizes_internal_slot(self) -> None:
364+
"""Non-ASAP wire code is preserved while internal rpc_code uses remote-generic slot."""
365+
err = remote_rpc_error_from_json(
366+
rpc_code=-32603,
367+
message="internal error",
368+
data={"recoverable": False, "foo": "bar"},
369+
)
370+
371+
assert isinstance(err, RemoteFatalRPCError)
372+
assert err.json_rpc_code == -32603
373+
assert err.rpc_code == RPC_REMOTE_GENERIC
374+
assert err.details == {"foo": "bar"}
375+
376+
def test_jsonrpc_error_data_for_recoverable_exception(self) -> None:
377+
"""Server-side JSON-RPC data payload includes taxonomy and recoverability hints."""
378+
err = ASAPConnectionError(
379+
"connection refused",
380+
url="https://agent.example.test/asap",
381+
rpc_code=RPC_CONNECTION_ERROR,
382+
retry_after_ms=500,
383+
alternative_agents=["urn:asap:agent:fallback"],
384+
)
385+
386+
payload = jsonrpc_error_data_for_asap_exception(err)
387+
assert payload["code"] == "asap:transport/connection_error"
388+
assert payload["rpc_code"] == RPC_CONNECTION_ERROR
389+
assert payload["recoverable"] is True
390+
assert payload["asap_taxonomy_code"] == "asap:transport/connection_error"
391+
assert payload["retry_after_ms"] == 500
392+
assert payload["alternative_agents"] == ["urn:asap:agent:fallback"]
393+
394+
def test_connection_error_message_not_duplicated_when_verify_text_present(self) -> None:
395+
"""Connection errors with existing troubleshooting guidance are not duplicated."""
396+
err = ASAPConnectionError(
397+
"Verify service availability before retrying.",
398+
url="https://agent.example.test/asap",
399+
)
400+
assert str(err) == "Verify service availability before retrying."

tests/transport/test_streaming.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
import pytest
1010
from httpx import ASGITransport
1111

12+
from asap.models.constants import ASAP_DEFAULT_TRANSPORT_VERSION, ASAP_VERSION_HEADER
1213
from asap.models.entities import Manifest
1314
from asap.models.envelope import Envelope
1415
from asap.models.enums import TaskStatus
1516
from asap.models.payloads import TaskRequest, TaskStream
1617
from asap.transport.handlers import HandlerRegistry, create_echo_handler
17-
from asap.transport.jsonrpc import ASAP_METHOD
18+
from asap.transport.jsonrpc import ASAP_METHOD, VERSION_INCOMPATIBLE
1819
from asap.transport.server import create_app
1920

2021
if TYPE_CHECKING:
@@ -119,3 +120,113 @@ async def test_asap_stream_sse_endpoint(
119120
assert events[0].payload_dict.get("final") is False
120121
assert events[-1].payload_dict.get("final") is True
121122
assert events[-1].payload_dict.get("status") == "completed"
123+
124+
125+
@pytest.mark.anyio
126+
async def test_asap_stream_negotiates_first_supported_wire_version(
127+
sample_manifest: Manifest,
128+
isolated_rate_limiter: ASAPRateLimiter | None,
129+
) -> None:
130+
"""`/asap/stream` uses the first supported ASAP-Version token and echoes it."""
131+
registry = HandlerRegistry()
132+
registry.register("task.request", create_echo_handler())
133+
registry.register_streaming_handler("task.request", _word_stream_handler)
134+
app = create_app(sample_manifest, registry, rate_limit="999999/minute")
135+
if isolated_rate_limiter is not None:
136+
app.state.limiter = isolated_rate_limiter
137+
138+
env = Envelope(
139+
asap_version="0.1",
140+
sender="urn:asap:agent:client",
141+
recipient=sample_manifest.id,
142+
payload_type="task.request",
143+
payload=TaskRequest(
144+
conversation_id="conv-version-stream-ok",
145+
skill_id="echo",
146+
input={"text": "one two"},
147+
).model_dump(),
148+
correlation_id="corr-version-stream-ok",
149+
)
150+
rpc = {
151+
"jsonrpc": "2.0",
152+
"method": ASAP_METHOD,
153+
"params": {"envelope": env.model_dump(mode="json")},
154+
"id": 2,
155+
}
156+
157+
transport = ASGITransport(app=app)
158+
async with (
159+
httpx.AsyncClient(transport=transport, base_url="http://test") as client,
160+
client.stream(
161+
"POST",
162+
"/asap/stream",
163+
json=rpc,
164+
headers={ASAP_VERSION_HEADER: "2.2, 2.1"},
165+
) as response,
166+
):
167+
assert response.status_code == 200
168+
assert response.headers.get(ASAP_VERSION_HEADER) == "2.2"
169+
assert response.headers["content-type"].startswith("text/event-stream")
170+
171+
buf = ""
172+
events: list[Envelope] = []
173+
async for chunk in response.aiter_text():
174+
buf += chunk
175+
while "\n\n" in buf:
176+
raw_event, buf = buf.split("\n\n", 1)
177+
for line in raw_event.split("\n"):
178+
stripped = line.strip()
179+
if stripped.startswith("data:"):
180+
payload_json = stripped[5:].strip()
181+
events.append(Envelope.model_validate(json.loads(payload_json)))
182+
183+
assert len(events) == 2
184+
assert events[-1].payload_dict.get("final") is True
185+
186+
187+
@pytest.mark.anyio
188+
async def test_asap_stream_rejects_incompatible_wire_version(
189+
sample_manifest: Manifest,
190+
isolated_rate_limiter: ASAPRateLimiter | None,
191+
) -> None:
192+
"""`/asap/stream` returns JSON-RPC VERSION_INCOMPATIBLE when header has no match."""
193+
registry = HandlerRegistry()
194+
registry.register("task.request", create_echo_handler())
195+
registry.register_streaming_handler("task.request", _word_stream_handler)
196+
app = create_app(sample_manifest, registry, rate_limit="999999/minute")
197+
if isolated_rate_limiter is not None:
198+
app.state.limiter = isolated_rate_limiter
199+
200+
env = Envelope(
201+
asap_version="0.1",
202+
sender="urn:asap:agent:client",
203+
recipient=sample_manifest.id,
204+
payload_type="task.request",
205+
payload=TaskRequest(
206+
conversation_id="conv-version-stream-bad",
207+
skill_id="echo",
208+
input={"text": "one two"},
209+
).model_dump(),
210+
correlation_id="corr-version-stream-bad",
211+
)
212+
rpc = {
213+
"jsonrpc": "2.0",
214+
"method": ASAP_METHOD,
215+
"params": {"envelope": env.model_dump(mode="json")},
216+
"id": 3,
217+
}
218+
219+
transport = ASGITransport(app=app)
220+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
221+
response = await client.post(
222+
"/asap/stream",
223+
json=rpc,
224+
headers={ASAP_VERSION_HEADER: "9.9"},
225+
)
226+
227+
assert response.status_code == 200
228+
assert response.headers.get(ASAP_VERSION_HEADER) == ASAP_DEFAULT_TRANSPORT_VERSION
229+
assert response.headers["content-type"].startswith("application/json")
230+
payload = response.json()
231+
assert payload.get("error", {}).get("code") == VERSION_INCOMPATIBLE
232+
assert payload.get("error", {}).get("data", {}).get("requested") == "9.9"

uv.lock

Lines changed: 13 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)