@friggframework/devtools 2.0.0--canary.474.4793186.0 → 2.0.0--canary.474.82fd52e.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.
- package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.js +146 -0
- package/infrastructure/domains/health/application/use-cases/reconcile-properties-use-case.test.js +343 -0
- package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.js +229 -0
- package/infrastructure/domains/health/application/use-cases/repair-via-import-use-case.test.js +376 -0
- package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.js +180 -0
- package/infrastructure/domains/health/application/use-cases/run-health-check-use-case.test.js +441 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.js +397 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-property-reconciler.test.js +461 -0
- package/package.json +6 -6
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunHealthCheckUseCase - Orchestrate Complete Stack Health Check
|
|
3
|
+
*
|
|
4
|
+
* Application Layer - Use Case
|
|
5
|
+
*
|
|
6
|
+
* Business logic for the "frigg doctor" command. Orchestrates multiple
|
|
7
|
+
* repositories and domain services to produce a comprehensive health report.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - Coordinate stack information retrieval
|
|
11
|
+
* - Detect drift at stack and resource level
|
|
12
|
+
* - Find orphaned resources
|
|
13
|
+
* - Analyze property mismatches
|
|
14
|
+
* - Calculate health score
|
|
15
|
+
* - Build comprehensive health report
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const StackHealthReport = require('../../domain/entities/stack-health-report');
|
|
19
|
+
const Resource = require('../../domain/entities/resource');
|
|
20
|
+
const Issue = require('../../domain/entities/issue');
|
|
21
|
+
const ResourceState = require('../../domain/value-objects/resource-state');
|
|
22
|
+
|
|
23
|
+
class RunHealthCheckUseCase {
|
|
24
|
+
/**
|
|
25
|
+
* Create use case with required dependencies
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} params
|
|
28
|
+
* @param {IStackRepository} params.stackRepository - Stack operations
|
|
29
|
+
* @param {IResourceDetector} params.resourceDetector - Resource discovery
|
|
30
|
+
* @param {MismatchAnalyzer} params.mismatchAnalyzer - Property drift analysis
|
|
31
|
+
* @param {HealthScoreCalculator} params.healthScoreCalculator - Health scoring
|
|
32
|
+
*/
|
|
33
|
+
constructor({ stackRepository, resourceDetector, mismatchAnalyzer, healthScoreCalculator }) {
|
|
34
|
+
if (!stackRepository) {
|
|
35
|
+
throw new Error('stackRepository is required');
|
|
36
|
+
}
|
|
37
|
+
if (!resourceDetector) {
|
|
38
|
+
throw new Error('resourceDetector is required');
|
|
39
|
+
}
|
|
40
|
+
if (!mismatchAnalyzer) {
|
|
41
|
+
throw new Error('mismatchAnalyzer is required');
|
|
42
|
+
}
|
|
43
|
+
if (!healthScoreCalculator) {
|
|
44
|
+
throw new Error('healthScoreCalculator is required');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.stackRepository = stackRepository;
|
|
48
|
+
this.resourceDetector = resourceDetector;
|
|
49
|
+
this.mismatchAnalyzer = mismatchAnalyzer;
|
|
50
|
+
this.healthScoreCalculator = healthScoreCalculator;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Execute complete health check for a stack
|
|
55
|
+
*
|
|
56
|
+
* @param {Object} params
|
|
57
|
+
* @param {StackIdentifier} params.stackIdentifier - Stack to check
|
|
58
|
+
* @returns {Promise<StackHealthReport>} Comprehensive health report
|
|
59
|
+
*/
|
|
60
|
+
async execute({ stackIdentifier }) {
|
|
61
|
+
// 1. Verify stack exists
|
|
62
|
+
await this.stackRepository.getStack(stackIdentifier);
|
|
63
|
+
|
|
64
|
+
// 2. Detect stack-level drift
|
|
65
|
+
const driftDetection = await this.stackRepository.detectStackDrift(stackIdentifier);
|
|
66
|
+
|
|
67
|
+
// 3. Get all stack resources
|
|
68
|
+
const stackResources = await this.stackRepository.listResources(stackIdentifier);
|
|
69
|
+
|
|
70
|
+
// 4. Build resource entities with drift status
|
|
71
|
+
const resources = [];
|
|
72
|
+
const issues = [];
|
|
73
|
+
|
|
74
|
+
for (const stackResource of stackResources) {
|
|
75
|
+
let resourceState;
|
|
76
|
+
|
|
77
|
+
// Determine resource state
|
|
78
|
+
if (!stackResource.physicalId || stackResource.driftStatus === 'DELETED') {
|
|
79
|
+
// Missing resource (defined in template but doesn't exist in cloud)
|
|
80
|
+
resourceState = ResourceState.MISSING;
|
|
81
|
+
|
|
82
|
+
// Create issue for missing resource using factory method
|
|
83
|
+
issues.push(
|
|
84
|
+
Issue.missingResource({
|
|
85
|
+
resourceType: stackResource.resourceType,
|
|
86
|
+
resourceId: stackResource.logicalId,
|
|
87
|
+
description: `CloudFormation resource ${stackResource.logicalId} (${stackResource.resourceType}) is defined in the template but does not exist in the cloud.`,
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
} else if (stackResource.driftStatus === 'MODIFIED') {
|
|
91
|
+
// Drifted resource - get detailed drift information
|
|
92
|
+
resourceState = ResourceState.DRIFTED;
|
|
93
|
+
|
|
94
|
+
const resourceDrift = await this.stackRepository.getResourceDrift(
|
|
95
|
+
stackIdentifier,
|
|
96
|
+
stackResource.logicalId
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Analyze property mismatches using domain service
|
|
100
|
+
if (
|
|
101
|
+
resourceDrift.propertyDifferences &&
|
|
102
|
+
resourceDrift.propertyDifferences.length > 0
|
|
103
|
+
) {
|
|
104
|
+
const propertyMismatches = this.mismatchAnalyzer.analyzePropertyMismatches(
|
|
105
|
+
resourceDrift.propertyDifferences,
|
|
106
|
+
stackResource.resourceType
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Create issue for each property mismatch using factory method
|
|
110
|
+
for (const mismatch of propertyMismatches) {
|
|
111
|
+
issues.push(
|
|
112
|
+
Issue.propertyMismatch({
|
|
113
|
+
resourceType: stackResource.resourceType,
|
|
114
|
+
resourceId: stackResource.physicalId,
|
|
115
|
+
mismatch,
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
// Resource is in sync
|
|
122
|
+
resourceState = ResourceState.IN_STACK;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Create resource entity
|
|
126
|
+
// For missing resources, use placeholder physicalId since they don't exist in cloud
|
|
127
|
+
const resource = new Resource({
|
|
128
|
+
logicalId: stackResource.logicalId,
|
|
129
|
+
physicalId: stackResource.physicalId || `missing-${stackResource.logicalId}`,
|
|
130
|
+
resourceType: stackResource.resourceType,
|
|
131
|
+
state: resourceState,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
resources.push(resource);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 5. Find orphaned resources (exist in cloud but not in stack)
|
|
138
|
+
const orphanedResources = await this.resourceDetector.findOrphanedResources({
|
|
139
|
+
stackIdentifier,
|
|
140
|
+
stackResources,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
for (const orphan of orphanedResources) {
|
|
144
|
+
// Create resource entity for orphan
|
|
145
|
+
const orphanResource = new Resource({
|
|
146
|
+
logicalId: null, // No logical ID (not in template)
|
|
147
|
+
physicalId: orphan.physicalId,
|
|
148
|
+
resourceType: orphan.resourceType,
|
|
149
|
+
state: ResourceState.ORPHANED,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
resources.push(orphanResource);
|
|
153
|
+
|
|
154
|
+
// Create issue for orphaned resource using factory method
|
|
155
|
+
issues.push(
|
|
156
|
+
Issue.orphanedResource({
|
|
157
|
+
resourceType: orphan.resourceType,
|
|
158
|
+
resourceId: orphan.physicalId,
|
|
159
|
+
description: `Resource ${orphan.physicalId} exists in the cloud but is not managed by CloudFormation stack ${stackIdentifier.stackName}.`,
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 6. Calculate health score using domain service
|
|
165
|
+
const healthScore = this.healthScoreCalculator.calculate({ resources, issues });
|
|
166
|
+
|
|
167
|
+
// 7. Build comprehensive health report (aggregate root)
|
|
168
|
+
const report = new StackHealthReport({
|
|
169
|
+
stackIdentifier,
|
|
170
|
+
healthScore,
|
|
171
|
+
resources,
|
|
172
|
+
issues,
|
|
173
|
+
timestamp: new Date(),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return report;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = RunHealthCheckUseCase;
|
|
@@ -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
|
+
});
|