172 lines
6.2 KiB
Python
172 lines
6.2 KiB
Python
"""Tests for identity mapping."""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from radicle_reticulum.identity import (
|
|
RadicleIdentity,
|
|
_base58btc_encode,
|
|
_base58btc_decode,
|
|
)
|
|
|
|
|
|
class TestBase58:
|
|
"""Test base58btc encoding/decoding."""
|
|
|
|
def test_encode_decode_roundtrip(self):
|
|
"""Test that encode/decode are inverses."""
|
|
test_data = b"\x00\x01\x02\x03\x04\x05"
|
|
encoded = _base58btc_encode(test_data)
|
|
decoded = _base58btc_decode(encoded)
|
|
assert decoded == test_data
|
|
|
|
def test_encode_known_value(self):
|
|
"""Test encoding against known value."""
|
|
# "Hello" in base58btc
|
|
data = b"Hello"
|
|
encoded = _base58btc_encode(data)
|
|
assert encoded == "9Ajdvzr"
|
|
|
|
def test_preserves_leading_zeros(self):
|
|
"""Test that leading zeros are preserved."""
|
|
data = b"\x00\x00\x00test"
|
|
encoded = _base58btc_encode(data)
|
|
decoded = _base58btc_decode(encoded)
|
|
assert decoded == data
|
|
|
|
|
|
class TestRadicleIdentity:
|
|
"""Test RadicleIdentity class."""
|
|
|
|
def test_generate_creates_valid_identity(self):
|
|
"""Test that generate() creates a valid identity."""
|
|
identity = RadicleIdentity.generate()
|
|
|
|
assert identity.private_key is not None
|
|
assert identity.public_key is not None
|
|
assert identity.rns_identity is not None
|
|
assert len(identity.public_key_bytes) == 32
|
|
assert len(identity.rns_hash) == 16
|
|
|
|
def test_did_format(self):
|
|
"""Test that DID has correct format."""
|
|
identity = RadicleIdentity.generate()
|
|
did = identity.did
|
|
|
|
assert did.startswith("did:key:z6Mk")
|
|
assert identity.node_id == did
|
|
|
|
def test_did_roundtrip(self):
|
|
"""Test DID encoding/decoding roundtrip."""
|
|
identity = RadicleIdentity.generate()
|
|
original_did = identity.did
|
|
|
|
# Create identity from DID (public key only)
|
|
restored = RadicleIdentity.from_did(original_did)
|
|
|
|
assert restored.did == original_did
|
|
assert restored.public_key_bytes == identity.public_key_bytes
|
|
assert restored.private_key is None # DID import is public-only
|
|
assert restored.rns_identity is None # DID doesn't have X25519 key
|
|
|
|
def test_sign_and_verify(self):
|
|
"""Test signing and verification."""
|
|
identity = RadicleIdentity.generate()
|
|
message = b"test message"
|
|
|
|
signature = identity.sign(message)
|
|
assert len(signature) == 64 # Ed25519 signature size
|
|
|
|
assert identity.verify(signature, message)
|
|
assert not identity.verify(signature, b"wrong message")
|
|
|
|
def test_cannot_sign_without_private_key(self):
|
|
"""Test that signing fails without private key."""
|
|
identity = RadicleIdentity.generate()
|
|
public_only = RadicleIdentity.from_did(identity.did)
|
|
|
|
with pytest.raises(ValueError, match="Cannot sign without private key"):
|
|
public_only.sign(b"test")
|
|
|
|
def test_invalid_did_format(self):
|
|
"""Test that invalid DIDs are rejected."""
|
|
with pytest.raises(ValueError, match="Invalid DID format"):
|
|
RadicleIdentity.from_did("not:a:valid:did")
|
|
|
|
def test_rns_hash_is_stable(self):
|
|
"""Test that RNS hash is deterministic for same key."""
|
|
identity = RadicleIdentity.generate()
|
|
hash1 = identity.rns_hash_hex
|
|
|
|
# The hash should be consistent
|
|
assert identity.rns_hash_hex == hash1
|
|
assert len(hash1) == 32 # 16 bytes = 32 hex chars
|
|
|
|
def test_repr(self):
|
|
"""Test string representation."""
|
|
identity = RadicleIdentity.generate()
|
|
repr_str = repr(identity)
|
|
|
|
assert "RadicleIdentity" in repr_str
|
|
assert "with private key" in repr_str
|
|
|
|
public_only = RadicleIdentity.from_did(identity.did)
|
|
assert "public only" in repr(public_only)
|
|
|
|
|
|
class TestIdentityPersistence:
|
|
"""Test identity save/load."""
|
|
|
|
def test_save_and_load_roundtrip(self):
|
|
"""Saved identity reloads with same DID and RNS hash."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
path = Path(tmpdir) / "identity"
|
|
original = RadicleIdentity.generate()
|
|
original.save(path)
|
|
|
|
loaded = RadicleIdentity.load(path)
|
|
assert loaded.did == original.did
|
|
assert loaded.rns_identity_hash_hex == original.rns_identity_hash_hex
|
|
assert loaded.private_key is not None
|
|
|
|
def test_save_creates_parent_dirs(self):
|
|
"""save() creates intermediate directories."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
path = Path(tmpdir) / "subdir" / "nested" / "identity"
|
|
identity = RadicleIdentity.generate()
|
|
identity.save(path)
|
|
assert path.exists()
|
|
|
|
def test_load_missing_file_raises(self):
|
|
"""load() raises FileNotFoundError for missing path."""
|
|
with pytest.raises(FileNotFoundError):
|
|
RadicleIdentity.load("/nonexistent/path/identity")
|
|
|
|
def test_load_or_generate_creates_on_first_run(self):
|
|
"""load_or_generate() creates and saves a new identity when absent."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
path = Path(tmpdir) / "identity"
|
|
assert not path.exists()
|
|
identity = RadicleIdentity.load_or_generate(path)
|
|
assert path.exists()
|
|
assert identity.did.startswith("did:key:z6Mk")
|
|
|
|
def test_load_or_generate_stable_across_calls(self):
|
|
"""load_or_generate() returns the same identity on subsequent calls."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
path = Path(tmpdir) / "identity"
|
|
first = RadicleIdentity.load_or_generate(path)
|
|
second = RadicleIdentity.load_or_generate(path)
|
|
assert first.did == second.did
|
|
assert first.rns_identity_hash_hex == second.rns_identity_hash_hex
|
|
|
|
def test_save_public_only_raises(self):
|
|
"""save() raises ValueError for public-key-only identities."""
|
|
identity = RadicleIdentity.from_did(
|
|
"did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
|
)
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
with pytest.raises(ValueError, match="public-key-only"):
|
|
identity.save(Path(tmpdir) / "identity")
|