radicle-reticulum/tests/test_sync.py

544 lines
19 KiB
Python

"""Tests for sync data structures and SyncManager logic (no RNS networking)."""
import struct
import time
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import LXMF
from radicle_reticulum.sync import (
RefsAnnouncement,
SyncManager,
SyncMode,
CONTENT_TYPE_BUNDLE,
CONTENT_TYPE_BUNDLE_CHUNK,
CONTENT_TYPE_REFS_ANNOUNCE,
CHUNK_HEADER_SIZE,
)
from radicle_reticulum.identity import RadicleIdentity
from radicle_reticulum.git_bundle import GitBundle, BundleMetadata, BundleType
import hashlib
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_manager(tmp_path: Path) -> SyncManager:
"""Instantiate SyncManager with LXMF/RNS components patched out."""
identity = RadicleIdentity.generate()
mock_router = MagicMock()
mock_dest = MagicMock()
mock_dest.hash = b"\x00" * 32
mock_dest.hash_hex = "00" * 32
with patch("radicle_reticulum.sync.RNS.Reticulum"), \
patch("radicle_reticulum.sync.LXMF.LXMRouter", return_value=mock_router), \
patch("radicle_reticulum.sync.RNS.log"):
mock_router.register_delivery_identity.return_value = mock_dest
manager = SyncManager(identity=identity, storage_path=tmp_path)
manager._lxmf_router = mock_router
manager._lxmf_destination = mock_dest
return manager
def _make_bundle(data: bytes = b"git bundle") -> GitBundle:
checksum = hashlib.sha256(data).digest()
metadata = BundleMetadata(
bundle_type=BundleType.FULL,
repository_id="rad:z3test",
source_node="did:key:z6Mktest",
timestamp=1000,
refs_included=["refs/heads/main"],
prerequisites=[],
size_bytes=len(data),
checksum=checksum,
)
return GitBundle(metadata=metadata, data=data)
class TestRefsAnnouncement:
def test_encode_decode_roundtrip(self):
ann = RefsAnnouncement(
repository_id="rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
node_id="did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK",
refs={
"refs/heads/main": "abc123def456789012345678901234567890abcd",
"refs/rad/id": "0000000000000000000000000000000000000001",
},
timestamp=1234567890123,
)
encoded = ann.encode()
decoded = RefsAnnouncement.decode(encoded)
assert decoded.repository_id == ann.repository_id
assert decoded.node_id == ann.node_id
assert decoded.refs == ann.refs
assert decoded.timestamp == ann.timestamp
def test_empty_refs(self):
ann = RefsAnnouncement(
repository_id="rad:test",
node_id="did:key:z6Mk...",
refs={},
timestamp=0,
)
decoded = RefsAnnouncement.decode(ann.encode())
assert decoded.refs == {}
def test_many_refs(self):
refs = {f"refs/heads/branch-{i}": "a" * 40 for i in range(50)}
ann = RefsAnnouncement(
repository_id="rad:z3test",
node_id="did:key:z6Mktest",
refs=refs,
timestamp=9999,
)
decoded = RefsAnnouncement.decode(ann.encode())
assert decoded.refs == refs
def test_special_characters_in_ref_names(self):
refs = {"refs/heads/feature/my-branch_v2.0": "b" * 40}
ann = RefsAnnouncement(
repository_id="rad:z3test",
node_id="did:key:z6Mktest",
refs=refs,
timestamp=1,
)
decoded = RefsAnnouncement.decode(ann.encode())
assert decoded.refs == refs
def test_encode_produces_bytes(self):
ann = RefsAnnouncement(
repository_id="rad:test",
node_id="did:key:z6Mk",
refs={"refs/heads/main": "a" * 40},
timestamp=1000,
)
assert isinstance(ann.encode(), bytes)
assert len(ann.encode()) > 0
# ---------------------------------------------------------------------------
# SyncManager state management
# ---------------------------------------------------------------------------
class TestSyncManagerPeers:
def test_initial_peer_list_empty(self, tmp_path):
manager = _make_manager(tmp_path)
assert manager.get_known_peers() == []
def test_register_peer_adds_to_list(self, tmp_path):
manager = _make_manager(tmp_path)
with patch("radicle_reticulum.sync.RNS.log"):
manager.register_peer(b"\x01" * 16)
assert b"\x01" * 16 in manager.get_known_peers()
def test_register_multiple_peers(self, tmp_path):
manager = _make_manager(tmp_path)
hashes = [bytes([i]) * 16 for i in range(3)]
with patch("radicle_reticulum.sync.RNS.log"):
for h in hashes:
manager.register_peer(h)
assert set(manager.get_known_peers()) == set(hashes)
def test_peer_learned_from_incoming_message(self, tmp_path):
manager = _make_manager(tmp_path)
source_hash = b"\xab" * 16
bundle = _make_bundle()
msg = MagicMock()
msg.source_hash = source_hash
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE}
msg.content = bundle.encode()
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg)
assert source_hash in manager.get_known_peers()
def test_message_without_source_hash_still_processed(self, tmp_path):
manager = _make_manager(tmp_path)
bundle = _make_bundle()
msg = MagicMock()
msg.source_hash = None
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE}
msg.content = bundle.encode()
received = []
manager.set_on_bundle_received(received.append)
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg)
assert len(received) == 1
# ---------------------------------------------------------------------------
# SyncManager delivery callbacks
# ---------------------------------------------------------------------------
class TestSyncManagerDelivery:
def test_bundle_callback_fires_on_bundle_message(self, tmp_path):
manager = _make_manager(tmp_path)
bundle = _make_bundle()
received = []
manager.set_on_bundle_received(received.append)
msg = MagicMock()
msg.source_hash = b"\x01" * 16
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE}
msg.content = bundle.encode()
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg)
assert len(received) == 1
assert received[0].metadata.repository_id == "rad:z3test"
def test_refs_callback_fires_on_refs_announce_message(self, tmp_path):
manager = _make_manager(tmp_path)
ann = RefsAnnouncement(
repository_id="rad:z3test",
node_id="did:key:z6Mktest",
refs={"refs/heads/main": "a" * 40},
timestamp=999,
)
received = []
manager.set_on_refs_announced(received.append)
msg = MagicMock()
msg.source_hash = b"\x02" * 16
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_REFS_ANNOUNCE}
msg.content = ann.encode()
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg)
assert len(received) == 1
assert received[0].repository_id == "rad:z3test"
def test_unknown_content_type_silently_ignored(self, tmp_path):
manager = _make_manager(tmp_path)
msg = MagicMock()
msg.source_hash = None
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: 0xFF}
msg.content = b"garbage"
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg) # should not raise
# ---------------------------------------------------------------------------
# Chunk reassembly
# ---------------------------------------------------------------------------
class TestChunkReassembly:
def _make_chunks(self, data: bytes, chunk_size: int = 10):
"""Split data into chunk messages as _send_chunked_bundle would."""
bundle_id = hashlib.sha256(data).digest()[:16]
total = (len(data) + chunk_size - 1) // chunk_size
msgs = []
for i in range(total):
chunk = data[i * chunk_size: (i + 1) * chunk_size]
header = struct.pack("!16sHH", bundle_id, i, total)
msgs.append(header + chunk)
return msgs
def test_reassemble_two_chunks(self, tmp_path):
manager = _make_manager(tmp_path)
bundle = _make_bundle(b"first half second half ")
bundle_bytes = bundle.encode()
received = []
manager.set_on_bundle_received(received.append)
chunks = self._make_chunks(bundle_bytes, chunk_size=len(bundle_bytes) // 2 + 1)
assert len(chunks) == 2
for chunk_data in chunks:
msg = MagicMock()
msg.source_hash = b"\x01" * 16
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE_CHUNK}
msg.content = chunk_data
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg)
assert len(received) == 1
assert received[0].data == bundle.data
def test_out_of_order_chunks_reassemble_correctly(self, tmp_path):
manager = _make_manager(tmp_path)
bundle = _make_bundle(b"abcdefghijklmnopqrstuvwxyz0123456789")
bundle_bytes = bundle.encode()
received = []
manager.set_on_bundle_received(received.append)
chunks = self._make_chunks(bundle_bytes, chunk_size=8)
assert len(chunks) >= 3
for chunk_data in reversed(chunks): # deliver in reverse order
msg = MagicMock()
msg.source_hash = None
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE_CHUNK}
msg.content = chunk_data
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg)
assert len(received) == 1
assert received[0].data == bundle.data
def test_malformed_chunk_too_short_is_ignored(self, tmp_path):
manager = _make_manager(tmp_path)
msg = MagicMock()
msg.source_hash = None
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE_CHUNK}
msg.content = b"\x00" * (CHUNK_HEADER_SIZE - 1) # too short
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg) # should not raise
assert manager._chunk_buffers == {}
def test_partial_chunks_not_delivered_until_complete(self, tmp_path):
manager = _make_manager(tmp_path)
bundle = _make_bundle(b"partial test data here")
bundle_bytes = bundle.encode()
received = []
manager.set_on_bundle_received(received.append)
chunks = self._make_chunks(bundle_bytes, chunk_size=5)
assert len(chunks) >= 2
# Send only first chunk
msg = MagicMock()
msg.source_hash = None
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_BUNDLE_CHUNK}
msg.content = chunks[0]
with patch("radicle_reticulum.sync.RNS.log"):
manager._on_lxmf_delivery(msg)
assert received == [] # not yet complete
# ---------------------------------------------------------------------------
# SyncManager repository management
# ---------------------------------------------------------------------------
class TestSyncManagerRepositories:
def test_register_repository(self, tmp_path):
manager = _make_manager(tmp_path)
repo_path = tmp_path / "repo"
repo_path.mkdir()
with patch("radicle_reticulum.sync.RNS.log"):
state = manager.register_repository("rad:z3test", repo_path)
assert state.repository_id == "rad:z3test"
assert state.local_path == repo_path
def test_register_same_repo_twice_returns_same_state(self, tmp_path):
manager = _make_manager(tmp_path)
repo_path = tmp_path / "repo"
repo_path.mkdir()
with patch("radicle_reticulum.sync.RNS.log"):
s1 = manager.register_repository("rad:z3test", repo_path)
s2 = manager.register_repository("rad:z3test", repo_path)
assert s1 is s2
def test_get_sync_status_returns_none_for_unknown(self, tmp_path):
manager = _make_manager(tmp_path)
assert manager.get_sync_status("rad:unknown") is None
def test_get_sync_status_after_register(self, tmp_path):
manager = _make_manager(tmp_path)
repo_path = tmp_path / "repo"
repo_path.mkdir()
with patch("radicle_reticulum.sync.RNS.log"):
manager.register_repository("rad:z3test", repo_path)
status = manager.get_sync_status("rad:z3test")
assert status is not None
assert status["repository_id"] == "rad:z3test"
assert status["known_peers"] == 0
# ---------------------------------------------------------------------------
# Phase 4: speculative push
# ---------------------------------------------------------------------------
def _make_manager_auto_push(tmp_path: Path) -> SyncManager:
identity = RadicleIdentity.generate()
mock_router = MagicMock()
mock_dest = MagicMock()
mock_dest.hash = b"\x00" * 32
mock_dest.hash_hex = "00" * 32
with patch("radicle_reticulum.sync.RNS.Reticulum"), \
patch("radicle_reticulum.sync.LXMF.LXMRouter", return_value=mock_router), \
patch("radicle_reticulum.sync.RNS.log"):
mock_router.register_delivery_identity.return_value = mock_dest
manager = SyncManager(identity=identity, storage_path=tmp_path, auto_push=True)
manager._lxmf_router = mock_router
manager._lxmf_destination = mock_dest
return manager
class TestShouldPushToPeer:
def test_no_push_when_peer_up_to_date(self, tmp_path):
manager = _make_manager(tmp_path)
our_refs = {"refs/heads/main": "a" * 40}
peer_refs = {"refs/heads/main": "a" * 40}
assert manager._should_push_to_peer(our_refs, peer_refs) is False
def test_push_when_peer_behind(self, tmp_path):
manager = _make_manager(tmp_path)
our_refs = {"refs/heads/main": "b" * 40}
peer_refs = {"refs/heads/main": "a" * 40}
assert manager._should_push_to_peer(our_refs, peer_refs) is True
def test_push_when_peer_missing_ref(self, tmp_path):
manager = _make_manager(tmp_path)
our_refs = {"refs/heads/main": "a" * 40, "refs/heads/dev": "b" * 40}
peer_refs = {"refs/heads/main": "a" * 40}
assert manager._should_push_to_peer(our_refs, peer_refs) is True
def test_no_push_when_our_refs_empty(self, tmp_path):
manager = _make_manager(tmp_path)
assert manager._should_push_to_peer({}, {"refs/heads/main": "a" * 40}) is False
def test_no_push_when_both_empty(self, tmp_path):
manager = _make_manager(tmp_path)
assert manager._should_push_to_peer({}, {}) is False
class TestSpeculativePush:
def _make_announcement(self, repo_id="rad:z3test", refs=None):
return RefsAnnouncement(
repository_id=repo_id,
node_id="did:key:z6Mkpeer",
refs=refs or {"refs/heads/main": "a" * 40},
timestamp=1000,
)
def test_auto_push_false_does_not_call_maybe_push(self, tmp_path):
manager = _make_manager(tmp_path) # auto_push=False by default
ann = self._make_announcement()
msg = MagicMock()
msg.source_hash = b"\x01" * 16
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_REFS_ANNOUNCE}
msg.content = ann.encode()
with patch("radicle_reticulum.sync.RNS.log"), \
patch.object(manager, "_maybe_push_to_peer") as mock_push:
manager._on_lxmf_delivery(msg)
mock_push.assert_not_called()
def test_auto_push_true_calls_maybe_push_on_refs_announce(self, tmp_path):
manager = _make_manager_auto_push(tmp_path)
ann = self._make_announcement()
msg = MagicMock()
msg.source_hash = b"\x02" * 16
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_REFS_ANNOUNCE}
msg.content = ann.encode()
with patch("radicle_reticulum.sync.RNS.log"), \
patch.object(manager, "_maybe_push_to_peer") as mock_push:
manager._on_lxmf_delivery(msg)
mock_push.assert_called_once_with(b"\x02" * 16, mock_push.call_args[0][1])
def test_auto_push_skipped_when_source_hash_none(self, tmp_path):
manager = _make_manager_auto_push(tmp_path)
ann = self._make_announcement()
msg = MagicMock()
msg.source_hash = None
msg.fields = {LXMF.FIELD_CUSTOM_TYPE: CONTENT_TYPE_REFS_ANNOUNCE}
msg.content = ann.encode()
with patch("radicle_reticulum.sync.RNS.log"), \
patch.object(manager, "_maybe_push_to_peer") as mock_push:
manager._on_lxmf_delivery(msg)
mock_push.assert_not_called()
def test_maybe_push_skips_unknown_repository(self, tmp_path):
manager = _make_manager_auto_push(tmp_path)
ann = self._make_announcement(repo_id="rad:unknown")
with patch("radicle_reticulum.sync.RNS.log"), \
patch("radicle_reticulum.sync.GitBundleGenerator") as mock_gen:
manager._maybe_push_to_peer(b"\x03" * 16, ann)
mock_gen.assert_not_called()
def test_maybe_push_skips_when_peer_up_to_date(self, tmp_path):
manager = _make_manager_auto_push(tmp_path)
repo_path = tmp_path / "repo"
repo_path.mkdir()
with patch("radicle_reticulum.sync.RNS.log"):
manager.register_repository("rad:z3test", repo_path)
our_refs = {"refs/heads/main": "a" * 40}
peer_refs = {"refs/heads/main": "a" * 40}
ann = self._make_announcement(refs=peer_refs)
with patch("radicle_reticulum.sync.RNS.log"), \
patch("radicle_reticulum.sync.GitBundleGenerator") as mock_gen_cls:
mock_gen = MagicMock()
mock_gen_cls.return_value = mock_gen
mock_gen.get_refs.return_value = our_refs
manager._maybe_push_to_peer(b"\x04" * 16, ann)
mock_gen.create_incremental_bundle.assert_not_called()
def test_maybe_push_sends_bundle_when_ahead(self, tmp_path):
manager = _make_manager_auto_push(tmp_path)
repo_path = tmp_path / "repo"
repo_path.mkdir()
with patch("radicle_reticulum.sync.RNS.log"):
manager.register_repository("rad:z3test", repo_path)
our_refs = {"refs/heads/main": "b" * 40}
peer_refs = {"refs/heads/main": "a" * 40}
ann = self._make_announcement(refs=peer_refs)
bundle = _make_bundle(b"incremental data")
with patch("radicle_reticulum.sync.RNS.log"), \
patch("radicle_reticulum.sync.GitBundleGenerator") as mock_gen_cls, \
patch.object(manager, "_send_lxmf_message", return_value=True) as mock_send:
mock_gen = MagicMock()
mock_gen_cls.return_value = mock_gen
mock_gen.get_refs.return_value = our_refs
mock_gen.create_incremental_bundle.return_value = bundle
manager._maybe_push_to_peer(b"\x05" * 16, ann)
mock_send.assert_called_once()
call_args = mock_send.call_args[0]
assert call_args[0] == b"\x05" * 16
assert call_args[1] == CONTENT_TYPE_BUNDLE