radicle-reticulum/tests/test_qr.py

109 lines
4.0 KiB
Python

"""Tests for QR bundle encoding/decoding."""
import hashlib
import pytest
from radicle_reticulum.git_bundle import GitBundle, BundleMetadata, BundleType
from radicle_reticulum.qr import (
encode_bundle_to_qr,
decode_bundle_from_qr_data,
BundleTooLargeForQR,
QR_MAX_BYTES,
QR_MAGIC,
)
def make_small_bundle(data: bytes = b"tiny git bundle data") -> GitBundle:
"""Create a minimal GitBundle for testing."""
metadata = BundleMetadata(
bundle_type=BundleType.INCREMENTAL,
repository_id="rad:z3test",
source_node="did:key:z6Mktest",
timestamp=1000,
refs_included=["refs/heads/main"],
prerequisites=["abc123"],
size_bytes=len(data),
checksum=hashlib.sha256(data).digest(),
)
return GitBundle(metadata=metadata, data=data)
class TestQRPayloadRoundtrip:
"""Test the binary payload encode/decode without QR rendering."""
def _encode_payload(self, bundle: GitBundle) -> bytes:
"""Extract the raw bytes that would be put into a QR code."""
import struct
bundle_bytes = bundle.encode()
checksum = hashlib.sha256(bundle_bytes).digest()
length_prefix = len(bundle_bytes).to_bytes(4, "big")
return QR_MAGIC + length_prefix + checksum + bundle_bytes
def test_decode_roundtrip(self):
bundle = make_small_bundle()
payload = self._encode_payload(bundle)
decoded = decode_bundle_from_qr_data(payload)
assert decoded.metadata.repository_id == bundle.metadata.repository_id
assert decoded.data == bundle.data
def test_decode_rejects_wrong_magic(self):
bundle = make_small_bundle()
payload = b"WRONGMAGIC" + self._encode_payload(bundle)[len(QR_MAGIC):]
with pytest.raises(ValueError, match="Not a Radicle QR payload"):
decode_bundle_from_qr_data(payload)
def test_decode_detects_corruption(self):
bundle = make_small_bundle()
payload = bytearray(self._encode_payload(bundle))
payload[-1] ^= 0xFF # flip last bit of bundle data
with pytest.raises(ValueError, match="checksum mismatch"):
decode_bundle_from_qr_data(bytes(payload))
def test_decode_rejects_truncated_payload(self):
bundle = make_small_bundle()
payload = self._encode_payload(bundle)
# Truncate the bundle data portion
truncated = payload[:-10]
with pytest.raises(ValueError, match="Truncated"):
decode_bundle_from_qr_data(truncated)
class TestBundleTooLarge:
def test_oversized_bundle_raises(self):
large_data = b"x" * (QR_MAX_BYTES + 1)
bundle = make_small_bundle(data=large_data)
with pytest.raises(BundleTooLargeForQR, match="QR capacity"):
encode_bundle_to_qr(bundle)
def test_exact_limit_would_include_overhead(self):
# QR_MAX_BYTES is the limit for the serialised bundle, including metadata.
# A bundle with data just under the limit should still fail due to metadata overhead.
# This test verifies the check catches realistic oversized bundles.
oversized_data = b"a" * QR_MAX_BYTES
bundle = make_small_bundle(data=oversized_data)
with pytest.raises(BundleTooLargeForQR):
encode_bundle_to_qr(bundle)
class TestQREncodeOutput:
"""Test QR rendering (requires qrcode package)."""
def test_encode_returns_ascii_art(self):
bundle = make_small_bundle()
result = encode_bundle_to_qr(bundle)
assert isinstance(result, str)
assert len(result) > 0
# ASCII art QR codes use block characters
assert any(c in result for c in ("", "", " ", "\n"))
def test_encode_small_bundle_succeeds(self):
bundle = make_small_bundle(b"small")
result = encode_bundle_to_qr(bundle)
assert result # non-empty
def test_error_correction_levels(self):
bundle = make_small_bundle()
for level in ("L", "M", "Q", "H"):
result = encode_bundle_to_qr(bundle, error_correction=level)
assert isinstance(result, str)