Skip to content

Commit 3d33caa

Browse files
test(nip77): cover negentropy wire protocol and sessions
Signed-off-by: Priyanshubhartistm <bhartipriyanshustm@gmail.com>
1 parent 32215f2 commit 3d33caa

11 files changed

Lines changed: 1070 additions & 0 deletions

‎test/unit/adapters/web-socket-adapter.spec.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const { expect } = chai
1313

1414
import { WebSocketAdapterEvent, WebSocketServerAdapterEvent } from '../../../src/constants/adapter'
1515
import { IWebSocketServerAdapter } from '../../../src/@types/adapters'
16+
import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy'
1617
import { WebSocketAdapter } from '../../../src/adapters/web-socket-adapter'
1718
import { markActionableTarget, resetHiddenContentCache } from '../../../src/utils/hidden-content-cache'
1819

@@ -976,4 +977,86 @@ describe('WebSocketAdapter', () => {
976977
adapter2.removeAllListeners()
977978
})
978979
})
980+
981+
describe('NIP-77 negentropy sessions', () => {
982+
const emptyStorage = () => {
983+
const storage = new NegentropyStorageVector()
984+
storage.seal()
985+
986+
return new Negentropy(storage)
987+
}
988+
989+
const openSession = (id: string, idleTimeoutMs = 60000) => {
990+
const sessions = adapter.getNegentropySessions()
991+
sessions.activate(id, sessions.reserve(id, 3) as symbol, emptyStorage(), idleTimeoutMs)
992+
}
993+
994+
const sentMessages = () => client.send.getCalls().map((call: any) => JSON.parse(call.args[0]))
995+
996+
const triggerMessage = async (payload: unknown) => {
997+
const call = client.on.getCalls().find((c: any) => c.args[0] === 'message')
998+
await call.args[1](Buffer.from(JSON.stringify(payload)))
999+
}
1000+
1001+
it('hands the same sessions to every caller', () => {
1002+
expect(adapter.getNegentropySessions()).to.equal(adapter.getNegentropySessions())
1003+
})
1004+
1005+
it('dispatches NEG-OPEN, NEG-MSG and NEG-CLOSE to a message handler', async () => {
1006+
const handleMessage = sandbox.stub().resolves()
1007+
createMessageHandler.returns({ handleMessage })
1008+
1009+
await triggerMessage(['NEG-OPEN', 'sync1', { kinds: [1] }, '6100000200'])
1010+
await triggerMessage(['NEG-MSG', 'sync1', '6100'])
1011+
await triggerMessage(['NEG-CLOSE', 'sync1'])
1012+
1013+
expect(handleMessage.getCalls().map((call: any) => call.args[0][0])).to.deep.equal([
1014+
'NEG-OPEN',
1015+
'NEG-MSG',
1016+
'NEG-CLOSE',
1017+
])
1018+
})
1019+
1020+
it('replies with a NOTICE to a NEG-OPEN whose initial message is not hex', async () => {
1021+
await triggerMessage(['NEG-OPEN', 'sync1', {}, 'not hex'])
1022+
1023+
expect(createMessageHandler).to.not.have.been.called
1024+
expect(sentMessages()).to.have.lengthOf(1)
1025+
expect(sentMessages()[0][0]).to.equal('NOTICE')
1026+
expect(sentMessages()[0][1]).to.match(/^invalid:/)
1027+
})
1028+
1029+
it('tells the client when a session times out', () => {
1030+
const clock = sandbox.useFakeTimers()
1031+
openSession('sync1', 1000)
1032+
1033+
clock.tick(1000)
1034+
1035+
expect(sentMessages()).to.deep.include(['NEG-ERR', 'sync1', 'closed: negentropy session timed out'])
1036+
expect(adapter.getNegentropySessions().getSessionIds()).to.deep.equal([])
1037+
})
1038+
1039+
it('drops every session and its timer when the client disconnects', () => {
1040+
const clock = sandbox.useFakeTimers()
1041+
openSession('sync1', 1000)
1042+
const onClose = client.on.getCalls().find((call: any) => call.args[0] === 'close').args[1]
1043+
1044+
onClose()
1045+
clock.tick(5000)
1046+
1047+
expect(adapter.getNegentropySessions().getSessionIds()).to.deep.equal([])
1048+
expect(sentMessages().filter((message: any[]) => message[0] === 'NEG-ERR')).to.deep.equal([])
1049+
})
1050+
1051+
it('tells the client its sessions are closed when the relay drains', () => {
1052+
openSession('a')
1053+
openSession('b')
1054+
1055+
adapter.drainAndClose('relay shutting down')
1056+
1057+
expect(sentMessages()).to.deep.include(['NEG-ERR', 'a', 'closed: relay shutting down'])
1058+
expect(sentMessages()).to.deep.include(['NEG-ERR', 'b', 'closed: relay shutting down'])
1059+
expect(adapter.getNegentropySessions().getSessionIds()).to.deep.equal([])
1060+
})
1061+
})
9791062
})

