Files
meshcore-bot/tests/unit/test_path_command_distance.py
agessaman 33ed32a6c1 fix: address Codex round 3 findings
Path distance was still leaking between concurrent requests three ways,
all of which Codex reproduced:

- The formatter fell back to shared instance state whenever the request's
  own value was None, so a request with no measurable distance rendered
  another request's. It now consults instance state only when there is no
  request to read from.
- One extraction branch still called _decode_path without the request, so
  that route stored its distance on shared state alone.
- _get_sender_location read the shared _current_message, so an
  interleaved command could measure from the wrong sender. It takes the
  request's message, falling back to the shared value only for direct
  calls that pass nothing.

Scope authorisation: '*' permits unscoped global traffic, not traffic of
unknown scope. When a scope-eligible packet was heard but could not be
tied to this message, its scope is unknown and '*' no longer admits it.
Deliberately narrower than the reviewer suggested: when no scope-eligible
packet was heard at all, '*' still applies, because nothing scoped being
heard is what a genuinely global message looks like. Requiring positive
proof of FLOOD in that case would silence legitimate global replies
whenever correlation is unavailable, which is too much availability to
trade for the residual risk.
2026-08-22 00:42:08 -07:00

198 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for the PathCommand {path_distance} placeholder.
Distance is summed sender -> each resolved hop -> bot, and is deliberately blank
whenever any node in that chain lacks usable coordinates, so a partial sum never
gets reported as the real distance travelled.
"""
import pytest
from modules.commands.path_command import PathCommand
@pytest.mark.unit
class TestPathCommandDistance:
"""_calculate_path_distance_km and its {path_distance} rendering."""
@pytest.fixture
def path_command(self, mock_bot):
cmd = PathCommand(mock_bot)
# Bot sits at the origin; sender and hops are placed east of it.
cmd.bot_latitude = 47.0
cmd.bot_longitude = -122.0
cmd._get_sender_location = lambda message=None: (47.0, -122.5)
return cmd
@staticmethod
def _info(lat, lon, **over):
base = {'found': True, 'collision': False, 'latitude': lat, 'longitude': lon}
base.update(over)
return base
def test_sums_sender_through_hops_to_bot(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': self._info(47.0, -122.2)}
km = path_command._calculate_path_distance_km(['AA', 'BB'], info)
assert km is not None
# Three legs spanning 0.5 deg of longitude at 47N (~75.9 km/deg) => ~38 km.
assert 37.0 < km < 39.0
def test_renders_with_km_suffix(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': self._info(47.0, -122.2)}
path_command._last_path_distance_km = path_command._calculate_path_distance_km(
['AA', 'BB'], info
)
rendered = path_command._format_path_distance()
assert rendered.endswith("km")
assert rendered[0].isdigit()
def test_blank_when_a_hop_has_no_coordinates(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': self._info(None, None)}
assert path_command._calculate_path_distance_km(['AA', 'BB'], info) is None
def test_blank_when_a_hop_is_a_prefix_collision(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': self._info(47.0, -122.2, collision=True)}
assert path_command._calculate_path_distance_km(['AA', 'BB'], info) is None
def test_blank_when_a_hop_is_unresolved(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': {'found': False}}
assert path_command._calculate_path_distance_km(['AA', 'BB'], info) is None
def test_blank_when_hop_coordinates_are_null_island(self, path_command):
"""0,0 in the DB means 'unset', not a real position in the Gulf of Guinea."""
info = {'AA': self._info(0, 0)}
assert path_command._calculate_path_distance_km(['AA'], info) is None
def test_blank_when_sender_location_unknown(self, path_command):
path_command._get_sender_location = lambda message=None: None
info = {'AA': self._info(47.0, -122.4)}
assert path_command._calculate_path_distance_km(['AA'], info) is None
def test_blank_when_bot_has_no_configured_position(self, path_command):
path_command.bot_latitude = None
info = {'AA': self._info(47.0, -122.4)}
assert path_command._calculate_path_distance_km(['AA'], info) is None
def test_placeholder_is_empty_string_when_unmeasurable(self, path_command):
path_command._last_path_distance_km = None
assert path_command._format_path_distance() == ""
@pytest.mark.unit
class TestDistanceThroughTheRealLookup:
"""Regression for a gap the unit tests above could not see.
Those pass hand-built repeater_info dicts straight to the calculator. The
resolution code that actually *builds* repeater_info was dropping latitude and
longitude, so {path_distance} was always blank in production while the tests
stayed green. These feed raw DB-shaped rows through _lookup_repeater_names via
its lookup_func hook, so the real construction code runs.
"""
@pytest.fixture
def path_command(self, mock_bot):
from modules.commands.path_command import PathCommand
cmd = PathCommand(mock_bot)
cmd.bot_latitude = 47.0
cmd.bot_longitude = -122.0
cmd._get_sender_location = lambda message=None: (47.0, -122.5)
return cmd
@staticmethod
def _row(node_id, lat, lon):
"""A row shaped like the repeater query's output."""
return {
'name': f'Hop {node_id}',
'public_key': node_id.lower() * 32,
'device_type': 'Repeater',
'last_seen': '2026-08-21 12:00:00',
'last_heard': '2026-08-21 12:00:00',
'last_advert_timestamp': None,
'is_active': True,
'latitude': lat,
'longitude': lon,
'city': 'Seattle',
'state': 'WA',
'country': 'US',
'snr': 5.0,
'is_starred': False,
}
@pytest.mark.asyncio
async def test_coordinates_survive_the_real_repeater_info_builder(self, path_command):
info = await path_command._lookup_repeater_names(
['AA'], lookup_func=lambda node_id: [self._row(node_id, 47.0, -122.3)]
)
assert info['AA']['found'] is True
# The bug: these were dropped when repeater_info was constructed.
assert info['AA']['latitude'] == 47.0
assert info['AA']['longitude'] == -122.3
@pytest.mark.asyncio
async def test_distance_is_computed_end_to_end(self, path_command):
info = await path_command._lookup_repeater_names(
['AA'], lookup_func=lambda node_id: [self._row(node_id, 47.0, -122.3)]
)
km = path_command._calculate_path_distance_km(['AA'], info)
assert km is not None and km > 0
@pytest.mark.asyncio
async def test_row_without_coordinates_still_yields_no_distance(self, path_command):
info = await path_command._lookup_repeater_names(
['AA'], lookup_func=lambda node_id: [self._row(node_id, None, None)]
)
assert path_command._calculate_path_distance_km(['AA'], info) is None
@pytest.mark.unit
class TestDistanceIsRequestScoped:
"""Two path commands can interleave: the decode awaits a database lookup and the
dispatcher runs handlers as independent tasks. Neither may see the other's value."""
@pytest.fixture
def path_command(self, mock_bot):
from modules.commands.path_command import PathCommand
cmd = PathCommand(mock_bot)
cmd.bot_latitude = 47.0
cmd.bot_longitude = -122.0
return cmd
@staticmethod
def _msg(sender):
from modules.models import MeshMessage
return MeshMessage(content="path", sender_id=sender, channel="#general")
def test_a_request_without_a_distance_does_not_borrow_another(self, path_command):
"""The exact leak: B stores None, A stores a value, B must still render ''."""
a, b = self._msg("a"), self._msg("b")
path_command._store_path_distance(None, b)
path_command._store_path_distance(42.0, a)
assert path_command._format_path_distance(b) == ""
assert path_command._format_path_distance(a) == "42.0km"
def test_interleaved_requests_keep_their_own_values(self, path_command):
a, b = self._msg("a"), self._msg("b")
path_command._store_path_distance(10.0, a)
path_command._store_path_distance(20.0, b)
assert path_command._format_path_distance(a) == "10.0km"
assert path_command._format_path_distance(b) == "20.0km"
def test_sender_location_uses_the_request_not_shared_state(self, path_command):
"""_current_message is shared; a concurrent command can replace it."""
a, b = self._msg("a"), self._msg("b")
a.sender_pubkey = "aa" * 32
b.sender_pubkey = "bb" * 32
path_command._current_message = b # as if another request overwrote it
seen = {}
path_command.bot.db_manager.execute_query = lambda q, params: seen.setdefault('key', params[0]) and []
path_command._get_sender_location(a)
assert seen['key'] == a.sender_pubkey