@aws-blocks/bb-app-setting 0.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/LICENSE +174 -0
- package/README.md +156 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/index.aws.d.ts +76 -0
- package/dist/index.aws.d.ts.map +1 -0
- package/dist/index.aws.js +135 -0
- package/dist/index.browser.d.ts +5 -0
- package/dist/index.browser.d.ts.map +1 -0
- package/dist/index.browser.js +9 -0
- package/dist/index.cdk.d.ts +35 -0
- package/dist/index.cdk.d.ts.map +1 -0
- package/dist/index.cdk.js +242 -0
- package/dist/index.cdk.test.d.ts +2 -0
- package/dist/index.cdk.test.d.ts.map +1 -0
- package/dist/index.cdk.test.js +187 -0
- package/dist/index.hooks.d.ts +2 -0
- package/dist/index.hooks.d.ts.map +1 -0
- package/dist/index.hooks.js +3 -0
- package/dist/index.mock.d.ts +77 -0
- package/dist/index.mock.d.ts.map +1 -0
- package/dist/index.mock.js +153 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +542 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/package.json +46 -0
- package/src/errors.ts +27 -0
- package/src/index.aws.ts +165 -0
- package/src/index.browser.ts +10 -0
- package/src/index.cdk.test.ts +227 -0
- package/src/index.cdk.ts +291 -0
- package/src/index.hooks.ts +5 -0
- package/src/index.mock.ts +181 -0
- package/src/index.test.ts +627 -0
- package/src/types.ts +56 -0
- package/src/version.ts +3 -0
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import { test, beforeEach, describe } from 'node:test';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
import { rmSync, existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { AppSetting, AppSettingErrors } from './index.mock.js';
|
|
9
|
+
|
|
10
|
+
// Clean mock data between tests
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
try { rmSync('.bb-data', { recursive: true, force: true }); } catch {}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// ── Helper: minimal StandardSchemaV1 implementation ─────────────────────────
|
|
16
|
+
|
|
17
|
+
function makeSchema<T>(validate: (value: unknown) => T | null) {
|
|
18
|
+
return {
|
|
19
|
+
'~standard': {
|
|
20
|
+
version: 1 as const,
|
|
21
|
+
vendor: 'test',
|
|
22
|
+
validate: (value: unknown) => {
|
|
23
|
+
const result = validate(value);
|
|
24
|
+
if (result !== null) return { value: result };
|
|
25
|
+
return { issues: [{ message: 'Schema validation failed' }] };
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const configSchema = makeSchema<{ maxRetries: number; timeout: number }>((v) => {
|
|
32
|
+
if (typeof v === 'object' && v !== null && 'maxRetries' in v && 'timeout' in v
|
|
33
|
+
&& typeof (v as any).maxRetries === 'number' && typeof (v as any).timeout === 'number') {
|
|
34
|
+
return v as { maxRetries: number; timeout: number };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
40
|
+
// Plain string settings
|
|
41
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
42
|
+
|
|
43
|
+
describe('Plain string settings', () => {
|
|
44
|
+
test('get returns provided value when no stored value exists', async () => {
|
|
45
|
+
const setting = new AppSetting({ id: 'root' } as any, 'test', {
|
|
46
|
+
name: '/app/test',
|
|
47
|
+
value: 'hello',
|
|
48
|
+
});
|
|
49
|
+
assert.strictEqual(await setting.get(), 'hello');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('put then get returns updated value', async () => {
|
|
53
|
+
const setting = new AppSetting({ id: 'root' } as any, 'test', {
|
|
54
|
+
name: '/app/test',
|
|
55
|
+
value: 'initial',
|
|
56
|
+
});
|
|
57
|
+
await setting.put('updated');
|
|
58
|
+
assert.strictEqual(await setting.get(), 'updated');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('put overwrites previous value', async () => {
|
|
62
|
+
const setting = new AppSetting({ id: 'root' } as any, 'test', {
|
|
63
|
+
name: '/app/test',
|
|
64
|
+
value: 'v1',
|
|
65
|
+
});
|
|
66
|
+
await setting.put('v2');
|
|
67
|
+
await setting.put('v3');
|
|
68
|
+
assert.strictEqual(await setting.get(), 'v3');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('put with empty string is valid', async () => {
|
|
72
|
+
const setting = new AppSetting({ id: 'root' } as any, 'test', {
|
|
73
|
+
name: '/app/test',
|
|
74
|
+
value: 'initial',
|
|
75
|
+
});
|
|
76
|
+
await setting.put('');
|
|
77
|
+
assert.strictEqual(await setting.get(), '');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('string values are stored as-is without JSON quoting', async () => {
|
|
81
|
+
const setting = new AppSetting({ id: 'root' } as any, 'test', {
|
|
82
|
+
name: '/app/test',
|
|
83
|
+
value: 'hello world',
|
|
84
|
+
});
|
|
85
|
+
await setting.put('no "quotes" added');
|
|
86
|
+
const result = await setting.get();
|
|
87
|
+
assert.strictEqual(result, 'no "quotes" added');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('unicode strings round-trip correctly', async () => {
|
|
91
|
+
const setting = new AppSetting({ id: 'root' } as any, 'test', {
|
|
92
|
+
name: '/app/test',
|
|
93
|
+
value: '',
|
|
94
|
+
});
|
|
95
|
+
const unicode = '日本語テスト 🎉 émojis';
|
|
96
|
+
await setting.put(unicode);
|
|
97
|
+
assert.strictEqual(await setting.get(), unicode);
|
|
98
|
+
});
|
|
99
|
+
test('numeric value round-trips correctly without schema', async () => {
|
|
100
|
+
const setting = new AppSetting<number>({ id: 'root' } as any, 'temp', {
|
|
101
|
+
name: '/app/temp', value: 0.7,
|
|
102
|
+
});
|
|
103
|
+
await setting.put(0.7);
|
|
104
|
+
const v = await setting.get();
|
|
105
|
+
assert.strictEqual(v, 0.7);
|
|
106
|
+
assert.strictEqual(typeof v, 'number');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
+
// Typed object settings with schema
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
describe('Typed object settings with schema', () => {
|
|
115
|
+
test('get returns initial object value', async () => {
|
|
116
|
+
const setting = new AppSetting({ id: 'root' } as any, 'config', {
|
|
117
|
+
name: '/app/config',
|
|
118
|
+
value: { maxRetries: 3, timeout: 5000 },
|
|
119
|
+
schema: configSchema,
|
|
120
|
+
});
|
|
121
|
+
assert.deepStrictEqual(await setting.get(), { maxRetries: 3, timeout: 5000 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('put then get returns updated object', async () => {
|
|
125
|
+
const setting = new AppSetting({ id: 'root' } as any, 'config', {
|
|
126
|
+
name: '/app/config',
|
|
127
|
+
value: { maxRetries: 3, timeout: 5000 },
|
|
128
|
+
schema: configSchema,
|
|
129
|
+
});
|
|
130
|
+
await setting.put({ maxRetries: 5, timeout: 10000 });
|
|
131
|
+
assert.deepStrictEqual(await setting.get(), { maxRetries: 5, timeout: 10000 });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('object values survive serialization round-trip', async () => {
|
|
135
|
+
const setting = new AppSetting({ id: 'root' } as any, 'config', {
|
|
136
|
+
name: '/app/config',
|
|
137
|
+
value: { maxRetries: 0, timeout: 0 },
|
|
138
|
+
schema: configSchema,
|
|
139
|
+
});
|
|
140
|
+
const values = [
|
|
141
|
+
{ maxRetries: 0, timeout: 0 },
|
|
142
|
+
{ maxRetries: 999, timeout: 999999 },
|
|
143
|
+
{ maxRetries: -1, timeout: -1 },
|
|
144
|
+
];
|
|
145
|
+
for (const v of values) {
|
|
146
|
+
await setting.put(v);
|
|
147
|
+
assert.deepStrictEqual(await setting.get(), v);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('schema validation rejects invalid values on put', async () => {
|
|
152
|
+
const setting = new AppSetting({ id: 'root' } as any, 'config', {
|
|
153
|
+
name: '/app/config',
|
|
154
|
+
value: { maxRetries: 3, timeout: 5000 },
|
|
155
|
+
schema: configSchema,
|
|
156
|
+
});
|
|
157
|
+
await assert.rejects(
|
|
158
|
+
() => setting.put({ wrong: 'field' } as any),
|
|
159
|
+
(err: Error) => err.name === AppSettingErrors.ValidationFailed,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('value remains unchanged after failed schema validation', async () => {
|
|
164
|
+
const setting = new AppSetting({ id: 'root' } as any, 'config', {
|
|
165
|
+
name: '/app/config',
|
|
166
|
+
value: { maxRetries: 3, timeout: 5000 },
|
|
167
|
+
schema: configSchema,
|
|
168
|
+
});
|
|
169
|
+
await setting.put({ maxRetries: 10, timeout: 1000 });
|
|
170
|
+
try {
|
|
171
|
+
await setting.put({ bad: true } as any);
|
|
172
|
+
} catch {}
|
|
173
|
+
assert.deepStrictEqual(await setting.get(), { maxRetries: 10, timeout: 1000 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('schema validation rejects null', async () => {
|
|
177
|
+
const setting = new AppSetting({ id: 'root' } as any, 'config', {
|
|
178
|
+
name: '/app/config',
|
|
179
|
+
value: { maxRetries: 3, timeout: 5000 },
|
|
180
|
+
schema: configSchema,
|
|
181
|
+
});
|
|
182
|
+
await assert.rejects(
|
|
183
|
+
() => setting.put(null as any),
|
|
184
|
+
(err: Error) => err.name === AppSettingErrors.ValidationFailed,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('schema validation rejects string when object expected', async () => {
|
|
189
|
+
const setting = new AppSetting({ id: 'root' } as any, 'config', {
|
|
190
|
+
name: '/app/config',
|
|
191
|
+
value: { maxRetries: 3, timeout: 5000 },
|
|
192
|
+
schema: configSchema,
|
|
193
|
+
});
|
|
194
|
+
await assert.rejects(
|
|
195
|
+
() => setting.put('not an object' as any),
|
|
196
|
+
(err: Error) => err.name === AppSettingErrors.ValidationFailed,
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
202
|
+
// Secret settings
|
|
203
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
204
|
+
|
|
205
|
+
describe('Secret settings', () => {
|
|
206
|
+
test('secret without value generates a random initial value', async () => {
|
|
207
|
+
const setting = new AppSetting({ id: 'root' } as any, 'secret', {
|
|
208
|
+
name: '/app/secret',
|
|
209
|
+
secret: true,
|
|
210
|
+
});
|
|
211
|
+
const value = await setting.get();
|
|
212
|
+
assert.ok(typeof value === 'string');
|
|
213
|
+
assert.ok(value.length > 0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('two secret instances generate different random values', async () => {
|
|
217
|
+
const s1 = new AppSetting({ id: 'root' } as any, 'secret1', {
|
|
218
|
+
name: '/app/secret1',
|
|
219
|
+
secret: true,
|
|
220
|
+
});
|
|
221
|
+
const s2 = new AppSetting({ id: 'root' } as any, 'secret2', {
|
|
222
|
+
name: '/app/secret2',
|
|
223
|
+
secret: true,
|
|
224
|
+
});
|
|
225
|
+
assert.notStrictEqual(await s1.get(), await s2.get());
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('secret value can be updated via put', async () => {
|
|
229
|
+
const setting = new AppSetting({ id: 'root' } as any, 'secret', {
|
|
230
|
+
name: '/app/secret',
|
|
231
|
+
secret: true,
|
|
232
|
+
});
|
|
233
|
+
await setting.put('my-real-secret-value');
|
|
234
|
+
assert.strictEqual(await setting.get(), 'my-real-secret-value');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('secret put overwrites the random initial value', async () => {
|
|
238
|
+
const setting = new AppSetting({ id: 'root' } as any, 'secret', {
|
|
239
|
+
name: '/app/secret',
|
|
240
|
+
secret: true,
|
|
241
|
+
});
|
|
242
|
+
const randomValue = await setting.get();
|
|
243
|
+
await setting.put('explicit-secret');
|
|
244
|
+
assert.strictEqual(await setting.get(), 'explicit-secret');
|
|
245
|
+
assert.notStrictEqual(await setting.get(), randomValue);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
250
|
+
// Secret empty-string rejection
|
|
251
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
252
|
+
|
|
253
|
+
describe('Secret empty-string rejection', () => {
|
|
254
|
+
test('get() throws when secret parameter value is empty string', async () => {
|
|
255
|
+
const setting = new AppSetting({ id: 'root' } as any, 'empty-secret', {
|
|
256
|
+
name: '/app/empty-secret',
|
|
257
|
+
secret: true,
|
|
258
|
+
});
|
|
259
|
+
// Force an empty string into storage (simulates SSM returning empty)
|
|
260
|
+
await setting.put('placeholder');
|
|
261
|
+
const { writeFileSync, readFileSync: rf } = await import('node:fs');
|
|
262
|
+
const { join } = await import('node:path');
|
|
263
|
+
const settingsPath = join('.bb-data', 'settings.json');
|
|
264
|
+
const data = JSON.parse(rf(settingsPath, 'utf8'));
|
|
265
|
+
data['root-empty-secret'] = '';
|
|
266
|
+
writeFileSync(settingsPath, JSON.stringify(data));
|
|
267
|
+
|
|
268
|
+
await assert.rejects(
|
|
269
|
+
() => setting.get(),
|
|
270
|
+
(err: Error) => {
|
|
271
|
+
assert.strictEqual(err.name, AppSettingErrors.ParameterNotFound);
|
|
272
|
+
assert.ok(err.message.includes('empty value'));
|
|
273
|
+
assert.ok(err.message.includes('secrets must not be empty'));
|
|
274
|
+
return true;
|
|
275
|
+
},
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('get() does NOT throw when non-secret parameter value is empty string', async () => {
|
|
280
|
+
const setting = new AppSetting({ id: 'root' } as any, 'empty-nonsecret', {
|
|
281
|
+
name: '/app/empty-nonsecret',
|
|
282
|
+
value: 'initial',
|
|
283
|
+
});
|
|
284
|
+
await setting.put('');
|
|
285
|
+
const result = await setting.get();
|
|
286
|
+
assert.strictEqual(result, '');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('get() works normally when secret has a real value', async () => {
|
|
290
|
+
const setting = new AppSetting({ id: 'root' } as any, 'real-secret', {
|
|
291
|
+
name: '/app/real-secret',
|
|
292
|
+
secret: true,
|
|
293
|
+
});
|
|
294
|
+
await setting.put('super-secret-key-123');
|
|
295
|
+
const result = await setting.get();
|
|
296
|
+
assert.strictEqual(result, 'super-secret-key-123');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('get() throws ParameterNotFound (not ValidationFailed) for empty secrets', async () => {
|
|
300
|
+
const setting = new AppSetting({ id: 'root' } as any, 'errtype-secret', {
|
|
301
|
+
name: '/app/errtype-secret',
|
|
302
|
+
secret: true,
|
|
303
|
+
});
|
|
304
|
+
await setting.put('temp');
|
|
305
|
+
const { writeFileSync, readFileSync: rf } = await import('node:fs');
|
|
306
|
+
const { join } = await import('node:path');
|
|
307
|
+
const settingsPath = join('.bb-data', 'settings.json');
|
|
308
|
+
const data = JSON.parse(rf(settingsPath, 'utf8'));
|
|
309
|
+
data['root-errtype-secret'] = '';
|
|
310
|
+
writeFileSync(settingsPath, JSON.stringify(data));
|
|
311
|
+
|
|
312
|
+
await assert.rejects(
|
|
313
|
+
() => setting.get(),
|
|
314
|
+
(err: Error) => {
|
|
315
|
+
assert.strictEqual(err.name, AppSettingErrors.ParameterNotFound);
|
|
316
|
+
assert.notStrictEqual(err.name, AppSettingErrors.ValidationFailed);
|
|
317
|
+
return true;
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('auto-generated secret initial value is never empty', async () => {
|
|
323
|
+
const setting = new AppSetting({ id: 'root' } as any, 'autogen-secret', {
|
|
324
|
+
name: '/app/autogen-secret',
|
|
325
|
+
secret: true,
|
|
326
|
+
});
|
|
327
|
+
// First get() returns the auto-generated value (should be non-empty)
|
|
328
|
+
const value = await setting.get();
|
|
329
|
+
assert.ok(typeof value === 'string');
|
|
330
|
+
assert.ok(value.length > 0, 'Auto-generated secret must be non-empty');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
335
|
+
// 4 KB size limit
|
|
336
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
337
|
+
|
|
338
|
+
describe('4 KB size limit', () => {
|
|
339
|
+
test('put rejects values exceeding 4 KB for non-secret parameters', async () => {
|
|
340
|
+
const setting = new AppSetting({ id: 'root' } as any, 'big', {
|
|
341
|
+
name: '/app/big',
|
|
342
|
+
value: '',
|
|
343
|
+
});
|
|
344
|
+
const bigValue = 'x'.repeat(5000);
|
|
345
|
+
await assert.rejects(
|
|
346
|
+
() => setting.put(bigValue),
|
|
347
|
+
(err: Error) => err.name === AppSettingErrors.ValidationFailed,
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('put accepts values at exactly 4096 bytes, including JSON.stringify overhead', async () => {
|
|
352
|
+
const setting = new AppSetting({ id: 'root' } as any, 'exact', {
|
|
353
|
+
name: '/app/exact',
|
|
354
|
+
value: '',
|
|
355
|
+
});
|
|
356
|
+
// JSON.stringify adds 2 bytes for quotes around a plain string
|
|
357
|
+
const exactValue = 'x'.repeat(4094);
|
|
358
|
+
await setting.put(exactValue);
|
|
359
|
+
assert.strictEqual(await setting.get(), exactValue);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('put accepts values under 4096 bytes', async () => {
|
|
363
|
+
const setting = new AppSetting({ id: 'root' } as any, 'small', {
|
|
364
|
+
name: '/app/small',
|
|
365
|
+
value: '',
|
|
366
|
+
});
|
|
367
|
+
const smallValue = 'x'.repeat(100);
|
|
368
|
+
await setting.put(smallValue);
|
|
369
|
+
assert.strictEqual(await setting.get(), smallValue);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('size limit applies to serialized JSON for object values', async () => {
|
|
373
|
+
const bigSchema = makeSchema<{ data: string }>((v) => {
|
|
374
|
+
if (typeof v === 'object' && v !== null && 'data' in v && typeof (v as any).data === 'string') {
|
|
375
|
+
return v as { data: string };
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const setting = new AppSetting({ id: 'root' } as any, 'bigobj', {
|
|
381
|
+
name: '/app/bigobj',
|
|
382
|
+
value: { data: '' },
|
|
383
|
+
schema: bigSchema,
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// JSON.stringify({ data: 'x'.repeat(4090) }) is well over 4096 bytes
|
|
387
|
+
await assert.rejects(
|
|
388
|
+
() => setting.put({ data: 'x'.repeat(4090) }),
|
|
389
|
+
(err: Error) => err.name === AppSettingErrors.ValidationFailed,
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test('size limit does not apply to secret parameters', async () => {
|
|
394
|
+
const setting = new AppSetting({ id: 'root' } as any, 'bigsecret', {
|
|
395
|
+
name: '/app/bigsecret',
|
|
396
|
+
secret: true,
|
|
397
|
+
});
|
|
398
|
+
const bigValue = 'x'.repeat(5000);
|
|
399
|
+
await setting.put(bigValue);
|
|
400
|
+
assert.strictEqual(await setting.get(), bigValue);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('value unchanged after size limit rejection', async () => {
|
|
404
|
+
const setting = new AppSetting({ id: 'root' } as any, 'limit', {
|
|
405
|
+
name: '/app/limit',
|
|
406
|
+
value: 'original',
|
|
407
|
+
});
|
|
408
|
+
try {
|
|
409
|
+
await setting.put('x'.repeat(5000));
|
|
410
|
+
} catch {}
|
|
411
|
+
assert.strictEqual(await setting.get(), 'original');
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
416
|
+
// Disk persistence
|
|
417
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
418
|
+
|
|
419
|
+
describe('Disk persistence', () => {
|
|
420
|
+
test('data persists across instances with same scope path', async () => {
|
|
421
|
+
const s1 = new AppSetting({ id: 'root' } as any, 'persist', {
|
|
422
|
+
name: '/app/persist',
|
|
423
|
+
value: 'initial',
|
|
424
|
+
});
|
|
425
|
+
await s1.put('saved');
|
|
426
|
+
|
|
427
|
+
const s2 = new AppSetting({ id: 'root' } as any, 'persist', {
|
|
428
|
+
name: '/app/persist',
|
|
429
|
+
value: 'initial',
|
|
430
|
+
});
|
|
431
|
+
assert.strictEqual(await s2.get(), 'saved');
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('different scope paths have independent storage', async () => {
|
|
435
|
+
const s1 = new AppSetting({ id: 'scope1' } as any, 'setting', {
|
|
436
|
+
name: '/app/s1',
|
|
437
|
+
value: 'value1',
|
|
438
|
+
});
|
|
439
|
+
const s2 = new AppSetting({ id: 'scope2' } as any, 'setting', {
|
|
440
|
+
name: '/app/s2',
|
|
441
|
+
value: 'value2',
|
|
442
|
+
});
|
|
443
|
+
await s1.put('updated1');
|
|
444
|
+
assert.strictEqual(await s2.get(), 'value2'); // not affected by s1
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('mock stores data in .bb-data/settings.json', async () => {
|
|
448
|
+
const setting = new AppSetting({ id: 'root' } as any, 'diskcheck', {
|
|
449
|
+
name: '/app/diskcheck',
|
|
450
|
+
value: 'initial',
|
|
451
|
+
});
|
|
452
|
+
await setting.put('written');
|
|
453
|
+
const filePath = join('.bb-data', 'settings.json');
|
|
454
|
+
assert.ok(existsSync(filePath));
|
|
455
|
+
const content = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
456
|
+
assert.strictEqual(content['root-diskcheck'], 'written');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('corrupted settings.json falls back to initial value', async () => {
|
|
460
|
+
const setting = new AppSetting({ id: 'root' } as any, 'corrupt', {
|
|
461
|
+
name: '/app/corrupt',
|
|
462
|
+
value: 'fallback',
|
|
463
|
+
});
|
|
464
|
+
// Corrupt the shared settings file
|
|
465
|
+
const { writeFileSync } = await import('node:fs');
|
|
466
|
+
writeFileSync(join('.bb-data', 'settings.json'), 'not valid json{{{');
|
|
467
|
+
|
|
468
|
+
assert.strictEqual(await setting.get(), 'fallback');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
474
|
+
// Error constants
|
|
475
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
476
|
+
|
|
477
|
+
describe('Error constants', () => {
|
|
478
|
+
test('AppSettingErrors has ParameterNotFound', () => {
|
|
479
|
+
assert.strictEqual(AppSettingErrors.ParameterNotFound, 'ParameterNotFoundException');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('AppSettingErrors has ValidationFailed', () => {
|
|
483
|
+
assert.strictEqual(AppSettingErrors.ValidationFailed, 'ValidationFailedException');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test('AppSettingErrors is typed as const', () => {
|
|
487
|
+
// as const provides compile-time literal types, not runtime freezing
|
|
488
|
+
// Verify the values are string literals (not just 'string')
|
|
489
|
+
const pnf: 'ParameterNotFoundException' = AppSettingErrors.ParameterNotFound;
|
|
490
|
+
const vf: 'ValidationFailedException' = AppSettingErrors.ValidationFailed;
|
|
491
|
+
assert.ok(pnf);
|
|
492
|
+
assert.ok(vf);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
497
|
+
// Scope integration
|
|
498
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
499
|
+
|
|
500
|
+
describe('Scope integration', () => {
|
|
501
|
+
test('fullId generation with parent', () => {
|
|
502
|
+
const setting = new AppSetting({ id: 'parent' } as any, 'child', {
|
|
503
|
+
name: '/app/child',
|
|
504
|
+
value: 'test',
|
|
505
|
+
});
|
|
506
|
+
assert.strictEqual(setting.fullId, 'parent-child');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('extends Scope', () => {
|
|
510
|
+
const setting = new AppSetting({ id: 'root' } as any, 'test', {
|
|
511
|
+
name: '/app/test',
|
|
512
|
+
value: 'v',
|
|
513
|
+
});
|
|
514
|
+
assert.ok('fullId' in setting);
|
|
515
|
+
assert.ok('id' in setting);
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
520
|
+
// Default name (omitted name option)
|
|
521
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
522
|
+
|
|
523
|
+
describe('Default name (omitted name option)', () => {
|
|
524
|
+
test('constructor works without name option', () => {
|
|
525
|
+
const setting = new AppSetting({ id: 'root' } as any, 'no-name', {
|
|
526
|
+
value: 'hello',
|
|
527
|
+
});
|
|
528
|
+
assert.ok(setting);
|
|
529
|
+
assert.strictEqual(setting.fullId, 'root-no-name');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('get returns initial value when name is omitted', async () => {
|
|
533
|
+
const setting = new AppSetting({ id: 'app' } as any, 'setting', {
|
|
534
|
+
value: 'default-works',
|
|
535
|
+
});
|
|
536
|
+
assert.strictEqual(await setting.get(), 'default-works');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test('put then get works when name is omitted', async () => {
|
|
540
|
+
const setting = new AppSetting({ id: 'app' } as any, 'rw', {
|
|
541
|
+
value: 'initial',
|
|
542
|
+
});
|
|
543
|
+
await setting.put('updated');
|
|
544
|
+
assert.strictEqual(await setting.get(), 'updated');
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('schema validation works when name is omitted', async () => {
|
|
548
|
+
const setting = new AppSetting({ id: 'app' } as any, 'typed', {
|
|
549
|
+
value: { maxRetries: 1, timeout: 100 },
|
|
550
|
+
schema: configSchema,
|
|
551
|
+
});
|
|
552
|
+
assert.deepStrictEqual(await setting.get(), { maxRetries: 1, timeout: 100 });
|
|
553
|
+
await setting.put({ maxRetries: 5, timeout: 500 });
|
|
554
|
+
assert.deepStrictEqual(await setting.get(), { maxRetries: 5, timeout: 500 });
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('secret works when name is omitted', async () => {
|
|
558
|
+
const setting = new AppSetting({ id: 'app' } as any, 'secret', {
|
|
559
|
+
secret: true,
|
|
560
|
+
});
|
|
561
|
+
const val = await setting.get();
|
|
562
|
+
assert.ok(typeof val === 'string' && val.length > 0);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('explicit name still works as override', async () => {
|
|
566
|
+
const setting = new AppSetting({ id: 'app' } as any, 'explicit', {
|
|
567
|
+
name: '/custom/path',
|
|
568
|
+
value: 'override',
|
|
569
|
+
});
|
|
570
|
+
assert.strictEqual(await setting.get(), 'override');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test('two AppSettings without name have independent storage', async () => {
|
|
574
|
+
const s1 = new AppSetting({ id: 'root' } as any, 'alpha', { value: 'a' });
|
|
575
|
+
const s2 = new AppSetting({ id: 'root' } as any, 'beta', { value: 'b' });
|
|
576
|
+
await s1.put('updated-a');
|
|
577
|
+
assert.strictEqual(await s1.get(), 'updated-a');
|
|
578
|
+
assert.strictEqual(await s2.get(), 'b');
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
583
|
+
// Edge cases
|
|
584
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
585
|
+
|
|
586
|
+
describe('Edge cases', () => {
|
|
587
|
+
test('multiple puts in sequence all succeed', async () => {
|
|
588
|
+
const setting = new AppSetting({ id: 'root' } as any, 'multi', {
|
|
589
|
+
name: '/app/multi',
|
|
590
|
+
value: '',
|
|
591
|
+
});
|
|
592
|
+
for (let i = 0; i < 20; i++) {
|
|
593
|
+
await setting.put(`value-${i}`);
|
|
594
|
+
}
|
|
595
|
+
assert.strictEqual(await setting.get(), 'value-19');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('get is idempotent — multiple calls return same value', async () => {
|
|
599
|
+
const setting = new AppSetting({ id: 'root' } as any, 'idem', {
|
|
600
|
+
name: '/app/idem',
|
|
601
|
+
value: 'stable',
|
|
602
|
+
});
|
|
603
|
+
assert.strictEqual(await setting.get(), 'stable');
|
|
604
|
+
assert.strictEqual(await setting.get(), 'stable');
|
|
605
|
+
assert.strictEqual(await setting.get(), 'stable');
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test('value with special JSON characters round-trips correctly', async () => {
|
|
609
|
+
const setting = new AppSetting({ id: 'root' } as any, 'special', {
|
|
610
|
+
name: '/app/special',
|
|
611
|
+
value: '',
|
|
612
|
+
});
|
|
613
|
+
const special = '{"key": "value", "nested": [1,2,3]}';
|
|
614
|
+
await setting.put(special);
|
|
615
|
+
assert.strictEqual(await setting.get(), special);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test('newlines and tabs in string values preserved', async () => {
|
|
619
|
+
const setting = new AppSetting({ id: 'root' } as any, 'whitespace', {
|
|
620
|
+
name: '/app/whitespace',
|
|
621
|
+
value: '',
|
|
622
|
+
});
|
|
623
|
+
const value = 'line1\nline2\ttab';
|
|
624
|
+
await setting.put(value);
|
|
625
|
+
assert.strictEqual(await setting.get(), value);
|
|
626
|
+
});
|
|
627
|
+
});
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared types for AppSetting Building Block.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* This file is the canonical source for all public types and interfaces.
|
|
9
|
+
* Both `index.mock.ts` and `index.aws.ts` re-export from here.
|
|
10
|
+
*/
|
|
11
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
12
|
+
import type { ChildLogger } from '@aws-blocks/bb-logger';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration options for creating an AppSetting.
|
|
16
|
+
*/
|
|
17
|
+
export interface AppSettingOptions<T = string> {
|
|
18
|
+
/**
|
|
19
|
+
* SSM parameter path. Optional — when omitted, derived from the scope tree
|
|
20
|
+
* as `/${fullId}`, guaranteeing uniqueness within the stack.
|
|
21
|
+
*
|
|
22
|
+
* When providing an explicit name, ensure it is unique across all stacks
|
|
23
|
+
* deployed to the same AWS account to avoid collisions.
|
|
24
|
+
*/
|
|
25
|
+
name?: string;
|
|
26
|
+
/** The value of the SSM parameter. Set during CDK deployment and can be updated at runtime via `put()`. Required for non-secret parameters. Must not be provided for secrets. */
|
|
27
|
+
value?: T;
|
|
28
|
+
/** Runtime validation schema. Accepts any StandardSchemaV1 implementation (Zod, Valibot, ArkType). When provided, T is inferred from the schema. */
|
|
29
|
+
schema?: StandardSchemaV1<T>;
|
|
30
|
+
/** When true, creates an SSM SecureString parameter encrypted with the default aws/ssm KMS key. */
|
|
31
|
+
secret?: boolean;
|
|
32
|
+
/** Optional logger for internal operations. When omitted, a default Logger at error level is created. */
|
|
33
|
+
logger?: ChildLogger;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Package-internal options. Not exported from the package's public entry points,
|
|
38
|
+
* so `external` is never part of the public API — it is set ONLY by
|
|
39
|
+
* {@link AppSetting.fromExisting} and read by the constructors. (Mirrors how
|
|
40
|
+
* `KVStore`/`DistributedTable` model "existing" via a branded ref rather than a
|
|
41
|
+
* public boolean.)
|
|
42
|
+
*/
|
|
43
|
+
export interface InternalAppSettingOptions<T = string> extends AppSettingOptions<T> {
|
|
44
|
+
/**
|
|
45
|
+
* Marks the SSM parameter as **owned and created externally** — the construct
|
|
46
|
+
* will NOT create, seed, tag, or delete it; it only grants the app read-only
|
|
47
|
+
* access (`ssm:GetParameter`, plus `kms:Decrypt` for secrets) and registers the
|
|
48
|
+
* name for config resolution. Requires `name`, forbids `value`.
|
|
49
|
+
*
|
|
50
|
+
* Precondition: the parameter MUST already exist at deploy time — this construct
|
|
51
|
+
* does not create it, so if the external provisioner did not run (e.g. a raw
|
|
52
|
+
* `cdk deploy` that skipped `ensureSecrets`) the deploy succeeds but the app
|
|
53
|
+
* fails at runtime with `ParameterNotFound`.
|
|
54
|
+
*/
|
|
55
|
+
external?: boolean;
|
|
56
|
+
}
|
package/src/version.ts
ADDED