‎test/unit/factories/message-handler-factory.spec.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { EventMessageHandler } from '../../../src/handlers/event-message-handler
1515
import { IWebSocketAdapter } from '../../../src/@types/adapters'
1616
import { messageHandlerFactory } from '../../../src/factories/message-handler-factory'
1717
import { CountMessageHandler } from '../../../src/handlers/count-message-handler'
18+
import { NegCloseMessageHandler } from '../../../src/handlers/neg-close-message-handler'
19+
import { NegMsgMessageHandler } from '../../../src/handlers/neg-msg-message-handler'
20+
import { NegOpenMessageHandler } from '../../../src/handlers/neg-open-message-handler'
1821
import { SubscribeMessageHandler } from '../../../src/handlers/subscribe-message-handler'
1922
import { UnsubscribeMessageHandler } from '../../../src/handlers/unsubscribe-message-handler'
2023
import * as cacheModule from '../../../src/cache/client'
@@ -101,6 +104,24 @@ describe('messageHandlerFactory', () => {
101104
expect(factory([message, adapter])).to.be.an.instanceOf(AuthMessageHandler)
102105
})
103106

107+
it('returns NegOpenMessageHandler when given a NEG-OPEN message', () => {
108+
message = [MessageType.NEG_OPEN, 'sync1', {}, '6100000200'] as any
109+
110+
expect(factory([message, adapter])).to.be.an.instanceOf(NegOpenMessageHandler)
111+
})
112+
113+
it('returns NegMsgMessageHandler when given a NEG-MSG message', () => {
114+
message = [MessageType.NEG_MSG, 'sync1', '6100'] as any
115+
116+
expect(factory([message, adapter])).to.be.an.instanceOf(NegMsgMessageHandler)
117+
})
118+
119+
it('returns NegCloseMessageHandler when given a NEG-CLOSE message', () => {
120+
message = [MessageType.NEG_CLOSE, 'sync1'] as any
121+
122+
expect(factory([message, adapter])).to.be.an.instanceOf(NegCloseMessageHandler)
123+
})
124+
104125
it('throws when given an invalid message', () => {
105126
message = [] as any
106127

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { expect } from 'chai'
2+
import EventEmitter from 'events'
3+
4+
import { IWebSocketAdapter } from '../../../src/@types/adapters'
5+
import { MessageType } from '../../../src/@types/messages'
6+
import { NegCloseMessageHandler } from '../../../src/handlers/neg-close-message-handler'
7+
import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy'
8+
import { NegentropySessions } from '../../../src/utils/negentropy-sessions'
9+
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
10+
11+
describe('NegCloseMessageHandler', () => {
12+
let sessions: NegentropySessions
13+
let webSocket: IWebSocketAdapter
14+
let handler: NegCloseMessageHandler
15+
let emitted: unknown[][]
16+
17+
const openSession = (id: string) => {
18+
const storage = new NegentropyStorageVector()
19+
storage.seal()
20+
sessions.activate(id, sessions.reserve(id, 3) as symbol, new Negentropy(storage), 60000)
21+
}
22+
23+
beforeEach(() => {
24+
sessions = new NegentropySessions(() => undefined)
25+
webSocket = new EventEmitter() as any
26+
webSocket.getNegentropySessions = () => sessions
27+
emitted = []
28+
webSocket.on(WebSocketAdapterEvent.Message, (...args) => emitted.push(args))
29+
handler = new NegCloseMessageHandler(webSocket)
30+
})
31+
32+
afterEach(() => {
33+
sessions.closeAll()
34+
webSocket.removeAllListeners()
35+
})
36+
37+
it('closes the session without replying', async () => {
38+
openSession('sync1')
39+
40+
await handler.handleMessage([MessageType.NEG_CLOSE, 'sync1'] as any)
41+
42+
expect(sessions.getSessionIds()).to.deep.equal([])
43+
expect(emitted).to.deep.equal([])
44+
})
45+
46+
it('only closes the named session', async () => {
47+
openSession('a')
48+
openSession('b')
49+
50+
await handler.handleMessage([MessageType.NEG_CLOSE, 'a'] as any)
51+
52+
expect(sessions.getSessionIds()).to.deep.equal(['b'])
53+
})
54+
55+
it('ignores an unknown id', async () => {
56+
await handler.handleMessage([MessageType.NEG_CLOSE, 'nope'] as any)
57+
58+
expect(emitted).to.deep.equal([])
59+
})
60+
})
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import chai from 'chai'
2+
import { createHash } from 'crypto'
3+
import EventEmitter from 'events'
4+
import Sinon from 'sinon'
5+
import sinonChai from 'sinon-chai'
6+
7+
import { IWebSocketAdapter } from '../../../src/@types/adapters'
8+
import { MessageType } from '../../../src/@types/messages'
9+
import { Settings } from '../../../src/@types/settings'
10+
import { WebSocketAdapterEvent } from '../../../src/constants/adapter'
11+
import { NegMsgMessageHandler } from '../../../src/handlers/neg-msg-message-handler'
12+
import { Negentropy, NegentropyStorageVector } from '../../../src/utils/negentropy'
13+
import { NegentropySessions } from '../../../src/utils/negentropy-sessions'
14+
15+
chai.use(sinonChai)
16+
const { expect } = chai
17+
18+
const storageOf = (count: number): NegentropyStorageVector => {
19+
const storage = new NegentropyStorageVector()
20+
for (let i = 0; i < count; i++) {
21+
storage.insert(1700000000 + i, createHash('sha256').update(`neg-msg-${i}`).digest())
22+
}
23+
storage.seal()
24+
25+
return storage
26+
}
27+
28+
describe('NegMsgMessageHandler', () => {
29+
let sandbox: Sinon.SinonSandbox
30+
let webSocket: IWebSocketAdapter
31+
let sessions: NegentropySessions
32+
let settings: Settings
33+
let emitted: Sinon.SinonStub
34+
let handler: NegMsgMessageHandler
35+
36+
const send = (id: string, hex: string) => handler.handleMessage([MessageType.NEG_MSG, id, hex] as any)
37+
38+
const openSession = (id: string, itemCount = 5) => {
39+
const token = sessions.reserve(id, 3) as symbol
40+
sessions.activate(id, token, new Negentropy(storageOf(itemCount)), 60000)
41+
}
42+
43+
const validMessageHex = Buffer.from(new Negentropy(storageOf(0)).initiate()).toString('hex')
44+
45+
beforeEach(() => {
46+
sandbox = Sinon.createSandbox()
47+
sessions = new NegentropySessions(() => undefined)
48+
settings = { nip77: { enabled: true } } as Settings
49+
50+
webSocket = new EventEmitter() as any
51+
webSocket.getNegentropySessions = () => sessions
52+
53+
emitted = sandbox.stub()
54+
webSocket.on(WebSocketAdapterEvent.Message, emitted)
55+
56+
handler = new NegMsgMessageHandler(webSocket, () => settings)
57+
})
58+
59+
afterEach(() => {
60+
sessions.closeAll()
61+
webSocket.removeAllListeners()
62+
sandbox.restore()
63+
})
64+
65+
it('answers a message on an open session with a NEG-MSG', async () => {
66+
openSession('sync1')
67+
68+
await send('sync1', validMessageHex)
69+
70+
expect(emitted).to.have.been.calledOnce
71+
const [type, id, payload] = emitted.firstCall.args[0]
72+
expect(type).to.equal(MessageType.NEG_MSG)
73+
expect(id).to.equal('sync1')
74+
expect(payload).to.match(/^61(?:[0-9a-f]{2})+$/)
75+
expect(sessions.getSessionIds()).to.deep.equal(['sync1'])
76+
})
77+
78+
it('replies NEG-ERR closed for an unknown subscription id', async () => {
79+
await send('nope', validMessageHex)
80+
81+
expect(emitted).to.have.been.calledOnceWithExactly([
82+
MessageType.NEG_ERR,
83+
'nope',
84+
'closed: unknown negentropy subscription',
85+
])
86+
})
87+
88+
it('does not answer for a REQ subscription id: the namespaces are separate', async () => {
89+
// A REQ subscription named "sync1" lives in the adapter's subscriptions, not in the negentropy sessions.
90+
await send('sync1', validMessageHex)
91+
92+
expect(emitted.firstCall.args[0][0]).to.equal(MessageType.NEG_ERR)
93+
})
94+
95+
it('closes the session and replies NEG-ERR when the message is malformed', async () => {
96+
openSession('sync1')
97+
98+
await send('sync1', '6100')
99+
100+
expect(emitted).to.have.been.calledOnceWithExactly([
101+
MessageType.NEG_ERR,
102+
'sync1',
103+
'invalid: malformed negentropy message',
104+
])
105+
expect(sessions.getSessionIds()).to.deep.equal([])
106+
})
107+
108+
it('keeps other sessions when one is closed by an error', async () => {
109+
openSession('a')
110+
openSession('b')
111+
112+
await send('a', '6100')
113+
114+
expect(sessions.getSessionIds()).to.deep.equal(['b'])
115+
})
116+
117+
it('ends the session with NEG-ERR blocked when NIP-77 is turned off while it is open', async () => {
118+
openSession('sync1')
119+
settings = { nip77: { enabled: false } } as Settings
120+
121+
await send('sync1', validMessageHex)
122+
123+
expect(emitted).to.have.been.calledOnceWithExactly([
124+
MessageType.NEG_ERR,
125+
'sync1',
126+
'blocked: NIP-77 is disabled by relay configuration',
127+
])
128+
expect(sessions.getSessionIds()).to.deep.equal([])
129+
})
130+
131+
it('answers an unsupported protocol version with just the supported version', async () => {
132+
openSession('sync1')
133+
134+
await send('sync1', '62')
135+
136+
expect(emitted).to.have.been.calledOnceWithExactly([MessageType.NEG_MSG, 'sync1', '61'])
137+
})
138+
})

0 commit comments

Comments
 (0)