109 lines
4.0 KiB
Python
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)
|