ForcePilot/backend/test/unit/channels/test_nostr_crypto.py

275 lines
11 KiB
Python
Raw Normal View History

from __future__ import annotations
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channels.adapters.nostr.crypto import (
NostrCrypto,
NostrCryptoError,
normalize_pubkey,
)
class AsyncContextManagerMock:
def __init__(self, return_value=None):
self.return_value = return_value
async def __aenter__(self):
return self.return_value
async def __aexit__(self, *args, **kwargs):
pass
class TestNormalizePubkey:
def test_empty_string_returns_empty(self):
assert normalize_pubkey("") == ""
def test_none_returns_empty(self):
assert normalize_pubkey(None) == ""
def test_whitespace_only_returns_empty(self):
assert normalize_pubkey(" ") == ""
def test_valid_hex_64_chars(self):
hex_key = "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"
assert normalize_pubkey(hex_key) == hex_key
def test_hex_with_whitespace(self):
hex_key = " 79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6 "
result = normalize_pubkey(hex_key)
assert len(result) == 64
def test_invalid_hex_length_returns_empty(self):
assert normalize_pubkey("abc123") == ""
def test_non_hex_chars_returns_empty(self):
assert normalize_pubkey("z" * 64) == ""
def test_nostr_prefix_hex(self):
hex_key = "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6"
assert normalize_pubkey(f"nostr:{hex_key}") == hex_key
def test_npub_invalid_returns_empty(self):
assert normalize_pubkey("npub1invalidkey123") == ""
def test_nprofile_invalid_returns_empty(self):
assert normalize_pubkey("nprofile1invalid123") == ""
def test_nsec_returns_empty_for_invalid(self):
assert normalize_pubkey("nsec1invalid123") == ""
class TestNostrCryptoConstruct:
@pytest.fixture(autouse=True)
def mock_nostr_sdk(self):
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mock_keys_cls:
mock_keys_cls.generate.return_value = MagicMock()
mock_keys_cls.parse.return_value = MagicMock()
yield
def test_auto_generate_keys(self):
crypto = NostrCrypto()
assert hasattr(crypto, "npub") or hasattr(crypto, "_keys")
def test_hex_key_should_fail_if_invalid(self):
with patch("yuxi.channels.adapters.nostr.crypto.Keys.parse", side_effect=Exception("bad key")):
with pytest.raises(NostrCryptoError, match="私钥解析失败"):
NostrCrypto("xxxx")
def test_empty_string_auto_generates(self):
crypto = NostrCrypto("")
assert hasattr(crypto, "_keys")
class TestNostrCryptoNIP04Mock:
@pytest.fixture
def crypto(self):
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mock_keys_cls:
mock_keys = MagicMock()
mock_keys.public_key.return_value = MagicMock()
mock_keys.secret_key.return_value = MagicMock()
mock_keys_cls.generate.return_value = mock_keys
mock_keys_cls.parse.return_value = mock_keys
return NostrCrypto()
def test_encrypt_uses_nip04_encrypt(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.nip04_encrypt") as mock_enc:
mock_enc.return_value = "encrypted_test"
with patch("yuxi.channels.adapters.nostr.crypto.PublicKey") as mock_pk:
mock_pk.from_hex.return_value = MagicMock()
result = crypto.encrypt_nip04("test", "aa" * 32)
assert result == "encrypted_test"
mock_enc.assert_called_once()
def test_decrypt_uses_nip04_decrypt(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.nip04_decrypt") as mock_dec:
mock_dec.return_value = "decrypted_test"
with patch("yuxi.channels.adapters.nostr.crypto.PublicKey") as mock_pk:
mock_pk.from_hex.return_value = MagicMock()
result = crypto.decrypt_nip04("encrypted", "aa" * 32)
assert result == "decrypted_test"
mock_dec.assert_called_once()
class TestNostrCryptoNIP17Mock:
@pytest.fixture
def crypto(self):
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mock_keys_cls:
mock_keys = MagicMock()
mock_keys.public_key.return_value = MagicMock()
mock_keys.secret_key.return_value = MagicMock()
mock_keys_cls.generate.return_value = mock_keys
mock_keys_cls.parse.return_value = mock_keys
ns = NostrCrypto()
ns._keys = mock_keys
ns._public_key = mock_keys.public_key.return_value
ns._secret_key = mock_keys.secret_key.return_value
return ns
@pytest.mark.asyncio
async def test_encrypt_nip17_calls_seal(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.EventBuilder") as mock_eb_cls, \
patch("yuxi.channels.adapters.nostr.crypto.PublicKey") as mock_pk:
mock_pk.from_hex.return_value = MagicMock()
mock_rumor = MagicMock()
mock_eb_cls.private_msg_rumor.return_value.build.return_value = mock_rumor
mock_sealed_event = MagicMock()
mock_sealed_event.as_json.return_value = '{"id":"gw","kind":1059}'
mock_seal_result = MagicMock()
mock_seal_result.sign_with_keys.return_value = mock_sealed_event
mock_eb_cls.seal = AsyncMock(return_value=mock_seal_result)
result = await crypto.encrypt_nip17("test message", "aa" * 32)
assert "1059" in result
@pytest.mark.asyncio
async def test_decrypt_nip17_calls_unseal_gift(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.Event") as mock_event_cls, \
patch("yuxi.channels.adapters.nostr.crypto.UnwrappedGift") as mock_uw_cls:
mock_event = MagicMock()
mock_event_cls.from_json.return_value = mock_event
mock_uw = MagicMock()
mock_uw.rumor.return_value.content.return_value = "decrypted_message"
mock_uw_cls.from_gift_wrap.return_value = mock_uw
result = await crypto.decrypt_nip17('{"id":"test","kind":1059}')
assert result == "decrypted_message"
class TestNostrCryptoBuildSignEvent:
@pytest.fixture
def crypto(self):
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mock_keys_cls:
mock_keys = MagicMock()
mock_keys.public_key.return_value = MagicMock()
mock_keys.secret_key.return_value = MagicMock()
mock_keys_cls.generate.return_value = mock_keys
mock_keys_cls.parse.return_value = mock_keys
return NostrCrypto()
def test_build_and_sign_kind1(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.EventBuilder") as mock_eb_cls, \
patch("yuxi.channels.adapters.nostr.crypto.Tag") as mock_tag_cls, \
patch("yuxi.channels.adapters.nostr.crypto.Kind"):
mock_event = MagicMock()
mock_event.as_json.return_value = json.dumps({
"id": "evt_id_001",
"kind": 1,
"content": "hello world",
"created_at": int(time.time()),
"pubkey": "pubkey_hex",
"sig": "signature_hex",
"tags": [],
})
eb_instance = MagicMock()
eb_instance.tags.return_value = eb_instance
eb_instance.custom_created_at.return_value = eb_instance
eb_instance.sign_with_keys.return_value = mock_event
mock_eb_cls.return_value = eb_instance
event = crypto.build_and_sign_event(kind=1, content="hello world", tags=[])
assert event["kind"] == 1
assert event["content"] == "hello world"
assert "id" in event
assert "sig" in event
def test_build_and_sign_with_tags(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.EventBuilder") as mock_eb_cls, \
patch("yuxi.channels.adapters.nostr.crypto.Tag", side_effect=lambda t: MagicMock()), \
patch("yuxi.channels.adapters.nostr.crypto.Kind"):
mock_event = MagicMock()
mock_event.as_json.return_value = json.dumps({
"id": "evt_id_002",
"kind": 4,
"content": "dm",
"tags": [["p", "recipient"]],
"created_at": int(time.time()),
"pubkey": "pk",
"sig": "sig",
})
eb_instance = MagicMock()
eb_instance.tags.return_value = eb_instance
eb_instance.custom_created_at.return_value = eb_instance
eb_instance.sign_with_keys.return_value = mock_event
mock_eb_cls.return_value = eb_instance
event = crypto.build_and_sign_event(kind=4, content="dm", tags=[["p", "recipient"]])
assert event["kind"] == 4
class TestNostrCryptoVerify:
@pytest.fixture
def crypto(self):
with patch("yuxi.channels.adapters.nostr.crypto.Keys") as mock_keys_cls:
mock_keys = MagicMock()
mock_keys.public_key.return_value = MagicMock()
mock_keys.secret_key.return_value = MagicMock()
mock_keys_cls.generate.return_value = mock_keys
mock_keys_cls.parse.return_value = mock_keys
return NostrCrypto()
def test_verify_valid_event(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.Event") as mock_event_cls:
mock_event_cls.from_json.return_value = MagicMock()
assert crypto.verify_event({"id": "fake", "kind": 1}) is True
def test_verify_event_exception_returns_false(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.Event") as mock_event_cls:
mock_event_cls.from_json.side_effect = Exception("bad")
assert crypto.verify_event({"id": "bad"}) is False
def test_verify_event_strict_id_mismatch(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.Event") as mock_event_cls:
mock_event = MagicMock()
mock_event.id.return_value.to_hex.return_value = "different_id"
mock_event.id.return_value.to_bech32.return_value = "different_id"
mock_event_cls.from_json.return_value = mock_event
assert crypto.verify_event_strict({"id": "expected_id"}) is False
def test_verify_event_strict_passes(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.Event") as mock_event_cls:
mock_event = MagicMock()
mock_event.id.return_value.to_hex.return_value = "expected_id"
mock_event.id.return_value.to_bech32.return_value = "expected_id"
mock_event_cls.from_json.return_value = mock_event
assert crypto.verify_event_strict({"id": "expected_id"}) is True
def test_verify_empty_event_fails(self, crypto):
with patch("yuxi.channels.adapters.nostr.crypto.Event") as mock_event_cls:
mock_event_cls.from_json.side_effect = Exception("bad")
assert crypto.verify_event({}) is False