import asyncio import queue import pytest from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription from k1link.viewer.node_media import NodeMediaPeers, admit_sdp SDP_HEADER = "v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\n" @pytest.mark.parametrize( "address,kind", [("8.8.8.8", "host"), ("192.168.1.2", "relay"), ("::1", "host")] ) def test_media_rejects_non_private_or_relay_candidates(address, kind): with pytest.raises(ValueError): admit_sdp(SDP_HEADER + f"a=candidate:1 1 udp 1 {address} 12345 typ {kind}\r\n") def test_media_accepts_paired_lan_and_tailnet_candidates(): for address in ("192.168.1.2", "100.80.6.113", "peer.local"): admit_sdp(SDP_HEADER + f"a=candidate:1 1 udp 1 {address} 12345 typ host\r\n") def test_failed_media_offer_retires_peer_without_camera_channel(monkeypatch): async def reject(*_): raise ValueError("Invalid remote description") monkeypatch.setattr(RTCPeerConnection, "setRemoteDescription", reject) async def run(): class Camera: def snapshot(self): return {} peers = NodeMediaPeers(None, Camera()) try: with pytest.raises(ValueError): await peers.offer({"sdp": SDP_HEADER}) assert peers.items == {} finally: await peers.close_all() asyncio.run(run()) def test_native_webrtc_roundtrip_and_missing_camera_preserve_rrd(monkeypatch): """One bounded loopback peer, no STUN/TURN, device or external network.""" import aioice.ice class Subscription: def __init__(self): self.output = queue.Queue() self.output.put(b"RRF2-transport-fixture") def read(self): try: return self.output.get(timeout=0.1) except queue.Empty: return b"" def close(self): pass class Hub: def subscribe(self): return Subscription() class Camera: def snapshot(self): return {"generation": None} async def run(): peers = NodeMediaPeers(Hub(), Camera()) monkeypatch.setattr(aioice.ice, "get_host_addresses", lambda **_: ["127.0.0.1"]) client = RTCPeerConnection(RTCConfiguration(iceServers=[])) channel = client.createDataChannel("rrd", ordered=True) client.createDataChannel("camera", ordered=True) received = asyncio.Event() payloads = [] @channel.on("message") def message(data): payloads.append(data) received.set() try: await client.setLocalDescription(await client.createOffer()) answer = await peers.offer({"sdp": client.localDescription.sdp}) await client.setRemoteDescription( RTCSessionDescription(sdp=answer["sdp"], type="answer") ) await asyncio.wait_for(received.wait(), timeout=8) assert payloads == [b"RRF2-transport-fixture"] assert answer["peer_id"] in peers.items assert channel.readyState == "open" finally: await client.close() await peers.close_all() assert not peers.items asyncio.run(run())