Skip to content

Commit cb18202

Browse files
nficanoclaude
andcommitted
refactor phase 8: cov to 90%, PT006 fix, --cov-fail-under=90 gate
Add 35 new unit tests across four previously thin modules: - tests/unit/test_jwt.py (7 tests): JWTValidator happy path, sub fallback to principal, audience mismatch, bad signature, missing sub, empty sub, non-string sub. arcp.auth.jwt 55% -> 100%. - tests/unit/test_pending.py (9 tests): PendingRequestRegistry register/resolve/reject/cancel/cancel_all and their no-op return paths. arcp.runtime.pending 60% -> 100%. - tests/unit/test_session_handshake.py (14 tests): negotiate_capabilities, HandshakeDriver.handle_open/issue_challenge, consume_authenticate, including all the rejection paths (UNIMPLEMENTED scheme, missing token, anonymous-not-negotiated, malformed payloads, invalid extension namespace). arcp.runtime.session 72% -> 95%. - tests/unit/test_stdio_transport_unit.py (5 tests): in-process pipe pair exercising send/recv/close, including EOF-on-recv and writer failures. arcp.transport.stdio 72% -> 100%. The OS-level connect_stdio_pipe helper is marked pragma: no cover with the rationale (it needs a subprocess fixture; transport behavior itself is now unit-tested). Fix the lone PT006 (parametrize names should be a tuple). Wire --cov=arcp --cov-fail-under=90 into pytest addopts so the coverage floor is enforced. Coverage 86% -> 90.23%; 159 tests -> 194 tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9f2433c commit cb18202

7 files changed

Lines changed: 442 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ strict = ["src/arcp"]
4242
pythonVersion = "3.13"
4343
typeCheckingMode = "strict"
4444
reportMissingTypeStubs = false
45+
# Use the local uv-managed venv so third-party imports resolve when analyzing from this directory.
46+
venvPath = "."
47+
venv = ".venv"
4548

