mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-27 20:12:41 +02:00
754 lines
25 KiB
Python
754 lines
25 KiB
Python
import asyncio
|
|
import time
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from repeater.handler_helpers.room_server import (
|
|
MAX_POST_TEXT_LEN,
|
|
MAX_UNSYNCED_POSTS,
|
|
TXT_TYPE_PLAIN,
|
|
TXT_TYPE_SIGNED_PLAIN,
|
|
GlobalRateLimiter,
|
|
RoomServer,
|
|
_truncate_utf8,
|
|
)
|
|
|
|
|
|
class _FakeIdentity:
|
|
def __init__(self, pubkey: bytes):
|
|
self._pubkey = pubkey
|
|
|
|
def get_public_key(self):
|
|
return self._pubkey
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, pubkey: bytes, shared_secret=b"s" * 32, out_path=b"", out_path_len=-1):
|
|
self.id = _FakeIdentity(pubkey)
|
|
self.shared_secret = shared_secret
|
|
self.out_path = bytearray(out_path)
|
|
self.out_path_len = out_path_len
|
|
self.sync_since = 0
|
|
|
|
|
|
class _FakeACL:
|
|
def __init__(self, clients=None):
|
|
self._clients = list(clients or [])
|
|
self.remove_client = MagicMock(return_value=True)
|
|
|
|
def get_all_clients(self):
|
|
return list(self._clients)
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self):
|
|
self.insert_room_message = MagicMock(return_value=1)
|
|
self.upsert_client_sync = MagicMock()
|
|
self.get_client_sync = MagicMock(return_value=None)
|
|
self.get_unsynced_count = MagicMock(return_value=0)
|
|
self.get_all_room_clients = MagicMock(return_value=[])
|
|
self.get_unsynced_messages = MagicMock(return_value=[])
|
|
self.cleanup_old_messages = MagicMock(return_value=0)
|
|
|
|
|
|
def _make_room_server(db=None, acl=None, injector=None, max_posts=8):
|
|
return RoomServer(
|
|
room_hash=0x34,
|
|
room_name="room-alpha",
|
|
local_identity=_FakeIdentity(b"R" * 32),
|
|
sqlite_handler=db or _FakeDB(),
|
|
packet_injector=injector or AsyncMock(return_value=True),
|
|
acl=acl or _FakeACL(),
|
|
max_posts=max_posts,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_add_post_stores_message_and_updates_sync_state():
|
|
db = _FakeDB()
|
|
rs = _make_room_server(db=db, acl=_FakeACL([_FakeClient(b"C" * 32)]))
|
|
|
|
ok = await rs.add_post(
|
|
client_pubkey=b"A" * 32,
|
|
message_text="hello room",
|
|
sender_timestamp=111,
|
|
txt_type=TXT_TYPE_PLAIN,
|
|
)
|
|
|
|
assert ok is True
|
|
db.insert_room_message.assert_called_once()
|
|
db.upsert_client_sync.assert_called_once()
|
|
kwargs = db.upsert_client_sync.call_args.kwargs
|
|
assert kwargs["room_hash"] == "0x34"
|
|
assert kwargs["client_pubkey"] == (b"A" * 32).hex()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_add_post_does_not_advance_author_sync_watermark():
|
|
"""Posting must not move the author's sync_since watermark: it advances only
|
|
when a push is ACKed. Self-echo is prevented by the author exclusion in the
|
|
unsynced-message query, so an on-post advance would only skip OTHER authors'
|
|
older, still-unsynced messages for this client. Activity is still refreshed."""
|
|
db = _FakeDB()
|
|
rs = _make_room_server(db=db, acl=_FakeACL([_FakeClient(b"C" * 32)]))
|
|
|
|
ok = await rs.add_post(
|
|
client_pubkey=b"A" * 32,
|
|
message_text="newer post",
|
|
sender_timestamp=222,
|
|
txt_type=TXT_TYPE_PLAIN,
|
|
)
|
|
|
|
assert ok is True
|
|
kwargs = db.upsert_client_sync.call_args.kwargs
|
|
assert "sync_since" not in kwargs
|
|
assert kwargs["last_activity"] > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_add_post_truncates_and_rate_limits_client():
|
|
db = _FakeDB()
|
|
rs = _make_room_server(db=db)
|
|
client_key = b"B" * 32
|
|
|
|
long_msg = "x" * 500
|
|
first_ok = await rs.add_post(client_key, long_msg, sender_timestamp=5)
|
|
assert first_ok is True
|
|
args = db.insert_room_message.call_args.kwargs
|
|
assert len(args["message_text"].encode("utf-8")) == MAX_POST_TEXT_LEN
|
|
|
|
# Force client to appear at post-per-minute limit.
|
|
rs.client_post_times[client_key.hex()] = [time.time() - 1] * 10
|
|
second_ok = await rs.add_post(client_key, "blocked", sender_timestamp=6)
|
|
assert second_ok is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_add_post_ascii_over_limit_stores_exactly_max_bytes():
|
|
db = _FakeDB()
|
|
rs = _make_room_server(db=db)
|
|
|
|
long_msg = "a" * 200 # ASCII: 1 byte per char, well over MAX_POST_TEXT_LEN
|
|
ok = await rs.add_post(b"G" * 32, long_msg, sender_timestamp=1)
|
|
assert ok is True
|
|
|
|
stored = db.insert_room_message.call_args.kwargs["message_text"]
|
|
assert len(stored.encode("utf-8")) == MAX_POST_TEXT_LEN
|
|
assert stored == "a" * MAX_POST_TEXT_LEN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_add_post_multibyte_utf8_truncates_on_codepoint_boundary():
|
|
db = _FakeDB()
|
|
rs = _make_room_server(db=db)
|
|
|
|
# Each emoji is 4 bytes in UTF-8; padding forces the cut to land mid-emoji
|
|
# if truncation were byte-naive instead of codepoint-aware.
|
|
padding = "a" * (MAX_POST_TEXT_LEN - 2)
|
|
msg = padding + "\U0001f600\U0001f600\U0001f600" # grinning face emoji x3
|
|
assert len(msg.encode("utf-8")) > MAX_POST_TEXT_LEN
|
|
|
|
ok = await rs.add_post(b"H" * 32, msg, sender_timestamp=2)
|
|
assert ok is True
|
|
|
|
stored = db.insert_room_message.call_args.kwargs["message_text"]
|
|
encoded = stored.encode("utf-8")
|
|
assert len(encoded) <= MAX_POST_TEXT_LEN
|
|
# Must decode cleanly (no partial multi-byte sequence) and round-trip.
|
|
assert encoded.decode("utf-8") == stored
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_add_post_exact_limit_text_untouched():
|
|
db = _FakeDB()
|
|
rs = _make_room_server(db=db)
|
|
|
|
msg = "y" * MAX_POST_TEXT_LEN
|
|
ok = await rs.add_post(b"I" * 32, msg, sender_timestamp=3)
|
|
assert ok is True
|
|
|
|
stored = db.insert_room_message.call_args.kwargs["message_text"]
|
|
assert stored == msg
|
|
assert len(stored.encode("utf-8")) == MAX_POST_TEXT_LEN
|
|
|
|
|
|
def test_truncate_utf8_helper_boundary_cases():
|
|
# Under the limit: untouched.
|
|
assert _truncate_utf8("short", 151) == "short"
|
|
|
|
# Exactly at the limit: untouched.
|
|
exact = "z" * 151
|
|
assert _truncate_utf8(exact, 151) == exact
|
|
|
|
# Multi-byte straddling the boundary: cuts cleanly, decodes, stays <= limit.
|
|
text = ("b" * 149) + "\U0001f600\U0001f600" # 149 + 4 + 4 = 157 bytes
|
|
result = _truncate_utf8(text, 151)
|
|
encoded = result.encode("utf-8")
|
|
assert len(encoded) <= 151
|
|
assert encoded.decode("utf-8") == result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_add_post_returns_false_on_db_insert_failure():
|
|
db = _FakeDB()
|
|
db.insert_room_message.return_value = 0
|
|
rs = _make_room_server(db=db)
|
|
|
|
ok = await rs.add_post(b"D" * 32, "msg", sender_timestamp=9)
|
|
assert ok is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_send_advert_callback_returns_false_on_inject_failure():
|
|
config = {
|
|
"identities": {
|
|
"room_servers": [
|
|
{
|
|
"name": "room-alpha",
|
|
"settings": {"node_name": "Room Alpha", "latitude": 1.0, "longitude": 2.0},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
injector = AsyncMock(return_value=False)
|
|
rs = RoomServer(
|
|
room_hash=0x34,
|
|
room_name="room-alpha",
|
|
local_identity=_FakeIdentity(b"R" * 32),
|
|
sqlite_handler=_FakeDB(),
|
|
packet_injector=injector,
|
|
acl=_FakeACL(),
|
|
config_path="/tmp/room.yaml",
|
|
config=config,
|
|
config_manager=SimpleNamespace(),
|
|
)
|
|
|
|
packet = SimpleNamespace()
|
|
with patch("openhop_core.protocol.PacketBuilder.create_advert", return_value=packet):
|
|
ok = await rs.cli.send_advert_callback()
|
|
|
|
assert ok is False
|
|
injector.assert_awaited_once()
|
|
|
|
|
|
def test_room_server_init_caps_max_posts_to_hard_limit():
|
|
rs = _make_room_server(max_posts=MAX_UNSYNCED_POSTS + 50)
|
|
assert rs.max_posts == MAX_UNSYNCED_POSTS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_push_post_to_client_success_direct_route_sets_path_and_ack():
|
|
db = _FakeDB()
|
|
db.get_client_sync.return_value = {"push_failures": 0}
|
|
injector = AsyncMock(return_value=True)
|
|
rs = _make_room_server(db=db, injector=injector)
|
|
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
|
rs._handle_ack_received = AsyncMock()
|
|
|
|
client = _FakeClient(pubkey=b"E" * 32, out_path=b"\xaa\xbb", out_path_len=2)
|
|
post = {
|
|
"author_pubkey": (b"F" * 32).hex(),
|
|
"message_text": "payload",
|
|
"post_timestamp": 1234.5,
|
|
}
|
|
|
|
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
|
with (
|
|
patch(
|
|
"repeater.handler_helpers.room_server.CryptoUtils.sha256",
|
|
return_value=b"\x01\x02\x03\x04abcd",
|
|
),
|
|
patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
|
return_value=packet,
|
|
),
|
|
):
|
|
ok = await rs.push_post_to_client(client, post)
|
|
|
|
assert ok is True
|
|
assert bytes(packet.path) == b"\xaa\xbb"
|
|
assert packet.path_len == 2
|
|
injector.assert_awaited_once_with(
|
|
packet,
|
|
wait_for_ack=True,
|
|
expected_crc=67305985,
|
|
ack_timeout_s=10.0,
|
|
)
|
|
rs._handle_ack_received.assert_awaited_once_with(
|
|
client.id.get_public_key(), post["post_timestamp"]
|
|
)
|
|
rs.global_limiter.release.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_push_post_to_client_clamps_oversized_legacy_stored_text():
|
|
"""A post stored before the write-time limit was enforced (or otherwise
|
|
over MAX_POST_TEXT_LEN bytes) must still be clamped at push time so the
|
|
outgoing frame's text portion never exceeds the firmware's budget."""
|
|
db = _FakeDB()
|
|
db.get_client_sync.return_value = {"push_failures": 0}
|
|
injector = AsyncMock(return_value=True)
|
|
rs = _make_room_server(db=db, injector=injector)
|
|
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
|
rs._handle_ack_received = AsyncMock()
|
|
|
|
client = _FakeClient(pubkey=b"E" * 32, out_path=b"\xaa\xbb", out_path_len=2)
|
|
oversized_text = "q" * (MAX_POST_TEXT_LEN + 50)
|
|
post = {
|
|
"author_pubkey": (b"F" * 32).hex(),
|
|
"message_text": oversized_text,
|
|
"post_timestamp": 1234.5,
|
|
}
|
|
|
|
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
|
with (
|
|
patch(
|
|
"repeater.handler_helpers.room_server.CryptoUtils.sha256",
|
|
return_value=b"\x01\x02\x03\x04abcd",
|
|
),
|
|
patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
|
return_value=packet,
|
|
) as create_datagram,
|
|
):
|
|
ok = await rs.push_post_to_client(client, post)
|
|
|
|
assert ok is True
|
|
plaintext = create_datagram.call_args.kwargs["plaintext"]
|
|
# Prefix is timestamp(4) + flags(1) + author_prefix(4) = 9 bytes.
|
|
text_portion = plaintext[9:]
|
|
assert len(text_portion) == MAX_POST_TEXT_LEN
|
|
assert text_portion.decode("utf-8") == "q" * MAX_POST_TEXT_LEN
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_push_expected_ack_matches_firmware_signed_ack():
|
|
"""The pending ACK CRC must match what a signed-plain receiver sends back.
|
|
|
|
Firmware clients (BaseChatMesh::onPeerDataRecv) and openhop_core's text
|
|
handler ack TXT_TYPE_SIGNED_PLAIN with
|
|
``sha256(decrypted[0 : 9 + strlen(text)] || client_pubkey)[:4]``.
|
|
Regression test for the room server expecting a plain-DM style hash
|
|
(timestamp + attempt + text) instead: every push timed out and posts were
|
|
re-pushed forever (issue #286).
|
|
"""
|
|
from openhop_core import LocalIdentity
|
|
from openhop_core.protocol import CryptoUtils, Identity
|
|
|
|
room_id = LocalIdentity()
|
|
client_id = LocalIdentity()
|
|
client_pk = client_id.get_public_key()
|
|
shared = Identity(client_pk).calc_shared_secret(room_id.get_private_key())
|
|
|
|
db = _FakeDB()
|
|
sent = []
|
|
injector_kwargs = []
|
|
|
|
async def injector(packet, wait_for_ack=False, **kwargs):
|
|
sent.append(packet)
|
|
injector_kwargs.append(kwargs)
|
|
return False # no ACK: leaves the pending upsert as the only db write
|
|
|
|
rs = RoomServer(
|
|
room_hash=room_id.get_public_key()[0],
|
|
room_name="parity",
|
|
local_identity=room_id,
|
|
sqlite_handler=db,
|
|
packet_injector=injector,
|
|
acl=_FakeACL(),
|
|
)
|
|
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
|
|
|
author = LocalIdentity().get_public_key()
|
|
client = _FakeClient(pubkey=client_pk, shared_secret=shared, out_path=b"\x01", out_path_len=1)
|
|
post = {
|
|
"author_pubkey": author.hex(),
|
|
"message_text": "parity check",
|
|
"post_timestamp": 1234.5,
|
|
}
|
|
|
|
ok = await rs.push_post_to_client(client, post)
|
|
assert ok is False
|
|
assert len(sent) == 1
|
|
|
|
upserts = [
|
|
c.kwargs for c in db.upsert_client_sync.call_args_list if "pending_ack_crc" in c.kwargs
|
|
]
|
|
assert len(upserts) == 1
|
|
expected_ack_crc = upserts[0]["pending_ack_crc"]
|
|
|
|
# The injector must be told the crypto CRC (and the computed timeout) so
|
|
# dispatcher.wait_for_ack matches the client's actual ACK.
|
|
assert injector_kwargs[0]["expected_crc"] == expected_ack_crc
|
|
assert injector_kwargs[0]["ack_timeout_s"] > 0
|
|
|
|
# Decrypt the pushed datagram and verify the signed-plain layout.
|
|
pkt = sent[0]
|
|
encrypted = bytes(pkt.payload[2 : pkt.payload_len])
|
|
decrypted = CryptoUtils.mac_then_decrypt(shared[:16], shared, encrypted)
|
|
assert decrypted is not None
|
|
flags = decrypted[4]
|
|
assert (flags >> 2) & 0x3F == TXT_TYPE_SIGNED_PLAIN
|
|
assert bytes(decrypted[5:9]) == author[:4]
|
|
body = bytes(decrypted[9:])
|
|
nul = body.find(b"\x00")
|
|
text_len = nul if nul >= 0 else len(body)
|
|
assert body[:text_len] == b"parity check"
|
|
|
|
# Recompute the ACK exactly as the receiving client does.
|
|
client_ack_crc = int.from_bytes(
|
|
CryptoUtils.sha256(bytes(decrypted[: 9 + text_len]) + client_pk)[:4], "little"
|
|
)
|
|
assert client_ack_crc == expected_ack_crc
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_serializes_stored_post_timestamp_not_walltime():
|
|
"""The pushed frame's timestamp must be the post's own stored timestamp,
|
|
not the delivery wall-clock time (MyMesh.cpp:56 serializes the stored
|
|
post's timestamp, not `now`)."""
|
|
db = _FakeDB()
|
|
db.get_client_sync.return_value = {"push_failures": 0}
|
|
injector = AsyncMock(return_value=True)
|
|
rs = _make_room_server(db=db, injector=injector)
|
|
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
|
rs._handle_ack_received = AsyncMock()
|
|
|
|
client = _FakeClient(pubkey=b"E" * 32)
|
|
post = {
|
|
"author_pubkey": (b"F" * 32).hex(),
|
|
"message_text": "payload",
|
|
"post_timestamp": 1000.0,
|
|
}
|
|
|
|
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
|
with (
|
|
patch(
|
|
"repeater.handler_helpers.room_server.CryptoUtils.sha256",
|
|
return_value=b"\x01\x02\x03\x04abcd",
|
|
),
|
|
patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
|
return_value=packet,
|
|
) as create_datagram,
|
|
):
|
|
ok = await rs.push_post_to_client(client, post)
|
|
|
|
assert ok is True
|
|
plaintext = create_datagram.call_args.kwargs["plaintext"]
|
|
assert int.from_bytes(plaintext[0:4], "little") == 1000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_attempt_bits_randomized_across_retries():
|
|
"""Each push must OR a fresh random byte's low 2 bits into flags so a
|
|
retried post gets a different packet hash (and ACK), per MyMesh.cpp:59-60
|
|
("so packet hash (and ACK) will be different")."""
|
|
db = _FakeDB()
|
|
db.get_client_sync.return_value = {"push_failures": 0}
|
|
injector = AsyncMock(return_value=True)
|
|
rs = _make_room_server(db=db, injector=injector)
|
|
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
|
rs._handle_ack_received = AsyncMock()
|
|
|
|
client = _FakeClient(pubkey=b"E" * 32)
|
|
post = {
|
|
"author_pubkey": (b"F" * 32).hex(),
|
|
"message_text": "payload",
|
|
"post_timestamp": 1234.5,
|
|
}
|
|
|
|
plaintexts = []
|
|
acks = []
|
|
with patch("repeater.handler_helpers.room_server.secrets.randbits", side_effect=[1, 2]):
|
|
for _ in range(2):
|
|
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
|
with patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
|
return_value=packet,
|
|
) as create_datagram:
|
|
ok = await rs.push_post_to_client(client, post)
|
|
assert ok is True
|
|
plaintexts.append(create_datagram.call_args.kwargs["plaintext"])
|
|
acks.append(db.upsert_client_sync.call_args.kwargs["pending_ack_crc"])
|
|
|
|
flags = [pt[4] for pt in plaintexts]
|
|
assert (flags[0] >> 2) & 0x3F == TXT_TYPE_SIGNED_PLAIN
|
|
assert (flags[1] >> 2) & 0x3F == TXT_TYPE_SIGNED_PLAIN
|
|
assert flags[0] & 0x03 != flags[1] & 0x03
|
|
assert plaintexts[0][0:4] == plaintexts[1][0:4] # timestamp unchanged
|
|
assert plaintexts[0][5:] == plaintexts[1][5:] # author prefix + text unchanged
|
|
assert acks[0] != acks[1]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_watermark_equals_serialized_timestamp():
|
|
"""The db sync watermark update must use the same timestamp value that
|
|
was serialized into the pushed plaintext."""
|
|
db = _FakeDB()
|
|
db.get_client_sync.return_value = {"push_failures": 0}
|
|
injector = AsyncMock(return_value=True)
|
|
rs = _make_room_server(db=db, injector=injector)
|
|
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
|
rs._handle_ack_received = AsyncMock()
|
|
|
|
client = _FakeClient(pubkey=b"E" * 32)
|
|
post = {
|
|
"author_pubkey": (b"F" * 32).hex(),
|
|
"message_text": "payload",
|
|
"post_timestamp": 555.0,
|
|
}
|
|
|
|
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
|
with (
|
|
patch(
|
|
"repeater.handler_helpers.room_server.CryptoUtils.sha256",
|
|
return_value=b"\x01\x02\x03\x04abcd",
|
|
),
|
|
patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
|
return_value=packet,
|
|
) as create_datagram,
|
|
):
|
|
ok = await rs.push_post_to_client(client, post)
|
|
|
|
assert ok is True
|
|
plaintext = create_datagram.call_args.kwargs["plaintext"]
|
|
serialized_ts = int.from_bytes(plaintext[0:4], "little")
|
|
watermark = db.upsert_client_sync.call_args.kwargs["push_post_timestamp"]
|
|
assert int(watermark) == serialized_ts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_push_post_to_client_backoff_skip_and_timeout_path():
|
|
db = _FakeDB()
|
|
injector = AsyncMock(return_value=False)
|
|
rs = _make_room_server(db=db, injector=injector)
|
|
rs.global_limiter = SimpleNamespace(acquire=AsyncMock(), release=MagicMock())
|
|
rs._handle_ack_timeout = AsyncMock()
|
|
|
|
client = _FakeClient(pubkey=b"G" * 32)
|
|
post = {
|
|
"author_pubkey": (b"H" * 32).hex(),
|
|
"message_text": "payload",
|
|
"post_timestamp": 88.0,
|
|
}
|
|
|
|
# In backoff window: skip send.
|
|
db.get_client_sync.return_value = {"push_failures": 2, "updated_at": time.time()}
|
|
skip_ok = await rs.push_post_to_client(client, post)
|
|
assert skip_ok is False
|
|
injector.assert_not_awaited()
|
|
|
|
# Out of backoff and send fails -> timeout handler called.
|
|
db.get_client_sync.return_value = {"push_failures": 1, "updated_at": time.time() - 9999}
|
|
with (
|
|
patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder._pack_timestamp_data",
|
|
return_value=b"pk",
|
|
),
|
|
patch(
|
|
"repeater.handler_helpers.room_server.CryptoUtils.sha256",
|
|
return_value=b"\x01\x02\x03\x04abcd",
|
|
),
|
|
patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
|
return_value=SimpleNamespace(path=bytearray(), path_len=0),
|
|
),
|
|
):
|
|
fail_ok = await rs.push_post_to_client(client, post)
|
|
|
|
assert fail_ok is False
|
|
rs._handle_ack_timeout.assert_awaited_once_with(client.id.get_public_key())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_ack_helpers_and_unsynced_count_fallbacks():
|
|
db = _FakeDB()
|
|
rs = _make_room_server(db=db)
|
|
|
|
await rs._handle_ack_received(b"I" * 32, post_timestamp=123.0)
|
|
db.upsert_client_sync.assert_called()
|
|
|
|
db.get_client_sync.return_value = {"push_failures": 2}
|
|
await rs._handle_ack_timeout(b"I" * 32)
|
|
# last call should have incremented failures and cleared pending ack
|
|
timeout_kwargs = db.upsert_client_sync.call_args.kwargs
|
|
assert timeout_kwargs["push_failures"] == 3
|
|
assert timeout_kwargs["pending_ack_crc"] == 0
|
|
|
|
db.get_client_sync.side_effect = RuntimeError("db down")
|
|
assert rs.get_unsynced_count(b"I" * 32) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_evict_failed_clients_and_check_ack_timeouts():
|
|
db = _FakeDB()
|
|
acl = _FakeACL([_FakeClient(b"J" * 32)])
|
|
rs = _make_room_server(db=db, acl=acl)
|
|
|
|
now = time.time()
|
|
db.get_all_room_clients.return_value = [
|
|
{
|
|
"client_pubkey": (b"J" * 32).hex(),
|
|
"push_failures": 3,
|
|
"last_activity": now,
|
|
"pending_ack_crc": 0,
|
|
"ack_timeout_time": 0,
|
|
},
|
|
{
|
|
"client_pubkey": (b"K" * 32).hex(),
|
|
"push_failures": 0,
|
|
"last_activity": now - 5000,
|
|
"pending_ack_crc": 0,
|
|
"ack_timeout_time": 0,
|
|
},
|
|
]
|
|
|
|
await rs._evict_failed_clients()
|
|
assert db.upsert_client_sync.call_count >= 2
|
|
assert acl.remove_client.call_count == 2
|
|
|
|
rs._handle_ack_timeout = AsyncMock()
|
|
db.get_all_room_clients.return_value = [
|
|
{
|
|
"client_pubkey": (b"L" * 32).hex(),
|
|
"pending_ack_crc": 123,
|
|
"ack_timeout_time": now - 1,
|
|
},
|
|
{
|
|
"client_pubkey": (b"M" * 32).hex(),
|
|
"pending_ack_crc": 0,
|
|
"ack_timeout_time": now - 1,
|
|
},
|
|
]
|
|
await rs._check_ack_timeouts()
|
|
rs._handle_ack_timeout.assert_awaited_once_with(b"L" * 32)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_room_server_start_and_stop_are_idempotent():
|
|
rs = _make_room_server()
|
|
|
|
await rs.start()
|
|
assert rs._running is True
|
|
first_task = rs._sync_task
|
|
|
|
# Second start should not replace task.
|
|
await rs.start()
|
|
assert rs._sync_task is first_task
|
|
|
|
await rs.stop()
|
|
assert rs._running is False
|
|
|
|
# Stop again should be safe.
|
|
await rs.stop()
|
|
|
|
# Ensure task is cleaned up.
|
|
if first_task:
|
|
assert first_task.cancelled() or first_task.done()
|
|
|
|
|
|
def _push_post(author=b"F" * 32):
|
|
return {
|
|
"author_pubkey": author.hex(),
|
|
"message_text": "payload",
|
|
"post_timestamp": 1234.5,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_global_rate_limiter_serializes_concurrent_room_pushes():
|
|
"""Two room loops sharing the limiter must not transmit concurrently, and
|
|
the inter-message gap must be enforced between their sends.
|
|
|
|
Regression: acquire() used to release the lock before returning, so
|
|
both pushes ran their radio send with no lock held.
|
|
"""
|
|
limiter = GlobalRateLimiter(min_gap_seconds=0.05)
|
|
|
|
active = 0
|
|
max_active = 0
|
|
starts = []
|
|
|
|
async def injector(packet, wait_for_ack=True, expected_crc=0, ack_timeout_s=0.0):
|
|
nonlocal active, max_active
|
|
starts.append(time.monotonic())
|
|
active += 1
|
|
max_active = max(max_active, active)
|
|
try:
|
|
await asyncio.sleep(0.02) # simulate airtime
|
|
finally:
|
|
active -= 1
|
|
return True
|
|
|
|
rooms = []
|
|
for i in range(2):
|
|
db = _FakeDB()
|
|
db.get_client_sync.return_value = {"push_failures": 0}
|
|
rs = _make_room_server(db=db, injector=injector)
|
|
rs.global_limiter = limiter # share one real limiter across both rooms
|
|
rs._handle_ack_received = AsyncMock()
|
|
rooms.append(rs)
|
|
|
|
client = _FakeClient(pubkey=b"E" * 32)
|
|
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
|
with (
|
|
patch(
|
|
"repeater.handler_helpers.room_server.CryptoUtils.sha256",
|
|
return_value=b"\x01\x02\x03\x04abcd",
|
|
),
|
|
patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
|
return_value=packet,
|
|
),
|
|
):
|
|
results = await asyncio.gather(
|
|
*(rs.push_post_to_client(client, _push_post()) for rs in rooms)
|
|
)
|
|
|
|
assert results == [True, True]
|
|
assert max_active == 1 # never two transmissions in flight at once
|
|
assert len(starts) == 2
|
|
# The second send started at least min_gap after the first began.
|
|
assert starts[1] - starts[0] >= 0.05
|
|
assert not limiter.lock.locked() # fully released at the end
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_global_rate_limiter_releases_lock_on_send_exception():
|
|
"""If the radio send raises, the limiter lock must still be released so the
|
|
next push is not deadlocked."""
|
|
limiter = GlobalRateLimiter(min_gap_seconds=0.0)
|
|
|
|
async def failing_injector(packet, **kwargs):
|
|
raise RuntimeError("radio boom")
|
|
|
|
db = _FakeDB()
|
|
db.get_client_sync.return_value = {"push_failures": 0}
|
|
rs = _make_room_server(db=db, injector=failing_injector)
|
|
rs.global_limiter = limiter
|
|
|
|
client = _FakeClient(pubkey=b"E" * 32)
|
|
packet = SimpleNamespace(path=bytearray(), path_len=0)
|
|
with (
|
|
patch(
|
|
"repeater.handler_helpers.room_server.CryptoUtils.sha256",
|
|
return_value=b"\x01\x02\x03\x04abcd",
|
|
),
|
|
patch(
|
|
"repeater.handler_helpers.room_server.PacketBuilder.create_datagram",
|
|
return_value=packet,
|
|
),
|
|
):
|
|
ok = await rs.push_post_to_client(client, _push_post())
|
|
|
|
assert ok is False
|
|
assert not limiter.lock.locked()
|
|
# The lock is reusable immediately afterwards.
|
|
await limiter.acquire()
|
|
limiter.release()
|