@mojaloop/sdk-scheme-adapter 12.0.2 → 12.1.0
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.
- package/.env.example +13 -0
- package/CHANGELOG.md +15 -0
- package/audit-resolve.json +20 -0
- package/package.json +5 -1
- package/src/ControlAgent/index.js +221 -0
- package/src/ControlServer/handlers.js +63 -0
- package/src/ControlServer/index.js +294 -0
- package/src/InboundServer/index.js +33 -1
- package/src/OutboundServer/index.js +15 -0
- package/src/TestServer/index.js +31 -0
- package/src/config.js +6 -0
- package/test/config/integration.env +9 -0
- package/test/unit/ControlClient.test.js +113 -0
- package/test/unit/ControlServer/events.js +41 -0
- package/test/unit/ControlServer/index.js +231 -0
- package/test/unit/ControlServer.test.js +126 -0
- package/test/unit/config.test.js +1 -0
- package/test/unit/index.test.js +1 -0
- package/junit.xml +0 -515
|
@@ -24,6 +24,7 @@ const Validate = require('../lib/validate');
|
|
|
24
24
|
const router = require('../lib/router');
|
|
25
25
|
const handlers = require('./handlers');
|
|
26
26
|
const middlewares = require('./middlewares');
|
|
27
|
+
const check = require('../lib/check');
|
|
27
28
|
|
|
28
29
|
class InboundApi extends EventEmitter {
|
|
29
30
|
constructor(conf, logger, cache, validator) {
|
|
@@ -43,7 +44,7 @@ class InboundApi extends EventEmitter {
|
|
|
43
44
|
});
|
|
44
45
|
|
|
45
46
|
if (conf.validateInboundJws) {
|
|
46
|
-
this._jwsVerificationKeys = InboundApi._GetJwsKeys(conf.jwsVerificationKeysDirectory);
|
|
47
|
+
this._jwsVerificationKeys = conf.pm4mlEnabled ? conf.peerJWSKeys : InboundApi._GetJwsKeys(conf.jwsVerificationKeysDirectory);
|
|
47
48
|
}
|
|
48
49
|
this._api = InboundApi._SetupApi({
|
|
49
50
|
conf,
|
|
@@ -187,6 +188,37 @@ class InboundServer extends EventEmitter {
|
|
|
187
188
|
this._logger.log('inbound shut down complete');
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
async reconfigure(conf, logger, cache) {
|
|
192
|
+
// It may be possible to extract the socket from an existing HTTP/HTTPS server and replace
|
|
193
|
+
// it in a new server of the other type, as Node's HTTP and HTTPS servers both eventually
|
|
194
|
+
// are subclasses of net.Server. This wasn't considered as a requirement at the time of
|
|
195
|
+
// writing.
|
|
196
|
+
assert(
|
|
197
|
+
this._conf.mutualTLS.inboundRequests.enabled === conf.mutualTLS.inboundRequests.enabled,
|
|
198
|
+
'Cannot live-restart an HTTPS server as HTTP or vice versa',
|
|
199
|
+
);
|
|
200
|
+
const newApi = new InboundApi(conf, logger, cache, this._validator);
|
|
201
|
+
await newApi.start();
|
|
202
|
+
return () => {
|
|
203
|
+
this._logger = logger;
|
|
204
|
+
this._cache = cache;
|
|
205
|
+
// TODO: .tls might be undefined, causing an.. err.. undefined dereference..
|
|
206
|
+
const tlsCredsChanged = check.notDeepEqual(
|
|
207
|
+
conf.inbound.tls.creds,
|
|
208
|
+
this._conf.inbound.tls.creds
|
|
209
|
+
);
|
|
210
|
+
if (this._conf.mutualTLS.inboundRequests.enabled && tlsCredsChanged) {
|
|
211
|
+
this._server.setSecureContext(conf.inbound.tls.creds);
|
|
212
|
+
}
|
|
213
|
+
this._server.removeAllListeners('request');
|
|
214
|
+
this._server.on('request', newApi.callback());
|
|
215
|
+
this._api.stop();
|
|
216
|
+
this._api = newApi;
|
|
217
|
+
this._conf = conf;
|
|
218
|
+
this._logger.log('restarted');
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
190
222
|
_createServer(tlsEnabled, tlsCreds, handler) {
|
|
191
223
|
if (!tlsEnabled) {
|
|
192
224
|
return http.createServer(handler);
|
|
@@ -132,6 +132,21 @@ class OutboundServer extends EventEmitter {
|
|
|
132
132
|
}
|
|
133
133
|
this._logger.log('Shut down complete');
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
async reconfigure(conf, logger, cache, metricsClient) {
|
|
137
|
+
const newApi = new OutboundApi(conf, logger, cache, this._validator, metricsClient);
|
|
138
|
+
await newApi.start();
|
|
139
|
+
return () => {
|
|
140
|
+
this._logger = logger;
|
|
141
|
+
this._cache = cache;
|
|
142
|
+
this._server.removeAllListeners('request');
|
|
143
|
+
this._server.on('request', newApi.callback());
|
|
144
|
+
this._api.stop();
|
|
145
|
+
this._api = newApi;
|
|
146
|
+
this._conf = conf;
|
|
147
|
+
this._logger.log('restarted');
|
|
148
|
+
};
|
|
149
|
+
}
|
|
135
150
|
}
|
|
136
151
|
|
|
137
152
|
module.exports = OutboundServer;
|
package/src/TestServer/index.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
const Koa = require('koa');
|
|
12
12
|
const ws = require('ws');
|
|
13
13
|
|
|
14
|
+
const assert = require('assert').strict;
|
|
14
15
|
const http = require('http');
|
|
15
16
|
const yaml = require('js-yaml');
|
|
16
17
|
const fs = require('fs').promises;
|
|
@@ -210,6 +211,36 @@ class TestServer {
|
|
|
210
211
|
}
|
|
211
212
|
this._logger.log('Test server shutdown complete');
|
|
212
213
|
}
|
|
214
|
+
|
|
215
|
+
async reconfigure({ port, logger, cache }) {
|
|
216
|
+
assert(port === this._port, 'Cannot reconfigure running port');
|
|
217
|
+
const newApi = new TestApi(logger, cache, this._validator);
|
|
218
|
+
const newWsApi = new WsServer(logger.push({ component: 'websocket-server' }), cache);
|
|
219
|
+
await newWsApi.start();
|
|
220
|
+
|
|
221
|
+
return () => {
|
|
222
|
+
const oldWsApi = this._wsapi;
|
|
223
|
+
this._logger = logger;
|
|
224
|
+
this._cache = cache;
|
|
225
|
+
this._wsapi = newWsApi;
|
|
226
|
+
this._api = newApi;
|
|
227
|
+
this._server.removeAllListeners('upgrade');
|
|
228
|
+
this._server.on('upgrade', (req, socket, head) => {
|
|
229
|
+
this._wsapi.handleUpgrade(req, socket, head, (ws) =>
|
|
230
|
+
this._wsapi.emit('connection', ws, req));
|
|
231
|
+
});
|
|
232
|
+
this._server.removeAllListeners('request');
|
|
233
|
+
this._server.on('request', newApi.callback());
|
|
234
|
+
// TODO: we can't guarantee client implementations. Therefore we can't guarantee
|
|
235
|
+
// reconnect logic/behaviour. Therefore instead of closing all websocket client
|
|
236
|
+
// connections as we do below, we should replace handlers.
|
|
237
|
+
oldWsApi.stop().catch((err) => {
|
|
238
|
+
this._logger.push({ err }).log('Error stopping websocket server during reconfigure');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
this._logger.log('restarted');
|
|
242
|
+
};
|
|
243
|
+
}
|
|
213
244
|
}
|
|
214
245
|
|
|
215
246
|
module.exports = TestServer;
|
package/src/config.js
CHANGED
|
@@ -58,6 +58,10 @@ const env = from(process.env, {
|
|
|
58
58
|
|
|
59
59
|
module.exports = {
|
|
60
60
|
__parseResourceVersion: parseResourceVersions,
|
|
61
|
+
control: {
|
|
62
|
+
mgmtAPIWsUrl: env.get('MGMT_API_WS_URL').required().asString(),
|
|
63
|
+
mgmtAPIWsPort: env.get('MGMT_API_WS_PORT').default('4005').asPortNumber()
|
|
64
|
+
},
|
|
61
65
|
mutualTLS: {
|
|
62
66
|
inboundRequests: {
|
|
63
67
|
enabled: env.get('INBOUND_MUTUAL_TLS_ENABLED').default('false').asBool(),
|
|
@@ -160,4 +164,6 @@ module.exports = {
|
|
|
160
164
|
// a transactionRequestId. this option decodes the ilp packet for
|
|
161
165
|
// the `transactionId` to retrieve the quote from cache
|
|
162
166
|
allowDifferentTransferTransactionId: env.get('ALLOW_DIFFERENT_TRANSFER_TRANSACTION_ID').default('false').asBool(),
|
|
167
|
+
|
|
168
|
+
pm4mlEnabled: env.get('PM4ML_ENABLED').default('false').asBool(),
|
|
163
169
|
};
|
|
@@ -131,3 +131,12 @@ TRANSFERS_ENDPOINT=ml-testing-toolkit:5000
|
|
|
131
131
|
# The incoming transfer request should consists of an ILP packet and a matching condition in this case.
|
|
132
132
|
# The fulfilment will be generated from the provided ILP packet, and must hash to the provided condition.
|
|
133
133
|
ALLOW_TRANSFER_WITHOUT_QUOTE=false
|
|
134
|
+
|
|
135
|
+
# Management API websocket connection settings.
|
|
136
|
+
# The Management API uses this for exchanging connector management messages.
|
|
137
|
+
MGMT_API_WS_URL=127.0.0.1
|
|
138
|
+
MGMT_API_WS_PORT=4005
|
|
139
|
+
|
|
140
|
+
# Set to true to enable the use of PM4ML-related services e.g MCM, Management API service
|
|
141
|
+
# when running the scheme-adapter as a mojaloop connector component within Payment Manager for Mojaloop.
|
|
142
|
+
PM4ML_ENABLED=false
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
|
|
2
|
+
const ControlAgent = require('~/ControlAgent');
|
|
3
|
+
const TestControlServer = require('./ControlServer');
|
|
4
|
+
const InboundServer = require('~/InboundServer');
|
|
5
|
+
const OutboundServer = require('~/OutboundServer');
|
|
6
|
+
const TestServer = require('~/TestServer');
|
|
7
|
+
const defaultConfig = require('./data/defaultConfig.json');
|
|
8
|
+
const { Logger } = require('@mojaloop/sdk-standard-components');
|
|
9
|
+
|
|
10
|
+
jest.mock('~/lib/cache');
|
|
11
|
+
const Cache = require('~/lib/cache');
|
|
12
|
+
|
|
13
|
+
// TODO:
|
|
14
|
+
// - diff against master to determine what else needs testing
|
|
15
|
+
// - especially look for assertions in the code
|
|
16
|
+
// - err.. grep the code for TODO
|
|
17
|
+
|
|
18
|
+
describe('ControlAgent', () => {
|
|
19
|
+
it('exposes a valid message API', () => {
|
|
20
|
+
expect(Object.keys(ControlAgent.build).sort()).toEqual(
|
|
21
|
+
Object.keys(ControlAgent.MESSAGE).sort(),
|
|
22
|
+
'The API exposed by the builder object must contain as top-level keys all of the message types exposed in the MESSAGE constant. Check that ControlAgent.MESSAGE has the same keys as ControlAgent.build.'
|
|
23
|
+
);
|
|
24
|
+
Object.entries(ControlAgent.build).forEach(([messageType, builders]) => {
|
|
25
|
+
expect(Object.keys(ControlAgent.VERB)).toEqual(
|
|
26
|
+
expect.arrayContaining(Object.keys(builders)),
|
|
27
|
+
`For message type '${messageType}' every builder must correspond to a verb. Check that ControlAgent.build.${messageType} has the same keys as ControlAgent.VERB.`
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
expect(Object.keys(ControlAgent.build.ERROR.NOTIFY).sort()).toEqual(
|
|
31
|
+
Object.keys(ControlAgent.ERROR).sort(),
|
|
32
|
+
'ControlAgent.ERROR.NOTIFY should contain the same keys as ControlAgent.ERROR'
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('API', () => {
|
|
37
|
+
let server, logger, client;
|
|
38
|
+
const appConfig = { control: { port: 4005 }, what: 'ever' };
|
|
39
|
+
const changedConfig = { ...appConfig, some: 'thing' };
|
|
40
|
+
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
logger = new Logger.Logger({ stringify: () => '' });
|
|
43
|
+
server = new TestControlServer.Server({ logger, appConfig });
|
|
44
|
+
client = await ControlAgent.Client.Create({
|
|
45
|
+
address: 'localhost',
|
|
46
|
+
port: server.address().port,
|
|
47
|
+
logger,
|
|
48
|
+
appConfig
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(async () => {
|
|
53
|
+
await client.stop();
|
|
54
|
+
await server.stop();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('receives config when requested', async () => {
|
|
58
|
+
await client.send(ControlAgent.build.CONFIGURATION.READ());
|
|
59
|
+
const response = await client.receive();
|
|
60
|
+
expect(response).toEqual({
|
|
61
|
+
...JSON.parse(ControlAgent.build.CONFIGURATION.NOTIFY(appConfig, response.id)),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('emits new config when received', async () => {
|
|
66
|
+
const newConfigEvent = new Promise(
|
|
67
|
+
(resolve) => client.on(ControlAgent.EVENT.RECONFIGURE, resolve)
|
|
68
|
+
);
|
|
69
|
+
await server.broadcastConfigChange(changedConfig);
|
|
70
|
+
const newConfEventData = await newConfigEvent;
|
|
71
|
+
expect(newConfEventData).toEqual(changedConfig);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('Server reconfigure methods', () => {
|
|
77
|
+
let conf, logger, cache;
|
|
78
|
+
|
|
79
|
+
const isPromise = (o) => Promise.resolve(o) === o;
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
conf = JSON.parse(JSON.stringify(defaultConfig));
|
|
83
|
+
logger = new Logger.Logger({ stringify: () => '' });
|
|
84
|
+
cache = new Cache({ ...conf.cacheConfig, logger: logger.push({ component: 'cache' }) });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('InboundServer reconfigure method returns sync function', async () => {
|
|
88
|
+
const server = new InboundServer(conf, logger, cache);
|
|
89
|
+
const res = await server.reconfigure(conf, logger, cache);
|
|
90
|
+
expect(isPromise(res)).toEqual(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('OutboundServer reconfigure method returns sync function', async () => {
|
|
94
|
+
const server = new OutboundServer(conf, logger, cache);
|
|
95
|
+
const res = await server.reconfigure(conf, logger, cache);
|
|
96
|
+
expect(isPromise(res)).toEqual(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('TestServer reconfigure method returns sync function', async () => {
|
|
100
|
+
const server = new TestServer({ logger, cache });
|
|
101
|
+
const res = await server.reconfigure({ logger, cache });
|
|
102
|
+
expect(isPromise(res)).toEqual(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('ControlClient reconfigure method returns sync function', async () => {
|
|
106
|
+
const server = new TestControlServer.Server({ logger, appConfig: { ...conf, control: { port: 4005 }}});
|
|
107
|
+
const client = await ControlAgent.Client.Create({ port: 4005, logger, appConfig: {} });
|
|
108
|
+
const res = await client.reconfigure({ logger, port: 4005, appConfig: {} });
|
|
109
|
+
expect(isPromise(res)).toEqual(false);
|
|
110
|
+
await client.close();
|
|
111
|
+
await server.close();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**************************************************************************
|
|
2
|
+
* (C) Copyright ModusBox Inc. 2020 - All rights reserved. *
|
|
3
|
+
* *
|
|
4
|
+
* This file is made available under the terms of the license agreement *
|
|
5
|
+
* specified in the corresponding source code repository. *
|
|
6
|
+
* *
|
|
7
|
+
* ORIGINAL AUTHOR: *
|
|
8
|
+
* Steven Oderayi - steven.oderayi@modusbox.com *
|
|
9
|
+
**************************************************************************/
|
|
10
|
+
|
|
11
|
+
const { EventEmitter } = require('events');
|
|
12
|
+
|
|
13
|
+
/**************************************************************************
|
|
14
|
+
* Internal events received by the control server via the exposed internal
|
|
15
|
+
* event emitter.
|
|
16
|
+
*************************************************************************/
|
|
17
|
+
const INTERNAL_EVENTS = {
|
|
18
|
+
SERVER: {
|
|
19
|
+
BROADCAST_CONFIG_CHANGE: 'BROADCAST_CONFIG_CHANGE',
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const internalEventEmitter = new EventEmitter();
|
|
23
|
+
|
|
24
|
+
/**************************************************************************
|
|
25
|
+
* getInternalEventEmitter
|
|
26
|
+
*
|
|
27
|
+
* Returns an EventEmmitter that can be used to exchange internal events with
|
|
28
|
+
* either the control server or the client from other modules within this service.
|
|
29
|
+
* This prevents the need to pass down references to either the server or the client
|
|
30
|
+
* from one module to another in order to use their interfaces.
|
|
31
|
+
*
|
|
32
|
+
* @returns {events.EventEmitter}
|
|
33
|
+
*************************************************************************/
|
|
34
|
+
const getInternalEventEmitter = () => {
|
|
35
|
+
return internalEventEmitter;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
module.exports = {
|
|
39
|
+
getInternalEventEmitter,
|
|
40
|
+
INTERNAL_EVENTS
|
|
41
|
+
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**************************************************************************
|
|
2
|
+
* (C) Copyright ModusBox Inc. 2020 - All rights reserved. *
|
|
3
|
+
* *
|
|
4
|
+
* This file is made available under the terms of the license agreement *
|
|
5
|
+
* specified in the corresponding source code repository. *
|
|
6
|
+
* *
|
|
7
|
+
* ORIGINAL AUTHOR: *
|
|
8
|
+
* Matt Kingston - matt.kingston@modusbox.com *
|
|
9
|
+
**************************************************************************/
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const ws = require('ws');
|
|
13
|
+
const jsonPatch = require('fast-json-patch');
|
|
14
|
+
const randomPhrase = require('~/lib/randomphrase');
|
|
15
|
+
const { getInternalEventEmitter, INTERNAL_EVENTS } = require('./events');
|
|
16
|
+
|
|
17
|
+
const ControlServerEventEmitter = getInternalEventEmitter();
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/**************************************************************************
|
|
21
|
+
* The message protocol messages, verbs, and errors
|
|
22
|
+
*************************************************************************/
|
|
23
|
+
const MESSAGE = {
|
|
24
|
+
CONFIGURATION: 'CONFIGURATION',
|
|
25
|
+
ERROR: 'ERROR',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const VERB = {
|
|
29
|
+
READ: 'READ',
|
|
30
|
+
NOTIFY: 'NOTIFY',
|
|
31
|
+
PATCH: 'PATCH'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const ERROR = {
|
|
35
|
+
UNSUPPORTED_MESSAGE: 'UNSUPPORTED_MESSAGE',
|
|
36
|
+
UNSUPPORTED_VERB: 'UNSUPPORTED_VERB',
|
|
37
|
+
JSON_PARSE_ERROR: 'JSON_PARSE_ERROR',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**************************************************************************
|
|
41
|
+
* Private convenience functions
|
|
42
|
+
*************************************************************************/
|
|
43
|
+
const serialise = JSON.stringify;
|
|
44
|
+
const deserialise = (msg) => {
|
|
45
|
+
//reviver function
|
|
46
|
+
return JSON.parse(msg.toString(), (k, v) => {
|
|
47
|
+
if (
|
|
48
|
+
v !== null &&
|
|
49
|
+
typeof v === 'object' &&
|
|
50
|
+
'type' in v &&
|
|
51
|
+
v.type === 'Buffer' &&
|
|
52
|
+
'data' in v &&
|
|
53
|
+
Array.isArray(v.data)) {
|
|
54
|
+
return new Buffer(v.data);
|
|
55
|
+
}
|
|
56
|
+
return v;
|
|
57
|
+
});
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
const buildMsg = (verb, msg, data, id = randomPhrase()) => serialise({
|
|
62
|
+
verb,
|
|
63
|
+
msg,
|
|
64
|
+
data,
|
|
65
|
+
id,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const buildPatchConfiguration = (oldConf, newConf, id) => {
|
|
69
|
+
const patches = jsonPatch.compare(oldConf, newConf);
|
|
70
|
+
return buildMsg(VERB.PATCH, MESSAGE.CONFIGURATION, patches, id);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**************************************************************************
|
|
74
|
+
* build
|
|
75
|
+
*
|
|
76
|
+
* Public object exposing an API to build valid protocol messages.
|
|
77
|
+
* It is not the only way to build valid messages within the protocol.
|
|
78
|
+
*************************************************************************/
|
|
79
|
+
const build = {
|
|
80
|
+
CONFIGURATION: {
|
|
81
|
+
PATCH: buildPatchConfiguration,
|
|
82
|
+
READ: (id) => buildMsg(VERB.READ, MESSAGE.CONFIGURATION, {}, id),
|
|
83
|
+
NOTIFY: (config, id) => buildMsg(VERB.NOTIFY, MESSAGE.CONFIGURATION, config, id),
|
|
84
|
+
},
|
|
85
|
+
ERROR: {
|
|
86
|
+
NOTIFY: {
|
|
87
|
+
UNSUPPORTED_MESSAGE: (id) => buildMsg(VERB.NOTIFY, MESSAGE.ERROR, ERROR.UNSUPPORTED_MESSAGE, id),
|
|
88
|
+
UNSUPPORTED_VERB: (id) => buildMsg(VERB.NOTIFY, MESSAGE.ERROR, ERROR.UNSUPPORTED_VERB, id),
|
|
89
|
+
JSON_PARSE_ERROR: (id) => buildMsg(VERB.NOTIFY, MESSAGE.ERROR, ERROR.JSON_PARSE_ERROR, id),
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**************************************************************************
|
|
95
|
+
* Server
|
|
96
|
+
*
|
|
97
|
+
* The Control Server. Exposes a websocket control API.
|
|
98
|
+
* Used to hot-restart the SDK.
|
|
99
|
+
*
|
|
100
|
+
* logger - Logger- see SDK logger used elsewhere
|
|
101
|
+
* port - HTTP port to host on
|
|
102
|
+
* appConfig - The configuration for the entire application- supplied here as this class uses it to
|
|
103
|
+
* validate reconfiguration requests- it is not used for configuration here, however
|
|
104
|
+
* server - optional HTTP/S server on which to serve the websocket
|
|
105
|
+
*************************************************************************/
|
|
106
|
+
class Server extends ws.Server {
|
|
107
|
+
constructor({ logger, appConfig = {} }) {
|
|
108
|
+
super({ clientTracking: true, port: appConfig.control.port });
|
|
109
|
+
|
|
110
|
+
this._logger = logger;
|
|
111
|
+
this._port = appConfig.control.port;
|
|
112
|
+
this._appConfig = appConfig;
|
|
113
|
+
this._clientData = new Map();
|
|
114
|
+
|
|
115
|
+
this.on('error', err => {
|
|
116
|
+
this._logger.push({ err })
|
|
117
|
+
.log('Unhandled websocket error occurred. Shutting down.');
|
|
118
|
+
process.exit(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
this.on('connection', (socket, req) => {
|
|
122
|
+
const logger = this._logger.push({
|
|
123
|
+
url: req.url,
|
|
124
|
+
ip: 'localhost',
|
|
125
|
+
remoteAddress: req.socket.remoteAddress,
|
|
126
|
+
});
|
|
127
|
+
logger.log('Websocket connection received');
|
|
128
|
+
this._clientData.set(socket, { ip: req.connection.remoteAddress, logger });
|
|
129
|
+
|
|
130
|
+
socket.on('close', (code, reason) => {
|
|
131
|
+
logger.push({ code, reason }).log('Websocket connection closed');
|
|
132
|
+
this._clientData.delete(socket);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
socket.on('message', this._handle(socket, logger));
|
|
136
|
+
});
|
|
137
|
+
this._logger.push(this.address()).log('running on');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Close the server then wait for all the client sockets to close
|
|
141
|
+
async stop() {
|
|
142
|
+
await new Promise(this.close.bind(this));
|
|
143
|
+
this._logger.log('Control server shutdown complete');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_handle(client, logger) {
|
|
147
|
+
return (data) => {
|
|
148
|
+
// TODO: json-schema validation of received message- should be pretty straight-forward
|
|
149
|
+
// and will allow better documentation of the API
|
|
150
|
+
let msg;
|
|
151
|
+
try {
|
|
152
|
+
msg = deserialise(data);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
logger.push({ data }).log('Couldn\'t parse received message');
|
|
155
|
+
client.send(build.ERROR.NOTIFY.JSON_PARSE_ERROR());
|
|
156
|
+
}
|
|
157
|
+
logger.push({ msg }).log('Handling received message');
|
|
158
|
+
switch (msg.msg) {
|
|
159
|
+
case MESSAGE.CONFIGURATION:
|
|
160
|
+
switch (msg.verb) {
|
|
161
|
+
case VERB.READ:
|
|
162
|
+
(async () => {
|
|
163
|
+
const jwsCerts = await this.populateConfig();
|
|
164
|
+
client.send(build.CONFIGURATION.NOTIFY(jwsCerts, msg.id));
|
|
165
|
+
})();
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
client.send(build.ERROR.NOTIFY.UNSUPPORTED_VERB(msg.id));
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
break;
|
|
172
|
+
default:
|
|
173
|
+
client.send(build.ERROR.NOTIFY.UNSUPPORTED_MESSAGE(msg.id));
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async populateConfig(){
|
|
180
|
+
return this._appConfig;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Register this server instance to receive internal server messages
|
|
186
|
+
* from other modules.
|
|
187
|
+
*/
|
|
188
|
+
registerInternalEvents() {
|
|
189
|
+
ControlServerEventEmitter.on(INTERNAL_EVENTS.SERVER.BROADCAST_CONFIG_CHANGE, (params) => {
|
|
190
|
+
this.broadcastConfigChange(params);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Broadcast configuration change to all connected clients.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} params Updated configuration
|
|
198
|
+
*/
|
|
199
|
+
async broadcastConfigChange(updatedConfig) {
|
|
200
|
+
const updateConfMsg = build.CONFIGURATION.PATCH({}, updatedConfig, randomPhrase());
|
|
201
|
+
const errorLogger = (socket, message) => (err) =>
|
|
202
|
+
this._logger
|
|
203
|
+
.push({ message, ip: this._clientData.get(socket).ip, err })
|
|
204
|
+
.log('Error sending JWS keys notification to client');
|
|
205
|
+
return await this.broadcast(updateConfMsg, errorLogger);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Broadcasts a protocol message to all connected clients.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} msg
|
|
212
|
+
* @param {object} errorLogger
|
|
213
|
+
*/
|
|
214
|
+
async broadcast(msg, errorLogger) {
|
|
215
|
+
const sendToAllClients = (msg, errorLogger) => Promise.all(
|
|
216
|
+
[...this.clients.values()].map((socket) =>
|
|
217
|
+
(new Promise((resolve) => socket.send(msg, resolve))).catch(errorLogger(socket, msg))
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
return await sendToAllClients(msg, errorLogger);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
module.exports = {
|
|
226
|
+
Server,
|
|
227
|
+
build,
|
|
228
|
+
MESSAGE,
|
|
229
|
+
VERB,
|
|
230
|
+
ERROR,
|
|
231
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
|
|
2
|
+
const ControlServer = require('~/ControlServer');
|
|
3
|
+
const InboundServer = require('~/InboundServer');
|
|
4
|
+
const OutboundServer = require('~/OutboundServer');
|
|
5
|
+
const TestServer = require('~/TestServer');
|
|
6
|
+
const defaultConfig = require('./data/defaultConfig.json');
|
|
7
|
+
const { Logger } = require('@mojaloop/sdk-standard-components');
|
|
8
|
+
|
|
9
|
+
jest.mock('~/lib/cache');
|
|
10
|
+
const Cache = require('~/lib/cache');
|
|
11
|
+
|
|
12
|
+
// TODO:
|
|
13
|
+
// - diff against master to determine what else needs testing
|
|
14
|
+
// - especially look for assertions in the code
|
|
15
|
+
// - err.. grep the code for TODO
|
|
16
|
+
|
|
17
|
+
describe('ControlServer', () => {
|
|
18
|
+
it('exposes a valid message API', () => {
|
|
19
|
+
expect(Object.keys(ControlServer.build).sort()).toEqual(
|
|
20
|
+
Object.keys(ControlServer.MESSAGE).sort(),
|
|
21
|
+
'The API exposed by the builder object must contain as top-level keys all of the message types exposed in the MESSAGE constant. Check that ControlServer.MESSAGE has the same keys as ControlServer.build.'
|
|
22
|
+
);
|
|
23
|
+
Object.entries(ControlServer.build).forEach(([messageType, builders]) => {
|
|
24
|
+
expect(Object.keys(ControlServer.VERB)).toEqual(
|
|
25
|
+
expect.arrayContaining(Object.keys(builders)),
|
|
26
|
+
`For message type '${messageType}' every builder must correspond to a verb. Check that ControlServer.build.${messageType} has the same keys as ControlServer.VERB.`
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
expect(Object.keys(ControlServer.build.ERROR.NOTIFY).sort()).toEqual(
|
|
30
|
+
Object.keys(ControlServer.ERROR).sort(),
|
|
31
|
+
'ControlServer.ERROR.NOTIFY should contain the same keys as ControlServer.ERROR'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('API', () => {
|
|
36
|
+
let server, logger, client;
|
|
37
|
+
const appConfig = { what: 'ever' };
|
|
38
|
+
const changedConfig = { ...appConfig, some: 'thing' };
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
logger = new Logger.Logger({ stringify: () => '' });
|
|
42
|
+
server = new ControlServer.Server({ logger, appConfig });
|
|
43
|
+
client = await ControlServer.Client.Create({
|
|
44
|
+
address: 'localhost',
|
|
45
|
+
port: server.address().port,
|
|
46
|
+
logger
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await server.stop();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('supplies config when requested', async () => {
|
|
55
|
+
await client.send(ControlServer.build.CONFIGURATION.READ());
|
|
56
|
+
const response = await client.receive();
|
|
57
|
+
expect(response).toEqual({
|
|
58
|
+
...JSON.parse(ControlServer.build.CONFIGURATION.NOTIFY(appConfig, response.id)),
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('emits new config when received', async () => {
|
|
63
|
+
const newConfigEvent = new Promise(
|
|
64
|
+
(resolve) => server.on(ControlServer.EVENT.RECONFIGURE, resolve)
|
|
65
|
+
);
|
|
66
|
+
await client.send(ControlServer.build.CONFIGURATION.PATCH(appConfig, changedConfig));
|
|
67
|
+
const newConfEventData = await newConfigEvent;
|
|
68
|
+
expect(newConfEventData).toEqual(changedConfig);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('sends new config to clients when instructed', async () => {
|
|
72
|
+
const client2 = await ControlServer.Client.Create({
|
|
73
|
+
address: 'localhost',
|
|
74
|
+
port: server.address().port,
|
|
75
|
+
logger
|
|
76
|
+
});
|
|
77
|
+
const changedConfig = { ...appConfig, some: 'thing' };
|
|
78
|
+
await client.send(ControlServer.build.CONFIGURATION.PATCH(appConfig, changedConfig));
|
|
79
|
+
const restart = server.reconfigure({ appConfig: changedConfig });
|
|
80
|
+
restart();
|
|
81
|
+
await server.notifyClientsOfCurrentConfig();
|
|
82
|
+
const [notification, notification2] =
|
|
83
|
+
await Promise.all([client.receive(), client2.receive()]);
|
|
84
|
+
const expected = ControlServer.build.CONFIGURATION.NOTIFY(changedConfig, notification.id);
|
|
85
|
+
expect(JSON.stringify(notification)).toEqual(expected);
|
|
86
|
+
expect(JSON.stringify(notification2)).toEqual(expected);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('Server reconfigure methods', () => {
|
|
92
|
+
let conf, logger, cache;
|
|
93
|
+
|
|
94
|
+
const isPromise = (o) => Promise.resolve(o) === o;
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
conf = JSON.parse(JSON.stringify(defaultConfig));
|
|
98
|
+
logger = new Logger.Logger({ stringify: () => '' });
|
|
99
|
+
cache = new Cache({ ...conf.cacheConfig, logger: logger.push({ component: 'cache' }) });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('InboundServer reconfigure method returns sync function', async () => {
|
|
103
|
+
const server = new InboundServer(conf, logger, cache);
|
|
104
|
+
const res = await server.reconfigure(conf, logger, cache);
|
|
105
|
+
expect(isPromise(res)).toEqual(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('OutboundServer reconfigure method returns sync function', async () => {
|
|
109
|
+
const server = new OutboundServer(conf, logger, cache);
|
|
110
|
+
const res = await server.reconfigure(conf, logger, cache);
|
|
111
|
+
expect(isPromise(res)).toEqual(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('TestServer reconfigure method returns sync function', async () => {
|
|
115
|
+
const server = new TestServer({ logger, cache });
|
|
116
|
+
const res = await server.reconfigure({ logger, cache });
|
|
117
|
+
expect(isPromise(res)).toEqual(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('ControlServer reconfigure method returns sync function', async () => {
|
|
121
|
+
const server = new ControlServer.Server({ logger, appConfig: {} });
|
|
122
|
+
const res = await server.reconfigure({ logger, appConfig: {} });
|
|
123
|
+
expect(isPromise(res)).toEqual(false);
|
|
124
|
+
await server.close();
|
|
125
|
+
});
|
|
126
|
+
});
|
package/test/unit/config.test.js
CHANGED
|
@@ -28,6 +28,7 @@ describe('config', () => {
|
|
|
28
28
|
process.env.BACKEND_ENDPOINT = '172.17.0.5:4000';
|
|
29
29
|
process.env.CACHE_HOST = '172.17.0.2';
|
|
30
30
|
process.env.CACHE_PORT = '6379';
|
|
31
|
+
process.env.MGMT_API_WS_URL = '0.0.0.0';
|
|
31
32
|
certDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jest-'));
|
|
32
33
|
});
|
|
33
34
|
|
package/test/unit/index.test.js
CHANGED
|
@@ -21,6 +21,7 @@ process.env.PEER_ENDPOINT = '172.17.0.3:4000';
|
|
|
21
21
|
process.env.BACKEND_ENDPOINT = '172.17.0.5:4000';
|
|
22
22
|
process.env.CACHE_HOST = '172.17.0.2';
|
|
23
23
|
process.env.CACHE_PORT = '6379';
|
|
24
|
+
process.env.MGMT_API_WS_URL = '0.0.0.0';
|
|
24
25
|
|
|
25
26
|
const index = require('~/index.js');
|
|
26
27
|
|