diff --git a/tests/bdd-test-scenarios.feature b/tests/bdd-test-scenarios.feature new file mode 100644 index 0000000..9897bac --- /dev/null +++ b/tests/bdd-test-scenarios.feature @@ -0,0 +1,1206 @@ +# ============================================================================= +# BDD TEST SCENARIOS FOR NOSTR-JS-SDK — MISSING COVERAGE +# ============================================================================= +# Techniques applied per scenario are annotated with: +# [EP] = Equivalence Partitioning +# [BVA] = Boundary Value Analysis +# [DT] = Decision Table Testing +# [ST] = State Transition Testing +# [UC] = Use Case Testing +# [EG] = Error Guessing +# [PW] = Pairwise/Combinatorial Testing +# [SC] = Statement/Branch/Condition Coverage +# [LC] = Loop Testing +# [RB] = Risk-Based Testing +# ============================================================================= + + +# ============================================================================= +# FEATURE 1: NostrClient — Relay Connection Lifecycle +# ============================================================================= + +Feature: NostrClient relay connection lifecycle + As a Nostr application developer + I want the client to manage WebSocket connections to relays + So that events can be published and received reliably + + Background: + Given a NostrKeyManager with a generated key pair + And a NostrClient created with that key manager + + # --- State Transition Testing [ST] --- + # States: Created → Connecting → Connected → Disconnected → (terminal) + # Created → Connecting → Failed + # Connected → Reconnecting → Connected + + Scenario: Client starts in disconnected state + Then the client should report isConnected as false + And getConnectedRelays should return an empty set + + Scenario: Successful connection to a single relay + Given a mock WebSocket that opens successfully + When I connect to "wss://relay1.example.com" + Then the client should report isConnected as true + And getConnectedRelays should contain "wss://relay1.example.com" + + Scenario: Successful connection to multiple relays + Given mock WebSockets that open successfully + When I connect to "wss://relay1.example.com" and "wss://relay2.example.com" + Then getConnectedRelays should contain both relay URLs + And the client should report isConnected as true + + Scenario: Connection to already-connected relay is idempotent + Given I am connected to "wss://relay1.example.com" + When I connect to "wss://relay1.example.com" again + Then the connection should succeed without creating a second socket + And getConnectedRelays should contain exactly 1 entry + + Scenario: Connection timeout after 30 seconds + Given a mock WebSocket that never fires onopen + When I connect to "wss://slow-relay.example.com" + And 30 seconds elapse without onopen + Then the connect promise should reject with "timed out" + + # [BVA] — Connection timeout boundary + Scenario: Connection succeeds just before timeout + Given a mock WebSocket that opens at 29999ms + When I connect to "wss://relay.example.com" + Then the connect promise should resolve successfully + + Scenario: WebSocket creation fails + Given createWebSocket rejects with "ECONNREFUSED" + When I connect to "wss://down-relay.example.com" + Then the connect promise should reject with an error + + Scenario: WebSocket fires onerror before onopen + Given a mock WebSocket that fires onerror immediately + When I connect to "wss://bad-relay.example.com" + Then the connect promise should reject with "Failed to connect" + + # [ST] — Closed state is terminal + Scenario: All operations reject after disconnect + Given I am connected to "wss://relay.example.com" + When I call disconnect + Then calling connect should reject with "disconnected" + And calling publishEvent should reject with "disconnected" + And calling sendPrivateMessage should reject with "disconnected" + And calling sendTokenTransfer should reject with "disconnected" + And calling sendPaymentRequest should reject with "disconnected" + + # [EG] — Double disconnect + Scenario: Calling disconnect multiple times does not throw + When I call disconnect + And I call disconnect again + Then no error should be thrown + + +# ============================================================================= +# FEATURE 2: NostrClient — Relay Message Handling +# ============================================================================= + +Feature: NostrClient relay message handling + As a Nostr application developer + I want the client to correctly parse and dispatch relay messages + So that my subscriptions receive the right events + + Background: + Given a connected NostrClient with a mock relay + + # --- Decision Table Testing [DT] --- + # Message Type | Array Length | Subscription Exists | Expected Behavior + # EVENT | >= 3 | yes | Parse event, call onEvent + # EVENT | >= 3 | no | Ignore silently + # EVENT | < 3 | any | Ignore silently + # OK | >= 4 | pending exists | Resolve/reject pending promise + # OK | < 4 | any | Ignore silently + # EOSE | >= 2 | yes with callback | Call onEndOfStoredEvents + # EOSE | >= 2 | no callback | Ignore silently + # NOTICE | >= 2 | any | Log warning + # CLOSED | >= 3 | yes with onError | Call onError + # AUTH | >= 2 | any | Send AUTH event, resubscribe + # (unknown) | any | any | Ignore silently + # (malformed) | any | any | Ignore silently + + Scenario: Receiving EVENT message dispatches to correct subscription listener + Given I have an active subscription "sub_1" with a listener + When the relay sends '["EVENT", "sub_1", {"id":"abc","pubkey":"...","kind":1,"content":"hello","tags":[],"created_at":1000,"sig":"..."}]' + Then the listener's onEvent should be called with the parsed Event + And the event content should be "hello" + + Scenario: EVENT for unknown subscription is silently ignored + When the relay sends '["EVENT", "unknown_sub", {"id":"abc","pubkey":"...","kind":1,"content":"test","tags":[],"created_at":1000,"sig":"..."}]' + Then no error should be thrown + And no listener should be called + + # [BVA] — Minimum valid array length for EVENT + Scenario: EVENT message with fewer than 3 elements is ignored + When the relay sends '["EVENT", "sub_1"]' + Then no listener should be called + + Scenario: OK message resolves pending publish (accepted) + Given I published an event with id "event123" that is awaiting OK + When the relay sends '["OK", "event123", true, ""]' + Then the publish promise should resolve with "event123" + + Scenario: OK message rejects pending publish (rejected) + Given I published an event with id "event123" that is awaiting OK + When the relay sends '["OK", "event123", false, "blocked: rate limit exceeded"]' + Then the publish promise should reject with "Event rejected: blocked: rate limit exceeded" + + # [BVA] — OK message with fewer than 4 elements + Scenario: OK message with insufficient elements is ignored + Given I published an event with id "event123" that is awaiting OK + When the relay sends '["OK", "event123", true]' + Then the pending promise should remain unresolved + + Scenario: EOSE triggers onEndOfStoredEvents callback + Given I have an active subscription "sub_1" with an onEndOfStoredEvents callback + When the relay sends '["EOSE", "sub_1"]' + Then onEndOfStoredEvents should be called with "sub_1" + + Scenario: EOSE for subscription without onEndOfStoredEvents is handled gracefully + Given I have an active subscription "sub_1" with only onEvent + When the relay sends '["EOSE", "sub_1"]' + Then no error should be thrown + + Scenario: CLOSED message triggers onError callback + Given I have an active subscription "sub_1" with an onError callback + When the relay sends '["CLOSED", "sub_1", "auth-required: must authenticate"]' + Then onError should be called with "sub_1" and message containing "auth-required" + + Scenario: NOTICE message is logged as warning + When the relay sends '["NOTICE", "rate-limited: slow down"]' + Then a console warning should be emitted with "rate-limited: slow down" + + Scenario: AUTH message triggers NIP-42 authentication flow + When the relay sends '["AUTH", "challenge-string-abc"]' + Then the client should send an AUTH event with kind 22242 + And the AUTH event should contain a "relay" tag with the relay URL + And the AUTH event should contain a "challenge" tag with "challenge-string-abc" + And all subscriptions should be resubscribed after a 100ms delay + + # [EG] — Malformed and garbage messages + Scenario: Malformed JSON message is silently ignored + When the relay sends "this is not JSON" + Then no error should be thrown + + Scenario: Non-array JSON message is silently ignored + When the relay sends '{"type": "EVENT"}' + Then no error should be thrown + + Scenario: Empty array message is silently ignored + When the relay sends '[]' + Then no error should be thrown + + Scenario: Single-element array message is silently ignored + When the relay sends '["EVENT"]' + Then no error should be thrown + + Scenario: Unknown message type is silently ignored + When the relay sends '["UNKNOWN_TYPE", "data"]' + Then no error should be thrown + + # [EG] — Invalid event data in EVENT message + Scenario: EVENT message with invalid event JSON is silently ignored + Given I have an active subscription "sub_1" with a listener + When the relay sends '["EVENT", "sub_1", {"invalid": "event"}]' + Then the listener's onEvent should NOT be called + + # [SC] — Branch: lastPongTime update on any message + Scenario: Any relay message updates the lastPongTime + Given the relay last-pong time was 60 seconds ago + When the relay sends '["NOTICE", "hello"]' + Then the relay last-pong time should be updated to now + + +# ============================================================================= +# FEATURE 3: NostrClient — Event Publishing +# ============================================================================= + +Feature: NostrClient event publishing + As a Nostr application developer + I want to publish events to connected relays + So that other users can receive them + + Background: + Given a NostrKeyManager with a generated key pair + And a NostrClient created with that key manager + + # --- Equivalence Partitioning [EP] --- + # Valid: connected to 1+ relays, event is well-formed + # Invalid: not connected, client disconnected + + Scenario: Publishing event broadcasts to all connected relays + Given I am connected to "wss://relay1.example.com" and "wss://relay2.example.com" + When I publish a text note event with content "Hello Nostr" + Then the event should be sent to both relays as '["EVENT", ...]' + And the publish promise should eventually resolve with the event ID + + Scenario: Publishing event broadcasts only to connected relays (skips disconnected) + Given I am connected to "wss://relay1.example.com" + And "wss://relay2.example.com" has a disconnected socket + When I publish a text note event + Then the event should be sent only to relay1 + + # [ST] — Offline queuing + Scenario: Publishing when not connected queues the event + Given I am not connected to any relay + When I publish a text note event + Then the event should be queued + And the publish promise should remain pending + + Scenario: Queued events are flushed upon connection + Given I published 3 events while offline (queued) + When I connect to "wss://relay.example.com" + Then all 3 queued events should be broadcast to the relay + And all 3 publish promises should resolve + + Scenario: Queued events are rejected on disconnect + Given I published 2 events while offline (queued) + When I call disconnect + Then both publish promises should reject with "Client disconnected" + + # [BVA] — OK timeout behavior (5 second timeout) + Scenario: Publish resolves after 5 seconds even without OK response + Given I am connected to "wss://relay.example.com" + When I publish a text note event + And the relay never sends an OK response + And 5 seconds elapse + Then the publish promise should resolve with the event ID (optimistic) + + Scenario: OK response clears the pending timeout + Given I am connected to "wss://relay.example.com" + When I publish a text note event + And the relay sends OK accepted immediately + Then the publish promise should resolve with the event ID + And the pending OK timer should be cleared + + # [EP] — Publishing specific event types through convenience methods + Scenario: Publishing an encrypted direct message (NIP-04) + Given I am connected to "wss://relay.example.com" + And a recipient key pair + When I call publishEncryptedMessage with recipient pubkey and "secret" + Then the published event kind should be 4 + And the event should have a "p" tag with the recipient pubkey + And the content should be NIP-04 encrypted + + Scenario: Publishing a nametag binding + Given I am connected to "wss://relay.example.com" + When I call publishNametagBinding with "alice" and an address + Then the published event should be a kind 30078 event + And publishNametagBinding should return true + + Scenario: publishNametagBinding returns false on publish failure + Given I am connected to "wss://relay.example.com" + And the relay rejects all events + When I call publishNametagBinding with "alice" and an address + Then publishNametagBinding should return false + + Scenario: createAndPublishEvent creates and publishes a signed event + Given I am connected to "wss://relay.example.com" + When I call createAndPublishEvent with kind 1 and content "test" + Then the published event should be signed by my key + And the publish promise should resolve with the event ID + + +# ============================================================================= +# FEATURE 4: NostrClient — Subscriptions +# ============================================================================= + +Feature: NostrClient subscription management + As a Nostr application developer + I want to subscribe to events matching filters + So that I can receive real-time updates from relays + + Background: + Given a connected NostrClient with a mock relay + + # --- Equivalence Partitioning [EP] --- + # Valid: auto-generated ID, custom ID + # Invalid: unsubscribe non-existent ID + + Scenario: Subscribe with auto-generated ID + When I subscribe with a filter for kind 1 events + Then the subscription ID should match pattern "sub_\d+" + And a REQ message should be sent to the relay + + Scenario: Subscribe with custom ID + When I subscribe with ID "my-custom-sub" and a filter for kind 1 events + Then the subscription ID should be "my-custom-sub" + And a REQ message with ID "my-custom-sub" should be sent to the relay + + Scenario: Auto-generated IDs are sequential + When I create 3 subscriptions + Then the IDs should be "sub_1", "sub_2", "sub_3" + + Scenario: Subscribe sends REQ to all connected relays + Given I am connected to 3 relays + When I subscribe with a filter + Then each relay should receive a REQ message + + Scenario: Subscribe while not connected stores subscription locally + Given I am not connected to any relay + When I subscribe with a filter for kind 1 events + Then the subscription should be stored + And no REQ message should be sent (no connected relays) + + # [ST] — Stored subscriptions re-established on connect + Scenario: Subscriptions are re-established after reconnection + Given I have subscriptions "sub_1" and "sub_2" before reconnection + When the relay reconnects + Then both subscriptions should be sent as REQ messages to the relay + + Scenario: Unsubscribe sends CLOSE to all connected relays + Given I have an active subscription "sub_1" + When I unsubscribe from "sub_1" + Then a CLOSE message for "sub_1" should be sent to all relays + And the subscription should be removed from internal tracking + + Scenario: Unsubscribe with unknown ID does nothing + When I unsubscribe from "non_existent_sub" + Then no CLOSE message should be sent + And no error should be thrown + + # [LC] — Loop: multiple subscriptions concurrently + Scenario: Managing 100 concurrent subscriptions + When I create 100 subscriptions with different filters + Then all 100 should be stored internally + And all 100 REQ messages should be sent to the relay + When I unsubscribe from all 100 + Then all 100 CLOSE messages should be sent + And no subscriptions should remain + + +# ============================================================================= +# FEATURE 5: NostrClient — Reconnection & Health +# ============================================================================= + +Feature: NostrClient automatic reconnection + As a Nostr application developer + I want the client to automatically reconnect on connection loss + So that the application remains resilient to network issues + + Background: + Given a NostrKeyManager with a generated key pair + + # --- State Transition Testing [ST] --- + # Connected → socket.onclose → scheduleReconnect → Reconnecting → connectToRelay → Connected + # Connected → socket.onclose → (autoReconnect=false) → stays Disconnected + + Scenario: Auto-reconnect after connection loss + Given a NostrClient with autoReconnect enabled (default) + And I am connected to "wss://relay.example.com" + When the WebSocket fires onclose + Then the client should emit a "disconnect" event + And the client should schedule a reconnection attempt + + Scenario: No auto-reconnect when disabled + Given a NostrClient with autoReconnect set to false + And I am connected to "wss://relay.example.com" + When the WebSocket fires onclose + Then the client should emit a "disconnect" event + And the client should NOT schedule a reconnection attempt + + # [BVA] — Exponential backoff boundaries + Scenario Outline: Exponential backoff delay calculation + Given reconnectIntervalMs is and maxReconnectIntervalMs is + When reconnect attempt is scheduled + Then the delay should be ms + + Examples: + | base | max | attempt | expected_delay | + | 1000 | 30000 | 1 | 1000 | + | 1000 | 30000 | 2 | 2000 | + | 1000 | 30000 | 3 | 4000 | + | 1000 | 30000 | 4 | 8000 | + | 1000 | 30000 | 5 | 16000 | + | 1000 | 30000 | 6 | 30000 | + | 1000 | 30000 | 100 | 30000 | + | 500 | 10000 | 1 | 500 | + | 500 | 10000 | 5 | 8000 | + | 500 | 10000 | 6 | 10000 | + + Scenario: Reconnection resets attempt counter on success + Given I am reconnecting after 5 failed attempts + When the reconnection succeeds + Then the reconnect attempt counter should reset to 0 + + Scenario: Reconnection emits "reconnected" event (not "connect") + Given I was previously connected to "wss://relay.example.com" + When the client successfully reconnects + Then the "reconnected" event should be emitted (not "connect") + + Scenario: First connection emits "connect" event + When I connect to "wss://relay.example.com" for the first time + Then the "connect" event should be emitted + + # --- Connection Event Listeners [DT] --- + # Listener has | Event Type | Expected callback + # onConnect | connect | onConnect called + # onConnect | disconnect | nothing (no onDisconnect) + # all methods | reconnecting | onReconnecting called with attempt number + # all methods | reconnected | onReconnected called + + Scenario: Connection listener errors are swallowed + Given a connection listener whose onConnect throws an exception + When the client connects to a relay + Then the connection should succeed + And the error from the listener should be swallowed + + # --- Ping/Health Check [ST][BVA] --- + Scenario: Ping sends subscription request as health check + Given I am connected with pingIntervalMs set to 30000 + When 30 seconds elapse + Then the client should send a CLOSE "ping" then REQ "ping" with limit:1 + + Scenario: Stale connection is force-closed after 2x ping interval + Given I am connected with pingIntervalMs set to 30000 + And the relay has not sent any message for 61 seconds + When the next ping check runs + Then the socket should be force-closed + And reconnection should be triggered + + # [BVA] — Exactly at the stale threshold + Scenario: Connection at exactly 2x ping interval is not considered stale + Given I am connected with pingIntervalMs set to 30000 + And the relay last message was exactly 60000ms ago + When the next ping check runs + Then the connection should NOT be force-closed + + Scenario: Ping disabled when pingIntervalMs is 0 + Given a NostrClient with pingIntervalMs set to 0 + When I connect to a relay + Then no ping timer should be started + + Scenario: Ping timer is stopped on disconnect + Given I am connected with active ping timer + When the WebSocket fires onclose + Then the ping timer should be cleared + + Scenario: Ping send failure triggers reconnect + Given I am connected to a relay + And the socket.send throws an error + When the ping check runs + Then the socket should be force-closed + + +# ============================================================================= +# FEATURE 6: NostrClient — NIP-17 Private Messaging +# ============================================================================= + +Feature: NostrClient NIP-17 private message integration + As a Nostr application user + I want to send and receive private messages via the client + So that my communication is end-to-end encrypted with sender anonymity + + Background: + Given a connected NostrClient "Alice" with a mock relay + And a separate NostrKeyManager "Bob" + + # --- Use Case Testing [UC] --- + + Scenario: Send a private message via gift-wrapping + When Alice sends a private message "Hello Bob" to Bob's pubkey + Then a kind 1059 (gift wrap) event should be published + And the gift wrap's "p" tag should contain Bob's pubkey + And the gift wrap should be signed by an ephemeral key (not Alice's) + + Scenario: Send a private message to a nametag (resolved via relay) + Given Bob has a nametag binding "bob123" published on the relay + When Alice sends a private message to nametag "bob123" + Then the client should first query the relay for "bob123" + And then send a gift-wrapped message to Bob's resolved pubkey + + Scenario: Send private message to unknown nametag fails + Given no nametag binding exists for "unknown-user" + When Alice sends a private message to nametag "unknown-user" + Then the promise should reject with "Nametag not found: unknown-user" + + Scenario: Send a read receipt + When Alice sends a read receipt to Bob for message "event-id-123" + Then a kind 1059 (gift wrap) event should be published + And the inner rumor should be kind 15 (read receipt) + + Scenario: Unwrap a received gift-wrapped message + Given a gift-wrapped message from Bob to Alice + When Alice calls unwrapPrivateMessage + Then the result should contain Bob's pubkey as sender + And the result should contain the decrypted message content + + # [EG] — Reply message + Scenario: Send a reply to a previous message + When Alice sends a private message "reply text" to Bob with replyToEventId "prev-event-id" + Then the inner rumor should have an "e" tag with "prev-event-id" + + +# ============================================================================= +# FEATURE 7: NostrClient — Token Transfer & Payment via Client +# ============================================================================= + +Feature: NostrClient token transfer and payment request delegation + As a Nostr application developer + I want convenience methods on the client for transfers and payments + So that I don't have to construct protocol events manually + + Background: + Given a connected NostrClient with a mock relay + + # --- Use Case Testing [UC] --- + + Scenario: Send token transfer + When I call sendTokenTransfer with recipient pubkey and token JSON '{"id":"tok1"}' + Then a kind 31113 event should be published + And the content should be NIP-04 encrypted + + Scenario: Send token transfer with amount and symbol + When I call sendTokenTransfer with options amount=100 and symbol="ALPHA" + Then the published event should have an "amount" tag with "100" + And the published event should have a "symbol" tag with "ALPHA" + + Scenario: Send token transfer as reply to payment request + When I call sendTokenTransfer with replyToEventId "req-event-id" + Then the published event should have an "e" tag with "req-event-id" + + Scenario: Send payment request + When I call sendPaymentRequest with target pubkey and amount 1000000000n and coinId "0x01" + Then a kind 31115 event should be published + + Scenario: Send payment request decline + When I call sendPaymentRequestDecline with original sender pubkey, eventId, and requestId + Then a kind 31116 response event should be published + And the response status should be "DECLINED" + + Scenario: Send payment request response with EXPIRED status + When I call sendPaymentRequestResponse with status "EXPIRED" + Then the response status should be "EXPIRED" + + +# ============================================================================= +# FEATURE 8: NostrClient — Nametag Query +# ============================================================================= + +Feature: NostrClient nametag query + As a Nostr application developer + I want to look up public keys by nametag + So that I can address users by human-readable identifiers + + Background: + Given a connected NostrClient with a mock relay + + # --- State Transition Testing [ST] --- + # Query states: Pending → (receive events) → EOSE → Resolved + # Query states: Pending → (timeout) → Resolved(null) + + Scenario: Query resolves with pubkey on EOSE + Given a nametag binding event for "alice" with pubkey "abc123" exists on the relay + When I call queryPubkeyByNametag("alice") + And the relay sends the binding event followed by EOSE + Then the promise should resolve with "abc123" + + Scenario: Query resolves with null when no binding exists + When I call queryPubkeyByNametag("nobody") + And the relay sends EOSE with no events + Then the promise should resolve with null + + Scenario: Query returns most recent binding when multiple exist + Given two nametag binding events for "alice": + | pubkey | created_at | + | old123 | 1000 | + | new456 | 2000 | + When I call queryPubkeyByNametag("alice") + And the relay sends both events followed by EOSE + Then the promise should resolve with "new456" + + # [BVA] — Query timeout + Scenario: Query times out and resolves with null + Given queryTimeoutMs is 5000 + When I call queryPubkeyByNametag("slow-lookup") + And 5 seconds elapse without EOSE + Then the promise should resolve with null + And the subscription should be cleaned up + + Scenario: Query timeout respects custom timeout value + Given queryTimeoutMs is set to 1000 + When I call queryPubkeyByNametag("test") + And 1 second elapses without EOSE + Then the promise should resolve with null + + +# ============================================================================= +# FEATURE 9: NostrClient — Disconnect Cleanup +# ============================================================================= + +Feature: NostrClient disconnect cleanup + As a Nostr application developer + I want disconnect to clean up all resources + So that there are no resource leaks or dangling promises + + Background: + Given a NostrClient connected to 2 relays with active subscriptions + + # --- Risk-Based Testing [RB] --- + + Scenario: Disconnect clears all pending OK promises + Given I have 3 pending OK acknowledgments + When I call disconnect + Then all 3 pending promises should reject with "Client disconnected" + And the pendingOks map should be empty + + Scenario: Disconnect rejects all queued events + Given I have 2 events queued for offline delivery + When I call disconnect + Then both queued event promises should reject with "Client disconnected" + And the event queue should be empty + + Scenario: Disconnect closes all WebSocket connections + When I call disconnect + Then socket.close(1000, "Client disconnected") should be called for each relay + And each relay should receive a "disconnect" event emission + + Scenario: Disconnect clears all timers + Given relay1 has an active ping timer + And relay2 has an active reconnect timer + When I call disconnect + Then both timers should be cleared + + Scenario: Disconnect clears all subscriptions + Given I have 5 active subscriptions + When I call disconnect + Then the subscriptions map should be empty + + Scenario: Disconnect clears relay map + When I call disconnect + Then the relays map should be empty + + +# ============================================================================= +# FEATURE 10: WebSocketAdapter — Message Extraction +# ============================================================================= + +Feature: WebSocket message data extraction + As the NostrClient internals + I want to extract string data from various WebSocket message formats + So that relay messages can be parsed regardless of platform + + # --- Equivalence Partitioning [EP] --- + # Valid partitions: string data, ArrayBuffer data, Node.js Buffer data + # Invalid partitions: Blob data, unknown type + + Scenario: Extract string data from string message + Given a WebSocket message event with data "hello" + When I call extractMessageData + Then the result should be "hello" + + Scenario: Extract string data from ArrayBuffer message + Given a WebSocket message event with data as ArrayBuffer of "hello" + When I call extractMessageData + Then the result should be "hello" + + Scenario: Extract string data from Node.js Buffer message + Given a WebSocket message event with data as Buffer of "hello" + When I call extractMessageData + Then the result should be "hello" + + Scenario: Blob message throws an error + Given a WebSocket message event with Blob data + When I call extractMessageData + Then it should throw "Blob messages are not supported" + + # [EG] — Unusual data types + Scenario: Numeric data is converted to string + Given a WebSocket message event with data as number 42 + When I call extractMessageData + Then the result should be "42" + + # [BVA] — Empty string + Scenario: Empty string data returns empty string + Given a WebSocket message event with data "" + When I call extractMessageData + Then the result should be "" + + # [BVA] — Large message + Scenario: Very large ArrayBuffer message is extracted correctly + Given a WebSocket message event with a 1MB ArrayBuffer of repeated text + When I call extractMessageData + Then the result should be the full decoded text + + # [PW] — Platform combinations + Scenario: createWebSocket uses native WebSocket in browser environment + Given the global WebSocket constructor is available + When I call createWebSocket("wss://relay.example.com") + Then it should return a native WebSocket instance + + Scenario: createWebSocket uses ws package in Node.js environment + Given the global WebSocket constructor is NOT available + And the "ws" package is importable + When I call createWebSocket("wss://relay.example.com") + Then it should return a ws WebSocket instance + + Scenario: createWebSocket throws when no WebSocket is available + Given the global WebSocket constructor is NOT available + And the "ws" package import fails + When I call createWebSocket("wss://relay.example.com") + Then it should throw 'WebSocket not available. In Node.js, install the "ws" package' + + +# ============================================================================= +# FEATURE 11: NostrKeyManager — NIP-44 Encryption Methods +# ============================================================================= + +Feature: NostrKeyManager NIP-44 encryption + As a Nostr application developer + I want to use NIP-44 encryption through the KeyManager + So that I can use modern XChaCha20-Poly1305 encryption without raw key handling + + Background: + Given a NostrKeyManager "Alice" with a generated key pair + And a NostrKeyManager "Bob" with a generated key pair + + # --- Equivalence Partitioning [EP] --- + # Valid: normal message, unicode, long message + # Invalid: cleared key manager, empty message + + Scenario: Encrypt and decrypt a message with NIP-44 (bytes keys) + When Alice encrypts "Hello Bob" with NIP-44 using Bob's public key bytes + And Bob decrypts the result with NIP-44 using Alice's public key bytes + Then the decrypted message should be "Hello Bob" + + Scenario: Encrypt and decrypt a message with NIP-44 (hex keys) + When Alice encrypts "Hello Bob" with encryptNip44Hex using Bob's public key hex + And Bob decrypts the result with decryptNip44Hex using Alice's public key hex + Then the decrypted message should be "Hello Bob" + + Scenario: NIP-44 encryption produces different ciphertext each time + When Alice encrypts "same message" with NIP-44 twice + Then the two ciphertexts should be different (random nonce) + + Scenario: NIP-44 encryption with unicode content + When Alice encrypts "Привіт 🌍 مرحبا" with NIP-44 using Bob's public key + And Bob decrypts the result + Then the decrypted message should be "Привіт 🌍 مرحبا" + + # [BVA] — Message length boundaries + Scenario: NIP-44 encryption of 1-byte message + When Alice encrypts "x" with NIP-44 using Bob's public key + And Bob decrypts the result + Then the decrypted message should be "x" + + Scenario: NIP-44 encryption of maximum-length message (65535 bytes) + Given a message of exactly 65535 bytes + When Alice encrypts it with NIP-44 using Bob's public key + And Bob decrypts the result + Then the decrypted message should match the original + + Scenario: NIP-44 encryption of message exceeding max length rejects + Given a message of 65536 bytes + When Alice tries to encrypt it with NIP-44 + Then it should throw "Message too long" + + # [EP] — Wrong key decryption + Scenario: Decrypting with wrong key fails + Given a third NostrKeyManager "Eve" + When Alice encrypts "secret" with NIP-44 for Bob + And Eve tries to decrypt the result using Alice's public key + Then it should throw an error (authentication failure) + + # [ST] — Cleared key manager + Scenario: NIP-44 encryption fails after key manager is cleared + When Alice's key manager is cleared + Then calling encryptNip44 should throw "KeyManager has been cleared" + And calling decryptNip44 should throw "KeyManager has been cleared" + And calling encryptNip44Hex should throw "KeyManager has been cleared" + And calling decryptNip44Hex should throw "KeyManager has been cleared" + And calling deriveConversationKey should throw "KeyManager has been cleared" + + # --- Conversation Key Derivation --- + + Scenario: Derive conversation key produces consistent result + When Alice derives a conversation key with Bob's public key + And Alice derives the conversation key again with Bob's public key + Then both keys should be identical + + Scenario: Conversation key is symmetric (A→B equals B→A) + When Alice derives a conversation key with Bob's public key + And Bob derives a conversation key with Alice's public key + Then both conversation keys should be identical + + Scenario: Different key pairs produce different conversation keys + Given a third NostrKeyManager "Charlie" + When Alice derives conversation key with Bob + And Alice derives conversation key with Charlie + Then the two conversation keys should differ + + +# ============================================================================= +# FEATURE 12: CallbackEventListener +# ============================================================================= + +Feature: CallbackEventListener + As a developer + I want a convenience class for creating event listeners from callbacks + So that I can use inline functions instead of implementing the full interface + + # --- Equivalence Partitioning [EP] --- + + Scenario: onEvent callback is invoked + Given a CallbackEventListener with an onEvent callback + When onEvent is called with an Event + Then the callback should receive the event + + Scenario: onEndOfStoredEvents callback is invoked when provided + Given a CallbackEventListener with onEvent and onEndOfStoredEvents callbacks + When onEndOfStoredEvents is called with "sub_1" + Then the EOSE callback should receive "sub_1" + + Scenario: onEndOfStoredEvents does not throw when not provided + Given a CallbackEventListener with only an onEvent callback + When onEndOfStoredEvents is called with "sub_1" + Then no error should be thrown + + Scenario: onError callback is invoked when provided + Given a CallbackEventListener with all three callbacks + When onError is called with "sub_1" and "connection lost" + Then the error callback should receive "sub_1" and "connection lost" + + Scenario: onError does not throw when not provided + Given a CallbackEventListener with only an onEvent callback + When onError is called with "sub_1" and "error" + Then no error should be thrown + + +# ============================================================================= +# FEATURE 13: Edge Cases — NIP-04 Corrupted Data +# ============================================================================= + +Feature: NIP-04 corrupted and malformed data handling + As the encryption layer + I want to handle corrupted inputs gracefully + So that the application doesn't crash on bad data + + # --- Error Guessing [EG] --- + + Scenario: Decrypt with corrupted base64 ciphertext + Given an encrypted NIP-04 message + When I corrupt the base64 ciphertext portion + And I attempt to decrypt + Then it should throw an error (decryption failure) + + Scenario: Decrypt with corrupted IV + Given an encrypted NIP-04 message + When I corrupt the IV portion + And I attempt to decrypt + Then it should throw an error + + Scenario: Decrypt with missing IV separator + When I attempt to decrypt "justbase64withoutiv" + Then it should throw an error about invalid format + + Scenario: Decrypt with empty ciphertext + When I attempt to decrypt "?iv=aGVsbG8=" + Then it should throw an error + + Scenario: Decrypt compressed message with corrupted GZIP data + When I attempt to decrypt "gz:invalidbase64data?iv=aGVsbG8=" + Then it should throw an error + + Scenario: Decrypt message with truncated ciphertext + Given an encrypted NIP-04 message + When I truncate the ciphertext to half its length + And I attempt to decrypt + Then it should throw an error + + +# ============================================================================= +# FEATURE 14: Edge Cases — NIP-44 Corrupted Data +# ============================================================================= + +Feature: NIP-44 corrupted and malformed data handling + As the encryption layer + I want to handle corrupted NIP-44 inputs gracefully + So that authentication failures are caught + + # --- Error Guessing [EG] --- + + Scenario: Decrypt with corrupted base64 payload + Given a valid NIP-44 encrypted message + When I corrupt a byte in the ciphertext portion + And I attempt to decrypt + Then it should throw an error (authentication failure / Poly1305 MAC mismatch) + + Scenario: Decrypt with wrong version byte + Given a NIP-44 payload with version byte 0x01 instead of 0x02 + When I attempt to decrypt + Then it should throw "Unsupported NIP-44 version: 1" + + Scenario: Decrypt with version byte 0x00 + Given a NIP-44 payload with version byte 0x00 + When I attempt to decrypt + Then it should throw "Unsupported NIP-44 version: 0" + + # [BVA] — Payload too short + Scenario: Decrypt with payload shorter than minimum + Given a base64 payload of only 10 bytes + When I attempt to decrypt with NIP-44 + Then it should throw "Payload too short" + + Scenario: Decrypt with exactly minimum valid payload length + Given a base64 payload of exactly 1 + 24 + 32 + 16 = 73 bytes (but invalid crypto) + When I attempt to decrypt with NIP-44 + Then it should throw an error (crypto failure, not "too short") + + Scenario: Decrypt with truncated nonce + Given a NIP-44 payload with correct version but only 10 bytes of nonce + When I attempt to decrypt + Then it should throw "Payload too short" + + +# ============================================================================= +# FEATURE 15: Edge Cases — NIP-44 Padding +# ============================================================================= + +Feature: NIP-44 padding edge cases + As the encryption layer + I want padding to work correctly at all boundary values + So that message lengths are properly hidden + + # --- Boundary Value Analysis [BVA] --- + + Scenario Outline: calcPaddedLen returns correct padded length + When I calculate padded length for + Then the result should be + + Examples: + | input_len | expected | + | 1 | 32 | + | 31 | 32 | + | 32 | 32 | + | 33 | 64 | + | 37 | 64 | + | 63 | 64 | + | 64 | 64 | + | 65 | 96 | + | 100 | 128 | + | 255 | 256 | + | 256 | 256 | + | 257 | 320 | + | 1000 | 1024 | + | 65535 | 65536 | + + # [BVA] — Invalid inputs + Scenario: calcPaddedLen rejects zero length + When I call calcPaddedLen(0) + Then it should throw "Message too short" + + Scenario: calcPaddedLen rejects negative length + When I call calcPaddedLen(-1) + Then it should throw "Message too short" + + Scenario: calcPaddedLen rejects length exceeding maximum + When I call calcPaddedLen(65536) + Then it should throw "Message too long" + + # --- Pad/Unpad roundtrip [EP] --- + Scenario: pad and unpad roundtrip preserves message + Given a message of 50 bytes + When I pad the message + And I unpad the result + Then the output should equal the original message + + Scenario: Unpad rejects payload with wrong padding length + Given a padded message with deliberately incorrect padding size + When I call unpad + Then it should throw "Invalid padding" + + Scenario: Unpad rejects payload with zero length prefix + Given a padded message where the 2-byte length prefix is 0 + When I call unpad + Then it should throw "Invalid message length: 0" + + +# ============================================================================= +# FEATURE 16: Edge Cases — Event Handling +# ============================================================================= + +Feature: Event edge cases + As the Nostr protocol layer + I want events to handle unusual inputs correctly + + # --- Error Guessing [EG] --- + + Scenario: Event with empty tags array + When I create an event with kind 1, empty tags, and content "test" + Then the event should be created successfully + And getTagValue for any tag should return undefined + + Scenario: Event with many tags (1000+) + When I create an event with 1000 tags + Then the event should be created and signed successfully + And the event ID should be valid + + Scenario: Event with empty content string + When I create an event with content "" + Then the event should be created successfully + + # [BVA] — Tag access methods + Scenario: getTagEntryValues returns all entries for a tag name + Given an event with tags [["p","pk1"],["p","pk2"],["p","pk3"]] + When I call getTagEntryValues("p") + Then the result should be [["p","pk1"],["p","pk2"],["p","pk3"]] + + Scenario: getTagValues returns values at index 1 for a tag name + Given an event with tags [["p","pk1","relay1"],["p","pk2","relay2"]] + When I call getTagValues("p") + Then the result should be ["pk1","pk2"] + + Scenario: hasTag returns false for non-existent tag + Given an event with tags [["p","pk1"]] + When I call hasTag("e") + Then the result should be false + + +# ============================================================================= +# FEATURE 17: Pairwise — Client Configuration Combinations +# ============================================================================= + +Feature: NostrClient configuration combinations + As a Nostr application developer + I want all combinations of client options to work correctly + + # --- Pairwise Testing [PW] --- + + Scenario Outline: Client works with various option combinations + Given a NostrClient with options: + | autoReconnect | | + | queryTimeoutMs | | + | pingIntervalMs | | + Then the client should be created successfully + And getQueryTimeout should return + + Examples: + | autoReconnect | queryTimeoutMs | pingIntervalMs | + | true | 5000 | 30000 | + | true | 1000 | 0 | + | false | 5000 | 0 | + | false | 10000 | 30000 | + | true | 30000 | 60000 | + | false | 100 | 10000 | + + +# ============================================================================= +# FEATURE 18: Risk-Based — Security Critical Paths +# ============================================================================= + +Feature: Security-critical operations + As a security-conscious developer + I want to verify that sensitive operations behave correctly + So that no cryptographic keys or messages leak + + # --- Risk-Based Testing [RB] --- + + Scenario: Private key is not accessible after clear() + Given a NostrKeyManager with a key pair + When I call clear() + Then getPrivateKey should throw "KeyManager has been cleared" + And getPrivateKeyHex should throw "KeyManager has been cleared" + And getNsec should throw "KeyManager has been cleared" + And sign should throw "KeyManager has been cleared" + And encrypt should throw "KeyManager has been cleared" + + Scenario: Private key memory is zeroed on clear + Given a NostrKeyManager with a key pair + When I call clear() + Then the internal private key buffer should contain all zeros + + Scenario: getPrivateKey returns a copy, not a reference + Given a NostrKeyManager with a key pair + When I get the private key + And I modify the returned array + Then getting the private key again should return the original unmodified key + + Scenario: Gift wrap does not leak sender identity + Given a gift-wrapped message from Alice to Bob + Then the gift wrap event's pubkey should NOT be Alice's pubkey + And the gift wrap event's signature should be from an ephemeral key + + Scenario: NIP-44 conversation key is symmetric + When Alice derives conversation key with Bob's public key + And Bob derives conversation key with Alice's public key + Then both keys should be byte-identical + + Scenario: NIP-17 timestamps are randomized for privacy + When Alice creates 20 gift wraps + Then the created_at timestamps should vary (not all the same) + And all timestamps should be within ±2 days of current time + + Scenario: AUTH event contains correct challenge-response + Given a relay sends AUTH challenge "test-challenge-123" + When the client responds with an AUTH event + Then the AUTH event kind should be 22242 + And the AUTH event should have tag ["relay", ""] + And the AUTH event should have tag ["challenge", "test-challenge-123"] + And the AUTH event should be signed by the client's key manager + + +# ============================================================================= +# FEATURE 19: Loop Testing — Subscription Re-establishment +# ============================================================================= + +Feature: Subscription re-establishment on reconnect + As a Nostr application developer + I want all subscriptions to be re-established after reconnect + So that no events are missed during brief disconnections + + # --- Loop Testing [LC] --- + + Scenario: Zero subscriptions — nothing to re-establish + Given I have no active subscriptions + When the relay reconnects + Then no REQ messages should be sent + + Scenario: One subscription is re-established + Given I have 1 active subscription + When the relay reconnects + Then exactly 1 REQ message should be sent + + Scenario: Many subscriptions are all re-established + Given I have 50 active subscriptions + When the relay reconnects + Then exactly 50 REQ messages should be sent + And each REQ should contain the original filter + + Scenario: Unsubscribed subscriptions are NOT re-established + Given I had 3 subscriptions and unsubscribed from 1 + When the relay reconnects + Then exactly 2 REQ messages should be sent + + +# ============================================================================= +# FEATURE 20: Exploratory — Concurrent Operations +# ============================================================================= + +Feature: Concurrent operations safety + As a Nostr application developer + I want concurrent operations to not corrupt state + + # --- Exploratory / Error Guessing [EG] --- + + Scenario: Publishing 100 events concurrently + Given I am connected to a relay + When I publish 100 events concurrently (Promise.all) + Then all 100 should be sent without errors + And the pending OKs map should have 100 entries + + Scenario: Subscribing and unsubscribing rapidly + When I subscribe and immediately unsubscribe 50 times + Then no subscriptions should remain + And no errors should be thrown + + Scenario: Disconnect while publish is pending + Given I am connected and have published an event awaiting OK + When I call disconnect before OK arrives + Then the pending publish should reject with "Client disconnected" + + Scenario: Connect to same relay URL concurrently + When I call connect("wss://relay.example.com") twice concurrently + Then only one WebSocket connection should be established + Or both connect calls should resolve without error diff --git a/tests/unit/callback-listener.test.ts b/tests/unit/callback-listener.test.ts new file mode 100644 index 0000000..c10157c --- /dev/null +++ b/tests/unit/callback-listener.test.ts @@ -0,0 +1,90 @@ +/** + * Unit tests for CallbackEventListener + * Feature 12: CallbackEventListener + * Techniques: [EP] Equivalence Partitioning + */ + +import { describe, it, expect, vi } from 'vitest'; +import { CallbackEventListener } from '../../src/client/NostrEventListener.js'; +import { Event } from '../../src/protocol/Event.js'; +import { NostrKeyManager } from '../../src/NostrKeyManager.js'; +import * as EventKinds from '../../src/protocol/EventKinds.js'; + +describe('CallbackEventListener', () => { + const keyManager = NostrKeyManager.generate(); + + function createTestEvent(): Event { + return Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content: 'test', + }); + } + + // [EP] Valid: onEvent callback invoked + it('should invoke onEvent callback with the event', () => { + const onEvent = vi.fn(); + const listener = new CallbackEventListener(onEvent); + const event = createTestEvent(); + + listener.onEvent(event); + + expect(onEvent).toHaveBeenCalledOnce(); + expect(onEvent).toHaveBeenCalledWith(event); + }); + + // [EP] Valid: onEndOfStoredEvents callback invoked when provided + it('should invoke onEndOfStoredEvents callback when provided', () => { + const onEvent = vi.fn(); + const onEose = vi.fn(); + const listener = new CallbackEventListener(onEvent, onEose); + + listener.onEndOfStoredEvents('sub_1'); + + expect(onEose).toHaveBeenCalledOnce(); + expect(onEose).toHaveBeenCalledWith('sub_1'); + }); + + // [EP] Valid: onEndOfStoredEvents does not throw when not provided + it('should not throw when onEndOfStoredEvents is called without callback', () => { + const onEvent = vi.fn(); + const listener = new CallbackEventListener(onEvent); + + expect(() => listener.onEndOfStoredEvents('sub_1')).not.toThrow(); + }); + + // [EP] Valid: onError callback invoked when provided + it('should invoke onError callback when provided', () => { + const onEvent = vi.fn(); + const onEose = vi.fn(); + const onError = vi.fn(); + const listener = new CallbackEventListener(onEvent, onEose, onError); + + listener.onError('sub_1', 'connection lost'); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith('sub_1', 'connection lost'); + }); + + // [EP] Valid: onError does not throw when not provided + it('should not throw when onError is called without callback', () => { + const onEvent = vi.fn(); + const listener = new CallbackEventListener(onEvent); + + expect(() => listener.onError('sub_1', 'error')).not.toThrow(); + }); + + // Multiple events + it('should handle multiple events', () => { + const onEvent = vi.fn(); + const listener = new CallbackEventListener(onEvent); + + const event1 = createTestEvent(); + const event2 = createTestEvent(); + + listener.onEvent(event1); + listener.onEvent(event2); + + expect(onEvent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/event-edge-cases.test.ts b/tests/unit/event-edge-cases.test.ts new file mode 100644 index 0000000..b2f8218 --- /dev/null +++ b/tests/unit/event-edge-cases.test.ts @@ -0,0 +1,220 @@ +/** + * Unit tests for Event edge cases + * Feature 16: Event Edge Cases + * Techniques: [EG] Error Guessing, [BVA] Boundary Value Analysis + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { Event } from '../../src/protocol/Event.js'; +import { NostrKeyManager } from '../../src/NostrKeyManager.js'; +import * as EventKinds from '../../src/protocol/EventKinds.js'; + +describe('Event Edge Cases', () => { + let keyManager: NostrKeyManager; + + beforeEach(() => { + keyManager = NostrKeyManager.generate(); + }); + + // [EG] Empty tags array + it('should create event with empty tags array', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content: 'test', + }); + + expect(event.tags).toEqual([]); + expect(event.getTagValue('p')).toBeUndefined(); + expect(event.getTagValue('e')).toBeUndefined(); + expect(event.getTagValues('p')).toEqual([]); + expect(event.hasTag('p')).toBe(false); + expect(event.verify()).toBe(true); + }); + + // [EG] Event with many tags (1000+) + it('should create and sign event with 1000 tags', () => { + const tags: [string, string][] = []; + for (let i = 0; i < 1000; i++) { + tags.push(['p', `pubkey_${i.toString().padStart(4, '0')}`]); + } + + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags, + content: 'event with many tags', + }); + + expect(event.tags).toHaveLength(1000); + expect(event.id).toHaveLength(64); + expect(event.verify()).toBe(true); + }); + + // [EG] Empty content string + it('should create event with empty content', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content: '', + }); + + expect(event.content).toBe(''); + expect(event.verify()).toBe(true); + }); + + // [EG] Very long content + it('should create event with very long content', () => { + const content = 'A'.repeat(100000); + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content, + }); + + expect(event.content).toBe(content); + expect(event.verify()).toBe(true); + }); + + // [EG] Unicode content + it('should handle unicode content correctly', () => { + const content = '\ud83d\ude00\ud83c\udf89 \u0425\u0435\u043b\u043b\u043e \u4e16\u754c \u0645\u0631\u062d\u0628\u0627'; + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content, + }); + + expect(event.content).toBe(content); + expect(event.verify()).toBe(true); + }); + + // [BVA] getTagEntryValues returns all entries for a tag name + it('getTagEntryValues should return all entries for tag name', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [ + ['p', 'pk1', 'relay1'], + ['p', 'pk2', 'relay2'], + ['p', 'pk3'], + ['e', 'evt1'], + ], + content: 'test', + }); + + // getTagEntryValues returns values from first matching tag (beyond tag name) + const pValues = event.getTagEntryValues('p'); + expect(pValues).toEqual(['pk1', 'relay1']); + }); + + // [BVA] getTagValues returns all first-values for a tag name + it('getTagValues should return values at index 1 for all matching tags', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [ + ['p', 'pk1', 'relay1'], + ['p', 'pk2', 'relay2'], + ['e', 'evt1'], + ], + content: 'test', + }); + + expect(event.getTagValues('p')).toEqual(['pk1', 'pk2']); + expect(event.getTagValues('e')).toEqual(['evt1']); + }); + + // [BVA] hasTag returns false for non-existent tag + it('hasTag should return false for non-existent tag', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [['p', 'pk1']], + content: 'test', + }); + + expect(event.hasTag('p')).toBe(true); + expect(event.hasTag('e')).toBe(false); + expect(event.hasTag('t')).toBe(false); + expect(event.hasTag('')).toBe(false); + }); + + // [EG] Tags with empty values + it('should handle tags with empty string values', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [['d', '']], + content: 'test', + }); + + expect(event.getTagValue('d')).toBe(''); + expect(event.hasTag('d')).toBe(true); + expect(event.verify()).toBe(true); + }); + + // [EG] Deterministic ID calculation + it('should produce same ID for same inputs', () => { + const id1 = Event.calculateId('abc', 1000, 1, [['p', 'pk1']], 'content'); + const id2 = Event.calculateId('abc', 1000, 1, [['p', 'pk1']], 'content'); + expect(id1).toBe(id2); + }); + + // [EG] Different content produces different ID + it('should produce different IDs for different content', () => { + const id1 = Event.calculateId('abc', 1000, 1, [], 'content1'); + const id2 = Event.calculateId('abc', 1000, 1, [], 'content2'); + expect(id1).not.toBe(id2); + }); + + // [EG] Different kind produces different ID + it('should produce different IDs for different kinds', () => { + const id1 = Event.calculateId('abc', 1000, 1, [], 'content'); + const id2 = Event.calculateId('abc', 1000, 4, [], 'content'); + expect(id1).not.toBe(id2); + }); + + // [BVA] Event with kind 0 (metadata) + it('should create event with kind 0', () => { + const event = Event.create(keyManager, { + kind: 0, + tags: [], + content: '{"name":"test"}', + }); + + expect(event.kind).toBe(0); + expect(event.verify()).toBe(true); + }); + + // [EG] JSON roundtrip preserves all fields + it('should preserve all fields through JSON roundtrip', () => { + const original = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [['p', 'pk1', 'extra'], ['e', 'eid']], + content: 'hello world', + created_at: 1700000000, + }); + + const json = original.toJSON(); + const parsed = Event.fromJSON(json); + + expect(parsed.id).toBe(original.id); + expect(parsed.pubkey).toBe(original.pubkey); + expect(parsed.created_at).toBe(original.created_at); + expect(parsed.kind).toBe(original.kind); + expect(parsed.tags).toEqual(original.tags); + expect(parsed.content).toBe(original.content); + expect(parsed.sig).toBe(original.sig); + }); + + // [EG] JSON string parsing + it('should parse from JSON string', () => { + const original = Event.create(keyManager, { + kind: 1, + tags: [], + content: 'test', + }); + + const jsonString = JSON.stringify(original.toJSON()); + const parsed = Event.fromJSON(jsonString); + + expect(parsed.id).toBe(original.id); + expect(parsed.verify()).toBe(true); + }); +}); diff --git a/tests/unit/event-kinds-comprehensive.test.ts b/tests/unit/event-kinds-comprehensive.test.ts new file mode 100644 index 0000000..2536c5f --- /dev/null +++ b/tests/unit/event-kinds-comprehensive.test.ts @@ -0,0 +1,268 @@ +/** + * Comprehensive tests for EventKinds helper functions + * Covers isReplaceable, isEphemeral, isParameterizedReplaceable, getName + * Techniques: [BVA] Boundary Value Analysis, [EP] Equivalence Partitioning + */ + +import { describe, it, expect } from 'vitest'; +import * as EventKinds from '../../src/protocol/EventKinds.js'; + +describe('EventKinds Helper Functions', () => { + // ========================================================== + // isReplaceable + // ========================================================== + describe('isReplaceable', () => { + it('should return true for kind 0 (Profile)', () => { + expect(EventKinds.isReplaceable(0)).toBe(true); + }); + + it('should return false for kind 1 (Text Note)', () => { + expect(EventKinds.isReplaceable(1)).toBe(false); + }); + + it('should return false for kind 2', () => { + expect(EventKinds.isReplaceable(2)).toBe(false); + }); + + it('should return true for kind 3 (Contacts)', () => { + expect(EventKinds.isReplaceable(3)).toBe(true); + }); + + it('should return false for kind 4', () => { + expect(EventKinds.isReplaceable(4)).toBe(false); + }); + + // Boundary: 9999 (just below range) + it('should return false for kind 9999', () => { + expect(EventKinds.isReplaceable(9999)).toBe(false); + }); + + // Boundary: 10000 (start of range) + it('should return true for kind 10000', () => { + expect(EventKinds.isReplaceable(10000)).toBe(true); + }); + + // Inside range + it('should return true for kind 15000', () => { + expect(EventKinds.isReplaceable(15000)).toBe(true); + }); + + // Boundary: 19999 (end of range) + it('should return true for kind 19999', () => { + expect(EventKinds.isReplaceable(19999)).toBe(true); + }); + + // Boundary: 20000 (just above range) + it('should return false for kind 20000', () => { + expect(EventKinds.isReplaceable(20000)).toBe(false); + }); + }); + + // ========================================================== + // isEphemeral + // ========================================================== + describe('isEphemeral', () => { + it('should return false for kind 0', () => { + expect(EventKinds.isEphemeral(0)).toBe(false); + }); + + it('should return false for kind 1', () => { + expect(EventKinds.isEphemeral(1)).toBe(false); + }); + + // Boundary: 19999 (just below range) + it('should return false for kind 19999', () => { + expect(EventKinds.isEphemeral(19999)).toBe(false); + }); + + // Boundary: 20000 (start of range) + it('should return true for kind 20000', () => { + expect(EventKinds.isEphemeral(20000)).toBe(true); + }); + + // AUTH is in ephemeral range + it('should return true for AUTH kind (22242)', () => { + expect(EventKinds.isEphemeral(EventKinds.AUTH)).toBe(true); + }); + + // Inside range + it('should return true for kind 25000', () => { + expect(EventKinds.isEphemeral(25000)).toBe(true); + }); + + // Boundary: 29999 (end of range) + it('should return true for kind 29999', () => { + expect(EventKinds.isEphemeral(29999)).toBe(true); + }); + + // Boundary: 30000 (just above range) + it('should return false for kind 30000', () => { + expect(EventKinds.isEphemeral(30000)).toBe(false); + }); + }); + + // ========================================================== + // isParameterizedReplaceable + // ========================================================== + describe('isParameterizedReplaceable', () => { + it('should return false for kind 0', () => { + expect(EventKinds.isParameterizedReplaceable(0)).toBe(false); + }); + + it('should return false for kind 1', () => { + expect(EventKinds.isParameterizedReplaceable(1)).toBe(false); + }); + + // Boundary: 29999 (just below range) + it('should return false for kind 29999', () => { + expect(EventKinds.isParameterizedReplaceable(29999)).toBe(false); + }); + + // Boundary: 30000 (start of range) + it('should return true for kind 30000', () => { + expect(EventKinds.isParameterizedReplaceable(30000)).toBe(true); + }); + + // APP_DATA is parameterized replaceable + it('should return true for APP_DATA kind (30078)', () => { + expect(EventKinds.isParameterizedReplaceable(EventKinds.APP_DATA)).toBe(true); + }); + + // TOKEN_TRANSFER is parameterized replaceable + it('should return true for TOKEN_TRANSFER kind (31113)', () => { + expect(EventKinds.isParameterizedReplaceable(EventKinds.TOKEN_TRANSFER)).toBe(true); + }); + + // Inside range + it('should return true for kind 35000', () => { + expect(EventKinds.isParameterizedReplaceable(35000)).toBe(true); + }); + + // Boundary: 39999 (end of range) + it('should return true for kind 39999', () => { + expect(EventKinds.isParameterizedReplaceable(39999)).toBe(true); + }); + + // Boundary: 40000 (just above range) + it('should return false for kind 40000', () => { + expect(EventKinds.isParameterizedReplaceable(40000)).toBe(false); + }); + }); + + // ========================================================== + // getName + // ========================================================== + describe('getName', () => { + it('should return "Profile" for kind 0', () => { + expect(EventKinds.getName(0)).toBe('Profile'); + }); + + it('should return "Text Note" for kind 1', () => { + expect(EventKinds.getName(1)).toBe('Text Note'); + }); + + it('should return "Recommend Relay" for kind 2', () => { + expect(EventKinds.getName(2)).toBe('Recommend Relay'); + }); + + it('should return "Contacts" for kind 3', () => { + expect(EventKinds.getName(3)).toBe('Contacts'); + }); + + it('should return "Encrypted DM" for kind 4', () => { + expect(EventKinds.getName(4)).toBe('Encrypted DM'); + }); + + it('should return "Deletion" for kind 5', () => { + expect(EventKinds.getName(5)).toBe('Deletion'); + }); + + it('should return "Reaction" for kind 7', () => { + expect(EventKinds.getName(7)).toBe('Reaction'); + }); + + it('should return "Seal" for kind 13', () => { + expect(EventKinds.getName(13)).toBe('Seal'); + }); + + it('should return "Chat Message" for kind 14', () => { + expect(EventKinds.getName(14)).toBe('Chat Message'); + }); + + it('should return "Read Receipt" for kind 15', () => { + expect(EventKinds.getName(15)).toBe('Read Receipt'); + }); + + it('should return "Gift Wrap" for kind 1059', () => { + expect(EventKinds.getName(1059)).toBe('Gift Wrap'); + }); + + it('should return "Relay List" for kind 10002', () => { + expect(EventKinds.getName(10002)).toBe('Relay List'); + }); + + it('should return "App Data" for kind 30078', () => { + expect(EventKinds.getName(30078)).toBe('App Data'); + }); + + it('should return "Token Transfer" for kind 31113', () => { + expect(EventKinds.getName(31113)).toBe('Token Transfer'); + }); + + it('should return "Payment Request" for kind 31115', () => { + expect(EventKinds.getName(31115)).toBe('Payment Request'); + }); + + it('should return "Payment Request Response" for kind 31116', () => { + expect(EventKinds.getName(31116)).toBe('Payment Request Response'); + }); + + // Unknown kinds should include classification + it('should return "Replaceable (X)" for unknown replaceable kinds', () => { + expect(EventKinds.getName(10500)).toBe('Replaceable (10500)'); + }); + + it('should return "Ephemeral (X)" for unknown ephemeral kinds', () => { + expect(EventKinds.getName(25000)).toBe('Ephemeral (25000)'); + }); + + it('should return "Parameterized Replaceable (X)" for unknown parameterized kinds', () => { + expect(EventKinds.getName(35000)).toBe('Parameterized Replaceable (35000)'); + }); + + it('should return "Unknown (X)" for completely unknown kinds', () => { + expect(EventKinds.getName(999)).toBe('Unknown (999)'); + }); + }); + + // ========================================================== + // Constants existence + // ========================================================== + describe('constants', () => { + it('should have all standard NIP kinds defined', () => { + expect(EventKinds.PROFILE).toBe(0); + expect(EventKinds.TEXT_NOTE).toBe(1); + expect(EventKinds.RECOMMEND_RELAY).toBe(2); + expect(EventKinds.CONTACTS).toBe(3); + expect(EventKinds.ENCRYPTED_DM).toBe(4); + expect(EventKinds.DELETION).toBe(5); + expect(EventKinds.REACTION).toBe(7); + expect(EventKinds.SEAL).toBe(13); + expect(EventKinds.CHAT_MESSAGE).toBe(14); + expect(EventKinds.READ_RECEIPT).toBe(15); + expect(EventKinds.GIFT_WRAP).toBe(1059); + expect(EventKinds.RELAY_LIST).toBe(10002); + expect(EventKinds.AUTH).toBe(22242); + expect(EventKinds.APP_DATA).toBe(30078); + }); + + it('should have all Unicity custom kinds defined', () => { + expect(EventKinds.AGENT_PROFILE).toBe(31111); + expect(EventKinds.AGENT_LOCATION).toBe(31112); + expect(EventKinds.TOKEN_TRANSFER).toBe(31113); + expect(EventKinds.FILE_METADATA).toBe(31114); + expect(EventKinds.PAYMENT_REQUEST).toBe(31115); + expect(EventKinds.PAYMENT_REQUEST_RESPONSE).toBe(31116); + }); + }); +}); diff --git a/tests/unit/event-mutability.test.ts b/tests/unit/event-mutability.test.ts new file mode 100644 index 0000000..49e42c9 --- /dev/null +++ b/tests/unit/event-mutability.test.ts @@ -0,0 +1,304 @@ +/** + * Unit tests for Event mutability and equality edge cases + * Covers tags mutability issue found in Java SDK + * Techniques: [EG] Error Guessing, [RB] Risk-Based Testing + */ + +import { describe, it, expect } from 'vitest'; +import { Event } from '../../src/protocol/Event.js'; +import { NostrKeyManager } from '../../src/NostrKeyManager.js'; +import * as EventKinds from '../../src/protocol/EventKinds.js'; + +describe('Event Mutability Edge Cases', () => { + let keyManager: NostrKeyManager; + + beforeEach(() => { + keyManager = NostrKeyManager.generate(); + }); + + // ========================================================== + // Tags mutability (Java finding #8) + // ========================================================== + describe('tags mutability', () => { + it('tags array is directly exposed (known issue)', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [['p', 'pubkey1']], + content: 'test', + }); + + // WARNING: This is a known issue - tags is mutable + // Callers CAN modify internal state + const originalTagCount = event.tags.length; + event.tags.push(['e', 'eventid']); + + // Tags array is mutated + expect(event.tags.length).toBe(originalTagCount + 1); + + // This breaks the event integrity - signature no longer matches + expect(event.verify()).toBe(false); + }); + + it('modifying tags after creation invalidates signature', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [['t', 'topic']], + content: 'test', + }); + + expect(event.verify()).toBe(true); + + // Mutate tags + event.tags[0]![1] = 'modified'; + + // Signature is now invalid + expect(event.verify()).toBe(false); + }); + + it('clearing tags array invalidates signature', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [['p', 'pk1'], ['e', 'e1']], + content: 'test', + }); + + expect(event.verify()).toBe(true); + + // Clear tags + event.tags.length = 0; + + expect(event.verify()).toBe(false); + }); + }); + + // ========================================================== + // Content mutability + // ========================================================== + describe('content mutability', () => { + it('modifying content invalidates signature', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content: 'original', + }); + + expect(event.verify()).toBe(true); + + // Mutate content + event.content = 'modified'; + + expect(event.verify()).toBe(false); + }); + }); + + // ========================================================== + // Event equality (Java finding #4 about null IDs) + // ========================================================== + describe('event comparison', () => { + it('events with same ID are logically equal', () => { + const event1 = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content: 'test', + created_at: 1000, + }); + + // Parse same event from JSON + const event2 = Event.fromJSON(event1.toJSON()); + + expect(event1.id).toBe(event2.id); + }); + + it('events with different IDs are different', () => { + const event1 = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content: 'test1', + }); + + const event2 = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content: 'test2', + }); + + expect(event1.id).not.toBe(event2.id); + }); + + // TypeScript doesn't have the Java null-ID equality issue + // because Event constructor requires all fields including id + it('Event.fromJSON requires id field', () => { + expect(() => Event.fromJSON({ + pubkey: 'abc', + created_at: 1000, + kind: 1, + tags: [], + content: '', + sig: 'abc', + // id is missing + })).toThrow('Invalid event data'); + }); + }); + + // ========================================================== + // JSON serialization consistency + // ========================================================== + describe('JSON serialization', () => { + it('toJSON creates a new object (not a reference)', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [['p', 'pk1']], + content: 'test', + }); + + const json1 = event.toJSON(); + const json2 = event.toJSON(); + + // Different object references + expect(json1).not.toBe(json2); + + // But equal values + expect(json1).toEqual(json2); + }); + + it('toJSON tags IS the same reference as event.tags (known issue)', () => { + const event = Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [['p', 'pk1']], + content: 'test', + }); + + const json = event.toJSON(); + + // WARNING: This is a known issue - toJSON returns same reference + // Modifying json.tags DOES affect event.tags + json.tags.push(['e', 'eid']); + + // Both are affected because they're the same array + expect(event.tags.length).toBe(2); + expect(json.tags.length).toBe(2); + + // This breaks event integrity + expect(event.verify()).toBe(false); + }); + }); + + // ========================================================== + // Event ID calculation consistency + // ========================================================== + describe('Event ID calculation', () => { + it('calculateId is deterministic', () => { + const id1 = Event.calculateId('pubkey', 1000, 1, [['p', 'pk']], 'content'); + const id2 = Event.calculateId('pubkey', 1000, 1, [['p', 'pk']], 'content'); + expect(id1).toBe(id2); + }); + + it('calculateId produces 64-character hex string', () => { + const id = Event.calculateId('pubkey', 1000, 1, [], 'content'); + expect(id.length).toBe(64); + expect(/^[0-9a-f]+$/.test(id)).toBe(true); + }); + + it('different content produces different ID', () => { + const id1 = Event.calculateId('pubkey', 1000, 1, [], 'content1'); + const id2 = Event.calculateId('pubkey', 1000, 1, [], 'content2'); + expect(id1).not.toBe(id2); + }); + + it('different kind produces different ID', () => { + const id1 = Event.calculateId('pubkey', 1000, 1, [], 'content'); + const id2 = Event.calculateId('pubkey', 1000, 4, [], 'content'); + expect(id1).not.toBe(id2); + }); + + it('different tags produce different ID', () => { + const id1 = Event.calculateId('pubkey', 1000, 1, [], 'content'); + const id2 = Event.calculateId('pubkey', 1000, 1, [['p', 'pk']], 'content'); + expect(id1).not.toBe(id2); + }); + + it('different pubkey produces different ID', () => { + const id1 = Event.calculateId('pubkey1', 1000, 1, [], 'content'); + const id2 = Event.calculateId('pubkey2', 1000, 1, [], 'content'); + expect(id1).not.toBe(id2); + }); + + it('different timestamp produces different ID', () => { + const id1 = Event.calculateId('pubkey', 1000, 1, [], 'content'); + const id2 = Event.calculateId('pubkey', 2000, 1, [], 'content'); + expect(id1).not.toBe(id2); + }); + }); + + // ========================================================== + // isValidEventData + // ========================================================== + describe('isValidEventData', () => { + it('should reject null', () => { + expect(Event.isValidEventData(null)).toBe(false); + }); + + it('should reject undefined', () => { + expect(Event.isValidEventData(undefined)).toBe(false); + }); + + it('should reject string', () => { + expect(Event.isValidEventData('not an event')).toBe(false); + }); + + it('should reject number', () => { + expect(Event.isValidEventData(123)).toBe(false); + }); + + it('should reject empty object', () => { + expect(Event.isValidEventData({})).toBe(false); + }); + + it('should reject object missing id', () => { + expect(Event.isValidEventData({ + pubkey: 'pk', + created_at: 1000, + kind: 1, + tags: [], + content: '', + sig: 'sig', + })).toBe(false); + }); + + it('should reject object with wrong id type', () => { + expect(Event.isValidEventData({ + id: 123, + pubkey: 'pk', + created_at: 1000, + kind: 1, + tags: [], + content: '', + sig: 'sig', + })).toBe(false); + }); + + it('should reject object with wrong tags type', () => { + expect(Event.isValidEventData({ + id: 'id', + pubkey: 'pk', + created_at: 1000, + kind: 1, + tags: 'not an array', + content: '', + sig: 'sig', + })).toBe(false); + }); + + it('should accept valid event data', () => { + expect(Event.isValidEventData({ + id: 'id', + pubkey: 'pk', + created_at: 1000, + kind: 1, + tags: [], + content: '', + sig: 'sig', + })).toBe(true); + }); + }); +}); diff --git a/tests/unit/keymanager-nip44.test.ts b/tests/unit/keymanager-nip44.test.ts new file mode 100644 index 0000000..2317bc7 --- /dev/null +++ b/tests/unit/keymanager-nip44.test.ts @@ -0,0 +1,149 @@ +/** + * Unit tests for NostrKeyManager NIP-44 encryption methods + * Feature 11: KeyManager NIP-44 Encryption + * Techniques: [EP] Equivalence Partitioning, [BVA] Boundary Value Analysis, [ST] State Transition + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { bytesToHex } from '@noble/hashes/utils'; +import { NostrKeyManager } from '../../src/NostrKeyManager.js'; + +describe('NostrKeyManager NIP-44 Encryption', () => { + let alice: NostrKeyManager; + let bob: NostrKeyManager; + + beforeEach(() => { + alice = NostrKeyManager.generate(); + bob = NostrKeyManager.generate(); + }); + + // [EP] Valid: encrypt/decrypt with byte keys + describe('encryptNip44 / decryptNip44 (bytes keys)', () => { + it('should encrypt and decrypt a message', () => { + const encrypted = alice.encryptNip44('Hello Bob', bob.getPublicKey()); + const decrypted = bob.decryptNip44(encrypted, alice.getPublicKey()); + expect(decrypted).toBe('Hello Bob'); + }); + + it('should handle unicode content', () => { + const message = '\u041f\u0440\u0438\u0432\u0456\u0442 \ud83c\udf0d \u0645\u0631\u062d\u0628\u0627'; + const encrypted = alice.encryptNip44(message, bob.getPublicKey()); + const decrypted = bob.decryptNip44(encrypted, alice.getPublicKey()); + expect(decrypted).toBe(message); + }); + + it('should produce different ciphertext each time (random nonce)', () => { + const ct1 = alice.encryptNip44('same message', bob.getPublicKey()); + const ct2 = alice.encryptNip44('same message', bob.getPublicKey()); + expect(ct1).not.toBe(ct2); + }); + }); + + // [EP] Valid: encrypt/decrypt with hex keys + describe('encryptNip44Hex / decryptNip44Hex (hex keys)', () => { + it('should encrypt and decrypt a message', () => { + const encrypted = alice.encryptNip44Hex('Hello Bob', bob.getPublicKeyHex()); + const decrypted = bob.decryptNip44Hex(encrypted, alice.getPublicKeyHex()); + expect(decrypted).toBe('Hello Bob'); + }); + + it('should produce base64-encoded output', () => { + const encrypted = alice.encryptNip44Hex('test', bob.getPublicKeyHex()); + // base64 characters are alphanumeric + /+= + expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/); + }); + }); + + // [BVA] Message length boundaries + describe('message length boundaries', () => { + it('should encrypt 1-byte message', () => { + const encrypted = alice.encryptNip44('x', bob.getPublicKey()); + const decrypted = bob.decryptNip44(encrypted, alice.getPublicKey()); + expect(decrypted).toBe('x'); + }); + + it('should encrypt a long message (close to max)', () => { + // 60000 bytes is well under 65535 limit but large enough to stress test + const message = 'A'.repeat(60000); + const encrypted = alice.encryptNip44(message, bob.getPublicKey()); + const decrypted = bob.decryptNip44(encrypted, alice.getPublicKey()); + expect(decrypted).toBe(message); + }); + + it('should reject empty message', () => { + expect(() => alice.encryptNip44('', bob.getPublicKey())).toThrow(/too short/); + }); + }); + + // [EP] Invalid: wrong key decryption + describe('wrong key decryption', () => { + it('should fail when decrypting with wrong key', () => { + const eve = NostrKeyManager.generate(); + const encrypted = alice.encryptNip44('secret', bob.getPublicKey()); + + expect(() => eve.decryptNip44(encrypted, alice.getPublicKey())).toThrow(); + }); + }); + + // [ST] Cleared key manager + describe('cleared key manager', () => { + it('encryptNip44 should throw after clear', () => { + alice.clear(); + expect(() => alice.encryptNip44('test', bob.getPublicKey())) + .toThrow(/has been cleared/); + }); + + it('decryptNip44 should throw after clear', () => { + const encrypted = alice.encryptNip44('test', bob.getPublicKey()); + bob.clear(); + expect(() => bob.decryptNip44(encrypted, alice.getPublicKey())) + .toThrow(/has been cleared/); + }); + + it('encryptNip44Hex should throw after clear', () => { + alice.clear(); + expect(() => alice.encryptNip44Hex('test', bob.getPublicKeyHex())) + .toThrow(/has been cleared/); + }); + + it('decryptNip44Hex should throw after clear', () => { + const encrypted = alice.encryptNip44Hex('test', bob.getPublicKeyHex()); + bob.clear(); + expect(() => bob.decryptNip44Hex(encrypted, alice.getPublicKeyHex())) + .toThrow(/has been cleared/); + }); + + it('deriveConversationKey should throw after clear', () => { + alice.clear(); + expect(() => alice.deriveConversationKey(bob.getPublicKey())) + .toThrow(/has been cleared/); + }); + }); + + // Conversation key derivation + describe('deriveConversationKey', () => { + it('should produce consistent result', () => { + const key1 = alice.deriveConversationKey(bob.getPublicKey()); + const key2 = alice.deriveConversationKey(bob.getPublicKey()); + expect(bytesToHex(key1)).toBe(bytesToHex(key2)); + }); + + it('should be symmetric (A->B equals B->A)', () => { + const keyAB = alice.deriveConversationKey(bob.getPublicKey()); + const keyBA = bob.deriveConversationKey(alice.getPublicKey()); + expect(bytesToHex(keyAB)).toBe(bytesToHex(keyBA)); + }); + + it('should differ for different key pairs', () => { + const charlie = NostrKeyManager.generate(); + const keyAB = alice.deriveConversationKey(bob.getPublicKey()); + const keyAC = alice.deriveConversationKey(charlie.getPublicKey()); + expect(bytesToHex(keyAB)).not.toBe(bytesToHex(keyAC)); + }); + + it('should return 32-byte key', () => { + const key = alice.deriveConversationKey(bob.getPublicKey()); + expect(key.length).toBe(32); + }); + }); +}); diff --git a/tests/unit/nametag-edge-cases.test.ts b/tests/unit/nametag-edge-cases.test.ts new file mode 100644 index 0000000..6271a5f --- /dev/null +++ b/tests/unit/nametag-edge-cases.test.ts @@ -0,0 +1,268 @@ +/** + * Unit tests for Nametag edge cases + * Covers phone number heuristic, normalization quirks, display formatting + * Techniques: [EG] Error Guessing, [BVA] Boundary Value Analysis + */ + +import { describe, it, expect } from 'vitest'; +import * as NametagUtils from '../../src/nametag/NametagUtils.js'; + +describe('Nametag Edge Cases', () => { + // ========================================================== + // Phone number heuristic edge cases + // ========================================================== + describe('phone number heuristic', () => { + // These SHOULD be treated as phone numbers + it('should treat +1 prefix as phone number', () => { + const normalized = NametagUtils.normalizeNametag('+14155551234'); + expect(normalized).toBe('+14155551234'); + }); + + it('should treat formatted US phone as phone number', () => { + const normalized = NametagUtils.normalizeNametag('(415) 555-1234'); + expect(normalized).toBe('+14155551234'); + }); + + it('should treat phone with dashes as phone number', () => { + const normalized = NametagUtils.normalizeNametag('415-555-1234'); + expect(normalized).toBe('+14155551234'); + }); + + // Edge cases that might be misclassified + it('should NOT treat "user123" as phone (only 3 digits)', () => { + const normalized = NametagUtils.normalizeNametag('user123'); + expect(normalized).toBe('user123'); + }); + + it('should NOT treat "test12345" as phone (only 5 digits)', () => { + const normalized = NametagUtils.normalizeNametag('test12345'); + expect(normalized).toBe('test12345'); + }); + + it('should NOT treat "abc1234567" as phone (7 digits but <50% ratio)', () => { + // 7 digits, 10 total chars = 70% but with 'abc' prefix it's borderline + const normalized = NametagUtils.normalizeNametag('abc1234567'); + // This has 7 digits out of 10 chars = 70% ratio > 50%, so it WILL be treated as phone + // But it's not a valid phone number, so it falls back to standard normalization + expect(normalized).toBe('abc1234567'); + }); + + // WARNING: This is the "user1234567" case from Java findings + // A nametag like "user1234567" (7+ digits, >50% digits) gets treated as a phone number + it('should handle "user1234567" - 7 digits with text prefix', () => { + // 7 digits out of 11 chars = 63% ratio > 50% + // This WILL trigger phone detection, but then fail validation + const normalized = NametagUtils.normalizeNametag('user1234567'); + // Falls back to standard normalization since it's not a valid phone + expect(normalized).toBe('user1234567'); + }); + + it('should handle "12345678901" - just digits, looks like phone', () => { + const normalized = NametagUtils.normalizeNametag('12345678901'); + // 11 digits, all digits = 100% ratio, treated as phone + // libphonenumber interprets leading 1 as US country code + expect(normalized).toBe('+12345678901'); + }); + + it('should handle 6-digit code (below threshold)', () => { + const normalized = NametagUtils.normalizeNametag('123456'); + // Only 6 digits, below 7 threshold, NOT treated as phone + expect(normalized).toBe('123456'); + }); + + it('should handle 7-digit number (at threshold)', () => { + // Exactly 7 digits, 100% ratio, treated as phone + // May or may not be valid depending on country + const normalized = NametagUtils.normalizeNametag('1234567'); + expect(normalized).toBeDefined(); + }); + }); + + // ========================================================== + // Standard normalization + // ========================================================== + describe('standard normalization', () => { + it('should lowercase nametag', () => { + expect(NametagUtils.normalizeNametag('Alice')).toBe('alice'); + }); + + it('should lowercase mixed case', () => { + expect(NametagUtils.normalizeNametag('AlIcE')).toBe('alice'); + }); + + it('should remove @unicity suffix', () => { + expect(NametagUtils.normalizeNametag('alice@unicity')).toBe('alice'); + }); + + it('should remove @UNICITY suffix (case insensitive)', () => { + expect(NametagUtils.normalizeNametag('Alice@UNICITY')).toBe('alice'); + }); + + it('should trim whitespace', () => { + expect(NametagUtils.normalizeNametag(' alice ')).toBe('alice'); + }); + + it('should handle empty string', () => { + expect(NametagUtils.normalizeNametag('')).toBe(''); + }); + + it('should handle whitespace only', () => { + expect(NametagUtils.normalizeNametag(' ')).toBe(''); + }); + + it('should preserve non-@unicity suffixes', () => { + expect(NametagUtils.normalizeNametag('alice@example')).toBe('alice@example'); + }); + }); + + // ========================================================== + // hashNametag determinism + // ========================================================== + describe('hashNametag', () => { + it('should produce deterministic hash', () => { + const hash1 = NametagUtils.hashNametag('alice'); + const hash2 = NametagUtils.hashNametag('alice'); + expect(hash1).toBe(hash2); + }); + + it('should produce 64-character hex hash', () => { + const hash = NametagUtils.hashNametag('alice'); + expect(hash.length).toBe(64); + expect(/^[0-9a-f]+$/.test(hash)).toBe(true); + }); + + it('should normalize before hashing', () => { + const hash1 = NametagUtils.hashNametag('Alice'); + const hash2 = NametagUtils.hashNametag('alice'); + expect(hash1).toBe(hash2); + }); + + it('should normalize @unicity suffix before hashing', () => { + const hash1 = NametagUtils.hashNametag('alice@unicity'); + const hash2 = NametagUtils.hashNametag('alice'); + expect(hash1).toBe(hash2); + }); + + it('should produce different hashes for different nametags', () => { + const hash1 = NametagUtils.hashNametag('alice'); + const hash2 = NametagUtils.hashNametag('bob'); + expect(hash1).not.toBe(hash2); + }); + + it('should normalize phone before hashing', () => { + const hash1 = NametagUtils.hashNametag('+14155551234'); + const hash2 = NametagUtils.hashNametag('(415) 555-1234'); + expect(hash1).toBe(hash2); + }); + }); + + // ========================================================== + // areSameNametag + // ========================================================== + describe('areSameNametag', () => { + it('should match different case', () => { + expect(NametagUtils.areSameNametag('Alice', 'alice')).toBe(true); + }); + + it('should match with and without @unicity suffix', () => { + expect(NametagUtils.areSameNametag('alice', 'alice@unicity')).toBe(true); + }); + + it('should match phone numbers in different formats', () => { + expect(NametagUtils.areSameNametag('+14155551234', '(415) 555-1234')).toBe(true); + }); + + it('should NOT match different nametags', () => { + expect(NametagUtils.areSameNametag('alice', 'bob')).toBe(false); + }); + + it('should match with trimming', () => { + expect(NametagUtils.areSameNametag(' alice ', 'alice')).toBe(true); + }); + }); + + // ========================================================== + // formatForDisplay + // ========================================================== + describe('formatForDisplay', () => { + it('should mask phone number middle digits', () => { + const display = NametagUtils.formatForDisplay('+14155551234'); + expect(display).toBe('+1415***1234'); + }); + + it('should mask formatted phone number', () => { + const display = NametagUtils.formatForDisplay('(415) 555-1234'); + expect(display).toBe('+1415***1234'); + }); + + // NOTE: Java finding #6 - formatForDisplay returns un-normalized for non-phones + // TypeScript returns normalizeNametag() result for non-phones (line 168) + it('should return normalized text nametag (not raw input)', () => { + const display = NametagUtils.formatForDisplay('Alice@UNICITY'); + // TypeScript returns normalized, not raw + expect(display).toBe('alice'); + }); + + it('should return normalized for non-phone digit strings', () => { + const display = NametagUtils.formatForDisplay('user123'); + expect(display).toBe('user123'); + }); + + it('should handle short phone numbers without masking', () => { + // Numbers with <= 6 digits after + shouldn't be masked + const display = NametagUtils.formatForDisplay('+123456'); + // Falls back to standard normalization since it's not a valid phone + expect(display).toBeDefined(); + }); + }); + + // ========================================================== + // isPhoneNumber + // ========================================================== + describe('isPhoneNumber', () => { + it('should return true for valid US phone', () => { + expect(NametagUtils.isPhoneNumber('+14155551234')).toBe(true); + }); + + it('should return true for formatted US phone', () => { + expect(NametagUtils.isPhoneNumber('(415) 555-1234')).toBe(true); + }); + + it('should return false for text nametag', () => { + expect(NametagUtils.isPhoneNumber('alice')).toBe(false); + }); + + it('should return false for short digit string', () => { + expect(NametagUtils.isPhoneNumber('12345')).toBe(false); + }); + + it('should return true for international phone', () => { + expect(NametagUtils.isPhoneNumber('+442071234567')).toBe(true); + }); + }); + + // ========================================================== + // Country code handling + // ========================================================== + describe('country code handling', () => { + it('should use US as default country', () => { + // 10-digit number without country code should be treated as US + const normalized = NametagUtils.normalizeNametag('4155551234'); + expect(normalized).toBe('+14155551234'); + }); + + it('should accept explicit country code', () => { + const normalized = NametagUtils.normalizeNametag('02071234567', 'GB'); + expect(normalized).toBe('+442071234567'); + }); + + it('should hash with custom country code', () => { + const hashUS = NametagUtils.hashNametag('4155551234', 'US'); + const hashGB = NametagUtils.hashNametag('4155551234', 'GB'); + // Different country codes should produce different results for ambiguous numbers + // (though this specific number may normalize the same) + expect(hashUS).toBeDefined(); + expect(hashGB).toBeDefined(); + }); + }); +}); diff --git a/tests/unit/nip04-edge-cases.test.ts b/tests/unit/nip04-edge-cases.test.ts new file mode 100644 index 0000000..8f192bd --- /dev/null +++ b/tests/unit/nip04-edge-cases.test.ts @@ -0,0 +1,103 @@ +/** + * Unit tests for NIP-04 corrupted and malformed data handling + * Feature 13: NIP-04 Corrupted Data + * Techniques: [EG] Error Guessing + */ + +import { describe, it, expect } from 'vitest'; +import * as NIP04 from '../../src/crypto/nip04.js'; +import * as Schnorr from '../../src/crypto/schnorr.js'; + +describe('NIP-04 Corrupted Data Handling', () => { + const alicePrivateKey = new Uint8Array(32).fill(0x01); + const alicePublicKey = Schnorr.getPublicKey(alicePrivateKey); + const bobPrivateKey = new Uint8Array(32).fill(0x02); + const bobPublicKey = Schnorr.getPublicKey(bobPrivateKey); + + // [EG] Corrupted base64 ciphertext + it('should fail when ciphertext is corrupted', async () => { + const encrypted = await NIP04.encrypt('test message', alicePrivateKey, bobPublicKey); + const parts = encrypted.split('?iv='); + // Corrupt the ciphertext by flipping characters + const corrupted = parts[0]!.slice(0, -4) + 'XXXX' + '?iv=' + parts[1]; + + await expect( + NIP04.decrypt(corrupted, bobPrivateKey, alicePublicKey) + ).rejects.toThrow(); + }); + + // [EG] Missing IV separator + it('should reject message without IV separator', async () => { + await expect( + NIP04.decrypt('justbase64withoutiv', bobPrivateKey, alicePublicKey) + ).rejects.toThrow(/Invalid encrypted content format/); + }); + + // [EG] Empty ciphertext with valid IV format + it('should fail with empty ciphertext portion', async () => { + // Empty ciphertext but valid IV format + await expect( + NIP04.decrypt('?iv=dGVzdGl2MTIzNDU2Nzg=', bobPrivateKey, alicePublicKey) + ).rejects.toThrow(); + }); + + // [EG] Corrupted compressed message + it('should fail with corrupted GZIP data', async () => { + // gz: prefix indicates compression but the data is invalid + await expect( + NIP04.decrypt('gz:aW52YWxpZGd6aXBkYXRh?iv=dGVzdGl2MTIzNDU2Nzg=', bobPrivateKey, alicePublicKey) + ).rejects.toThrow(); + }); + + // [EG] Truncated ciphertext + it('should fail with truncated ciphertext', async () => { + const encrypted = await NIP04.encrypt('Hello World test message', alicePrivateKey, bobPublicKey); + const parts = encrypted.split('?iv='); + // Truncate ciphertext to half + const truncated = parts[0]!.slice(0, Math.floor(parts[0]!.length / 2)) + '?iv=' + parts[1]; + + await expect( + NIP04.decrypt(truncated, bobPrivateKey, alicePublicKey) + ).rejects.toThrow(); + }); + + // [EG] Invalid IV length + it('should fail with invalid IV length', async () => { + const encrypted = await NIP04.encrypt('test', alicePrivateKey, bobPublicKey); + const parts = encrypted.split('?iv='); + // Replace IV with a short one (not 16 bytes) + const badIv = Buffer.from('short').toString('base64'); + const modified = parts[0] + '?iv=' + badIv; + + await expect( + NIP04.decrypt(modified, bobPrivateKey, alicePublicKey) + ).rejects.toThrow(/Invalid IV length/); + }); + + // [EG] Multiple ?iv= separators + it('should fail with multiple IV separators', async () => { + await expect( + NIP04.decrypt('part1?iv=part2?iv=part3', bobPrivateKey, alicePublicKey) + ).rejects.toThrow(); + }); + + // [EG] Valid format but completely random data + it('should fail to decrypt random ciphertext', async () => { + const randomCiphertext = Buffer.from(new Uint8Array(48)).toString('base64'); + const randomIv = Buffer.from(new Uint8Array(16)).toString('base64'); + + await expect( + NIP04.decrypt(`${randomCiphertext}?iv=${randomIv}`, bobPrivateKey, alicePublicKey) + ).rejects.toThrow(); + }); + + // [EG] Very long ciphertext (should not crash) + it('should handle very long ciphertext gracefully', async () => { + const longCiphertext = Buffer.from(new Uint8Array(100000)).toString('base64'); + const randomIv = Buffer.from(new Uint8Array(16)).toString('base64'); + + await expect( + NIP04.decrypt(`${longCiphertext}?iv=${randomIv}`, bobPrivateKey, alicePublicKey) + ).rejects.toThrow(); + }); +}); diff --git a/tests/unit/nip44-edge-cases.test.ts b/tests/unit/nip44-edge-cases.test.ts new file mode 100644 index 0000000..94fee97 --- /dev/null +++ b/tests/unit/nip44-edge-cases.test.ts @@ -0,0 +1,268 @@ +/** + * Unit tests for NIP-44 corrupted data and padding edge cases + * Features 14 & 15: NIP-44 Corrupted Data + Padding Edge Cases + * Techniques: [EG] Error Guessing, [BVA] Boundary Value Analysis, [EP] Equivalence Partitioning + */ + +import { describe, it, expect } from 'vitest'; +import { bytesToHex } from '@noble/hashes/utils'; +import * as NIP44 from '../../src/crypto/nip44.js'; +import * as Schnorr from '../../src/crypto/schnorr.js'; + +describe('NIP-44 Corrupted Data Handling', () => { + const alicePrivateKey = new Uint8Array(32).fill(0x01); + const alicePublicKey = Schnorr.getPublicKey(alicePrivateKey); + const bobPrivateKey = new Uint8Array(32).fill(0x02); + const bobPublicKey = Schnorr.getPublicKey(bobPrivateKey); + + // [EG] Corrupted ciphertext byte + it('should fail to decrypt when ciphertext is corrupted (MAC mismatch)', () => { + const encrypted = NIP44.encrypt('test message', alicePrivateKey, bobPublicKey); + const decoded = Buffer.from(encrypted, 'base64'); + + // Corrupt a byte in the ciphertext area (after version + nonce = 25 bytes) + if (decoded.length > 30) { + decoded[30] = (decoded[30]! + 1) % 256; + } + const corrupted = decoded.toString('base64'); + + expect(() => NIP44.decrypt(corrupted, bobPrivateKey, alicePublicKey)).toThrow(); + }); + + // [EG] Wrong version byte 0x01 + it('should reject version byte 0x01', () => { + const encrypted = NIP44.encrypt('test', alicePrivateKey, bobPublicKey); + const decoded = Buffer.from(encrypted, 'base64'); + decoded[0] = 0x01; + const modified = decoded.toString('base64'); + + expect(() => NIP44.decrypt(modified, bobPrivateKey, alicePublicKey)) + .toThrow(/Unsupported NIP-44 version: 1/); + }); + + // [EG] Wrong version byte 0x00 + it('should reject version byte 0x00', () => { + const encrypted = NIP44.encrypt('test', alicePrivateKey, bobPublicKey); + const decoded = Buffer.from(encrypted, 'base64'); + decoded[0] = 0x00; + const modified = decoded.toString('base64'); + + expect(() => NIP44.decrypt(modified, bobPrivateKey, alicePublicKey)) + .toThrow(/Unsupported NIP-44 version: 0/); + }); + + // [EG] Wrong version byte 0xFF + it('should reject version byte 0xFF', () => { + const encrypted = NIP44.encrypt('test', alicePrivateKey, bobPublicKey); + const decoded = Buffer.from(encrypted, 'base64'); + decoded[0] = 0xFF; + const modified = decoded.toString('base64'); + + expect(() => NIP44.decrypt(modified, bobPrivateKey, alicePublicKey)) + .toThrow(/Unsupported NIP-44 version/); + }); + + // [BVA] Payload too short — 10 bytes + it('should reject payload of only 10 bytes', () => { + const shortPayload = Buffer.from(new Uint8Array(10)).toString('base64'); + // Set version byte + const buf = Buffer.from(shortPayload, 'base64'); + buf[0] = 0x02; + const encoded = buf.toString('base64'); + + expect(() => NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey)) + .toThrow(/too short/); + }); + + // [BVA] Payload at minimum valid length (1 + 24 + 32 + 16 = 73 bytes) but invalid crypto + it('should reject minimum-length payload with invalid crypto data', () => { + const payload = new Uint8Array(73); + payload[0] = 0x02; // correct version + // rest is zeros — will fail on crypto, not on "too short" + const encoded = Buffer.from(payload).toString('base64'); + + expect(() => NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey)).toThrow(); + // Should NOT throw "too short" — it's the right length, just bad crypto + try { + NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey); + } catch (e: unknown) { + expect((e as Error).message).not.toMatch(/too short/); + } + }); + + // [BVA] Payload of 72 bytes (1 under minimum) + it('should reject payload of 72 bytes as too short', () => { + const payload = new Uint8Array(72); + payload[0] = 0x02; + const encoded = Buffer.from(payload).toString('base64'); + + expect(() => NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey)) + .toThrow(/too short/); + }); + + // [EG] Truncated nonce + it('should reject payload with truncated nonce', () => { + // 1 (version) + 10 (partial nonce) = 11 bytes total + const payload = new Uint8Array(11); + payload[0] = 0x02; + const encoded = Buffer.from(payload).toString('base64'); + + expect(() => NIP44.decrypt(encoded, bobPrivateKey, alicePublicKey)) + .toThrow(/too short/); + }); + + // [EG] Corrupted nonce but correct length + it('should fail when nonce is corrupted', () => { + const encrypted = NIP44.encrypt('test data', alicePrivateKey, bobPublicKey); + const decoded = Buffer.from(encrypted, 'base64'); + + // Corrupt nonce area (bytes 1-24) + for (let i = 1; i <= 24 && i < decoded.length; i++) { + decoded[i] = (decoded[i]! + 1) % 256; + } + const corrupted = decoded.toString('base64'); + + expect(() => NIP44.decrypt(corrupted, bobPrivateKey, alicePublicKey)).toThrow(); + }); + + // [EG] Empty base64 string + it('should reject empty base64 string', () => { + expect(() => NIP44.decrypt('', bobPrivateKey, alicePublicKey)).toThrow(); + }); +}); + +describe('NIP-44 Padding Edge Cases', () => { + // [BVA] calcPaddedLen boundary values + describe('calcPaddedLen boundaries', () => { + it('should return 32 for lengths 1 through 32', () => { + expect(NIP44.calcPaddedLen(1)).toBe(32); + expect(NIP44.calcPaddedLen(16)).toBe(32); + expect(NIP44.calcPaddedLen(31)).toBe(32); + expect(NIP44.calcPaddedLen(32)).toBe(32); + }); + + it('should return 64 for length 33', () => { + expect(NIP44.calcPaddedLen(33)).toBe(64); + }); + + it('should return 64 for length 64', () => { + expect(NIP44.calcPaddedLen(64)).toBe(64); + }); + + it('should handle mid-range values', () => { + // For 65: nextPow2=128, chunk=max(32,16)=32 -> ceil(65/32)*32 = 96 + expect(NIP44.calcPaddedLen(65)).toBe(96); + // For 100: nextPow2=128, chunk=max(32,16)=32 -> ceil(100/32)*32 = 128 + expect(NIP44.calcPaddedLen(100)).toBe(128); + }); + + it('should handle values near 256', () => { + expect(NIP44.calcPaddedLen(255)).toBe(256); + expect(NIP44.calcPaddedLen(256)).toBe(256); + expect(NIP44.calcPaddedLen(257)).toBe(320); + }); + + it('should handle large values', () => { + expect(NIP44.calcPaddedLen(1000)).toBe(1024); + expect(NIP44.calcPaddedLen(65535)).toBe(65536); + }); + + // [BVA] Invalid: zero length + it('should reject zero length', () => { + expect(() => NIP44.calcPaddedLen(0)).toThrow(/too short/); + }); + + // [BVA] Invalid: negative length + it('should reject negative length', () => { + expect(() => NIP44.calcPaddedLen(-1)).toThrow(/too short/); + }); + + // [BVA] Invalid: exceeds maximum + it('should reject length 65536', () => { + expect(() => NIP44.calcPaddedLen(65536)).toThrow(/too long/); + }); + + // [BVA] Maximum valid length + it('should accept length 65535', () => { + expect(NIP44.calcPaddedLen(65535)).toBeGreaterThanOrEqual(65535); + }); + }); + + // [EP] Pad/Unpad roundtrip + describe('pad/unpad roundtrip', () => { + it('should preserve message for various lengths', () => { + const testLengths = [1, 5, 16, 31, 32, 33, 50, 64, 65, 100, 256, 500, 1000]; + + for (const len of testLengths) { + const message = new Uint8Array(len); + for (let i = 0; i < len; i++) { + message[i] = i % 256; + } + + const padded = NIP44.pad(message); + const unpadded = NIP44.unpad(padded); + + expect(bytesToHex(unpadded)).toBe(bytesToHex(message)); + } + }); + + it('should include 2-byte big-endian length prefix', () => { + const message = new Uint8Array(300); // 0x012C + const padded = NIP44.pad(message); + + expect(padded[0]).toBe(0x01); // high byte + expect(padded[1]).toBe(0x2C); // low byte (300 & 0xFF = 44 = 0x2C) + }); + + it('should include correct length prefix for small message', () => { + const message = new Uint8Array(5); + const padded = NIP44.pad(message); + + expect(padded[0]).toBe(0x00); // high byte + expect(padded[1]).toBe(0x05); // low byte + }); + }); + + // Unpad error conditions + describe('unpad error handling', () => { + it('should reject padded data that is too short', () => { + // Less than 2 + 32 = 34 bytes + const shortPadded = new Uint8Array(10); + expect(() => NIP44.unpad(shortPadded)).toThrow(/too short/); + }); + + it('should reject padded data with zero length prefix', () => { + // Create a padded buffer with length prefix = 0 + const padded = new Uint8Array(2 + 32); // 34 bytes + padded[0] = 0x00; + padded[1] = 0x00; // length = 0 + + expect(() => NIP44.unpad(padded)).toThrow(/Invalid message length/); + }); + + it('should reject padded data with wrong padding size', () => { + // Create a valid-looking padded buffer but with mismatched padding + const padded = new Uint8Array(2 + 64); // 66 bytes total + padded[0] = 0x00; + padded[1] = 0x05; // claims 5 bytes, but calcPaddedLen(5) = 32, not 64 + + expect(() => NIP44.unpad(padded)).toThrow(/Invalid padding/); + }); + }); + + // Pad error conditions + describe('pad error handling', () => { + it('should reject empty message', () => { + expect(() => NIP44.pad(new Uint8Array(0))).toThrow(/too short/); + }); + + it('should reject message exceeding max length', () => { + expect(() => NIP44.pad(new Uint8Array(65536))).toThrow(/too long/); + }); + + it('should accept maximum-length message', () => { + const padded = NIP44.pad(new Uint8Array(65535)); + expect(padded.length).toBeGreaterThanOrEqual(2 + 65535); + }); + }); +}); diff --git a/tests/unit/nostr-client.test.ts b/tests/unit/nostr-client.test.ts new file mode 100644 index 0000000..b9f62c3 --- /dev/null +++ b/tests/unit/nostr-client.test.ts @@ -0,0 +1,1038 @@ +/** + * Unit tests for NostrClient + * Features 1-9, 17, 19, 20: Connection lifecycle, message handling, publishing, + * subscriptions, reconnection, NIP-17, token/payment delegation, nametag query, + * disconnect cleanup, config combinations, subscription re-establishment, concurrency + * + * Techniques: [ST] State Transition, [DT] Decision Table, [EP] Equivalence Partitioning, + * [BVA] Boundary Value Analysis, [UC] Use Case, [EG] Error Guessing, [PW] Pairwise, + * [LC] Loop Testing, [RB] Risk-Based, [SC] Statement/Branch Coverage + */ + +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { NostrClient, type ConnectionEventListener } from '../../src/client/NostrClient.js'; +import { NostrKeyManager } from '../../src/NostrKeyManager.js'; +import { Event } from '../../src/protocol/Event.js'; +import { Filter } from '../../src/protocol/Filter.js'; +import * as EventKinds from '../../src/protocol/EventKinds.js'; +import type { IWebSocket, WebSocketMessageEvent, WebSocketCloseEvent, WebSocketErrorEvent } from '../../src/client/WebSocketAdapter.js'; + +// ============================================================ +// Mock WebSocket Infrastructure +// ============================================================ + +class MockWebSocket implements IWebSocket { + readyState = 0; // CONNECTING + onopen: ((event: unknown) => void) | null = null; + onmessage: ((event: WebSocketMessageEvent) => void) | null = null; + onclose: ((event: WebSocketCloseEvent) => void) | null = null; + onerror: ((event: WebSocketErrorEvent) => void) | null = null; + sentMessages: string[] = []; + closeCode?: number; + closeReason?: string; + + send(data: string): void { + this.sentMessages.push(data); + } + + close(code?: number, reason?: string): void { + this.closeCode = code; + this.closeReason = reason; + this.readyState = 3; // CLOSED + } + + simulateOpen(): void { + this.readyState = 1; // OPEN + this.onopen?.({}); + } + + simulateMessage(data: string): void { + this.onmessage?.({ data }); + } + + simulateClose(code = 1000, reason = ''): void { + this.readyState = 3; // CLOSED + this.onclose?.({ code, reason }); + } + + simulateError(message = 'Error'): void { + this.onerror?.({ message }); + } +} + +let mockSockets: MockWebSocket[] = []; +let createWebSocketMock: Mock; + +vi.mock('../../src/client/WebSocketAdapter.js', () => ({ + createWebSocket: (...args: unknown[]) => createWebSocketMock(...args), + extractMessageData: (event: WebSocketMessageEvent) => { + if (typeof event.data === 'string') return event.data; + return String(event.data); + }, + CONNECTING: 0, + OPEN: 1, + CLOSING: 2, + CLOSED: 3, +})); + +// ============================================================ +// Helpers +// ============================================================ + +function createMockSocket(): MockWebSocket { + const socket = new MockWebSocket(); + mockSockets.push(socket); + return socket; +} + +/** Flush microtask queue so promise chains settle */ +async function flushMicrotasks(): Promise { + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } +} + +/** Connect client to a relay and return the mock socket */ +async function connectClient(client: NostrClient, url = 'wss://relay.example.com'): Promise { + const socket = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(socket); + const connectPromise = client.connect(url); + await flushMicrotasks(); + socket.simulateOpen(); + await connectPromise; + return socket; +} + +function createTestEvent(keyManager: NostrKeyManager, content = 'test'): Event { + return Event.create(keyManager, { + kind: EventKinds.TEXT_NOTE, + tags: [], + content, + }); +} + +// ============================================================ +// Tests +// ============================================================ + +describe('NostrClient', () => { + let keyManager: NostrKeyManager; + let client: NostrClient; + + beforeEach(() => { + mockSockets = []; + createWebSocketMock = vi.fn(); + keyManager = NostrKeyManager.generate(); + client = new NostrClient(keyManager); + }); + + afterEach(() => { + client.disconnect(); + }); + + // ========================================================== + // Feature 1: Connection Lifecycle [ST] + // ========================================================== + describe('Feature 1: Connection Lifecycle', () => { + it('should start in disconnected state', () => { + expect(client.isConnected()).toBe(false); + expect(client.getConnectedRelays().size).toBe(0); + }); + + it('should connect to a single relay', async () => { + await connectClient(client, 'wss://relay1.example.com'); + + expect(client.isConnected()).toBe(true); + expect(client.getConnectedRelays().has('wss://relay1.example.com')).toBe(true); + }); + + it('should connect to multiple relays', async () => { + const socket1 = createMockSocket(); + const socket2 = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(socket1).mockResolvedValueOnce(socket2); + + const connectPromise = client.connect('wss://relay1.example.com', 'wss://relay2.example.com'); + await flushMicrotasks(); + socket1.simulateOpen(); + socket2.simulateOpen(); + await connectPromise; + + expect(client.getConnectedRelays().size).toBe(2); + }); + + it('should not create duplicate connection to already-connected relay', async () => { + await connectClient(client, 'wss://relay.example.com'); + + await client.connect('wss://relay.example.com'); + + expect(createWebSocketMock).toHaveBeenCalledTimes(1); + }); + + it('should timeout connection after 30 seconds', async () => { + vi.useFakeTimers(); + const socket = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(socket); + + const connectPromise = client.connect('wss://slow.example.com'); + // Attach a catch handler before advancing timers to prevent unhandled rejection + let timeoutError: Error | undefined; + connectPromise.catch(e => { timeoutError = e; }); + + await vi.advanceTimersByTimeAsync(30001); + await flushMicrotasks(); + + expect(timeoutError).toBeDefined(); + expect(timeoutError!.message).toMatch(/timed out/); + vi.useRealTimers(); + }); + + it('should reject if createWebSocket fails', async () => { + createWebSocketMock.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + await expect(client.connect('wss://down.example.com')).rejects.toThrow('ECONNREFUSED'); + }); + + it('should reject if WebSocket fires onerror before onopen', async () => { + const socket = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(socket); + + const connectPromise = client.connect('wss://bad.example.com'); + await flushMicrotasks(); + socket.simulateError('Connection refused'); + + await expect(connectPromise).rejects.toThrow(/Failed to connect/); + }); + + it('should reject all operations after disconnect [ST terminal state]', async () => { + client.disconnect(); + + await expect(client.connect('wss://relay.example.com')).rejects.toThrow(/disconnected/); + + const event = createTestEvent(keyManager); + await expect(client.publishEvent(event)).rejects.toThrow(/disconnected/); + }); + + it('should handle multiple disconnect calls gracefully [EG]', () => { + expect(() => client.disconnect()).not.toThrow(); + expect(() => client.disconnect()).not.toThrow(); + expect(() => client.disconnect()).not.toThrow(); + }); + + it('should return key manager', () => { + expect(client.getKeyManager()).toBe(keyManager); + }); + }); + + // ========================================================== + // Feature 2: Relay Message Handling [DT] + // ========================================================== + describe('Feature 2: Relay Message Handling', () => { + let socket: MockWebSocket; + + beforeEach(async () => { + socket = await connectClient(client); + }); + + it('EVENT message dispatches to correct subscription listener', () => { + const onEvent = vi.fn(); + client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent }); + + const event = createTestEvent(keyManager, 'hello'); + socket.simulateMessage(JSON.stringify(['EVENT', 'sub_1', event.toJSON()])); + + expect(onEvent).toHaveBeenCalledOnce(); + expect(onEvent.mock.calls[0]![0].content).toBe('hello'); + }); + + it('EVENT for unknown subscription is silently ignored', () => { + const onEvent = vi.fn(); + client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent }); + + const event = createTestEvent(keyManager); + socket.simulateMessage(JSON.stringify(['EVENT', 'unknown_sub', event.toJSON()])); + + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('EVENT message with fewer than 3 elements is ignored [BVA]', () => { + const onEvent = vi.fn(); + client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent }); + + socket.simulateMessage(JSON.stringify(['EVENT', 'sub_1'])); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('EVENT with invalid event JSON is silently ignored [EG]', () => { + const onEvent = vi.fn(); + client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent }); + + socket.simulateMessage(JSON.stringify(['EVENT', 'sub_1', { invalid: 'data' }])); + expect(onEvent).not.toHaveBeenCalled(); + }); + + it('OK message resolves pending publish (accepted)', async () => { + const event = createTestEvent(keyManager); + const publishPromise = client.publishEvent(event); + + socket.simulateMessage(JSON.stringify(['OK', event.id, true, ''])); + await expect(publishPromise).resolves.toBe(event.id); + }); + + it('OK message rejects pending publish (rejected)', async () => { + const event = createTestEvent(keyManager); + const publishPromise = client.publishEvent(event); + + socket.simulateMessage(JSON.stringify(['OK', event.id, false, 'blocked: rate limit'])); + await expect(publishPromise).rejects.toThrow(/Event rejected: blocked: rate limit/); + }); + + it('OK message with insufficient elements is ignored [BVA]', async () => { + vi.useFakeTimers(); + const socket2 = await connectClient(client, 'wss://relay2.example.com'); + const event = createTestEvent(keyManager); + const publishPromise = client.publishEvent(event); + + socket2.simulateMessage(JSON.stringify(['OK', event.id, true])); + // Not resolved — advance past timeout + await vi.advanceTimersByTimeAsync(5001); + // Resolves via timeout (optimistic) + await expect(publishPromise).resolves.toBe(event.id); + vi.useRealTimers(); + }); + + it('EOSE triggers onEndOfStoredEvents callback', () => { + const onEvent = vi.fn(); + const onEndOfStoredEvents = vi.fn(); + client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent, onEndOfStoredEvents }); + + socket.simulateMessage(JSON.stringify(['EOSE', 'sub_1'])); + expect(onEndOfStoredEvents).toHaveBeenCalledWith('sub_1'); + }); + + it('EOSE handled gracefully when no onEndOfStoredEvents callback', () => { + const onEvent = vi.fn(); + client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent }); + + expect(() => { + socket.simulateMessage(JSON.stringify(['EOSE', 'sub_1'])); + }).not.toThrow(); + }); + + it('CLOSED message triggers onError callback', () => { + const onEvent = vi.fn(); + const onError = vi.fn(); + client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent, onError }); + + socket.simulateMessage(JSON.stringify(['CLOSED', 'sub_1', 'auth-required: must authenticate'])); + expect(onError).toHaveBeenCalledWith('sub_1', expect.stringContaining('auth-required')); + }); + + it('AUTH message triggers NIP-42 authentication', () => { + socket.simulateMessage(JSON.stringify(['AUTH', 'challenge-abc'])); + + const authMessages = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'AUTH'; } catch { return false; } + }); + + expect(authMessages.length).toBe(1); + const authResponse = JSON.parse(authMessages[0]!); + const authEvent = authResponse[1]; + expect(authEvent.kind).toBe(22242); + + const relayTag = authEvent.tags.find((t: string[]) => t[0] === 'relay'); + const challengeTag = authEvent.tags.find((t: string[]) => t[0] === 'challenge'); + expect(relayTag[1]).toBe('wss://relay.example.com'); + expect(challengeTag[1]).toBe('challenge-abc'); + }); + + it('AUTH triggers resubscription after 100ms delay', async () => { + vi.useFakeTimers(); + const socket3 = await connectClient(client, 'wss://relay3.example.com'); + const onEvent = vi.fn(); + client.subscribe('sub_1', Filter.builder().kinds(1).build(), { onEvent }); + socket3.sentMessages = []; + + socket3.simulateMessage(JSON.stringify(['AUTH', 'challenge'])); + + // Before 100ms — only AUTH sent, no REQ yet + const reqsBefore = socket3.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } + }); + expect(reqsBefore.length).toBe(0); + + await vi.advanceTimersByTimeAsync(100); + + const reqsAfter = socket3.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } + }); + expect(reqsAfter.length).toBeGreaterThanOrEqual(1); + vi.useRealTimers(); + }); + + it('malformed JSON is silently ignored [EG]', () => { + expect(() => socket.simulateMessage('not json')).not.toThrow(); + }); + + it('non-array JSON is silently ignored [EG]', () => { + expect(() => socket.simulateMessage('{"type":"EVENT"}')).not.toThrow(); + }); + + it('empty array is silently ignored [EG]', () => { + expect(() => socket.simulateMessage('[]')).not.toThrow(); + }); + + it('single-element array is silently ignored [EG]', () => { + expect(() => socket.simulateMessage('["EVENT"]')).not.toThrow(); + }); + + it('unknown message type is silently ignored [EG]', () => { + expect(() => socket.simulateMessage('["UNKNOWN_TYPE","data"]')).not.toThrow(); + }); + }); + + // ========================================================== + // Feature 3: Event Publishing [EP] + // ========================================================== + describe('Feature 3: Event Publishing', () => { + it('should broadcast event to all connected relays', async () => { + const socket1 = createMockSocket(); + const socket2 = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(socket1).mockResolvedValueOnce(socket2); + + const connectPromise = client.connect('wss://relay1.example.com', 'wss://relay2.example.com'); + await flushMicrotasks(); + socket1.simulateOpen(); + socket2.simulateOpen(); + await connectPromise; + + const event = createTestEvent(keyManager, 'Hello Nostr'); + const publishPromise = client.publishEvent(event); + + expect(socket1.sentMessages.some(m => m.includes('Hello Nostr'))).toBe(true); + expect(socket2.sentMessages.some(m => m.includes('Hello Nostr'))).toBe(true); + + socket1.simulateMessage(JSON.stringify(['OK', event.id, true, ''])); + await publishPromise; + }); + + it('should queue event when not connected [ST offline]', async () => { + const event = createTestEvent(keyManager); + const publishPromise = client.publishEvent(event); + + expect(publishPromise).toBeInstanceOf(Promise); + + client.disconnect(); + await expect(publishPromise).rejects.toThrow(/disconnected/); + }); + + it('should flush queued events on connection', async () => { + const event1 = createTestEvent(keyManager, 'queued1'); + const event2 = createTestEvent(keyManager, 'queued2'); + + const p1 = client.publishEvent(event1); + const p2 = client.publishEvent(event2); + + const socket = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(socket); + const connectPromise = client.connect('wss://relay.example.com'); + await flushMicrotasks(); + socket.simulateOpen(); + await connectPromise; + await flushMicrotasks(); + + expect(socket.sentMessages.some(m => m.includes('queued1'))).toBe(true); + expect(socket.sentMessages.some(m => m.includes('queued2'))).toBe(true); + + // Resolve via OK + socket.simulateMessage(JSON.stringify(['OK', event1.id, true, ''])); + socket.simulateMessage(JSON.stringify(['OK', event2.id, true, ''])); + await p1; + await p2; + }); + + it('should resolve after 5s timeout even without OK [BVA]', async () => { + vi.useFakeTimers(); + await connectClient(client); + + const event = createTestEvent(keyManager); + const publishPromise = client.publishEvent(event); + + await vi.advanceTimersByTimeAsync(5001); + + await expect(publishPromise).resolves.toBe(event.id); + vi.useRealTimers(); + }); + + it('OK response should clear the pending timeout', async () => { + const socket = await connectClient(client); + + const event = createTestEvent(keyManager); + const publishPromise = client.publishEvent(event); + + socket.simulateMessage(JSON.stringify(['OK', event.id, true, ''])); + await expect(publishPromise).resolves.toBe(event.id); + }); + }); + + // ========================================================== + // Feature 4: Subscriptions [EP] + // ========================================================== + describe('Feature 4: Subscriptions', () => { + it('should auto-generate sequential subscription IDs', async () => { + await connectClient(client); + const filter = Filter.builder().kinds(1).build(); + const onEvent = vi.fn(); + + const id1 = client.subscribe(filter, { onEvent }); + const id2 = client.subscribe(filter, { onEvent }); + const id3 = client.subscribe(filter, { onEvent }); + + expect(id1).toBe('sub_1'); + expect(id2).toBe('sub_2'); + expect(id3).toBe('sub_3'); + }); + + it('should accept custom subscription ID', async () => { + await connectClient(client); + const filter = Filter.builder().kinds(1).build(); + + const id = client.subscribe('my-custom-id', filter, { onEvent: vi.fn() }); + expect(id).toBe('my-custom-id'); + }); + + it('should send REQ to all connected relays', async () => { + const socket1 = createMockSocket(); + const socket2 = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(socket1).mockResolvedValueOnce(socket2); + + const connectPromise = client.connect('wss://relay1.example.com', 'wss://relay2.example.com'); + await flushMicrotasks(); + socket1.simulateOpen(); + socket2.simulateOpen(); + await connectPromise; + + const filter = Filter.builder().kinds(1).build(); + client.subscribe(filter, { onEvent: vi.fn() }); + + const req1 = socket1.sentMessages.find(m => { try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } }); + const req2 = socket2.sentMessages.find(m => { try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } }); + expect(req1).toBeDefined(); + expect(req2).toBeDefined(); + }); + + it('should store subscription when not connected (no REQ sent)', () => { + const filter = Filter.builder().kinds(1).build(); + const id = client.subscribe(filter, { onEvent: vi.fn() }); + + expect(id).toMatch(/^sub_\d+$/); + }); + + it('should send CLOSE on unsubscribe', async () => { + const socket = await connectClient(client); + const filter = Filter.builder().kinds(1).build(); + + const subId = client.subscribe(filter, { onEvent: vi.fn() }); + client.unsubscribe(subId); + + const closeMsg = socket.sentMessages.find(m => { + try { const p = JSON.parse(m); return p[0] === 'CLOSE' && p[1] === subId; } + catch { return false; } + }); + expect(closeMsg).toBeDefined(); + }); + + it('should handle unsubscribe with unknown ID gracefully', async () => { + await connectClient(client); + expect(() => client.unsubscribe('non_existent')).not.toThrow(); + }); + + it('should manage many concurrent subscriptions [LC]', async () => { + const socket = await connectClient(client); + + const ids: string[] = []; + for (let i = 0; i < 100; i++) { + ids.push(client.subscribe(Filter.builder().kinds(1).build(), { onEvent: vi.fn() })); + } + + expect(ids.length).toBe(100); + + const reqMessages = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } + }); + expect(reqMessages.length).toBe(100); + + for (const id of ids) { + client.unsubscribe(id); + } + + const closeMessages = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'CLOSE'; } catch { return false; } + }); + expect(closeMessages.length).toBe(100); + }); + }); + + // ========================================================== + // Feature 5: Reconnection & Health [ST] + // ========================================================== + describe('Feature 5: Reconnection', () => { + it('should schedule reconnect after connection loss (autoReconnect=true)', async () => { + vi.useFakeTimers(); + const socket = await connectClient(client, 'wss://relay.example.com'); + const onDisconnect = vi.fn(); + const onReconnecting = vi.fn(); + client.addConnectionListener({ onDisconnect, onReconnecting }); + + socket.simulateClose(1006, 'network error'); + + expect(onDisconnect).toHaveBeenCalledWith('wss://relay.example.com', 'network error'); + + await vi.advanceTimersByTimeAsync(1000); + expect(onReconnecting).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('should NOT reconnect when autoReconnect is false', async () => { + vi.useFakeTimers(); + client.disconnect(); + client = new NostrClient(keyManager, { autoReconnect: false }); + + const socket = await connectClient(client, 'wss://relay.example.com'); + const onReconnecting = vi.fn(); + client.addConnectionListener({ onReconnecting }); + + socket.simulateClose(1006, 'network error'); + await vi.advanceTimersByTimeAsync(60000); + + expect(onReconnecting).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('should emit "connect" on first connection', async () => { + const onConnect = vi.fn(); + client.addConnectionListener({ onConnect }); + + await connectClient(client, 'wss://relay.example.com'); + + expect(onConnect).toHaveBeenCalledWith('wss://relay.example.com'); + }); + + it('should swallow listener errors [EG]', async () => { + client.addConnectionListener({ + onConnect: () => { throw new Error('listener crash'); }, + }); + + await connectClient(client, 'wss://relay.example.com'); + expect(client.isConnected()).toBe(true); + }); + + it('should remove connection listener', async () => { + const onConnect = vi.fn(); + const listener: ConnectionEventListener = { onConnect }; + client.addConnectionListener(listener); + client.removeConnectionListener(listener); + + await connectClient(client, 'wss://relay.example.com'); + expect(onConnect).not.toHaveBeenCalled(); + }); + + it('should handle removing non-existent listener gracefully', () => { + expect(() => client.removeConnectionListener({ onConnect: vi.fn() })).not.toThrow(); + }); + }); + + // ========================================================== + // Feature 8: Nametag Query [ST] + // ========================================================== + describe('Feature 8: Nametag Query', () => { + it('should resolve with null on timeout', async () => { + client.disconnect(); + client = new NostrClient(keyManager, { queryTimeoutMs: 50, pingIntervalMs: 0 }); + await connectClient(client); + + const result = await client.queryPubkeyByNametag('nobody'); + expect(result).toBeNull(); + }, 10000); + + it('should respect custom query timeout', async () => { + client.disconnect(); + client = new NostrClient(keyManager, { queryTimeoutMs: 50, pingIntervalMs: 0 }); + await connectClient(client); + + const result = await client.queryPubkeyByNametag('test'); + expect(result).toBeNull(); + }, 10000); + + it('should return pubkey on EOSE', async () => { + const socket = await connectClient(client); + + const queryPromise = client.queryPubkeyByNametag('alice'); + await flushMicrotasks(); + + // Find the subscription + const reqMsg = socket.sentMessages.find(m => { + try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } + }); + expect(reqMsg).toBeDefined(); + const subId = JSON.parse(reqMsg!)[1] as string; + + // Simulate a binding event + const bindingEvent = Event.create(keyManager, { + kind: EventKinds.APP_DATA, + tags: [['d', 'nametag:hash']], + content: '{}', + created_at: 2000, + }); + socket.simulateMessage(JSON.stringify(['EVENT', subId, bindingEvent.toJSON()])); + socket.simulateMessage(JSON.stringify(['EOSE', subId])); + + const result = await queryPromise; + expect(result).toBe(keyManager.getPublicKeyHex()); + }); + + it('should use setQueryTimeout value', () => { + client.setQueryTimeout(15000); + expect(client.getQueryTimeout()).toBe(15000); + }); + }); + + // ========================================================== + // Feature 9: Disconnect Cleanup [RB] + // ========================================================== + describe('Feature 9: Disconnect Cleanup', () => { + it('should reject all pending OK promises on disconnect', async () => { + await connectClient(client); + + const event1 = createTestEvent(keyManager, 'ev1'); + const event2 = createTestEvent(keyManager, 'ev2'); + const p1 = client.publishEvent(event1); + const p2 = client.publishEvent(event2); + + client.disconnect(); + + await expect(p1).rejects.toThrow(/disconnected/); + await expect(p2).rejects.toThrow(/disconnected/); + }); + + it('should reject all queued events on disconnect', async () => { + const p1 = client.publishEvent(createTestEvent(keyManager, 'q1')); + const p2 = client.publishEvent(createTestEvent(keyManager, 'q2')); + + client.disconnect(); + + await expect(p1).rejects.toThrow(/disconnected/); + await expect(p2).rejects.toThrow(/disconnected/); + }); + + it('should close all WebSocket connections', async () => { + const socket = await connectClient(client); + client.disconnect(); + + expect(socket.closeCode).toBe(1000); + expect(socket.closeReason).toBe('Client disconnected'); + }); + + it('should emit disconnect event for each relay', async () => { + await connectClient(client, 'wss://relay.example.com'); + const onDisconnect = vi.fn(); + client.addConnectionListener({ onDisconnect }); + + client.disconnect(); + + expect(onDisconnect).toHaveBeenCalledWith('wss://relay.example.com', 'Client disconnected'); + }); + + it('should clear all internal state', async () => { + await connectClient(client); + client.subscribe(Filter.builder().kinds(1).build(), { onEvent: vi.fn() }); + + client.disconnect(); + + expect(client.isConnected()).toBe(false); + expect(client.getConnectedRelays().size).toBe(0); + }); + }); + + // ========================================================== + // Feature 17: Configuration Combinations [PW] + // ========================================================== + describe('Feature 17: Configuration Combinations', () => { + const configs = [ + { autoReconnect: true, queryTimeoutMs: 5000, pingIntervalMs: 30000 }, + { autoReconnect: true, queryTimeoutMs: 1000, pingIntervalMs: 0 }, + { autoReconnect: false, queryTimeoutMs: 5000, pingIntervalMs: 0 }, + { autoReconnect: false, queryTimeoutMs: 10000, pingIntervalMs: 30000 }, + { autoReconnect: true, queryTimeoutMs: 30000, pingIntervalMs: 60000 }, + { autoReconnect: false, queryTimeoutMs: 100, pingIntervalMs: 10000 }, + ]; + + for (const config of configs) { + it(`should work with config: autoReconnect=${config.autoReconnect}, queryTimeout=${config.queryTimeoutMs}, ping=${config.pingIntervalMs}`, () => { + const c = new NostrClient(keyManager, config); + expect(c.getQueryTimeout()).toBe(config.queryTimeoutMs); + c.disconnect(); + }); + } + }); + + // ========================================================== + // Feature 19: Subscription Re-establishment [LC] + // ========================================================== + describe('Feature 19: Subscription Re-establishment', () => { + it('zero subscriptions — nothing to re-establish', async () => { + vi.useFakeTimers(); + const socket = await connectClient(client); + socket.simulateClose(); + + const newSocket = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(newSocket); + await vi.advanceTimersByTimeAsync(1000); + await flushMicrotasks(); + newSocket.simulateOpen(); + await flushMicrotasks(); + + const reqs = newSocket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } + }); + expect(reqs.length).toBe(0); + vi.useRealTimers(); + }); + + it('subscriptions should be re-established after reconnect', async () => { + vi.useFakeTimers(); + const socket = await connectClient(client); + + client.subscribe(Filter.builder().kinds(1).build(), { onEvent: vi.fn() }); + client.subscribe(Filter.builder().kinds(4).build(), { onEvent: vi.fn() }); + + socket.simulateClose(); + const newSocket = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(newSocket); + await vi.advanceTimersByTimeAsync(1000); + await flushMicrotasks(); + newSocket.simulateOpen(); + await flushMicrotasks(); + + const reqs = newSocket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } + }); + expect(reqs.length).toBe(2); + vi.useRealTimers(); + }); + + it('unsubscribed subs should NOT be re-established', async () => { + vi.useFakeTimers(); + const socket = await connectClient(client); + + const sub1 = client.subscribe(Filter.builder().kinds(1).build(), { onEvent: vi.fn() }); + client.subscribe(Filter.builder().kinds(4).build(), { onEvent: vi.fn() }); + client.subscribe(Filter.builder().kinds(14).build(), { onEvent: vi.fn() }); + client.unsubscribe(sub1); + + socket.simulateClose(); + const newSocket = createMockSocket(); + createWebSocketMock.mockResolvedValueOnce(newSocket); + await vi.advanceTimersByTimeAsync(1000); + await flushMicrotasks(); + newSocket.simulateOpen(); + await flushMicrotasks(); + + const reqs = newSocket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'REQ'; } catch { return false; } + }); + expect(reqs.length).toBe(2); + vi.useRealTimers(); + }); + }); + + // ========================================================== + // Feature 20: Concurrent Operations [EG] + // ========================================================== + describe('Feature 20: Concurrent Operations', () => { + it('should handle many concurrent publishes', async () => { + const socket = await connectClient(client); + + const promises: Promise[] = []; + for (let i = 0; i < 50; i++) { + promises.push(client.publishEvent(createTestEvent(keyManager, `msg${i}`))); + } + + const eventMessages = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; } + }); + expect(eventMessages.length).toBe(50); + + // Resolve all via OK + for (let i = 0; i < 50; i++) { + const sentEvent = JSON.parse(eventMessages[i]!)[1]; + socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, ''])); + } + const results = await Promise.all(promises); + expect(results.length).toBe(50); + }); + + it('should handle rapid subscribe/unsubscribe', async () => { + const socket = await connectClient(client); + + for (let i = 0; i < 50; i++) { + const subId = client.subscribe( + Filter.builder().kinds(1).build(), + { onEvent: vi.fn() } + ); + client.unsubscribe(subId); + } + + // Verify all CLOSE messages were sent for each subscription + const closeMessages = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'CLOSE'; } catch { return false; } + }); + expect(closeMessages.length).toBe(50); + }); + + it('disconnect while publish is pending rejects the pending promise', async () => { + await connectClient(client); + + const event = createTestEvent(keyManager); + const publishPromise = client.publishEvent(event); + + client.disconnect(); + + await expect(publishPromise).rejects.toThrow(/disconnected/); + }); + }); + + // ========================================================== + // Feature 6: NIP-17 via Client [UC] + // ========================================================== + describe('Feature 6: NIP-17 via Client', () => { + it('should send private message via gift wrapping', async () => { + const socket = await connectClient(client); + const bob = NostrKeyManager.generate(); + + const publishPromise = client.sendPrivateMessage( + bob.getPublicKeyHex(), + 'Hello Bob' + ); + + const eventMsgs = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; } + }); + expect(eventMsgs.length).toBe(1); + + const sentEvent = JSON.parse(eventMsgs[0]!)[1]; + expect(sentEvent.kind).toBe(EventKinds.GIFT_WRAP); + expect(sentEvent.tags.find((t: string[]) => t[0] === 'p')[1]).toBe(bob.getPublicKeyHex()); + expect(sentEvent.pubkey).not.toBe(keyManager.getPublicKeyHex()); + + socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, ''])); + await publishPromise; + }); + + it('should send read receipt', async () => { + const socket = await connectClient(client); + const bob = NostrKeyManager.generate(); + + const publishPromise = client.sendReadReceipt( + bob.getPublicKeyHex(), + 'event-id-123' + ); + + const eventMsgs = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; } + }); + expect(eventMsgs.length).toBe(1); + + const sentEvent = JSON.parse(eventMsgs[0]!)[1]; + expect(sentEvent.kind).toBe(EventKinds.GIFT_WRAP); + + socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, ''])); + await publishPromise; + }); + + it('should unwrap a received private message', async () => { + const bob = NostrKeyManager.generate(); + + const { createGiftWrap } = await import('../../src/messaging/nip17.js'); + const giftWrap = createGiftWrap(bob, keyManager.getPublicKeyHex(), 'secret from bob'); + + const message = client.unwrapPrivateMessage(giftWrap); + + expect(message.senderPubkey).toBe(bob.getPublicKeyHex()); + expect(message.content).toBe('secret from bob'); + }); + + it('should reject sending to unknown nametag', async () => { + vi.useFakeTimers(); + await connectClient(client); + + const sendPromise = client.sendPrivateMessageToNametag('unknown-user', 'hi'); + // Attach catch handler before advancing timers to prevent unhandled rejection + let nametagError: Error | undefined; + sendPromise.catch(e => { nametagError = e; }); + + await vi.advanceTimersByTimeAsync(5001); + await flushMicrotasks(); + + expect(nametagError).toBeDefined(); + expect(nametagError!.message).toMatch(/Nametag not found/); + vi.useRealTimers(); + }); + }); + + // ========================================================== + // Feature 7: Token/Payment via Client [UC] + // ========================================================== + describe('Feature 7: Token/Payment via Client', () => { + it('should create and publish event via createAndPublishEvent', async () => { + const socket = await connectClient(client); + + const publishPromise = client.createAndPublishEvent({ + kind: EventKinds.TEXT_NOTE, + tags: [['t', 'test']], + content: 'published via helper', + }); + + const eventMsgs = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; } + }); + expect(eventMsgs.length).toBe(1); + + const sentEvent = JSON.parse(eventMsgs[0]!)[1]; + expect(sentEvent.content).toBe('published via helper'); + expect(sentEvent.pubkey).toBe(keyManager.getPublicKeyHex()); + + socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, ''])); + await publishPromise; + }); + + it('should publish encrypted DM via publishEncryptedMessage', async () => { + const socket = await connectClient(client); + const bob = NostrKeyManager.generate(); + + const publishPromise = client.publishEncryptedMessage( + bob.getPublicKeyHex(), + 'secret message' + ); + + // Poll until the async encryption completes and the event is sent + let eventMsgs: string[] = []; + for (let i = 0; i < 50; i++) { + eventMsgs = socket.sentMessages.filter(m => { + try { return JSON.parse(m)[0] === 'EVENT'; } catch { return false; } + }); + if (eventMsgs.length > 0) break; + await new Promise(r => setTimeout(r, 20)); + } + expect(eventMsgs.length).toBe(1); + + const sentEvent = JSON.parse(eventMsgs[0]!)[1]; + expect(sentEvent.kind).toBe(EventKinds.ENCRYPTED_DM); + expect(sentEvent.tags.find((t: string[]) => t[0] === 'p')[1]).toBe(bob.getPublicKeyHex()); + expect(sentEvent.content).not.toBe('secret message'); + expect(sentEvent.content).toContain('?iv='); + + socket.simulateMessage(JSON.stringify(['OK', sentEvent.id, true, ''])); + await publishPromise; + }); + }); +}); diff --git a/tests/unit/schnorr-edge-cases.test.ts b/tests/unit/schnorr-edge-cases.test.ts new file mode 100644 index 0000000..c32af97 --- /dev/null +++ b/tests/unit/schnorr-edge-cases.test.ts @@ -0,0 +1,217 @@ +/** + * Unit tests for Schnorr signing edge cases + * Covers input validation for wrong key/message lengths + * Techniques: [BVA] Boundary Value Analysis, [EG] Error Guessing + */ + +import { describe, it, expect } from 'vitest'; +import * as Schnorr from '../../src/crypto/schnorr.js'; + +describe('Schnorr Edge Cases', () => { + const validPrivateKey = new Uint8Array(32).fill(0x42); + const validMessage = new Uint8Array(32).fill(0xAB); + const validPublicKey = Schnorr.getPublicKey(validPrivateKey); + const validSignature = Schnorr.sign(validMessage, validPrivateKey); + + // ========================================================== + // getPublicKey input validation + // ========================================================== + describe('getPublicKey input validation', () => { + it('should reject empty private key', () => { + expect(() => Schnorr.getPublicKey(new Uint8Array(0))).toThrow('Private key must be 32 bytes'); + }); + + it('should reject 31-byte private key', () => { + expect(() => Schnorr.getPublicKey(new Uint8Array(31))).toThrow('Private key must be 32 bytes'); + }); + + it('should reject 33-byte private key', () => { + expect(() => Schnorr.getPublicKey(new Uint8Array(33))).toThrow('Private key must be 32 bytes'); + }); + + it('should reject 64-byte private key', () => { + expect(() => Schnorr.getPublicKey(new Uint8Array(64))).toThrow('Private key must be 32 bytes'); + }); + + it('should accept 32-byte private key', () => { + const pubkey = Schnorr.getPublicKey(validPrivateKey); + expect(pubkey.length).toBe(32); + }); + + it('should handle private key with leading zeros', () => { + const keyWithLeadingZeros = new Uint8Array(32); + keyWithLeadingZeros[31] = 0x01; // Only last byte is non-zero + const pubkey = Schnorr.getPublicKey(keyWithLeadingZeros); + expect(pubkey.length).toBe(32); + }); + }); + + // ========================================================== + // sign input validation + // ========================================================== + describe('sign input validation', () => { + it('should reject empty message', () => { + expect(() => Schnorr.sign(new Uint8Array(0), validPrivateKey)).toThrow('Message must be 32 bytes'); + }); + + it('should reject 31-byte message', () => { + expect(() => Schnorr.sign(new Uint8Array(31), validPrivateKey)).toThrow('Message must be 32 bytes'); + }); + + it('should reject 33-byte message', () => { + expect(() => Schnorr.sign(new Uint8Array(33), validPrivateKey)).toThrow('Message must be 32 bytes'); + }); + + it('should reject empty private key in sign', () => { + expect(() => Schnorr.sign(validMessage, new Uint8Array(0))).toThrow('Private key must be 32 bytes'); + }); + + it('should reject 31-byte private key in sign', () => { + expect(() => Schnorr.sign(validMessage, new Uint8Array(31))).toThrow('Private key must be 32 bytes'); + }); + + it('should reject 33-byte private key in sign', () => { + expect(() => Schnorr.sign(validMessage, new Uint8Array(33))).toThrow('Private key must be 32 bytes'); + }); + + it('should produce 64-byte signature', () => { + const sig = Schnorr.sign(validMessage, validPrivateKey); + expect(sig.length).toBe(64); + }); + + it('should produce valid signatures (BIP-340 uses randomness)', () => { + // Note: BIP-340 Schnorr signatures use auxiliary randomness + // So two signatures of the same message are NOT identical + const sig1 = Schnorr.sign(validMessage, validPrivateKey); + const sig2 = Schnorr.sign(validMessage, validPrivateKey); + + // Both should be valid, even if different + expect(Schnorr.verify(sig1, validMessage, validPublicKey)).toBe(true); + expect(Schnorr.verify(sig2, validMessage, validPublicKey)).toBe(true); + }); + + it('should produce different signatures for different messages', () => { + const msg1 = new Uint8Array(32).fill(0x01); + const msg2 = new Uint8Array(32).fill(0x02); + const sig1 = Schnorr.sign(msg1, validPrivateKey); + const sig2 = Schnorr.sign(msg2, validPrivateKey); + expect(sig1).not.toEqual(sig2); + }); + }); + + // ========================================================== + // verify input validation + // ========================================================== + describe('verify input validation', () => { + it('should return false for empty signature', () => { + expect(Schnorr.verify(new Uint8Array(0), validMessage, validPublicKey)).toBe(false); + }); + + it('should return false for 63-byte signature', () => { + expect(Schnorr.verify(new Uint8Array(63), validMessage, validPublicKey)).toBe(false); + }); + + it('should return false for 65-byte signature', () => { + expect(Schnorr.verify(new Uint8Array(65), validMessage, validPublicKey)).toBe(false); + }); + + it('should return false for empty message', () => { + expect(Schnorr.verify(validSignature, new Uint8Array(0), validPublicKey)).toBe(false); + }); + + it('should return false for 31-byte message', () => { + expect(Schnorr.verify(validSignature, new Uint8Array(31), validPublicKey)).toBe(false); + }); + + it('should return false for 33-byte message', () => { + expect(Schnorr.verify(validSignature, new Uint8Array(33), validPublicKey)).toBe(false); + }); + + it('should return false for empty public key', () => { + expect(Schnorr.verify(validSignature, validMessage, new Uint8Array(0))).toBe(false); + }); + + it('should return false for 31-byte public key', () => { + expect(Schnorr.verify(validSignature, validMessage, new Uint8Array(31))).toBe(false); + }); + + it('should return false for 33-byte public key', () => { + expect(Schnorr.verify(validSignature, validMessage, new Uint8Array(33))).toBe(false); + }); + + it('should return false for all-zero inputs', () => { + expect(Schnorr.verify( + new Uint8Array(64), + new Uint8Array(32), + new Uint8Array(32) + )).toBe(false); + }); + + it('should return false for wrong public key', () => { + const otherKey = Schnorr.getPublicKey(new Uint8Array(32).fill(0x99)); + expect(Schnorr.verify(validSignature, validMessage, otherKey)).toBe(false); + }); + + it('should return false for tampered message', () => { + const tamperedMessage = new Uint8Array(validMessage); + tamperedMessage[0] ^= 0xFF; + expect(Schnorr.verify(validSignature, tamperedMessage, validPublicKey)).toBe(false); + }); + + it('should return false for tampered signature', () => { + const tamperedSig = new Uint8Array(validSignature); + tamperedSig[0] ^= 0xFF; + expect(Schnorr.verify(tamperedSig, validMessage, validPublicKey)).toBe(false); + }); + + it('should return true for valid signature', () => { + expect(Schnorr.verify(validSignature, validMessage, validPublicKey)).toBe(true); + }); + }); + + // ========================================================== + // verifyHex edge cases + // ========================================================== + describe('verifyHex edge cases', () => { + it('should return false for invalid hex in signature', () => { + expect(Schnorr.verifyHex('ZZZZ', validMessage, '00'.repeat(32))).toBe(false); + }); + + it('should return false for odd-length hex signature', () => { + expect(Schnorr.verifyHex('abc', validMessage, '00'.repeat(32))).toBe(false); + }); + + it('should return false for invalid hex in public key', () => { + expect(Schnorr.verifyHex('00'.repeat(64), validMessage, 'ZZZZ')).toBe(false); + }); + }); + + // ========================================================== + // Multiple keypairs + // ========================================================== + describe('multiple keypairs', () => { + it('should correctly sign and verify with different keypairs', () => { + // Note: 0xFF fill is invalid for secp256k1 (exceeds curve order) + const keypairs = [ + new Uint8Array(32).fill(0x01), + new Uint8Array(32).fill(0x42), + new Uint8Array(32).fill(0x7F), // Valid key (not 0xFF which exceeds N) + ]; + + for (const privateKey of keypairs) { + const publicKey = Schnorr.getPublicKey(privateKey); + const signature = Schnorr.sign(validMessage, privateKey); + + expect(Schnorr.verify(signature, validMessage, publicKey)).toBe(true); + + // Cross-verify should fail + for (const otherKey of keypairs) { + if (otherKey !== privateKey) { + const otherPubkey = Schnorr.getPublicKey(otherKey); + expect(Schnorr.verify(signature, validMessage, otherPubkey)).toBe(false); + } + } + } + }); + }); +}); diff --git a/tests/unit/security-critical.test.ts b/tests/unit/security-critical.test.ts new file mode 100644 index 0000000..72a59d4 --- /dev/null +++ b/tests/unit/security-critical.test.ts @@ -0,0 +1,226 @@ +/** + * Unit tests for security-critical operations + * Feature 18: Security Critical Paths + * Techniques: [RB] Risk-Based Testing + */ + +import { describe, it, expect } from 'vitest'; +import { bytesToHex } from '@noble/hashes/utils'; +import { NostrKeyManager } from '../../src/NostrKeyManager.js'; +import { Event } from '../../src/protocol/Event.js'; +import * as NIP17 from '../../src/messaging/nip17.js'; +import * as EventKinds from '../../src/protocol/EventKinds.js'; + +describe('Security Critical Paths', () => { + // [RB] Private key not accessible after clear + describe('key clearing', () => { + it('should prevent all private key access after clear', () => { + const km = NostrKeyManager.generate(); + km.clear(); + + expect(() => km.getPrivateKey()).toThrow(/has been cleared/); + expect(() => km.getPrivateKeyHex()).toThrow(/has been cleared/); + expect(() => km.getNsec()).toThrow(/has been cleared/); + expect(() => km.getPublicKey()).toThrow(/has been cleared/); + expect(() => km.getPublicKeyHex()).toThrow(/has been cleared/); + expect(() => km.getNpub()).toThrow(/has been cleared/); + }); + + it('should prevent signing after clear', () => { + const km = NostrKeyManager.generate(); + km.clear(); + + expect(() => km.sign(new Uint8Array(32))).toThrow(/has been cleared/); + expect(() => km.signHex(new Uint8Array(32))).toThrow(/has been cleared/); + }); + + it('should prevent NIP-04 encryption after clear', async () => { + const km = NostrKeyManager.generate(); + const other = NostrKeyManager.generate(); + km.clear(); + + await expect(km.encrypt('test', other.getPublicKey())).rejects.toThrow(/has been cleared/); + await expect(km.encryptHex('test', other.getPublicKeyHex())).rejects.toThrow(/has been cleared/); + await expect(km.decrypt('data', other.getPublicKey())).rejects.toThrow(/has been cleared/); + await expect(km.decryptHex('data', other.getPublicKeyHex())).rejects.toThrow(/has been cleared/); + }); + + it('should prevent NIP-44 encryption after clear', () => { + const km = NostrKeyManager.generate(); + const other = NostrKeyManager.generate(); + km.clear(); + + expect(() => km.encryptNip44('test', other.getPublicKey())).toThrow(/has been cleared/); + expect(() => km.encryptNip44Hex('test', other.getPublicKeyHex())).toThrow(/has been cleared/); + expect(() => km.decryptNip44('data', other.getPublicKey())).toThrow(/has been cleared/); + expect(() => km.decryptNip44Hex('data', other.getPublicKeyHex())).toThrow(/has been cleared/); + }); + + it('should prevent shared secret derivation after clear', () => { + const km = NostrKeyManager.generate(); + const other = NostrKeyManager.generate(); + km.clear(); + + expect(() => km.deriveSharedSecret(other.getPublicKey())).toThrow(/has been cleared/); + expect(() => km.deriveConversationKey(other.getPublicKey())).toThrow(/has been cleared/); + }); + + // [RB] Input copy semantics — fromPrivateKey copies, doesn't alias + it('should not mutate the original input array on clear', () => { + const privateKeyBytes = new Uint8Array(32).fill(0x42); + const km = NostrKeyManager.fromPrivateKey(privateKeyBytes); + + // Verify key works before clear + const sig = km.sign(new Uint8Array(32)); + expect(sig.length).toBe(64); + + km.clear(); + + // Original input should be unchanged (was copied, not aliased) + expect(privateKeyBytes[0]).toBe(0x42); + expect(privateKeyBytes.every(b => b === 0x42)).toBe(true); + }); + }); + + // [RB] Private key copy semantics + describe('key copy semantics', () => { + it('getPrivateKey returns a copy, not a reference', () => { + const km = NostrKeyManager.generate(); + const key1 = km.getPrivateKey(); + const original = bytesToHex(key1); + + // Modify the returned array + key1[0] = 0xFF; + key1[1] = 0xFF; + + // Get key again — should be unmodified + const key2 = km.getPrivateKey(); + expect(bytesToHex(key2)).toBe(original); + }); + + it('getPublicKey returns a copy, not a reference', () => { + const km = NostrKeyManager.generate(); + const key1 = km.getPublicKey(); + const original = bytesToHex(key1); + + key1[0] = 0xFF; + + const key2 = km.getPublicKey(); + expect(bytesToHex(key2)).toBe(original); + }); + + it('constructor copies input private key', () => { + const inputKey = new Uint8Array(32).fill(0x42); + const km = NostrKeyManager.fromPrivateKey(inputKey); + + // Modify the original input + inputKey[0] = 0xFF; + + // Key manager should still have the original value + expect(km.getPrivateKey()[0]).toBe(0x42); + }); + }); + + // [RB] Gift wrap does not leak sender identity + describe('NIP-17 sender anonymity', () => { + it('gift wrap event pubkey should NOT be the sender pubkey', () => { + const alice = NostrKeyManager.generate(); + const bob = NostrKeyManager.generate(); + + const giftWrap = NIP17.createGiftWrap( + alice, + bob.getPublicKeyHex(), + 'secret message' + ); + + // The gift wrap is signed by an ephemeral key, NOT Alice + expect(giftWrap.pubkey).not.toBe(alice.getPublicKeyHex()); + }); + + it('different gift wraps use different ephemeral keys', () => { + const alice = NostrKeyManager.generate(); + const bob = NostrKeyManager.generate(); + + const gw1 = NIP17.createGiftWrap(alice, bob.getPublicKeyHex(), 'msg1'); + const gw2 = NIP17.createGiftWrap(alice, bob.getPublicKeyHex(), 'msg2'); + + // Different ephemeral keys each time + expect(gw1.pubkey).not.toBe(gw2.pubkey); + }); + + it('gift wrap recipient tag points to actual recipient', () => { + const alice = NostrKeyManager.generate(); + const bob = NostrKeyManager.generate(); + + const giftWrap = NIP17.createGiftWrap( + alice, + bob.getPublicKeyHex(), + 'message' + ); + + expect(giftWrap.getTagValue('p')).toBe(bob.getPublicKeyHex()); + }); + }); + + // [RB] NIP-17 timestamp randomization + describe('NIP-17 timestamp privacy', () => { + it('timestamps should be randomized within +/- 2 days', () => { + const alice = NostrKeyManager.generate(); + const bob = NostrKeyManager.generate(); + const now = Math.floor(Date.now() / 1000); + const twoDays = 2 * 24 * 60 * 60; + + const timestamps: number[] = []; + for (let i = 0; i < 20; i++) { + const gw = NIP17.createGiftWrap(alice, bob.getPublicKeyHex(), `msg${i}`); + timestamps.push(gw.created_at); + } + + // All should be within +/- 2 days of now + for (const ts of timestamps) { + expect(ts).toBeGreaterThanOrEqual(now - twoDays - 10); + expect(ts).toBeLessThanOrEqual(now + twoDays + 10); + } + + // Not all timestamps should be the same (randomized) + const unique = new Set(timestamps); + expect(unique.size).toBeGreaterThan(1); + }); + }); + + // [RB] NIP-44 conversation key symmetry + describe('NIP-44 conversation key symmetry', () => { + it('conversation key should be symmetric (A->B equals B->A)', () => { + const alice = NostrKeyManager.generate(); + const bob = NostrKeyManager.generate(); + + const keyAB = alice.deriveConversationKey(bob.getPublicKey()); + const keyBA = bob.deriveConversationKey(alice.getPublicKey()); + + expect(bytesToHex(keyAB)).toBe(bytesToHex(keyBA)); + }); + }); + + // [RB] AUTH event correctness + describe('AUTH event structure', () => { + it('should create valid AUTH event with relay and challenge tags', () => { + const km = NostrKeyManager.generate(); + + const authEvent = Event.create(km, { + kind: EventKinds.AUTH, + tags: [ + ['relay', 'wss://relay.example.com'], + ['challenge', 'test-challenge-123'], + ], + content: '', + }); + + expect(authEvent.kind).toBe(22242); + expect(authEvent.getTagValue('relay')).toBe('wss://relay.example.com'); + expect(authEvent.getTagValue('challenge')).toBe('test-challenge-123'); + expect(authEvent.content).toBe(''); + expect(authEvent.pubkey).toBe(km.getPublicKeyHex()); + expect(authEvent.verify()).toBe(true); + }); + }); +}); diff --git a/tests/unit/websocket-adapter.test.ts b/tests/unit/websocket-adapter.test.ts new file mode 100644 index 0000000..a6fa87d --- /dev/null +++ b/tests/unit/websocket-adapter.test.ts @@ -0,0 +1,105 @@ +/** + * Unit tests for WebSocketAdapter + * Feature 10: WebSocket message data extraction + * Techniques: [EP] Equivalence Partitioning, [BVA] Boundary Value Analysis, [EG] Error Guessing + */ + +import { describe, it, expect } from 'vitest'; +import { + extractMessageData, + createWebSocket, + CONNECTING, + OPEN, + CLOSING, + CLOSED, +} from '../../src/client/WebSocketAdapter.js'; +import type { WebSocketMessageEvent } from '../../src/client/WebSocketAdapter.js'; + +describe('WebSocketAdapter', () => { + describe('extractMessageData', () => { + // [EP] Valid: string data + it('should extract string data from string message', () => { + const event: WebSocketMessageEvent = { data: 'hello' }; + expect(extractMessageData(event)).toBe('hello'); + }); + + // [EP] Valid: ArrayBuffer data + it('should extract string data from ArrayBuffer message', () => { + const encoder = new TextEncoder(); + const buffer = encoder.encode('hello').buffer; + const event: WebSocketMessageEvent = { data: buffer as ArrayBuffer }; + expect(extractMessageData(event)).toBe('hello'); + }); + + // [EP] Valid: Node.js Buffer data + it('should extract string data from Node.js Buffer message', () => { + const buf = Buffer.from('hello', 'utf-8'); + const event = { data: buf } as unknown as WebSocketMessageEvent; + expect(extractMessageData(event)).toBe('hello'); + }); + + // [EP] Invalid: Blob data throws error + it('should throw for Blob messages', () => { + const blob = new Blob(['hello']); + const event = { data: blob } as unknown as WebSocketMessageEvent; + expect(() => extractMessageData(event)).toThrow('Blob messages are not supported'); + }); + + // [BVA] Empty string + it('should return empty string for empty string data', () => { + const event: WebSocketMessageEvent = { data: '' }; + expect(extractMessageData(event)).toBe(''); + }); + + // [BVA] Empty ArrayBuffer + it('should return empty string for empty ArrayBuffer', () => { + const event: WebSocketMessageEvent = { data: new ArrayBuffer(0) }; + expect(extractMessageData(event)).toBe(''); + }); + + // [BVA] Large ArrayBuffer + it('should handle large ArrayBuffer message', () => { + const text = 'A'.repeat(100000); + const encoder = new TextEncoder(); + const buffer = encoder.encode(text).buffer; + const event: WebSocketMessageEvent = { data: buffer as ArrayBuffer }; + expect(extractMessageData(event)).toBe(text); + }); + + // [EP] Valid: JSON relay message + it('should correctly extract JSON relay message', () => { + const json = '["EVENT","sub_1",{"id":"abc","kind":1}]'; + const event: WebSocketMessageEvent = { data: json }; + expect(extractMessageData(event)).toBe(json); + }); + + // [EP] Unicode content + it('should handle unicode in ArrayBuffer', () => { + const text = 'Hello \ud83c\udf0d \u4e16\u754c'; + const encoder = new TextEncoder(); + const buffer = encoder.encode(text).buffer; + const event: WebSocketMessageEvent = { data: buffer as ArrayBuffer }; + expect(extractMessageData(event)).toBe(text); + }); + }); + + describe('ready state constants', () => { + it('should have correct WebSocket ready state values', () => { + expect(CONNECTING).toBe(0); + expect(OPEN).toBe(1); + expect(CLOSING).toBe(2); + expect(CLOSED).toBe(3); + }); + }); + + describe('createWebSocket', () => { + // [EP] Node.js environment uses ws package + it('should return a WebSocket instance in Node.js environment', async () => { + const ws = await createWebSocket('wss://relay.example.com'); + // Suppress async connection errors — no server is running + ws.onerror = () => {}; + expect(ws).toBeDefined(); + expect(ws.readyState).toBeDefined(); + }); + }); +});