diff --git a/src/ca2a_runtime/response.py b/src/ca2a_runtime/response.py index 0d36ee8d..660e916e 100644 --- a/src/ca2a_runtime/response.py +++ b/src/ca2a_runtime/response.py @@ -32,8 +32,14 @@ class AuthenticatedPeerError(CA2AError): """An authenticated peer denial, not proof that no side effect occurred.""" def __init__(self, response: VerifiedResponse) -> None: - error = response.body["error"] - super().__init__(str(error.get("message", "peer denial"))) + error = response.body.get("error") + if not isinstance(error, dict): + raise _fail() + detail = error.get("detail") + super().__init__( + str(error.get("message", "peer denial")), + detail=detail if isinstance(detail, str) else None, + ) self.code = str(error.get("code", "PEER_DENIAL")) self.http_status = response.status self.response_authentication = response.authentication() @@ -81,10 +87,14 @@ def check(item: Any, depth: int = 0) -> None: def decode(raw: bytes) -> dict[str, Any]: """Reject ambiguous/oversized JSON before it can be authenticated.""" - if len(raw) > MAX_BYTES: + if type(raw) is not bytes or len(raw) > MAX_BYTES: raise _fail() try: - value = json.loads(raw.decode("utf-8"), object_pairs_hook=_object) + value = json.loads( + raw.decode("utf-8"), + object_pairs_hook=_object, + parse_constant=lambda _token: (_ for _ in ()).throw(_fail()), + ) if not isinstance(value, dict): raise _fail() encode(value) @@ -158,6 +168,14 @@ def __init__(self, private: X25519PrivateKey, envelope: dict[str, Any]) -> None: self._key = _key(private, self.envelope["recipient"], self.request_sha256) def finish(self, status: int, body: dict[str, Any]) -> dict[str, Any]: + if ( + type(status) is not int + or not (status == 200 or 400 <= status <= 599) + or not isinstance(body, dict) + or (status == 200 and body.get("accepted") is not True) + or (status != 200 and not isinstance(body.get("error"), dict)) + ): + raise _fail() statement = { "profile": PROFILE, "request_sha256": self.request_sha256, @@ -186,7 +204,12 @@ def __init__( timeout: float = 30.0, clock: Callable[[], float] = time.monotonic, ) -> None: - if not math.isfinite(timeout) or not 0 < timeout <= 300: + if ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or not math.isfinite(timeout) + or not 0 < timeout <= 300 + ): raise ValueError("response timeout must be finite and in (0, 300]") private = X25519PrivateKey.generate() self._request = encode( diff --git a/tests/unit/test_response_authentication.py b/tests/unit/test_response_authentication.py index 5170a827..91685c9e 100644 --- a/tests/unit/test_response_authentication.py +++ b/tests/unit/test_response_authentication.py @@ -121,7 +121,7 @@ def test_deadline_clock_rollback_and_invalid_clock_fail_closed(now): pending.verify(200, encode(success(producer))) -@pytest.mark.parametrize("timeout", [0, -1, 301, float("nan"), float("inf")]) +@pytest.mark.parametrize("timeout", [0, -1, 301, float("nan"), float("inf"), True, False, "30"]) def test_invalid_timeout(timeout): private = X25519PrivateKey.generate() with pytest.raises(ValueError): @@ -132,6 +132,34 @@ def test_invalid_timeout(timeout): ) +@pytest.mark.parametrize( + ("status", "body"), + [ + (True, {"accepted": True}), + (201, {"accepted": True}), + (302, {"error": {"code": "X"}}), + (200, {"accepted": False}), + (200, {}), + (403, {"error": "not-a-dict"}), + (403, {}), + ], +) +def test_response_producer_finish_rejects_invalid_status_or_body(status, body): + _, _, producer = exchange() + with pytest.raises(ResponseAuthenticationFailed): + producer.finish(status, body) + + +def test_decode_rejects_non_bytes_and_authenticated_peer_error_preserves_detail(): + with pytest.raises(ResponseAuthenticationFailed): + decode("{}") # type: ignore[arg-type] + _, pending, producer = exchange() + body = {"error": {"code": "DENIED", "message": "denied", "detail": "missing cap:write"}} + response = pending.verify(403, encode(producer.finish(403, body))) + err = AuthenticatedPeerError(response) + assert err.detail == "missing cap:write" + + def test_concurrent_consumption_has_exactly_one_success(): _, pending, producer = exchange() raw = encode(success(producer))