@friggframework/devtools 2.0.0--canary.474.86c5119.0 → 2.0.0--canary.474.898a56c.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 (24) hide show
  1. package/infrastructure/domains/health/application/use-cases/__tests__/mismatch-analyzer-method-name.test.js +167 -0
  2. package/infrastructure/domains/health/application/use-cases/__tests__/repair-via-import-use-case.test.js +762 -0
  3. package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +154 -1
  4. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +20 -5
  5. package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +3 -3
  6. package/infrastructure/domains/health/docs/ACME-DEV-DRIFT-ANALYSIS.md +267 -0
  7. package/infrastructure/domains/health/docs/BUILD-VS-DEPLOYED-TEMPLATE-ANALYSIS.md +324 -0
  8. package/infrastructure/domains/health/docs/ORPHAN-DETECTION-ANALYSIS.md +386 -0
  9. package/infrastructure/domains/health/docs/SPEC-CLEANUP-COMMAND.md +1419 -0
  10. package/infrastructure/domains/health/docs/TDD-IMPLEMENTATION-SUMMARY.md +391 -0
  11. package/infrastructure/domains/health/docs/TEMPLATE-COMPARISON-IMPLEMENTATION.md +551 -0
  12. package/infrastructure/domains/health/domain/services/__tests__/health-score-percentage-based.test.js +380 -0
  13. package/infrastructure/domains/health/domain/services/__tests__/logical-id-mapper.test.js +645 -0
  14. package/infrastructure/domains/health/domain/services/__tests__/template-parser.test.js +496 -0
  15. package/infrastructure/domains/health/domain/services/health-score-calculator.js +174 -91
  16. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +332 -228
  17. package/infrastructure/domains/health/domain/services/logical-id-mapper.js +330 -0
  18. package/infrastructure/domains/health/domain/services/template-parser.js +245 -0
  19. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-cfn-tagged.test.js +312 -0
  20. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-multi-stack.test.js +367 -0
  21. package/infrastructure/domains/health/infrastructure/adapters/__tests__/orphan-detection-relationship-analysis.test.js +432 -0
  22. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.js +108 -14
  23. package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +69 -12
  24. package/package.json +6 -6
