@mojaloop/central-services-shared 18.6.3 → 18.7.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/.nycrc.yml CHANGED
@@ -14,5 +14,6 @@ reporter: [
14
14
  "text-summary"
15
15
  ]
16
16
  exclude: [
17
- "**/node_modules/**"
17
+ "**/node_modules/**",
18
+ "src/util/proxies.js" # todo: increase test coverage
18
19
  ]
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [18.7.0](https://github.com/mojaloop/central-services-shared/compare/v18.6.3...v18.7.0) (2024-07-26)
6
+
7
+
8
+ ### Features
9
+
10
+ * **csi-16:** added getAllProxiesNames method ([#387](https://github.com/mojaloop/central-services-shared/issues/387)) ([3fd95ac](https://github.com/mojaloop/central-services-shared/commit/3fd95ac128a1cb0c60afb4e359aef75230b49b69)), closes [#393](https://github.com/mojaloop/central-services-shared/issues/393)
11
+
5
12
  ### [18.6.3](https://github.com/mojaloop/central-services-shared/compare/v18.6.2...v18.6.3) (2024-07-12)
6
13
 
7
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojaloop/central-services-shared",
3
- "version": "18.6.3",
3
+ "version": "18.7.0",
4
4
  "description": "Shared code for mojaloop central services",
5
5
  "license": "Apache-2.0",
6
6
  "author": "ModusBox",
@@ -56,13 +56,14 @@
56
56
  "dependencies": {
57
57
  "@hapi/catbox": "12.1.1",
58
58
  "@hapi/catbox-memory": "5.0.1",
59
- "@mojaloop/inter-scheme-proxy-cache-lib": "1.4.0",
59
+ "@mojaloop/inter-scheme-proxy-cache-lib": "2.0.0-snapshot.1",
60
60
  "axios": "1.7.2",
61
61
  "clone": "2.1.2",
62
62
  "dotenv": "16.4.5",
63
63
  "env-var": "7.5.0",
64
64
  "event-stream": "4.0.1",
65
- "immutable": "4.3.6",
65
+ "fast-safe-stringify": "^2.1.1",
66
+ "immutable": "4.3.7",
66
67
  "lodash": "4.17.21",
67
68
  "mustache": "4.2.0",
68
69
  "openapi-backend": "5.10.6",
@@ -71,7 +72,7 @@
71
72
  "shins": "2.6.0",
72
73
  "uuid4": "2.0.3",
73
74
  "widdershins": "^4.0.1",
74
- "yaml": "2.4.5"
75
+ "yaml": "2.5.0"
75
76
  },
76
77
  "devDependencies": {
77
78
  "@hapi/hapi": "21.3.10",
@@ -105,6 +105,7 @@ const FspEndpointTemplates = {
105
105
  TRANSACTION_REQUEST_GET: '/transactionRequests/{{ID}}',
106
106
  TRANSACTION_REQUEST_PUT_ERROR: '/transactionRequests/{{ID}}/error',
107
107
  PARTICIPANT_ENDPOINTS_GET: '/participants/{{fsp}}/endpoints',
108
+ PARTICIPANTS_GET_ALL: '/participants',
108
109
  PARTICIPANTS_GET: '/participants/{{fsp}}',
109
110
  PARTICIPANTS_POST: '/participants',
110
111
  PARTIES_GET: '/parties/{{fsp}}',
package/src/enums/http.js CHANGED
@@ -36,6 +36,7 @@ const Headers = {
36
36
  FSPIOP: {
37
37
  SOURCE: 'fspiop-source',
38
38
  DESTINATION: 'fspiop-destination',
39
+ PROXY: 'fspiop-proxy',
39
40
  HTTP_METHOD: 'fspiop-http-method',
40
41
  SIGNATURE: 'fspiop-signature',
41
42
  URI: 'fspiop-uri'
@@ -88,7 +88,7 @@ class HealthCheck {
88
88
  * @description Gets the health of the service along with sub-services
89
89
  *
90
90
  */
91
- async getHealth () {
91
+ async getHealth (context = undefined) {
92
92
  // Default values
93
93
  let status = statusEnum.OK
94
94
  let isHealthy = true
@@ -100,7 +100,7 @@ class HealthCheck {
100
100
  const versionNumber = this.packageJson.version
101
101
 
102
102
  try {
103
- const services = await Promise.all(this.serviceChecks.map(s => s()))
103
+ const services = await Promise.all(this.serviceChecks.map(s => s(context)))
104
104
  isHealthy = HealthCheck.evaluateServiceHealth(services)
105
105
  subServices = {
106
106
  services
@@ -16,7 +16,8 @@ const serviceName = {
16
16
  datastore: 'datastore',
17
17
  broker: 'broker',
18
18
  sidecar: 'sidecar',
19
- cache: 'cache'
19
+ cache: 'cache',
20
+ proxyCache: 'proxyCache'
20
21
  }
21
22
 
22
23
  module.exports = {
package/src/index.d.ts CHANGED
@@ -9,6 +9,7 @@ declare namespace CentralServicesShared {
9
9
  FSPIOP: {
10
10
  SOURCE: string;
11
11
  DESTINATION: string;
12
+ PROXY: string;
12
13
  HTTP_METHOD: string;
13
14
  SIGNATURE: string;
14
15
  URI: string;
@@ -599,16 +600,26 @@ declare namespace CentralServicesShared {
599
600
  };
600
601
  };
601
602
  }
602
- interface Endpoints {
603
- fetchEndpoints(fspId: string): Promise<any>
604
- getEndpoint(switchUrl: string, fsp: string, endpointType: FspEndpointTypesEnum, options?: any): Promise<string>
603
+
604
+ interface Cacheable {
605
605
  initializeCache(policyOptions: object, config: { hubName: string, hubNameRegex: RegExp }): Promise<boolean>
606
+ stopCache(): Promise<void>
607
+ }
608
+
609
+ interface Endpoints extends Cacheable {
610
+ getEndpoint(switchUrl: string, fsp: string, endpointType: FspEndpointTypesEnum, options?: any): Promise<string>
606
611
  getEndpointAndRender(switchUrl: string, fsp: string, endpointType: FspEndpointTypesEnum, path: string, options?: any): Promise<string>
607
612
  }
608
613
 
609
- interface Participants {
614
+ interface Participants extends Cacheable {
610
615
  getParticipant(switchUrl: string, fsp: string): Promise<object>
611
- initializeCache(policyOptions: object, config: { hubName: string, hubNameRegex: RegExp }): Promise<boolean>
616
+ invalidateParticipantCache(fsp: string): Promise<void>
617
+ }
618
+
619
+ type ProxyNames = string[]
620
+ interface Proxies extends Cacheable {
621
+ getAllProxiesNames(switchUrl: string): Promise<ProxyNames>
622
+ invalidateProxiesCache(): Promise<void>
612
623
  }
613
624
 
614
625
  interface ProtocolVersionsType {
@@ -625,8 +636,10 @@ declare namespace CentralServicesShared {
625
636
  createGeneralTopicConf(template: string, functionality: string, action: string, key?: string, partition?: number, opaqueKey?: any, topicNameOverride?: string): {topicName: string, key: string | null, partition: number | null, opaqueKey: any }
626
637
  }
627
638
 
639
+ type MimeTypes = 'text/plain' | 'application/json' | 'application/vnd.interoperability.'
628
640
  interface StreamingProtocol {
629
641
  decodePayload(input: string, options: Object): Object
642
+ encodePayload(input: string | Buffer, mimeType: MimeTypes): string
630
643
  }
631
644
 
632
645
  interface HeaderValidation {
@@ -643,6 +656,7 @@ declare namespace CentralServicesShared {
643
656
  interface Util {
644
657
  Endpoints: Endpoints;
645
658
  Participants: Participants;
659
+ proxies: Proxies;
646
660
  Hapi: any;
647
661
  Kafka: Kafka;
648
662
  OpenapiBackend: any;
package/src/util/index.js CHANGED
@@ -30,6 +30,7 @@ const _ = require('lodash')
30
30
  const Kafka = require('./kafka')
31
31
  const Endpoints = require('./endpoints')
32
32
  const Participants = require('./participants')
33
+ const proxies = require('./proxies')
33
34
  const Request = require('./request')
34
35
  const Http = require('./http')
35
36
  const Hapi = require('./hapi')
@@ -238,6 +239,7 @@ module.exports = {
238
239
  filterExtensions,
239
240
  Kafka,
240
241
  Participants,
242
+ proxies,
241
243
  Endpoints,
242
244
  Request,
243
245
  Http,
@@ -145,6 +145,12 @@ exports.getParticipant = async (switchUrl, fsp) => {
145
145
  histTimer({ success: true, hit: false })
146
146
  }
147
147
 
148
+ /* istanbul ignore next */
149
+ if (!participant) {
150
+ Logger.isWarnEnabled && Logger.warn('participantCache::getParticipant - no participant found')
151
+ return null
152
+ }
153
+
148
154
  if (participant.errorInformation) {
149
155
  // Drop error from cache
150
156
  await policy.drop(fsp)
@@ -0,0 +1,182 @@
1
+ /*****
2
+ License
3
+ --------------
4
+ Copyright © 2017 Bill & Melinda Gates Foundation
5
+ The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at
6
+ http://www.apache.org/licenses/LICENSE-2.0
7
+ Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
8
+
9
+ Contributors
10
+ --------------
11
+ This is the official list of the Mojaloop project contributors for this file.
12
+ Names of the original copyright holders (individuals or organizations)
13
+ should be listed with a '*' in the first column. People who have
14
+ contributed from an organization can be listed under the organization
15
+ that actually holds the copyright for their contributions (see the
16
+ Gates Foundation organization for an example). Those individuals should have
17
+ their names indented and be marked with a '-'. Email address can be added
18
+ optionally within square brackets <email>.
19
+ * Gates Foundation
20
+ - Name Surname <name.surname@gatesfoundation.com>
21
+
22
+ * Eugen Klymniuk <eugen.klymniuk@infitx.com>
23
+ --------------
24
+ **********/
25
+
26
+ const Mustache = require('mustache')
27
+ const Catbox = require('@hapi/catbox')
28
+ const CatboxMemory = require('@hapi/catbox-memory')
29
+ const ErrorHandler = require('@mojaloop/central-services-error-handling')
30
+ const Logger = require('@mojaloop/central-services-logger')
31
+ const Metrics = require('@mojaloop/central-services-metrics')
32
+
33
+ const Enum = require('../enums')
34
+ const Http = require('./http')
35
+ const request = require('./request')
36
+
37
+ const partition = 'proxies-cache'
38
+ const clientOptions = { partition }
39
+ const cacheKey = 'allProxies'
40
+
41
+ let client
42
+ let policy
43
+ let switchEndpoint
44
+ let hubName
45
+ let hubNameRegex
46
+
47
+ /**
48
+ * @function fetchProxies
49
+ * @description This populates the cache of proxies
50
+ * @returns {array} proxies Returns the list containing proxies
51
+ */
52
+ const fetchProxies = async () => {
53
+ const histTimer = Metrics.getHistogram(
54
+ 'fetchProxies',
55
+ 'fetchProxies - Metrics for fetchProxies',
56
+ ['success']
57
+ ).startTimer()
58
+ try {
59
+ Logger.isDebugEnabled && Logger.debug('proxiesCache::fetchProxies := Refreshing proxies cache')
60
+ if (!hubName || !hubNameRegex) {
61
+ throw Error('No hubName or hubNameRegex! Initialize the cache first.')
62
+ }
63
+ const defaultHeaders = Http.SwitchDefaultHeaders(hubName, Enum.Http.HeaderResources.PARTICIPANTS, hubName)
64
+ const url = Mustache.render(switchEndpoint + Enum.EndPoints.FspEndpointTemplates.PARTICIPANTS_GET_ALL)
65
+ const params = { isProxy: true }
66
+ Logger.isDebugEnabled && Logger.debug(`proxiesCache::fetchProxies := URL: ${url} QS: ${JSON.stringify(params)}`)
67
+ const response = await request.sendRequest({
68
+ url,
69
+ headers: defaultHeaders,
70
+ source: hubName,
71
+ destination: hubName,
72
+ params,
73
+ hubNameRegex
74
+ })
75
+ const proxies = response.data
76
+ histTimer({ success: true })
77
+ return proxies
78
+ } catch (e) {
79
+ histTimer({ success: false })
80
+ Logger.isErrorEnabled && Logger.error(`proxiesCache::fetchProxies:: ERROR:'${e}'`)
81
+ }
82
+ }
83
+
84
+ /**
85
+ * @function initializeCache
86
+ *
87
+ * @description This initializes the cache for allProxies
88
+ * @param {object} policyOptions The Endpoint_Cache_Config for the Cache being stored https://hapi.dev/module/catbox/api/?v=12.1.1#policy
89
+ * @param {object} config The config object containing paramters used for the request function
90
+ * @returns {boolean} Returns true on successful initialization of the cache, throws error on failures
91
+ */
92
+ exports.initializeCache = async (policyOptions, config) => {
93
+ try {
94
+ Logger.isDebugEnabled && Logger.debug(`proxiesCache::initializeCache::start::clientOptions - ${JSON.stringify(clientOptions)}`)
95
+ client = new Catbox.Client(CatboxMemory, clientOptions)
96
+ await client.start()
97
+ policyOptions.generateFunc = fetchProxies
98
+ Logger.isDebugEnabled && Logger.debug(`proxiesCache::initializeCache::start::policyOptions - ${JSON.stringify(policyOptions)}`)
99
+ policy = new Catbox.Policy(policyOptions, client, partition)
100
+ Logger.isDebugEnabled && Logger.debug('proxiesCache::initializeCache::Cache initialized successfully')
101
+ hubName = config.hubName
102
+ hubNameRegex = config.hubNameRegex
103
+ return true
104
+ } catch (err) {
105
+ Logger.isErrorEnabled && Logger.error(`proxiesCache::Cache error:: ERROR:'${err}'`)
106
+ throw ErrorHandler.Factory.reformatFSPIOPError(err)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * @function getAllProxiesNames
112
+ * @description It returns a list of allProxies names from the cache if the cache is still valid, otherwise it will refresh the cache and return the value
113
+ *
114
+ * @param {string} switchUrl the endpoint for the switch
115
+ *
116
+ * @returns {string[]} - Returns list of allProxies names, throws error if failure occurs
117
+ */
118
+ exports.getAllProxiesNames = async (switchUrl) => {
119
+ const histTimer = Metrics.getHistogram(
120
+ 'getAllProxiesNames',
121
+ 'getAllProxiesNames - Metrics for getAllProxies with cache hit rate',
122
+ ['success', 'hit']
123
+ ).startTimer()
124
+ switchEndpoint = switchUrl
125
+ Logger.isDebugEnabled && Logger.debug('proxiesCache::getAllProxiesNames')
126
+ try {
127
+ // If a service passes in `getDecoratedValue` as true, then an object
128
+ // { value, cached, report } is returned, where value is the cached value,
129
+ // cached is null on a cache miss.
130
+ let proxies = await policy.get(cacheKey)
131
+
132
+ if ('value' in proxies && 'cached' in proxies) {
133
+ if (proxies.cached === null) {
134
+ histTimer({ success: true, hit: false })
135
+ } else {
136
+ histTimer({ success: true, hit: true })
137
+ }
138
+ proxies = proxies.value
139
+ } else {
140
+ histTimer({ success: true, hit: false })
141
+ }
142
+
143
+ if (proxies.errorInformation) {
144
+ // Drop error from cache
145
+ await policy.drop(cacheKey)
146
+ throw ErrorHandler.Factory.createFSPIOPErrorFromErrorInformation(proxies.errorInformation)
147
+ }
148
+ return proxies.map(p => p.name)
149
+ } catch (err) {
150
+ histTimer({ success: false, hit: false })
151
+ Logger.isErrorEnabled && Logger.error(`proxiesCache::getAllProxiesNames:: ERROR:'${err}'`)
152
+ throw ErrorHandler.Factory.reformatFSPIOPError(err)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * @function invalidateProxiesCache
158
+ *
159
+ * @description It drops the cache for all proxies
160
+ *
161
+ * @returns {void}
162
+ */
163
+ exports.invalidateProxiesCache = async () => {
164
+ Logger.isDebugEnabled && Logger.debug('proxiesCache::invalidateProxiesCache::Invalidating the cache')
165
+ if (policy) {
166
+ return policy.drop(cacheKey)
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @function stopCache
172
+ *
173
+ * @description It stops the cache client
174
+ *
175
+ * @returns {boolean} - Returns the status
176
+ */
177
+ exports.stopCache = async () => {
178
+ Logger.isDebugEnabled && Logger.debug('proxiesCache::stopCache::Stopping the cache')
179
+ if (client) {
180
+ return client.stop()
181
+ }
182
+ }
@@ -24,12 +24,13 @@
24
24
  ******/
25
25
  'use strict'
26
26
 
27
- const EventSdk = require('@mojaloop/event-sdk')
27
+ const http = require('node:http')
28
28
  const request = require('axios')
29
+ const stringify = require('fast-safe-stringify')
30
+ const EventSdk = require('@mojaloop/event-sdk')
29
31
  const Logger = require('@mojaloop/central-services-logger')
30
32
  const ErrorHandler = require('@mojaloop/central-services-error-handling')
31
33
  const Metrics = require('@mojaloop/central-services-metrics')
32
- const http = require('http')
33
34
  const Headers = require('./headers/transformer')
34
35
  const enums = require('../enums')
35
36
 
@@ -59,6 +60,7 @@ request.defaults.httpAgent.toJSON = () => ({})
59
60
  * @param {string} source id for which callback is being sent from
60
61
  * @param {string} destination id for which callback is being sent
61
62
  * @param {object | undefined} payload the body of the request being sent
63
+ * @param {object | null} params URL parameters to be sent with the request. Must be a plain object, URLSearchParams object or null/undefined
62
64
  * @param {string} responseType the type of the response object
63
65
  * @param {object | undefined} span a span for event logging if this request is within a span
64
66
  * @param {object | undefined} jwsSigner the jws signer for signing the requests
@@ -75,6 +77,7 @@ const sendRequest = async ({
75
77
  destination,
76
78
  method = enums.Http.RestMethods.GET,
77
79
  payload = undefined,
80
+ params,
78
81
  responseType = enums.Http.ResponseTypes.JSON,
79
82
  span = undefined,
80
83
  jwsSigner = undefined,
@@ -93,7 +96,8 @@ const sendRequest = async ({
93
96
  sendRequestSpan.setTags({ source, destination, method, url })
94
97
  }
95
98
  let requestOptions
96
- if (!url || !method || !headers || (method !== enums.Http.RestMethods.GET && method !== enums.Http.RestMethods.DELETE && !payload) || !source || !destination || !hubNameRegex) {
99
+ if (!url || !method || !headers || (method !== enums.Http.RestMethods.GET && method !== enums.Http.RestMethods.DELETE && !payload) || !source || !hubNameRegex) {
100
+ // think, if we can just avoid checking "destination"
97
101
  throw ErrorHandler.Factory.createInternalServerFSPIOPError(MISSING_FUNCTION_PARAMETERS)
98
102
  }
99
103
  try {
@@ -109,6 +113,7 @@ const sendRequest = async ({
109
113
  method,
110
114
  headers: transformedHeaders,
111
115
  data: payload,
116
+ params,
112
117
  responseType,
113
118
  httpAgent: new http.Agent({ keepAlive: true }),
114
119
  ...axiosRequestOptionsOverride
@@ -122,29 +127,29 @@ const sendRequest = async ({
122
127
  requestOptions = span.injectContextToHttpRequest(requestOptions)
123
128
  span.audit(requestOptions, EventSdk.AuditEventAction.egress)
124
129
  }
125
- Logger.isDebugEnabled && Logger.debug(`sendRequest::request ${JSON.stringify(requestOptions)}`)
130
+ Logger.isDebugEnabled && Logger.debug(`sendRequest::requestOptions ${stringify(requestOptions)}`)
126
131
  const response = await request(requestOptions)
127
- Logger.isDebugEnabled && Logger.debug(`Success: sendRequest::response ${JSON.stringify(response, Object.getOwnPropertyNames(response))}`)
132
+
128
133
  !!sendRequestSpan && await sendRequestSpan.finish()
129
134
  histTimerEnd({ success: true, source, destination, method })
130
135
  return response
131
136
  } catch (error) {
132
- Logger.isErrorEnabled && Logger.error(error)
137
+ Logger.isErrorEnabled && Logger.error(`error in request.sendRequest: ${error.stack}`)
133
138
  const extensionArray = [
134
139
  { key: 'url', value: url },
135
140
  { key: 'sourceFsp', value: source },
136
141
  { key: 'destinationFsp', value: destination },
137
142
  { key: 'method', value: method },
138
- { key: 'request', value: JSON.stringify(requestOptions) },
143
+ { key: 'request', value: stringify(requestOptions) },
139
144
  { key: 'errorMessage', value: error.message }
140
145
  ]
141
146
  const extensions = []
142
147
  if (error.response) {
143
- extensionArray.push({ key: 'status', value: error.response && error.response.status })
144
- extensionArray.push({ key: 'response', value: error.response && error.response.data })
145
- extensions.push({ key: 'status', value: error.response && error.response.status })
148
+ extensionArray.push({ key: 'status', value: error.response?.status })
149
+ extensionArray.push({ key: 'response', value: error.response?.data })
150
+ extensions.push({ key: 'status', value: error.response?.status })
146
151
  }
147
- const cause = JSON.stringify(extensionArray)
152
+ const cause = stringify(extensionArray)
148
153
  extensions.push({ key: 'cause', value: cause })
149
154
  const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.DESTINATION_COMMUNICATION_ERROR, 'Failed to send HTTP request to host', error, source, extensions)
150
155
  if (sendRequestSpan) {
@@ -205,7 +205,7 @@ const createEventState = (status, code, description) => {
205
205
  /**
206
206
  * Encodes Payload to base64 encoded data URI
207
207
  *
208
- * @param {buffer\|string} input - Buffer or String
208
+ * @param {buffer|string} input - Buffer or String
209
209
  * @param {MimeTypes} mimeType - mime type of the input
210
210
  *
211
211
  * @return {string} - Returns base64 encoded data URI string
@@ -0,0 +1,48 @@
1
+ const Test = require('tapes')(require('tape'))
2
+ const sinon = require('sinon')
3
+ const proxyquire = require('proxyquire')
4
+ const { Http } = require('../../src/enums')
5
+
6
+ Test('sendRequest Tests -->', test => {
7
+ let sandbox
8
+ let axios
9
+ let request
10
+
11
+ test.beforeEach(t => {
12
+ sandbox = sinon.createSandbox()
13
+ axios = sandbox.stub()
14
+ request = proxyquire('../../src/util/request', { axios })
15
+ // sinon can't mock such way of using axios: axios(requestOptions)
16
+ t.end()
17
+ })
18
+
19
+ test.afterEach(t => {
20
+ sandbox.restore()
21
+ t.end()
22
+ })
23
+
24
+ test.test('should add fspiop-signature header if jwsSigner is passed ', async test => {
25
+ const signature = 'signature'
26
+ const jwsSigner = {
27
+ getSignature: sandbox.stub().callsFake(() => signature)
28
+ }
29
+
30
+ await request.sendRequest({
31
+ url: 'http://localhost:1234',
32
+ jwsSigner,
33
+ headers: {
34
+ [Http.Headers.FSPIOP.SOURCE]: 'source'
35
+ },
36
+ source: 'source',
37
+ destination: 'destination',
38
+ hubNameRegex: 'hubNameRegex'
39
+ })
40
+
41
+ test.ok(axios.calledOnce)
42
+ const { headers } = axios.lastCall.args[0]
43
+ test.equal(headers['fspiop-signature'], signature)
44
+ test.end()
45
+ })
46
+
47
+ test.end()
48
+ })