4649
[tool.ruff]
4750
line-length = 100
@@ -99,7 +102,7 @@ indent-style = "space"
99102
[tool.pytest.ini_options]
100103
testpaths = ["tests"]
101104
asyncio_mode = "auto"
102-
addopts = "-ra --strict-markers --strict-config"
105+
addopts = "-ra --strict-markers --strict-config --cov=arcp --cov-fail-under=90"
103106
filterwarnings = [
104107
"error",
105108
"ignore::DeprecationWarning",

src/arcp/transport/stdio.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,16 @@ def is_closed(self) -> bool:
5454
return self._closed
5555

5656

57-
async def connect_stdio_pipe() -> StdioTransport:
57+
async def connect_stdio_pipe() -> StdioTransport: # pragma: no cover - sys.stdin/stdout helper
5858
"""Wrap ``sys.stdin``/``sys.stdout`` as a transport.
5959
6060
Reads lines from stdin and writes lines to stdout. Useful for subprocess
6161
integration where the parent runtime spawns a child agent.
62-
"""
6362
63+
Excluded from coverage because it touches process-wide sys.stdin/stdout and
64+
needs a subprocess fixture; the transport's I/O behavior is unit-tested via
65+
``tests/unit/test_stdio_transport_unit.py`` against an in-process pipe pair.
66+
"""
6467
loop = asyncio.get_running_loop()
6568
reader = asyncio.StreamReader()
6669
protocol = asyncio.StreamReaderProtocol(reader)

tests/unit/test_errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def test_every_error_code_is_string(code: ErrorCode) -> None:
1717

1818

1919
@pytest.mark.parametrize(
20-
"code,expected",
20+
("code", "expected"),
2121
[
2222
(ErrorCode.RESOURCE_EXHAUSTED, True),
2323
(ErrorCode.UNAVAILABLE, True),

tests/unit/test_jwt.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Unit tests for arcp.auth.jwt.JWTValidator."""
2+
3+
from __future__ import annotations
4+
5+
import jwt
6+
import pytest
7+
8+
from arcp.auth.jwt import JWTValidator
9+
from arcp.errors import ARCPError, ErrorCode
10+
11+
# PyJWT 2.10+ warns on HS256 secrets shorter than the algorithm's key size; pad to 32 bytes.
12+
_SECRET = "supers3cret-padding-to-32-bytes!"
13+
_AUDIENCE = "arcp-test"
14+
15+
16+
def _token(claims: dict[str, object], *, secret: str = _SECRET, alg: str = "HS256") -> str:
17+
return jwt.encode(claims, secret, algorithm=alg)
18+
19+
20+
def test_validates_and_returns_sub() -> None:
21+
v = JWTValidator(secret=_SECRET, audience=_AUDIENCE)
22+
token = _token({"sub": "alice", "aud": _AUDIENCE})
23+
assert v.validate(token) == "alice"
24+
25+
26+
def test_falls_back_to_principal_claim() -> None:
27+
v = JWTValidator(secret=_SECRET, audience=_AUDIENCE)
28+
token = _token({"principal": "bob", "aud": _AUDIENCE})
29+
assert v.validate(token) == "bob"
30+
31+
32+
def test_rejects_wrong_audience() -> None:
33+
v = JWTValidator(secret=_SECRET, audience=_AUDIENCE)
34+
token = _token({"sub": "alice", "aud": "different"})
35+
with pytest.raises(ARCPError) as excinfo:
36+
v.validate(token)
37+
assert excinfo.value.code == ErrorCode.UNAUTHENTICATED
38+
39+
40+
def test_rejects_bad_signature() -> None:
41+
v = JWTValidator(secret=_SECRET, audience=_AUDIENCE)
42+
token = _token(
43+
{"sub": "alice", "aud": _AUDIENCE},
44+
secret="wrong-secret-also-32-bytes-pad!!",
45+
)
46+
with pytest.raises(ARCPError) as excinfo:
47+
v.validate(token)
48+
assert excinfo.value.code == ErrorCode.UNAUTHENTICATED
49+
50+
51+
def test_rejects_missing_sub_and_principal() -> None:
52+
v = JWTValidator(secret=_SECRET, audience=_AUDIENCE)
53+
token = _token({"aud": _AUDIENCE})
54+
with pytest.raises(ARCPError, match="missing 'sub' claim") as excinfo:
55+
v.validate(token)
56+
assert excinfo.value.code == ErrorCode.UNAUTHENTICATED
57+
58+
59+
def test_rejects_non_string_sub() -> None:
60+
v = JWTValidator(secret=_SECRET, audience=_AUDIENCE)
61+
token = _token({"sub": 12345, "aud": _AUDIENCE})
62+
with pytest.raises(ARCPError) as excinfo:
63+
v.validate(token)
64+
# PyJWT itself rejects non-string sub before our own check runs.
65+
assert excinfo.value.code == ErrorCode.UNAUTHENTICATED
66+
67+
68+
def test_rejects_empty_string_sub() -> None:
69+
v = JWTValidator(secret=_SECRET, audience=_AUDIENCE)
70+
token = _token({"sub": "", "aud": _AUDIENCE})
71+
with pytest.raises(ARCPError, match="missing 'sub' claim"):
72+
v.validate(token)

tests/unit/test_pending.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Unit tests for arcp.runtime.pending.PendingRequestRegistry."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
7+
import pytest
8+
9+
from arcp.runtime.pending import PendingRequestRegistry
10+
11+
12+
async def test_register_resolve_round_trip() -> None:
13+
reg = PendingRequestRegistry()
14+
fut = reg.register("c1")
15+
assert reg.resolve("c1", {"v": 1}) is True
16+
assert await fut == {"v": 1}
17+
18+
19+
async def test_register_duplicate_raises() -> None:
20+
reg = PendingRequestRegistry()
21+
reg.register("c1")
22+
with pytest.raises(ValueError, match="duplicate pending correlation_id"):
23+
reg.register("c1")
24+
25+
26+
async def test_resolve_unknown_returns_false() -> None:
27+
reg = PendingRequestRegistry()
28+
assert reg.resolve("nope", {}) is False
29+
30+
31+
async def test_resolve_done_future_returns_false() -> None:
32+
reg = PendingRequestRegistry()
33+
reg.register("c1")
34+
assert reg.resolve("c1", {}) is True
35+
# second resolve finds nothing pending
36+
assert reg.resolve("c1", {}) is False
37+
38+
39+
async def test_reject_propagates_exception() -> None:
40+
reg = PendingRequestRegistry()
41+
fut = reg.register("c1")
42+
err = RuntimeError("boom")
43+
assert reg.reject("c1", err) is True
44+
with pytest.raises(RuntimeError, match="boom"):
45+
await fut
46+
47+
48+
async def test_reject_unknown_returns_false() -> None:
49+
reg = PendingRequestRegistry()
50+
assert reg.reject("missing", RuntimeError()) is False
51+
52+
53+
async def test_cancel_marks_future_cancelled() -> None:
54+
reg = PendingRequestRegistry()
55+
fut = reg.register("c1")
56+
assert reg.cancel("c1") is True
57+
with pytest.raises(asyncio.CancelledError):
58+
await fut
59+
60+
61+
async def test_cancel_unknown_returns_false() -> None:
62+
reg = PendingRequestRegistry()
63+
assert reg.cancel("missing") is False
64+
65+
66+
async def test_cancel_all_clears_all_pending() -> None:
67+
reg = PendingRequestRegistry()
68+
f1 = reg.register("c1")
69+
f2 = reg.register("c2")
70+
reg.cancel_all()
71+
assert f1.cancelled()
72+
assert f2.cancelled()
73+
# registry is empty after cancel_all
74+
assert reg.resolve("c1", {}) is False
75+
assert reg.resolve("c2", {}) is False

0 commit comments

Comments
 (0)