Files
mc-webui/tests/test_firmware_1_18.py
MarekWoandClaude Opus 5.5 6451cef2ea feat: client support for MeshCore firmware 1.18
Firmware 1.18 gives contact flag 0x10 a security meaning (the contact may
run any companion CLI command over the mesh, unseen by the app), forwards a
refused remote command as a txt_type 3 DM, and serves the companion CLI over
the app link (CMD_RUN_CLI_COMMAND, fw_ver_code 14).

- Remote CLI permission is visible: red badge on the contact card and in
  the DM contact-info window. Console change_flags shows flags in words,
  accepts 0x.., reports old/new, warns on grant, restores the in-memory
  record when the device refuses, and invalidates the contacts cache.
- txt_type 3 DMs are stored with their type, rendered as "Remote CLI
  command - not executed" with the command verbatim, logged at WARNING,
  and the notification says so.
- New console command `cli <command>`, gated on fw_ver_code >= 14 and on
  the library having run_cli_command (2.3.9+, requirements untouched).
  poweroff/shutdown refused; reboot and `set name`/`set path.hash.mode`
  routed through the existing commands so caches follow.
- Re-adding a contact the device holds (a rename) starts from the device
  record, so its flags and path survive instead of being reset to zero.
- Fix: approving a pending contact raised KeyError('out_path') for every
  record not produced by a NEW_CONTACT event (advert-injected entries and
  the DB fallback). Records are now completed before add_contact(), and a
  device ERROR is reported instead of a false success.
- set_channel refuses a named slot with an all-zero key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:06:16 +02:00

376 lines
12 KiB
Python

