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