@friggframework/devtools 2.0.0--canary.474.6ec870b.0 → 2.0.0--canary.474.d64c550.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 (20) hide show
  1. package/infrastructure/domains/health/application/ports/IPropertyReconciler.js +164 -0
  2. package/infrastructure/domains/health/application/ports/IResourceDetector.js +129 -0
  3. package/infrastructure/domains/health/application/ports/IResourceImporter.js +142 -0
  4. package/infrastructure/domains/health/application/ports/IStackRepository.js +131 -0
  5. package/infrastructure/domains/health/application/ports/index.js +26 -0
  6. package/infrastructure/domains/health/domain/entities/issue.js +250 -0
  7. package/infrastructure/domains/health/domain/entities/issue.test.js +417 -0
  8. package/infrastructure/domains/health/domain/entities/property-mismatch.js +7 -4
  9. package/infrastructure/domains/health/domain/entities/property-mismatch.test.js +28 -4
  10. package/infrastructure/domains/health/domain/entities/resource.js +159 -0
  11. package/infrastructure/domains/health/domain/entities/resource.test.js +432 -0
  12. package/infrastructure/domains/health/domain/entities/stack-health-report.js +306 -0
  13. package/infrastructure/domains/health/domain/entities/stack-health-report.test.js +601 -0
  14. package/infrastructure/domains/health/domain/services/health-score-calculator.js +165 -0
  15. package/infrastructure/domains/health/domain/services/health-score-calculator.test.js +400 -0
  16. package/infrastructure/domains/health/domain/services/mismatch-analyzer.js +234 -0
  17. package/infrastructure/domains/health/domain/services/mismatch-analyzer.test.js +431 -0
  18. package/infrastructure/domains/health/domain/value-objects/stack-identifier.js +13 -0
  19. package/infrastructure/domains/health/domain/value-objects/stack-identifier.test.js +29 -0
  20. package/package.json +6 -6
@@ -31,24 +31,48 @@ describe('PropertyMismatch', () => {
31
31
  }).toThrow('propertyPath is required');
32
32
  });
33
33
 
34
- it('should require expectedValue', () => {
34
+ it('should require expectedValue parameter', () => {
35
35
  expect(() => {
36
36
  new PropertyMismatch({
37
37
  propertyPath: 'Properties.Name',
38
38
  actualValue: 'value2',
39
39
  mutability: PropertyMutability.MUTABLE,
40
40
  });
41
- }).toThrow('expectedValue is required');
41
+ }).toThrow('expectedValue must be provided');
42
42
  });
43
43
 
44
- it('should require actualValue', () => {
44
+ it('should require actualValue parameter', () => {
45
45
  expect(() => {
46
46
  new PropertyMismatch({
47
47
  propertyPath: 'Properties.Name',
48
48
  expectedValue: 'value1',
49
49
  mutability: PropertyMutability.MUTABLE,
50
50
  });
51
- }).toThrow('actualValue is required');
51
+ }).toThrow('actualValue must be provided');
52
+ });
53
+
54
+ it('should accept undefined as expectedValue', () => {
55
+ const mismatch = new PropertyMismatch({
56
+ propertyPath: 'Properties.NewProperty',
57
+ expectedValue: undefined,
58
+ actualValue: 'new-value',
59
+ mutability: PropertyMutability.MUTABLE,
60
+ });
61
+
62
+ expect(mismatch.expectedValue).toBeUndefined();
63
+ expect(mismatch.actualValue).toBe('new-value');
64
+ });
65
+
66
+ it('should accept undefined as actualValue', () => {
67
+ const mismatch = new PropertyMismatch({
68
+ propertyPath: 'Properties.OldProperty',
69
+ expectedValue: 'old-value',
70
+ actualValue: undefined,
71
+ mutability: PropertyMutability.MUTABLE,
72
+ });
73
+
74
+ expect(mismatch.expectedValue).toBe('old-value');
75
+ expect(mismatch.actualValue).toBeUndefined();
52
76
  });
53
77
 
