This commit is contained in:
Jack Kingsman
2026-03-07 15:05:13 -08:00
parent f302cc04ae
commit 5f039b9c41
25 changed files with 583 additions and 98 deletions
+2 -2
View File
@@ -365,7 +365,7 @@ class TestCalculatePacketHash:
expected = hashlib.sha256(bytes([2]) + payload).hexdigest()[:16].upper()
assert result == expected
def test_multi_byte_path_uses_hop_count_for_trace_hash(self):
def test_multi_byte_path_uses_packed_path_byte_for_trace_hash(self):
import hashlib
payload = b"\x99\x88"
@@ -373,7 +373,7 @@ class TestCalculatePacketHash:
result = _calculate_packet_hash(raw)
expected = (
hashlib.sha256(bytes([9]) + (2).to_bytes(2, byteorder="little") + payload)
hashlib.sha256(bytes([9]) + (0x42).to_bytes(2, byteorder="little") + payload)
.hexdigest()[:16]
.upper()
)
+15
View File
@@ -107,6 +107,21 @@ class TestPacketParsing:
assert extract_payload(packet) == b"payload_data"
def test_parse_packet_with_three_byte_hops(self):
"""Packets support three-byte hop identifiers as well as one/two-byte hops."""
packet = bytes([0x0A, 0x82, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06]) + b"msg"
result = parse_packet(packet)
assert result is not None
assert result.route_type == RouteType.DIRECT
assert result.payload_type == PayloadType.TEXT_MESSAGE
assert result.path_length == 2
assert result.path_hash_size == 3
assert result.path_byte_length == 6
assert result.path == bytes([0x01, 0x02, 0x03, 0x04, 0x05, 0x06])
assert result.payload == b"msg"
def test_parse_transport_flood_skips_transport_code(self):
"""TRANSPORT_FLOOD packets have 4-byte transport code to skip."""
# Header: route_type=TRANSPORT_FLOOD(0), payload_type=GROUP_TEXT(5)
+77 -26
View File
@@ -114,8 +114,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 38 # All migrations run
assert await get_version(conn) == 38
assert applied == 39 # All migrations run
assert await get_version(conn) == 39
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -197,9 +197,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
assert applied1 == 38 # All migrations run
assert applied1 == 39 # All migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 38
assert await get_version(conn) == 39
finally:
await conn.close()
@@ -260,8 +260,8 @@ class TestMigration001:
applied = await run_migrations(conn)
# All migrations applied (version incremented) but no error
assert applied == 38
assert await get_version(conn) == 38
assert applied == 39
assert await get_version(conn) == 39
finally:
await conn.close()
@@ -388,10 +388,10 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14-38 which also run)
# Run migration 13 (plus 14-39 which also run)
applied = await run_migrations(conn)
assert applied == 26
assert await get_version(conn) == 38
assert applied == 27
assert await get_version(conn) == 39
# Bots were migrated from app_settings to fanout_configs (migration 37)
# and the bots column was dropped (migration 38)
@@ -509,7 +509,7 @@ class TestMigration018:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 38
assert await get_version(conn) == 39
# Verify autoindex is gone
cursor = await conn.execute(
@@ -587,8 +587,8 @@ class TestMigration018:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 21 # Migrations 18-38 run (18+19 skip internally)
assert await get_version(conn) == 38
assert applied == 22 # Migrations 18-39 run (18+19 skip internally)
assert await get_version(conn) == 39
finally:
await conn.close()
@@ -660,7 +660,7 @@ class TestMigration019:
assert await cursor.fetchone() is not None
await run_migrations(conn)
assert await get_version(conn) == 38
assert await get_version(conn) == 39
# Verify autoindex is gone
cursor = await conn.execute(
@@ -726,8 +726,8 @@ class TestMigration020:
assert (await cursor.fetchone())[0] == "delete"
applied = await run_migrations(conn)
assert applied == 19 # Migrations 20-38
assert await get_version(conn) == 38
assert applied == 20 # Migrations 20-39
assert await get_version(conn) == 39
# Verify WAL mode
cursor = await conn.execute("PRAGMA journal_mode")
@@ -757,7 +757,7 @@ class TestMigration020:
await set_version(conn, 20)
applied = await run_migrations(conn)
assert applied == 18 # Migrations 21-38 still run
assert applied == 19 # Migrations 21-39 still run
# Still WAL + INCREMENTAL
cursor = await conn.execute("PRAGMA journal_mode")
@@ -815,8 +815,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 38
assert applied == 12
assert await get_version(conn) == 39
# Verify payload_hash column is now BLOB
cursor = await conn.execute("PRAGMA table_info(raw_packets)")
@@ -885,8 +885,8 @@ class TestMigration028:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 11 # Version still bumped
assert await get_version(conn) == 38
assert applied == 12 # Version still bumped
assert await get_version(conn) == 39
# Verify data unchanged
cursor = await conn.execute("SELECT payload_hash FROM raw_packets")
@@ -935,8 +935,8 @@ class TestMigration032:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 7
assert await get_version(conn) == 38
assert applied == 8
assert await get_version(conn) == 39
# Community MQTT columns were added by migration 32 and dropped by migration 38.
# Verify community settings were NOT migrated (no community config existed).
@@ -1002,8 +1002,8 @@ class TestMigration034:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 38
assert applied == 6
assert await get_version(conn) == 39
# Verify column exists with correct default
cursor = await conn.execute("SELECT flood_scope FROM app_settings WHERE id = 1")
@@ -1045,8 +1045,8 @@ class TestMigration033:
await conn.commit()
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 38
assert applied == 7
assert await get_version(conn) == 39
cursor = await conn.execute(
"SELECT key, name, is_hashtag, on_radio FROM channels WHERE key = ?",
@@ -1102,3 +1102,54 @@ class TestMigration033:
assert row["on_radio"] == 1 # Not overwritten
finally:
await conn.close()
class TestMigration039:
"""Test migration 039: add contacts.out_path_hash_mode."""
@pytest.mark.asyncio
async def test_migration_adds_out_path_hash_mode_and_backfills(self):
conn = await aiosqlite.connect(":memory:")
conn.row_factory = aiosqlite.Row
try:
await set_version(conn, 38)
await conn.execute("""
CREATE TABLE contacts (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
)
""")
await conn.execute(
"""
INSERT INTO contacts (
public_key, last_path, last_path_len, on_radio
) VALUES (?, ?, ?, ?)
""",
("aa" * 32, "11223344", 2, 1),
)
await conn.commit()
applied = await run_migrations(conn)
assert applied == 1
assert await get_version(conn) == 39
cursor = await conn.execute(
"SELECT out_path_hash_mode FROM contacts WHERE public_key = ?",
("aa" * 32,),
)
row = await cursor.fetchone()
assert row["out_path_hash_mode"] == 1
finally:
await conn.close()
+36
View File
@@ -6,6 +6,7 @@ import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshcore import EventType
class TestRadioManagerConnect:
@@ -688,3 +689,38 @@ class TestPostConnectSetupOrdering:
await rm.post_connect_setup()
mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
@pytest.mark.asyncio
async def test_path_hash_mode_cached_during_setup(self):
"""post_connect_setup caches path hash mode from device info."""
from app.models import AppSettings
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
mock_mc.commands.set_flood_scope = AsyncMock()
mock_mc.commands.send_device_query = AsyncMock(
return_value=MagicMock(type=EventType.DEVICE_INFO, payload={"path_hash_mode": 2})
)
rm._meshcore = mock_mc
with (
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
patch(
"app.repository.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=AppSettings(),
),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
patch("app.radio_sync.start_message_polling"),
):
await rm.post_connect_setup()
assert rm.path_hash_mode_info == (2, True)
+10 -4
View File
@@ -45,9 +45,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
prev_path_hash_mode = radio_manager._path_hash_mode
prev_path_hash_mode_supported = radio_manager._path_hash_mode_supported
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
radio_manager._path_hash_mode = prev_path_hash_mode
radio_manager._path_hash_mode_supported = prev_path_hash_mode_supported
def _mock_meshcore_with_info():
@@ -82,10 +86,10 @@ class TestGetRadioConfig:
@pytest.mark.asyncio
async def test_maps_self_info_to_response(self):
mc = _mock_meshcore_with_info()
radio_manager.set_path_hash_mode_info(1, True)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
response = await get_radio_config()
@@ -97,6 +101,7 @@ class TestGetRadioConfig:
assert response.radio.cr == 5
assert response.path_hash_mode == 1
assert response.path_hash_mode_supported is True
mc.commands.send_device_query.assert_not_awaited()
@pytest.mark.asyncio
async def test_returns_503_when_self_info_missing(self):
@@ -105,7 +110,6 @@ class TestGetRadioConfig:
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
with pytest.raises(HTTPException) as exc:
await get_radio_config()
@@ -115,17 +119,17 @@ class TestGetRadioConfig:
@pytest.mark.asyncio
async def test_marks_path_hash_mode_unsupported_when_device_info_lacks_field(self):
mc = _mock_meshcore_with_info()
mc.commands.send_device_query = AsyncMock(return_value=_radio_result(payload={}))
radio_manager.set_path_hash_mode_info(0, False)
with (
patch("app.routers.radio.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
patch.object(radio_manager, "radio_operation", _noop_radio_operation(mc)),
):
response = await get_radio_config()
assert response.path_hash_mode == 0
assert response.path_hash_mode_supported is False
mc.commands.send_device_query.assert_not_awaited()
class TestUpdateRadioConfig:
@@ -167,6 +171,7 @@ class TestUpdateRadioConfig:
async def test_updates_path_hash_mode_via_raw_command_fallback(self):
mc = _mock_meshcore_with_info()
mc.commands.set_path_hash_mode = None
radio_manager.set_path_hash_mode_info(1, True)
expected = RadioConfigResponse(
public_key="aa" * 32,
name="NodeA",
@@ -197,6 +202,7 @@ class TestUpdateRadioConfig:
async def test_rejects_path_hash_mode_update_when_radio_does_not_expose_it(self):
mc = _mock_meshcore_with_info()
mc.commands.send_device_query = AsyncMock(return_value=_radio_result(payload={}))
radio_manager.set_path_hash_mode_info(0, False)
with (
patch("app.routers.radio.require_connected", return_value=mc),
+35
View File
@@ -30,9 +30,13 @@ def _reset_radio_state():
"""Save/restore radio_manager state so tests don't leak."""
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
prev_path_hash_mode = radio_manager._path_hash_mode
prev_path_hash_mode_supported = radio_manager._path_hash_mode_supported
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
radio_manager._path_hash_mode = prev_path_hash_mode
radio_manager._path_hash_mode_supported = prev_path_hash_mode_supported
def _make_radio_result(payload=None):
@@ -158,6 +162,37 @@ class TestOutgoingDMBroadcast:
assert add_contact_arg["out_path_len"] == 2
assert add_contact_arg["out_path_hash_mode"] == 1
@pytest.mark.asyncio
async def test_send_dm_uses_persisted_out_path_hash_mode_when_present(self, test_db):
mc = _make_mc()
pub_key = "ef" * 32
await ContactRepository.upsert(
{
"public_key": pub_key,
"name": "Carol",
"type": 0,
"flags": 0,
"last_path": "11223344",
"last_path_len": 2,
"out_path_hash_mode": 0,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
}
)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
await send_direct_message(SendDirectMessageRequest(destination=pub_key, text="hi"))
add_contact_arg = mc.commands.add_contact.await_args.args[0]
assert add_contact_arg["out_path_hash_mode"] == 0
class TestOutgoingChannelBroadcast:
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""