@@ -0,0 +1,762 @@
1
+ /**
2
+ * RepairViaImportUseCase Tests - importWithLogicalIdMapping
3
+ *
4
+ * TDD tests for the application layer use case orchestrating template comparison
5
+ * and logical ID mapping for the `frigg repair --import` command.
6
+ *
7
+ * Application Layer - Use Case Tests
8
+ * Following TDD, DDD, and Hexagonal Architecture principles
9
+ */
10
+
11
+ const RepairViaImportUseCase = require('../repair-via-import-use-case');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ describe('RepairViaImportUseCase - importWithLogicalIdMapping', () => {
16
+ let useCase;
17
+ let mockResourceImporter;
18
+ let mockResourceDetector;
19
+ let mockStackRepository;
20
+ let mockTemplateParser;
21
+ let mockLogicalIdMapper;
22
+ let tempDir;
23
+
24
+ beforeEach(() => {
25
+ // Create temp directory for test files
26
+ tempDir = path.join(__dirname, 'temp-test-files');
27
+ if (!fs.existsSync(tempDir)) {
28
+ fs.mkdirSync(tempDir, { recursive: true });
29
+ }
30
+
31
+ // Mock dependencies
32
+ mockResourceImporter = {
33
+ validateImport: jest.fn(),
34
+ importResource: jest.fn(),
35
+ importMultipleResources: jest.fn(),
36
+ getImportStatus: jest.fn(),
37
+ generateTemplateSnippet: jest.fn(),
38
+ };
39
+
40
+ mockResourceDetector = {
41
+ getResourceDetails: jest.fn(),
42
+ };
43
+
44
+ mockStackRepository = {
45
+ getTemplate: jest.fn(),
46
+ };
47
+
48
+ mockTemplateParser = {
49
+ parseTemplate: jest.fn(),
50
+ };
51
+
52
+ mockLogicalIdMapper = {
53
+ mapOrphanedResourcesToLogicalIds: jest.fn(),
54
+ };
55
+
56
+ useCase = new RepairViaImportUseCase({
57
+ resourceImporter: mockResourceImporter,
58
+ resourceDetector: mockResourceDetector,
59
+ stackRepository: mockStackRepository,
60
+ templateParser: mockTemplateParser,
61
+ logicalIdMapper: mockLogicalIdMapper,
62
+ });
63
+ });
64
+
65
+ afterEach(() => {
66
+ // Cleanup temp directory
67
+ if (fs.existsSync(tempDir)) {
68
+ const files = fs.readdirSync(tempDir);
69
+ files.forEach((file) => {
70
+ fs.unlinkSync(path.join(tempDir, file));
71
+ });
72
+ fs.rmdirSync(tempDir);
73
+ }
74
+ });
75
+
76
+ describe('importWithLogicalIdMapping', () => {
77
+ it('should throw error if buildTemplatePath not provided', async () => {
78
+ // Arrange
79
+ const stackIdentifier = {
80
+ stackName: 'acme-integrations-dev',
81
+ region: 'us-east-1',
82
+ };
83
+ const orphanedResources = [];
84
+
85
+ // Act & Assert
86
+ await expect(
87
+ useCase.importWithLogicalIdMapping({
88
+ stackIdentifier,
89
+ orphanedResources,
90
+ buildTemplatePath: null,
91
+ })
92
+ ).rejects.toThrow('buildTemplatePath is required');
93
+ });
94
+
95
+ it('should throw error if build template file does not exist', async () => {
96
+ // Arrange
97
+ const stackIdentifier = {
98
+ stackName: 'acme-integrations-dev',
99
+ region: 'us-east-1',
100
+ };
101
+ const orphanedResources = [];
102
+ const nonExistentPath = '/path/to/nonexistent/template.json';
103
+
104
+ // Act & Assert
105
+ await expect(
106
+ useCase.importWithLogicalIdMapping({
107
+ stackIdentifier,
108
+ orphanedResources,
109
+ buildTemplatePath: nonExistentPath,
110
+ })
111
+ ).rejects.toThrow(/Build template not found at/);
112
+ });
113
+
114
+ it('should successfully map orphaned resources to logical IDs', async () => {
115
+ // Arrange
116
+ const stackIdentifier = {
117
+ stackName: 'acme-integrations-dev',
118
+ region: 'us-east-1',
119
+ };
120
+
121
+ const orphanedResources = [
122
+ {
123
+ physicalId: 'vpc-0eadd96976d29ede7',
124
+ resourceType: 'AWS::EC2::VPC',
125
+ tags: [
126
+ { Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' },
127
+ { Key: 'aws:cloudformation:logical-id', Value: 'FriggVPC' },
128
+ ],
129
+ },
130
+ {
131
+ physicalId: 'subnet-00ab9e0502e66aac3',
132
+ resourceType: 'AWS::EC2::Subnet',
133
+ tags: [
134
+ { Key: 'aws:cloudformation:stack-name', Value: 'acme-integrations-dev' },
135
+ ],
136
+ },
137
+ ];
138
+
139
+ const buildTemplate = {
140
+ resources: {
141
+ FriggVPC: { Type: 'AWS::EC2::VPC' },
142
+ FriggPrivateSubnet1: { Type: 'AWS::EC2::Subnet' },
143
+ },
144
+ };
145
+
146
+ const deployedTemplate = {
147
+ resources: {
148
+ FriggVPC: { Type: 'AWS::EC2::VPC' },
149
+ MyLambda: {
150
+ Type: 'AWS::Lambda::Function',
151
+ Properties: {
152
+ VpcConfig: {
153
+ SubnetIds: ['subnet-00ab9e0502e66aac3'],
154
+ },
155
+ },
156
+ },
157
+ },
158
+ };
159
+
160
+ const mappings = [
161
+ {
162
+ logicalId: 'FriggVPC',
163
+ physicalId: 'vpc-0eadd96976d29ede7',
164
+ resourceType: 'AWS::EC2::VPC',
165
+ matchMethod: 'tag',
166
+ confidence: 'high',
167
+ },
168
+ {
169
+ logicalId: 'FriggPrivateSubnet1',
170
+ physicalId: 'subnet-00ab9e0502e66aac3',
171
+ resourceType: 'AWS::EC2::Subnet',
172
+ matchMethod: 'vpc-usage',
173
+ confidence: 'high',
174
+ },
175
+ ];
176
+
177
+ // Create temporary build template file
178
+ const buildTemplatePath = path.join(tempDir, 'build-template.json');
179
+ fs.writeFileSync(buildTemplatePath, JSON.stringify(buildTemplate));
180
+
181
+ mockTemplateParser.parseTemplate.mockReturnValue(buildTemplate);
182
+ mockStackRepository.getTemplate.mockResolvedValue(deployedTemplate);
183
+ mockLogicalIdMapper.mapOrphanedResourcesToLogicalIds.mockResolvedValue(mappings);
184
+
185
+ // Act
186
+ const result = await useCase.importWithLogicalIdMapping({
187
+ stackIdentifier,
188
+ orphanedResources,
189
+ buildTemplatePath,
190
+ });
191
+
192
+ // Assert
193
+ expect(result.success).toBe(true);
194
+ expect(result.mappedCount).toBe(2);
195
+ expect(result.unmappedCount).toBe(0);
196
+ expect(result.mappings).toHaveLength(2);
197
+ expect(result.resourcesToImport).toHaveLength(2);
198
+
199
+ // Verify template parser was called
200
+ expect(mockTemplateParser.parseTemplate).toHaveBeenCalledWith(buildTemplatePath);
201
+
202
+ // Verify stack repository was called
203
+ expect(mockStackRepository.getTemplate).toHaveBeenCalledWith(stackIdentifier);
204
+
205
+ // Verify logical ID mapper was called
206
+ expect(mockLogicalIdMapper.mapOrphanedResourcesToLogicalIds).toHaveBeenCalledWith({
207
+ orphanedResources,
208
+ buildTemplate,
209
+ deployedTemplate,
210
+ });
211
+ });
212
+
213
+ it('should generate correct CloudFormation import format', async () => {
214
+ // Arrange
215
+ const stackIdentifier = {
216
+ stackName: 'acme-integrations-dev',
217
+ region: 'us-east-1',
218
+ };
219
+
220
+ const orphanedResources = [
221
+ {
222
+ physicalId: 'vpc-0eadd96976d29ede7',
223
+ resourceType: 'AWS::EC2::VPC',
224
+ tags: [],
225
+ },
226
+ ];
227
+
228
+ const buildTemplate = { resources: { FriggVPC: { Type: 'AWS::EC2::VPC' } } };
229
+ const deployedTemplate = { resources: {} };
230
+
231
+ const mappings = [
232
+ {
233
+ logicalId: 'FriggVPC',
234
+ physicalId: 'vpc-0eadd96976d29ede7',
235
+ resourceType: 'AWS::EC2::VPC',
236
+ matchMethod: 'tag',
237
+ confidence: 'high',
238
+ },
239
+ ];
240
+
241
+ const buildTemplatePath = path.join(tempDir, 'build-template.json');
242
+ fs.writeFileSync(buildTemplatePath, JSON.stringify(buildTemplate));
243
+
244
+ mockTemplateParser.parseTemplate.mockReturnValue(buildTemplate);
245
+ mockStackRepository.getTemplate.mockResolvedValue(deployedTemplate);
246
+ mockLogicalIdMapper.mapOrphanedResourcesToLogicalIds.mockResolvedValue(mappings);
247
+
248
+ // Act
249
+ const result = await useCase.importWithLogicalIdMapping({
250
+ stackIdentifier,
251
+ orphanedResources,
252
+ buildTemplatePath,
253
+ });
254
+
255
+ // Assert
256
+ expect(result.resourcesToImport).toEqual([
257
+ {
258
+ ResourceType: 'AWS::EC2::VPC',
259
+ LogicalResourceId: 'FriggVPC',
260
+ ResourceIdentifier: { VpcId: 'vpc-0eadd96976d29ede7' },
261
+ },
262
+ ]);
263
+ });
264
+
265
+ it('should handle multiple resource types with correct identifiers', async () => {
266
+ // Arrange
267
+ const stackIdentifier = {
268
+ stackName: 'acme-integrations-dev',
269
+ region: 'us-east-1',
270
+ };
271
+
272
+ const orphanedResources = [
273
+ { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', tags: [] },
274
+ { physicalId: 'subnet-456', resourceType: 'AWS::EC2::Subnet', tags: [] },
275
+ { physicalId: 'sg-789', resourceType: 'AWS::EC2::SecurityGroup', tags: [] },
276
+ { physicalId: 'igw-abc', resourceType: 'AWS::EC2::InternetGateway', tags: [] },
277
+ ];
278
+
279
+ const buildTemplate = { resources: {} };
280
+ const deployedTemplate = { resources: {} };
281
+
282
+ const mappings = [
283
+ {
284
+ logicalId: 'FriggVPC',
285
+ physicalId: 'vpc-123',
286
+ resourceType: 'AWS::EC2::VPC',
287
+ matchMethod: 'tag',
288
+ confidence: 'high',
289
+ },
290
+ {
291
+ logicalId: 'FriggPrivateSubnet1',
292
+ physicalId: 'subnet-456',
293
+ resourceType: 'AWS::EC2::Subnet',
294
+ matchMethod: 'vpc-usage',
295
+ confidence: 'high',
296
+ },
297
+ {
298
+ logicalId: 'FriggLambdaSecurityGroup',
299
+ physicalId: 'sg-789',
300
+ resourceType: 'AWS::EC2::SecurityGroup',
301
+ matchMethod: 'usage',
302
+ confidence: 'medium',
303
+ },
304
+ {
305
+ logicalId: 'FriggInternetGateway',
306
+ physicalId: 'igw-abc',
307
+ resourceType: 'AWS::EC2::InternetGateway',
308
+ matchMethod: 'tag',
309
+ confidence: 'high',
310
+ },
311
+ ];
312
+
313
+ const buildTemplatePath = path.join(tempDir, 'build-template.json');
314
+ fs.writeFileSync(buildTemplatePath, JSON.stringify(buildTemplate));
315
+
316
+ mockTemplateParser.parseTemplate.mockReturnValue(buildTemplate);
317
+ mockStackRepository.getTemplate.mockResolvedValue(deployedTemplate);
318
+ mockLogicalIdMapper.mapOrphanedResourcesToLogicalIds.mockResolvedValue(mappings);
319
+
320
+ // Act
321
+ const result = await useCase.importWithLogicalIdMapping({
322
+ stackIdentifier,
323
+ orphanedResources,
324
+ buildTemplatePath,
325
+ });
326
+
327
+ // Assert
328
+ expect(result.resourcesToImport).toEqual([
329
+ {
330
+ ResourceType: 'AWS::EC2::VPC',
331
+ LogicalResourceId: 'FriggVPC',
332
+ ResourceIdentifier: { VpcId: 'vpc-123' },
333
+ },
334
+ {
335
+ ResourceType: 'AWS::EC2::Subnet',
336
+ LogicalResourceId: 'FriggPrivateSubnet1',
337
+ ResourceIdentifier: { SubnetId: 'subnet-456' },
338
+ },
339
+ {
340
+ ResourceType: 'AWS::EC2::SecurityGroup',
341
+ LogicalResourceId: 'FriggLambdaSecurityGroup',
342
+ ResourceIdentifier: { GroupId: 'sg-789' },
343
+ },
344
+ {
345
+ ResourceType: 'AWS::EC2::InternetGateway',
346
+ LogicalResourceId: 'FriggInternetGateway',
347
+ ResourceIdentifier: { InternetGatewayId: 'igw-abc' },
348
+ },
349
+ ]);
350
+ });
351
+
352
+ it('should separate mapped and unmapped resources', async () => {
353
+ // Arrange
354
+ const stackIdentifier = {
355
+ stackName: 'acme-integrations-dev',
356
+ region: 'us-east-1',
357
+ };
358
+
359
+ const orphanedResources = [
360
+ { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', tags: [] },
361
+ { physicalId: 'vpc-456', resourceType: 'AWS::EC2::VPC', tags: [] }, // No match
362
+ { physicalId: 'subnet-789', resourceType: 'AWS::EC2::Subnet', tags: [] },
363
+ ];
364
+
365
+ const buildTemplate = { resources: {} };
366
+ const deployedTemplate = { resources: {} };
367
+
368
+ const mappings = [
369
+ {
370
+ logicalId: 'FriggVPC',
371
+ physicalId: 'vpc-123',
372
+ resourceType: 'AWS::EC2::VPC',
373
+ matchMethod: 'tag',
374
+ confidence: 'high',
375
+ },
376
+ {
377
+ logicalId: null, // Unmapped
378
+ physicalId: 'vpc-456',
379
+ resourceType: 'AWS::EC2::VPC',
380
+ matchMethod: 'none',
381
+ confidence: 'none',
382
+ },
383
+ {
384
+ logicalId: 'FriggPrivateSubnet1',
385
+ physicalId: 'subnet-789',
386
+ resourceType: 'AWS::EC2::Subnet',
387
+ matchMethod: 'vpc-usage',
388
+ confidence: 'high',
389
+ },
390
+ ];
391
+
392
+ const buildTemplatePath = path.join(tempDir, 'build-template.json');
393
+ fs.writeFileSync(buildTemplatePath, JSON.stringify(buildTemplate));
394
+
395
+ mockTemplateParser.parseTemplate.mockReturnValue(buildTemplate);
396
+ mockStackRepository.getTemplate.mockResolvedValue(deployedTemplate);
397
+ mockLogicalIdMapper.mapOrphanedResourcesToLogicalIds.mockResolvedValue(mappings);
398
+
399
+ // Act
400
+ const result = await useCase.importWithLogicalIdMapping({
401
+ stackIdentifier,
402
+ orphanedResources,
403
+ buildTemplatePath,
404
+ });
405
+
406
+ // Assert
407
+ expect(result.success).toBe(true);
408
+ expect(result.mappedCount).toBe(2);
409
+ expect(result.unmappedCount).toBe(1);
410
+ expect(result.mappings).toHaveLength(2);
411
+ expect(result.unmappedResources).toHaveLength(1);
412
+ expect(result.unmappedResources[0].physicalId).toBe('vpc-456');
413
+ });
414
+
415
+ it('should return failure if no resources could be mapped', async () => {
416
+ // Arrange
417
+ const stackIdentifier = {
418
+ stackName: 'acme-integrations-dev',
419
+ region: 'us-east-1',
420
+ };
421
+
422
+ const orphanedResources = [
423
+ { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', tags: [] },
424
+ { physicalId: 'vpc-456', resourceType: 'AWS::EC2::VPC', tags: [] },
425
+ ];
426
+
427
+ const buildTemplate = { resources: {} };
428
+ const deployedTemplate = { resources: {} };
429
+
430
+ // All resources unmapped
431
+ const mappings = [
432
+ {
433
+ logicalId: null,
434
+ physicalId: 'vpc-123',
435
+ resourceType: 'AWS::EC2::VPC',
436
+ matchMethod: 'none',
437
+ confidence: 'none',
438
+ },
439
+ {
440
+ logicalId: null,
441
+ physicalId: 'vpc-456',
442
+ resourceType: 'AWS::EC2::VPC',
443
+ matchMethod: 'none',
444
+ confidence: 'none',
445
+ },
446
+ ];
447
+
448
+ const buildTemplatePath = path.join(tempDir, 'build-template.json');
449
+ fs.writeFileSync(buildTemplatePath, JSON.stringify(buildTemplate));
450
+
451
+ mockTemplateParser.parseTemplate.mockReturnValue(buildTemplate);
452
+ mockStackRepository.getTemplate.mockResolvedValue(deployedTemplate);
453
+ mockLogicalIdMapper.mapOrphanedResourcesToLogicalIds.mockResolvedValue(mappings);
454
+
455
+ // Act
456
+ const result = await useCase.importWithLogicalIdMapping({
457
+ stackIdentifier,
458
+ orphanedResources,
459
+ buildTemplatePath,
460
+ });
461
+
462
+ // Assert
463
+ expect(result.success).toBe(false);
464
+ expect(result.message).toBe('No resources could be mapped to logical IDs');
465
+ expect(result.unmappedCount).toBe(2);
466
+ });
467
+
468
+ it('should generate warnings for multiple resources of same type', async () => {
469
+ // Arrange
470
+ const stackIdentifier = {
471
+ stackName: 'acme-integrations-dev',
472
+ region: 'us-east-1',
473
+ };
474
+
475
+ const orphanedResources = [
476
+ { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', tags: [] },
477
+ { physicalId: 'vpc-456', resourceType: 'AWS::EC2::VPC', tags: [] },
478
+ { physicalId: 'vpc-789', resourceType: 'AWS::EC2::VPC', tags: [] },
479
+ ];
480
+
481
+ const buildTemplate = { resources: {} };
482
+ const deployedTemplate = { resources: {} };
483
+
484
+ // Three VPCs mapped
485
+ const mappings = [
486
+ {
487
+ logicalId: 'FriggVPC',
488
+ physicalId: 'vpc-123',
489
+ resourceType: 'AWS::EC2::VPC',
490
+ matchMethod: 'tag',
491
+ confidence: 'high',
492
+ },
493
+ {
494
+ logicalId: 'FriggVPC2',
495
+ physicalId: 'vpc-456',
496
+ resourceType: 'AWS::EC2::VPC',
497
+ matchMethod: 'contained-resources',
498
+ confidence: 'high',
499
+ },
500
+ {
501
+ logicalId: 'FriggVPC3',
502
+ physicalId: 'vpc-789',
503
+ resourceType: 'AWS::EC2::VPC',
504
+ matchMethod: 'tag',
505
+ confidence: 'high',
506
+ },
507
+ ];
508
+
509
+ const buildTemplatePath = path.join(tempDir, 'build-template.json');
510
+ fs.writeFileSync(buildTemplatePath, JSON.stringify(buildTemplate));
511
+
512
+ mockTemplateParser.parseTemplate.mockReturnValue(buildTemplate);
513
+ mockStackRepository.getTemplate.mockResolvedValue(deployedTemplate);
514
+ mockLogicalIdMapper.mapOrphanedResourcesToLogicalIds.mockResolvedValue(mappings);
515
+
516
+ // Act
517
+ const result = await useCase.importWithLogicalIdMapping({
518
+ stackIdentifier,
519
+ orphanedResources,
520
+ buildTemplatePath,
521
+ });
522
+
523
+ // Assert
524
+ expect(result.success).toBe(true);
525
+ expect(result.warnings).toHaveLength(1);
526
+ expect(result.warnings[0].type).toBe('MULTIPLE_RESOURCES');
527
+ expect(result.warnings[0].resourceType).toBe('AWS::EC2::VPC');
528
+ expect(result.warnings[0].count).toBe(3);
529
+ expect(result.warnings[0].message).toContain('Multiple VPCs detected (3)');
530
+ expect(result.warnings[0].resources).toHaveLength(3);
531
+ });
532
+
533
+ it('should include build and deployed template paths in result', async () => {
534
+ // Arrange
535
+ const stackIdentifier = {
536
+ stackName: 'acme-integrations-dev',
537
+ region: 'us-east-1',
538
+ };
539
+
540
+ const orphanedResources = [
541
+ { physicalId: 'vpc-123', resourceType: 'AWS::EC2::VPC', tags: [] },
542
+ ];
543
+
544
+ const buildTemplate = { resources: { FriggVPC: { Type: 'AWS::EC2::VPC' } } };
545
+ const deployedTemplate = { resources: {} };
546
+
547
+ const mappings = [
548
+ {
549
+ logicalId: 'FriggVPC',
550
+ physicalId: 'vpc-123',
551
+ resourceType: 'AWS::EC2::VPC',
552
+ matchMethod: 'tag',
553
+ confidence: 'high',
554
+ },
555
+ ];
556
+
557
+ const buildTemplatePath = path.join(tempDir, 'build-template.json');
558
+ fs.writeFileSync(buildTemplatePath, JSON.stringify(buildTemplate));
559
+
560
+ mockTemplateParser.parseTemplate.mockReturnValue(buildTemplate);
561
+ mockStackRepository.getTemplate.mockResolvedValue(deployedTemplate);
562
+ mockLogicalIdMapper.mapOrphanedResourcesToLogicalIds.mockResolvedValue(mappings);
563
+
564
+ // Act
565
+ const result = await useCase.importWithLogicalIdMapping({
566
+ stackIdentifier,
567
+ orphanedResources,
568
+ buildTemplatePath,
569
+ });
570
+
571
+ // Assert
572
+ expect(result.buildTemplatePath).toBe(buildTemplatePath);
573
+ expect(result.deployedTemplatePath).toBe('CloudFormation (deployed)');
574
+ });
575
+
576
+ it('should throw error if stackRepository not provided', async () => {
577
+ // Arrange
578
+ const useCaseWithoutStackRepo = new RepairViaImportUseCase({
579
+ resourceImporter: mockResourceImporter,
580
+ resourceDetector: mockResourceDetector,
581
+ // stackRepository: NOT PROVIDED
582
+ templateParser: mockTemplateParser,
583
+ logicalIdMapper: mockLogicalIdMapper,
584
+ });
585
+
586
+ const stackIdentifier = {
587
+ stackName: 'acme-integrations-dev',
588
+ region: 'us-east-1',
589
+ };
590
+
591
+ const buildTemplatePath = path.join(tempDir, 'build-template.json');
592
+ fs.writeFileSync(buildTemplatePath, JSON.stringify({ resources: {} }));
593
+
594
+ mockTemplateParser.parseTemplate.mockReturnValue({ resources: {} });
595
+
596
+ // Act & Assert
597
+ await expect(
598
+ useCaseWithoutStackRepo.importWithLogicalIdMapping({
599
+ stackIdentifier,
600
+ orphanedResources: [],
601
+ buildTemplatePath,
602
+ })
603
+ ).rejects.toThrow('stackRepository is required for template comparison');
604
+ });
605
+ });
606
+
607
+ describe('_getResourceIdentifier', () => {
608
+ it('should return correct identifier for VPC', () => {
609
+ // Arrange
610
+ const mapping = {
611
+ physicalId: 'vpc-123',
612
+ resourceType: 'AWS::EC2::VPC',
613
+ };
614
+
615
+ // Act
616
+ const result = useCase._getResourceIdentifier(mapping);
617
+
618
+ // Assert
619
+ expect(result).toEqual({ VpcId: 'vpc-123' });
620
+ });
621
+
622
+ it('should return correct identifier for Subnet', () => {
623
+ // Arrange
624
+ const mapping = {
625
+ physicalId: 'subnet-456',
626
+ resourceType: 'AWS::EC2::Subnet',
627
+ };
628
+
629
+ // Act
630
+ const result = useCase._getResourceIdentifier(mapping);
631
+
632
+ // Assert
633
+ expect(result).toEqual({ SubnetId: 'subnet-456' });
634
+ });
635
+
636
+ it('should return correct identifier for SecurityGroup', () => {
637
+ // Arrange
638
+ const mapping = {
639
+ physicalId: 'sg-789',
640
+ resourceType: 'AWS::EC2::SecurityGroup',
641
+ };
642
+
643
+ // Act
644
+ const result = useCase._getResourceIdentifier(mapping);
645
+
646
+ // Assert
647
+ expect(result).toEqual({ GroupId: 'sg-789' });
648
+ });
649
+
650
+ it('should return generic identifier for unknown resource type', () => {
651
+ // Arrange
652
+ const mapping = {
653
+ physicalId: 'unknown-123',
654
+ resourceType: 'AWS::Unknown::Type',
655
+ };
656
+
657
+ // Act
658
+ const result = useCase._getResourceIdentifier(mapping);
659
+
660
+ // Assert
661
+ expect(result).toEqual({ Id: 'unknown-123' });
662
+ });
663
+ });
664
+
665
+ describe('_checkForMultipleResources', () => {
666
+ it('should return no warnings for single resource per type', () => {
667
+ // Arrange
668
+ const mappings = [
669
+ {
670
+ logicalId: 'FriggVPC',
671
+ physicalId: 'vpc-123',
672
+ resourceType: 'AWS::EC2::VPC',
673
+ },
674
+ {
675
+ logicalId: 'FriggPrivateSubnet1',
676
+ physicalId: 'subnet-456',
677
+ resourceType: 'AWS::EC2::Subnet',
678
+ },
679
+ ];
680
+
681
+ // Act
682
+ const warnings = useCase._checkForMultipleResources(mappings);
683
+
684
+ // Assert
685
+ expect(warnings).toHaveLength(0);
686
+ });
687
+
688
+ it('should return warning for multiple resources of same type', () => {
689
+ // Arrange
690
+ const mappings = [
691
+ {
692
+ logicalId: 'FriggVPC',
693
+ physicalId: 'vpc-123',
694
+ resourceType: 'AWS::EC2::VPC',
695
+ matchMethod: 'tag',
696
+ confidence: 'high',
697
+ },
698
+ {
699
+ logicalId: 'FriggVPC2',
700
+ physicalId: 'vpc-456',
701
+ resourceType: 'AWS::EC2::VPC',
702
+ matchMethod: 'contained-resources',
703
+ confidence: 'high',
704
+ },
705
+ ];
706
+
707
+ // Act
708
+ const warnings = useCase._checkForMultipleResources(mappings);
709
+
710
+ // Assert
711
+ expect(warnings).toHaveLength(1);
712
+ expect(warnings[0].type).toBe('MULTIPLE_RESOURCES');
713
+ expect(warnings[0].resourceType).toBe('AWS::EC2::VPC');
714
+ expect(warnings[0].count).toBe(2);
715
+ expect(warnings[0].resources).toHaveLength(2);
716
+ });
717
+
718
+ it('should return multiple warnings for different resource types with multiples', () => {
719
+ // Arrange
720
+ const mappings = [
721
+ {
722
+ logicalId: 'FriggVPC',
723
+ physicalId: 'vpc-123',
724
+ resourceType: 'AWS::EC2::VPC',
725
+ matchMethod: 'tag',
726
+ confidence: 'high',
727
+ },
728
+ {
729
+ logicalId: 'FriggVPC2',
730
+ physicalId: 'vpc-456',
731
+ resourceType: 'AWS::EC2::VPC',
732
+ matchMethod: 'tag',
733
+ confidence: 'high',
734
+ },
735
+ {
736
+ logicalId: 'FriggPrivateSubnet1',
737
+ physicalId: 'subnet-111',
738
+ resourceType: 'AWS::EC2::Subnet',
739
+ matchMethod: 'vpc-usage',
740
+ confidence: 'high',
741
+ },
742
+ {
743
+ logicalId: 'FriggPrivateSubnet2',
744
+ physicalId: 'subnet-222',
745
+ resourceType: 'AWS::EC2::Subnet',
746
+ matchMethod: 'vpc-usage',
747
+ confidence: 'high',
748
+ },
749
+ ];
750
+
751
+ // Act
752
+ const warnings = useCase._checkForMultipleResources(mappings);
753
+
754
+ // Assert
755
+ expect(warnings).toHaveLength(2);
756
+ expect(warnings[0].resourceType).toBe('AWS::EC2::VPC');
757
+ expect(warnings[0].count).toBe(2);
758
+ expect(warnings[1].resourceType).toBe('AWS::EC2::Subnet');
759
+ expect(warnings[1].count).toBe(2);
760
+ });
761
+ });
762
+ });