NODEDC_MISSION_CORE/tests/test_mqtt_capture.py

210 lines
7.5 KiB
Python

import hashlib
import json
import stat
from collections.abc import Callable
from pathlib import Path
from typing import Any, cast
import paho.mqtt.client as mqtt
import pytest
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.reasoncodes import ReasonCode
from k1link.mqtt.capture import (
FRAME_HEADER,
RAW_MAGIC,
REPORT_TOPICS,
CaptureError,
CaptureFormatError,
capture_mqtt,
iter_capture_frames,
validate_private_ipv4,
)
class FakeClient:
def __init__(self, *, topic: str = "RealtimePath", payload: bytes = b"pose-data") -> None:
self.on_connect: Callable[..., None] | None = None
self.on_subscribe: Callable[..., None] | None = None
self.on_message: Callable[..., None] | None = None
self.on_disconnect: Callable[..., None] | None = None
self.topic = topic
self.payload = payload
self.connect_calls: list[tuple[str, int, int]] = []
self.subscribe_calls: list[Any] = []
self.disconnect_count = 0
self._step = 0
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
self.connect_calls.append((host, port, keepalive))
return mqtt.MQTT_ERR_SUCCESS
def subscribe(self, topics: Any) -> tuple[mqtt.MQTTErrorCode, int]:
self.subscribe_calls.append(topics)
return mqtt.MQTT_ERR_SUCCESS, 7
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
assert timeout > 0
self._step += 1
if self._step == 1:
assert self.on_connect is not None
self.on_connect(
self,
None,
mqtt.ConnectFlags(session_present=False),
ReasonCode(PacketTypes.CONNACK, "Success"),
None,
)
elif self._step == 2:
assert self.on_subscribe is not None
self.on_subscribe(
self,
None,
7,
[ReasonCode(PacketTypes.SUBACK, identifier=0) for _ in REPORT_TOPICS],
None,
)
elif self._step == 3:
assert self.on_message is not None
message = mqtt.MQTTMessage(topic=self.topic.encode())
message.payload = self.payload
message.qos = 0
self.on_message(self, None, message)
else:
raise KeyboardInterrupt
return mqtt.MQTT_ERR_SUCCESS
def disconnect(self) -> mqtt.MQTTErrorCode:
self.disconnect_count += 1
return mqtt.MQTT_ERR_SUCCESS
@pytest.mark.parametrize("address", ["10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.4.2"])
def test_validate_private_ipv4_accepts_only_rfc1918(address: str) -> None:
assert validate_private_ipv4(address) == address
@pytest.mark.parametrize(
"address",
["8.8.8.8", "127.0.0.1", "169.254.1.2", "::1", "k1.local", " 192.168.1.2"],
)
def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
with pytest.raises(ValueError, match="private IPv4|RFC1918"):
validate_private_ipv4(address)
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
fake = FakeClient()
summary = capture_mqtt(
"192.168.1.50",
tmp_path / "capture",
duration_seconds=30,
_client_factory=lambda: cast(mqtt.Client, fake),
)
assert fake.connect_calls == [("192.168.1.50", 1883, 30)]
assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]]
assert fake.disconnect_count == 1
assert summary["stop_reason"] == "keyboard_interrupt"
assert summary["message_count"] == 1
assert summary["payload_bytes"] == len(fake.payload)
assert summary["subscriptions"] == list(REPORT_TOPICS)
capture_dir = tmp_path / "capture"
raw = (capture_dir / "mqtt.raw.k1mqtt").read_bytes()
assert raw.startswith(RAW_MAGIC)
topic_length, payload_length = FRAME_HEADER.unpack_from(raw, len(RAW_MAGIC))
topic_start = len(RAW_MAGIC) + FRAME_HEADER.size
payload_start = topic_start + topic_length
assert raw[topic_start:payload_start].decode() == fake.topic
assert payload_length == len(fake.payload)
assert raw[payload_start:] == fake.payload
records: list[dict[str, object]] = [
json.loads(line)
for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines()
]
assert len(records) == 1
record = records[0]
assert record["record_type"] == "message"
assert record["topic"] == fake.topic
assert record["payload_bytes"] == len(fake.payload)
assert record["payload_sha256"] == hashlib.sha256(fake.payload).hexdigest()
assert record["raw_frame_offset"] == len(RAW_MAGIC)
assert record["raw_payload_offset"] == payload_start
frames = list(iter_capture_frames(capture_dir / "mqtt.raw.k1mqtt"))
assert len(frames) == 1
assert frames[0].sequence == 1
assert frames[0].topic == fake.topic
assert frames[0].payload == fake.payload
assert frames[0].raw_frame_offset == len(RAW_MAGIC)
assert frames[0].raw_payload_offset == payload_start
saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text())
assert saved_summary == summary
assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest()
for artifact_name in ("mqtt.raw.k1mqtt", "mqtt.metadata.jsonl", "mqtt.summary.json"):
assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600
def test_oversize_payload_is_not_written_to_raw_capture(tmp_path: Path) -> None:
fake = FakeClient(payload=b"oversize")
capture_dir = tmp_path / "capture"
with pytest.raises(CaptureError, match="limit is 4 bytes") as error:
capture_mqtt(
"10.1.2.3",
capture_dir,
max_message_bytes=4,
_client_factory=lambda: cast(mqtt.Client, fake),
)
assert (capture_dir / "mqtt.raw.k1mqtt").read_bytes() == RAW_MAGIC
record = json.loads((capture_dir / "mqtt.metadata.jsonl").read_text())
assert record["record_type"] == "rejected_message"
assert record["payload_bytes"] == len(fake.payload)
assert error.value.summary is not None
assert error.value.summary["stop_reason"] == "message_too_large"
assert error.value.summary["message_count"] == 0
assert error.value.summary["rejected_message_count"] == 1
def test_capture_refuses_to_overwrite_existing_artifacts(tmp_path: Path) -> None:
capture_dir = tmp_path / "capture"
capture_dir.mkdir()
raw = capture_dir / "mqtt.raw.k1mqtt"
raw.write_bytes(b"existing evidence")
with pytest.raises(FileExistsError, match="refusing to overwrite"):
capture_mqtt(
"192.168.1.2",
capture_dir,
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
)
assert raw.read_bytes() == b"existing evidence"
@pytest.mark.parametrize(
("raw", "message"),
[
(b"not-mqtt", "magic/version"),
(RAW_MAGIC + b"\x00", "truncated length header"),
(RAW_MAGIC + FRAME_HEADER.pack(4, 1) + b"ab", "topic .* is truncated"),
(RAW_MAGIC + FRAME_HEADER.pack(1, 4) + b"t" + b"ab", "payload .* is truncated"),
(RAW_MAGIC + FRAME_HEADER.pack(1, 5) + b"t" + b"abcde", "exceeds 4"),
],
)
def test_capture_reader_rejects_invalid_or_unbounded_frames(
tmp_path: Path,
raw: bytes,
message: str,
) -> None:
path = tmp_path / "capture.raw"
path.write_bytes(raw)
with pytest.raises(CaptureFormatError, match=message):
list(iter_capture_frames(path, max_payload_bytes=4))