mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Phase 4
This commit is contained in:
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user