@onlineapps/service-common 1.0.19 → 1.1.1
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/package.json +3 -2
- package/src/index.js +14 -1
- package/src/jwt/createJwtValidator.js +89 -0
- package/src/jwt/extractTenantContext.js +82 -0
- package/src/jwt/index.js +12 -0
- package/src/jwt/verifyAccessToken.js +50 -0
- package/tests/unit/BusinessError.test.js +0 -171
- package/tests/unit/defaults.test.js +0 -48
- package/tests/unit/monitoringFallbackEmail.test.js +0 -57
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/service-common",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Common utilities for both infrastructure services and business services",
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Common utilities for both infrastructure services and business services (JWT auth, Redis/Postgres clients, business errors, runtime config)",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "jest",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"license": "MIT",
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@onlineapps/runtime-config": "1.0.2",
|
|
21
|
+
"jsonwebtoken": "^9.0.3",
|
|
21
22
|
"nodemailer": "^6.9.8",
|
|
22
23
|
"redis": "^4.6.0"
|
|
23
24
|
},
|
package/src/index.js
CHANGED
|
@@ -45,6 +45,12 @@ const {
|
|
|
45
45
|
businessErrorHandler,
|
|
46
46
|
notFoundHandler
|
|
47
47
|
} = require('./errors');
|
|
48
|
+
const {
|
|
49
|
+
verifyAccessToken,
|
|
50
|
+
createJwtValidator,
|
|
51
|
+
extractTenantContext,
|
|
52
|
+
ROLES_VERSION_PREFIX
|
|
53
|
+
} = require('./jwt');
|
|
48
54
|
|
|
49
55
|
module.exports = {
|
|
50
56
|
// Infrastructure readiness utilities (used by both infrastructure and business services)
|
|
@@ -85,7 +91,14 @@ module.exports = {
|
|
|
85
91
|
isBusinessError,
|
|
86
92
|
ERROR_TYPES,
|
|
87
93
|
businessErrorHandler,
|
|
88
|
-
notFoundHandler
|
|
94
|
+
notFoundHandler,
|
|
95
|
+
|
|
96
|
+
// JWT validation utilities (shared across services that accept client HTTP/WS requests)
|
|
97
|
+
// See: docs/standards/JWT_AUTH.md
|
|
98
|
+
verifyAccessToken,
|
|
99
|
+
createJwtValidator,
|
|
100
|
+
extractTenantContext,
|
|
101
|
+
ROLES_VERSION_PREFIX
|
|
89
102
|
};
|
|
90
103
|
|
|
91
104
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { verifyAccessToken } = require('./verifyAccessToken');
|
|
4
|
+
|
|
5
|
+
// See: docs/standards/JWT_AUTH.md §9.1 — forced refresh on role change
|
|
6
|
+
const ROLES_VERSION_PREFIX = 'person:roles_version:';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create Express middleware that validates JWT Bearer tokens on incoming requests.
|
|
10
|
+
*
|
|
11
|
+
* On success: sets req.auth = { person_uuid, person_id, email, tenants }.
|
|
12
|
+
* On failure: responds with 401 JSON.
|
|
13
|
+
* Paths listed in excludePaths are skipped (transparent pass-through).
|
|
14
|
+
*
|
|
15
|
+
* @param {object} options
|
|
16
|
+
* @param {object} options.logger - Logger with .warn() method (required)
|
|
17
|
+
* @param {string[]} [options.excludePaths] - Paths to skip JWT validation
|
|
18
|
+
* @param {object} [options.redisClient] - Redis client for role version check (optional)
|
|
19
|
+
* @returns {Function} Express middleware (req, res, next)
|
|
20
|
+
*/
|
|
21
|
+
function createJwtValidator({ logger, excludePaths, redisClient }) {
|
|
22
|
+
if (!logger || typeof logger.warn !== 'function') {
|
|
23
|
+
throw new Error('[JWT] Logger is required - Expected object with warn() method');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const excluded = new Set(excludePaths || []);
|
|
27
|
+
|
|
28
|
+
return async function jwtValidator(req, res, next) {
|
|
29
|
+
if (excluded.has(req.path)) {
|
|
30
|
+
return next();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const authHeader = req.headers.authorization;
|
|
34
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
35
|
+
return res.status(401).json({
|
|
36
|
+
error: 'Missing or invalid Authorization header - Expected: Bearer <token>'
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const token = authHeader.slice(7);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const decoded = verifyAccessToken(token);
|
|
44
|
+
|
|
45
|
+
if (redisClient && redisClient.isOpen && decoded.person_id) {
|
|
46
|
+
try {
|
|
47
|
+
const rolesVersion = await redisClient.get(`${ROLES_VERSION_PREFIX}${decoded.person_id}`);
|
|
48
|
+
if (rolesVersion) {
|
|
49
|
+
const versionTs = parseInt(rolesVersion, 10);
|
|
50
|
+
const tokenIat = decoded.iat * 1000;
|
|
51
|
+
if (!isNaN(versionTs) && tokenIat < versionTs) {
|
|
52
|
+
logger.warn('[JWT] Token stale — role changed after issuance', {
|
|
53
|
+
person_id: decoded.person_id,
|
|
54
|
+
token_iat: new Date(tokenIat).toISOString(),
|
|
55
|
+
roles_changed: new Date(versionTs).toISOString()
|
|
56
|
+
});
|
|
57
|
+
return res.status(401).json({
|
|
58
|
+
error: 'Token stale — your roles have changed, please refresh your access token',
|
|
59
|
+
code: 'TOKEN_STALE'
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (redisErr) {
|
|
64
|
+
logger.warn('[JWT] Redis role version check failed (non-blocking)', { error: redisErr.message });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
req.auth = {
|
|
69
|
+
person_uuid: decoded.sub,
|
|
70
|
+
person_id: decoded.person_id,
|
|
71
|
+
email: decoded.email,
|
|
72
|
+
tenants: decoded.tenants || []
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
next();
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err.name === 'TokenExpiredError') {
|
|
78
|
+
return res.status(401).json({ error: 'Token expired - refresh your access token' });
|
|
79
|
+
}
|
|
80
|
+
if (err.code === 'INVALID_TOKEN_TYPE') {
|
|
81
|
+
return res.status(401).json({ error: 'Invalid token type - Expected access token' });
|
|
82
|
+
}
|
|
83
|
+
logger.warn('[JWT] Token verification failed', { error: err.message });
|
|
84
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { createJwtValidator, ROLES_VERSION_PREFIX };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract tenant context from decoded JWT auth data and request headers.
|
|
5
|
+
*
|
|
6
|
+
* Pure function — no Express dependency. Callers (middleware) translate
|
|
7
|
+
* thrown errors into HTTP responses.
|
|
8
|
+
*
|
|
9
|
+
* Rules (per JWT_AUTH.md section 7):
|
|
10
|
+
* - Single tenant: auto-pick, x-tenant-uuid header optional
|
|
11
|
+
* - Multiple tenants: x-tenant-uuid header required
|
|
12
|
+
* - workspace_id: required for direct API calls (x-workspace-id header),
|
|
13
|
+
* optional for workflow submission (workspace is per-step in cookbook)
|
|
14
|
+
*
|
|
15
|
+
* @param {object} auth - req.auth from createJwtValidator: { person_uuid, person_id, email, tenants }
|
|
16
|
+
* @param {object} headers - HTTP request headers (lowercase keys)
|
|
17
|
+
* @param {object} [options] - { requireWorkspace: true } — set false for workflow submission
|
|
18
|
+
* @returns {object} { tenant_id, tenant_uuid, workspace_id, person_id, person_uuid, role }
|
|
19
|
+
* @throws {Error} with .statusCode = 400, 401, or 403
|
|
20
|
+
*/
|
|
21
|
+
// See: docs/standards/tenant-context-contract.md
|
|
22
|
+
function extractTenantContext(auth, headers, options) {
|
|
23
|
+
const { requireWorkspace = true } = options || {};
|
|
24
|
+
|
|
25
|
+
if (!auth || !Array.isArray(auth.tenants)) {
|
|
26
|
+
const err = new Error('[TenantContext] Missing auth data - Expected auth object with tenants array');
|
|
27
|
+
err.statusCode = 401;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tenantUuid = headers['x-tenant-uuid'];
|
|
32
|
+
const rawWorkspaceId = headers['x-workspace-id'];
|
|
33
|
+
|
|
34
|
+
let workspaceId = null;
|
|
35
|
+
if (rawWorkspaceId) {
|
|
36
|
+
workspaceId = parseInt(rawWorkspaceId, 10);
|
|
37
|
+
if (isNaN(workspaceId) || workspaceId < 1) {
|
|
38
|
+
const err = new Error(`[TenantContext] Invalid x-workspace-id '${rawWorkspaceId}' - must be a positive integer`);
|
|
39
|
+
err.statusCode = 400;
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
} else if (requireWorkspace) {
|
|
43
|
+
const err = new Error('[TenantContext] Missing x-workspace-id header - workspace is required for direct API calls');
|
|
44
|
+
err.statusCode = 400;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (tenantUuid) {
|
|
49
|
+
const membership = auth.tenants.find(t => t.tenant_uuid === tenantUuid);
|
|
50
|
+
if (!membership) {
|
|
51
|
+
const err = new Error('Access denied - No membership for specified tenant');
|
|
52
|
+
err.statusCode = 403;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
tenant_id: membership.tenant_id,
|
|
57
|
+
tenant_uuid: membership.tenant_uuid,
|
|
58
|
+
workspace_id: workspaceId,
|
|
59
|
+
person_id: auth.person_id,
|
|
60
|
+
person_uuid: auth.person_uuid,
|
|
61
|
+
role: membership.role
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (auth.tenants.length === 1) {
|
|
66
|
+
const t = auth.tenants[0];
|
|
67
|
+
return {
|
|
68
|
+
tenant_id: t.tenant_id,
|
|
69
|
+
tenant_uuid: t.tenant_uuid,
|
|
70
|
+
workspace_id: workspaceId,
|
|
71
|
+
person_id: auth.person_id,
|
|
72
|
+
person_uuid: auth.person_uuid,
|
|
73
|
+
role: t.role
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const err = new Error('Missing tenant context - Provide x-tenant-uuid header (multiple tenants available)');
|
|
78
|
+
err.statusCode = 400;
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { extractTenantContext };
|
package/src/jwt/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { verifyAccessToken } = require('./verifyAccessToken');
|
|
4
|
+
const { createJwtValidator, ROLES_VERSION_PREFIX } = require('./createJwtValidator');
|
|
5
|
+
const { extractTenantContext } = require('./extractTenantContext');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
verifyAccessToken,
|
|
9
|
+
createJwtValidator,
|
|
10
|
+
extractTenantContext,
|
|
11
|
+
ROLES_VERSION_PREFIX
|
|
12
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const jwt = require('jsonwebtoken');
|
|
4
|
+
|
|
5
|
+
const ISSUER = 'oa-auth';
|
|
6
|
+
const MIN_SECRET_LENGTH = 16;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve JWT_SECRET from environment. Fail-fast on missing/insecure value.
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
function getJwtSecret() {
|
|
13
|
+
const secret = process.env.JWT_SECRET;
|
|
14
|
+
if (!secret || secret.length < MIN_SECRET_LENGTH) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`[JWT] Missing or insecure JWT_SECRET - Expected env var with at least ${MIN_SECRET_LENGTH} characters`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return secret;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Verify and decode an OA Drive access token.
|
|
24
|
+
*
|
|
25
|
+
* Validates: signature (HMAC-SHA256), issuer ('oa-auth'), expiry, type ('access').
|
|
26
|
+
* Returns the full decoded payload on success.
|
|
27
|
+
* Throws on any validation failure — callers decide how to handle.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} token - Raw JWT string (without "Bearer " prefix)
|
|
30
|
+
* @returns {object} Decoded payload: { sub, person_id, email, tenants, type, iss, iat, exp }
|
|
31
|
+
* @throws {Error} TokenExpiredError, JsonWebTokenError, or type mismatch
|
|
32
|
+
*/
|
|
33
|
+
function verifyAccessToken(token) {
|
|
34
|
+
if (!token || typeof token !== 'string') {
|
|
35
|
+
throw new Error('[JWT] Token is required - Expected non-empty string');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const secret = getJwtSecret();
|
|
39
|
+
const decoded = jwt.verify(token, secret, { issuer: ISSUER });
|
|
40
|
+
|
|
41
|
+
if (decoded.type !== 'access') {
|
|
42
|
+
const err = new Error(`[JWT] Invalid token type - Expected 'access', got '${decoded.type}'`);
|
|
43
|
+
err.code = 'INVALID_TOKEN_TYPE';
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return decoded;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { verifyAccessToken, getJwtSecret, ISSUER, MIN_SECRET_LENGTH };
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const {
|
|
4
|
-
BusinessError,
|
|
5
|
-
NotFoundError,
|
|
6
|
-
ValidationError,
|
|
7
|
-
ConflictError,
|
|
8
|
-
BusinessRuleError,
|
|
9
|
-
AuthorizationError,
|
|
10
|
-
ServiceUnavailableError,
|
|
11
|
-
isBusinessError,
|
|
12
|
-
businessErrorHandler,
|
|
13
|
-
notFoundHandler
|
|
14
|
-
} = require('../../src/errors');
|
|
15
|
-
|
|
16
|
-
describe('BusinessError hierarchy', () => {
|
|
17
|
-
test('BusinessError has correct defaults', () => {
|
|
18
|
-
const err = new BusinessError('Something went wrong');
|
|
19
|
-
expect(err).toBeInstanceOf(Error);
|
|
20
|
-
expect(err).toBeInstanceOf(BusinessError);
|
|
21
|
-
expect(err.name).toBe('BusinessError');
|
|
22
|
-
expect(err.message).toBe('Something went wrong');
|
|
23
|
-
expect(err.statusCode).toBe(500);
|
|
24
|
-
expect(err.errorCode).toBe('INTERNAL_ERROR');
|
|
25
|
-
expect(err.type).toBe('BUSINESS');
|
|
26
|
-
expect(err.details).toEqual([]);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('NotFoundError sets 404', () => {
|
|
30
|
-
const err = new NotFoundError('Document not found', {
|
|
31
|
-
details: ['UUID abc123 does not exist'],
|
|
32
|
-
operation: 'getDocument'
|
|
33
|
-
});
|
|
34
|
-
expect(err).toBeInstanceOf(BusinessError);
|
|
35
|
-
expect(err.statusCode).toBe(404);
|
|
36
|
-
expect(err.errorCode).toBe('RESOURCE_NOT_FOUND');
|
|
37
|
-
expect(err.type).toBe('BUSINESS');
|
|
38
|
-
expect(err.details).toEqual(['UUID abc123 does not exist']);
|
|
39
|
-
expect(err.operation).toBe('getDocument');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('ValidationError sets 400', () => {
|
|
43
|
-
const err = new ValidationError('Invalid input', { details: ['field X is required'] });
|
|
44
|
-
expect(err.statusCode).toBe(400);
|
|
45
|
-
expect(err.errorCode).toBe('VALIDATION_FAILED');
|
|
46
|
-
expect(err.type).toBe('VALIDATION');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test('ConflictError sets 409', () => {
|
|
50
|
-
const err = new ConflictError('Duplicate');
|
|
51
|
-
expect(err.statusCode).toBe(409);
|
|
52
|
-
expect(err.errorCode).toBe('DUPLICATE_RESOURCE');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test('BusinessRuleError sets 422', () => {
|
|
56
|
-
const err = new BusinessRuleError('Cannot invoice draft');
|
|
57
|
-
expect(err.statusCode).toBe(422);
|
|
58
|
-
expect(err.errorCode).toBe('BUSINESS_RULE_VIOLATED');
|
|
59
|
-
expect(err.type).toBe('VALIDATION');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('AuthorizationError sets 403', () => {
|
|
63
|
-
const err = new AuthorizationError('No access');
|
|
64
|
-
expect(err.statusCode).toBe(403);
|
|
65
|
-
expect(err.errorCode).toBe('FORBIDDEN');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test('ServiceUnavailableError sets 503', () => {
|
|
69
|
-
const err = new ServiceUnavailableError('DB down');
|
|
70
|
-
expect(err.statusCode).toBe(503);
|
|
71
|
-
expect(err.errorCode).toBe('SERVICE_UNAVAILABLE');
|
|
72
|
-
expect(err.type).toBe('TRANSIENT');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('isBusinessError returns true for BusinessError subtypes', () => {
|
|
76
|
-
expect(isBusinessError(new NotFoundError('x'))).toBe(true);
|
|
77
|
-
expect(isBusinessError(new Error('x'))).toBe(false);
|
|
78
|
-
expect(isBusinessError(null)).toBe(false);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('toJSON returns structured error', () => {
|
|
82
|
-
const err = new NotFoundError('Not found', {
|
|
83
|
-
details: ['detail1'],
|
|
84
|
-
operation: 'findDoc',
|
|
85
|
-
service: 'invoicing'
|
|
86
|
-
});
|
|
87
|
-
const json = err.toJSON();
|
|
88
|
-
expect(json).toEqual({
|
|
89
|
-
code: 'RESOURCE_NOT_FOUND',
|
|
90
|
-
message: 'Not found',
|
|
91
|
-
statusCode: 404,
|
|
92
|
-
details: ['detail1'],
|
|
93
|
-
service: 'invoicing',
|
|
94
|
-
operation: 'findDoc'
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('custom errorCode can be overridden', () => {
|
|
99
|
-
const err = new NotFoundError('Tenant missing', { errorCode: 'TENANT_NOT_FOUND' });
|
|
100
|
-
expect(err.errorCode).toBe('TENANT_NOT_FOUND');
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe('businessErrorHandler middleware', () => {
|
|
105
|
-
let req, res, next;
|
|
106
|
-
|
|
107
|
-
beforeEach(() => {
|
|
108
|
-
req = { method: 'GET', originalUrl: '/api/test', app: { locals: { logger: { warn: jest.fn(), error: jest.fn() } } } };
|
|
109
|
-
res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), headersSent: false };
|
|
110
|
-
next = jest.fn();
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('formats BusinessError as structured JSON', () => {
|
|
114
|
-
const err = new NotFoundError('Doc not found', { details: ['UUID 123'] });
|
|
115
|
-
businessErrorHandler(err, req, res, next);
|
|
116
|
-
|
|
117
|
-
expect(res.status).toHaveBeenCalledWith(404);
|
|
118
|
-
expect(res.json).toHaveBeenCalledWith({
|
|
119
|
-
error: {
|
|
120
|
-
code: 'RESOURCE_NOT_FOUND',
|
|
121
|
-
message: 'Doc not found',
|
|
122
|
-
statusCode: 404,
|
|
123
|
-
details: ['UUID 123'],
|
|
124
|
-
service: undefined,
|
|
125
|
-
operation: undefined
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test('wraps generic Error as INTERNAL_ERROR 500', () => {
|
|
131
|
-
const err = new Error('kaboom');
|
|
132
|
-
businessErrorHandler(err, req, res, next);
|
|
133
|
-
|
|
134
|
-
expect(res.status).toHaveBeenCalledWith(500);
|
|
135
|
-
const body = res.json.mock.calls[0][0];
|
|
136
|
-
expect(body.error.code).toBe('INTERNAL_ERROR');
|
|
137
|
-
expect(body.error.statusCode).toBe(500);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test('does not leak stack in production', () => {
|
|
141
|
-
const origEnv = process.env.NODE_ENV;
|
|
142
|
-
process.env.NODE_ENV = 'production';
|
|
143
|
-
|
|
144
|
-
const err = new Error('secret details');
|
|
145
|
-
businessErrorHandler(err, req, res, next);
|
|
146
|
-
|
|
147
|
-
const body = res.json.mock.calls[0][0];
|
|
148
|
-
expect(body.error.message).toBe('Internal server error');
|
|
149
|
-
|
|
150
|
-
process.env.NODE_ENV = origEnv;
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
test('calls next() if headers already sent', () => {
|
|
154
|
-
res.headersSent = true;
|
|
155
|
-
const err = new Error('late');
|
|
156
|
-
businessErrorHandler(err, req, res, next);
|
|
157
|
-
expect(next).toHaveBeenCalledWith(err);
|
|
158
|
-
expect(res.status).not.toHaveBeenCalled();
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
describe('notFoundHandler middleware', () => {
|
|
163
|
-
test('returns ROUTE_NOT_FOUND 404', () => {
|
|
164
|
-
const req = { method: 'GET', originalUrl: '/unknown', app: { locals: { logger: { warn: jest.fn() } } } };
|
|
165
|
-
const res = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() };
|
|
166
|
-
notFoundHandler(req, res);
|
|
167
|
-
|
|
168
|
-
expect(res.status).toHaveBeenCalledWith(404);
|
|
169
|
-
expect(res.json.mock.calls[0][0].error.code).toBe('ROUTE_NOT_FOUND');
|
|
170
|
-
});
|
|
171
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const defaults = require('../../src/defaults');
|
|
4
|
-
const { getCriticalConfigWithFallbacks, getInfrastructureHealthConfig } = require('../../src/runtime-config');
|
|
5
|
-
const { buildRedisUrl } = require('../../src/redisClient');
|
|
6
|
-
|
|
7
|
-
describe('@onlineapps/service-common defaults @unit', () => {
|
|
8
|
-
test('should export stable module-owned defaults', () => {
|
|
9
|
-
expect(defaults.infrastructureHealthQueueName).toBe('infrastructure.health.checks');
|
|
10
|
-
expect(defaults.infrastructureHealthPublishIntervalMs).toBe(5000);
|
|
11
|
-
expect(defaults.infrastructureHealthWaitMaxTimeMs).toBe(60000);
|
|
12
|
-
expect(defaults.infrastructureHealthWaitCheckIntervalMs).toBe(2000);
|
|
13
|
-
expect(defaults.infrastructureHealthTimeoutMs).toBe(15000);
|
|
14
|
-
expect(defaults.infrastructureHealthRedisTtlSeconds).toBe(30);
|
|
15
|
-
expect(defaults.infrastructureHealthCleanupIntervalMs).toBe(10000);
|
|
16
|
-
expect(defaults.infrastructureHealthQueueWaitMaxTimeMs).toBe(60000);
|
|
17
|
-
expect(defaults.infrastructureHealthQueueWaitCheckIntervalMs).toBe(2000);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test('getCriticalConfigWithFallbacks should fail-fast when env is missing (no topology defaults)', () => {
|
|
21
|
-
const oldRedis = process.env.REDIS_URL;
|
|
22
|
-
const oldRabbit = process.env.RABBITMQ_URL;
|
|
23
|
-
delete process.env.REDIS_URL;
|
|
24
|
-
delete process.env.RABBITMQ_URL;
|
|
25
|
-
|
|
26
|
-
expect(() => getCriticalConfigWithFallbacks()).toThrow(/Missing required config/i);
|
|
27
|
-
|
|
28
|
-
if (oldRedis !== undefined) process.env.REDIS_URL = oldRedis;
|
|
29
|
-
if (oldRabbit !== undefined) process.env.RABBITMQ_URL = oldRabbit;
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test('getInfrastructureHealthConfig should default queue name from module defaults', () => {
|
|
33
|
-
const old = process.env.INFRASTRUCTURE_HEALTH_QUEUE;
|
|
34
|
-
delete process.env.INFRASTRUCTURE_HEALTH_QUEUE;
|
|
35
|
-
|
|
36
|
-
const cfg = getInfrastructureHealthConfig();
|
|
37
|
-
expect(cfg.queueName).toBe(defaults.infrastructureHealthQueueName);
|
|
38
|
-
|
|
39
|
-
if (old !== undefined) process.env.INFRASTRUCTURE_HEALTH_QUEUE = old;
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('buildRedisUrl should fall back to module defaults host/port', () => {
|
|
43
|
-
expect(() => buildRedisUrl({ env: {}, defaults: {} })).toThrow(/Missing required config/i);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
jest.mock('nodemailer', () => {
|
|
4
|
-
return {
|
|
5
|
-
createTransport: jest.fn(() => ({
|
|
6
|
-
sendMail: jest.fn().mockResolvedValue(true)
|
|
7
|
-
}))
|
|
8
|
-
};
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
describe('monitoringFallbackEmail', () => {
|
|
12
|
-
const ORIGINAL_ENV = process.env;
|
|
13
|
-
|
|
14
|
-
beforeEach(() => {
|
|
15
|
-
jest.resetModules();
|
|
16
|
-
process.env = { ...ORIGINAL_ENV };
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
process.env = ORIGINAL_ENV;
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
function loadReporter() {
|
|
24
|
-
return require('../../src/reporting/monitoringFallbackEmail');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
test('returns false when SMTP config missing', async () => {
|
|
28
|
-
delete process.env.INFRA_REPORT_SMTP_HOST;
|
|
29
|
-
const { sendMonitoringFailFallbackEmail } = loadReporter();
|
|
30
|
-
const result = await sendMonitoringFailFallbackEmail('t', 't', '<p>t</p>');
|
|
31
|
-
expect(result).toBe(false);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('sends email when SMTP config provided', async () => {
|
|
35
|
-
process.env.INFRA_REPORT_SMTP_HOST = 'smtp.example.com';
|
|
36
|
-
process.env.INFRA_REPORT_SMTP_PORT = '587';
|
|
37
|
-
process.env.INFRA_REPORT_SMTP_SECURE = 'false';
|
|
38
|
-
process.env.INFRA_REPORT_SMTP_USER = 'user@example.com';
|
|
39
|
-
process.env.INFRA_REPORT_SMTP_PASS = 'secret';
|
|
40
|
-
process.env.INFRA_REPORT_FROM = 'infra@example.com';
|
|
41
|
-
process.env.INFRA_REPORT_TO = 'ops@example.com';
|
|
42
|
-
|
|
43
|
-
const nodemailer = require('nodemailer');
|
|
44
|
-
const transportMock = {
|
|
45
|
-
sendMail: jest.fn().mockResolvedValue(true)
|
|
46
|
-
};
|
|
47
|
-
nodemailer.createTransport.mockReturnValue(transportMock);
|
|
48
|
-
|
|
49
|
-
const { sendMonitoringFailFallbackEmail } = loadReporter();
|
|
50
|
-
const result = await sendMonitoringFailFallbackEmail('Subject', 'Body', '<p>Body</p>');
|
|
51
|
-
|
|
52
|
-
expect(result).toBe(true);
|
|
53
|
-
expect(transportMock.sendMail).toHaveBeenCalledTimes(1);
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
|