275 lines
11 KiB
Python
275 lines
11 KiB
Python
|
|
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
|