Add room server

This commit is contained in:
Jack Kingsman
2026-03-19 19:22:40 -07:00
parent dbe2915635
commit 5b166c4b66
27 changed files with 1703 additions and 346 deletions
+27
View File
@@ -898,6 +898,33 @@ class TestDirectMessageDecryption:
assert result is None
def test_decrypt_signed_room_post_extracts_author_prefix(self):
"""TXT_TYPE_SIGNED_PLAIN room posts expose the 4-byte author prefix separately."""
shared_secret = bytes(range(32))
timestamp = 1_700_000_000
flags = (2 << 2) | 1
author_prefix = bytes.fromhex("aabbccdd")
plaintext = (
timestamp.to_bytes(4, "little")
+ bytes([flags])
+ author_prefix
+ b"hello room"
+ b"\x00"
)
padded = plaintext + (b"\x00" * ((16 - (len(plaintext) % 16)) % 16))
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
ciphertext = cipher.encrypt(padded)
mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
payload = bytes.fromhex("1020") + mac + ciphertext
result = decrypt_direct_message(payload, shared_secret)
assert result is not None
assert result.txt_type == 2
assert result.attempt == 1
assert result.signed_sender_prefix == "aabbccdd"
assert result.message == "hello room"
class TestTryDecryptDM:
"""Test full packet decryption for direct messages."""
+63
View File
@@ -429,6 +429,69 @@ class TestContactMessageCLIFiltering:
assert event_type == "message_acked"
assert set(payload.keys()) == EXPECTED_ACK_KEYS
@pytest.mark.asyncio
async def test_room_server_message_uses_author_prefix_for_sender_metadata(self, test_db):
from app.event_handlers import on_contact_message
room_key = "ab" * 32
author_key = "12345678" + ("cd" * 28)
await ContactRepository.upsert(
{
"public_key": room_key,
"name": "Ops Board",
"type": 3,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
await ContactRepository.upsert(
{
"public_key": author_key,
"name": "Alice",
"type": 1,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
class MockEvent:
payload = {
"pubkey_prefix": room_key[:12],
"text": "hello room",
"txt_type": 2,
"signature": author_key[:8],
"sender_timestamp": 1700000000,
}
await on_contact_message(MockEvent())
event_type, payload = mock_broadcast.call_args_list[-1][0]
assert event_type == "message"
assert payload["conversation_key"] == room_key
assert payload["sender_name"] == "Alice"
assert payload["sender_key"] == author_key
assert payload["signature"] == author_key[:8]
@pytest.mark.asyncio
async def test_missing_txt_type_defaults_to_normal(self, test_db):
"""Messages without txt_type field are treated as normal (not filtered)."""
+50
View File
@@ -1388,6 +1388,56 @@ class TestRepeaterMessageFiltering:
)
assert len(messages) == 0
@pytest.mark.asyncio
async def test_room_cli_response_not_stored(self, test_db, captured_broadcasts):
"""Room-server CLI responses should not be stored in chat history."""
from app.decoder import DecryptedDirectMessage
from app.models import CONTACT_TYPE_ROOM
from app.packet_processor import create_dm_message_from_decrypted
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
room_pub = "c3d4e5f6cb0a6fb9816ca956ff22dd7f12e2e5adbbf5e233bd8232774d6cffee"
await ContactRepository.upsert(
{
"public_key": room_pub,
"name": "Test Room",
"type": CONTACT_TYPE_ROOM,
"flags": 0,
"on_radio": False,
}
)
packet_id, _ = await RawPacketRepository.create(b"\x09\x00test", 1700000000)
decrypted = DecryptedDirectMessage(
timestamp=1700000000,
flags=(1 << 2),
message="> status ok",
dest_hash="fa",
src_hash="c3",
)
broadcasts, mock_broadcast = captured_broadcasts
with patch("app.packet_processor.broadcast_event", mock_broadcast):
msg_id = await create_dm_message_from_decrypted(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=room_pub,
our_public_key=self.OUR_PUB,
received_at=1700000001,
outgoing=False,
)
assert msg_id is None
assert len(broadcasts) == 0
messages = await MessageRepository.get_all(
msg_type="PRIV", conversation_key=room_pub.lower(), limit=10
)
assert len(messages) == 0
@pytest.mark.asyncio
async def test_client_message_still_stored(self, test_db, captured_broadcasts):
"""Messages from normal clients should still be stored."""
+187
View File
@@ -0,0 +1,187 @@
"""Tests for room-server contact routes."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from meshcore import EventType
from app.models import CommandRequest, RepeaterLoginRequest
from app.radio import radio_manager
from app.repository import ContactRepository
from app.routers.repeaters import send_repeater_command
from app.routers.rooms import room_acl, room_login, room_status
ROOM_KEY = "cc" * 32
AUTHOR_KEY = "12345678" + ("dd" * 28)
@pytest.fixture(autouse=True)
def _reset_radio_state():
prev = radio_manager._meshcore
prev_lock = radio_manager._operation_lock
yield
radio_manager._meshcore = prev
radio_manager._operation_lock = prev_lock
def _radio_result(event_type=EventType.OK, payload=None):
result = MagicMock()
result.type = event_type
result.payload = payload or {}
return result
def _mock_mc():
mc = MagicMock()
mc.commands = MagicMock()
mc.commands.send_login = AsyncMock(return_value=_radio_result(EventType.MSG_SENT))
mc.commands.req_status_sync = AsyncMock()
mc.commands.req_acl_sync = AsyncMock()
mc.commands.send_cmd = AsyncMock(return_value=_radio_result(EventType.OK))
mc.commands.get_msg = AsyncMock()
mc.commands.add_contact = AsyncMock(return_value=_radio_result(EventType.OK))
mc.subscribe = MagicMock(return_value=MagicMock(unsubscribe=MagicMock()))
mc.stop_auto_message_fetching = AsyncMock()
mc.start_auto_message_fetching = AsyncMock()
return mc
async def _insert_contact(public_key: str, name: str, contact_type: int):
await ContactRepository.upsert(
{
"public_key": public_key,
"name": name,
"type": contact_type,
"flags": 0,
"direct_path": None,
"direct_path_len": -1,
"direct_path_hash_mode": -1,
"last_advert": None,
"lat": None,
"lon": None,
"last_seen": None,
"on_radio": False,
"last_contacted": None,
"first_seen": None,
}
)
class TestRoomLogin:
@pytest.mark.asyncio
async def test_room_login_success(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
subscriptions: dict[EventType, tuple[object, object]] = {}
def _subscribe(event_type, callback, attribute_filters=None):
subscriptions[event_type] = (callback, attribute_filters)
return MagicMock(unsubscribe=MagicMock())
async def _send_login(*args, **kwargs):
callback, _filters = subscriptions[EventType.LOGIN_SUCCESS]
callback(_radio_result(EventType.LOGIN_SUCCESS, {"pubkey_prefix": ROOM_KEY[:12]}))
return _radio_result(EventType.MSG_SENT)
mc.subscribe = MagicMock(side_effect=_subscribe)
mc.commands.send_login = AsyncMock(side_effect=_send_login)
with (
patch("app.routers.rooms.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await room_login(ROOM_KEY, RepeaterLoginRequest(password="hello"))
assert response.status == "ok"
assert response.authenticated is True
@pytest.mark.asyncio
async def test_room_login_rejects_non_room(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Client", contact_type=1)
with (
patch("app.routers.rooms.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
with pytest.raises(HTTPException) as exc:
await room_login(ROOM_KEY, RepeaterLoginRequest(password="hello"))
assert exc.value.status_code == 400
class TestRoomStatus:
@pytest.mark.asyncio
async def test_room_status_maps_fields(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
mc.commands.req_status_sync = AsyncMock(
return_value={
"bat": 4025,
"tx_queue_len": 1,
"noise_floor": -118,
"last_rssi": -82,
"last_snr": 6.0,
"nb_recv": 80,
"nb_sent": 40,
"airtime": 120,
"rx_airtime": 240,
"uptime": 600,
"sent_flood": 5,
"sent_direct": 35,
"recv_flood": 7,
"recv_direct": 73,
"flood_dups": 2,
"direct_dups": 1,
"full_evts": 0,
}
)
with (
patch("app.routers.rooms.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await room_status(ROOM_KEY)
assert response.battery_volts == 4.025
assert response.packets_received == 80
assert response.recv_direct == 73
@pytest.mark.asyncio
async def test_room_acl_maps_entries(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
await _insert_contact(AUTHOR_KEY, name="Author", contact_type=1)
mc.commands.req_acl_sync = AsyncMock(return_value=[{"key": AUTHOR_KEY[:12], "perm": 3}])
with (
patch("app.routers.rooms.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await room_acl(ROOM_KEY)
assert len(response.acl) == 1
assert response.acl[0].name == "Author"
assert response.acl[0].permission_name == "Admin"
class TestRoomCommandReuse:
@pytest.mark.asyncio
async def test_generic_command_route_accepts_room_servers(self, test_db):
mc = _mock_mc()
await _insert_contact(ROOM_KEY, name="Room Server", contact_type=3)
mc.commands.get_msg = AsyncMock(
return_value=_radio_result(
EventType.CONTACT_MSG_RECV,
{"pubkey_prefix": ROOM_KEY[:12], "text": "> ok", "txt_type": 1},
)
)
with (
patch("app.routers.repeaters.require_connected", return_value=mc),
patch.object(radio_manager, "_meshcore", mc),
):
response = await send_repeater_command(ROOM_KEY, CommandRequest(command="ver"))
assert response.response == "ok"