Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/slow-fans-film.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
nostream: patch
---

Add integration test coverage for NIP-04 encrypted direct messages (kind 4).
38 changes: 38 additions & 0 deletions test/integration/features/nip-04/nip-04.feature
Original file line number Diff line number Diff line change
@@ -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:"
98 changes: 98 additions & 0 deletions test/integration/features/nip-04/nip-04.feature.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>>,
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<void>((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<OutgoingMessage>
const command = await new Promise<CommandResult>((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)
})
32 changes: 26 additions & 6 deletions test/unit/utils/messages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, ''])
Expand All @@ -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] }])
Expand All @@ -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

Expand All @@ -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',
])
})
})
Loading