mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 16:53:38 +02:00
Actually persist out_path_hash_mode instead of lossily deriving it
This commit is contained in:
@@ -651,6 +651,7 @@ class TestResetPath:
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact.last_path == ""
|
||||
assert contact.last_path_len == -1
|
||||
assert contact.out_path_hash_mode == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_not_found(self, test_db, client):
|
||||
@@ -661,7 +662,13 @@ class TestResetPath:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_pushes_to_radio(self, test_db, client):
|
||||
"""When radio connected and contact on_radio, pushes updated path."""
|
||||
await _insert_contact(KEY_A, on_radio=True, last_path="1122", last_path_len=1)
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
on_radio=True,
|
||||
last_path="1122",
|
||||
last_path_len=1,
|
||||
out_path_hash_mode=0,
|
||||
)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_result = MagicMock()
|
||||
@@ -678,6 +685,10 @@ class TestResetPath:
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_mc.commands.add_contact.assert_called_once()
|
||||
contact_payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert contact_payload["out_path"] == ""
|
||||
assert contact_payload["out_path_len"] == -1
|
||||
assert contact_payload["out_path_hash_mode"] == -1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_path_broadcasts_websocket_event(self, test_db, client):
|
||||
@@ -726,6 +737,34 @@ class TestAddRemoveRadio:
|
||||
contact = await ContactRepository.get_by_key(KEY_A)
|
||||
assert contact.on_radio is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_radio_preserves_stored_out_path_hash_mode(self, test_db, client):
|
||||
await _insert_contact(
|
||||
KEY_A,
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_result = MagicMock()
|
||||
mock_result.type = EventType.OK
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
with patch("app.dependencies.radio_manager") as mock_dep_rm:
|
||||
mock_dep_rm.is_connected = True
|
||||
mock_dep_rm.meshcore = mock_mc
|
||||
|
||||
response = await client.post(f"/api/contacts/{KEY_A}/add-to-radio")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert payload["out_path"] == "aa00bb00"
|
||||
assert payload["out_path_len"] == 2
|
||||
assert payload["out_path_hash_mode"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_already_on_radio(self, test_db, client):
|
||||
"""Adding a contact already on radio returns ok without calling add_contact."""
|
||||
|
||||
@@ -608,6 +608,37 @@ class TestOnPathUpdate:
|
||||
assert contact is not None
|
||||
assert contact.last_path == "0102"
|
||||
assert contact.last_path_len == 2
|
||||
assert contact.out_path_hash_mode == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updates_path_hash_mode_when_present(self, test_db):
|
||||
"""PATH_UPDATE persists explicit multibyte path hash mode."""
|
||||
from app.event_handlers import on_path_update
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": "ab" * 32,
|
||||
"name": "Alice",
|
||||
"type": 1,
|
||||
"flags": 0,
|
||||
}
|
||||
)
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"public_key": "ab" * 32,
|
||||
"path": "aa00bb00",
|
||||
"path_len": 2,
|
||||
"path_hash_mode": 1,
|
||||
}
|
||||
|
||||
await on_path_update(MockEvent())
|
||||
|
||||
contact = await ContactRepository.get_by_key("ab" * 32)
|
||||
assert contact is not None
|
||||
assert contact.last_path == "aa00bb00"
|
||||
assert contact.last_path_len == 2
|
||||
assert contact.out_path_hash_mode == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_nothing_when_contact_not_found(self, test_db):
|
||||
|
||||
@@ -1064,3 +1064,144 @@ class TestMigration033:
|
||||
assert row["on_radio"] == 1 # Not overwritten
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
class TestMigration039:
|
||||
"""Test migration 039: persist contacts.out_path_hash_mode."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adds_column_and_backfills_legacy_rows(self):
|
||||
"""Pre-039 contacts get flood=-1 and legacy routed paths=0."""
|
||||
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
|
||||
)
|
||||
""")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (
|
||||
public_key, name, last_path, last_path_len, first_seen
|
||||
) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"aa" * 32,
|
||||
"Flood",
|
||||
"",
|
||||
-1,
|
||||
1000,
|
||||
"bb" * 32,
|
||||
"LegacyPath",
|
||||
"1122",
|
||||
1,
|
||||
1001,
|
||||
),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 39
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_path_len, out_path_hash_mode
|
||||
FROM contacts
|
||||
ORDER BY public_key
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
assert rows[0]["public_key"] == "aa" * 32
|
||||
assert rows[0]["last_path_len"] == -1
|
||||
assert rows[0]["out_path_hash_mode"] == -1
|
||||
assert rows[1]["public_key"] == "bb" * 32
|
||||
assert rows[1]["last_path_len"] == 1
|
||||
assert rows[1]["out_path_hash_mode"] == 0
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_valid_modes_are_preserved_when_column_already_exists(self):
|
||||
"""Migration does not clobber post-upgrade multibyte rows."""
|
||||
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,
|
||||
out_path_hash_mode INTEGER NOT NULL DEFAULT 0,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
last_seen INTEGER,
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER
|
||||
)
|
||||
""")
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (
|
||||
public_key, name, last_path, last_path_len, out_path_hash_mode, first_seen
|
||||
) VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"cc" * 32,
|
||||
"Multi",
|
||||
"aa00bb00",
|
||||
2,
|
||||
1,
|
||||
1000,
|
||||
"dd" * 32,
|
||||
"Flood",
|
||||
"",
|
||||
-1,
|
||||
0,
|
||||
1001,
|
||||
),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 1
|
||||
assert await get_version(conn) == 39
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
SELECT public_key, out_path_hash_mode
|
||||
FROM contacts
|
||||
WHERE public_key IN (?, ?)
|
||||
ORDER BY public_key
|
||||
""",
|
||||
("cc" * 32, "dd" * 32),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
assert rows[0]["public_key"] == "cc" * 32
|
||||
assert rows[0]["out_path_hash_mode"] == 1
|
||||
assert rows[1]["public_key"] == "dd" * 32
|
||||
assert rows[1]["out_path_hash_mode"] == -1
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
+83
-48
@@ -5,7 +5,6 @@ import pytest
|
||||
from app.path_utils import (
|
||||
decode_path_byte,
|
||||
first_hop_hex,
|
||||
infer_hash_size,
|
||||
path_wire_len,
|
||||
split_path_hex,
|
||||
)
|
||||
@@ -125,60 +124,96 @@ class TestFirstHopHex:
|
||||
assert first_hop_hex("", 0) is None
|
||||
|
||||
|
||||
class TestInferHashSize:
|
||||
def test_one_byte(self):
|
||||
assert infer_hash_size("1a2b3c", 3) == 1
|
||||
|
||||
def test_two_byte(self):
|
||||
assert infer_hash_size("1a2b3c4d", 2) == 2
|
||||
|
||||
def test_three_byte(self):
|
||||
assert infer_hash_size("1a2b3c4d5e6f", 2) == 3
|
||||
|
||||
def test_empty_defaults_to_1(self):
|
||||
assert infer_hash_size("", 0) == 1
|
||||
|
||||
def test_inconsistent_defaults_to_1(self):
|
||||
assert infer_hash_size("1a2b3", 2) == 1
|
||||
|
||||
def test_zero_hop_count_defaults_to_1(self):
|
||||
assert infer_hash_size("1a2b", 0) == 1
|
||||
|
||||
|
||||
class TestContactToRadioDictHashMode:
|
||||
"""Test that Contact.to_radio_dict() correctly derives out_path_hash_mode."""
|
||||
"""Test that Contact.to_radio_dict() preserves the stored out_path_hash_mode."""
|
||||
|
||||
def test_1byte_hops(self):
|
||||
def test_preserves_1byte_mode(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(public_key="aa" * 32, last_path="1a2b3c", last_path_len=3)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 0 # infer_hash_size=1, mode=0
|
||||
|
||||
def test_2byte_hops(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(public_key="bb" * 32, last_path="1a2b3c4d", last_path_len=2)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 1 # infer_hash_size=2, mode=1
|
||||
|
||||
def test_3byte_hops(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(public_key="cc" * 32, last_path="1a2b3c4d5e6f", last_path_len=2)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 2 # infer_hash_size=3, mode=2
|
||||
|
||||
def test_no_path_defaults_to_mode0(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(public_key="dd" * 32, last_path=None, last_path_len=-1)
|
||||
c = Contact(
|
||||
public_key="aa" * 32,
|
||||
last_path="1a2b3c",
|
||||
last_path_len=3,
|
||||
out_path_hash_mode=0,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 0
|
||||
|
||||
def test_empty_path_defaults_to_mode0(self):
|
||||
def test_preserves_2byte_mode(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(public_key="ee" * 32, last_path="", last_path_len=0)
|
||||
c = Contact(
|
||||
public_key="bb" * 32,
|
||||
last_path="1a2b3c4d",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 0
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
|
||||
def test_preserves_3byte_mode(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(
|
||||
public_key="cc" * 32,
|
||||
last_path="1a2b3c4d5e6f",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=2,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 2
|
||||
|
||||
def test_preserves_flood_mode(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(
|
||||
public_key="dd" * 32,
|
||||
last_path=None,
|
||||
last_path_len=-1,
|
||||
out_path_hash_mode=-1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == -1
|
||||
|
||||
def test_preserves_mode_with_zero_bytes_in_path(self):
|
||||
from app.models import Contact
|
||||
|
||||
c = Contact(
|
||||
public_key="ee" * 32,
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
)
|
||||
d = c.to_radio_dict()
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
|
||||
|
||||
class TestContactFromRadioDictHashMode:
|
||||
"""Test that Contact.from_radio_dict() preserves explicit path hash mode."""
|
||||
|
||||
def test_preserves_mode_from_radio_payload(self):
|
||||
from app.models import Contact
|
||||
|
||||
d = Contact.from_radio_dict(
|
||||
"aa" * 32,
|
||||
{
|
||||
"adv_name": "Alice",
|
||||
"out_path": "aa00bb00",
|
||||
"out_path_len": 2,
|
||||
"out_path_hash_mode": 1,
|
||||
},
|
||||
)
|
||||
assert d["out_path_hash_mode"] == 1
|
||||
|
||||
def test_flood_falls_back_to_minus_one(self):
|
||||
from app.models import Contact
|
||||
|
||||
d = Contact.from_radio_dict(
|
||||
"bb" * 32,
|
||||
{
|
||||
"adv_name": "Bob",
|
||||
"out_path": "",
|
||||
"out_path_len": -1,
|
||||
},
|
||||
)
|
||||
assert d["out_path_hash_mode"] == -1
|
||||
|
||||
+44
-17
@@ -56,24 +56,24 @@ def _make_mc(name="TestNode"):
|
||||
return mc
|
||||
|
||||
|
||||
async def _insert_contact(public_key, name="Alice"):
|
||||
async def _insert_contact(public_key, name="Alice", **overrides):
|
||||
"""Insert a contact into the test database."""
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": public_key,
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
}
|
||||
)
|
||||
data = {
|
||||
"public_key": public_key,
|
||||
"name": name,
|
||||
"type": 0,
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
}
|
||||
data.update(overrides)
|
||||
await ContactRepository.upsert(data)
|
||||
|
||||
|
||||
class TestOutgoingDMBroadcast:
|
||||
@@ -125,6 +125,33 @@ class TestOutgoingDMBroadcast:
|
||||
assert exc_info.value.status_code == 409
|
||||
assert "ambiguous" in exc_info.value.detail.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_preserves_stored_out_path_hash_mode(self, test_db):
|
||||
"""Direct-message send pushes the persisted path hash mode back to the radio."""
|
||||
mc = _make_mc()
|
||||
pub_key = "cd" * 32
|
||||
await _insert_contact(
|
||||
pub_key,
|
||||
"Alice",
|
||||
last_path="aa00bb00",
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
request = SendDirectMessageRequest(destination=pub_key, text="Hello")
|
||||
await send_direct_message(request)
|
||||
|
||||
contact_payload = mc.commands.add_contact.call_args.args[0]
|
||||
assert contact_payload["public_key"] == pub_key
|
||||
assert contact_payload["out_path"] == "aa00bb00"
|
||||
assert contact_payload["out_path_len"] == 2
|
||||
assert contact_payload["out_path_hash_mode"] == 1
|
||||
|
||||
|
||||
class TestOutgoingChannelBroadcast:
|
||||
"""Test that outgoing channel messages are broadcast via broadcast_event for fanout dispatch."""
|
||||
|
||||
Reference in New Issue
Block a user