@friggframework/core 2.0.0-next.3 → 2.0.0-next.31
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/core/create-handler.js +2 -1
- package/database/models/WebsocketConnection.js +5 -0
- package/encrypt/encrypt.js +5 -33
- package/handlers/app-handler-helpers.js +59 -0
- package/handlers/backend-utils.js +85 -0
- package/handlers/routers/HEALTHCHECK.md +240 -0
- package/handlers/routers/auth.js +26 -0
- package/handlers/routers/health.js +451 -0
- package/handlers/routers/health.test.js +203 -0
- package/handlers/routers/integration-defined-routers.js +42 -0
- package/handlers/routers/middleware/loadUser.js +15 -0
- package/handlers/routers/middleware/requireLoggedInUser.js +12 -0
- package/handlers/routers/user.js +41 -0
- package/handlers/routers/websocket.js +55 -0
- package/handlers/workers/integration-defined-workers.js +24 -0
- package/index.js +4 -0
- package/integrations/integration-router.js +120 -96
- package/integrations/options.js +2 -3
- package/module-plugin/auther.js +97 -54
- package/module-plugin/requester/requester.js +1 -0
- package/package.json +20 -12
- package/utils/backend-path.js +38 -0
- package/utils/index.js +6 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
const { Router } = require('express');
|
|
2
|
+
const mongoose = require('mongoose');
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { moduleFactory, integrationFactory } = require('./../backend-utils');
|
|
6
|
+
const { createAppHandler } = require('./../app-handler-helpers');
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
const validateApiKey = (req, res, next) => {
|
|
11
|
+
const apiKey = req.headers['x-api-key'];
|
|
12
|
+
|
|
13
|
+
if (req.path === '/health') {
|
|
14
|
+
return next();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!apiKey || apiKey !== process.env.HEALTH_API_KEY) {
|
|
18
|
+
return res.status(401).json({
|
|
19
|
+
status: 'error',
|
|
20
|
+
message: 'Unauthorized'
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
next();
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
router.use(validateApiKey);
|
|
28
|
+
|
|
29
|
+
const checkExternalAPI = (url, timeout = 5000) => {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const protocol = url.startsWith('https:') ? https : http;
|
|
32
|
+
const startTime = Date.now();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const request = protocol.get(url, { timeout }, (res) => {
|
|
36
|
+
const responseTime = Date.now() - startTime;
|
|
37
|
+
resolve({
|
|
38
|
+
status: 'healthy',
|
|
39
|
+
statusCode: res.statusCode,
|
|
40
|
+
responseTime,
|
|
41
|
+
reachable: res.statusCode < 500
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
request.on('error', (error) => {
|
|
46
|
+
resolve({
|
|
47
|
+
status: 'unhealthy',
|
|
48
|
+
error: error.message,
|
|
49
|
+
responseTime: Date.now() - startTime,
|
|
50
|
+
reachable: false
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
request.on('timeout', () => {
|
|
55
|
+
request.destroy();
|
|
56
|
+
resolve({
|
|
57
|
+
status: 'timeout',
|
|
58
|
+
error: 'Request timeout',
|
|
59
|
+
responseTime: timeout,
|
|
60
|
+
reachable: false
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
} catch (error) {
|
|
64
|
+
resolve({
|
|
65
|
+
status: 'error',
|
|
66
|
+
error: error.message,
|
|
67
|
+
responseTime: Date.now() - startTime,
|
|
68
|
+
reachable: false
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const getDatabaseState = () => {
|
|
75
|
+
const stateMap = {
|
|
76
|
+
0: 'disconnected',
|
|
77
|
+
1: 'connected',
|
|
78
|
+
2: 'connecting',
|
|
79
|
+
3: 'disconnecting'
|
|
80
|
+
};
|
|
81
|
+
const readyState = mongoose.connection.readyState;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
readyState,
|
|
85
|
+
stateName: stateMap[readyState],
|
|
86
|
+
isConnected: readyState === 1
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const checkDatabaseHealth = async () => {
|
|
91
|
+
const { stateName, isConnected } = getDatabaseState();
|
|
92
|
+
const result = {
|
|
93
|
+
status: isConnected ? 'healthy' : 'unhealthy',
|
|
94
|
+
state: stateName
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (isConnected) {
|
|
98
|
+
const pingStart = Date.now();
|
|
99
|
+
await mongoose.connection.db.admin().ping({ maxTimeMS: 2000 });
|
|
100
|
+
result.responseTime = Date.now() - pingStart;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const getEncryptionConfiguration = () => {
|
|
107
|
+
const { STAGE, BYPASS_ENCRYPTION_STAGE, KMS_KEY_ARN, AES_KEY_ID } = process.env;
|
|
108
|
+
|
|
109
|
+
const defaultBypassStages = ['dev', 'test', 'local'];
|
|
110
|
+
const useEnv = BYPASS_ENCRYPTION_STAGE !== undefined;
|
|
111
|
+
const bypassStages = useEnv
|
|
112
|
+
? BYPASS_ENCRYPTION_STAGE.split(',').map((s) => s.trim())
|
|
113
|
+
: defaultBypassStages;
|
|
114
|
+
|
|
115
|
+
const isBypassed = bypassStages.includes(STAGE);
|
|
116
|
+
const hasAES = AES_KEY_ID && AES_KEY_ID.trim() !== '';
|
|
117
|
+
const hasKMS = KMS_KEY_ARN && KMS_KEY_ARN.trim() !== '' && !hasAES;
|
|
118
|
+
const mode = hasAES ? 'aes' : hasKMS ? 'kms' : 'none';
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
stage: STAGE || null,
|
|
122
|
+
isBypassed,
|
|
123
|
+
hasAES,
|
|
124
|
+
hasKMS,
|
|
125
|
+
mode,
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const createTestEncryptionModel = () => {
|
|
130
|
+
const { Encrypt } = require('./../../encrypt');
|
|
131
|
+
|
|
132
|
+
const testSchema = new mongoose.Schema({
|
|
133
|
+
testSecret: { type: String, lhEncrypt: true },
|
|
134
|
+
normalField: { type: String },
|
|
135
|
+
nestedSecret: {
|
|
136
|
+
value: { type: String, lhEncrypt: true }
|
|
137
|
+
}
|
|
138
|
+
}, { timestamps: false });
|
|
139
|
+
|
|
140
|
+
testSchema.plugin(Encrypt);
|
|
141
|
+
|
|
142
|
+
return mongoose.models.TestEncryption ||
|
|
143
|
+
mongoose.model('TestEncryption', testSchema);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const createTestDocument = async (TestModel) => {
|
|
147
|
+
const testData = {
|
|
148
|
+
testSecret: 'This is a secret value that should be encrypted',
|
|
149
|
+
normalField: 'This is a normal field that should not be encrypted',
|
|
150
|
+
nestedSecret: {
|
|
151
|
+
value: 'This is a nested secret that should be encrypted'
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const testDoc = new TestModel(testData);
|
|
156
|
+
await testDoc.save();
|
|
157
|
+
|
|
158
|
+
return { testDoc, testData };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const verifyDecryption = (retrievedDoc, originalData) => {
|
|
162
|
+
return retrievedDoc &&
|
|
163
|
+
retrievedDoc.testSecret === originalData.testSecret &&
|
|
164
|
+
retrievedDoc.normalField === originalData.normalField &&
|
|
165
|
+
retrievedDoc.nestedSecret?.value === originalData.nestedSecret.value;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const verifyEncryptionInDatabase = async (testDoc, originalData, TestModel) => {
|
|
169
|
+
const collectionName = TestModel.collection.name;
|
|
170
|
+
const rawDoc = await mongoose.connection.db
|
|
171
|
+
.collection(collectionName)
|
|
172
|
+
.findOne({ _id: testDoc._id });
|
|
173
|
+
|
|
174
|
+
const secretIsEncrypted = rawDoc &&
|
|
175
|
+
typeof rawDoc.testSecret === 'string' &&
|
|
176
|
+
rawDoc.testSecret.includes(':') &&
|
|
177
|
+
rawDoc.testSecret !== originalData.testSecret;
|
|
178
|
+
|
|
179
|
+
const nestedIsEncrypted = rawDoc?.nestedSecret?.value &&
|
|
180
|
+
typeof rawDoc.nestedSecret.value === 'string' &&
|
|
181
|
+
rawDoc.nestedSecret.value.includes(':') &&
|
|
182
|
+
rawDoc.nestedSecret.value !== originalData.nestedSecret.value;
|
|
183
|
+
|
|
184
|
+
const normalNotEncrypted = rawDoc &&
|
|
185
|
+
rawDoc.normalField === originalData.normalField;
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
secretIsEncrypted,
|
|
189
|
+
nestedIsEncrypted,
|
|
190
|
+
normalNotEncrypted
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const evaluateEncryptionTestResults = (decryptionWorks, encryptionResults) => {
|
|
195
|
+
const { secretIsEncrypted, nestedIsEncrypted, normalNotEncrypted } = encryptionResults;
|
|
196
|
+
|
|
197
|
+
if (decryptionWorks && secretIsEncrypted && nestedIsEncrypted && normalNotEncrypted) {
|
|
198
|
+
return {
|
|
199
|
+
status: 'enabled',
|
|
200
|
+
testResult: 'Encryption and decryption verified successfully'
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (decryptionWorks && (!secretIsEncrypted || !nestedIsEncrypted)) {
|
|
205
|
+
return {
|
|
206
|
+
status: 'unhealthy',
|
|
207
|
+
testResult: 'Fields are not being encrypted in database'
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (decryptionWorks && !normalNotEncrypted) {
|
|
212
|
+
return {
|
|
213
|
+
status: 'unhealthy',
|
|
214
|
+
testResult: 'Normal fields are being incorrectly encrypted'
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
status: 'unhealthy',
|
|
220
|
+
testResult: 'Decryption failed or data mismatch'
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const testEncryption = async () => {
|
|
225
|
+
const TestModel = createTestEncryptionModel();
|
|
226
|
+
const { testDoc, testData } = await createTestDocument(TestModel);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const retrievedDoc = await TestModel.findById(testDoc._id);
|
|
230
|
+
const decryptionWorks = verifyDecryption(retrievedDoc, testData);
|
|
231
|
+
const encryptionResults = await verifyEncryptionInDatabase(testDoc, testData, TestModel);
|
|
232
|
+
|
|
233
|
+
const evaluation = evaluateEncryptionTestResults(decryptionWorks, encryptionResults);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
...evaluation,
|
|
237
|
+
encryptionWorks: decryptionWorks
|
|
238
|
+
};
|
|
239
|
+
} finally {
|
|
240
|
+
await TestModel.deleteOne({ _id: testDoc._id });
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const checkEncryptionHealth = async () => {
|
|
245
|
+
const config = getEncryptionConfiguration();
|
|
246
|
+
|
|
247
|
+
if (config.isBypassed || config.mode === 'none') {
|
|
248
|
+
const testResult = config.isBypassed
|
|
249
|
+
? 'Encryption bypassed for this stage'
|
|
250
|
+
: 'No encryption keys configured';
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
status: 'disabled',
|
|
254
|
+
mode: config.mode,
|
|
255
|
+
bypassed: config.isBypassed,
|
|
256
|
+
stage: config.stage,
|
|
257
|
+
testResult,
|
|
258
|
+
encryptionWorks: false,
|
|
259
|
+
debug: {
|
|
260
|
+
hasKMS: config.hasKMS,
|
|
261
|
+
hasAES: config.hasAES
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const testResults = await testEncryption();
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
...testResults,
|
|
271
|
+
mode: config.mode,
|
|
272
|
+
bypassed: config.isBypassed,
|
|
273
|
+
stage: config.stage,
|
|
274
|
+
debug: {
|
|
275
|
+
hasKMS: config.hasKMS,
|
|
276
|
+
hasAES: config.hasAES
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
} catch (error) {
|
|
280
|
+
return {
|
|
281
|
+
status: 'unhealthy',
|
|
282
|
+
mode: config.mode,
|
|
283
|
+
bypassed: config.isBypassed,
|
|
284
|
+
stage: config.stage,
|
|
285
|
+
testResult: `Encryption test failed: ${error.message}`,
|
|
286
|
+
encryptionWorks: false,
|
|
287
|
+
debug: {
|
|
288
|
+
hasKMS: config.hasKMS,
|
|
289
|
+
hasAES: config.hasAES
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const checkExternalAPIs = async () => {
|
|
296
|
+
const apis = [
|
|
297
|
+
{ name: 'github', url: 'https://api.github.com/status' },
|
|
298
|
+
{ name: 'npm', url: 'https://registry.npmjs.org' }
|
|
299
|
+
];
|
|
300
|
+
|
|
301
|
+
const results = await Promise.all(
|
|
302
|
+
apis.map(api =>
|
|
303
|
+
checkExternalAPI(api.url).then(result => ({ name: api.name, ...result }))
|
|
304
|
+
)
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const apiStatuses = {};
|
|
308
|
+
let allReachable = true;
|
|
309
|
+
|
|
310
|
+
results.forEach(({ name, ...checkResult }) => {
|
|
311
|
+
apiStatuses[name] = checkResult;
|
|
312
|
+
if (!checkResult.reachable) {
|
|
313
|
+
allReachable = false;
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
return { apiStatuses, allReachable };
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const checkIntegrations = () => {
|
|
321
|
+
const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
|
|
322
|
+
? moduleFactory.moduleTypes
|
|
323
|
+
: [];
|
|
324
|
+
|
|
325
|
+
const integrationTypes = Array.isArray(integrationFactory.integrationTypes)
|
|
326
|
+
? integrationFactory.integrationTypes
|
|
327
|
+
: [];
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
status: 'healthy',
|
|
331
|
+
modules: {
|
|
332
|
+
count: moduleTypes.length,
|
|
333
|
+
available: moduleTypes,
|
|
334
|
+
},
|
|
335
|
+
integrations: {
|
|
336
|
+
count: integrationTypes.length,
|
|
337
|
+
available: integrationTypes,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const buildHealthCheckResponse = (startTime) => {
|
|
343
|
+
return {
|
|
344
|
+
service: 'frigg-core-api',
|
|
345
|
+
status: 'healthy',
|
|
346
|
+
timestamp: new Date().toISOString(),
|
|
347
|
+
checks: {},
|
|
348
|
+
calculateResponseTime: () => Date.now() - startTime
|
|
349
|
+
};
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
router.get('/health', async (_req, res) => {
|
|
353
|
+
const status = {
|
|
354
|
+
status: 'ok',
|
|
355
|
+
timestamp: new Date().toISOString(),
|
|
356
|
+
service: 'frigg-core-api'
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
res.status(200).json(status);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
router.get('/health/detailed', async (_req, res) => {
|
|
363
|
+
const startTime = Date.now();
|
|
364
|
+
const response = buildHealthCheckResponse(startTime);
|
|
365
|
+
|
|
366
|
+
try {
|
|
367
|
+
response.checks.database = await checkDatabaseHealth();
|
|
368
|
+
const dbState = getDatabaseState();
|
|
369
|
+
if (!dbState.isConnected) {
|
|
370
|
+
response.status = 'unhealthy';
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
response.checks.database = {
|
|
374
|
+
status: 'unhealthy',
|
|
375
|
+
error: error.message
|
|
376
|
+
};
|
|
377
|
+
response.status = 'unhealthy';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
response.checks.encryption = await checkEncryptionHealth();
|
|
382
|
+
if (response.checks.encryption.status === 'unhealthy') {
|
|
383
|
+
response.status = 'unhealthy';
|
|
384
|
+
}
|
|
385
|
+
} catch (error) {
|
|
386
|
+
response.checks.encryption = {
|
|
387
|
+
status: 'unhealthy',
|
|
388
|
+
error: error.message
|
|
389
|
+
};
|
|
390
|
+
response.status = 'unhealthy';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const { apiStatuses, allReachable } = await checkExternalAPIs();
|
|
394
|
+
response.checks.externalApis = apiStatuses;
|
|
395
|
+
if (!allReachable) {
|
|
396
|
+
response.status = 'unhealthy';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
response.checks.integrations = checkIntegrations();
|
|
401
|
+
} catch (error) {
|
|
402
|
+
response.checks.integrations = {
|
|
403
|
+
status: 'unhealthy',
|
|
404
|
+
error: error.message
|
|
405
|
+
};
|
|
406
|
+
response.status = 'unhealthy';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
response.responseTime = response.calculateResponseTime();
|
|
410
|
+
delete response.calculateResponseTime;
|
|
411
|
+
|
|
412
|
+
const statusCode = response.status === 'healthy' ? 200 : 503;
|
|
413
|
+
res.status(statusCode).json(response);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
router.get('/health/live', (_req, res) => {
|
|
417
|
+
res.status(200).json({
|
|
418
|
+
status: 'alive',
|
|
419
|
+
timestamp: new Date().toISOString()
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
router.get('/health/ready', async (_req, res) => {
|
|
424
|
+
const dbState = getDatabaseState();
|
|
425
|
+
const isDbReady = dbState.isConnected;
|
|
426
|
+
|
|
427
|
+
let areModulesReady = false;
|
|
428
|
+
try {
|
|
429
|
+
const moduleTypes = Array.isArray(moduleFactory.moduleTypes)
|
|
430
|
+
? moduleFactory.moduleTypes
|
|
431
|
+
: [];
|
|
432
|
+
areModulesReady = moduleTypes.length > 0;
|
|
433
|
+
} catch (error) {
|
|
434
|
+
areModulesReady = false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const isReady = isDbReady && areModulesReady;
|
|
438
|
+
|
|
439
|
+
res.status(isReady ? 200 : 503).json({
|
|
440
|
+
ready: isReady,
|
|
441
|
+
timestamp: new Date().toISOString(),
|
|
442
|
+
checks: {
|
|
443
|
+
database: isDbReady,
|
|
444
|
+
modules: areModulesReady
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const handler = createAppHandler('HTTP Event: Health', router);
|
|
450
|
+
|
|
451
|
+
module.exports = { handler, router };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
process.env.HEALTH_API_KEY = 'test-api-key';
|
|
2
|
+
|
|
3
|
+
jest.mock('mongoose', () => ({
|
|
4
|
+
set: jest.fn(),
|
|
5
|
+
connection: {
|
|
6
|
+
readyState: 1,
|
|
7
|
+
db: {
|
|
8
|
+
admin: () => ({
|
|
9
|
+
ping: jest.fn().mockResolvedValue(true)
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
jest.mock('./../backend-utils', () => ({
|
|
16
|
+
moduleFactory: {
|
|
17
|
+
moduleTypes: ['test-module', 'another-module']
|
|
18
|
+
},
|
|
19
|
+
integrationFactory: {
|
|
20
|
+
integrationTypes: ['test-integration', 'another-integration']
|
|
21
|
+
}
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock('./../app-handler-helpers', () => ({
|
|
25
|
+
createAppHandler: jest.fn((name, router) => ({ name, router }))
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const { router } = require('./health');
|
|
29
|
+
const mongoose = require('mongoose');
|
|
30
|
+
|
|
31
|
+
const mockRequest = (path, headers = {}) => ({
|
|
32
|
+
path,
|
|
33
|
+
headers
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const mockResponse = () => {
|
|
37
|
+
const res = {};
|
|
38
|
+
res.status = jest.fn().mockReturnValue(res);
|
|
39
|
+
res.json = jest.fn().mockReturnValue(res);
|
|
40
|
+
return res;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
describe('Health Check Endpoints', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
mongoose.connection.readyState = 1;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('Middleware - validateApiKey', () => {
|
|
49
|
+
it('should allow access to /health without authentication', async () => {
|
|
50
|
+
expect(true).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('GET /health', () => {
|
|
55
|
+
it('should return basic health status', async () => {
|
|
56
|
+
const req = mockRequest('/health');
|
|
57
|
+
const res = mockResponse();
|
|
58
|
+
|
|
59
|
+
const routeHandler = router.stack.find(layer =>
|
|
60
|
+
layer.route && layer.route.path === '/health'
|
|
61
|
+
).route.stack[0].handle;
|
|
62
|
+
|
|
63
|
+
await routeHandler(req, res);
|
|
64
|
+
|
|
65
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
66
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
67
|
+
status: 'ok',
|
|
68
|
+
timestamp: expect.any(String),
|
|
69
|
+
service: 'frigg-core-api'
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('GET /health/detailed', () => {
|
|
75
|
+
it('should return detailed health status when healthy', async () => {
|
|
76
|
+
const req = mockRequest('/health/detailed', { 'x-api-key': 'test-api-key' });
|
|
77
|
+
const res = mockResponse();
|
|
78
|
+
|
|
79
|
+
const originalPromiseAll = Promise.all;
|
|
80
|
+
Promise.all = jest.fn().mockResolvedValue([
|
|
81
|
+
{ name: 'github', status: 'healthy', reachable: true, statusCode: 200, responseTime: 100 },
|
|
82
|
+
{ name: 'npm', status: 'healthy', reachable: true, statusCode: 200, responseTime: 150 }
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const routeHandler = router.stack.find(layer =>
|
|
86
|
+
layer.route && layer.route.path === '/health/detailed'
|
|
87
|
+
).route.stack[0].handle;
|
|
88
|
+
|
|
89
|
+
await routeHandler(req, res);
|
|
90
|
+
|
|
91
|
+
Promise.all = originalPromiseAll;
|
|
92
|
+
|
|
93
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
94
|
+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
95
|
+
status: 'healthy',
|
|
96
|
+
service: 'frigg-core-api',
|
|
97
|
+
timestamp: expect.any(String),
|
|
98
|
+
checks: expect.objectContaining({
|
|
99
|
+
database: expect.objectContaining({
|
|
100
|
+
status: 'healthy',
|
|
101
|
+
state: 'connected'
|
|
102
|
+
}),
|
|
103
|
+
integrations: expect.objectContaining({
|
|
104
|
+
status: 'healthy'
|
|
105
|
+
})
|
|
106
|
+
}),
|
|
107
|
+
responseTime: expect.any(Number)
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
const response = res.json.mock.calls[0][0];
|
|
111
|
+
expect(response).not.toHaveProperty('version');
|
|
112
|
+
expect(response).not.toHaveProperty('uptime');
|
|
113
|
+
expect(response.checks).not.toHaveProperty('memory');
|
|
114
|
+
expect(response.checks.database).not.toHaveProperty('type');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should return 503 when database is disconnected', async () => {
|
|
118
|
+
mongoose.connection.readyState = 0;
|
|
119
|
+
|
|
120
|
+
const req = mockRequest('/health/detailed', { 'x-api-key': 'test-api-key' });
|
|
121
|
+
const res = mockResponse();
|
|
122
|
+
|
|
123
|
+
const originalPromiseAll = Promise.all;
|
|
124
|
+
Promise.all = jest.fn().mockResolvedValue([
|
|
125
|
+
{ name: 'github', status: 'healthy', reachable: true, statusCode: 200, responseTime: 100 },
|
|
126
|
+
{ name: 'npm', status: 'healthy', reachable: true, statusCode: 200, responseTime: 150 }
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const routeHandler = router.stack.find(layer =>
|
|
130
|
+
layer.route && layer.route.path === '/health/detailed'
|
|
131
|
+
).route.stack[0].handle;
|
|
132
|
+
|
|
133
|
+
await routeHandler(req, res);
|
|
134
|
+
|
|
135
|
+
Promise.all = originalPromiseAll;
|
|
136
|
+
|
|
137
|
+
expect(res.status).toHaveBeenCalledWith(503);
|
|
138
|
+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
139
|
+
status: 'unhealthy'
|
|
140
|
+
}));
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('GET /health/live', () => {
|
|
145
|
+
it('should return alive status', async () => {
|
|
146
|
+
const req = mockRequest('/health/live', { 'x-api-key': 'test-api-key' });
|
|
147
|
+
const res = mockResponse();
|
|
148
|
+
|
|
149
|
+
const routeHandler = router.stack.find(layer =>
|
|
150
|
+
layer.route && layer.route.path === '/health/live'
|
|
151
|
+
).route.stack[0].handle;
|
|
152
|
+
|
|
153
|
+
routeHandler(req, res);
|
|
154
|
+
|
|
155
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
156
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
157
|
+
status: 'alive',
|
|
158
|
+
timestamp: expect.any(String)
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('GET /health/ready', () => {
|
|
164
|
+
it('should return ready when all checks pass', async () => {
|
|
165
|
+
const req = mockRequest('/health/ready', { 'x-api-key': 'test-api-key' });
|
|
166
|
+
const res = mockResponse();
|
|
167
|
+
|
|
168
|
+
const routeHandler = router.stack.find(layer =>
|
|
169
|
+
layer.route && layer.route.path === '/health/ready'
|
|
170
|
+
).route.stack[0].handle;
|
|
171
|
+
|
|
172
|
+
await routeHandler(req, res);
|
|
173
|
+
|
|
174
|
+
expect(res.status).toHaveBeenCalledWith(200);
|
|
175
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
176
|
+
ready: true,
|
|
177
|
+
timestamp: expect.any(String),
|
|
178
|
+
checks: {
|
|
179
|
+
database: true,
|
|
180
|
+
modules: true
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should return 503 when database is not connected', async () => {
|
|
186
|
+
mongoose.connection.readyState = 0;
|
|
187
|
+
|
|
188
|
+
const req = mockRequest('/health/ready', { 'x-api-key': 'test-api-key' });
|
|
189
|
+
const res = mockResponse();
|
|
190
|
+
|
|
191
|
+
const routeHandler = router.stack.find(layer =>
|
|
192
|
+
layer.route && layer.route.path === '/health/ready'
|
|
193
|
+
).route.stack[0].handle;
|
|
194
|
+
|
|
195
|
+
await routeHandler(req, res);
|
|
196
|
+
|
|
197
|
+
expect(res.status).toHaveBeenCalledWith(503);
|
|
198
|
+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
|
199
|
+
ready: false
|
|
200
|
+
}));
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { createAppHandler } = require('./../app-handler-helpers');
|
|
2
|
+
const {
|
|
3
|
+
integrationFactory,
|
|
4
|
+
loadRouterFromObject,
|
|
5
|
+
} = require('./../backend-utils');
|
|
6
|
+
const { Router } = require('express');
|
|
7
|
+
|
|
8
|
+
const handlers = {};
|
|
9
|
+
for (const IntegrationClass of integrationFactory.integrationClasses) {
|
|
10
|
+
const router = Router();
|
|
11
|
+
const basePath = `/api/${IntegrationClass.Definition.name}-integration`;
|
|
12
|
+
|
|
13
|
+
console.log(`\n│ Configuring routes for ${IntegrationClass.Definition.name} Integration:`);
|
|
14
|
+
|
|
15
|
+
for (const routeDef of IntegrationClass.Definition.routes) {
|
|
16
|
+
if (typeof routeDef === 'function') {
|
|
17
|
+
router.use(basePath, routeDef(IntegrationClass));
|
|
18
|
+
console.log(`│ ANY ${basePath}/* (function handler)`);
|
|
19
|
+
} else if (typeof routeDef === 'object') {
|
|
20
|
+
router.use(
|
|
21
|
+
basePath,
|
|
22
|
+
loadRouterFromObject(IntegrationClass, routeDef)
|
|
23
|
+
);
|
|
24
|
+
const method = (routeDef.method || 'ANY').toUpperCase();
|
|
25
|
+
const fullPath = `${basePath}${routeDef.path}`;
|
|
26
|
+
console.log(`│ ${method} ${fullPath}`);
|
|
27
|
+
} else if (routeDef instanceof express.Router) {
|
|
28
|
+
router.use(basePath, routeDef);
|
|
29
|
+
console.log(`│ ANY ${basePath}/* (express router)`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
console.log('│');
|
|
33
|
+
|
|
34
|
+
handlers[`${IntegrationClass.Definition.name}`] = {
|
|
35
|
+
handler: createAppHandler(
|
|
36
|
+
`HTTP Event: ${IntegrationClass.Definition.name}`,
|
|
37
|
+
router
|
|
38
|
+
),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { handlers };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const catchAsyncError = require('express-async-handler');
|
|
2
|
+
const { User } = require('../../backend-utils');
|
|
3
|
+
|
|
4
|
+
module.exports = catchAsyncError(async (req, res, next) => {
|
|
5
|
+
const authorizationHeader = req.headers.authorization;
|
|
6
|
+
|
|
7
|
+
if (authorizationHeader) {
|
|
8
|
+
// Removes "Bearer " and trims
|
|
9
|
+
const token = authorizationHeader.split(' ')[1].trim();
|
|
10
|
+
// Load user for later middleware/routes to use
|
|
11
|
+
req.user = await User.newUser({ token });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return next();
|
|
15
|
+
});
|