"""
Unit tests for the client side of MeshCore firmware 1.18.
- Contact flag bit 0x10 now lets that contact run any CLI command on our node,
and CMD_ADD_UPDATE_CONTACT overwrites the whole flags byte, so every write
must carry the device's current flags rather than rebuild them.
- Approving a pending contact used to raise KeyError('out_path') for every
record not produced by a NEW_CONTACT event (advert-injected, DB fallback).
- A remote CLI command the companion refused to run arrives as a DM with
txt_type 3 and must be stored and shown as such.
- A named channel slot must never get an all-zero key.
- The companion's own CLI (CMD_RUN_CLI_COMMAND) needs firmware 1.18.
The real meshcore command classes build the frames; only send() is faked, so
the tests see the exact bytes that would reach the device.
Run: python -m pytest tests/test_firmware_1_18.py -v
"""
import asyncio
from types import SimpleNamespace
import pytest
pytest.importorskip('meshcore', reason='app.device_manager needs the meshcore lib')
from meshcore.commands.contact import ContactCommands # noqa: E402
from meshcore.commands.device import DeviceCommands # noqa: E402
from meshcore.events import EventType # noqa: E402
from app.device_manager import ( # noqa: E402
DeviceManager,
_device_contact_record,
describe_contact_flags,
)
PK = 'ab' * 32
CMD_ADD_UPDATE_CONTACT = 0x09
CMD_RUN_CLI_COMMAND = 66
def reply(event_type, payload=None):
return SimpleNamespace(type=event_type, payload={} if payload is None else payload)
def parse_contact_frame(frame):
"""Fields of a CMD_ADD_UPDATE_CONTACT frame, as the firmware reads them."""
assert frame[0] == CMD_ADD_UPDATE_CONTACT
return {
'public_key': frame[1:33].hex(),
'type': frame[33],
'flags': frame[34],
'out_path_len': frame[35],
'out_path': frame[36:100].hex().rstrip('0'),
'name': frame[100:132].rstrip(b'\0').decode(),
'last_advert': int.from_bytes(frame[132:136], 'little'),
}
class RecordingCommands(ContactCommands, DeviceCommands):
"""The library's own command code; send() records instead of transmitting."""
def __init__(self):
self.frames = []
self.reply = reply(EventType.OK)
async def send(self, data, expected_events=None, timeout=None):
self.frames.append(bytes(data))
if data[0] == CMD_RUN_CLI_COMMAND:
return reply(EventType.CLI_REPLY, {'text': f'> {bytes(data[1:]).decode()}'})
return self.reply
def contact_frames(self):
return [parse_contact_frame(f) for f in self.frames if f[0] == CMD_ADD_UPDATE_CONTACT]
class FakeMC:
def __init__(self, commands):
self.commands = commands
self.contacts = {}
self.pending_contacts = {}
async def ensure_contacts(self, follow=False):
return True # like the library: no refetch unless an advert marked it dirty
def get_contact_by_name(self, name):
return next((c for c in self.contacts.values() if c.get('adv_name') == name), None)
def get_contact_by_key_prefix(self, prefix):
return next((c for k, c in self.contacts.items() if k.startswith(prefix)), None)
class FakeDB:
def __init__(self):
self.contacts = {}
self.dms = []
self.upserts = []
def get_contact(self, public_key):
return self.contacts.get(public_key)
def get_contact_by_prefix(self, prefix):
return None
def upsert_contact(self, **kwargs):
self.upserts.append(kwargs)
def relink_orphaned_dms(self, public_key, name=None):
pass
def upsert_channel(self, idx, name, secret):
pass
def delete_channel(self, idx):
pass
def find_dm_duplicate(self, *args, **kwargs):
return None
def is_contact_blocked(self, public_key):
return False
def insert_direct_message(self, **kwargs):
self.dms.append(kwargs)
return len(self.dms)
class FakeSocketIO:
def __init__(self):
self.emitted = []
def emit(self, event, data, namespace=None):
self.emitted.append((event, data))
def device_contact(**overrides):
"""A contact as the library holds it after reading the device's table."""
contact = {
'public_key': PK, 'type': 1, 'flags': 0x13, 'out_path_len': 2,
'out_path': 'a1b2', 'out_path_hash_mode': 0, 'adv_name': 'Alice',
'last_advert': 1700000000, 'adv_lat': 50.5, 'adv_lon': 20.5, 'lastmod': 1700000001,
}
contact.update(overrides)
return contact
@pytest.fixture
def commands():
return RecordingCommands()
@pytest.fixture
def dm(commands):
manager = DeviceManager(config=SimpleNamespace(MC_DEVICE_NAME='auto'),
db=FakeDB(), socketio=FakeSocketIO())
manager.mc = FakeMC(commands)
manager._connected = True
manager._fw_ver_code = 14
manager.execute = lambda coro, timeout=None: asyncio.run(coro)
manager._refresh_channel_secret = lambda idx: None
return manager
# ================================================================
# Approving a pending contact
# ================================================================
def test_approve_contact_injected_from_an_advert(dm, commands):
# the shape _on_advertisement puts into mc.pending_contacts
dm.mc.pending_contacts[PK] = {
'public_key': PK, 'adv_name': 'Bob', 'name': 'Bob', 'type': 1,
'adv_lat': None, 'adv_lon': None, 'last_advert': 1700000000,
}
result = dm.approve_contact(PK)
assert result['success'], result
[frame] = commands.contact_frames()
assert frame['name'] == 'Bob'
assert frame['flags'] == 0
assert frame['out_path_len'] == 0xFF # flood
assert PK not in dm.mc.pending_contacts
assert dm.mc.contacts[PK]['out_path_len'] == -1
def test_approve_contact_from_the_db_fallback(dm, commands):
dm.db.contacts[PK] = {
'public_key': PK, 'name': 'Carol', 'type': 2, 'source': 'advert',
'adv_lat': 50.1, 'adv_lon': 20.2, 'last_advert': '1700000000',
}
result = dm.approve_contact(PK)
assert result['success'], result
[frame] = commands.contact_frames()
assert (frame['name'], frame['type'], frame['last_advert']) == ('Carol', 2, 1700000000)
def test_approve_contact_reports_a_device_refusal(dm, commands):
dm.mc.pending_contacts[PK] = {'public_key': PK, 'adv_name': 'Bob', 'type': 1}
commands.reply = reply(EventType.ERROR, {'error_code': 3, 'code_string': 'TABLE_FULL'})
result = dm.approve_contact(PK)
assert not result['success']
assert 'TABLE_FULL' in result['error']
assert PK in dm.mc.pending_contacts # still pending, nothing claimed
# ================================================================
# Re-adding a contact the device already holds (rename)
# ================================================================
def test_readding_a_device_contact_keeps_its_flags_and_path(dm, commands):
dm.mc.contacts[PK] = device_contact()
result = dm.add_contact_manual('Alice Renamed', PK, 1)
assert result['success'], result
[frame] = commands.contact_frames()
assert frame['flags'] == 0x13 # remote CLI + favourite + base telemetry kept
assert (frame['out_path_len'], frame['out_path']) == (2, 'a1b2')
assert frame['name'] == 'Alice Renamed'
assert dm.mc.contacts[PK]['adv_name'] == 'Alice Renamed'
def test_a_new_manual_contact_gets_no_flags(dm, commands):
result = dm.add_contact_manual('Dave', PK, 1)
assert result['success'], result
[frame] = commands.contact_frames()
assert frame['flags'] == 0
assert frame['out_path_len'] == 0xFF
def test_a_path_change_carries_the_current_flags(dm, commands):
dm.mc.contacts[PK] = device_contact(flags=0x10)
dm.change_path(PK, 'c3d4')
[frame] = commands.contact_frames()
assert frame['flags'] == 0x10
# ================================================================
# change_contact_flags
# ================================================================
def test_change_flags_reports_the_previous_value(dm, commands):
dm.mc.contacts[PK] = device_contact(flags=0x03)
result = dm.change_contact_flags('Alice', 0x13)
assert result['success'], result
assert (result['old_flags'], result['flags']) == (0x03, 0x13)
[frame] = commands.contact_frames()
assert frame['flags'] == 0x13
assert dm.mc.contacts[PK]['flags'] == 0x13
def test_change_flags_failure_leaves_the_record_untouched(dm, commands):
dm.mc.contacts[PK] = device_contact(flags=0x03)
commands.reply = reply(EventType.ERROR, {'reason': 'timeout'})
result = dm.change_contact_flags('Alice', 0x13)
assert not result['success']
assert dm.mc.contacts[PK]['flags'] == 0x03
def test_change_flags_rejects_out_of_range_values(dm, commands):
dm.mc.contacts[PK] = device_contact()
assert not dm.change_contact_flags('Alice', 256)['success']
assert commands.frames == []
def test_describe_contact_flags():
assert describe_contact_flags(0) == 'none'
assert describe_contact_flags(0x13) == 'favourite, telemetry: base, remote CLI'
assert describe_contact_flags(0x41) == 'favourite, unknown 0x40'
def test_contact_record_fills_what_the_library_requires():
record = _device_contact_record({'public_key': PK.upper(), 'name': 'Eve',
'last_advert': '1700000000.0', 'adv_lat': None})
assert record['public_key'] == PK
assert record['adv_name'] == 'Eve'
assert (record['flags'], record['out_path'], record['out_path_len']) == (0, '', -1)
assert record['last_advert'] == 1700000000
assert record['adv_lat'] == 0.0
# ================================================================
# Remote CLI command arriving as a DM (txt_type 3)
# ================================================================
def test_remote_cli_command_is_stored_with_its_type(dm):
dm.mc.contacts[PK] = device_contact(flags=0)
event = SimpleNamespace(payload={
'pubkey_prefix': PK[:12], 'txt_type': 3, 'text': 'get wifi.pwd',
'sender_timestamp': 1700000100,
})
asyncio.run(dm._on_dm_received(event))
[stored] = dm.db.dms
assert (stored['txt_type'], stored['content']) == (3, 'get wifi.pwd')
[(name, data)] = dm.socketio.emitted
assert name == 'new_message' and data['txt_type'] == 3
def test_plain_dm_is_stored_as_type_0(dm):
dm.mc.contacts[PK] = device_contact()
event = SimpleNamespace(payload={'pubkey_prefix': PK[:12], 'txt_type': 0, 'text': 'hi',
'sender_timestamp': 1700000100})
asyncio.run(dm._on_dm_received(event))
assert dm.db.dms[0]['txt_type'] == 0
# ================================================================
# Channel keys
# ================================================================
def test_named_channel_with_all_zero_key_is_refused(dm, commands):
result = dm.set_channel(3, 'Secret', bytes(16))
assert not result['success']
assert commands.frames == []
def test_channel_without_key_gets_a_derived_one(dm, commands):
assert dm.set_channel(3, 'Friends', None)['success']
assert dm.remove_channel(3)['success']
for frame in commands.frames:
assert any(frame[34:50]), 'the library must never send a zero key'
# ================================================================
# Companion CLI (CMD_RUN_CLI_COMMAND)
# ================================================================
def test_device_cli_returns_the_reply(dm, commands):
result = dm.run_device_cli('get cad')
assert result == {'success': True, 'reply': '> get cad'}
assert commands.frames == [bytes([CMD_RUN_CLI_COMMAND]) + b'get cad']
def test_device_cli_needs_firmware_1_18(dm, commands):
dm._fw_ver_code = 13
result = dm.run_device_cli('get cad')
assert not result['success'] and '1.18' in result['error']
assert commands.frames == []
def test_device_cli_refuses_poweroff(dm, commands):
for command in ('poweroff', 'shutdown'):
assert not dm.run_device_cli(command)['success']
assert commands.frames == []
def test_device_cli_set_name_goes_through_set_param(dm, commands, monkeypatch):
calls = []
monkeypatch.setattr(dm, 'set_param', lambda param, value: calls.append((param, value))
or {'success': True, 'message': 'renamed'})
assert dm.run_device_cli('set name New Name')['success']
assert calls == [('name', 'New Name')]
assert commands.frames == []