diff --git a/.changeset/slow-fans-film.md b/.changeset/slow-fans-film.md new file mode 100644 index 00000000..b13e339b --- /dev/null +++ b/.changeset/slow-fans-film.md @@ -0,0 +1,5 @@ +--- +nostream: patch +--- + +Add integration test coverage for NIP-04 encrypted direct messages (kind 4). diff --git a/test/integration/features/nip-04/nip-04.feature b/test/integration/features/nip-04/nip-04.feature new file mode 100644 index 00000000..a5003e68 --- /dev/null +++ b/test/integration/features/nip-04/nip-04.feature @@ -0,0 +1,38 @@ +Feature: NIP-04 Encrypted direct messages + Scenario: Alice publishes an encrypted direct message to Bob + Given someone called Alice + And someone called Bob + When Alice sends an encrypted_direct_message event with content "ciphertext-for-bob" to Bob + And Alice subscribes to author Alice + Then Alice receives an encrypted_direct_message event from Alice with content "ciphertext-for-bob" tagged for Bob + + Scenario: Alice gets her encrypted direct message by event ID + Given someone called Alice + And someone called Bob + When Alice sends an encrypted_direct_message event with content "ciphertext-by-id" to Bob + And Alice subscribes to last event from Alice + Then Alice receives an encrypted_direct_message event from Alice with content "ciphertext-by-id" tagged for Bob + + Scenario: Bob receives Alice's encrypted direct message through #p filter + Given someone called Alice + And someone called Bob + When Alice sends an encrypted_direct_message event with content "ciphertext-for-bob-filter" to Bob + And Bob subscribes to tag p with Bob pubkey + Then Bob receives an encrypted_direct_message event from Alice with content "ciphertext-for-bob-filter" tagged for Bob + + Scenario: Bob and Charlie receive identical ciphertext for Bob's #p filter + Given someone called Alice + And someone called Bob + And someone called Charlie + And Bob subscribes to tag p with Bob pubkey + And Charlie subscribes to tag p with Bob pubkey + When Alice sends an encrypted_direct_message event with content "ciphertext-visible-to-filter-subscribers" to Bob + Then Bob receives an encrypted_direct_message event from Alice with content "ciphertext-visible-to-filter-subscribers" tagged for Bob + And Charlie receives an encrypted_direct_message event from Alice with content "ciphertext-visible-to-filter-subscribers" tagged for Bob + + Scenario: Alice submits a duplicate encrypted direct message + Given someone called Alice + And someone called Bob + When Alice sends an encrypted_direct_message event with content "ciphertext-duplicate" to Bob + And Alice resubmits their last event + Then Alice receives a successful command result with message "duplicate:" diff --git a/test/integration/features/nip-04/nip-04.feature.ts b/test/integration/features/nip-04/nip-04.feature.ts new file mode 100644 index 00000000..f6b7de75 --- /dev/null +++ b/test/integration/features/nip-04/nip-04.feature.ts @@ -0,0 +1,98 @@ +import { Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import { Observable } from 'rxjs' +import WebSocket from 'ws' + +import { CommandResult, MessageType, OutgoingMessage } from '../../../../src/@types/messages' +import { createEvent, createSubscription, sendEvent, waitForEOSE, waitForNextEvent } from '../helpers' +import { EventKinds, EventTags } from '../../../../src/constants/base' +import { Event } from '../../../../src/@types/event' +import { streams } from '../shared' + +When(/^(\w+) sends an encrypted_direct_message event with content "([^"]+)" to (\w+)$/, async function( + name: string, + content: string, + recipient: string, +) { + const ws = this.parameters.clients[name] as WebSocket + const { pubkey, privkey } = this.parameters.identities[name] + const recipientPubkey = this.parameters.identities[recipient].pubkey + + const event: Event = await createEvent( + { + pubkey, + kind: EventKinds.ENCRYPTED_DIRECT_MESSAGE, + content, + tags: [[EventTags.Pubkey, recipientPubkey]], + }, + privkey, + ) + + await sendEvent(ws, event) + this.parameters.events[name].push(event) +}) + +When(/^(\w+) subscribes to tag p with (\w+) pubkey$/, async function( + this: World>, + name: string, + target: string, +) { + const ws = this.parameters.clients[name] as WebSocket + const targetPubkey = this.parameters.identities[target].pubkey + const subscription = { name: `test-${Math.random()}`, filters: [{ '#p': [targetPubkey] }] } + this.parameters.subscriptions[name].push(subscription) + + await createSubscription(ws, subscription.name, subscription.filters) + await waitForEOSE(ws, subscription.name) +}) + +Then(/(\w+) receives an encrypted_direct_message event from (\w+) with content "([^"]+?)" tagged for (\w+)/, async function( + name: string, + author: string, + content: string, + recipient: string, +) { + const ws = this.parameters.clients[name] as WebSocket + const subscription = this.parameters.subscriptions[name][this.parameters.subscriptions[name].length - 1] + const recipientPubkey = this.parameters.identities[recipient].pubkey + const receivedEvent = await waitForNextEvent(ws, subscription.name, content) + + expect(receivedEvent.kind).to.equal(EventKinds.ENCRYPTED_DIRECT_MESSAGE) + expect(receivedEvent.pubkey).to.equal(this.parameters.identities[author].pubkey) + expect(receivedEvent.content).to.equal(content) + expect(receivedEvent.tags).to.deep.include([EventTags.Pubkey, recipientPubkey]) +}) + +When(/^(\w+) resubmits their last event$/, async function(name: string) { + const ws = this.parameters.clients[name] as WebSocket + const event = this.parameters.events[name][this.parameters.events[name].length - 1] as Event + + await new Promise((resolve, reject) => { + ws.send(JSON.stringify(['EVENT', event]), (err?: Error) => err ? reject(err) : resolve()) + }) + + this.parameters.lastResubmittedEventId = this.parameters.lastResubmittedEventId ?? {} + this.parameters.lastResubmittedEventId[name] = event.id +}) + +Then(/^(\w+) receives a successful command result with message "([^"]+)"$/, async function(name: string, message: string) { + const ws = this.parameters.clients[name] as WebSocket + const eventId = this.parameters.lastResubmittedEventId[name] as string + const observable = streams.get(ws) as Observable + const command = await new Promise((resolve, reject) => { + observable.subscribe((response: OutgoingMessage) => { + if ( + response[0] === MessageType.OK && + response[1] === eventId && + response[3] === message + ) { + resolve(response) + } else if (response[0] === MessageType.NOTICE) { + reject(new Error(response[1])) + } + }) + }) + + expect(command[2]).to.equal(true) + expect(command[3]).to.equal(message) +}) diff --git a/test/unit/utils/messages.spec.ts b/test/unit/utils/messages.spec.ts index 1ca17ef5..de0aaa94 100644 --- a/test/unit/utils/messages.spec.ts +++ b/test/unit/utils/messages.spec.ts @@ -36,8 +36,16 @@ describe('createEndOfStoredEventsNoticeMessage', () => { }) }) -// NIP-20: Command Results describe('createCommandResult', () => { + it('returns a command result message', () => { + expect(createCommandResult('event-id', true, 'accepted')).to.deep.equal([ + MessageType.OK, + 'event-id', + true, + 'accepted', + ]) + }) + it('returns an OK message with success=true and a reason', () => { const eventId = 'b1601d26958e6508b7b9df0af609c652346c09392b6534d93aead9819a51b4ef' expect(createCommandResult(eventId, true, '')).to.deep.equal([MessageType.OK, eventId, true, '']) @@ -54,7 +62,6 @@ describe('createCommandResult', () => { }) }) -// NIP-01: Subscription messages (REQ) describe('createSubscriptionMessage', () => { it('returns a REQ message with a single filter', () => { const result = createSubscriptionMessage('sub1', [{ kinds: [1] }]) @@ -71,9 +78,18 @@ describe('createSubscriptionMessage', () => { expect(result[2]).to.deep.equal(filters[0]) expect(result[3]).to.deep.equal(filters[1]) }) + + it('returns a subscription message with filters', () => { + const filters = [{ authors: ['author-1'], kinds: [1], '#p': ['recipient-1'] }] + + expect(createSubscriptionMessage('subscriptionId', filters)).to.deep.equal([ + MessageType.REQ, + 'subscriptionId', + ...filters, + ]) + }) }) -// Relayed event messages (used for event mirroring between relays) describe('createRelayedEventMessage', () => { let event: RelayedEvent @@ -89,11 +105,15 @@ describe('createRelayedEventMessage', () => { } as any }) - it('returns an EVENT message without secret when no secret is provided', () => { + it('returns an EVENT message without secret when secret is missing', () => { expect(createRelayedEventMessage(event)).to.deep.equal([MessageType.EVENT, event]) }) - it('returns an EVENT message with secret appended when a secret is provided', () => { - expect(createRelayedEventMessage(event, 'my-secret')).to.deep.equal([MessageType.EVENT, event, 'my-secret']) + it('returns an EVENT message with secret when provided', () => { + expect(createRelayedEventMessage(event, 'shared-secret')).to.deep.equal([ + MessageType.EVENT, + event, + 'shared-secret', + ]) }) })