544 lines
19 KiB
Python
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
|