Actually persist out_path_hash_mode instead of lossily deriving it

This commit is contained in:
Jack Kingsman
2026-03-07 22:14:22 -08:00
parent 69c812cfd4
commit 76d11b01a7
13 changed files with 459 additions and 88 deletions
+40 -1
View File
@@ -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."""
+31
View File
@@ -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):
+141
View File
@@ -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
View File
@@ -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
View File
@@ -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."""