Files
MeshCore/test/test_t1000e_contact_protocol_stress.py
T

527 lines
20 KiB
Python

#!/usr/bin/env python3
"""Offline behavioral tests for the focused T1000-E protocol HIL tool."""
import argparse
import asyncio
import importlib.util
from pathlib import Path
import struct
import sys
from types import SimpleNamespace
import unittest
ROOT = Path(__file__).resolve().parents[1]
HIL_DIR = ROOT / "tools" / "hil"
if str(HIL_DIR) not in sys.path:
sys.path.insert(0, str(HIL_DIR))
TOOL_PATH = HIL_DIR / "t1000e_contact_protocol_stress.py"
SPEC = importlib.util.spec_from_file_location(
"t1000e_contact_protocol_stress", TOOL_PATH
)
assert SPEC is not None and SPEC.loader is not None
HIL = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = HIL
SPEC.loader.exec_module(HIL)
TEST_NODE_KEY_PREFIX = "01234567"
def valid_config(**changes):
values = dict(
port="COM23",
allow_destructive=True,
confirm_usb_serial=HIL.EXPECTED_USB_SERIAL,
expected_node_key_prefix=TEST_NODE_KEY_PREFIX,
expected_contact_keyset_sha256=HIL.EXPECTED_KEYSET_SHA256,
mutation_index=349,
stream_trigger_contacts=12,
settle_seconds=7.0,
verbose=False,
)
values.update(changes)
return HIL.Config(**values)
class SafetyGateTests(unittest.TestCase):
def test_all_destructive_confirmations_are_mandatory(self):
bad = (
valid_config(allow_destructive=False),
valid_config(confirm_usb_serial="WRONG"),
valid_config(expected_node_key_prefix="deadbee"),
valid_config(expected_node_key_prefix="not-hex!"),
valid_config(expected_contact_keyset_sha256="00" * 32),
)
for config in bad:
with self.subTest(config=config):
with self.assertRaises(HIL.ProtocolHilFailure):
HIL.require_consent(config)
HIL.require_consent(valid_config())
def test_ranges_fail_closed(self):
for config in (
valid_config(mutation_index=-1),
valid_config(mutation_index=350),
valid_config(stream_trigger_contacts=0),
valid_config(stream_trigger_contacts=101),
valid_config(settle_seconds=0),
):
with self.subTest(config=config):
with self.assertRaises(HIL.ProtocolHilFailure):
HIL.require_consent(config)
def test_port_is_required_outside_self_test(self):
parser = HIL.build_argument_parser()
args = parser.parse_args([])
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "--port"):
HIL.config_from_args(args)
class ContactFrameTests(unittest.TestCase):
def setUp(self):
self.contact = HIL.BASE.make_contact(349)
def test_encoder_matches_all_three_firmware_boundaries(self):
with_lastmod = HIL.encode_contact_update(self.contact)
gps_only = HIL.encode_contact_update(
self.contact, include_lastmod=False
)
self.assertEqual(len(with_lastmod), HIL.CONTACT_UPDATE_LASTMOD_LEN)
self.assertEqual(len(gps_only), HIL.CONTACT_UPDATE_GPS_LEN)
self.assertEqual(with_lastmod[0], HIL.CMD_ADD_UPDATE_CONTACT)
self.assertEqual(with_lastmod[1:33].hex(), self.contact["public_key"])
self.assertEqual(with_lastmod[33], 1)
self.assertEqual(with_lastmod[35], 0xFF)
def test_case_matrix_covers_every_requested_forbidden_length(self):
cases = HIL.malformed_cases(self.contact)
lengths = {
len(case.frame)
for case in cases
if case.name.startswith("forbidden_length_")
}
self.assertEqual(lengths, set(HIL.FORBIDDEN_CONTACT_UPDATE_LENGTHS))
self.assertEqual(
lengths,
{135, *range(137, 144), *range(145, 148)},
)
self.assertTrue(
all(case.frame[0] == HIL.CMD_ADD_UPDATE_CONTACT for case in cases)
)
def test_invalid_type_and_path_offsets_are_exact(self):
cases = {case.name: case.frame for case in HIL.malformed_cases(self.contact)}
for value in HIL.INVALID_CONTACT_TYPES:
frame = cases[f"invalid_type_{value}"]
self.assertEqual(len(frame), 148)
self.assertEqual(frame[33], value)
for value in HIL.INVALID_PATH_LENGTHS:
frame = cases[f"invalid_path_{value:02x}"]
self.assertEqual(len(frame), 148)
self.assertEqual(frame[35], value)
def test_illegal_arg_requires_error_code_six(self):
HIL.require_illegal_arg(
SimpleNamespace(
type=SimpleNamespace(name="ERROR"), payload={"error_code": 6}
),
"case",
)
for event in (
SimpleNamespace(type=SimpleNamespace(name="OK"), payload={}),
SimpleNamespace(
type=SimpleNamespace(name="ERROR"), payload={"error_code": 5}
),
):
with self.assertRaises(HIL.ProtocolHilFailure):
HIL.require_illegal_arg(event, "case")
class SnapshotTests(unittest.TestCase):
def setUp(self):
self.contacts = {
contact["public_key"]: contact
for contact in (
HIL.BASE.make_contact(index)
for index in range(HIL.EXPECTED_CONTACT_COUNT)
)
}
def test_exact_deterministic_table_has_locked_digest(self):
result = HIL.validate_exact_snapshot(
self.contacts, require_canonical_fields=True
)
self.assertEqual(result["count"], 350)
self.assertEqual(result["keyset_sha256"], HIL.EXPECTED_KEYSET_SHA256)
def test_missing_key_and_same_keys_changed_content_are_rejected(self):
missing = dict(self.contacts)
missing.pop(next(iter(missing)))
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "expected 350"):
HIL.validate_exact_snapshot(missing, require_canonical_fields=True)
changed = {key: dict(value) for key, value in self.contacts.items()}
changed[next(iter(changed))]["adv_name"] = "mutated"
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "unexpected contact fields"):
HIL.validate_exact_snapshot(changed, require_canonical_fields=True)
def test_wire_digest_includes_lastmod_while_canonical_digest_does_not(self):
baseline = {key: dict(value) for key, value in self.contacts.items()}
changed = {key: dict(value) for key, value in baseline.items()}
key = next(iter(changed))
baseline[key]["lastmod"] = 100
changed[key]["lastmod"] = 101
self.assertEqual(
HIL.semantic_digest(baseline), HIL.semantic_digest(changed)
)
self.assertNotEqual(
HIL.wire_snapshot_digest(baseline),
HIL.wire_snapshot_digest(changed),
)
def fake_event(name, payload=None):
return SimpleNamespace(
type=SimpleNamespace(name=name),
payload={} if payload is None else payload,
)
class FakeMalformedCommands:
def __init__(self):
self.frames = []
async def send(self, frame, expected_events, timeout):
self.frames.append((bytes(frame), tuple(expected_events), timeout))
return fake_event("ERROR", {"error_code": 6})
class FakeMalformedBase:
def __init__(self, contacts):
self._contacts = contacts
self.mc = SimpleNamespace(commands=FakeMalformedCommands())
self.contact_reads = 0
async def contacts(self):
self.contact_reads += 1
return {key: dict(value) for key, value in self._contacts.items()}
class MalformedExecutionTests(unittest.IsolatedAsyncioTestCase):
async def test_every_rejection_is_followed_by_an_exact_inventory(self):
contacts = {
contact["public_key"]: contact
for contact in (
HIL.BASE.make_contact(index)
for index in range(HIL.EXPECTED_CONTACT_COUNT)
)
}
subject = HIL.ProtocolRunner(
valid_config(), SimpleNamespace(serial_number=HIL.EXPECTED_USB_SERIAL)
)
fake = FakeMalformedBase(contacts)
subject.base = fake
await subject.test_malformed_updates(contacts)
self.assertEqual(len(fake.mc.commands.frames), 17)
self.assertEqual(fake.contact_reads, 17)
self.assertEqual(subject.steps[-1]["step"], "malformed_contact_updates")
self.assertTrue(subject.steps[-1]["unchanged_after_each"])
self.assertTrue(
all(frame[0] == HIL.CMD_ADD_UPDATE_CONTACT for frame, _, _ in fake.mc.commands.frames)
)
class FakeStreamReader:
def __init__(self, owner):
self.owner = owner
self.contact_nb = None
self.contacts = {}
async def handle_rx(self, raw):
packet_type = raw[0]
if packet_type == HIL.RESP_CODE_CONTACTS_START:
self.contact_nb = int.from_bytes(raw[1:5], "little")
self.contacts = {}
elif packet_type == HIL.RESP_CODE_CONTACT:
key = raw[1:33].hex()
self.contacts[key] = dict(self.owner.table[key])
class FakeStreamCommands:
def __init__(self, owner):
self.owner = owner
@staticmethod
def _start(count):
return bytes((HIL.RESP_CODE_CONTACTS_START,)) + struct.pack("<I", count)
@staticmethod
def _contact(key):
return bytes((HIL.RESP_CODE_CONTACT,)) + bytes.fromhex(key) + b"\0" * 115
async def get_contacts(self, *, lastmod, timeout):
self.owner.reader.contacts = {}
starting_revision = self.owner.revision
await self.owner.reader.handle_rx(self._start(len(self.owner.table)))
for key in sorted(self.owner.table)[:12]:
await self.owner.reader.handle_rx(self._contact(key))
await asyncio.sleep(0)
deadline = asyncio.get_running_loop().time() + 1.0
while self.owner.revision == starting_revision:
if asyncio.get_running_loop().time() >= deadline:
return fake_event("ERROR", {"reason": "mutation did not run"})
await asyncio.sleep(0)
# Both local mutation commands complete before this task is scheduled
# again, representing the firmware's revision-triggered fresh snapshot.
await self.owner.reader.handle_rx(self._start(len(self.owner.table)))
for key in sorted(self.owner.table):
await self.owner.reader.handle_rx(self._contact(key))
await asyncio.sleep(0)
await self.owner.reader.handle_rx(
bytes((HIL.RESP_CODE_END_OF_CONTACTS,)) + b"\0" * 4
)
return fake_event("CONTACTS", dict(self.owner.reader.contacts))
async def remove_contact(self, key):
self.owner.table.pop(key)
self.owner.revision += 1
return fake_event("OK")
async def add_contact(self, contact):
self.owner.table[contact["public_key"]] = dict(contact)
self.owner.revision += 1
return fake_event("OK")
async def send(self, frame, expected_events, timeout):
self.owner.table[self.owner.original_target["public_key"]] = dict(
self.owner.original_target
)
self.owner.revision += 1
return fake_event("OK")
class FakeStreamMesh:
def __init__(self, contacts):
self.table = {key: dict(value) for key, value in contacts.items()}
target_key = HIL.BASE.make_contact(349)["public_key"]
self.original_target = dict(self.table[target_key])
self.revision = 0
self.reader = FakeStreamReader(self)
self._reader = self.reader
self.commands = FakeStreamCommands(self)
class StructuralExecutionTests(unittest.IsolatedAsyncioTestCase):
async def test_live_remove_readd_requires_raw_restart_and_exact_sdk_result(self):
contacts = {
contact["public_key"]: contact
for contact in (
HIL.BASE.make_contact(index)
for index in range(HIL.EXPECTED_CONTACT_COUNT)
)
}
subject = HIL.ProtocolRunner(
valid_config(), SimpleNamespace(serial_number=HIL.EXPECTED_USB_SERIAL)
)
subject.base.mc = FakeStreamMesh(contacts)
target_key = HIL.BASE.make_contact(349)["public_key"]
subject.original_target_contact = dict(contacts[target_key])
await subject.test_structural_restart()
step = subject.steps[-1]
self.assertEqual(step["step"], "structural_stream_restart")
self.assertGreaterEqual(step["raw"]["fresh_starts"], 1)
self.assertEqual(step["raw"]["completed_count"], 350)
self.assertEqual(step["sdk"]["keyset_sha256"], HIL.EXPECTED_KEYSET_SHA256)
self.assertEqual(subject.base.mc.revision, 2)
class FakeRestoreCommands:
def __init__(self, owner):
self.owner = owner
self.sent_frames = []
async def send(self, frame, expected_events, timeout):
self.sent_frames.append(bytes(frame))
self.owner.table[self.owner.target["public_key"]] = dict(self.owner.target)
return fake_event("OK")
async def get_autoadd_config(self):
return fake_event("AUTOADD_CONFIG", {"config": self.owner.autoadd})
async def send_appstart(self):
return fake_event(
"SELF_INFO", {"manual_add_contacts": self.owner.manual}
)
class FakeRestoreBase:
def __init__(self, table, target):
self.table = {key: dict(value) for key, value in table.items()}
self.target = dict(target)
self.autoadd = 0
self.manual = True
self.reboots = 0
self.mc = SimpleNamespace(commands=FakeRestoreCommands(self))
@staticmethod
def _require_event(event, expected):
return HIL.require_event(event, expected, "fake")
async def contacts(self):
return {key: dict(value) for key, value in self.table.items()}
async def reboot_and_reconnect(self):
self.reboots += 1
async def set_autoadd_config(self, value):
self.autoadd = value
async def set_manual_contact_mode(self, value):
self.manual = value
class RestorationTests(unittest.IsolatedAsyncioTestCase):
async def test_restoration_preserves_lastmod_reboots_and_restores_admission(self):
table = {}
for index in range(HIL.EXPECTED_CONTACT_COUNT):
contact = HIL.BASE.make_contact(index)
contact["lastmod"] = 10_000 + index
table[contact["public_key"]] = contact
target_key = HIL.BASE.make_contact(349)["public_key"]
target = dict(table[target_key])
table[target_key] = {**target, "lastmod": 999_999}
subject = HIL.ProtocolRunner(
valid_config(settle_seconds=0.001),
SimpleNamespace(serial_number=HIL.EXPECTED_USB_SERIAL),
)
fake = FakeRestoreBase(table, target)
subject.base = fake
subject.restoration_armed = True
subject.original_target_contact = target
expected_table = {key: dict(value) for key, value in table.items()}
expected_table[target_key] = target
subject.baseline_wire_sha256 = HIL.wire_snapshot_digest(expected_table)
subject.original_autoadd_config = 31
subject.original_manual_add_contacts = False
await subject.restore()
self.assertEqual(fake.reboots, 1)
self.assertEqual(fake.table[target_key]["lastmod"], target["lastmod"])
self.assertEqual(len(fake.mc.commands.sent_frames), 1)
self.assertEqual(len(fake.mc.commands.sent_frames[0]), 148)
self.assertEqual(fake.autoadd, 31)
self.assertFalse(fake.manual)
self.assertEqual(
[step["step"] for step in subject.steps[-3:]],
[
"contact_table_restored",
"autoadd_config_restored",
"contact_admission_restored",
],
)
class RawContactStreamTests(unittest.TestCase):
@staticmethod
def start(count):
return bytes((HIL.RESP_CODE_CONTACTS_START,)) + struct.pack("<I", count)
@staticmethod
def contact(key):
return bytes((HIL.RESP_CODE_CONTACT,)) + bytes.fromhex(key) + b"\0" * 115
def test_restart_discards_partial_segment_and_accepts_exact_final_snapshot(self):
keys = sorted(HIL.deterministic_keys())
observer = HIL.ContactStreamObserver(trigger_contacts=3)
observer.observe(self.start(350))
for key in keys[:3]:
observer.observe(self.contact(key))
self.assertTrue(observer.ready.is_set())
marker = observer.frame_count
# A remove can be observed as a transient 349-start before the re-add;
# the next revision must restart again at the final count of 350.
observer.observe(self.start(349))
for key in keys[:2]:
observer.observe(self.contact(key))
observer.observe(self.start(350))
for key in keys:
observer.observe(self.contact(key))
observer.observe(bytes((HIL.RESP_CODE_END_OF_CONTACTS,)) + b"\0" * 4)
details = observer.validate_restart(set(keys), marker)
self.assertEqual(details["starts"], 3)
self.assertEqual(details["fresh_starts"], 2)
self.assertEqual(details["start_counts"], [350, 349, 350])
self.assertEqual(details["completed_count"], 350)
self.assertEqual(details["aborted_contact_counts"], [3, 2])
def test_no_fresh_start_and_duplicate_final_keys_fail(self):
keys = sorted(HIL.deterministic_keys())
no_restart = HIL.ContactStreamObserver(trigger_contacts=1)
no_restart.observe(self.start(350))
no_restart.observe(self.contact(keys[0]))
marker = no_restart.frame_count
no_restart.observe(bytes((HIL.RESP_CODE_END_OF_CONTACTS,)) + b"\0" * 4)
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "fresh CONTACTS_START"):
no_restart.validate_restart(set(keys), marker)
duplicate = HIL.ContactStreamObserver(trigger_contacts=1)
duplicate.observe(self.start(350))
duplicate.observe(self.contact(keys[0]))
marker = duplicate.frame_count
duplicate.observe(self.start(350))
for key in keys[:-1]:
duplicate.observe(self.contact(key))
duplicate.observe(self.contact(keys[0]))
duplicate.observe(bytes((HIL.RESP_CODE_END_OF_CONTACTS,)) + b"\0" * 4)
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "duplicate"):
duplicate.validate_restart(set(keys), marker)
def test_malformed_raw_sequence_is_rejected(self):
observer = HIL.ContactStreamObserver(trigger_contacts=1)
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "without"):
observer.observe(self.contact("11" * 32))
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "expected 5"):
observer.observe(bytes((HIL.RESP_CODE_CONTACTS_START, 1)))
def test_exact_contact_and_end_lengths_and_terminal_state_are_enforced(self):
key = next(iter(HIL.deterministic_keys()))
observer = HIL.ContactStreamObserver(trigger_contacts=1)
observer.observe(self.start(350))
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "expected 148"):
observer.observe(bytes((HIL.RESP_CODE_CONTACT,)) + bytes.fromhex(key))
observer = HIL.ContactStreamObserver(trigger_contacts=1)
observer.observe(self.start(350))
observer.observe(self.contact(key))
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "expected 5"):
observer.observe(bytes((HIL.RESP_CODE_END_OF_CONTACTS,)))
observer.observe(bytes((HIL.RESP_CODE_END_OF_CONTACTS,)) + b"\0" * 4)
with self.assertRaisesRegex(HIL.ProtocolHilFailure, "unexpected"):
observer.observe(self.start(350))
class OfflineEntryPointTests(unittest.TestCase):
def test_self_test_does_not_need_a_port(self):
parser = HIL.build_argument_parser()
args = parser.parse_args(["--self-test"])
self.assertTrue(args.self_test)
result = HIL.offline_self_test()
self.assertTrue(result["ok"])
self.assertEqual(result["malformed_cases"], 17)
if __name__ == "__main__":
unittest.main()