@friggframework/devtools 2.0.0--canary.474.898a56c.0 → 2.0.0--canary.474.a794ea3.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 (23) hide show
  1. package/infrastructure/domains/database/migration-builder.js +199 -1
  2. package/infrastructure/domains/database/migration-builder.test.js +73 -0
  3. package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
  4. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +397 -29
  5. package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
  6. package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +6 -0
  7. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +162 -9
  8. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +19 -1
  9. package/infrastructure/domains/health/domain/entities/issue.js +50 -1
  10. package/infrastructure/domains/health/domain/entities/issue.test.js +111 -0
  11. package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
  12. package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
  13. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +55 -28
  14. package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
  15. package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
  16. package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
  17. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +21 -6
  18. package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
  19. package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
  20. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +407 -20
  21. package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +698 -26
  22. package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +392 -1
  23. package/package.json +6 -6
@@ -0,0 +1,221 @@
1
+ /**
2
+ * ExecuteResourceImportUseCase - Execute CloudFormation Resource Import
3
+ *
4
+ * Application Layer - Use Case
5
+ *
6
+ * Business logic for executing CloudFormation import operations for orphaned resources.
7
+ * Orchestrates the complete import workflow including template generation, change set
8
+ * creation, execution monitoring, and verification.
9
+ *
10
+ * Responsibilities:
11
+ * - Generate import template from resources and build template
12
+ * - Create and execute CloudFormation import change set
13
+ * - Monitor import progress with resource-level updates
14
+ * - Verify imported resources are present in stack
15
+ * - Report detailed progress and results
16
+ *
17
+ * Based on: SPEC-IMPORT-EXECUTION.md (lines 712-867)
18
+ */
19
+
20
+ class ExecuteResourceImportUseCase {
21
+ /**
22
+ * Create use case with required dependencies
23
+ *
24
+ * @param {Object} params
25
+ * @param {Object} params.importTemplateGenerator - Generates import templates
26
+ * @param {Object} params.importProgressMonitor - Monitors import operations
27
+ * @param {Object} params.cloudFormationRepository - CloudFormation operations
28
+ * @param {Object} params.stackRepository - Stack template operations
29
+ */
30
+ constructor({ importTemplateGenerator, importProgressMonitor, cloudFormationRepository, stackRepository }) {
31
+ if (!importTemplateGenerator) {
32
+ throw new Error('importTemplateGenerator is required');
33
+ }
34
+ if (!importProgressMonitor) {
35
+ throw new Error('importProgressMonitor is required');
36
+ }
37
+ if (!cloudFormationRepository) {
38
+ throw new Error('cloudFormationRepository is required');
39
+ }
40
+ if (!stackRepository) {
41
+ throw new Error('stackRepository is required');
42
+ }
43
+
44
+ this.templateGenerator = importTemplateGenerator;
45
+ this.progressMonitor = importProgressMonitor;
46
+ this.cfRepo = cloudFormationRepository;
47
+ this.stackRepo = stackRepository;
48
+ }
49
+
50
+ /**
51
+ * Execute complete CloudFormation import workflow
52
+ *
53
+ * Orchestrates the full import process:
54
+ * 1. Generate import template
55
+ * 2. Create CloudFormation change set
56
+ * 3. Wait for change set to be ready
57
+ * 4. Execute change set
58
+ * 5. Monitor import progress
59
+ * 6. Verify imported resources
60
+ *
61
+ * @param {Object} params
62
+ * @param {Object} params.stackIdentifier - Stack name and region
63
+ * @param {Array<Object>} params.resourcesToImport - Resources to import
64
+ * @param {string} params.buildTemplatePath - Path to build template
65
+ * @param {Function} [params.onProgress] - Progress callback
66
+ * @returns {Promise<Object>} Import result with verification details
67
+ */
68
+ async execute({ stackIdentifier, resourcesToImport, buildTemplatePath, onProgress }) {
69
+ try {
70
+ // Step 1: Generate import template
71
+ if (onProgress) {
72
+ onProgress({ step: 'generate_template', status: 'in_progress' });
73
+ }
74
+
75
+ const { template, resourceIdentifiers } = await this.templateGenerator.generateImportTemplate({
76
+ resourcesToImport,
77
+ buildTemplatePath,
78
+ stackIdentifier,
79
+ });
80
+
81
+ if (onProgress) {
82
+ onProgress({ step: 'generate_template', status: 'complete' });
83
+ }
84
+
85
+ // Step 2: Create CloudFormation change set
86
+ if (onProgress) {
87
+ onProgress({ step: 'create_change_set', status: 'in_progress' });
88
+ }
89
+
90
+ const changeSetName = `import-orphaned-resources-${Date.now()}`;
91
+ const changeSet = await this.cfRepo.createChangeSet({
92
+ stackIdentifier,
93
+ changeSetName,
94
+ changeSetType: 'IMPORT',
95
+ template,
96
+ resourcesToImport: resourceIdentifiers,
97
+ });
98
+
99
+ if (onProgress) {
100
+ onProgress({
101
+ step: 'create_change_set',
102
+ status: 'complete',
103
+ changeSetName,
104
+ changeSetId: changeSet.Id,
105
+ });
106
+ }
107
+
108
+ // Step 3: Wait for change set to be ready
109
+ if (onProgress) {
110
+ onProgress({ step: 'wait_change_set', status: 'in_progress' });
111
+ }
112
+
113
+ await this.cfRepo.waitForChangeSet({ stackIdentifier, changeSetName });
114
+
115
+ if (onProgress) {
116
+ onProgress({ step: 'wait_change_set', status: 'complete' });
117
+ }
118
+
119
+ // Step 4: Execute change set
120
+ if (onProgress) {
121
+ onProgress({ step: 'execute_import', status: 'in_progress' });
122
+ }
123
+
124
+ await this.cfRepo.executeChangeSet({ stackIdentifier, changeSetName });
125
+
126
+ // Step 5: Monitor import progress
127
+ const resourceLogicalIds = resourcesToImport.map((r) => r.logicalId);
128
+ const importResult = await this.progressMonitor.monitorImport({
129
+ stackIdentifier,
130
+ resourceLogicalIds,
131
+ onProgress: (progress) => {
132
+ if (onProgress) {
133
+ onProgress({
134
+ step: 'execute_import',
135
+ status: 'in_progress',
136
+ resourceProgress: progress,
137
+ });
138
+ }
139
+ },
140
+ });
141
+
142
+ if (onProgress) {
143
+ onProgress({ step: 'execute_import', status: 'complete' });
144
+ }
145
+
146
+ // Step 6: Verify imported resources
147
+ if (onProgress) {
148
+ onProgress({ step: 'verify', status: 'in_progress' });
149
+ }
150
+
151
+ const verification = await this._verifyImportedResources({
152
+ stackIdentifier,
153
+ resourceLogicalIds,
154
+ });
155
+
156
+ if (onProgress) {
157
+ onProgress({ step: 'verify', status: 'complete' });
158
+ }
159
+
160
+ // Return success result with verification details
161
+ return {
162
+ success: true,
163
+ importedCount: importResult.importedCount,
164
+ failedCount: importResult.failedCount,
165
+ changeSetName,
166
+ stackStatus: verification.stackStatus,
167
+ verifiedResources: verification.resources,
168
+ };
169
+ } catch (error) {
170
+ // Return error with step information
171
+ return {
172
+ success: false,
173
+ error: error.message,
174
+ step: error.step || 'unknown',
175
+ };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Verify that imported resources are present in the CloudFormation stack
181
+ *
182
+ * Checks that each logical ID corresponds to an actual resource in the stack
183
+ * and retrieves physical IDs and resource types for verification.
184
+ *
185
+ * @param {Object} params
186
+ * @param {Object} params.stackIdentifier - Stack name and region
187
+ * @param {Array<string>} params.resourceLogicalIds - Logical IDs to verify
188
+ * @returns {Promise<Object>} Verification result with resource details
189
+ * @private
190
+ */
191
+ async _verifyImportedResources({ stackIdentifier, resourceLogicalIds }) {
192
+ // Get all resources from the stack
193
+ const stackResources = await this.cfRepo.getStackResources(stackIdentifier);
194
+
195
+ // Map each logical ID to its verification status
196
+ const verifiedResources = resourceLogicalIds.map((logicalId) => {
197
+ const resource = stackResources.find((r) => r.LogicalResourceId === logicalId);
198
+
199
+ return {
200
+ logicalId,
201
+ verified: !!resource,
202
+ physicalId: resource?.PhysicalResourceId,
203
+ resourceType: resource?.ResourceType,
204
+ };
205
+ });
206
+
207
+ // Check if all resources were verified
208
+ const allVerified = verifiedResources.every((r) => r.verified);
209
+
210
+ // Get current stack status
211
+ const stackStatus = await this.cfRepo.getStackStatus(stackIdentifier);
212
+
213
+ return {
214
+ allVerified,
215
+ resources: verifiedResources,
216
+ stackStatus,
217
+ };
218
+ }
219
+ }
220
+
221
+ module.exports = ExecuteResourceImportUseCase;
@@ -67,6 +67,8 @@ class ReconcilePropertiesUseCase {
67
67
  * @param {Object} params
68
68
  * @param {StackIdentifier} params.stackIdentifier - Stack identifier
69
69
  * @param {string} params.logicalId - Logical resource ID
70
+ * @param {string} [params.physicalId] - Physical resource ID (required for resource mode)
71
+ * @param {string} [params.resourceType] - Resource type (required for resource mode)
70
72
  * @param {PropertyMismatch[]} params.mismatches - Property mismatches to reconcile
71
73
  * @param {string} [params.mode='template'] - Reconciliation mode
72
74
  * @returns {Promise<Object>} Batch reconciliation result
@@ -74,6 +76,8 @@ class ReconcilePropertiesUseCase {
74
76
  async reconcileMultipleProperties({
75
77
  stackIdentifier,
76
78
  logicalId,
79
+ physicalId,
80
+ resourceType,
77
81
  mismatches,
78
82
  mode = 'template',
79
83
  }) {
@@ -107,6 +111,8 @@ class ReconcilePropertiesUseCase {
107
111
  const batchResult = await this.propertyReconciler.reconcileMultipleProperties({
108
112
  stackIdentifier,
109
113
  logicalId,
114
+ physicalId,
115
+ resourceType,
110
116
  mismatches: reconcilableProperties,
111
117
  mode,
112
118
  });
@@ -281,10 +281,7 @@ class RepairViaImportUseCase {
281
281
  deployedTemplate,
282
282
  });
283
283
 
284
- // 5. Check for multiple resources of same type
285
- const multiResourceWarnings = this._checkForMultipleResources(mappings);
286
-
287
- // 6. Filter out unmapped resources and prepare for import
284
+ // 5. Filter out unmapped resources
288
285
  const mappedResources = mappings.filter((m) => m.logicalId !== null);
289
286
  const unmappedResources = mappings.filter((m) => m.logicalId === null);
290
287
 
@@ -297,20 +294,31 @@ class RepairViaImportUseCase {
297
294
  };
298
295
  }
299
296
 
300
- // 7. Generate import-resources.json format
301
- const resourcesToImport = mappedResources.map((mapping) => ({
297
+ // 6. Deduplicate: Select ONE resource per logical ID based on deployed template
298
+ const { selectedResources, duplicates } = this._deduplicateResourcesByLogicalId(
299
+ mappedResources,
300
+ deployedTemplate
301
+ );
302
+
303
+ // 7. Check for warnings
304
+ const multiResourceWarnings = this._checkForMultipleResources(duplicates);
305
+
306
+ // 8. Generate import-resources.json format using SELECTED resources
307
+ const resourcesToImport = selectedResources.map((mapping) => ({
302
308
  ResourceType: mapping.resourceType,
303
309
  LogicalResourceId: mapping.logicalId,
304
310
  ResourceIdentifier: this._getResourceIdentifier(mapping),
305
311
  }));
306
312
 
307
- // 8. Return result with warnings for user review
313
+ // 9. Return result with deduplication info
308
314
  return {
309
315
  success: true,
310
- mappedCount: mappedResources.length,
316
+ mappedCount: selectedResources.length,
311
317
  unmappedCount: unmappedResources.length,
312
- mappings: mappedResources,
318
+ duplicatesRemoved: duplicates.length,
319
+ mappings: selectedResources,
313
320
  unmappedResources,
321
+ duplicates, // Resources that were filtered out
314
322
  resourcesToImport,
315
323
  warnings: multiResourceWarnings,
316
324
  buildTemplatePath,
@@ -318,6 +326,151 @@ class RepairViaImportUseCase {
318
326
  };
319
327
  }
320
328
 
329
+ /**
330
+ * Deduplicate resources: Select ONE resource per logical ID
331
+ * When multiple resources have the same logical ID, pick the one that's
332
+ * actually referenced in the deployed template.
333
+ *
334
+ * @param {Array} mappedResources - Resources with logical IDs
335
+ * @param {Object} deployedTemplate - Deployed CloudFormation template
336
+ * @returns {Object} { selectedResources, duplicates }
337
+ * @private
338
+ */
339
+ _deduplicateResourcesByLogicalId(mappedResources, deployedTemplate) {
340
+ // Group resources by logical ID
341
+ const byLogicalId = {};
342
+ mappedResources.forEach((resource) => {
343
+ if (!byLogicalId[resource.logicalId]) {
344
+ byLogicalId[resource.logicalId] = [];
345
+ }
346
+ byLogicalId[resource.logicalId].push(resource);
347
+ });
348
+
349
+ // Extract all physical IDs referenced in deployed template
350
+ const referencedIds = this._extractReferencedIdsFromTemplate(deployedTemplate);
351
+
352
+ const selectedResources = [];
353
+ const duplicates = [];
354
+
355
+ // For each logical ID, select ONE resource
356
+ Object.entries(byLogicalId).forEach(([logicalId, resources]) => {
357
+ if (resources.length === 1) {
358
+ // Only one resource - select it
359
+ selectedResources.push(resources[0]);
360
+ } else {
361
+ // Multiple resources - pick the one in deployed template
362
+ let selected = null;
363
+
364
+ // Try to find resource that's actually referenced
365
+ for (const resource of resources) {
366
+ if (this._isResourceReferenced(resource, referencedIds)) {
367
+ selected = resource;
368
+ break;
369
+ }
370
+ }
371
+
372
+ // Fallback: If none are referenced, pick first one
373
+ if (!selected) {
374
+ selected = resources[0];
375
+ }
376
+
377
+ selectedResources.push(selected);
378
+
379
+ // Mark others as duplicates
380
+ resources.forEach((r) => {
381
+ if (r.physicalId !== selected.physicalId) {
382
+ duplicates.push(r);
383
+ }
384
+ });
385
+ }
386
+ });
387
+
388
+ return { selectedResources, duplicates };
389
+ }
390
+
391
+ /**
392
+ * Extract all physical resource IDs referenced in deployed template
393
+ * Looks for hardcoded IDs in Lambda VPC configs, security group rules, etc.
394
+ * @private
395
+ */
396
+ _extractReferencedIdsFromTemplate(template) {
397
+ const referenced = {
398
+ vpcIds: new Set(),
399
+ subnetIds: new Set(),
400
+ securityGroupIds: new Set(),
401
+ };
402
+
403
+ if (!template || !template.resources) {
404
+ return referenced;
405
+ }
406
+
407
+ // Traverse all resources in template
408
+ Object.values(template.resources).forEach((resource) => {
409
+ // Lambda VPC config contains hardcoded IDs
410
+ if (
411
+ resource.Type === 'AWS::Lambda::Function' &&
412
+ resource.Properties?.VpcConfig
413
+ ) {
414
+ const { SubnetIds, SecurityGroupIds } = resource.Properties.VpcConfig;
415
+
416
+ if (SubnetIds) {
417
+ SubnetIds.forEach((id) => {
418
+ if (typeof id === 'string' && id.startsWith('subnet-')) {
419
+ referenced.subnetIds.add(id);
420
+ }
421
+ });
422
+ }
423
+
424
+ if (SecurityGroupIds) {
425
+ SecurityGroupIds.forEach((id) => {
426
+ if (typeof id === 'string' && id.startsWith('sg-')) {
427
+ referenced.securityGroupIds.add(id);
428
+ }
429
+ });
430
+ }
431
+ }
432
+
433
+ // Security group rules may reference other security groups
434
+ if (resource.Type === 'AWS::EC2::SecurityGroupIngress' ||
435
+ resource.Type === 'AWS::EC2::SecurityGroupEgress') {
436
+ const groupId = resource.Properties?.GroupId;
437
+ const sourceSecurityGroupId = resource.Properties?.SourceSecurityGroupId;
438
+
439
+ if (typeof groupId === 'string' && groupId.startsWith('sg-')) {
440
+ referenced.securityGroupIds.add(groupId);
441
+ }
442
+ if (typeof sourceSecurityGroupId === 'string' && sourceSecurityGroupId.startsWith('sg-')) {
443
+ referenced.securityGroupIds.add(sourceSecurityGroupId);
444
+ }
445
+ }
446
+ });
447
+
448
+ return referenced;
449
+ }
450
+
451
+ /**
452
+ * Check if a resource is referenced in the deployed template
453
+ * @private
454
+ */
455
+ _isResourceReferenced(resource, referencedIds) {
456
+ const { resourceType, physicalId } = resource;
457
+
458
+ if (resourceType === 'AWS::EC2::VPC') {
459
+ return referencedIds.vpcIds.has(physicalId);
460
+ }
461
+
462
+ if (resourceType === 'AWS::EC2::Subnet') {
463
+ return referencedIds.subnetIds.has(physicalId);
464
+ }
465
+
466
+ if (resourceType === 'AWS::EC2::SecurityGroup') {
467
+ return referencedIds.securityGroupIds.has(physicalId);
468
+ }
469
+
470
+ // For other resource types, we can't determine
471
+ return false;
472
+ }
473
+
321
474
  /**
322
475
  * Check for multiple resources of same type
323
476
  * Returns warnings when user needs to manually select
@@ -19,6 +19,7 @@ const StackHealthReport = require('../../domain/entities/stack-health-report');
19
19
  const Resource = require('../../domain/entities/resource');
20
20
  const Issue = require('../../domain/entities/issue');
21
21
  const ResourceState = require('../../domain/value-objects/resource-state');
22
+ const { getPropertyMutability } = require('../../domain/services/property-mutability-config');
22
23
 
23
24
  class RunHealthCheckUseCase {
24
25
  /**
@@ -112,10 +113,21 @@ class RunHealthCheckUseCase {
112
113
  resourceDrift.propertyDifferences &&
113
114
  resourceDrift.propertyDifferences.length > 0
114
115
  ) {
116
+ // Build property mutability map for this resource type
117
+ // AWS drift detection returns property paths, we need to provide mutability for each
118
+ const propertyMutabilityMap = {};
119
+ for (const propDiff of resourceDrift.propertyDifferences) {
120
+ const propertyPath = propDiff.PropertyPath.replace(/^\//, ''); // Remove leading slash
121
+ propertyMutabilityMap[propertyPath] = getPropertyMutability(
122
+ stackResource.resourceType,
123
+ propertyPath
124
+ );
125
+ }
126
+
115
127
  const propertyMismatches = this.mismatchAnalyzer.analyze({
116
128
  expected: resourceDrift.expectedProperties,
117
129
  actual: resourceDrift.actualProperties,
118
- propertyMutability: {},
130
+ propertyMutability: propertyMutabilityMap,
119
131
  ignoreProperties: [],
120
132
  });
121
133
 
@@ -156,11 +168,17 @@ class RunHealthCheckUseCase {
156
168
 
157
169
  for (const orphan of orphanedResources) {
158
170
  // Create resource entity for orphan
171
+ // IMPORTANT: Include both properties AND tags for template comparison
172
+ // Tags are stored in properties.tags for logical ID mapping
159
173
  const orphanResource = new Resource({
160
174
  logicalId: null, // No logical ID (not in template)
161
175
  physicalId: orphan.physicalId,
162
176
  resourceType: orphan.resourceType,
163
177
  state: ResourceState.ORPHANED,
178
+ properties: {
179
+ ...(orphan.properties || {}), // Include AWS properties
180
+ tags: orphan.tags || {}, // Include tags for logical ID matching
181
+ },
164
182
  });
165
183
 
166
184
  resources.push(orphanResource);
@@ -212,6 +212,51 @@ class Issue {
212
212
  });
213
213
  }
214
214
 
215
+ /**
216
+ * Format a value for display in issue descriptions
217
+ * Handles arrays, objects, and primitive types
218
+ *
219
+ * @private
220
+ * @param {*} value - Value to format
221
+ * @returns {string} Formatted value
222
+ */
223
+ static _formatValue(value) {
224
+ if (value === null || value === undefined) {
225
+ return String(value);
226
+ }
227
+
228
+ // Handle arrays
229
+ if (Array.isArray(value)) {
230
+ // For arrays of objects (like Tags), show count and first few items
231
+ if (value.length > 0 && typeof value[0] === 'object') {
232
+ if (value.length <= 3) {
233
+ return JSON.stringify(value);
234
+ }
235
+ // Show first 2 items + count for long arrays
236
+ const preview = value.slice(0, 2);
237
+ return `${JSON.stringify(preview).slice(0, -1)}, ... (${value.length} total)]`;
238
+ }
239
+ // For simple arrays, stringify
240
+ return JSON.stringify(value);
241
+ }
242
+
243
+ // Handle objects
244
+ if (typeof value === 'object') {
245
+ const keys = Object.keys(value);
246
+ if (keys.length === 0) {
247
+ return '{}';
248
+ }
249
+ if (keys.length <= 3) {
250
+ return JSON.stringify(value);
251
+ }
252
+ // For large objects, show keys count
253
+ return `{${keys.slice(0, 3).join(', ')}, ... (${keys.length} keys total)}`;
254
+ }
255
+
256
+ // Primitives
257
+ return String(value);
258
+ }
259
+
215
260
  /**
216
261
  * Create a property mismatch issue
217
262
  *
@@ -228,7 +273,11 @@ class Issue {
228
273
 
229
274
  const canAutoFix = mismatch.canAutoFix();
230
275
 
231
- const description = `Property mismatch: ${mismatch.propertyPath} (expected: ${mismatch.expectedValue}, actual: ${mismatch.actualValue})`;
276
+ // Format expected and actual values for display
277
+ const formattedExpected = Issue._formatValue(mismatch.expectedValue);
278
+ const formattedActual = Issue._formatValue(mismatch.actualValue);
279
+
280
+ const description = `Property mismatch: ${mismatch.propertyPath} (expected: ${formattedExpected}, actual: ${formattedActual})`;
232
281
 
233
282
  const resolution = canAutoFix
234
283
  ? 'Can be auto-fixed using frigg repair --reconcile'
@@ -414,4 +414,115 @@ describe('Issue', () => {
414
414
  expect(issue.canAutoFix).toBe(false); // Can't auto-fix immutable
415
415
  });
416
416
  });
417
+
418
+ describe('_formatValue', () => {
419
+ it('should format primitive values', () => {
420
+ expect(Issue._formatValue('test')).toBe('test');
421
+ expect(Issue._formatValue(123)).toBe('123');
422
+ expect(Issue._formatValue(true)).toBe('true');
423
+ expect(Issue._formatValue(null)).toBe('null');
424
+ expect(Issue._formatValue(undefined)).toBe('undefined');
425
+ });
426
+
427
+ it('should format simple arrays', () => {
428
+ expect(Issue._formatValue(['a', 'b', 'c'])).toBe('["a","b","c"]');
429
+ expect(Issue._formatValue([1, 2, 3])).toBe('[1,2,3]');
430
+ });
431
+
432
+ it('should format arrays of objects (like Tags)', () => {
433
+ const tags = [
434
+ { Key: 'Name', Value: 'test' },
435
+ { Key: 'Environment', Value: 'prod' },
436
+ ];
437
+ const result = Issue._formatValue(tags);
438
+ expect(result).toContain('Key');
439
+ expect(result).toContain('Name');
440
+ expect(result).toContain('test');
441
+ });
442
+
443
+ it('should truncate long arrays of objects', () => {
444
+ const manyTags = [
445
+ { Key: 'Tag1', Value: 'val1' },
446
+ { Key: 'Tag2', Value: 'val2' },
447
+ { Key: 'Tag3', Value: 'val3' },
448
+ { Key: 'Tag4', Value: 'val4' },
449
+ ];
450
+ const result = Issue._formatValue(manyTags);
451
+ expect(result).toContain('4 total');
452
+ expect(result).toContain('...');
453
+ });
454
+
455
+ it('should format small objects', () => {
456
+ const obj = { a: 1, b: 2 };
457
+ expect(Issue._formatValue(obj)).toBe('{"a":1,"b":2}');
458
+ });
459
+
460
+ it('should truncate large objects', () => {
461
+ const largeObj = { a: 1, b: 2, c: 3, d: 4, e: 5 };
462
+ const result = Issue._formatValue(largeObj);
463
+ expect(result).toContain('5 keys total');
464
+ expect(result).toContain('...');
465
+ });
466
+
467
+ it('should format empty collections', () => {
468
+ expect(Issue._formatValue([])).toBe('[]');
469
+ expect(Issue._formatValue({})).toBe('{}');
470
+ });
471
+ });
472
+
473
+ describe('propertyMismatch with formatted values', () => {
474
+ it('should format Tags arrays correctly in description', () => {
475
+ const mismatch = new PropertyMismatch({
476
+ propertyPath: 'Properties.Tags',
477
+ expectedValue: [
478
+ { Key: 'Name', Value: 'test' },
479
+ { Key: 'Environment', Value: 'prod' },
480
+ ],
481
+ actualValue: [
482
+ { Key: 'Name', Value: 'test' },
483
+ { Key: 'Environment', Value: 'prod' },
484
+ { Key: 'ManagedBy', Value: 'Frigg' },
485
+ ],
486
+ mutability: PropertyMutability.MUTABLE,
487
+ });
488
+
489
+ const issue = Issue.propertyMismatch({
490
+ resourceType: 'AWS::EC2::Subnet',
491
+ resourceId: 'subnet-123',
492
+ mismatch,
493
+ });
494
+
495
+ // Should not contain [object Object]
496
+ expect(issue.description).not.toContain('[object Object]');
497
+ // Should contain JSON representation
498
+ expect(issue.description).toContain('Key');
499
+ expect(issue.description).toContain('Name');
500
+ });
501
+
502
+ it('should handle complex nested objects', () => {
503
+ const mismatch = new PropertyMismatch({
504
+ propertyPath: 'Properties.VpcConfig',
505
+ expectedValue: {
506
+ SubnetIds: ['subnet-1', 'subnet-2'],
507
+ SecurityGroupIds: ['sg-1'],
508
+ },
509
+ actualValue: {
510
+ SubnetIds: ['subnet-3', 'subnet-4'],
511
+ SecurityGroupIds: ['sg-2'],
512
+ },
513
+ mutability: PropertyMutability.MUTABLE,
514
+ });
515
+
516
+ const issue = Issue.propertyMismatch({
517
+ resourceType: 'AWS::Lambda::Function',
518
+ resourceId: 'my-function',
519
+ mismatch,
520
+ });
521
+
522
+ // Should not contain [object Object]
523
+ expect(issue.description).not.toContain('[object Object]');
524
+ // Should contain structured representation
525
+ expect(issue.description).toContain('SubnetIds');
526
+ });
527
+ });
417
528
  });