@bitpay-labs/bitcore-wallet-service 11.10.8 → 11.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.mocharc.json +6 -0
  2. package/README.md +1 -4
  3. package/mcr.config.js +17 -0
  4. package/package.json +6 -6
  5. package/src/lib/messagebroker.ts +9 -3
  6. package/src/lib/model/notification.ts +2 -2
  7. package/src/lib/routes/helpers/error.ts +1 -0
  8. package/src/lib/routes/tss.ts +51 -4
  9. package/src/lib/server.ts +10 -6
  10. package/src/lib/storage.ts +5 -3
  11. package/src/lib/tss.ts +251 -34
  12. package/test/integration/server.test.ts +2 -2
  13. package/test/messagebroker.test.ts +137 -0
  14. package/test/tss.test.ts +165 -0
  15. package/ts_build/package.json +5 -5
  16. package/ts_build/src/lib/messagebroker.d.ts +4 -2
  17. package/ts_build/src/lib/messagebroker.d.ts.map +1 -1
  18. package/ts_build/src/lib/messagebroker.js +4 -1
  19. package/ts_build/src/lib/messagebroker.js.map +1 -1
  20. package/ts_build/src/lib/model/notification.d.ts +2 -2
  21. package/ts_build/src/lib/model/notification.d.ts.map +1 -1
  22. package/ts_build/src/lib/routes/helpers/error.d.ts.map +1 -1
  23. package/ts_build/src/lib/routes/helpers/error.js +1 -0
  24. package/ts_build/src/lib/routes/helpers/error.js.map +1 -1
  25. package/ts_build/src/lib/routes/tss.d.ts.map +1 -1
  26. package/ts_build/src/lib/routes/tss.js +31 -4
  27. package/ts_build/src/lib/routes/tss.js.map +1 -1
  28. package/ts_build/src/lib/server.d.ts +3 -2
  29. package/ts_build/src/lib/server.d.ts.map +1 -1
  30. package/ts_build/src/lib/server.js +9 -5
  31. package/ts_build/src/lib/server.js.map +1 -1
  32. package/ts_build/src/lib/storage.d.ts +5 -3
  33. package/ts_build/src/lib/storage.d.ts.map +1 -1
  34. package/ts_build/src/lib/storage.js.map +1 -1
  35. package/ts_build/src/lib/tss.d.ts +20 -4
  36. package/ts_build/src/lib/tss.d.ts.map +1 -1
  37. package/ts_build/src/lib/tss.js +155 -17
  38. package/ts_build/src/lib/tss.js.map +1 -1
  39. package/ts_build/test/integration/server.test.js +2 -2
  40. package/ts_build/test/integration/server.test.js.map +1 -1
  41. package/ts_build/test/messagebroker.test.d.ts +2 -0
  42. package/ts_build/test/messagebroker.test.d.ts.map +1 -0
  43. package/ts_build/test/messagebroker.test.js +155 -0
  44. package/ts_build/test/messagebroker.test.js.map +1 -0
  45. package/ts_build/test/tss.test.d.ts +2 -0
  46. package/ts_build/test/tss.test.d.ts.map +1 -0
  47. package/ts_build/test/tss.test.js +190 -0
  48. package/ts_build/test/tss.test.js.map +1 -0
  49. package/.mocharc.yml +0 -2
  50. package/.nycrc.yml +0 -13
package/src/lib/tss.ts CHANGED
@@ -1,36 +1,184 @@
1
+ import { EventEmitter } from 'events';
1
2
  import { BitcoreLib } from '@bitpay-labs/crypto-wallet-core';
2
3
  import { Constants } from './common/constants';
3
4
  import { Errors } from './errors/errordefinitions';
4
5
  import logger from './logger';
5
- import { ITssKeyMessageObject, TssKeyGenModel } from './model/tsskeygen';
6
- import { ITssSigMessageObject, TssSigGenModel } from './model/tsssign';
6
+ import { TssKeyGenModel } from './model/tsskeygen';
7
+ import { TssSigGenModel } from './model/tsssign';
7
8
  import { WalletService, checkRequired } from './server';
8
9
  import { Storage } from './storage';
