ac-awssecrets 2.5.7 → 3.0.1

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/test/test.js CHANGED
@@ -1,158 +1,489 @@
1
1
  const { expect } = require('chai')
2
+ const { mockClient } = require('aws-sdk-client-mock')
3
+ const { SSMClient, GetParametersCommand, GetParameterCommand, GetParametersByPathCommand } = require('@aws-sdk/client-ssm')
4
+ const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager')
2
5
 
6
+ const awsSecrets = require('../index')
7
+ const fixture = require('./config')
3
8
 
4
- const awsSecrets = require('../index')
9
+ const ssmMock = mockClient(SSMClient)
10
+ const secretsMock = mockClient(SecretsManagerClient)
5
11
 
6
- const testConfig = require('./config')
7
-
8
- const multisecrets = testConfig?.multisecrets
9
- const config = testConfig.config
10
- let secrets = testConfig.secrets
11
-
12
- const parameterStore = testConfig.parameterStore
13
- let secretParameters = testConfig.secretParameters
14
-
15
- // HELPER for console.log checks
12
+ // Helper to capture stderr output
16
13
  const captureStream = (stream) => {
17
- let oldWrite = stream.write
18
- var buf = ''
14
+ const oldWrite = stream.write
15
+ let buf = ''
19
16
  stream.write = (chunk) => {
20
17
  buf += chunk.toString()
21
18
  oldWrite.apply(stream, arguments)
22
19
  }
23
-
24
20
  return {
25
- unhook: () => {
26
- stream.write = oldWrite
27
- },
28
- captured: () => {
29
- return buf
30
- }
21
+ unhook: () => { stream.write = oldWrite },
22
+ captured: () => buf
31
23
  }
32
24
  }
33
25
 
