@defra-fish/fulfilment-job 1.55.0-rc.6 → 1.55.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/README.md CHANGED
@@ -19,16 +19,21 @@ provider.
19
19
 
20
20
  # Environment variables
21
21
 
22
- | name | description | required | default | valid | notes |
23
- | ----------------------------------- | ----------------------------------------------------------------------------- | :------: | ------- | ----------------------------------------------------------------------- | ----- |
24
- | NODE_ENV | Node environment | no | | development, test, production | |
25
- | FULFILMENT_FILE_SIZE | The maximum number of records written to an aggregated fulfilment file | yes | | | |
26
- | FULFILMENT_S3_BUCKET | The name of the AWS S3 bucket in which to stage and aggregate fulfilment data | yes | | | |
27
- | FULFILMENT_SEND_UNENCRYPTED_FILE | Flag for whether to send the unencrypted fulfilment file | no | false | true, false, 0, 1 | |
28
- | FULFILMENT_PGP_PUBLIC_KEY_SECRET_ID | The secret id for the file encryption public key | yes | | | |
29
- | DEBUG | Use to enable output of debug information to the console | yes | | fulfilment:\*, fulfilment:staging, fulfilment:transport, fulfilment:ftp | |
30
- | AIRBRAKE_HOST | URL of airbrake host | no | | | |
31
- | AIRBRAKE_PROJECT_KEY | Project key for airbrake logging | no | | | |
22
+ | name | description | required | default | valid | notes |
23
+ | ----------------------------------- | ----------------------------------------------------------------------------------------- | :------: | ------- | ----------------------------------------------------------------------- | ----- |
24
+ | NODE_ENV | Node environment | no | | development, test, production | |
25
+ | FULFILMENT_FILE_SIZE | The maximum number of records written to an aggregated fulfilment file | yes | | | |
26
+ | FULFILMENT_FTP_HOST | The hostname of the target FTP server | yes | | | |
27
+ | FULFILMENT_FTP_PORT | The port of the FTP service on the target server | yes | | | |
28
+ | FULFILMENT_FTP_PATH | The base path under which files should be written to the FTP server | yes | | | |
29
+ | FULFILMENT_FTP_USERNAME | The username used to authenticate with the FTP server | yes | | | |
30
+ | FULFILMENT_FTP_KEY_SECRET_ID | The ID of the secret in AWS secrets manager which contains the SSH key for authentication | yes | | | |
31
+ | FULFILMENT_S3_BUCKET | The name of the AWS S3 bucket in which to stage and aggregate fulfilment data | yes | | | |
32
+ | FULFILMENT_SEND_UNENCRYPTED_FILE | Flag for whether to send the unencrypted fulfilment file | no | false | true, false, 0, 1 | |
33
+ | FULFILMENT_PGP_PUBLIC_KEY_SECRET_ID | The secret id for the file encryption public key | yes | | | |
34
+ | DEBUG | Use to enable output of debug information to the console | yes | | fulfilment:\*, fulfilment:staging, fulfilment:transport, fulfilment:ftp | |
35
+ | AIRBRAKE_HOST | URL of airbrake host | no | | | |
36
+ | AIRBRAKE_PROJECT_KEY | Project key for airbrake logging | no | | | |
32
37
 
33
38
  ### See also:
34
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra-fish/fulfilment-job",
3
- "version": "1.55.0-rc.6",
3
+ "version": "1.55.0",
4
4
  "description": "Rod Licensing Sales Fulfilment Job",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,14 +35,15 @@
35
35
  "test": "echo \"Error: run tests from root\" && exit 1"
36
36
  },
37
37
  "dependencies": {
38
- "@defra-fish/connectors-lib": "1.55.0-rc.6",
39
- "@defra-fish/dynamics-lib": "1.55.0-rc.6",
38
+ "@defra-fish/connectors-lib": "1.55.0",
39
+ "@defra-fish/dynamics-lib": "1.55.0",
40
40
  "commander": "^7.2.0",
41
41
  "debug": "^4.3.3",
42
42
  "merge2": "^1.4.1",
43
43
  "moment": "^2.29.1",
44
44
  "openpgp": "^5.0.0-1",
45
- "pluralize": "^8.0.0"
45
+ "pluralize": "^8.0.0",
46
+ "ssh2-sftp-client": "^6.0.1"
46
47
  },
