mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 17:53:10 +02:00
Add room server
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user