@chaim-tools/cdk-lib 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.
Files changed (39) hide show
  1. package/README.md +238 -0
  2. package/lib/binders/base-chaim-binder.d.ts +144 -0
  3. package/lib/binders/base-chaim-binder.js +532 -0
  4. package/lib/binders/chaim-dynamodb-binder.d.ts +95 -0
  5. package/lib/binders/chaim-dynamodb-binder.js +292 -0
  6. package/lib/config/chaim-endpoints.d.ts +47 -0
  7. package/lib/config/chaim-endpoints.js +51 -0
  8. package/lib/index.d.ts +15 -0
  9. package/lib/index.js +43 -0
  10. package/lib/lambda-handler/.test-temp/snapshot.json +1 -0
  11. package/lib/lambda-handler/handler.js +513 -0
  12. package/lib/lambda-handler/handler.test.ts +365 -0
  13. package/lib/lambda-handler/package-lock.json +1223 -0
  14. package/lib/lambda-handler/package.json +14 -0
  15. package/lib/services/ingestion-service.d.ts +50 -0
  16. package/lib/services/ingestion-service.js +81 -0
  17. package/lib/services/os-cache-paths.d.ts +52 -0
  18. package/lib/services/os-cache-paths.js +123 -0
  19. package/lib/services/schema-service.d.ts +11 -0
  20. package/lib/services/schema-service.js +67 -0
  21. package/lib/services/snapshot-cleanup.d.ts +78 -0
  22. package/lib/services/snapshot-cleanup.js +220 -0
  23. package/lib/types/base-binder-props.d.ts +32 -0
  24. package/lib/types/base-binder-props.js +17 -0
  25. package/lib/types/credentials.d.ts +57 -0
  26. package/lib/types/credentials.js +83 -0
  27. package/lib/types/data-store-metadata.d.ts +67 -0
  28. package/lib/types/data-store-metadata.js +4 -0
  29. package/lib/types/failure-mode.d.ts +16 -0
  30. package/lib/types/failure-mode.js +21 -0
  31. package/lib/types/ingest-contract.d.ts +110 -0
  32. package/lib/types/ingest-contract.js +12 -0
  33. package/lib/types/snapshot-cache-policy.d.ts +52 -0
  34. package/lib/types/snapshot-cache-policy.js +57 -0
  35. package/lib/types/snapshot-payload.d.ts +245 -0
  36. package/lib/types/snapshot-payload.js +3 -0
  37. package/lib/types/table-binding-config.d.ts +43 -0
  38. package/lib/types/table-binding-config.js +57 -0
  39. package/package.json +67 -0
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Unit tests for Chaim Ingestion Lambda Handler
3
+ *
4
+ * Note: The Lambda handler is CommonJS and makes real HTTP requests.
5
+ * These tests focus on:
6
+ * - Response structure validation
7
+ * - Environment variable handling
8
+ * - Error handling paths
9
+ *
10
+ * Full integration tests with mocked API responses should be in the integration test suite.
11
+ */
12
+
13
+ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
14
+ import crypto from 'crypto';
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+
18
+ // Type definitions
19
+ interface CloudFormationEvent {
20
+ RequestType: 'Create' | 'Update' | 'Delete';
21
+ }
22
+
23
+ interface HandlerResponse {
24
+ PhysicalResourceId: string;
25
+ Data: {
26
+ EventId: string;
27
+ IngestStatus: string;
28
+ Action: string;
29
+ Timestamp: string;
30
+ ContentHash?: string;
31
+ Error?: string;
32
+ };
33
+ }
34
+
35
+ type HandlerFn = (event: CloudFormationEvent, context: object) => Promise<HandlerResponse>;
36
+
37
+ let handler: HandlerFn;
38
+ let snapshotPath: string;
39
+
40
+ describe('Lambda Handler', () => {
41
+ const mockSnapshotPayload = {
42
+ schemaVersion: '1.0',
43
+ provider: 'aws',
44
+ accountId: '123456789012',
45
+ region: 'us-east-1',
46
+ stackName: 'TestStack',
47
+ datastoreType: 'dynamodb',
48
+ resourceName: 'TestTable',
49
+ resourceId: 'TestTable__User',
50
+ appId: 'test-app',
51
+ schema: { schemaVersion: '1.0', namespace: 'test' },
52
+ dataStore: {
53
+ type: 'dynamodb',
54
+ arn: 'arn:aws:dynamodb:us-east-1:123456789012:table/TestTable',
55
+ name: 'TestTable',
56
+ },
57
+ context: { account: '123456789012', region: 'us-east-1' },
58
+ capturedAt: '2024-01-01T00:00:00.000Z',
59
+ };
60
+
61
+ const originalEnv = process.env;
62
+
63
+ beforeAll(async () => {
64
+ snapshotPath = path.join(process.cwd(), 'snapshot.json');
65
+ fs.writeFileSync(snapshotPath, JSON.stringify(mockSnapshotPayload));
66
+
67
+ // Dynamically import the CommonJS handler
68
+ const handlerModule = await import('./handler.js');
69
+ handler = handlerModule.handler;
70
+ });
71
+
72
+ afterAll(() => {
73
+ if (fs.existsSync(snapshotPath)) {
74
+ fs.unlinkSync(snapshotPath);
75
+ }
76
+ });
77
+
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ process.env = { ...originalEnv };
81
+ process.env.API_KEY = 'test-api-key';
82
+ process.env.API_SECRET = 'test-api-secret';
83
+ process.env.CHAIM_API_BASE_URL = 'https://api.test.chaim.co';
84
+ process.env.FAILURE_MODE = 'BEST_EFFORT';
85
+
86
+ fs.writeFileSync(snapshotPath, JSON.stringify(mockSnapshotPayload));
87
+ });
88
+
89
+ afterEach(() => {
90
+ process.env = originalEnv;
91
+ });
92
+
93
+ describe('Credential validation', () => {
94
+ it('should throw error in STRICT mode when credentials are missing', async () => {
95
+ delete process.env.API_KEY;
96
+ delete process.env.API_SECRET;
97
+ delete process.env.SECRET_NAME;
98
+ process.env.FAILURE_MODE = 'STRICT';
99
+
100
+ await expect(handler({ RequestType: 'Create' }, {})).rejects.toThrow(
101
+ 'Missing credentials: provide SECRET_NAME or API_KEY/API_SECRET'
102
+ );
103
+ });
104
+
105
+ it('should return FAILED status in BEST_EFFORT mode when credentials are missing', async () => {
106
+ delete process.env.API_KEY;
107
+ delete process.env.API_SECRET;
108
+ delete process.env.SECRET_NAME;
109
+ process.env.FAILURE_MODE = 'BEST_EFFORT';
110
+
111
+ const result = await handler({ RequestType: 'Create' }, {});
112
+
113
+ expect(result.Data.IngestStatus).toBe('FAILED');
114
+ expect(result.Data.Error).toContain('Missing credentials');
115
+ });
116
+ });
117
+
118
+ describe('Snapshot size validation', () => {
119
+ it('should throw error in STRICT mode when snapshot exceeds max size', async () => {
120
+ process.env.FAILURE_MODE = 'STRICT';
121
+ process.env.CHAIM_MAX_SNAPSHOT_BYTES = '100';
122
+
123
+ const largePayload = { ...mockSnapshotPayload, largeData: 'x'.repeat(200) };
124
+ fs.writeFileSync(snapshotPath, JSON.stringify(largePayload));
125
+
126
+ await expect(handler({ RequestType: 'Create' }, {})).rejects.toThrow(
127
+ /Snapshot size .* exceeds maximum allowed/
128
+ );
129
+ });
130
+
131
+ it('should return FAILED status in BEST_EFFORT mode when snapshot exceeds max size', async () => {
132
+ process.env.FAILURE_MODE = 'BEST_EFFORT';
133
+ process.env.CHAIM_MAX_SNAPSHOT_BYTES = '100';
134
+
135
+ const largePayload = { ...mockSnapshotPayload, largeData: 'x'.repeat(200) };
136
+ fs.writeFileSync(snapshotPath, JSON.stringify(largePayload));
137
+
138
+ const result = await handler({ RequestType: 'Create' }, {});
139
+
140
+ expect(result.Data.IngestStatus).toBe('FAILED');
141
+ expect(result.Data.Error).toContain('Snapshot size');
142
+ });
143
+ });
144
+
145
+ describe('Response structure', () => {
146
+ it('should return response with PhysicalResourceId for Create', async () => {
147
+ // This test will fail due to network error, but in BEST_EFFORT mode
148
+ // it should still return a valid response structure
149
+ const result = await handler({ RequestType: 'Create' }, {});
150
+
151
+ expect(result).toHaveProperty('PhysicalResourceId');
152
+ expect(result).toHaveProperty('Data');
153
+ expect(result.Data).toHaveProperty('EventId');
154
+ expect(result.Data).toHaveProperty('IngestStatus');
155
+ expect(result.Data).toHaveProperty('Action');
156
+ expect(result.Data.Action).toBe('UPSERT');
157
+ });
158
+
159
+ it('should return response with PhysicalResourceId for Delete', async () => {
160
+ const result = await handler({ RequestType: 'Delete' }, {});
161
+
162
+ expect(result).toHaveProperty('PhysicalResourceId');
163
+ expect(result).toHaveProperty('Data');
164
+ expect(result.Data).toHaveProperty('EventId');
165
+ expect(result.Data).toHaveProperty('IngestStatus');
166
+ expect(result.Data).toHaveProperty('Action');
167
+ expect(result.Data.Action).toBe('DELETE');
168
+ });
169
+
170
+ it('should include Timestamp in response', async () => {
171
+ const result = await handler({ RequestType: 'Delete' }, {});
172
+
173
+ expect(result.Data).toHaveProperty('Timestamp');
174
+ expect(result.Data.Timestamp).toBeDefined();
175
+ });
176
+
177
+ it('should send DELETE snapshot through presigned upload when ChaimBinder removed', async () => {
178
+ // This test verifies the Lambda sends DELETE snapshot through presign flow
179
+ // In a real scenario, this would be mocked, but here we're just
180
+ // verifying the flow completes (may fail due to network call)
181
+
182
+ // Note: This will likely fail in CI because it tries to make real HTTP requests
183
+ // For proper testing, we'd need to mock the httpRequest function
184
+ // For now, we just verify the response structure
185
+
186
+ const result = await handler({ RequestType: 'Delete' }, {});
187
+
188
+ expect(result).toHaveProperty('PhysicalResourceId');
189
+ expect(result.Data).toHaveProperty('EventId');
190
+ expect(result.Data).toHaveProperty('IngestStatus');
191
+ expect(result.Data.Action).toBe('DELETE');
192
+
193
+ // The IngestStatus might be FAILED if the API is unreachable,
194
+ // but the structure should still be correct
195
+ expect(['SUCCESS', 'FAILED']).toContain(result.Data.IngestStatus);
196
+ });
197
+ });
198
+
199
+ describe('ContentHash computation', () => {
200
+ it('should compute contentHash as SHA-256 of snapshot bytes', async () => {
201
+ // The handler enhances the snapshot with operation and producer metadata
202
+ // So we need to compute the hash of the enhanced snapshot, not the original
203
+ const result = await handler({ RequestType: 'Create' }, {});
204
+
205
+ // ContentHash should be computed and present
206
+ expect(result.Data.ContentHash).toBeDefined();
207
+ expect(result.Data.ContentHash).toMatch(/^sha256:[a-f0-9]{64}$/);
208
+ });
209
+
210
+ it('should include contentHash in response when available', async () => {
211
+ // ContentHash is computed from snapshot bytes
212
+ const snapshotBytes = JSON.stringify(mockSnapshotPayload);
213
+ const expectedHash = 'sha256:' + crypto.createHash('sha256').update(snapshotBytes).digest('hex');
214
+
215
+ const result = await handler({ RequestType: 'Delete' }, {});
216
+
217
+ // ContentHash may or may not be present depending on the error path
218
+ // but when computed, it should be the correct value
219
+ if (result.Data.ContentHash) {
220
+ expect(result.Data.ContentHash).toBe(expectedHash);
221
+ }
222
+ });
223
+ });
224
+
225
+ describe('EventId generation', () => {
226
+ it('should generate unique eventId for each invocation', async () => {
227
+ const result1 = await handler({ RequestType: 'Delete' }, {});
228
+ const result2 = await handler({ RequestType: 'Delete' }, {});
229
+
230
+ expect(result1.Data.EventId).not.toBe(result2.Data.EventId);
231
+ });
232
+
233
+ it('should use eventId as PhysicalResourceId', async () => {
234
+ const result = await handler({ RequestType: 'Delete' }, {});
235
+
236
+ expect(result.PhysicalResourceId).toBe(result.Data.EventId);
237
+ });
238
+
239
+ it('should generate valid UUID format for eventId', async () => {
240
+ const result = await handler({ RequestType: 'Delete' }, {});
241
+
242
+ // UUID v4 format regex
243
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
244
+ expect(result.Data.EventId).toMatch(uuidRegex);
245
+ });
246
+ });
247
+
248
+ describe('v1.1 Operation Metadata', () => {
249
+ it('should include cfRequestId in operation metadata when available', async () => {
250
+ // Note: This test verifies structure even though API may fail
251
+ const result = await handler({
252
+ RequestType: 'Delete',
253
+ RequestId: 'cf-test-request-id-123'
254
+ }, {});
255
+
256
+ expect(result).toBeDefined();
257
+ expect(result.Data.EventId).toBeDefined();
258
+ });
259
+
260
+ it('should include requestType in response', async () => {
261
+ const result = await handler({ RequestType: 'Create' }, {});
262
+
263
+ expect(result).toBeDefined();
264
+ expect(result.Data.Action).toBe('UPSERT');
265
+ });
266
+ });
267
+
268
+ describe('v1.1 Producer Metadata', () => {
269
+ it('should read package version from snapshot _packageVersion field', async () => {
270
+ // Modify snapshot to include version
271
+ const snapshotWithVersion = { ...mockSnapshotPayload, _packageVersion: '1.2.3' };
272
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshotWithVersion));
273
+
274
+ const result = await handler({ RequestType: 'Delete' }, {});
275
+
276
+ expect(result).toBeDefined();
277
+ // Version should be used by Lambda to populate producer metadata
278
+ });
279
+ });
280
+
281
+ describe('v1.1 Delete Context Inference', () => {
282
+ it('should infer BINDER_REMOVED as default deletion reason', async () => {
283
+ const result = await handler({
284
+ RequestType: 'Delete',
285
+ LogicalResourceId: 'TestBinder',
286
+ }, {});
287
+
288
+ expect(result).toBeDefined();
289
+ expect(result.Data.Action).toBe('DELETE');
290
+ });
291
+
292
+ it('should detect stack deletion from ResourceProperties', async () => {
293
+ const result = await handler({
294
+ RequestType: 'Delete',
295
+ ResourceProperties: {
296
+ StackStatus: 'DELETE_IN_PROGRESS'
297
+ }
298
+ }, {});
299
+
300
+ expect(result).toBeDefined();
301
+ expect(result.Data.Action).toBe('DELETE');
302
+ });
303
+
304
+ it('should include deletedAt timestamp for DELETE actions', async () => {
305
+ const result = await handler({ RequestType: 'Delete' }, {});
306
+
307
+ expect(result).toBeDefined();
308
+ expect(result.Data.Timestamp).toBeDefined();
309
+ });
310
+ });
311
+
312
+ describe('Failure mode behavior', () => {
313
+ it('should default to BEST_EFFORT when FAILURE_MODE is not set', async () => {
314
+ delete process.env.FAILURE_MODE;
315
+
316
+ // This should NOT throw even if the API call fails
317
+ const result = await handler({ RequestType: 'Create' }, {});
318
+
319
+ expect(result).toBeDefined();
320
+ expect(result.Data.IngestStatus).toBeDefined();
321
+ });
322
+
323
+ it('should include error message in FAILED response', async () => {
324
+ process.env.FAILURE_MODE = 'BEST_EFFORT';
325
+
326
+ const result = await handler({ RequestType: 'Create' }, {});
327
+
328
+ // Should have an error since the API is not mocked
329
+ if (result.Data.IngestStatus === 'FAILED') {
330
+ expect(result.Data.Error).toBeDefined();
331
+ }
332
+ });
333
+ });
334
+
335
+ describe('Environment variable handling', () => {
336
+ it('should read API_KEY from environment', async () => {
337
+ process.env.API_KEY = 'custom-key';
338
+
339
+ // The handler should try to use this key
340
+ // We can't fully verify without network mocking, but we can verify no credential error
341
+ const result = await handler({ RequestType: 'Delete' }, {});
342
+
343
+ // Should not fail with credential error
344
+ expect(result.Data.Error || '').not.toContain('Missing credentials');
345
+ });
346
+
347
+ it('should read CHAIM_API_BASE_URL from environment', async () => {
348
+ process.env.CHAIM_API_BASE_URL = 'https://custom.api.example.com';
349
+
350
+ const result = await handler({ RequestType: 'Delete' }, {});
351
+
352
+ // Handler should execute without throwing
353
+ expect(result).toBeDefined();
354
+ });
355
+
356
+ it('should use default API base URL when env var not set', async () => {
357
+ delete process.env.CHAIM_API_BASE_URL;
358
+
359
+ const result = await handler({ RequestType: 'Delete' }, {});
360
+
361
+ // Handler should execute without throwing
362
+ expect(result).toBeDefined();
363
+ });
364
+ });
365
+ });