velxio/test/backend/unit/test_picow_inbound_gateway.py

190 lines
6.9 KiB
Python

"""
Unit tests for the Pico W inbound IoT-gateway path.
The gateway lets a browser reach an HTTP server running inside the
browser-side lwIP of a simulated Pico W. The backend opens a TCP
connection INTO the chip (``tcp_inbound.TcpInbound``) over the WebSocket
bridge, sends a raw HTTP request, and parses the response back out.
These tests import the real modules (no re-implemented framing) so a
refactor of the stack surfaces here immediately:
- the inbound TCP client drives a correct SYN → SYN+ACK → ACK →
request → response → FIN exchange and returns the response bytes;
- the gateway response parser splits status/headers/body;
- the bridge learns the chip's real MAC via ARP before originating.
"""
from __future__ import annotations
import asyncio
import pytest
from app.services.picow_net.bridge import PicowNetBridge
from app.services.picow_net.consts import (
GATEWAY_IP,
STA_IP,
STA_MAC,
TCP_ACK,
TCP_FIN,
TCP_PSH,
TCP_SYN,
ip_to_bytes,
)
from app.services.picow_net.protocols import Ethernet, IPv4, TCP, Arp, make_frame_arp
from app.services.picow_net.tcp_inbound import (
TcpInbound,
_http_response_complete,
_seq_add,
)
from app.api.routes.iot_gateway import _parse_http_response
_STA = ip_to_bytes(STA_IP)
_GW = ip_to_bytes(GATEWAY_IP)
CHIP_MAC = bytes.fromhex('0242da0000aa')
def _parse_injected_tcp(frame: bytes) -> TCP:
eth = Ethernet.parse(frame)
ip = IPv4.parse(eth.payload)
return TCP.parse(ip.payload)
def _chip_seg(src_port: int, dst_port: int, seq: int, ack: int,
flags: int, payload: bytes = b'') -> tuple[IPv4, TCP]:
"""A segment as if sent by the chip's server (STA:80 → gateway)."""
ip = IPv4(protocol=6, src=_STA, dst=_GW)
tcp = TCP(src_port=src_port, dst_port=dst_port, seq=seq, ack=ack,
flags=flags, window=64240, payload=payload)
return ip, tcp
async def _wait_until(pred, timeout=1.0):
loop = asyncio.get_event_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
if pred():
return True
await asyncio.sleep(0.005)
return False
@pytest.mark.asyncio
async def test_inbound_tcp_http_roundtrip():
injected: list[bytes] = []
async def inject(frame: bytes) -> None:
injected.append(frame)
tin = TcpInbound(inject, lambda: CHIP_MAC)
request_bytes = b'GET /on HTTP/1.1\r\nHost: 10.13.37.42\r\nConnection: close\r\n\r\n'
task = asyncio.create_task(tin.request(request_bytes, timeout=2.0))
# 1. The client should inject a SYN to the chip's :80.
assert await _wait_until(lambda: len(injected) >= 1)
syn = _parse_injected_tcp(injected[0])
assert syn.flags & TCP_SYN and not (syn.flags & TCP_ACK)
assert syn.dst_port == 80
ephport = syn.src_port
our_isn = syn.seq
# 2. Reply with SYN+ACK; expect the client to ACK and then send the request.
chip_isn = 7000
ip, tcp = _chip_seg(80, ephport, seq=chip_isn, ack=_seq_add(our_isn, 1),
flags=TCP_SYN | TCP_ACK)
await tin.handle_chip_segment(ip, tcp)
assert await _wait_until(
lambda: any(_parse_injected_tcp(f).payload == request_bytes for f in injected))
# The handshake ACK must have gone out before the request.
assert any(_parse_injected_tcp(f).flags & TCP_ACK for f in injected)
# 3. Send the HTTP response, then FIN (a length-less, close-delimited body —
# the common MicroPython "socket then conn.close()" shape).
response = (b'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n'
b'<html><body>LED ON</body></html>')
ip, tcp = _chip_seg(80, ephport, seq=_seq_add(chip_isn, 1),
ack=0, flags=TCP_PSH | TCP_ACK, payload=response)
await tin.handle_chip_segment(ip, tcp)
ip, tcp = _chip_seg(80, ephport, seq=_seq_add(chip_isn, 1 + len(response)),
ack=0, flags=TCP_FIN | TCP_ACK)
await tin.handle_chip_segment(ip, tcp)
result = await asyncio.wait_for(task, timeout=2.0)
assert result == response
# The client must close cleanly: a FIN should have been injected.
assert any(_parse_injected_tcp(f).flags & TCP_FIN for f in injected)
@pytest.mark.asyncio
async def test_inbound_returns_none_when_chip_never_answers():
async def inject(frame: bytes) -> None:
pass
tin = TcpInbound(inject, lambda: CHIP_MAC)
# No SYN+ACK ever arrives → request gives up (short SYN timeout path).
result = await tin.request(b'GET / HTTP/1.1\r\n\r\n', timeout=0.5)
assert result is None
def test_http_response_complete_by_content_length():
full = b'HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello'
assert _http_response_complete(bytearray(full))
# One byte short → not complete.
assert not _http_response_complete(bytearray(full[:-1]))
# No declared length → relies on FIN/idle, never "complete" here.
assert not _http_response_complete(bytearray(b'HTTP/1.1 200 OK\r\n\r\nhi'))
def test_parse_http_response_splits_status_headers_body():
raw = (b'HTTP/1.1 404 Not Found\r\n'
b'Content-Type: application/json\r\n'
b'X-Foo: bar\r\n\r\n'
b'{"missing":true}')
status, headers, body = _parse_http_response(raw)
assert status == 404
assert headers['content-type'] == 'application/json'
assert headers['x-foo'] == 'bar'
assert body == b'{"missing":true}'
@pytest.mark.asyncio
async def test_ensure_chip_mac_primes_gateway_arp():
"""ensure_chip_mac emits an ARP for the STA (priming the chip's gateway
lookup) and returns the chip MAC. The chip's on-wire MAC is
deterministically STA_MAC, so the default is already the right target."""
sent: list[tuple[str, dict]] = []
async def emit(event: str, data: dict) -> None:
sent.append((event, data))
bridge = PicowNetBridge('sess::pico', emit, wifi_enabled=True)
mac = await bridge.ensure_chip_mac()
assert mac == STA_MAC
# An ARP request for the STA, sourced from the gateway, must have gone out.
injected = [d['ether_b64'] for e, d in sent if e == 'picow_packet_in']
assert injected, 'ensure_chip_mac should inject an ARP'
import base64
eth = Ethernet.parse(base64.b64decode(injected[0]))
assert eth.ethertype == 0x0806
arp = Arp.parse(eth.payload)
assert arp.opcode == 1 and bytes(arp.tpa) == _STA
@pytest.mark.asyncio
async def test_bridge_tracks_chip_mac_from_outbound_frame():
"""If the chip ever sends an outbound frame, the bridge adopts its src
MAC (used as the destination for injected replies)."""
async def emit(event: str, data: dict) -> None:
pass
bridge = PicowNetBridge('sess::pico', emit, wifi_enabled=True)
assert bridge._chip_mac == STA_MAC
# A gratuitous ARP from a chip that happens to use a different MAC.
reply = Arp(opcode=2, sha=CHIP_MAC, spa=_STA, tha=STA_MAC, tpa=_GW)
await bridge.deliver_packet_out(make_frame_arp(STA_MAC, CHIP_MAC, reply))
assert bridge._chip_mac == CHIP_MAC