Skip to content

Commit f74602b

Browse files
bdracoCopilot
andauthored
tests: cover error paths in peer-link client (84% → 100%) (#506)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent fd50659 commit f74602b

1 file changed

Lines changed: 232 additions & 0 deletions

File tree

tests/test_remote_build_peer_link_client.py

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from aiohttp import web
2828
from aiohttp.test_utils import TestServer
2929

30+
from esphome_device_builder.controllers import remote_build_peer_link_client
3031
from esphome_device_builder.controllers.config import (
3132
load_offloader_remote_build_settings,
3233
)
@@ -48,6 +49,7 @@
4849
get_or_create_peer_link_identity,
4950
)
5051
from esphome_device_builder.helpers.peer_link_noise import (
52+
HandshakeNotCompleteError,
5153
PeerLinkNoiseSession,
5254
pin_sha256_for_pubkey,
5355
)
@@ -235,6 +237,236 @@ async def _faulty_handler(request: web.Request) -> web.WebSocketResponse:
235237
await server.close()
236238

237239

240+
@pytest.mark.asyncio
241+
async def test_preview_pair_non_ok_intent_response_raises_client_error() -> None:
242+
"""Receiver's preview returns a non-OK intent_response → PeerLinkClientError.
243+
244+
Preview's accept-set is just ``IntentResponse.OK``. Anything
245+
else (a future code we don't know, a deployment bug, a
246+
receiver that's mid-rotation) has to surface as a client
247+
error so the WS-command layer can map it to ``UNAVAILABLE``
248+
without leaking the raw response.
249+
"""
250+
receiver_priv = secrets.token_bytes(32)
251+
252+
async def _handler(request: web.Request) -> web.WebSocketResponse:
253+
ws = web.WebSocketResponse()
254+
await ws.prepare(request)
255+
sess = PeerLinkNoiseSession.responder(receiver_priv)
256+
sess.read_handshake_message(await ws.receive_bytes())
257+
await ws.send_bytes(sess.write_handshake_message(b""))
258+
sess.read_handshake_message(await ws.receive_bytes())
259+
# Preview never gets ``rejected`` from a real receiver
260+
# (preview's responder branch unconditionally answers
261+
# OK), but a misbehaving deployment could; pin the
262+
# offloader-side rejection regardless.
263+
await ws.send_bytes(sess.encrypt(b'{"intent_response": "rejected"}'))
264+
await ws.close()
265+
return ws
266+
267+
app = web.Application()
268+
app.router.add_get(PEER_LINK_PATH, _handler)
269+
server = TestServer(app)
270+
await server.start_server()
271+
initiator_priv = secrets.token_bytes(32)
272+
try:
273+
with pytest.raises(PeerLinkClientError, match="preview rejected"):
274+
await preview_pair(
275+
hostname="127.0.0.1",
276+
port=server.port,
277+
identity_priv=initiator_priv,
278+
)
279+
finally:
280+
await server.close()
281+
282+
283+
@pytest.mark.asyncio
284+
async def test_drive_initiator_round_trip_non_object_response_raises_client_error() -> None:
285+
"""Receiver's response decrypts to a JSON value that isn't a dict → PeerLinkClientError.
286+
287+
Defends against a wire-format slip where the receiver
288+
encrypts a list / scalar / null instead of the agreed
289+
``{intent_response: ...}`` object. The driver has to refuse
290+
cleanly so the caller doesn't pass a malformed value to
291+
``decoded.get(...)``.
292+
"""
293+
receiver_priv = secrets.token_bytes(32)
294+
295+
async def _handler(request: web.Request) -> web.WebSocketResponse:
296+
ws = web.WebSocketResponse()
297+
await ws.prepare(request)
298+
sess = PeerLinkNoiseSession.responder(receiver_priv)
299+
sess.read_handshake_message(await ws.receive_bytes())
300+
await ws.send_bytes(sess.write_handshake_message(b""))
301+
sess.read_handshake_message(await ws.receive_bytes())
302+
# Valid Noise + valid JSON, but not the object shape.
303+
await ws.send_bytes(sess.encrypt(b'["surprise", 1, 2]'))
304+
await ws.close()
305+
return ws
306+
307+
app = web.Application()
308+
app.router.add_get(PEER_LINK_PATH, _handler)
309+
server = TestServer(app)
310+
await server.start_server()
311+
initiator_priv = secrets.token_bytes(32)
312+
try:
313+
with pytest.raises(PeerLinkClientError, match="not a JSON object"):
314+
await drive_initiator_round_trip(
315+
hostname="127.0.0.1",
316+
port=server.port,
317+
identity_priv=initiator_priv,
318+
intent=PeerLinkIntent.PREVIEW,
319+
)
320+
finally:
321+
await server.close()
322+
323+
324+
@pytest.mark.asyncio
325+
async def test_drive_initiator_round_trip_missing_intent_response_raises_client_error() -> None:
326+
"""Response object without an ``intent_response`` string field → PeerLinkClientError.
327+
328+
Pin the contract: the driver has to refuse rather than pass
329+
a missing-key dict to the caller's accept-set check (which
330+
would silently mismatch every known response code).
331+
"""
332+
receiver_priv = secrets.token_bytes(32)
333+
334+
async def _handler(request: web.Request) -> web.WebSocketResponse:
335+
ws = web.WebSocketResponse()
336+
await ws.prepare(request)
337+
sess = PeerLinkNoiseSession.responder(receiver_priv)
338+
sess.read_handshake_message(await ws.receive_bytes())
339+
await ws.send_bytes(sess.write_handshake_message(b""))
340+
sess.read_handshake_message(await ws.receive_bytes())
341+
# Object shape but the wrong keys.
342+
await ws.send_bytes(sess.encrypt(b'{"unrelated": "payload"}'))
343+
await ws.close()
344+
return ws
345+
346+
app = web.Application()
347+
app.router.add_get(PEER_LINK_PATH, _handler)
348+
server = TestServer(app)
349+
await server.start_server()
350+
initiator_priv = secrets.token_bytes(32)
351+
try:
352+
with pytest.raises(PeerLinkClientError, match="missing 'intent_response'"):
353+
await drive_initiator_round_trip(
354+
hostname="127.0.0.1",
355+
port=server.port,
356+
identity_priv=initiator_priv,
357+
intent=PeerLinkIntent.PREVIEW,
358+
)
359+
finally:
360+
await server.close()
361+
362+
363+
@pytest.mark.asyncio
364+
async def test_drive_initiator_round_trip_handshake_not_complete_raises_client_error(
365+
receiver_server: tuple[TestServer, RemoteBuildController, str],
366+
monkeypatch: pytest.MonkeyPatch,
367+
) -> None:
368+
"""Defensive: ``remote_static_pub`` raising ``HandshakeNotCompleteError`` is mapped.
369+
370+
Structurally unreachable from a black-box receiver — the
371+
property only raises when the handshake hasn't completed,
372+
and the driver only reaches that access after a successful
373+
decrypt + JSON-parse + intent_response check (all of which
374+
require a completed handshake). Replace the *initiator*
375+
factory with a subclass whose ``remote_static_pub`` always
376+
raises so the guard's mapping is exercised; the receiver's
377+
factory is untouched so the in-process handshake still
378+
completes normally. Without this guard, a future refactor
379+
that broke the post-handshake state (an upstream API change
380+
in noiseprotocol, a session that swallowed the captured
381+
pubkey) would surface as an uncaught exception instead of
382+
the documented ``PeerLinkClientError`` → ``UNAVAILABLE``
383+
shape.
384+
"""
385+
server, _, _ = receiver_server
386+
initiator_priv = secrets.token_bytes(32)
387+
388+
class _BrokenInitiator(PeerLinkNoiseSession):
389+
@property
390+
def remote_static_pub(self) -> bytes:
391+
raise HandshakeNotCompleteError("forced for test")
392+
393+
real_initiator = PeerLinkNoiseSession.initiator
394+
395+
def _broken_factory(identity_priv: bytes) -> PeerLinkNoiseSession:
396+
sess = real_initiator(identity_priv)
397+
sess.__class__ = _BrokenInitiator
398+
return sess
399+
400+
# Patch the symbol the driver actually imports. Overriding only
401+
# ``initiator`` instances keeps the receiver-side ``responder``
402+
# handshake intact, while changing shared base-class behavior
403+
# such as ``remote_static_pub`` would affect both sides and fail
404+
# the test before the initiator's post-handshake access.
405+
monkeypatch.setattr(
406+
remote_build_peer_link_client.PeerLinkNoiseSession,
407+
"initiator",
408+
staticmethod(_broken_factory),
409+
)
410+
411+
with pytest.raises(PeerLinkClientError, match="without capturing remote static pubkey"):
412+
await drive_initiator_round_trip(
413+
hostname="127.0.0.1",
414+
port=server.port,
415+
identity_priv=initiator_priv,
416+
intent=PeerLinkIntent.PREVIEW,
417+
)
418+
419+
420+
@pytest.mark.asyncio
421+
async def test_drive_initiator_round_trip_short_msg2_raises_noise_handshake_failed() -> None:
422+
"""A msg2 too short to parse → ``NoiseValueError`` → PeerLinkClientError.
423+
424+
The Noise read on msg2 raises out of :data:`NOISE_ERRORS`
425+
rather than the connect-time / decode-time tuple. Pin that
426+
the driver's separate ``except NOISE_ERRORS`` branch maps it
427+
to the same :class:`PeerLinkClientError` surface (with the
428+
distinguishing ``"Noise handshake failed"`` text in the
429+
message) so the WS-command layer keeps a single
430+
``UNAVAILABLE`` mapping while logs preserve the underlying
431+
cause.
432+
433+
A *too-short* msg2 lands as ``NoiseValueError("Invalid
434+
length of public_bytes")``; a *length-correct but
435+
cryptographically wrong* msg2 lands as a bare ``ValueError``
436+
from the X25519 shared-key step (caught by the broader
437+
transport-failure branch). Both surface as
438+
``PeerLinkClientError``, but only the short-msg2 path
439+
exercises the dedicated ``NOISE_ERRORS`` clause.
440+
"""
441+
442+
async def _handler(request: web.Request) -> web.WebSocketResponse:
443+
ws = web.WebSocketResponse()
444+
await ws.prepare(request)
445+
# Read msg1 to keep the WS upgrade clean, then write a
446+
# too-short payload that trips ``NoiseValueError`` on
447+
# the public-key parse before any crypto runs.
448+
await ws.receive_bytes()
449+
await ws.send_bytes(b"")
450+
await ws.close()
451+
return ws
452+
453+
app = web.Application()
454+
app.router.add_get(PEER_LINK_PATH, _handler)
455+
server = TestServer(app)
456+
await server.start_server()
457+
initiator_priv = secrets.token_bytes(32)
458+
try:
459+
with pytest.raises(PeerLinkClientError, match="Noise handshake failed"):
460+
await drive_initiator_round_trip(
461+
hostname="127.0.0.1",
462+
port=server.port,
463+
identity_priv=initiator_priv,
464+
intent=PeerLinkIntent.PREVIEW,
465+
)
466+
finally:
467+
await server.close()
468+
469+
238470
def test_build_ws_url_uses_plain_ws_scheme() -> None:
239471
"""Peer-link runs over plain TCP; Noise XX provides transport security."""
240472
assert str(_build_ws_url("desk.local", 6055)) == "ws://desk.local:6055/remote-build/peer-link"

0 commit comments

Comments
 (0)