54
78
  it('should require mutability', () => {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Resource Entity
3
+ *
4
+ * Represents a CloudFormation resource with its current state and any detected issues
5
+ */
6
+
7
+ const ResourceState = require('../value-objects/resource-state');
8
+
9
+ class Resource {
10
+ /**
11
+ * Create a new Resource
12
+ *
13
+ * @param {Object} params
14
+ * @param {string|null} params.logicalId - CloudFormation logical ID (null for orphaned resources)
15
+ * @param {string} params.physicalId - Physical resource ID in cloud provider
16
+ * @param {string} params.resourceType - CloudFormation resource type (e.g., AWS::EC2::VPC)
17
+ * @param {ResourceState} params.state - Resource state
18
+ * @param {Object} [params.properties={}] - Resource properties
19
+ * @param {Issue[]} [params.issues=[]] - Detected issues with this resource
20
+ */
21
+ constructor({
22
+ logicalId = null,
23
+ physicalId,
24
+ resourceType,
25
+ state,
26
+ properties = {},
27
+ issues = [],
28
+ }) {
29
+ // Validate required fields
30
+ if (physicalId === undefined || physicalId === null) {
31
+ throw new Error('physicalId is required');
32
+ }
33
+
34
+ if (!resourceType) {
35
+ throw new Error('resourceType is required');
36
+ }
37
+
38
+ if (!state) {
39
+ throw new Error('state is required');
40
+ }
41
+
42
+ if (!(state instanceof ResourceState)) {
43
+ throw new Error('state must be a ResourceState instance');
44
+ }
45
+
46
+ this.logicalId = logicalId;
47
+ this.physicalId = physicalId;
48
+ this.resourceType = resourceType;
49
+ this.state = state;
50
+ this.properties = properties;
51
+ this.issues = [...issues]; // Copy array to avoid mutations
52
+ }
53
+
54
+ /**
55
+ * Check if resource is in CloudFormation stack
56
+ * @returns {boolean}
57
+ */
58
+ isInStack() {
59
+ return this.state.isInStack();
60
+ }
61
+
62
+ /**
63
+ * Check if resource is orphaned (exists in cloud but not in stack)
64
+ * @returns {boolean}
65
+ */
66
+ isOrphaned() {
67
+ return this.state.isOrphaned();
68
+ }
69
+
70
+ /**
71
+ * Check if resource is missing (exists in stack but not in cloud)
72
+ * @returns {boolean}
73
+ */
74
+ isMissing() {
75
+ return this.state.isMissing();
76
+ }
77
+
78
+ /**
79
+ * Check if resource has drifted (properties differ)
80
+ * @returns {boolean}
81
+ */
82
+ isDrifted() {
83
+ return this.state.isDrifted();
84
+ }
85
+
86
+ /**
87
+ * Add an issue to this resource
88
+ * @param {Issue} issue
89
+ */
90
+ addIssue(issue) {
91
+ this.issues.push(issue);
92
+ }
93
+
94
+ /**
95
+ * Check if resource has any issues
96
+ * @returns {boolean}
97
+ */
98
+ hasIssues() {
99
+ return this.issues.length > 0;
100
+ }
101
+
102
+ /**
103
+ * Check if resource has critical issues
104
+ * @returns {boolean}
105
+ */
106
+ hasCriticalIssues() {
107
+ return this.issues.some(issue => issue.isCritical());
108
+ }
109
+
110
+ /**
111
+ * Get all critical issues
112
+ * @returns {Issue[]}
113
+ */
114
+ getCriticalIssues() {
115
+ return this.issues.filter(issue => issue.isCritical());
116
+ }
117
+
118
+ /**
119
+ * Check if resource is healthy (no issues)
120
+ * @returns {boolean}
121
+ */
122
+ isHealthy() {
123
+ return !this.hasIssues();
124
+ }
125
+
126
+ /**
127
+ * Get resource identifier (logical ID or physical ID)
128
+ * @returns {string}
129
+ */
130
+ getIdentifier() {
131
+ return this.logicalId || this.physicalId;
132
+ }
133
+
134
+ /**
135
+ * Get string representation
136
+ * @returns {string}
137
+ */
138
+ toString() {
139
+ return `Resource: ${this.resourceType} [${this.state.toString()}] - LogicalId: ${this.logicalId}, PhysicalId: ${this.physicalId}`;
140
+ }
141
+
142
+ /**
143
+ * Serialize to JSON
144
+ * @returns {Object}
145
+ */
146
+ toJSON() {
147
+ return {
148
+ logicalId: this.logicalId,
149
+ physicalId: this.physicalId,
150
+ resourceType: this.resourceType,
151
+ state: this.state.toString(),
152
+ properties: this.properties,
153
+ issues: this.issues.map(issue => issue.toJSON()),
154
+ isHealthy: this.isHealthy(),
155
+ };
156
+ }
157
+ }
158
+
159
+ module.exports = Resource;
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Tests for Resource Entity
3
+ */
4
+
5
+ const Resource = require('./resource');
6
+ const ResourceState = require('../value-objects/resource-state');
7
+ const Issue = require('./issue');
8
+ const PropertyMismatch = require('./property-mismatch');
9
+ const PropertyMutability = require('../value-objects/property-mutability');
10
+
11
+ describe('Resource', () => {
12
+ describe('constructor', () => {
13
+ it('should create a resource in stack', () => {
14
+ const resource = new Resource({
15
+ logicalId: 'ProductionVPC',
16
+ physicalId: 'vpc-0abc123def456',
17
+ resourceType: 'AWS::EC2::VPC',
18
+ state: ResourceState.IN_STACK,
19
+ properties: {
20
+ CidrBlock: '10.0.0.0/16',
21
+ Tags: [{ Key: 'Environment', Value: 'production' }],
22
+ },
23
+ });
24
+
25
+ expect(resource.logicalId).toBe('ProductionVPC');
26
+ expect(resource.physicalId).toBe('vpc-0abc123def456');
27
+ expect(resource.resourceType).toBe('AWS::EC2::VPC');
28
+ expect(resource.state.value).toBe('IN_STACK');
29
+ expect(resource.properties.CidrBlock).toBe('10.0.0.0/16');
30
+ });
31
+
32
+ it('should create an orphaned resource', () => {
33
+ const resource = new Resource({
34
+ logicalId: null,
35
+ physicalId: 'my-app-prod-aurora',
36
+ resourceType: 'AWS::RDS::DBCluster',
37
+ state: ResourceState.ORPHANED,
38
+ properties: {
39
+ Engine: 'aurora-postgresql',
40
+ EngineVersion: '13.7',
41
+ },
42
+ });
43
+
44
+ expect(resource.logicalId).toBeNull();
45
+ expect(resource.physicalId).toBe('my-app-prod-aurora');
46
+ expect(resource.state.value).toBe('ORPHANED');
47
+ });
48
+
49
+ it('should require physicalId', () => {
50
+ expect(() => {
51
+ new Resource({
52
+ logicalId: 'MyResource',
53
+ resourceType: 'AWS::S3::Bucket',
54
+ state: ResourceState.IN_STACK,
55
+ });
56
+ }).toThrow('physicalId is required');
57
+ });
58
+
59
+ it('should require resourceType', () => {
60
+ expect(() => {
61
+ new Resource({
62
+ logicalId: 'MyResource',
63
+ physicalId: 'my-bucket',
64
+ state: ResourceState.IN_STACK,
65
+ });
66
+ }).toThrow('resourceType is required');
67
+ });
68
+
69
+ it('should require state', () => {
70
+ expect(() => {
71
+ new Resource({
72
+ logicalId: 'MyResource',
73
+ physicalId: 'my-bucket',
74
+ resourceType: 'AWS::S3::Bucket',
75
+ });
76
+ }).toThrow('state is required');
77
+ });
78
+
79
+ it('should validate state is ResourceState instance', () => {
80
+ expect(() => {
81
+ new Resource({
82
+ logicalId: 'MyResource',
83
+ physicalId: 'my-bucket',
84
+ resourceType: 'AWS::S3::Bucket',
85
+ state: 'IN_STACK', // String instead of ResourceState
86
+ });
87
+ }).toThrow('state must be a ResourceState instance');
88
+ });
89
+
90
+ it('should initialize empty issues array', () => {
91
+ const resource = new Resource({
92
+ logicalId: 'MyVPC',
93
+ physicalId: 'vpc-123',
94
+ resourceType: 'AWS::EC2::VPC',
95
+ state: ResourceState.IN_STACK,
96
+ });
97
+
98
+ expect(resource.issues).toEqual([]);
99
+ });
100
+
101
+ it('should initialize with provided issues', () => {
102
+ const issue = Issue.orphanedResource({
103
+ resourceType: 'AWS::RDS::DBCluster',
104
+ resourceId: 'my-cluster',
105
+ description: 'Test',
106
+ });
107
+
108
+ const resource = new Resource({
109
+ logicalId: null,
110
+ physicalId: 'my-cluster',
111
+ resourceType: 'AWS::RDS::DBCluster',
112
+ state: ResourceState.ORPHANED,
113
+ issues: [issue],
114
+ });
115
+
116
+ expect(resource.issues).toHaveLength(1);
117
+ expect(resource.issues[0]).toBe(issue);
118
+ });
119
+
120
+ it('should default properties to empty object', () => {
121
+ const resource = new Resource({
122
+ logicalId: 'MyVPC',
123
+ physicalId: 'vpc-123',
124
+ resourceType: 'AWS::EC2::VPC',
125
+ state: ResourceState.IN_STACK,
126
+ });
127
+
128
+ expect(resource.properties).toEqual({});
129
+ });
130
+ });
131
+
132
+ describe('state checks', () => {
133
+ it('should check if resource is in stack', () => {
134
+ const resource = new Resource({
135
+ logicalId: 'MyVPC',
136
+ physicalId: 'vpc-123',
137
+ resourceType: 'AWS::EC2::VPC',
138
+ state: ResourceState.IN_STACK,
139
+ });
140
+
141
+ expect(resource.isInStack()).toBe(true);
142
+ expect(resource.isOrphaned()).toBe(false);
143
+ expect(resource.isMissing()).toBe(false);
144
+ expect(resource.isDrifted()).toBe(false);
145
+ });
146
+
147
+ it('should check if resource is orphaned', () => {
148
+ const resource = new Resource({
149
+ logicalId: null,
150
+ physicalId: 'my-cluster',
151
+ resourceType: 'AWS::RDS::DBCluster',
152
+ state: ResourceState.ORPHANED,
153
+ });
154
+
155
+ expect(resource.isInStack()).toBe(false);
156
+ expect(resource.isOrphaned()).toBe(true);
157
+ expect(resource.isMissing()).toBe(false);
158
+ expect(resource.isDrifted()).toBe(false);
159
+ });
160
+
161
+ it('should check if resource is missing', () => {
162
+ const resource = new Resource({
163
+ logicalId: 'FriggKMSKey',
164
+ physicalId: 'key-id-that-does-not-exist',
165
+ resourceType: 'AWS::KMS::Key',
166
+ state: ResourceState.MISSING,
167
+ });
168
+
169
+ expect(resource.isInStack()).toBe(false);
170
+ expect(resource.isOrphaned()).toBe(false);
171
+ expect(resource.isMissing()).toBe(true);
172
+ expect(resource.isDrifted()).toBe(false);
173
+ });
174
+
175
+ it('should check if resource is drifted', () => {
176
+ const resource = new Resource({
177
+ logicalId: 'MyVPC',
178
+ physicalId: 'vpc-123',
179
+ resourceType: 'AWS::EC2::VPC',
180
+ state: ResourceState.DRIFTED,
181
+ });
182
+
183
+ expect(resource.isInStack()).toBe(false);
184
+ expect(resource.isOrphaned()).toBe(false);
185
+ expect(resource.isMissing()).toBe(false);
186
+ expect(resource.isDrifted()).toBe(true);
187
+ });
188
+ });
189
+
190
+ describe('issue management', () => {
191
+ it('should add an issue', () => {
192
+ const resource = new Resource({
193
+ logicalId: 'MyVPC',
194
+ physicalId: 'vpc-123',
195
+ resourceType: 'AWS::EC2::VPC',
196
+ state: ResourceState.IN_STACK,
197
+ });
198
+
199
+ const issue = Issue.propertyMismatch({
200
+ resourceType: 'AWS::EC2::VPC',
201
+ resourceId: 'vpc-123',
202
+ mismatch: new PropertyMismatch({
203
+ propertyPath: 'Properties.Tags',
204
+ expectedValue: ['tag1'],
205
+ actualValue: ['tag2'],
206
+ mutability: PropertyMutability.MUTABLE,
207
+ }),
208
+ });
209
+
210
+ resource.addIssue(issue);
211
+
212
+ expect(resource.issues).toHaveLength(1);
213
+ expect(resource.issues[0]).toBe(issue);
214
+ });
215
+
216
+ it('should check if resource has issues', () => {
217
+ const resource = new Resource({
218
+ logicalId: 'MyVPC',
219
+ physicalId: 'vpc-123',
220
+ resourceType: 'AWS::EC2::VPC',
221
+ state: ResourceState.IN_STACK,
222
+ });
223
+
224
+ expect(resource.hasIssues()).toBe(false);
225
+
226
+ const issue = Issue.orphanedResource({
227
+ resourceType: 'AWS::EC2::VPC',
228
+ resourceId: 'vpc-123',
229
+ description: 'Test',
230
+ });
231
+ resource.addIssue(issue);
232
+
233
+ expect(resource.hasIssues()).toBe(true);
234
+ });
235
+
236
+ it('should check if resource has critical issues', () => {
237
+ const resource = new Resource({
238
+ logicalId: 'MyVPC',
239
+ physicalId: 'vpc-123',
240
+ resourceType: 'AWS::EC2::VPC',
241
+ state: ResourceState.IN_STACK,
242
+ });
243
+
244
+ expect(resource.hasCriticalIssues()).toBe(false);
245
+
246
+ // Add warning issue
247
+ const warningIssue = new Issue({
248
+ type: 'PROPERTY_MISMATCH',
249
+ severity: 'warning',
250
+ resourceType: 'AWS::EC2::VPC',
251
+ resourceId: 'vpc-123',
252
+ description: 'Test warning',
253
+ });
254
+ resource.addIssue(warningIssue);
255
+ expect(resource.hasCriticalIssues()).toBe(false);
256
+
257
+ // Add critical issue
258
+ const criticalIssue = Issue.orphanedResource({
259
+ resourceType: 'AWS::EC2::VPC',
260
+ resourceId: 'vpc-123',
261
+ description: 'Test critical',
262
+ });
263
+ resource.addIssue(criticalIssue);
264
+ expect(resource.hasCriticalIssues()).toBe(true);
265
+ });
266
+
267
+ it('should get critical issues', () => {
268
+ const resource = new Resource({
269
+ logicalId: 'MyVPC',
270
+ physicalId: 'vpc-123',
271
+ resourceType: 'AWS::EC2::VPC',
272
+ state: ResourceState.IN_STACK,
273
+ });
274
+
275
+ const warningIssue = new Issue({
276
+ type: 'PROPERTY_MISMATCH',
277
+ severity: 'warning',
278
+ resourceType: 'AWS::EC2::VPC',
279
+ resourceId: 'vpc-123',
280
+ description: 'Warning',
281
+ });
282
+
283
+ const criticalIssue1 = Issue.orphanedResource({
284
+ resourceType: 'AWS::EC2::VPC',
285
+ resourceId: 'vpc-123',
286
+ description: 'Critical 1',
287
+ });
288
+
289
+ const criticalIssue2 = Issue.missingResource({
290
+ resourceType: 'AWS::EC2::VPC',
291
+ resourceId: 'vpc-123',
292
+ description: 'Critical 2',
293
+ });
294
+
295
+ resource.addIssue(warningIssue);
296
+ resource.addIssue(criticalIssue1);
297
+ resource.addIssue(criticalIssue2);
298
+
299
+ const criticalIssues = resource.getCriticalIssues();
300
+ expect(criticalIssues).toHaveLength(2);
301
+ expect(criticalIssues).toContain(criticalIssue1);
302
+ expect(criticalIssues).toContain(criticalIssue2);
303
+ });
304
+ });
305
+
306
+ describe('isHealthy', () => {
307
+ it('should be healthy with no issues', () => {
308
+ const resource = new Resource({
309
+ logicalId: 'MyVPC',
310
+ physicalId: 'vpc-123',
311
+ resourceType: 'AWS::EC2::VPC',
312
+ state: ResourceState.IN_STACK,
313
+ });
314
+
315
+ expect(resource.isHealthy()).toBe(true);
316
+ });
317
+
318
+ it('should not be healthy with issues', () => {
319
+ const resource = new Resource({
320
+ logicalId: 'MyVPC',
321
+ physicalId: 'vpc-123',
322
+ resourceType: 'AWS::EC2::VPC',
323
+ state: ResourceState.IN_STACK,
324
+ });
325
+
326
+ const issue = Issue.propertyMismatch({
327
+ resourceType: 'AWS::EC2::VPC',
328
+ resourceId: 'vpc-123',
329
+ mismatch: new PropertyMismatch({
330
+ propertyPath: 'Properties.Tags',
331
+ expectedValue: ['tag1'],
332
+ actualValue: ['tag2'],
333
+ mutability: PropertyMutability.MUTABLE,
334
+ }),
335
+ });
336
+
337
+ resource.addIssue(issue);
338
+ expect(resource.isHealthy()).toBe(false);
339
+ });
340
+ });
341
+
342
+ describe('getIdentifier', () => {
343
+ it('should return logical ID if present', () => {
344
+ const resource = new Resource({
345
+ logicalId: 'ProductionVPC',
346
+ physicalId: 'vpc-123',
347
+ resourceType: 'AWS::EC2::VPC',
348
+ state: ResourceState.IN_STACK,
349
+ });
350
+
351
+ expect(resource.getIdentifier()).toBe('ProductionVPC');
352
+ });
353
+
354
+ it('should return physical ID if no logical ID', () => {
355
+ const resource = new Resource({
356
+ logicalId: null,
357
+ physicalId: 'vpc-123',
358
+ resourceType: 'AWS::EC2::VPC',
359
+ state: ResourceState.ORPHANED,
360
+ });
361
+
362
+ expect(resource.getIdentifier()).toBe('vpc-123');
363
+ });
364
+ });
365
+
366
+ describe('toString', () => {
367
+ it('should return string representation', () => {
368
+ const resource = new Resource({
369
+ logicalId: 'ProductionVPC',
370
+ physicalId: 'vpc-123',
371
+ resourceType: 'AWS::EC2::VPC',
372
+ state: ResourceState.IN_STACK,
373
+ });
374
+
375
+ const str = resource.toString();
376
+ expect(str).toContain('AWS::EC2::VPC');
377
+ expect(str).toContain('ProductionVPC');
378
+ expect(str).toContain('vpc-123');
379
+ expect(str).toContain('IN_STACK');
380
+ });
381
+ });
382
+
383
+ describe('toJSON', () => {
384
+ it('should serialize to JSON', () => {
385
+ const resource = new Resource({
386
+ logicalId: 'ProductionVPC',
387
+ physicalId: 'vpc-123',
388
+ resourceType: 'AWS::EC2::VPC',
389
+ state: ResourceState.IN_STACK,
390
+ properties: {
391
+ CidrBlock: '10.0.0.0/16',
392
+ },
393
+ });
394
+
395
+ const json = resource.toJSON();
396
+
397
+ expect(json).toEqual({
398
+ logicalId: 'ProductionVPC',
399
+ physicalId: 'vpc-123',
400
+ resourceType: 'AWS::EC2::VPC',
401
+ state: 'IN_STACK',
402
+ properties: {
403
+ CidrBlock: '10.0.0.0/16',
404
+ },
405
+ issues: [],
406
+ isHealthy: true,
407
+ });
408
+ });
409
+
410
+ it('should include issues in JSON', () => {
411
+ const resource = new Resource({
412
+ logicalId: 'MyVPC',
413
+ physicalId: 'vpc-123',
414
+ resourceType: 'AWS::EC2::VPC',
415
+ state: ResourceState.DRIFTED,
416
+ });
417
+
418
+ const issue = Issue.orphanedResource({
419
+ resourceType: 'AWS::EC2::VPC',
420
+ resourceId: 'vpc-123',
421
+ description: 'Test',
422
+ });
423
+ resource.addIssue(issue);
424
+
425
+ const json = resource.toJSON();
426
+
427
+ expect(json.issues).toHaveLength(1);
428
+ expect(json.issues[0].type).toBe('ORPHANED_RESOURCE');
429
+ expect(json.isHealthy).toBe(false);
430
+ });
431
+ });
432
+ });