@friggframework/devtools 2.0.0--canary.474.884529c.0 → 2.0.0--canary.474.988ec0b.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.
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Tests for RunHealthCheckUseCase
3
+ *
4
+ * Use case for orchestrating complete stack health check (frigg doctor command)
5
+ */
6
+
7
+ const RunHealthCheckUseCase = require('./run-health-check-use-case');
8
+ const StackIdentifier = require('../../domain/value-objects/stack-identifier');
9
+ const ResourceState = require('../../domain/value-objects/resource-state');
10
+ const PropertyMutability = require('../../domain/value-objects/property-mutability');
11
+ const PropertyMismatch = require('../../domain/entities/property-mismatch');
12
+ const HealthScore = require('../../domain/value-objects/health-score');
13
+
14
+ describe('RunHealthCheckUseCase', () => {
15
+ let useCase;
16
+ let mockStackRepository;
17
+ let mockResourceDetector;
18
+ let mockMismatchAnalyzer;
19
+ let mockHealthScoreCalculator;
20
+
21
+ beforeEach(() => {
22
+ // Mock repositories
23
+ mockStackRepository = {
24
+ getStack: jest.fn(),
25
+ listResources: jest.fn(),
26
+ detectStackDrift: jest.fn(),
27
+ getResourceDrift: jest.fn(),
28
+ };
29
+
30
+ mockResourceDetector = {
31
+ detectResources: jest.fn(),
32
+ findOrphanedResources: jest.fn(),
33
+ };
34
+
35
+ mockMismatchAnalyzer = {
36
+ analyzePropertyMismatches: jest.fn(),
37
+ };
38
+
39
+ mockHealthScoreCalculator = {
40
+ calculate: jest.fn(),
41
+ };
42
+
43
+ useCase = new RunHealthCheckUseCase({
44
+ stackRepository: mockStackRepository,
45
+ resourceDetector: mockResourceDetector,
46
+ mismatchAnalyzer: mockMismatchAnalyzer,
47
+ healthScoreCalculator: mockHealthScoreCalculator,
48
+ });
49
+ });
50
+
51
+ describe('execute', () => {
52
+ it('should return healthy report for stack with no issues', async () => {
53
+ const stackIdentifier = new StackIdentifier({
54
+ stackName: 'my-app-prod',
55
+ region: 'us-east-1',
56
+ });
57
+
58
+ // Mock stack exists and is healthy
59
+ mockStackRepository.getStack.mockResolvedValue({
60
+ stackName: 'my-app-prod',
61
+ stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
62
+ status: 'UPDATE_COMPLETE',
63
+ createdTime: new Date('2024-01-01'),
64
+ lastUpdatedTime: new Date('2024-01-15'),
65
+ parameters: [],
66
+ outputs: [],
67
+ tags: [],
68
+ });
69
+
70
+ // Mock no drift
71
+ mockStackRepository.detectStackDrift.mockResolvedValue({
72
+ stackDriftStatus: 'IN_SYNC',
73
+ driftedResourcesCount: 0,
74
+ });
75
+
76
+ // Mock resources in stack
77
+ mockStackRepository.listResources.mockResolvedValue([
78
+ {
79
+ logicalId: 'MyVPC',
80
+ physicalId: 'vpc-123',
81
+ resourceType: 'AWS::EC2::VPC',
82
+ status: 'UPDATE_COMPLETE',
83
+ driftStatus: 'IN_SYNC',
84
+ },
85
+ ]);
86
+
87
+ // Mock no orphaned resources
88
+ mockResourceDetector.findOrphanedResources.mockResolvedValue([]);
89
+
90
+ // Mock healthy score
91
+ mockHealthScoreCalculator.calculate.mockReturnValue(new HealthScore(100));
92
+
93
+ const report = await useCase.execute({ stackIdentifier });
94
+
95
+ expect(report.stackIdentifier.stackName).toBe('my-app-prod');
96
+ expect(report.healthScore.value).toBe(100);
97
+ expect(report.issues).toHaveLength(0);
98
+ expect(report.resources).toHaveLength(1);
99
+ expect(report.getIssueCount()).toBe(0);
100
+ expect(report.getCriticalIssueCount()).toBe(0);
101
+ });
102
+
103
+ it('should detect and report property drift', async () => {
104
+ const stackIdentifier = new StackIdentifier({
105
+ stackName: 'my-app-prod',
106
+ region: 'us-east-1',
107
+ });
108
+
109
+ mockStackRepository.getStack.mockResolvedValue({
110
+ stackName: 'my-app-prod',
111
+ stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
112
+ status: 'UPDATE_COMPLETE',
113
+ createdTime: new Date('2024-01-01'),
114
+ });
115
+
116
+ mockStackRepository.detectStackDrift.mockResolvedValue({
117
+ stackDriftStatus: 'DRIFTED',
118
+ driftedResourcesCount: 1,
119
+ });
120
+
121
+ mockStackRepository.listResources.mockResolvedValue([
122
+ {
123
+ logicalId: 'MyVPC',
124
+ physicalId: 'vpc-123',
125
+ resourceType: 'AWS::EC2::VPC',
126
+ status: 'UPDATE_COMPLETE',
127
+ driftStatus: 'MODIFIED',
128
+ },
129
+ ]);
130
+
131
+ // Mock drift details
132
+ mockStackRepository.getResourceDrift.mockResolvedValue({
133
+ driftStatus: 'MODIFIED',
134
+ propertyDifferences: [
135
+ {
136
+ propertyPath: 'Properties.EnableDnsSupport',
137
+ expectedValue: true,
138
+ actualValue: false,
139
+ differenceType: 'NOT_EQUAL',
140
+ },
141
+ ],
142
+ });
143
+
144
+ // Mock property mismatch analysis
145
+ const propertyMismatch = new PropertyMismatch({
146
+ propertyPath: 'Properties.EnableDnsSupport',
147
+ expectedValue: true,
148
+ actualValue: false,
149
+ mutability: PropertyMutability.MUTABLE,
150
+ });
151
+
152
+ mockMismatchAnalyzer.analyzePropertyMismatches.mockReturnValue([propertyMismatch]);
153
+
154
+ mockResourceDetector.findOrphanedResources.mockResolvedValue([]);
155
+
156
+ mockHealthScoreCalculator.calculate.mockReturnValue(new HealthScore(85));
157
+
158
+ const report = await useCase.execute({ stackIdentifier });
159
+
160
+ expect(report.healthScore.value).toBe(85);
161
+ expect(report.issues).toHaveLength(1);
162
+ expect(report.issues[0].type).toBe('PROPERTY_MISMATCH');
163
+ expect(report.issues[0].severity).toBe('warning');
164
+ expect(report.resources[0].state.value).toBe('DRIFTED');
165
+ });
166
+
167
+ it('should detect and report orphaned resources', async () => {
168
+ const stackIdentifier = new StackIdentifier({
169
+ stackName: 'my-app-prod',
170
+ region: 'us-east-1',
171
+ });
172
+
173
+ mockStackRepository.getStack.mockResolvedValue({
174
+ stackName: 'my-app-prod',
175
+ stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
176
+ status: 'UPDATE_COMPLETE',
177
+ createdTime: new Date('2024-01-01'),
178
+ });
179
+
180
+ mockStackRepository.detectStackDrift.mockResolvedValue({
181
+ stackDriftStatus: 'IN_SYNC',
182
+ driftedResourcesCount: 0,
183
+ });
184
+
185
+ mockStackRepository.listResources.mockResolvedValue([
186
+ {
187
+ logicalId: 'MyVPC',
188
+ physicalId: 'vpc-123',
189
+ resourceType: 'AWS::EC2::VPC',
190
+ status: 'UPDATE_COMPLETE',
191
+ driftStatus: 'IN_SYNC',
192
+ },
193
+ ]);
194
+
195
+ // Mock orphaned RDS cluster
196
+ mockResourceDetector.findOrphanedResources.mockResolvedValue([
197
+ {
198
+ physicalId: 'my-orphan-cluster',
199
+ resourceType: 'AWS::RDS::DBCluster',
200
+ properties: {
201
+ Engine: 'aurora-postgresql',
202
+ EngineVersion: '13.7',
203
+ },
204
+ tags: [
205
+ { Key: 'frigg:stack', Value: 'my-app-prod' },
206
+ ],
207
+ createdTime: new Date('2024-01-10'),
208
+ },
209
+ ]);
210
+
211
+ mockHealthScoreCalculator.calculate.mockReturnValue(new HealthScore(75));
212
+
213
+ const report = await useCase.execute({ stackIdentifier });
214
+
215
+ expect(report.healthScore.value).toBe(75);
216
+ expect(report.issues).toHaveLength(1);
217
+ expect(report.issues[0].type).toBe('ORPHANED_RESOURCE');
218
+ expect(report.issues[0].severity).toBe('critical');
219
+ expect(report.issues[0].resourceId).toBe('my-orphan-cluster');
220
+ expect(report.getOrphanedResourceCount()).toBe(1);
221
+ });
222
+
223
+ it('should detect missing resources', async () => {
224
+ const stackIdentifier = new StackIdentifier({
225
+ stackName: 'my-app-prod',
226
+ region: 'us-east-1',
227
+ });
228
+
229
+ mockStackRepository.getStack.mockResolvedValue({
230
+ stackName: 'my-app-prod',
231
+ stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
232
+ status: 'UPDATE_COMPLETE',
233
+ createdTime: new Date('2024-01-01'),
234
+ });
235
+
236
+ mockStackRepository.detectStackDrift.mockResolvedValue({
237
+ stackDriftStatus: 'DRIFTED',
238
+ driftedResourcesCount: 1,
239
+ });
240
+
241
+ mockStackRepository.listResources.mockResolvedValue([
242
+ {
243
+ logicalId: 'MyVPC',
244
+ physicalId: 'vpc-123',
245
+ resourceType: 'AWS::EC2::VPC',
246
+ status: 'UPDATE_COMPLETE',
247
+ driftStatus: 'IN_SYNC',
248
+ },
249
+ {
250
+ logicalId: 'MySubnet',
251
+ physicalId: null, // Missing resource
252
+ resourceType: 'AWS::EC2::Subnet',
253
+ status: 'DELETE_COMPLETE',
254
+ driftStatus: 'DELETED',
255
+ },
256
+ ]);
257
+
258
+ mockResourceDetector.findOrphanedResources.mockResolvedValue([]);
259
+
260
+ mockHealthScoreCalculator.calculate.mockReturnValue(new HealthScore(60));
261
+
262
+ const report = await useCase.execute({ stackIdentifier });
263
+
264
+ expect(report.healthScore.value).toBe(60);
265
+ expect(report.issues).toHaveLength(1);
266
+ expect(report.issues[0].type).toBe('MISSING_RESOURCE');
267
+ expect(report.issues[0].severity).toBe('critical');
268
+ expect(report.resources[1].state.value).toBe('MISSING');
269
+ });
270
+
271
+ it('should handle multiple issue types simultaneously', async () => {
272
+ const stackIdentifier = new StackIdentifier({
273
+ stackName: 'my-app-prod',
274
+ region: 'us-east-1',
275
+ });
276
+
277
+ mockStackRepository.getStack.mockResolvedValue({
278
+ stackName: 'my-app-prod',
279
+ stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
280
+ status: 'UPDATE_COMPLETE',
281
+ createdTime: new Date('2024-01-01'),
282
+ });
283
+
284
+ mockStackRepository.detectStackDrift.mockResolvedValue({
285
+ stackDriftStatus: 'DRIFTED',
286
+ driftedResourcesCount: 2,
287
+ });
288
+
289
+ mockStackRepository.listResources.mockResolvedValue([
290
+ {
291
+ logicalId: 'MyVPC',
292
+ physicalId: 'vpc-123',
293
+ resourceType: 'AWS::EC2::VPC',
294
+ status: 'UPDATE_COMPLETE',
295
+ driftStatus: 'MODIFIED',
296
+ },
297
+ {
298
+ logicalId: 'MySubnet',
299
+ physicalId: null,
300
+ resourceType: 'AWS::EC2::Subnet',
301
+ status: 'DELETE_COMPLETE',
302
+ driftStatus: 'DELETED',
303
+ },
304
+ ]);
305
+
306
+ // Mock drift for VPC
307
+ mockStackRepository.getResourceDrift.mockResolvedValue({
308
+ driftStatus: 'MODIFIED',
309
+ propertyDifferences: [
310
+ {
311
+ propertyPath: 'Properties.EnableDnsSupport',
312
+ expectedValue: true,
313
+ actualValue: false,
314
+ differenceType: 'NOT_EQUAL',
315
+ },
316
+ ],
317
+ });
318
+
319
+ const propertyMismatch = new PropertyMismatch({
320
+ propertyPath: 'Properties.EnableDnsSupport',
321
+ expectedValue: true,
322
+ actualValue: false,
323
+ mutability: PropertyMutability.MUTABLE,
324
+ });
325
+
326
+ mockMismatchAnalyzer.analyzePropertyMismatches.mockReturnValue([propertyMismatch]);
327
+
328
+ // Mock orphaned resources
329
+ mockResourceDetector.findOrphanedResources.mockResolvedValue([
330
+ {
331
+ physicalId: 'my-orphan-cluster',
332
+ resourceType: 'AWS::RDS::DBCluster',
333
+ properties: {},
334
+ tags: [{ Key: 'frigg:stack', Value: 'my-app-prod' }],
335
+ createdTime: new Date('2024-01-10'),
336
+ },
337
+ ]);
338
+
339
+ mockHealthScoreCalculator.calculate.mockReturnValue(new HealthScore(40));
340
+
341
+ const report = await useCase.execute({ stackIdentifier });
342
+
343
+ expect(report.healthScore.value).toBe(40);
344
+ expect(report.issues.length).toBeGreaterThanOrEqual(3);
345
+ expect(report.getIssueCount()).toBeGreaterThanOrEqual(3);
346
+ expect(report.getCriticalIssueCount()).toBeGreaterThanOrEqual(1);
347
+ expect(report.getDriftedResourceCount()).toBe(1);
348
+ expect(report.getOrphanedResourceCount()).toBe(1);
349
+ expect(report.getMissingResourceCount()).toBe(1);
350
+ });
351
+
352
+ it('should throw error if stack does not exist', async () => {
353
+ const stackIdentifier = new StackIdentifier({
354
+ stackName: 'non-existent-stack',
355
+ region: 'us-east-1',
356
+ });
357
+
358
+ mockStackRepository.getStack.mockRejectedValue(
359
+ new Error('Stack non-existent-stack does not exist')
360
+ );
361
+
362
+ await expect(useCase.execute({ stackIdentifier })).rejects.toThrow(
363
+ 'Stack non-existent-stack does not exist'
364
+ );
365
+ });
366
+
367
+ it('should include timestamp in report', async () => {
368
+ const stackIdentifier = new StackIdentifier({
369
+ stackName: 'my-app-prod',
370
+ region: 'us-east-1',
371
+ });
372
+
373
+ mockStackRepository.getStack.mockResolvedValue({
374
+ stackName: 'my-app-prod',
375
+ stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
376
+ status: 'UPDATE_COMPLETE',
377
+ createdTime: new Date('2024-01-01'),
378
+ });
379
+
380
+ mockStackRepository.detectStackDrift.mockResolvedValue({
381
+ stackDriftStatus: 'IN_SYNC',
382
+ driftedResourcesCount: 0,
383
+ });
384
+
385
+ mockStackRepository.listResources.mockResolvedValue([]);
386
+ mockResourceDetector.findOrphanedResources.mockResolvedValue([]);
387
+
388
+ mockHealthScoreCalculator.calculate.mockReturnValue(new HealthScore(100));
389
+
390
+ const beforeExecution = new Date();
391
+ const report = await useCase.execute({ stackIdentifier });
392
+ const afterExecution = new Date();
393
+
394
+ expect(report.timestamp).toBeInstanceOf(Date);
395
+ expect(report.timestamp.getTime()).toBeGreaterThanOrEqual(beforeExecution.getTime());
396
+ expect(report.timestamp.getTime()).toBeLessThanOrEqual(afterExecution.getTime());
397
+ });
398
+ });
399
+
400
+ describe('constructor', () => {
401
+ it('should require stackRepository', () => {
402
+ expect(() => {
403
+ new RunHealthCheckUseCase({
404
+ resourceDetector: mockResourceDetector,
405
+ mismatchAnalyzer: mockMismatchAnalyzer,
406
+ healthScoreCalculator: mockHealthScoreCalculator,
407
+ });
408
+ }).toThrow('stackRepository is required');
409
+ });
410
+
411
+ it('should require resourceDetector', () => {
412
+ expect(() => {
413
+ new RunHealthCheckUseCase({
414
+ stackRepository: mockStackRepository,
415
+ mismatchAnalyzer: mockMismatchAnalyzer,
416
+ healthScoreCalculator: mockHealthScoreCalculator,
417
+ });
418
+ }).toThrow('resourceDetector is required');
419
+ });
420
+
421
+ it('should require mismatchAnalyzer', () => {
422
+ expect(() => {
423
+ new RunHealthCheckUseCase({
424
+ stackRepository: mockStackRepository,
425
+ resourceDetector: mockResourceDetector,
426
+ healthScoreCalculator: mockHealthScoreCalculator,
427
+ });
428
+ }).toThrow('mismatchAnalyzer is required');
429
+ });
430
+
431
+ it('should require healthScoreCalculator', () => {
432
+ expect(() => {
433
+ new RunHealthCheckUseCase({
434
+ stackRepository: mockStackRepository,
435
+ resourceDetector: mockResourceDetector,
436
+ mismatchAnalyzer: mockMismatchAnalyzer,
437
+ });
438
+ }).toThrow('healthScoreCalculator is required');
439
+ });
440
+ });
441
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.474.884529c.0",
4
+ "version": "2.0.0--canary.474.988ec0b.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-ec2": "^3.835.0",
7
7
  "@aws-sdk/client-kms": "^3.835.0",
@@ -11,8 +11,8 @@
11
11
  "@babel/eslint-parser": "^7.18.9",
12
12
  "@babel/parser": "^7.25.3",
13
13
  "@babel/traverse": "^7.25.3",
14
- "@friggframework/schemas": "2.0.0--canary.474.884529c.0",
15
- "@friggframework/test": "2.0.0--canary.474.884529c.0",
14
+ "@friggframework/schemas": "2.0.0--canary.474.988ec0b.0",
15
+ "@friggframework/test": "2.0.0--canary.474.988ec0b.0",
16
16
  "@hapi/boom": "^10.0.1",
17
17
  "@inquirer/prompts": "^5.3.8",
18
18
  "axios": "^1.7.2",
@@ -34,8 +34,8 @@
34
34
  "serverless-http": "^2.7.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@friggframework/eslint-config": "2.0.0--canary.474.884529c.0",
38
- "@friggframework/prettier-config": "2.0.0--canary.474.884529c.0",
37
+ "@friggframework/eslint-config": "2.0.0--canary.474.988ec0b.0",
38
+ "@friggframework/prettier-config": "2.0.0--canary.474.988ec0b.0",
39
39
  "aws-sdk-client-mock": "^4.1.0",
40
40
  "aws-sdk-client-mock-jest": "^4.1.0",
41
41
  "jest": "^30.1.3",
@@ -70,5 +70,5 @@
70
70
  "publishConfig": {
71
71
  "access": "public"
72
72
  },
73
- "gitHead": "884529c77537c39c284673e5e31433048c1251ec"
73
+ "gitHead": "988ec0bd25a84c55638db9a71fcbccb2b678e603"
74
74
  }