10
+ import type { INotification } from './model/notification';
11
+ import type { ITssKeyMessageObject } from './model/tsskeygen';
12
+ import type { ITssSigMessageObject } from './model/tsssign';
13
+
14
+ type SessionHandler = (message: INotification) => Promise<void>;
15
+
16
+ const sessionRegistry = new Map<string, Set<SessionHandler>>();
17
+ let dispatcherRegistered = false;
18
+
19
+ function sessionKey(type: string, id: string | number): string {
20
+ return JSON.stringify([type, id]);
21
+ }
22
+
23
+ function dispatchSessionMessage(message: INotification): void {
24
+ const handlers = sessionRegistry.get(sessionKey(message.type, message.id));
25
+ for (const notify of [...(handlers ?? [])]) {
26
+ void notify(message).catch(err => {
27
+ logger.error('Error handling TSS session update: %o', err);
28
+ });
29
+ }
30
+ }
31
+
32
+ function subscribeToSession(type: string, id: string, handler: SessionHandler): () => void {
33
+ if (!dispatcherRegistered) {
34
+ WalletService.getMessageBroker().onMessage(dispatchSessionMessage);
35
+ dispatcherRegistered = true;
36
+ }
37
+
38
+ const key = sessionKey(type, id);
39
+ let handlers = sessionRegistry.get(key);
40
+ if (!handlers) {
41
+ handlers = new Set();
42
+ sessionRegistry.set(key, handlers);
43
+ }
44
+ handlers.add(handler);
45
+
46
+ return () => {
47
+ if (!handlers.delete(handler)) {
48
+ return;
49
+ }
50
+ if (handlers.size === 0) {
51
+ sessionRegistry.delete(key);
52
+ }
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Get a bounded wait time in milliseconds for TSS message retrieval. The wait time is bounded between 0 and 20 seconds.
58
+ * If no maxWaitTimeSec is provided, the default is maxSec seconds (default: 20).
59
+ * @param {number} maxWaitTimeSec The value to be bounded
60
+ * @param {number} maxSec The maximum wait time in seconds (default: 20)
61
+ * @param {number} minSec The minimum wait time in seconds (default: 0)
62
+ */
63
+ function getBoundedWaitTime(maxWaitTimeSec?: number, maxSec = 20, minSec = 0): number {
64
+ maxWaitTimeSec = Math.max(isNaN(maxWaitTimeSec) ? maxSec : maxWaitTimeSec, minSec);
65
+ const maxWaitTime = Math.min(maxWaitTimeSec, maxSec) * 1000;
66
+ return maxWaitTime;
67
+ }
68
+
69
+ /**
70
+ * Common function for listening to the completion of a TSS session's round.
71
+ */
72
+ async function listenForSessionComplete<T extends TssKeyGenModel | TssSigGenModel>(params: {
73
+ /** Message type to listen for */
74
+ messageType: typeof TssKeyGenClass.TSS_KEYGEN_MESSAGE_TYPE | typeof TssSignClass.TSS_SIGGEN_MESSAGE_TYPE;
75
+ /** Session to listen for updates */
76
+ session: T;
77
+ /** Function to determine if the round is complete */
78
+ isComplete: (session: T) => boolean;
79
+ /** Function to fetch the latest session state */
80
+ fetchSession: (params: { id: string }) => Promise<T>;
81
+ /** Maximum time (in milliseconds) to wait for the round to complete */
82
+ maxWaitTime: number;
83
+ /** Abort signal to cancel waiting for a complete round */
84
+ abortSignal?: AbortSignal;
85
+ }): Promise<T> {
86
+ const { messageType, isComplete, fetchSession, maxWaitTime, abortSignal } = params;
87
+ let { session } = params;
88
+
89
+ const events = new EventEmitter();
90
+ const sessionUpdateHandler = async () => {
91
+ try {
92
+ const _session = await fetchSession({ id: session.id });
93
+ if (isComplete(_session)) {
94
+ unsubscribe();
95
+ events.emit('session', _session);
96
+ }
97
+ } catch (err) {
98
+ // Do not throw on possibly transient db connection errors. At worst, this runs until the maxWaitTime expires
99
+ logger.error('Error fetching updated TSS session: %o - %o', session.id, err);
100
+ }
101
+ };
102
+ const unsubscribe = subscribeToSession(messageType, session.id, sessionUpdateHandler);
103
+
104
+ // Listen for session update events over the message broker service
105
+ let timer: NodeJS.Timeout;
106
+ const sessionUpdate = Promise.race([
107
+ new Promise<T>(r => events.once('session', r)),
108
+ new Promise<T>(r => timer = setTimeout(() => { unsubscribe(); r(session); }, maxWaitTime)),
109
+ new Promise<T>((_, j) => abortSignal?.addEventListener('abort', () => { unsubscribe(); j(new Error('Aborted')); }))
110
+ ]);
111
+ sessionUpdate.catch(() => {}); // marks sessionUpdate as "handled" immediately; the await below still sees the rejection
112
+
113
+ try {
114
+ // Check for an updated session one last time before awaiting the subscription.
115
+ // This is to prevent a race condition where the update arrives before we start listening for it.
116
+ const _session = await fetchSession({ id: session.id });
117
+ if (isComplete(_session)) {
118
+ session = _session;
119
+ } else if (!abortSignal?.aborted) {
120
+ session = await sessionUpdate;
121
+ }
122
+ return session;
123
+ } finally {
124
+ unsubscribe();
125
+ clearTimeout(timer);
126
+ events.removeAllListeners();
127
+ }
128
+ }
9
129
 
10
130
  class TssKeyGenClass {
131
+ static TSS_KEYGEN_MESSAGE_TYPE = 'TssKeyGenMessage' as const;
132
+
133
+ /**
134
+ * Check if a copayer is a participant in a TSS keygen session and return the session.
135
+ * Throws an error if the copayer is not a participant or if the session does not exist.
136
+ */
137
+ async getSessionForCopayer(params: {
138
+ /** Session ID */
139
+ id: string;
140
+ /** Copayer ID of the requesting party */
141
+ copayerId: string;
142
+ }): Promise<TssKeyGenModel> {
143
+ const { id, copayerId } = params;
144
+ const storage = WalletService.getStorage();
145
+ const session = await storage.fetchTssKeyGenSession({ id });
146
+ if (!session) {
147
+ throw Errors.TSS_SESSION_NOT_FOUND;
148
+ }
149
+
150
+ const isParticipant = session.participants.includes(copayerId);
151
+ if (!isParticipant) {
152
+ throw Errors.TSS_NON_PARTICIPANT;
153
+ }
154
+
155
+ return session;
156
+ }
157
+
11
158
  /**
12
159
  * Get messages for a given party in a TSS keygen session.
13
160
  * Only returns messages if all other parties have sent their messages for the round.
14
161
  */
15
162
  async getMessagesForParty(params: {
16
- /** Session ID */
17
- id: string;
163
+ /** Session */
164
+ session: TssKeyGenModel;
18
165
  /** Round number */
19
166
  round: number;
20
167
  /** Copayer ID of the requesting party */
21
168
  copayerId: string;
169
+ /** Maximum time (in seconds) to wait for a complete round */
170
+ maxWaitTimeSec?: number;
171
+ /** Abort signal to cancel waiting for a complete round */
172
+ abortSignal?: AbortSignal;
22
173
  }): Promise<{
23
174
  messages?: ITssKeyMessageObject[];
24
175
  publicKey?: string;
25
176
  hasKeyBackup?: boolean;
26
177
  }> {
27
- const { id, round, copayerId } = params;
178
+ const { round, copayerId, abortSignal } = params;
179
+ let { session } = params;
180
+ const maxWaitTime = getBoundedWaitTime(params.maxWaitTimeSec);
28
181
 
29
- const storage = WalletService.getStorage();
30
- const session = await storage.fetchTssKeyGenSession({ id });
31
- if (!session) {
32
- throw Errors.TSS_SESSION_NOT_FOUND;
33
- }
34
182
  if (!session.rounds[round]) {
35
183
  return {};
36
184
  }
@@ -40,15 +188,33 @@ class TssKeyGenClass {
40
188
  throw Errors.TSS_NON_PARTICIPANT;
41
189
  }
42
190
 
43
- const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != partyId);
191
+ const isRoundComplete = (session) => {
192
+ const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != partyId);
193
+ return otherPartyMsgs.length === session.n - 1;
194
+ };
195
+
196
+ if (!isRoundComplete(session)) {
197
+ const storage = WalletService.getStorage();
198
+ session = await listenForSessionComplete<TssKeyGenModel>({
199
+ messageType: TssKeyGenClass.TSS_KEYGEN_MESSAGE_TYPE,
200
+ session,
201
+ isComplete: isRoundComplete,
202
+ fetchSession: storage.fetchTssKeyGenSession.bind(storage),
203
+ maxWaitTime,
204
+ abortSignal
205
+ });
206
+ }
207
+
208
+
44
209
  // Only return message if all other parties have sent their messages.
45
210
  // This is to prevent complexity in TSS session management when processing rounds. There's
46
211
  // no value in partially processing rounds with missing messages and messages can't be
47
212
  // re-processed, so it makes sense to only return messages when the round is complete.
48
- if (otherPartyMsgs.length !== session.n - 1) {
213
+ if (!isRoundComplete(session)) {
49
214
  return {};
50
215
  }
51
216
 
217
+ const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != partyId);
52
218
  const messages = otherPartyMsgs.map(m => m.messages);
53
219
  for (const m of messages) {
54
220
  m.p2pMessages = m.p2pMessages.filter(m => m.to == partyId);
@@ -121,7 +287,7 @@ class TssKeyGenClass {
121
287
 
122
288
  let result = false;
123
289
  while (!result) {
124
- result = await this._pushMessage({ id, session, message, storage });
290
+ result = await this._pushMessage({ session, message, storage });
125
291
  if (!result) {
126
292
  session = await storage.fetchTssKeyGenSession({ id });
127
293
  }
@@ -225,8 +391,6 @@ class TssKeyGenClass {
225
391
  * This will fail if the round is already complete or if the message is from a party that has already sent a message for the round.
226
392
  */
227
393
  private async _pushMessage(params: {
228
- /** Session ID */
229
- id: string;
230
394
  /** TSS keygen session fetched from BWS storage */
231
395
  session: TssKeyGenModel;
232
396
  /** Message to push to the session */
@@ -234,7 +398,8 @@ class TssKeyGenClass {
234
398
  /** BWS storage instance */
235
399
  storage: Storage;
236
400
  }) {
237
- const { id, session, message, storage } = params;
401
+ const { session, message, storage } = params;
402
+ const { id } = session;
238
403
  const { round } = message;
239
404
 
240
405
  const currentRound = session.getCurrentRound();
@@ -248,6 +413,8 @@ class TssKeyGenClass {
248
413
  if (existing) {
249
414
  throw Errors.TSS_ROUND_MESSAGE_EXISTS;
250
415
  }
416
+
417
+ const messageBroker = WalletService.getMessageBroker();
251
418
 
252
419
  try {
253
420
  const result = await storage.storeTssKeyGenMessage({ id, message, __v: session.__v });
@@ -255,6 +422,7 @@ class TssKeyGenClass {
255
422
  logger.error('Failed to store TSS key generation message %o %o %o', id, result, message);
256
423
  throw Errors.TSS_GENERIC_ERROR.withMessage('Failed to store TSS key generation message');
257
424
  }
425
+ messageBroker.send({ type: TssKeyGenClass.TSS_KEYGEN_MESSAGE_TYPE, id } as INotification);
258
426
  return true;
259
427
  } catch (e) {
260
428
  if (e?.message?.startsWith('MONGO_DOC_OUTDATED')) {
@@ -373,25 +541,54 @@ class TssKeyGenClass {
373
541
  export const TssKeyGen = new TssKeyGenClass();
374
542
 
375
543
  class TssSignClass {
544
+ static TSS_SIGGEN_MESSAGE_TYPE = 'TssSigMessage' as const;
545
+
546
+ /**
547
+ * Check if a copayer is a participant in a TSS signature session and return the session.
548
+ * Throws an error if the copayer is not a participant or if the session does not exist.
549
+ */
550
+ async getSessionForCopayer(params: {
551
+ /** Session ID */
552
+ id: string;
553
+ /** Copayer ID of the requesting party */
554
+ copayerId: string;
555
+ }): Promise<TssSigGenModel> {
556
+ const { id, copayerId } = params;
557
+ const storage = WalletService.getStorage();
558
+ const session = await storage.fetchTssSigSession({ id });
559
+ if (!session) {
560
+ throw Errors.TSS_SESSION_NOT_FOUND;
561
+ }
562
+
563
+ const party = session.participants.find(p => p.copayerId === copayerId);
564
+ if (!party) {
565
+ throw Errors.TSS_NON_PARTICIPANT;
566
+ }
567
+
568
+ return session;
569
+ }
570
+
571
+
376
572
  /**
377
573
  * Get messages for a given party in a TSS signature session.
378
574
  * Only returns messages if all other parties have sent their messages for the round.
379
575
  */
380
576
  async getMessagesForParty(params: {
381
577
  /** Session ID */
382
- id: string;
578
+ session: TssSigGenModel;
383
579
  /** Round number */
384
580
  round: number;
385
581
  /** Copayer ID of the requesting party */
386
582
  copayerId: string;
583
+ /** Maximum time (in seconds) to wait for a complete round */
584
+ maxWaitTimeSec?: number;
585
+ /** Abort signal to cancel waiting for a complete round */
586
+ abortSignal?: AbortSignal;
387
587
  }): Promise<{ messages?: ITssSigMessageObject[]; signature?: ITssSigMessageObject['signature']; participants?: string[] }> {
388
- const { id, round, copayerId } = params;
588
+ const { round, copayerId, abortSignal } = params;
589
+ let { session } = params;
590
+ const maxWaitTime = getBoundedWaitTime(params.maxWaitTimeSec);
389
591
 
390
- const storage = WalletService.getStorage();
391
- const session = await storage.fetchTssSigSession({ id });
392
- if (!session) {
393
- throw Errors.TSS_SESSION_NOT_FOUND;
394
- }
395
592
  if (!session.rounds[round]) {
396
593
  return {};
397
594
  }
@@ -401,20 +598,38 @@ class TssSignClass {
401
598
  throw Errors.TSS_NON_PARTICIPANT;
402
599
  }
403
600
 
601
+ const isRoundComplete = (session) => {
602
+ const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != party.partyId);
603
+ return otherPartyMsgs.length === session.m - 1;
604
+ };
605
+
606
+ if (!isRoundComplete(session)) {
607
+ const storage = WalletService.getStorage();
608
+ session = await listenForSessionComplete<TssSigGenModel>({
609
+ messageType: TssSignClass.TSS_SIGGEN_MESSAGE_TYPE,
610
+ session,
611
+ isComplete: isRoundComplete,
612
+ fetchSession: storage.fetchTssSigSession.bind(storage),
613
+ maxWaitTime,
614
+ abortSignal
615
+ });
616
+ }
617
+
404
618
  const otherPartyMsgs = session.rounds[round].filter(m => m.fromPartyId != party.partyId);
405
619
  const participants = otherPartyMsgs.map(m => {
406
620
  const p = session.participants.find(p => p.partyId === m.fromPartyId);
407
621
  return p?.copayerId;
408
622
  }).filter(Boolean) as string[];
409
623
 
410
- if (otherPartyMsgs.length === session.m - 1) {
411
- const messages = otherPartyMsgs.map(m => m.messages);
412
- for (const m of messages) {
413
- m.p2pMessages = m.p2pMessages.filter(m => m.to == party.partyId);
414
- }
415
- return { messages, signature: session.signature, participants };
624
+ if (!isRoundComplete(session)) {
625
+ return { participants };
416
626
  }
417
- return { participants };
627
+
628
+ const messages = otherPartyMsgs.map(m => m.messages);
629
+ for (const m of messages) {
630
+ m.p2pMessages = m.p2pMessages.filter(m => m.to == party.partyId);
631
+ }
632
+ return { messages, signature: session.signature, participants };
418
633
  }
419
634
 
420
635
  /**
@@ -486,7 +701,7 @@ class TssSignClass {
486
701
 
487
702
  let result = false;
488
703
  while (!result) {
489
- result = await this._pushMessage({ id, session, message, storage });
704
+ result = await this._pushMessage({ session, message, storage });
490
705
  // `result` will be false if the session was stale (version conflict) and we need to retry
491
706
  // Any other failure of the message state will result in a throw (e.g. same-message race condition, round already done, etc.)
492
707
  if (!result) {
@@ -563,8 +778,6 @@ class TssSignClass {
563
778
  * Push a TSS signature message to the session.
564
779
  */
565
780
  private async _pushMessage(params: {
566
- /** Session ID */
567
- id: string;
568
781
  /** TSS sig generation session fetched from BWS storage */
569
782
  session: TssSigGenModel;
570
783
  /** TSS signature message to be pushed */
@@ -572,7 +785,8 @@ class TssSignClass {
572
785
  /** BWS storage instance */
573
786
  storage: Storage;
574
787
  }): Promise<boolean> {
575
- const { id, session, message, storage } = params;
788
+ const { session, message, storage } = params;
789
+ const { id } = session;
576
790
  const { round } = message;
577
791
 
578
792
  const currentRound = session.getCurrentRound();
@@ -587,12 +801,15 @@ class TssSignClass {
587
801
  throw Errors.TSS_ROUND_MESSAGE_EXISTS;
588
802
  }
589
803
 
804
+ const messageBroker = WalletService.getMessageBroker();
805
+
590
806
  try {
591
807
  const result = await storage.storeTssSigMessage({ id, message, __v: session.__v });
592
808
  if (!result.result.ok) {
593
809
  logger.error('Failed to store TSS key generation message %o %o %o', id, result, message);
594
810
  throw Errors.TSS_GENERIC_ERROR.withMessage('Failed to store TSS key generation message');
595
811
  }
812
+ messageBroker.send({ type: TssSignClass.TSS_SIGGEN_MESSAGE_TYPE, id } as INotification);
596
813
  return true;
597
814
  } catch (e) {
598
815
  if (e?.message?.startsWith('MONGO_DOC_OUTDATED')) {
@@ -1665,7 +1665,7 @@ describe('Wallet service', function() {
1665
1665
  message: { partyId: 0, broadcastMessages: [], p2pMessages: [], publicKey: 'dummy', round: 0 },
1666
1666
  n: 1,
1667
1667
  copayerId: legitCopayerId,
1668
- version: 1.1,
1668
+ version: Defaults.TSS_KEYGEN_SCHEME_VERSION
1669
1669
  });
1670
1670
  session.sharedPublicKey = 'dummy-shared-public-key';
1671
1671
  await server.storage.db.collection('tss_keygen').deleteMany({ id: session.id });
@@ -1732,7 +1732,7 @@ describe('Wallet service', function() {
1732
1732
  message: { partyId: 0, broadcastMessages: [], p2pMessages: [], publicKey: 'dummy', round: 0 },
1733
1733
  n: 1,
1734
1734
  copayerId: ancillaryDerivedCopayerId,
1735
- version: 1.1,
1735
+ version: Defaults.TSS_KEYGEN_SCHEME_VERSION
1736
1736
  });
1737
1737
  session.sharedPublicKey = 'dummy-shared-public-key';
1738
1738
  await server.storage.db.collection('tss_keygen').deleteMany({ id: session.id });
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ import 'chai/register-should';
4
+ import * as sinon from 'sinon';
5
+ import * as chai from 'chai';
6
+ import io from 'socket.io-client';
7
+ import logger from '../src/lib/logger';
8
+ import { MessageBroker } from '../src/lib/messagebroker';
9
+ import type { INotification } from '../src/lib/model/notification';
10
+
11
+ const should = chai.should();
12
+
13
+ describe('MessageBroker', function() {
14
+ const sandbox = sinon.createSandbox();
15
+ const opts = {
16
+ messageBrokerServer: {
17
+ url: 'http://dummy:3380'
18
+ }
19
+ };
20
+
21
+ beforeEach(function() {
22
+ sandbox.stub(io, 'connect').returns({
23
+ on: sandbox.stub(),
24
+ emit: sandbox.stub()
25
+ });
26
+ sandbox.stub(logger, 'info');
27
+ sandbox.stub(logger, 'warn');
28
+ sandbox.stub(logger, 'error');
29
+ });
30
+
31
+ afterEach(function() {
32
+ sandbox.restore();
33
+ });
34
+
35
+ describe('#constructor', function() {
36
+ it('should create a local MessageBroker instance without opts', function() {
37
+ const mb = new MessageBroker(null);
38
+ mb.should.be.instanceof(MessageBroker);
39
+ should.not.exist(mb.mq);
40
+ mb.remote.should.equal(false);
41
+ });
42
+
43
+ it('should create a fleshed out MessageBroker instance', function() {
44
+ const mb = new MessageBroker(opts);
45
+ mb.should.be.instanceof(MessageBroker);
46
+ should.exist(mb.mq);
47
+ mb.remote.should.equal(true);
48
+ const onStub = mb.mq.on as sinon.SinonStub;
49
+ onStub.callCount.should.equal(3);
50
+ onStub.getCall(0).args[0].should.equal('connect');
51
+ onStub.getCall(1).args[0].should.equal('connect_error');
52
+ onStub.getCall(2).args[0].should.equal('msg');
53
+ (logger.info as sinon.SinonStub).callCount.should.equal(1);
54
+ });
55
+ });
56
+
57
+ describe('#send', function() {
58
+ it('should emit a message when remote is false', function() {
59
+ const mb = new MessageBroker(null);
60
+ const emitSpy = sandbox.spy(mb, 'emit');
61
+ const data = { type: 'test' };
62
+ mb.send(data as INotification);
63
+ emitSpy.calledOnce.should.equal(true);
64
+ emitSpy.calledWith('msg', data).should.equal(true);
65
+ });
66
+
67
+ it('should emit a message when remote is true', function() {
68
+ const mb = new MessageBroker(opts);
69
+ const emitSpy = mb.mq.emit as sinon.SinonSpy;
70
+ const data = { type: 'test' };
71
+ mb.send(data as INotification);
72
+ emitSpy.calledOnce.should.equal(true);
73
+ emitSpy.calledWith('msg', data).should.equal(true);
74
+ });
75
+ });
76
+
77
+ describe('#onMessage', function() {
78
+ it('should register a message handler', function() {
79
+ const mb = new MessageBroker(null);
80
+ const handler = sandbox.stub();
81
+ mb.onMessage(handler);
82
+ mb.emit('msg', { type: 'test' });
83
+ handler.calledOnce.should.equal(true);
84
+ });
85
+
86
+ it('should register multiple message handlers', function() {
87
+ const mb = new MessageBroker(null);
88
+ const handler1 = sandbox.stub();
89
+ const handler2 = sandbox.stub();
90
+ mb.onMessage(handler1);
91
+ mb.onMessage(handler2);
92
+ mb.emit('msg', { type: 'test' });
93
+ handler1.calledOnce.should.equal(true);
94
+ handler2.calledOnce.should.equal(true);
95
+ });
96
+
97
+ it('should register the same handler twice', function() {
98
+ const mb = new MessageBroker(null);
99
+ const handler = sandbox.stub();
100
+ mb.onMessage(handler);
101
+ mb.onMessage(handler);
102
+ mb.emit('msg', { type: 'test' });
103
+ handler.calledTwice.should.equal(true);
104
+ });
105
+ });
106
+
107
+ describe('#offMessage', function() {
108
+ it('should unregister a message handler', function() {
109
+ const mb = new MessageBroker(null);
110
+ const handler = sandbox.stub();
111
+ mb.onMessage(handler);
112
+ mb.offMessage(handler);
113
+ mb.emit('msg', { type: 'test' });
114
+ handler.called.should.equal(false);
115
+ });
116
+
117
+ it('should unregister a message handler closure', function() {
118
+ const mb = new MessageBroker(null);
119
+ const closure = sandbox.stub();
120
+ const handler = (message: INotification) => {
121
+ closure(message);
122
+ };
123
+
124
+ // Register the same closure more than once.
125
+ mb.onMessage(handler);
126
+ mb.onMessage(handler);
127
+ mb.listeners('msg').length.should.equal(2);
128
+
129
+ // Unsubscribe should remove only one instance of the same closure.
130
+ mb.offMessage(handler);
131
+ mb.listeners('msg').length.should.equal(1);
132
+
133
+ mb.emit('msg', { type: 'test' });
134
+ closure.calledOnce.should.equal(true);
135
+ });
136
+ });
137
+ });