47
- "gitHead": "2c55000bca5980f5de132040edd1b7d1166e5d95"
48
+ "gitHead": "3405322b81a2016ba22e530de8d096a49be0bea4"
48
49
  }
@@ -0,0 +1,9 @@
1
+ const ssh2sftpClient = jest.genMockFromModule('ssh2-sftp-client')
2
+
3
+ export const mockedFtpMethods = {
4
+ connect: jest.fn(async () => {}),
5
+ put: jest.fn(async () => {}),
6
+ end: jest.fn()
7
+ }
8
+ ssh2sftpClient.mockImplementation(() => mockedFtpMethods)
9
+ export default ssh2sftpClient
@@ -17,6 +17,11 @@ const clearEnvVars = () => {
17
17
 
18
18
  const envVars = Object.freeze({
19
19
  FULFILMENT_FILE_SIZE: 1234,
20
+ FULFILMENT_FTP_HOST: 'test-host',
21
+ FULFILMENT_FTP_PORT: 2222,
22
+ FULFILMENT_FTP_PATH: '/remote/share',
23
+ FULFILMENT_FTP_USERNAME: 'test-user',
24
+ FULFILMENT_FTP_KEY_SECRET_ID: 'test-secret-id',
20
25
  FULFILMENT_S3_BUCKET: 'test-bucket',
21
26
  FULFILMENT_PGP_PUBLIC_KEY_SECRET_ID: 'pgp-key-secret-id',
22
27
  FULFILMENT_SEND_UNENCRYPTED_FILE: 'false'
@@ -39,6 +44,32 @@ describe('config', () => {
39
44
  })
40
45
  })
41
46
 
47
+ describe('ftp', () => {
48
+ it('provides properties relating the use of SFTP', async () => {
49
+ expect(config.ftp).toEqual(
50
+ expect.objectContaining({
51
+ host: 'test-host',
52
+ port: '2222',
53
+ path: '/remote/share',
54
+ username: 'test-user',
55
+ privateKey: 'test-ssh-key',
56
+ algorithms: { cipher: expect.any(Array), kex: expect.any(Array) },
57
+ // Wait up to 60 seconds for the SSH handshake
58
+ readyTimeout: expect.any(Number),
59
+ // Retry 5 times over a minute
60
+ retries: expect.any(Number),
61
+ retry_minTimeout: expect.any(Number),
62
+ debug: expect.any(Function)
63
+ })
64
+ )
65
+ })
66
+ it('defaults the sftp port to 22 if the environment variable is not configured', async () => {
67
+ delete process.env.FULFILMENT_FTP_PORT
68
+ await config.initialise()
69
+ expect(config.ftp.port).toEqual('22')
70
+ })
71
+ })
72
+
42
73
  describe('s3', () => {
43
74
  it('provides properties relating the use of Amazon S3', async () => {
44
75
  expect(config.s3.bucket).toEqual('test-bucket')
@@ -48,7 +79,7 @@ describe('config', () => {
48
79
 
49
80
  describe('pgp config', () => {
50
81
  const init = async (samplePublicKey = 'sample-pgp-key') => {
51
- AwsMock.SecretsManager.__setNextResponses('getSecretValue', { SecretString: samplePublicKey })
82
+ AwsMock.SecretsManager.__setNextResponses('getSecretValue', { SecretString: 'test-ssh-key' }, { SecretString: samplePublicKey })
52
83
  await config.initialise()
53
84
  }
54
85
  beforeAll(setEnvVars)
package/src/config.js CHANGED
@@ -1,6 +1,45 @@
1
1
  import { AWS } from '@defra-fish/connectors-lib'
2
-
2
+ import db from 'debug'
3
3
  const { secretsManager } = AWS()
4
+
5
+ /**
6
+ * Key exchange algorithms for public key authentication - in descending order of priority
7
+ * @type {string[]}
8
+ */
9
+ export const SFTP_KEY_EXCHANGE_ALGORITHMS = [
10
+ 'curve25519-sha256@libssh.org',
11
+ 'curve25519-sha256',
12
+ 'ecdh-sha2-nistp521',
13
+ 'ecdh-sha2-nistp384',
14
+ 'ecdh-sha2-nistp256',
15
+ 'diffie-hellman-group-exchange-sha256',
16
+ 'diffie-hellman-group14-sha256',
17
+ 'diffie-hellman-group16-sha512',
18
+ 'diffie-hellman-group18-sha512',
19
+ 'diffie-hellman-group14-sha1',
20
+ 'diffie-hellman-group-exchange-sha1',
21
+ 'diffie-hellman-group1-sha1'
22
+ ]
23
+ /**
24
+ * Ciphers for SFTP support - in descending order of priority
25
+ * @type {string[]}
26
+ */
27
+ export const SFTP_CIPHERS = [
28
+ // http://tools.ietf.org/html/rfc4344#section-4
29
+ 'aes256-ctr',
30
+ 'aes192-ctr',
31
+ 'aes128-ctr',
32
+ 'aes256-gcm',
33
+ 'aes256-gcm@openssh.com',
34
+ 'aes128-gcm',
35
+ 'aes128-gcm@openssh.com',
36
+ 'aes256-cbc',
37
+ 'aes192-cbc',
38
+ 'aes128-cbc',
39
+ 'blowfish-cbc',
40
+ '3des-cbc',
41
+ 'cast128-cbc'
42
+ ]
4
43
  const falseRegEx = /(false|0)/i
5
44
  const trueRegEx = /(true|1)/i
6
45
  const toBoolean = val => {
@@ -15,6 +54,7 @@ const toBoolean = val => {
15
54
 
16
55
  class Config {
17
56
  _file
57
+ _ftp
18
58
  _s3
19
59
  _pgp
20
60
 
@@ -28,6 +68,20 @@ class Config {
28
68
  */
29
69
  partFileSize: Math.min(Number.parseInt(process.env.FULFILMENT_FILE_SIZE), 999)
30
70
  }
71
+ this.ftp = {
72
+ host: process.env.FULFILMENT_FTP_HOST,
73
+ port: process.env.FULFILMENT_FTP_PORT || '22',
74
+ path: process.env.FULFILMENT_FTP_PATH,
75
+ username: process.env.FULFILMENT_FTP_USERNAME,
76
+ privateKey: (await secretsManager.getSecretValue({ SecretId: process.env.FULFILMENT_FTP_KEY_SECRET_ID }).promise()).SecretString,
77
+ algorithms: { cipher: SFTP_CIPHERS, kex: SFTP_KEY_EXCHANGE_ALGORITHMS },
78
+ // Wait up to 60 seconds for the SSH handshake
79
+ readyTimeout: 60000,
80
+ // Retry 5 times over a minute
81
+ retries: 5,
82
+ retry_minTimeout: 12000,
83
+ debug: db('fulfilment:ftp')
84
+ }
31
85
  this.s3 = {
32
86
  bucket: process.env.FULFILMENT_S3_BUCKET
33
87
  }
@@ -50,6 +104,18 @@ class Config {
50
104
  this._file = cfg
51
105
  }
52
106
 
107
+ /**
108
+ * FTP configuration settings
109
+ * @type {object}
110
+ */
111
+ get ftp () {
112
+ return this._ftp
113
+ }
114
+
115
+ set ftp (cfg) {
116
+ this._ftp = cfg
117
+ }
118
+
53
119
  /**
54
120
  * S3 configuration settings
55
121
  * @type {object}
@@ -1,6 +1,7 @@
1
1
  import { Readable, PassThrough, Writable } from 'stream'
2
2
  import { deliverFulfilmentFiles } from '../deliver-fulfilment-files.js'
3
3
  import { createS3WriteStream, readS3PartFiles } from '../../transport/s3.js'
4
+ import { createFtpWriteStream } from '../../transport/ftp.js'
4
5
  import { FULFILMENT_FILE_STATUS_OPTIONSET, getOptionSetEntry } from '../staging-common.js'
5
6
  import { FulfilmentRequestFile, executeQuery, persist } from '@defra-fish/dynamics-lib'
6
7
  import openpgp from 'openpgp'
@@ -9,6 +10,7 @@ import streamHelper from '../streamHelper.js'
9
10
  import merge2 from 'merge2'
10
11
 
11
12
  jest.mock('../../transport/s3.js')
13
+ jest.mock('../../transport/ftp.js')
12
14
  jest.mock('openpgp', () => ({
13
15
  readKey: jest.fn(() => ({})),
14
16
  encrypt: jest.fn(({ message: readableStream }) => readableStream),
@@ -44,10 +46,20 @@ describe('deliverFulfilmentFiles', () => {
44
46
  executeQuery.mockResolvedValue([{ entity: mockFulfilmentRequestFile2 }, { entity: mockFulfilmentRequestFile1 }])
45
47
 
46
48
  // Streams for file1
47
- const { s3DataStreamFile: s3DataStreamFile1, s3HashStreamFile: s3HashStreamFile1 } = createMockFileStreams()
49
+ const {
50
+ s3DataStreamFile: s3DataStreamFile1,
51
+ ftpDataStreamFile: ftpDataStreamFile1,
52
+ s3HashStreamFile: s3HashStreamFile1,
53
+ ftpHashStreamFile: ftpHashStreamFile1
54
+ } = createMockFileStreams()
48
55
 
49
56
  // Streams for file2
50
- const { s3DataStreamFile: s3DataStreamFile2, s3HashStreamFile: s3HashStreamFile2 } = createMockFileStreams()
57
+ const {
58
+ s3DataStreamFile: s3DataStreamFile2,
59
+ ftpDataStreamFile: ftpDataStreamFile2,
60
+ s3HashStreamFile: s3HashStreamFile2,
61
+ ftpHashStreamFile: ftpHashStreamFile2
62
+ } = createMockFileStreams()
51
63
 
52
64
  // Run the delivery
53
65
  await expect(deliverFulfilmentFiles()).resolves.toBeUndefined()
@@ -57,14 +69,22 @@ describe('deliverFulfilmentFiles', () => {
57
69
  // File 1 expectations
58
70
  expect(createS3WriteStream).toHaveBeenNthCalledWith(1, 'EAFF202006180001.json')
59
71
  expect(createS3WriteStream).toHaveBeenNthCalledWith(3, 'EAFF202006180001.json.sha256')
72
+ expect(createFtpWriteStream).toHaveBeenNthCalledWith(1, 'EAFF202006180001.json')
73
+ expect(createFtpWriteStream).toHaveBeenNthCalledWith(3, 'EAFF202006180001.json.sha256')
60
74
  expect(JSON.parse(s3DataStreamFile1.dataProcessed)).toEqual({ licences: [{ part: 0 }, { part: 1 }] })
75
+ expect(JSON.parse(ftpDataStreamFile1.dataProcessed)).toEqual({ licences: [{ part: 0 }, { part: 1 }] })
61
76
  expect(s3HashStreamFile1.dataProcessed).toEqual(fileShaHash) // validated
77
+ expect(ftpHashStreamFile1.dataProcessed).toEqual(fileShaHash) // validated
62
78
 
63
79
  // File 2 expectations
64
80
  expect(createS3WriteStream).toHaveBeenNthCalledWith(4, 'EAFF202006180002.json')
65
81
  expect(createS3WriteStream).toHaveBeenNthCalledWith(6, 'EAFF202006180002.json.sha256')
82
+ expect(createFtpWriteStream).toHaveBeenNthCalledWith(4, 'EAFF202006180002.json')
83
+ expect(createFtpWriteStream).toHaveBeenNthCalledWith(6, 'EAFF202006180002.json.sha256')
66
84
  expect(JSON.parse(s3DataStreamFile2.dataProcessed)).toEqual({ licences: [{ part: 0 }, { part: 1 }] })
85
+ expect(JSON.parse(ftpDataStreamFile2.dataProcessed)).toEqual({ licences: [{ part: 0 }, { part: 1 }] })
67
86
  expect(s3HashStreamFile2.dataProcessed).toEqual(fileShaHash) // validated
87
+ expect(ftpHashStreamFile2.dataProcessed).toEqual(fileShaHash) // validated
68
88
 
69
89
  // Persist to dynamics for file 1
70
90
  expect(persist).toHaveBeenNthCalledWith(1, [
@@ -177,7 +197,10 @@ describe('deliverFulfilmentFiles', () => {
177
197
  const s3 = createTestableStream()
178
198
  streamHelper.pipelinePromise.mockResolvedValue()
179
199
  openpgp.encrypt.mockResolvedValue(s2)
180
- merge2.mockReturnValueOnce(s1).mockReturnValueOnce(s2).mockReturnValueOnce(s3)
200
+ merge2
201
+ .mockReturnValueOnce(s1)
202
+ .mockReturnValueOnce(s2)
203
+ .mockReturnValueOnce(s3)
181
204
  await mockExecuteQuery()
182
205
  createMockFileStreams()
183
206
 
@@ -204,18 +227,27 @@ const createMockFulfilmentRequestFile = async (fileName, date) =>
204
227
 
205
228
  const createMockFileStreams = () => {
206
229
  const s3DataStreamFile = createTestableStream()
230
+ const ftpDataStreamFile = createTestableStream()
207
231
  createS3WriteStream.mockReturnValueOnce({ s3WriteStream: s3DataStreamFile, managedUpload: Promise.resolve() })
232
+ createFtpWriteStream.mockReturnValueOnce({ ftpWriteStream: ftpDataStreamFile, managedUpload: Promise.resolve() })
208
233
 
209
234
  const s3EncryptedDataStreamFile = createTestableStream()
235
+ const ftpEncryptedDataStreamFile = createTestableStream()
210
236
  createS3WriteStream.mockReturnValueOnce({ s3WriteStream: s3EncryptedDataStreamFile, managedUpload: Promise.resolve() })
237
+ createFtpWriteStream.mockReturnValueOnce({ ftpWriteStream: ftpEncryptedDataStreamFile, managedUpload: Promise.resolve() })
211
238
 
212
239
  const s3HashStreamFile = createTestableStream()
240
+ const ftpHashStreamFile = createTestableStream()
213
241
  createS3WriteStream.mockReturnValueOnce({ s3WriteStream: s3HashStreamFile, managedUpload: Promise.resolve() })
242
+ createFtpWriteStream.mockReturnValueOnce({ ftpWriteStream: ftpHashStreamFile, managedUpload: Promise.resolve() })
214
243
 
215
244
  return {
216
245
  s3DataStreamFile,
246
+ ftpDataStreamFile,
217
247
  s3EncryptedDataStreamFile,
218
- s3HashStreamFile
248
+ ftpEncryptedDataStreamFile,
249
+ s3HashStreamFile,
250
+ ftpHashStreamFile
219
251
  }
220
252
  }
221
253
 
@@ -4,6 +4,7 @@ import merge2 from 'merge2'
4
4
  import moment from 'moment'
5
5
  import { executeQuery, persist, findFulfilmentFiles } from '@defra-fish/dynamics-lib'
6
6
  import { createS3WriteStream, readS3PartFiles } from '../transport/s3.js'
7
+ import { createFtpWriteStream } from '../transport/ftp.js'
7
8
  import { FULFILMENT_FILE_STATUS_OPTIONSET, getOptionSetEntry } from './staging-common.js'
8
9
  import db from 'debug'
9
10
  import openpgp from 'openpgp'
@@ -68,6 +69,11 @@ const createEncryptedDataReadStream = async file => {
68
69
  */
69
70
  const deliver = async (targetFileName, readableStream, ...transforms) => {
70
71
  const { s3WriteStream: s3DataStream, managedUpload: s3DataManagedUpload } = createS3WriteStream(targetFileName)
72
+ const { ftpWriteStream: ftpDataStream, managedUpload: ftpDataManagedUpload } = createFtpWriteStream(targetFileName)
71
73
 
72
- await Promise.all([streamHelper.pipelinePromise([readableStream, ...transforms, s3DataStream]), s3DataManagedUpload])
74
+ await Promise.all([
75
+ streamHelper.pipelinePromise([readableStream, ...transforms, s3DataStream, ftpDataStream]),
76
+ s3DataManagedUpload,
77
+ ftpDataManagedUpload
78
+ ])
73
79
  }
@@ -0,0 +1,63 @@
1
+ import { createFtpWriteStream } from '../ftp.js'
2
+ import { mockedFtpMethods } from 'ssh2-sftp-client'
3
+
4
+ jest.mock('stream')
5
+ jest.mock('../../config.js', () => ({
6
+ ftp: {
7
+ host: 'testhost',
8
+ port: 2222,
9
+ path: 'testpath/',
10
+ username: 'testusername',
11
+ privateKey: 'testprivatekey'
12
+ }
13
+ }))
14
+
15
+ describe('ftp', () => {
16
+ beforeEach(() => {
17
+ jest.clearAllMocks()
18
+ })
19
+
20
+ describe('createFtpWriteStream', () => {
21
+ it('creates a stream to write to the configured FTP server', async () => {
22
+ const { ftpWriteStream, managedUpload } = createFtpWriteStream('testfile.json')
23
+ ftpWriteStream.write('Some data')
24
+ ftpWriteStream.end()
25
+ await managedUpload
26
+ expect(mockedFtpMethods.connect).toHaveBeenCalledWith(
27
+ expect.objectContaining({
28
+ host: 'testhost',
29
+ port: 2222,
30
+ username: 'testusername',
31
+ privateKey: 'testprivatekey'
32
+ })
33
+ )
34
+ expect(mockedFtpMethods.put).toHaveBeenCalledWith(ftpWriteStream, 'testpath/testfile.json', {
35
+ flags: 'w',
36
+ encoding: 'UTF-8',
37
+ autoClose: false
38
+ })
39
+ expect(mockedFtpMethods.end).toHaveBeenCalled()
40
+ })
41
+
42
+ it('rejects the managed upload promise if an FTP upload error occurs', async () => {
43
+ const testError = new Error('Test error')
44
+ mockedFtpMethods.put.mockImplementationOnce(() => Promise.reject(testError))
45
+ const { ftpWriteStream, managedUpload } = createFtpWriteStream('testfile.json')
46
+ await expect(managedUpload).rejects.toThrow('Test error')
47
+ expect(mockedFtpMethods.connect).toHaveBeenCalledWith(
48
+ expect.objectContaining({
49
+ host: 'testhost',
50
+ port: 2222,
51
+ username: 'testusername',
52
+ privateKey: 'testprivatekey'
53
+ })
54
+ )
55
+ expect(mockedFtpMethods.put).toHaveBeenCalledWith(ftpWriteStream, 'testpath/testfile.json', {
56
+ flags: 'w',
57
+ encoding: 'UTF-8',
58
+ autoClose: false
59
+ })
60
+ expect(mockedFtpMethods.end).toHaveBeenCalled()
61
+ })
62
+ })
63
+ })
@@ -0,0 +1,28 @@
1
+ import FtpClient from 'ssh2-sftp-client'
2
+ import Path from 'path'
3
+ import { PassThrough } from 'stream'
4
+ import config from '../config.js'
5
+ import db from 'debug'
6
+ const debug = db('fulfilment:transport')
7
+
8
+ /**
9
+ * Create a stream to write to the configured FTP server
10
+ *
11
+ * @param {string} filename The name of the file to be written to the remote server
12
+ * @returns {{ftpWriteStream: module:stream.internal.PassThrough, managedUpload: Promise<*>}}
13
+ */
14
+ export const createFtpWriteStream = filename => {
15
+ const sftp = new FtpClient()
16
+ const passThrough = new PassThrough()
17
+ const remoteFilePath = Path.join(config.ftp.path, filename)
18
+ return {
19
+ ftpWriteStream: passThrough,
20
+ managedUpload: sftp
21
+ .connect(config.ftp)
22
+ .then(() => sftp.put(passThrough, remoteFilePath, { flags: 'w', encoding: 'UTF-8', autoClose: false }))
23
+ .then(() =>
24
+ debug('File successfully uploaded to fulfilment provider at sftp://%s:%s%s', config.ftp.host, config.ftp.port, remoteFilePath)
25
+ )
26
+ .finally(() => sftp.end())
27
+ }
28
+ }