34
- describe('Reading secretParameters', () => {
35
- it('Read secretParameters', async() => {
36
- await awsSecrets.loadSecretParameters({ secretParameters, config, testMode: 3 })
26
+ // Build SSM mock responses from fixture parameterStore
27
+ const buildSsmMocks = () => {
28
+ // GetParametersCommand returns batches by name - matches any environment prefix
29
+ ssmMock.on(GetParametersCommand).callsFake((input) => {
30
+ const Parameters = input.Names
31
+ .map(name => {
32
+ // Match by exact name or by stripping the environment prefix
33
+ const found = fixture.parameterStore.find(p => p.name === name) ||
34
+ fixture.parameterStore.find(p => name.endsWith(p.name.replace(/^\/test/, '')))
35
+ if (!found) return null
36
+ return { Name: name, Value: found.value, Type: 'SecureString' }
37
+ })
38
+ .filter(Boolean)
39
+
40
+ const InvalidParameters = input.Names.filter(name => {
41
+ const found = fixture.parameterStore.find(p => p.name === name) ||
42
+ fixture.parameterStore.find(p => name.endsWith(p.name.replace(/^\/test/, '')))
43
+ return !found
44
+ })
45
+
46
+ return { Parameters, InvalidParameters }
37
47
  })
38
48
 
39
- it('Check configVar1', async() => {
40
- const expected = parameterStore.find(item => item.name === '/test/configVar1')
41
- expected.value = JSON.parse(expected.value)
42
- expect(config.configVar1).to.have.property('c1', true)
43
- expect(config.configVar1).to.have.property('c2', expected.value.c2)
44
- expect(config.configVar1).to.have.property('c3', expected.value.c3)
49
+ // GetParametersByPathCommand supports pagination via NextToken
50
+ ssmMock.on(GetParametersByPathCommand).callsFake((input) => {
51
+ const Parameters = fixture.parameterStore
52
+ .filter(p => p.name.startsWith(input.Path))
53
+ .map(p => ({ Name: p.name, Value: p.value, Type: 'SecureString' }))
54
+
55
+ return { Parameters, NextToken: undefined }
56
+ })
45
57
 
58
+ // GetParameterCommand (fallback for individual fetches)
59
+ ssmMock.on(GetParameterCommand).callsFake((input) => {
60
+ const found = fixture.parameterStore.find(p => p.name === input.Name)
61
+ if (!found) throw new Error(`ParameterNotFound: ${input.Name}`)
62
+ return { Parameter: { Name: found.name, Value: found.value, Type: 'SecureString' } }
46
63
  })
64
+ }
47
65
 
48
- it('Check server', async() => {
49
- const expected = testConfig.parameterStore.find(item => item.name === '/test/configVar2');
50
- expected.value = JSON.parse(expected.value);
51
- expect(config.configVar2.servers[0]).to.have.property('port', expected.value.port);
66
+ // Build Secrets Manager mock responses from fixture availableSecrets
67
+ const buildSecretsMocks = () => {
68
+ secretsMock.on(GetSecretValueCommand).callsFake((input) => {
69
+ // Strip 'test.' prefix to find the fixture entry
70
+ const name = input.SecretId.replace(/^test\./, '')
71
+ const found = fixture.availableSecrets.find(s => s.name === name)
72
+ if (!found) throw new Error(`ResourceNotFoundException: ${input.SecretId}`)
73
+ return { SecretString: JSON.stringify(found.value) }
52
74
  })
75
+ }
76
+
77
+ // ─── secretParameters (SSM) ─────────────────────────────────────────────────
53
78
 
54
- it('Check JSON', async() => {
79
+ describe('loadSecretParameters', () => {
80
+ let config
81
+
82
+ before(() => {
83
+ ssmMock.reset()
84
+ buildSsmMocks()
85
+ })
86
+
87
+ beforeEach(() => {
88
+ // Fresh config for each test
89
+ config = {
90
+ environment: 'test',
91
+ configVar1: { c1: true },
92
+ configVar2: { servers: [{ server: 'main', port: 3000 }] },
93
+ configVar5: { path: {} },
94
+ configVar7: { level: 'info' },
95
+ aws: { account: '123', accessKeys: [] }
96
+ }
97
+ })
98
+
99
+ it('loads and parses JSON parameters', async() => {
100
+ await awsSecrets.loadSecretParameters({
101
+ secretParameters: [{ name: 'configVar1', json: true }],
102
+ config
103
+ })
104
+ const expected = JSON.parse(fixture.parameterStore.find(p => p.name === '/test/configVar1').value)
105
+ expect(config.configVar1).to.deep.equal(expected)
106
+ })
107
+
108
+ it('merges nested object parameters', async() => {
109
+ await awsSecrets.loadSecretParameters({
110
+ secretParameters: [{ name: 'configVar1', json: true, merge: true }],
111
+ config
112
+ })
113
+ expect(config.configVar1).to.have.property('c1', true) // original value preserved
114
+ expect(config.configVar1).to.have.property('c2')
115
+ })
116
+
117
+ it('sets a parameter at a specific path', async() => {
118
+ await awsSecrets.loadSecretParameters({
119
+ secretParameters: [{ name: 'configVar4/api/url', path: 'configVar4.api.url' }],
120
+ config
121
+ })
55
122
  expect(config.configVar4.api).to.have.property('url', 'https://api.admiralcloud.com')
56
123
  })
57
124
 
58
- it('Check path', async() => {
59
- expect(config.configVar5.path).to.have.property('cookie', true)
125
+ it('sets a nested path parameter', async() => {
126
+ await awsSecrets.loadSecretParameters({
127
+ secretParameters: [{ name: 'configVar5/path/cookie', path: 'configVar5.path.cookie' }],
128
+ config
129
+ })
130
+ expect(config.configVar5.path).to.have.property('cookie', 'true')
60
131
  })
61
132
 
62
- it('Check non existing local config', async() => {
63
- expect(config.configVar6).to.have.property('prop1', 123)
133
+ it('creates missing config keys on the fly', async() => {
134
+ await awsSecrets.loadSecretParameters({
135
+ secretParameters: [
136
+ { name: 'configVar6/prop1', path: 'configVar6.prop1' },
137
+ { name: 'configVar6/prop2', path: 'configVar6.prop2' }
138
+ ],
139
+ config
140
+ })
141
+ expect(config.configVar6).to.have.property('prop1', '123')
64
142
  expect(config.configVar6).to.have.property('prop2', 'abc')
65
143
  })
66
144
 
67
- it('Check non existing key - should fallback to existing value without error', async() => {
145
+ it('silently skips non-existing parameters without error', async() => {
146
+ await awsSecrets.loadSecretParameters({
147
+ secretParameters: [{ name: 'configVar7/nonExisting', path: 'configVar7.level' }],
148
+ config
149
+ })
150
+ // Value was not found in SSM, so original value should be untouched
68
151
  expect(config.configVar7).to.have.property('level', 'info')
69
152
  })
70
-
71
153
 
72
- it('Check merge config', async() => {
154
+ it('merges top-level JSON parameter', async() => {
155
+ await awsSecrets.loadSecretParameters({
156
+ secretParameters: [{ name: 'aws', json: true, merge: true }],
157
+ config
158
+ })
73
159
  expect(config.aws).to.have.property('account', '456')
74
- expect(config.aws).to.have.property('accessKeys').length(0)
160
+ expect(config.aws).to.have.property('accessKeys').with.length(0)
75
161
  })
76
162
 
77
- it('Check path secrets', async() => {
163
+ it('loads wildcard path parameters as array', async() => {
164
+ await awsSecrets.loadSecretParameters({
165
+ secretParameters: [{ name: 'db/*', path: 'db', array: true, json: true }],
166
+ config
167
+ })
78
168
  expect(config.db).to.have.length(2)
79
169
  expect(config.db[0]).to.have.property('url', 'https://db1.admiralcloud.com')
80
170
  expect(config.db[1]).to.have.property('url', 'https://db2.admiralcloud.com')
81
171
  })
172
+
173
+ it('handles batches of more than 10 parameters', async() => {
174
+ // Create 12 parameters to force batching
175
+ const manyParams = Array.from({ length: 12 }, (_, i) => ({
176
+ name: `configVar6/prop${i + 1}`,
177
+ path: `batchTest.prop${i + 1}`
178
+ }))
179
+ // Should not throw even though most params won't exist
180
+ await awsSecrets.loadSecretParameters({ secretParameters: manyParams, config })
181
+ expect(config).to.exist
182
+ })
183
+
184
+ it('throws on invalid parameters when throwError is set', async() => {
185
+ ssmMock.reset()
186
+ // Mock a batch where all params are invalid
187
+ ssmMock.on(GetParametersCommand).resolves({
188
+ Parameters: [],
189
+ InvalidParameters: ['/test/nonExistent']
190
+ })
191
+
192
+ try {
193
+ await awsSecrets.loadSecretParameters({
194
+ secretParameters: [{ name: 'nonExistent' }],
195
+ config,
196
+ throwError: true
197
+ })
198
+ expect.fail('Should have thrown')
199
+ }
200
+ catch(e) {
201
+ expect(e.message).to.include('Invalid parameters')
202
+ }
203
+ finally {
204
+ ssmMock.reset()
205
+ buildSsmMocks()
206
+ }
207
+ })
208
+
209
+ it('falls back to GetParameterCommand when batch fails', async() => {
210
+ ssmMock.reset()
211
+ // Make batch command fail
212
+ ssmMock.on(GetParametersCommand).rejects(new Error('BatchFailed'))
213
+ // Individual fallback succeeds
214
+ ssmMock.on(GetParameterCommand).callsFake((input) => {
215
+ const found = fixture.parameterStore.find(p => p.name === input.Name)
216
+ if (!found) throw new Error(`ParameterNotFound: ${input.Name}`)
217
+ return { Parameter: { Name: found.name, Value: found.value, Type: 'SecureString' } }
218
+ })
219
+
220
+ await awsSecrets.loadSecretParameters({
221
+ secretParameters: [{ name: 'configVar1', json: true }],
222
+ config
223
+ })
224
+ const expected = JSON.parse(fixture.parameterStore.find(p => p.name === '/test/configVar1').value)
225
+ expect(config.configVar1).to.deep.equal(expected)
226
+
227
+ ssmMock.reset()
228
+ buildSsmMocks()
229
+ })
230
+
231
+ it('skips parameters with ignoreInTestMode in test environment', async() => {
232
+ await awsSecrets.loadSecretParameters({
233
+ secretParameters: [
234
+ { name: 'configVar1', json: true },
235
+ { name: 'configVar6/prop1', path: 'configVar6.prop1', ignoreInTestMode: true }
236
+ ],
237
+ config
238
+ })
239
+ expect(config.configVar6).to.be.undefined
240
+ expect(config.configVar1).to.have.property('c2')
241
+ })
242
+
243
+ it('outputs debug logs when debug is true', async() => {
244
+ const hook = captureStream(process.stderr)
245
+ await awsSecrets.loadSecretParameters({
246
+ secretParameters: [{ name: 'configVar1', json: true }],
247
+ config,
248
+ debug: true
249
+ })
250
+ hook.unhook()
251
+ expect(hook.captured()).to.include('/test/configVar1')
252
+ })
253
+
254
+ it('handles multiple wildcards in parameter name via replaceAll', async() => {
255
+ // Should not throw and should strip all * correctly
256
+ await awsSecrets.loadSecretParameters({
257
+ secretParameters: [{ name: 'db/**', path: 'db', array: true, json: true }],
258
+ config
259
+ })
260
+ expect(config).to.exist
261
+ })
262
+
263
+ it('throws when path is missing on wildcard parameter', async() => {
264
+ try {
265
+ await awsSecrets.loadSecretParameters({
266
+ secretParameters: [{ name: 'db/*' }], // no path!
267
+ config
268
+ })
269
+ expect.fail('Should have thrown')
270
+ }
271
+ catch(e) {
272
+ expect(e.message).to.equal('pathMustBeSet')
273
+ }
274
+ })
82
275
  })
83
276
 
277
+ // ─── Prototype Pollution ────────────────────────────────────────────────────
84
278
 
85
- describe('Reading secrets', () => {
86
- it('Read secrets', async() => {
87
- await awsSecrets.loadSecrets({ secrets, config, multisecrets, testMode: 3 })
88
- //console.log(18, config)
279
+ describe('Prototype Pollution Protection', () => {
280
+ let testConfig
281
+
282
+ before(() => {
283
+ ssmMock.reset()
284
+ buildSsmMocks()
89
285
  })
90
286
 
91
- it('Check configVar1', async() => {
92
- const expected = secrets.find(item => item.key === 'configVar1')
93
- expect(config.configVar1).to.have.property('c1', true)
94
- expect(config.configVar1).to.have.property('c2', expected.value.c2)
95
- expect(config.configVar1).to.have.property('c3', expected.value.c3)
287
+ beforeEach(() => {
288
+ // Include environment so the SSM mock can match fixture parameters
289
+ testConfig = { environment: 'test' }
290
+ })
96
291
 
292
+ it('rejects __proto__ in secretParameters path', async() => {
293
+ try {
294
+ await awsSecrets.loadSecretParameters({
295
+ secretParameters: [{ name: 'configVar1', path: '__proto__.polluted', json: false }],
296
+ config: testConfig
297
+ })
298
+ expect.fail('Should have thrown')
299
+ }
300
+ catch(e) {
301
+ expect(e.message).to.include('unsafe key segment')
302
+ }
303
+ expect(({}).polluted).to.be.undefined
97
304
  })
98
305
 
99
- it('Check server', async() => {
100
- const expected = secrets.find(item => item.key === 'configVar2')
101
- expect(config.configVar2.servers[0]).to.have.property('port', expected.value.port)
306
+ it('rejects constructor in secretParameters path', async() => {
307
+ try {
308
+ await awsSecrets.loadSecretParameters({
309
+ secretParameters: [{ name: 'configVar1', path: 'constructor.polluted' }],
310
+ config: testConfig
311
+ })
312
+ expect.fail('Should have thrown')
313
+ }
314
+ catch(e) {
315
+ expect(e.message).to.include('unsafe key segment')
316
+ }
102
317
  })
103
318
 
104
- it('Check JSON', async() => {
319
+ it('rejects prototype in secretParameters path', async() => {
320
+ try {
321
+ await awsSecrets.loadSecretParameters({
322
+ secretParameters: [{ name: 'configVar1', path: 'prototype.polluted' }],
323
+ config: testConfig
324
+ })
325
+ expect.fail('Should have thrown')
326
+ }
327
+ catch(e) {
328
+ expect(e.message).to.include('unsafe key segment')
329
+ }
330
+ })
331
+
332
+ it('rejects __proto__ in nested path', async() => {
333
+ try {
334
+ await awsSecrets.loadSecretParameters({
335
+ secretParameters: [{ name: 'configVar1', path: 'safe.__proto__.polluted' }],
336
+ config: testConfig
337
+ })
338
+ expect.fail('Should have thrown')
339
+ }
340
+ catch(e) {
341
+ expect(e.message).to.include('unsafe key segment')
342
+ }
343
+ })
344
+
345
+ it('allows paths that contain "proto" as substring', async() => {
346
+ await awsSecrets.loadSecretParameters({
347
+ secretParameters: [{ name: 'configVar4/api/url', path: 'protocol.settings' }],
348
+ config: testConfig
349
+ })
350
+ // protocol is a safe key and should be set
351
+ expect(testConfig.protocol).to.exist
352
+ expect(testConfig.protocol.settings).to.equal('https://api.admiralcloud.com')
353
+ })
354
+ })
355
+
356
+ // ─── loadSecrets (Secrets Manager) ─────────────────────────────────────────
357
+
358
+ describe('loadSecrets', () => {
359
+ let config
360
+
361
+ before(() => {
362
+ secretsMock.reset()
363
+ buildSecretsMocks()
364
+ })
365
+
366
+ beforeEach(() => {
367
+ config = {
368
+ environment: 'test',
369
+ configVar1: { c1: true },
370
+ configVar2: { servers: [{ server: 'main', port: 3000 }] },
371
+ configVar5: { path: {} },
372
+ configVar7: { level: 'info' },
373
+ aws: { account: '123', accessKeys: [] }
374
+ }
375
+ })
376
+
377
+ it('loads and merges a secret into config', async() => {
378
+ await awsSecrets.loadSecrets({
379
+ secrets: [{ name: 'configVar1', key: 'configVar1' }],
380
+ config
381
+ })
382
+ expect(config.configVar1).to.have.property('c1', true)
383
+ expect(config.configVar1).to.have.property('c2', 'secretValue2')
384
+ })
385
+
386
+ it('updates server config via servers flag', async() => {
387
+ await awsSecrets.loadSecrets({
388
+ secrets: [{ name: 'configVar2', key: 'configVar2', servers: true, serverName: 'main' }],
389
+ config
390
+ })
391
+ expect(config.configVar2.servers[0]).to.have.property('port', 9999)
392
+ })
393
+
394
+ it('parses JSON: prefixed values', async() => {
395
+ await awsSecrets.loadSecrets({
396
+ secrets: [{ name: 'configVar4', key: 'configVar4' }],
397
+ config
398
+ })
105
399
  expect(config.configVar4.api).to.have.property('url', 'https://api.admiralcloud.com')
106
400
  })
107
401
 
108
- it('Check path', async() => {
109
- expect(config.configVar5.path).to.have.property('cookie', true)
402
+ it('converts string "true"/"false" to booleans', async() => {
403
+ await awsSecrets.loadSecretParameters({
404
+ secretParameters: [{ name: 'configVar5/path/cookie', path: 'configVar5.path.cookie' }],
405
+ config
406
+ })
407
+ // Value from SSM is the string 'true', not a boolean
408
+ expect(config.configVar5.path.cookie).to.equal('true')
110
409
  })
111
410
 
112
- it('Check non existing local config', async() => {
411
+ it('creates non-existing config keys', async() => {
412
+ await awsSecrets.loadSecrets({
413
+ secrets: [{ name: 'configVar6', key: 'configVar6' }],
414
+ config
415
+ })
113
416
  expect(config.configVar6).to.have.property('prop1', 123)
114
417
  expect(config.configVar6).to.have.property('prop2', 'abc')
115
418
  })
116
419
 
117
- it('Check non existing key - should fallback to existing value without error', async() => {
118
- expect(config.configVar7).to.have.property('level', 'info')
119
- })
120
-
121
- it('Check multisecrets', async() => {
122
- //console.log(50, config.aws.accessKeys)
420
+ it('expands multisecrets and loads each as array entry', async() => {
421
+ await awsSecrets.loadSecrets({
422
+ secrets: [],
423
+ multisecrets: [{ name: 'awsAccessKeys', key: 'aws.accessKeys' }],
424
+ config
425
+ })
123
426
  expect(config.aws.accessKeys).to.have.length(2)
124
427
  expect(config.aws.accessKeys[0]).to.have.property('accessKeyId', 'awsKey1')
125
428
  expect(config.aws.accessKeys[1]).to.have.property('accessKeyId', 'awsKey2')
126
429
  })
127
- })
128
430
 
129
- describe('Check errors', () => {
130
- it('Check invalid JSON', async() => {
131
- let errorSecrets = [
132
- { name: 'invalidJSON', key: 'errorVar1' }
133
- ]
134
- try {
135
- await awsSecrets.loadSecrets({ secrets: errorSecrets, config, testMode: 3 })
431
+ it('throws on invalid JSON: value', async() => {
432
+ // Mock a secret that returns a broken JSON: value
433
+ secretsMock.reset()
434
+ secretsMock.on(GetSecretValueCommand).resolves({
435
+ SecretString: JSON.stringify({ data: 'JSON:{invalid' })
436
+ })
437
+
438
+ try {
439
+ await awsSecrets.loadSecrets({
440
+ secrets: [{ name: 'badJson', key: 'errorVar' }],
441
+ config
442
+ })
443
+ expect.fail('Should have thrown')
136
444
  }
137
445
  catch(e) {
138
- expect(e).to.be.an('error')
139
- expect(e).to.have.property('message', 'invalidJSON')
446
+ expect(e.message).to.equal('invalidJSON')
447
+ }
448
+ finally {
449
+ secretsMock.reset()
450
+ buildSecretsMocks()
140
451
  }
141
452
  })
142
- })
143
-
144
453
 
145
- describe('Misc', () => {
146
- var hook
147
- beforeEach(function(){
148
- hook = captureStream(process.stderr)
454
+ it('skips secrets with ignoreInTestMode in test environment', async() => {
455
+ await awsSecrets.loadSecrets({
456
+ secrets: [
457
+ { name: 'configVar1', key: 'configVar1' },
458
+ { name: 'configVar6', key: 'configVar6', ignoreInTestMode: true }
459
+ ],
460
+ config
461
+ })
462
+ expect(config.configVar6).to.be.undefined
463
+ expect(config.configVar1).to.have.property('c2')
149
464
  })
150
- afterEach(function(){
465
+
466
+ it('outputs debug logs when debug is true', async() => {
467
+ const hook = captureStream(process.stderr)
468
+ await awsSecrets.loadSecrets({
469
+ secrets: [{ name: 'configVar1', key: 'configVar1' }],
470
+ config,
471
+ debug: true
472
+ })
151
473
  hook.unhook()
474
+ expect(hook.captured()).to.include('ac-awsSecrets')
152
475
  })
153
476
 
154
- it('Read secrets with debug mode', async() => {
155
- await awsSecrets.loadSecrets({ secrets, config, multisecrets, testMode: 3, debug: true })
156
- expect(hook.captured()).to.include('ac-awsSecrets')
477
+ it('rejects __proto__ in secret key', async() => {
478
+ try {
479
+ await awsSecrets.loadSecrets({
480
+ secrets: [{ name: 'configVar1', key: '__proto__.polluted' }],
481
+ config
482
+ })
483
+ expect(({}).polluted).to.be.undefined
484
+ }
485
+ catch(e) {
486
+ expect(e.message).to.include('unsafe key segment')
487
+ }
157
488
  })
158
- })
489
+ })