@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
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Issue Entity
3
+ *
4
+ * Represents a problem detected in infrastructure health check
5
+ */
6
+
7
+ class Issue {
8
+ /**
9
+ * Valid issue types
10
+ */
11
+ static TYPES = {
12
+ ORPHANED_RESOURCE: 'ORPHANED_RESOURCE',
13
+ MISSING_RESOURCE: 'MISSING_RESOURCE',
14
+ PROPERTY_MISMATCH: 'PROPERTY_MISMATCH',
15
+ DRIFTED_RESOURCE: 'DRIFTED_RESOURCE',
16
+ MISSING_TAG: 'MISSING_TAG',
17
+ };
18
+
19
+ /**
20
+ * Valid severity levels
21
+ */
22
+ static SEVERITIES = {
23
+ CRITICAL: 'critical',
24
+ WARNING: 'warning',
25
+ INFO: 'info',
26
+ };
27
+
28
+ /**
29
+ * Create a new Issue
30
+ *
31
+ * @param {Object} params
32
+ * @param {string} params.type - Issue type (ORPHANED_RESOURCE, MISSING_RESOURCE, etc.)
33
+ * @param {string} params.severity - Severity level (critical, warning, info)
34
+ * @param {string} params.resourceType - CloudFormation resource type
35
+ * @param {string} params.resourceId - Resource identifier (physical or logical ID)
36
+ * @param {string} params.description - Human-readable description
37
+ * @param {string} [params.resolution] - Suggested resolution
38
+ * @param {boolean} [params.canAutoFix=false] - Whether issue can be automatically fixed
39
+ * @param {PropertyMismatch} [params.propertyMismatch] - Property mismatch details (for PROPERTY_MISMATCH type)
40
+ */
41
+ constructor({
42
+ type,
43
+ severity,
44
+ resourceType,
45
+ resourceId,
46
+ description,
47
+ resolution = null,
48
+ canAutoFix = false,
49
+ propertyMismatch = null,
50
+ }) {
51
+ // Validate required fields
52
+ if (!type) {
53
+ throw new Error('type is required');
54
+ }
55
+
56
+ if (!severity) {
57
+ throw new Error('severity is required');
58
+ }
59
+
60
+ if (!resourceType) {
61
+ throw new Error('resourceType is required');
62
+ }
63
+
64
+ if (!resourceId) {
65
+ throw new Error('resourceId is required');
66
+ }
67
+
68
+ if (!description) {
69
+ throw new Error('description is required');
70
+ }
71
+
72
+ // Validate type
73
+ if (!Object.values(Issue.TYPES).includes(type)) {
74
+ throw new Error(`Invalid issue type: ${type}`);
75
+ }
76
+
77
+ // Validate severity
78
+ if (!Object.values(Issue.SEVERITIES).includes(severity)) {
79
+ throw new Error(`Invalid severity: ${severity}`);
80
+ }
81
+
82
+ this.type = type;
83
+ this.severity = severity;
84
+ this.resourceType = resourceType;
85
+ this.resourceId = resourceId;
86
+ this.description = description;
87
+ this.resolution = resolution;
88
+ this.canAutoFix = canAutoFix;
89
+ this.propertyMismatch = propertyMismatch;
90
+ }
91
+
92
+ /**
93
+ * Check if issue is an orphaned resource
94
+ * @returns {boolean}
95
+ */
96
+ isOrphanedResource() {
97
+ return this.type === Issue.TYPES.ORPHANED_RESOURCE;
98
+ }
99
+
100
+ /**
101
+ * Check if issue is a missing resource
102
+ * @returns {boolean}
103
+ */
104
+ isMissingResource() {
105
+ return this.type === Issue.TYPES.MISSING_RESOURCE;
106
+ }
107
+
108
+ /**
109
+ * Check if issue is a property mismatch
110
+ * @returns {boolean}
111
+ */
112
+ isPropertyMismatch() {
113
+ return this.type === Issue.TYPES.PROPERTY_MISMATCH;
114
+ }
115
+
116
+ /**
117
+ * Check if issue is a drifted resource
118
+ * @returns {boolean}
119
+ */
120
+ isDrifted() {
121
+ return this.type === Issue.TYPES.DRIFTED_RESOURCE;
122
+ }
123
+
124
+ /**
125
+ * Check if issue is critical severity
126
+ * @returns {boolean}
127
+ */
128
+ isCritical() {
129
+ return this.severity === Issue.SEVERITIES.CRITICAL;
130
+ }
131
+
132
+ /**
133
+ * Check if issue is warning severity
134
+ * @returns {boolean}
135
+ */
136
+ isWarning() {
137
+ return this.severity === Issue.SEVERITIES.WARNING;
138
+ }
139
+
140
+ /**
141
+ * Check if issue is info severity
142
+ * @returns {boolean}
143
+ */
144
+ isInfo() {
145
+ return this.severity === Issue.SEVERITIES.INFO;
146
+ }
147
+
148
+ /**
149
+ * Get string representation
150
+ * @returns {string}
151
+ */
152
+ toString() {
153
+ return `Issue: ${this.type} [${this.severity}] - ${this.resourceType} (${this.resourceId}): ${this.description}`;
154
+ }
155
+
156
+ /**
157
+ * Serialize to JSON
158
+ * @returns {Object}
159
+ */
160
+ toJSON() {
161
+ return {
162
+ type: this.type,
163
+ severity: this.severity,
164
+ resourceType: this.resourceType,
165
+ resourceId: this.resourceId,
166
+ description: this.description,
167
+ resolution: this.resolution,
168
+ canAutoFix: this.canAutoFix,
169
+ propertyMismatch: this.propertyMismatch ? this.propertyMismatch.toJSON() : null,
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Create an orphaned resource issue
175
+ *
176
+ * @param {Object} params
177
+ * @param {string} params.resourceType - CloudFormation resource type
178
+ * @param {string} params.resourceId - Resource physical ID
179
+ * @param {string} params.description - Issue description
180
+ * @returns {Issue}
181
+ */
182
+ static orphanedResource({ resourceType, resourceId, description }) {
183
+ return new Issue({
184
+ type: Issue.TYPES.ORPHANED_RESOURCE,
185
+ severity: Issue.SEVERITIES.CRITICAL,
186
+ resourceType,
187
+ resourceId,
188
+ description,
189
+ resolution: 'Import resource into CloudFormation stack using frigg repair --import',
190
+ canAutoFix: true,
191
+ });
192
+ }
193
+
194
+ /**
195
+ * Create a missing resource issue
196
+ *
197
+ * @param {Object} params
198
+ * @param {string} params.resourceType - CloudFormation resource type
199
+ * @param {string} params.resourceId - Resource logical ID
200
+ * @param {string} params.description - Issue description
201
+ * @returns {Issue}
202
+ */
203
+ static missingResource({ resourceType, resourceId, description }) {
204
+ return new Issue({
205
+ type: Issue.TYPES.MISSING_RESOURCE,
206
+ severity: Issue.SEVERITIES.CRITICAL,
207
+ resourceType,
208
+ resourceId,
209
+ description,
210
+ resolution: 'Verify resource was not manually deleted. May need to recreate or remove from stack definition.',
211
+ canAutoFix: false,
212
+ });
213
+ }
214
+
215
+ /**
216
+ * Create a property mismatch issue
217
+ *
218
+ * @param {Object} params
219
+ * @param {string} params.resourceType - CloudFormation resource type
220
+ * @param {string} params.resourceId - Resource identifier
221
+ * @param {PropertyMismatch} params.mismatch - Property mismatch details
222
+ * @returns {Issue}
223
+ */
224
+ static propertyMismatch({ resourceType, resourceId, mismatch }) {
225
+ const severity = mismatch.requiresReplacement()
226
+ ? Issue.SEVERITIES.CRITICAL
227
+ : Issue.SEVERITIES.WARNING;
228
+
229
+ const canAutoFix = mismatch.canAutoFix();
230
+
231
+ const description = `Property mismatch: ${mismatch.propertyPath} (expected: ${mismatch.expectedValue}, actual: ${mismatch.actualValue})`;
232
+
233
+ const resolution = canAutoFix
234
+ ? 'Can be auto-fixed using frigg repair --reconcile'
235
+ : 'Requires resource replacement - manual intervention needed';
236
+
237
+ return new Issue({
238
+ type: Issue.TYPES.PROPERTY_MISMATCH,
239
+ severity,
240
+ resourceType,
241
+ resourceId,
242
+ description,
243
+ resolution,
244
+ canAutoFix,
245
+ propertyMismatch: mismatch,
246
+ });
247
+ }
248
+ }
249
+
250
+ module.exports = Issue;
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Tests for Issue Entity
3
+ */
4
+
5
+ const Issue = require('./issue');
6
+ const ResourceState = require('../value-objects/resource-state');
7
+ const PropertyMismatch = require('./property-mismatch');
8
+ const PropertyMutability = require('../value-objects/property-mutability');
9
+
10
+ describe('Issue', () => {
11
+ describe('constructor', () => {
12
+ it('should create an orphaned resource issue', () => {
13
+ const issue = new Issue({
14
+ type: 'ORPHANED_RESOURCE',
15
+ severity: 'critical',
16
+ resourceType: 'AWS::RDS::DBCluster',
17
+ resourceId: 'my-app-prod-aurora',
18
+ description: 'Aurora cluster exists in AWS but not managed by CloudFormation',
19
+ resolution: 'Import resource into CloudFormation stack',
20
+ canAutoFix: true,
21
+ });
22
+
23
+ expect(issue.type).toBe('ORPHANED_RESOURCE');
24
+ expect(issue.severity).toBe('critical');
25
+ expect(issue.resourceType).toBe('AWS::RDS::DBCluster');
26
+ expect(issue.resourceId).toBe('my-app-prod-aurora');
27
+ expect(issue.description).toBe('Aurora cluster exists in AWS but not managed by CloudFormation');
28
+ expect(issue.resolution).toBe('Import resource into CloudFormation stack');
29
+ expect(issue.canAutoFix).toBe(true);
30
+ });
31
+
32
+ it('should create a missing resource issue', () => {
33
+ const issue = new Issue({
34
+ type: 'MISSING_RESOURCE',
35
+ severity: 'critical',
36
+ resourceType: 'AWS::KMS::Key',
37
+ resourceId: 'FriggKMSKey',
38
+ description: 'KMS key defined in stack but does not exist in AWS',
39
+ resolution: 'Verify resource was not manually deleted',
40
+ canAutoFix: false,
41
+ });
42
+
43
+ expect(issue.type).toBe('MISSING_RESOURCE');
44
+ expect(issue.severity).toBe('critical');
45
+ });
46
+
47
+ it('should create a property mismatch issue', () => {
48
+ const mismatch = new PropertyMismatch({
49
+ propertyPath: 'Properties.Tags',
50
+ expectedValue: [{ Key: 'Environment', Value: 'production' }],
51
+ actualValue: [{ Key: 'Env', Value: 'prod' }],
52
+ mutability: PropertyMutability.MUTABLE,
53
+ });
54
+
55
+ const issue = new Issue({
56
+ type: 'PROPERTY_MISMATCH',
57
+ severity: 'warning',
58
+ resourceType: 'AWS::EC2::VPC',
59
+ resourceId: 'vpc-0abc123',
60
+ description: 'VPC tags differ from expected configuration',
61
+ resolution: 'Update VPC tags to match desired state',
62
+ canAutoFix: true,
63
+ propertyMismatch: mismatch,
64
+ });
65
+
66
+ expect(issue.type).toBe('PROPERTY_MISMATCH');
67
+ expect(issue.propertyMismatch).toBe(mismatch);
68
+ expect(issue.canAutoFix).toBe(true);
69
+ });
70
+
71
+ it('should require type', () => {
72
+ expect(() => {
73
+ new Issue({
74
+ severity: 'critical',
75
+ resourceType: 'AWS::S3::Bucket',
76
+ resourceId: 'my-bucket',
77
+ description: 'Test',
78
+ resolution: 'Fix it',
79
+ });
80
+ }).toThrow('type is required');
81
+ });
82
+
83
+ it('should require severity', () => {
84
+ expect(() => {
85
+ new Issue({
86
+ type: 'ORPHANED_RESOURCE',
87
+ resourceType: 'AWS::S3::Bucket',
88
+ resourceId: 'my-bucket',
89
+ description: 'Test',
90
+ resolution: 'Fix it',
91
+ });
92
+ }).toThrow('severity is required');
93
+ });
94
+
95
+ it('should require resourceType', () => {
96
+ expect(() => {
97
+ new Issue({
98
+ type: 'ORPHANED_RESOURCE',
99
+ severity: 'critical',
100
+ resourceId: 'my-bucket',
101
+ description: 'Test',
102
+ resolution: 'Fix it',
103
+ });
104
+ }).toThrow('resourceType is required');
105
+ });
106
+
107
+ it('should require resourceId', () => {
108
+ expect(() => {
109
+ new Issue({
110
+ type: 'ORPHANED_RESOURCE',
111
+ severity: 'critical',
112
+ resourceType: 'AWS::S3::Bucket',
113
+ description: 'Test',
114
+ resolution: 'Fix it',
115
+ });
116
+ }).toThrow('resourceId is required');
117
+ });
118
+
119
+ it('should require description', () => {
120
+ expect(() => {
121
+ new Issue({
122
+ type: 'ORPHANED_RESOURCE',
123
+ severity: 'critical',
124
+ resourceType: 'AWS::S3::Bucket',
125
+ resourceId: 'my-bucket',
126
+ resolution: 'Fix it',
127
+ });
128
+ }).toThrow('description is required');
129
+ });
130
+
131
+ it('should validate issue type', () => {
132
+ expect(() => {
133
+ new Issue({
134
+ type: 'INVALID_TYPE',
135
+ severity: 'critical',
136
+ resourceType: 'AWS::S3::Bucket',
137
+ resourceId: 'my-bucket',
138
+ description: 'Test',
139
+ resolution: 'Fix it',
140
+ });
141
+ }).toThrow('Invalid issue type: INVALID_TYPE');
142
+ });
143
+
144
+ it('should validate severity', () => {
145
+ expect(() => {
146
+ new Issue({
147
+ type: 'ORPHANED_RESOURCE',
148
+ severity: 'invalid',
149
+ resourceType: 'AWS::S3::Bucket',
150
+ resourceId: 'my-bucket',
151
+ description: 'Test',
152
+ resolution: 'Fix it',
153
+ });
154
+ }).toThrow('Invalid severity: invalid');
155
+ });
156
+
157
+ it('should default canAutoFix to false', () => {
158
+ const issue = new Issue({
159
+ type: 'ORPHANED_RESOURCE',
160
+ severity: 'critical',
161
+ resourceType: 'AWS::S3::Bucket',
162
+ resourceId: 'my-bucket',
163
+ description: 'Test',
164
+ resolution: 'Fix it',
165
+ });
166
+
167
+ expect(issue.canAutoFix).toBe(false);
168
+ });
169
+ });
170
+
171
+ describe('type checks', () => {
172
+ it('should check if issue is orphaned resource', () => {
173
+ const issue = new Issue({
174
+ type: 'ORPHANED_RESOURCE',
175
+ severity: 'critical',
176
+ resourceType: 'AWS::RDS::DBCluster',
177
+ resourceId: 'my-cluster',
178
+ description: 'Test',
179
+ resolution: 'Import',
180
+ });
181
+
182
+ expect(issue.isOrphanedResource()).toBe(true);
183
+ expect(issue.isMissingResource()).toBe(false);
184
+ expect(issue.isPropertyMismatch()).toBe(false);
185
+ expect(issue.isDrifted()).toBe(false);
186
+ });
187
+
188
+ it('should check if issue is missing resource', () => {
189
+ const issue = new Issue({
190
+ type: 'MISSING_RESOURCE',
191
+ severity: 'critical',
192
+ resourceType: 'AWS::KMS::Key',
193
+ resourceId: 'FriggKMSKey',
194
+ description: 'Test',
195
+ resolution: 'Verify deletion',
196
+ });
197
+
198
+ expect(issue.isOrphanedResource()).toBe(false);
199
+ expect(issue.isMissingResource()).toBe(true);
200
+ expect(issue.isPropertyMismatch()).toBe(false);
201
+ expect(issue.isDrifted()).toBe(false);
202
+ });
203
+
204
+ it('should check if issue is property mismatch', () => {
205
+ const issue = new Issue({
206
+ type: 'PROPERTY_MISMATCH',
207
+ severity: 'warning',
208
+ resourceType: 'AWS::EC2::VPC',
209
+ resourceId: 'vpc-123',
210
+ description: 'Test',
211
+ resolution: 'Update',
212
+ });
213
+
214
+ expect(issue.isOrphanedResource()).toBe(false);
215
+ expect(issue.isMissingResource()).toBe(false);
216
+ expect(issue.isPropertyMismatch()).toBe(true);
217
+ expect(issue.isDrifted()).toBe(false);
218
+ });
219
+
220
+ it('should check if issue is drifted', () => {
221
+ const issue = new Issue({
222
+ type: 'DRIFTED_RESOURCE',
223
+ severity: 'warning',
224
+ resourceType: 'AWS::S3::Bucket',
225
+ resourceId: 'my-bucket',
226
+ description: 'Test',
227
+ resolution: 'Reconcile',
228
+ });
229
+
230
+ expect(issue.isOrphanedResource()).toBe(false);
231
+ expect(issue.isMissingResource()).toBe(false);
232
+ expect(issue.isPropertyMismatch()).toBe(false);
233
+ expect(issue.isDrifted()).toBe(true);
234
+ });
235
+ });
236
+
237
+ describe('severity checks', () => {
238
+ it('should check if issue is critical', () => {
239
+ const issue = new Issue({
240
+ type: 'ORPHANED_RESOURCE',
241
+ severity: 'critical',
242
+ resourceType: 'AWS::RDS::DBCluster',
243
+ resourceId: 'my-cluster',
244
+ description: 'Test',
245
+ resolution: 'Import',
246
+ });
247
+
248
+ expect(issue.isCritical()).toBe(true);
249
+ expect(issue.isWarning()).toBe(false);
250
+ expect(issue.isInfo()).toBe(false);
251
+ });
252
+
253
+ it('should check if issue is warning', () => {
254
+ const issue = new Issue({
255
+ type: 'PROPERTY_MISMATCH',
256
+ severity: 'warning',
257
+ resourceType: 'AWS::EC2::VPC',
258
+ resourceId: 'vpc-123',
259
+ description: 'Test',
260
+ resolution: 'Update',
261
+ });
262
+
263
+ expect(issue.isCritical()).toBe(false);
264
+ expect(issue.isWarning()).toBe(true);
265
+ expect(issue.isInfo()).toBe(false);
266
+ });
267
+
268
+ it('should check if issue is info', () => {
269
+ const issue = new Issue({
270
+ type: 'MISSING_TAG',
271
+ severity: 'info',
272
+ resourceType: 'AWS::Lambda::Function',
273
+ resourceId: 'my-function',
274
+ description: 'Test',
275
+ resolution: 'Add tag',
276
+ });
277
+
278
+ expect(issue.isCritical()).toBe(false);
279
+ expect(issue.isWarning()).toBe(false);
280
+ expect(issue.isInfo()).toBe(true);
281
+ });
282
+ });
283
+
284
+ describe('toString', () => {
285
+ it('should return string representation', () => {
286
+ const issue = new Issue({
287
+ type: 'ORPHANED_RESOURCE',
288
+ severity: 'critical',
289
+ resourceType: 'AWS::RDS::DBCluster',
290
+ resourceId: 'my-app-prod-aurora',
291
+ description: 'Aurora cluster exists in AWS but not managed by CloudFormation',
292
+ resolution: 'Import resource into CloudFormation stack',
293
+ });
294
+
295
+ const str = issue.toString();
296
+ expect(str).toContain('ORPHANED_RESOURCE');
297
+ expect(str).toContain('critical');
298
+ expect(str).toContain('AWS::RDS::DBCluster');
299
+ expect(str).toContain('my-app-prod-aurora');
300
+ });
301
+ });
302
+
303
+ describe('toJSON', () => {
304
+ it('should serialize to JSON', () => {
305
+ const issue = new Issue({
306
+ type: 'ORPHANED_RESOURCE',
307
+ severity: 'critical',
308
+ resourceType: 'AWS::RDS::DBCluster',
309
+ resourceId: 'my-app-prod-aurora',
310
+ description: 'Aurora cluster exists in AWS but not managed by CloudFormation',
311
+ resolution: 'Import resource into CloudFormation stack',
312
+ canAutoFix: true,
313
+ });
314
+
315
+ const json = issue.toJSON();
316
+
317
+ expect(json).toEqual({
318
+ type: 'ORPHANED_RESOURCE',
319
+ severity: 'critical',
320
+ resourceType: 'AWS::RDS::DBCluster',
321
+ resourceId: 'my-app-prod-aurora',
322
+ description: 'Aurora cluster exists in AWS but not managed by CloudFormation',
323
+ resolution: 'Import resource into CloudFormation stack',
324
+ canAutoFix: true,
325
+ propertyMismatch: null,
326
+ });
327
+ });
328
+
329
+ it('should include property mismatch in JSON', () => {
330
+ const mismatch = new PropertyMismatch({
331
+ propertyPath: 'Properties.Tags',
332
+ expectedValue: [{ Key: 'Env', Value: 'prod' }],
333
+ actualValue: [{ Key: 'Env', Value: 'dev' }],
334
+ mutability: PropertyMutability.MUTABLE,
335
+ });
336
+
337
+ const issue = new Issue({
338
+ type: 'PROPERTY_MISMATCH',
339
+ severity: 'warning',
340
+ resourceType: 'AWS::EC2::VPC',
341
+ resourceId: 'vpc-123',
342
+ description: 'VPC tags differ',
343
+ resolution: 'Update tags',
344
+ canAutoFix: true,
345
+ propertyMismatch: mismatch,
346
+ });
347
+
348
+ const json = issue.toJSON();
349
+
350
+ expect(json.propertyMismatch).toBeDefined();
351
+ expect(json.propertyMismatch.propertyPath).toBe('Properties.Tags');
352
+ });
353
+ });
354
+
355
+ describe('static factory methods', () => {
356
+ it('should create orphaned resource issue', () => {
357
+ const issue = Issue.orphanedResource({
358
+ resourceType: 'AWS::RDS::DBCluster',
359
+ resourceId: 'my-cluster',
360
+ description: 'Cluster not in stack',
361
+ });
362
+
363
+ expect(issue.type).toBe('ORPHANED_RESOURCE');
364
+ expect(issue.severity).toBe('critical');
365
+ expect(issue.canAutoFix).toBe(true);
366
+ });
367
+
368
+ it('should create missing resource issue', () => {
369
+ const issue = Issue.missingResource({
370
+ resourceType: 'AWS::KMS::Key',
371
+ resourceId: 'FriggKMSKey',
372
+ description: 'Key not in AWS',
373
+ });
374
+
375
+ expect(issue.type).toBe('MISSING_RESOURCE');
376
+ expect(issue.severity).toBe('critical');
377
+ expect(issue.canAutoFix).toBe(false);
378
+ });
379
+
380
+ it('should create property mismatch issue', () => {
381
+ const mismatch = new PropertyMismatch({
382
+ propertyPath: 'Properties.Tags',
383
+ expectedValue: ['tag1'],
384
+ actualValue: ['tag2'],
385
+ mutability: PropertyMutability.MUTABLE,
386
+ });
387
+
388
+ const issue = Issue.propertyMismatch({
389
+ resourceType: 'AWS::EC2::VPC',
390
+ resourceId: 'vpc-123',
391
+ mismatch,
392
+ });
393
+
394
+ expect(issue.type).toBe('PROPERTY_MISMATCH');
395
+ expect(issue.severity).toBe('warning'); // Default for mutable
396
+ expect(issue.propertyMismatch).toBe(mismatch);
397
+ });
398
+
399
+ it('should create property mismatch with critical severity for immutable', () => {
400
+ const mismatch = new PropertyMismatch({
401
+ propertyPath: 'Properties.BucketName',
402
+ expectedValue: 'bucket-v2',
403
+ actualValue: 'bucket-v1',
404
+ mutability: PropertyMutability.IMMUTABLE,
405
+ });
406
+
407
+ const issue = Issue.propertyMismatch({
408
+ resourceType: 'AWS::S3::Bucket',
409
+ resourceId: 'my-bucket',
410
+ mismatch,
411
+ });
412
+
413
+ expect(issue.severity).toBe('critical'); // Critical for immutable
414
+ expect(issue.canAutoFix).toBe(false); // Can't auto-fix immutable
415
+ });
416
+ });
417
+ });
@@ -23,12 +23,15 @@ class PropertyMismatch {
23
23
  throw new Error('propertyPath is required');
24
24
  }
25
25
 
26
- if (expectedValue === undefined) {
27
- throw new Error('expectedValue is required');
26
+ // Note: expectedValue and actualValue can be undefined (for missing properties)
27
+ // They can also be null (explicit null value)
28
+ // Only check if they're provided in the params object at all
29
+ if (!('expectedValue' in arguments[0])) {
30
+ throw new Error('expectedValue must be provided (can be null or undefined)');
28
31
  }
29
32
 
30
- if (actualValue === undefined) {
31
- throw new Error('actualValue is required');
33
+ if (!('actualValue' in arguments[0])) {
34
+ throw new Error('actualValue must be provided (can be null or undefined)');
32
35
  }
33
36
 
34
37
  if (!mutability) {