diff --git a/frontend/src/test/pathUtils.test.ts b/frontend/src/test/pathUtils.test.ts index 8ab0186..367fd27 100644 --- a/frontend/src/test/pathUtils.test.ts +++ b/frontend/src/test/pathUtils.test.ts @@ -69,6 +69,42 @@ describe('parsePathHops', () => { it('handles odd length by ignoring last character', () => { expect(parsePathHops('1A2B3')).toEqual(['1A', '2B']); }); + + it('parses 2-byte hops when hopCount is provided', () => { + // 8 hex chars / 2 hops = 4 chars per hop (2 bytes) + expect(parsePathHops('AABBCCDD', 2)).toEqual(['AABB', 'CCDD']); + }); + + it('parses 3-byte hops when hopCount is provided', () => { + // 12 hex chars / 2 hops = 6 chars per hop (3 bytes) + expect(parsePathHops('AABBCCDDEEFF', 2)).toEqual(['AABBCC', 'DDEEFF']); + }); + + it('parses single 2-byte hop', () => { + expect(parsePathHops('AABB', 1)).toEqual(['AABB']); + }); + + it('parses single 3-byte hop', () => { + expect(parsePathHops('AABBCC', 1)).toEqual(['AABBCC']); + }); + + it('falls back to 2-char chunks when hopCount does not divide evenly', () => { + // 6 hex chars / 2 hops = 3 chars per hop (odd, invalid) + expect(parsePathHops('1A2B3C', 2)).toEqual(['1A', '2B', '3C']); + }); + + it('falls back to 2-char chunks when hopCount is null', () => { + expect(parsePathHops('AABBCCDD', null)).toEqual(['AA', 'BB', 'CC', 'DD']); + }); + + it('falls back to 2-char chunks when hopCount is 0', () => { + expect(parsePathHops('AABB', 0)).toEqual(['AA', 'BB']); + }); + + it('handles 2-byte hops with many hops', () => { + // 3 hops × 4 chars = 12 hex chars + expect(parsePathHops('AABB11223344', 3)).toEqual(['AABB', '1122', '3344']); + }); }); describe('findContactsByPrefix', () => { @@ -496,6 +532,47 @@ describe('resolvePath', () => { expect(result.receiver.publicKey).toBeNull(); }); + + it('resolves 2-byte hop path using hopCount parameter', () => { + // Create repeaters whose public keys match 4-char prefixes + const repeater2byte1 = createContact({ + public_key: '1A2B' + 'A'.repeat(60), + name: 'Repeater2B1', + type: CONTACT_TYPE_REPEATER, + lat: 40.75, + lon: -74.0, + }); + const repeater2byte2 = createContact({ + public_key: '3C4D' + 'B'.repeat(60), + name: 'Repeater2B2', + type: CONTACT_TYPE_REPEATER, + lat: 40.8, + lon: -73.95, + }); + const contacts2byte = [repeater2byte1, repeater2byte2]; + + // Path "1A2B3C4D" with hopCount=2 → two 4-char hops: "1A2B", "3C4D" + const result = resolvePath('1A2B3C4D', sender, contacts2byte, config, 2); + + expect(result.hops).toHaveLength(2); + expect(result.hops[0].prefix).toBe('1A2B'); + expect(result.hops[0].matches).toHaveLength(1); + expect(result.hops[0].matches[0].name).toBe('Repeater2B1'); + expect(result.hops[1].prefix).toBe('3C4D'); + expect(result.hops[1].matches).toHaveLength(1); + expect(result.hops[1].matches[0].name).toBe('Repeater2B2'); + }); + + it('resolves same path differently without hopCount (legacy fallback)', () => { + // Without hopCount, "1A2B3C4D" → four 2-char hops: "1A", "2B", "3C", "4D" + const result = resolvePath('1A2B3C4D', sender, contacts, config); + + expect(result.hops).toHaveLength(4); + expect(result.hops[0].prefix).toBe('1A'); + expect(result.hops[1].prefix).toBe('2B'); + expect(result.hops[2].prefix).toBe('3C'); + expect(result.hops[3].prefix).toBe('4D'); + }); }); describe('formatDistance', () => { @@ -579,4 +656,36 @@ describe('formatHopCounts', () => { expect(result.allDirect).toBe(false); expect(result.hasMultiple).toBe(true); }); + + it('uses path_len metadata for 2-byte hops instead of hex length', () => { + // 8 hex chars with path_len=2 → 2 hops (not 4 as legacy would infer) + const result = formatHopCounts([{ path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }]); + expect(result.display).toBe('2'); + expect(result.allDirect).toBe(false); + }); + + it('uses path_len metadata for 3-byte hops', () => { + // 12 hex chars with path_len=2 → 2 hops (not 6 as legacy) + const result = formatHopCounts([ + { path: 'AABBCCDDEEFF', path_len: 2, received_at: 1700000000 }, + ]); + expect(result.display).toBe('2'); + }); + + it('falls back to legacy count when path_len is null', () => { + // 8 hex chars, no path_len → legacy: 8/2 = 4 hops + const result = formatHopCounts([{ path: 'AABBCCDD', received_at: 1700000000 }]); + expect(result.display).toBe('4'); + }); + + it('mixes paths with and without path_len metadata', () => { + const result = formatHopCounts([ + { path: 'AABBCCDD', path_len: 2, received_at: 1700000000 }, // 2 hops (2-byte) + { path: '1A2B', received_at: 1700000001 }, // 2 hops (legacy) + { path: '', received_at: 1700000002 }, // direct + ]); + expect(result.display).toBe('d/2/2'); + expect(result.allDirect).toBe(false); + expect(result.hasMultiple).toBe(true); + }); }); diff --git a/tests/test_community_mqtt.py b/tests/test_community_mqtt.py index fae00d0..b75dcdc 100644 --- a/tests/test_community_mqtt.py +++ b/tests/test_community_mqtt.py @@ -18,6 +18,7 @@ from app.fanout.community_mqtt import ( _build_radio_info, _build_status_topic, _calculate_packet_hash, + _decode_packet_fields, _ed25519_sign_expanded, _format_raw_packet, _generate_jwt_token, @@ -376,6 +377,156 @@ class TestCalculatePacketHash: raw = bytes([0x10, 0x01, 0x02]) assert _calculate_packet_hash(raw) == "0" * 16 + def test_multibyte_2byte_hops_skips_correct_path_length(self): + """Mode 1 (2-byte hops), 2 hops → 4 bytes of path data to skip.""" + import hashlib + + # FLOOD route, payload_type=2 (TXT_MSG): (2<<2)|1 = 0x09 + # path_byte = 0x42 → mode=1, hop_count=2 → path_wire_len = 2*2 = 4 + path_data = b"\xaa\xbb\xcc\xdd" + payload = b"\x48\x65\x6c\x6c\x6f" # "Hello" + raw = bytes([0x09, 0x42]) + path_data + payload + result = _calculate_packet_hash(raw) + + expected = hashlib.sha256(bytes([2]) + payload).hexdigest()[:16].upper() + assert result == expected + + def test_multibyte_3byte_hops_skips_correct_path_length(self): + """Mode 2 (3-byte hops), 1 hop → 3 bytes of path data to skip.""" + import hashlib + + # FLOOD route, payload_type=4 (ADVERT): (4<<2)|1 = 0x11 + # path_byte = 0x81 → mode=2, hop_count=1 → path_wire_len = 1*3 = 3 + path_data = b"\xaa\xbb\xcc" + payload = b"\xde\xad" + raw = bytes([0x11, 0x81]) + path_data + payload + result = _calculate_packet_hash(raw) + + expected = hashlib.sha256(bytes([4]) + payload).hexdigest()[:16].upper() + assert result == expected + + def test_trace_multibyte_uses_raw_wire_byte_in_hash(self): + """TRACE with multi-byte hops uses raw path_byte (not decoded hop count) in hash.""" + import hashlib + + # TRACE with FLOOD route: (9<<2)|1 = 0x25 + # path_byte = 0x42 → mode=1, hop_count=2 → path_wire_len = 4 + path_byte = 0x42 + path_data = b"\xaa\xbb\xcc\xdd" + payload = b"\x01\x02" + raw = bytes([0x25, path_byte]) + path_data + payload + result = _calculate_packet_hash(raw) + + # TRACE hash includes raw path_byte as uint16_t LE (0x42, 0x00) + expected = ( + hashlib.sha256(bytes([9]) + path_byte.to_bytes(2, byteorder="little") + payload) + .hexdigest()[:16] + .upper() + ) + assert result == expected + + def test_multibyte_truncated_path_returns_zeroes(self): + """Packet claiming multi-byte path but truncated before path ends → zero hash.""" + # FLOOD route, payload_type=2: (2<<2)|1 = 0x09 + # path_byte = 0x42 → mode=1, hop_count=2 → needs 4 bytes of path, only 2 present + raw = bytes([0x09, 0x42, 0xAA, 0xBB]) + assert _calculate_packet_hash(raw) == "0" * 16 + + def test_multibyte_transport_flood_with_2byte_hops(self): + """TRANSPORT_FLOOD with 2-byte hops correctly skips transport codes + path.""" + import hashlib + + # TRANSPORT_FLOOD (0x00), payload_type=4: (4<<2)|0 = 0x10 + transport_codes = b"\x01\x02\x03\x04" + # path_byte = 0x41 → mode=1, hop_count=1 → path_wire_len = 2 + path_data = b"\xaa\xbb" + payload = b"\xca\xfe" + raw = bytes([0x10]) + transport_codes + bytes([0x41]) + path_data + payload + result = _calculate_packet_hash(raw) + + expected = hashlib.sha256(bytes([4]) + payload).hexdigest()[:16].upper() + assert result == expected + + +class TestDecodePacketFieldsMultibyte: + """Test _decode_packet_fields with multi-byte hop paths.""" + + def test_1byte_hops_legacy(self): + """Legacy packet with mode=0, 3 hops → 3 path bytes.""" + # FLOOD route, payload_type=2 (TXT_MSG): (2<<2)|1 = 0x09 + path_data = b"\xaa\xbb\xcc" + payload = b"\x48\x65\x6c\x6c\x6f" + raw = bytes([0x09, 0x03]) + path_data + payload + + route, ptype, plen, path_values, payload_type = _decode_packet_fields(raw) + assert route == "F" + assert ptype == "2" + assert path_values == ["aa", "bb", "cc"] + assert payload_type == 2 + + def test_2byte_hops(self): + """Mode 1, 2 hops → 4 path bytes split into 2 two-byte identifiers.""" + # FLOOD route, payload_type=2: (2<<2)|1 = 0x09 + # path_byte = 0x42 → mode=1, hop_count=2 + path_data = b"\xaa\xbb\xcc\xdd" + payload = b"\x48\x65\x6c\x6c\x6f" + raw = bytes([0x09, 0x42]) + path_data + payload + + route, ptype, plen, path_values, payload_type = _decode_packet_fields(raw) + assert route == "F" + assert ptype == "2" + assert path_values == ["aabb", "ccdd"] + assert payload_type == 2 + assert plen == str(len(payload)) + + def test_3byte_hops(self): + """Mode 2, 1 hop → 3 path bytes as a single three-byte identifier.""" + # FLOOD route, payload_type=4 (ADVERT): (4<<2)|1 = 0x11 + # path_byte = 0x81 → mode=2, hop_count=1 + path_data = b"\xaa\xbb\xcc" + payload = b"\xde\xad" + raw = bytes([0x11, 0x81]) + path_data + payload + + route, ptype, plen, path_values, payload_type = _decode_packet_fields(raw) + assert route == "F" + assert ptype == "4" + assert path_values == ["aabbcc"] + assert payload_type == 4 + + def test_direct_packet_no_path(self): + """Direct packet (0 hops) → empty path list.""" + # FLOOD route, payload_type=2: (2<<2)|1 = 0x09 + # path_byte = 0x00 → mode=0, hop_count=0 + payload = b"\x48\x65\x6c\x6c\x6f" + raw = bytes([0x09, 0x00]) + payload + + route, ptype, plen, path_values, payload_type = _decode_packet_fields(raw) + assert path_values == [] + assert plen == str(len(payload)) + + def test_transport_flood_2byte_hops(self): + """TRANSPORT_FLOOD with 2-byte hops: skip 4 transport bytes, then decode path.""" + # TRANSPORT_FLOOD (0x00), payload_type=2: (2<<2)|0 = 0x08 + transport_codes = b"\x01\x02\x03\x04" + # path_byte = 0x42 → mode=1, hop_count=2 + path_data = b"\xaa\xbb\xcc\xdd" + payload = b"\xca\xfe" + raw = bytes([0x08]) + transport_codes + bytes([0x42]) + path_data + payload + + route, ptype, plen, path_values, payload_type = _decode_packet_fields(raw) + assert route == "F" + assert path_values == ["aabb", "ccdd"] + + def test_truncated_multibyte_path_returns_defaults(self): + """Packet claiming 2-byte hops but truncated → defaults.""" + # FLOOD route, payload_type=2: (2<<2)|1 = 0x09 + # path_byte = 0x42 → mode=1, hop_count=2 → needs 4 bytes, only 1 + raw = bytes([0x09, 0x42, 0xAA]) + + route, ptype, plen, path_values, payload_type = _decode_packet_fields(raw) + assert path_values == [] + assert plen == "0" + class TestCommunityMqttPublisher: def test_initial_state(self): diff --git a/tests/test_fanout.py b/tests/test_fanout.py index 1d33cab..9f07ffe 100644 --- a/tests/test_fanout.py +++ b/tests/test_fanout.py @@ -661,6 +661,75 @@ class TestAppriseFormatBody: ) assert "`direct`" in body + def test_dm_with_2byte_hop_path(self): + """Multi-byte (2-byte) hops are correctly split using path_len metadata.""" + from app.fanout.apprise_mod import _format_body + + body = _format_body( + { + "type": "PRIV", + "text": "hi", + "sender_name": "Alice", + "paths": [{"path": "aabbccdd", "path_len": 2}], + }, + include_path=True, + ) + assert "**via:**" in body + assert "`aabb`" in body + assert "`ccdd`" in body + + def test_dm_with_3byte_hop_path(self): + """Multi-byte (3-byte) hops are correctly split using path_len metadata.""" + from app.fanout.apprise_mod import _format_body + + body = _format_body( + { + "type": "PRIV", + "text": "hi", + "sender_name": "Alice", + "paths": [{"path": "aabbccddeeff", "path_len": 2}], + }, + include_path=True, + ) + assert "**via:**" in body + assert "`aabbcc`" in body + assert "`ddeeff`" in body + + def test_channel_with_multibyte_path(self): + """Channel message with 2-byte hop path_len metadata.""" + from app.fanout.apprise_mod import _format_body + + body = _format_body( + { + "type": "CHAN", + "text": "hi", + "sender_name": "Bob", + "channel_name": "#general", + "paths": [{"path": "aabbccdd", "path_len": 2}], + }, + include_path=True, + ) + assert "**#general:**" in body + assert "`aabb`" in body + assert "`ccdd`" in body + + def test_legacy_path_without_path_len(self): + """Legacy path (no path_len) falls back to 2-char chunks.""" + from app.fanout.apprise_mod import _format_body + + body = _format_body( + { + "type": "PRIV", + "text": "hi", + "sender_name": "Alice", + "paths": [{"path": "aabb"}], + }, + include_path=True, + ) + assert "**via:**" in body + assert "`aa`" in body + assert "`bb`" in body + class TestAppriseNormalizeDiscordUrl: def test_discord_scheme(self): diff --git a/tests/test_path_utils.py b/tests/test_path_utils.py index 93a0e68..7c8f755 100644 --- a/tests/test_path_utils.py +++ b/tests/test_path_utils.py @@ -143,3 +143,42 @@ class TestInferHashSize: 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.""" + + def test_1byte_hops(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) + d = c.to_radio_dict() + assert d["out_path_hash_mode"] == 0 + + def test_empty_path_defaults_to_mode0(self): + from app.models import Contact + + c = Contact(public_key="ee" * 32, last_path="", last_path_len=0) + d = c.to_radio_dict() + assert d["out_path_hash_mode"] == 0