@flusys/nestjs-shared 6.0.2 → 6.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/cjs/classes/api-controller.class.spec.js +684 -0
- package/cjs/classes/api-service.class.spec.js +809 -0
- package/cjs/classes/handle-custome-db.spec.js +82 -0
- package/cjs/classes/hybrid-cache.class.spec.js +168 -0
- package/cjs/classes/winston.logger.class.spec.js +330 -0
- package/cjs/decorators/api-response.decorator.spec.js +126 -0
- package/cjs/decorators/current-user.decorator.spec.js +74 -0
- package/cjs/decorators/log-action.decorator.spec.js +353 -0
- package/cjs/decorators/public.decorator.spec.js +50 -0
- package/cjs/decorators/require-permission.decorator.spec.js +147 -0
- package/cjs/exceptions/base-app.exception.spec.js +157 -0
- package/cjs/exceptions/permission.exception.spec.js +82 -0
- package/cjs/filters/global-exception.filter.spec.js +440 -0
- package/cjs/guards/jwt-auth.guard.spec.js +75 -0
- package/cjs/guards/optional-jwt-auth.guard.spec.js +55 -0
- package/cjs/guards/permission.guard.spec.js +358 -0
- package/cjs/interceptors/delete-empty-id-from-body.interceptor.spec.js +107 -0
- package/cjs/interceptors/idempotency.interceptor.spec.js +116 -0
- package/cjs/interceptors/response-meta.interceptor.spec.js +89 -0
- package/cjs/interceptors/set-user-field-on-body.interceptor.spec.js +128 -0
- package/cjs/interceptors/slug.interceptor.spec.js +110 -0
- package/cjs/middlewares/logger.middleware.spec.js +462 -0
- package/cjs/modules/cache/cache.module.spec.js +48 -0
- package/cjs/modules/datasource/multi-tenant-datasource.service.spec.js +381 -0
- package/cjs/modules/utils/utils.service.spec.js +169 -0
- package/cjs/utils/date-time.util.spec.js +84 -0
- package/cjs/utils/html-sanitizer.util.spec.js +76 -0
- package/cjs/utils/permission-cache-key.util.spec.js +88 -0
- package/cjs/utils/query-helpers.util.spec.js +209 -0
- package/cjs/utils/request.util.spec.js +249 -0
- package/cjs/utils/string.util.spec.js +63 -0
- package/fesm/classes/api-controller.class.spec.js +680 -0
- package/fesm/classes/api-service.class.spec.js +805 -0
- package/fesm/classes/handle-custome-db.spec.js +78 -0
- package/fesm/classes/hybrid-cache.class.spec.js +164 -0
- package/fesm/classes/winston.logger.class.spec.js +327 -0
- package/fesm/decorators/api-response.decorator.spec.js +122 -0
- package/fesm/decorators/current-user.decorator.spec.js +73 -0
- package/fesm/decorators/log-action.decorator.spec.js +349 -0
- package/fesm/decorators/public.decorator.spec.js +46 -0
- package/fesm/decorators/require-permission.decorator.spec.js +143 -0
- package/fesm/exceptions/base-app.exception.spec.js +153 -0
- package/fesm/exceptions/permission.exception.spec.js +78 -0
- package/fesm/filters/global-exception.filter.spec.js +436 -0
- package/fesm/guards/jwt-auth.guard.spec.js +71 -0
- package/fesm/guards/optional-jwt-auth.guard.spec.js +51 -0
- package/fesm/guards/permission.guard.spec.js +354 -0
- package/fesm/interceptors/delete-empty-id-from-body.interceptor.spec.js +103 -0
- package/fesm/interceptors/idempotency.interceptor.spec.js +112 -0
- package/fesm/interceptors/response-meta.interceptor.spec.js +85 -0
- package/fesm/interceptors/set-user-field-on-body.interceptor.spec.js +124 -0
- package/fesm/interceptors/slug.interceptor.spec.js +106 -0
- package/fesm/middlewares/logger.middleware.spec.js +459 -0
- package/fesm/modules/cache/cache.module.spec.js +44 -0
- package/fesm/modules/datasource/multi-tenant-datasource.service.spec.js +377 -0
- package/fesm/modules/utils/utils.service.spec.js +165 -0
- package/fesm/utils/date-time.util.spec.js +80 -0
- package/fesm/utils/html-sanitizer.util.spec.js +72 -0
- package/fesm/utils/permission-cache-key.util.spec.js +84 -0
- package/fesm/utils/query-helpers.util.spec.js +205 -0
- package/fesm/utils/request.util.spec.js +245 -0
- package/fesm/utils/string.util.spec.js +59 -0
- package/package.json +2 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _rxjs = require("rxjs");
|
|
6
|
+
const _setuserfieldonbodyinterceptor = require("./set-user-field-on-body.interceptor");
|
|
7
|
+
function buildContext(body, user) {
|
|
8
|
+
const request = {
|
|
9
|
+
body,
|
|
10
|
+
user
|
|
11
|
+
};
|
|
12
|
+
const context = {
|
|
13
|
+
switchToHttp: ()=>({
|
|
14
|
+
getRequest: ()=>request
|
|
15
|
+
})
|
|
16
|
+
};
|
|
17
|
+
return {
|
|
18
|
+
context,
|
|
19
|
+
request
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function buildCallHandler() {
|
|
23
|
+
return {
|
|
24
|
+
handle: jest.fn().mockReturnValue((0, _rxjs.of)('next-result'))
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
describe('createSetUserFieldInterceptor', ()=>{
|
|
28
|
+
it('should set the field on a single object body from the authenticated user', ()=>{
|
|
29
|
+
const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('createdById');
|
|
30
|
+
const interceptor = new Interceptor();
|
|
31
|
+
const { context, request } = buildContext({
|
|
32
|
+
name: 'Product'
|
|
33
|
+
}, {
|
|
34
|
+
id: 'user-1'
|
|
35
|
+
});
|
|
36
|
+
interceptor.intercept(context, buildCallHandler());
|
|
37
|
+
expect(request.body).toEqual({
|
|
38
|
+
name: 'Product',
|
|
39
|
+
createdById: 'user-1'
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
it('should set the field on every item of an array body', ()=>{
|
|
43
|
+
const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('updatedById');
|
|
44
|
+
const interceptor = new Interceptor();
|
|
45
|
+
const { context, request } = buildContext([
|
|
46
|
+
{
|
|
47
|
+
name: 'A'
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'B'
|
|
51
|
+
}
|
|
52
|
+
], {
|
|
53
|
+
id: 'user-2'
|
|
54
|
+
});
|
|
55
|
+
interceptor.intercept(context, buildCallHandler());
|
|
56
|
+
expect(request.body).toEqual([
|
|
57
|
+
{
|
|
58
|
+
name: 'A',
|
|
59
|
+
updatedById: 'user-2'
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: 'B',
|
|
63
|
+
updatedById: 'user-2'
|
|
64
|
+
}
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
it('should not modify the body when there is no authenticated user', ()=>{
|
|
68
|
+
const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('deletedById');
|
|
69
|
+
const interceptor = new Interceptor();
|
|
70
|
+
const { context, request } = buildContext({
|
|
71
|
+
name: 'Product'
|
|
72
|
+
}, undefined);
|
|
73
|
+
interceptor.intercept(context, buildCallHandler());
|
|
74
|
+
expect(request.body).toEqual({
|
|
75
|
+
name: 'Product'
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
it('should not throw when body is null and there is no user', ()=>{
|
|
79
|
+
const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('createdById');
|
|
80
|
+
const interceptor = new Interceptor();
|
|
81
|
+
const { context, request } = buildContext(null, undefined);
|
|
82
|
+
expect(()=>interceptor.intercept(context, buildCallHandler())).not.toThrow();
|
|
83
|
+
expect(request.body).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
it('should return next.handle() observable', (done)=>{
|
|
86
|
+
const Interceptor = (0, _setuserfieldonbodyinterceptor.createSetUserFieldInterceptor)('createdById');
|
|
87
|
+
const interceptor = new Interceptor();
|
|
88
|
+
const { context } = buildContext({}, {
|
|
89
|
+
id: 'user-1'
|
|
90
|
+
});
|
|
91
|
+
interceptor.intercept(context, buildCallHandler()).subscribe((value)=>{
|
|
92
|
+
expect(value).toBe('next-result');
|
|
93
|
+
done();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('preconfigured user-field interceptors', ()=>{
|
|
98
|
+
it('should set createdById via SetCreatedByOnBody', ()=>{
|
|
99
|
+
const interceptor = new _setuserfieldonbodyinterceptor.SetCreatedByOnBody();
|
|
100
|
+
const { context, request } = buildContext({}, {
|
|
101
|
+
id: 'user-1'
|
|
102
|
+
});
|
|
103
|
+
interceptor.intercept(context, buildCallHandler());
|
|
104
|
+
expect(request.body).toEqual({
|
|
105
|
+
createdById: 'user-1'
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
it('should set updatedById via SetUpdateByOnBody', ()=>{
|
|
109
|
+
const interceptor = new _setuserfieldonbodyinterceptor.SetUpdateByOnBody();
|
|
110
|
+
const { context, request } = buildContext({}, {
|
|
111
|
+
id: 'user-1'
|
|
112
|
+
});
|
|
113
|
+
interceptor.intercept(context, buildCallHandler());
|
|
114
|
+
expect(request.body).toEqual({
|
|
115
|
+
updatedById: 'user-1'
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
it('should set deletedById via SetDeletedByOnBody', ()=>{
|
|
119
|
+
const interceptor = new _setuserfieldonbodyinterceptor.SetDeletedByOnBody();
|
|
120
|
+
const { context, request } = buildContext({}, {
|
|
121
|
+
id: 'user-1'
|
|
122
|
+
});
|
|
123
|
+
interceptor.intercept(context, buildCallHandler());
|
|
124
|
+
expect(request.body).toEqual({
|
|
125
|
+
deletedById: 'user-1'
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const _rxjs = require("rxjs");
|
|
6
|
+
const _sluginterceptor = require("./slug.interceptor");
|
|
7
|
+
function buildContext(body) {
|
|
8
|
+
const request = {
|
|
9
|
+
body
|
|
10
|
+
};
|
|
11
|
+
const context = {
|
|
12
|
+
switchToHttp: ()=>({
|
|
13
|
+
getRequest: ()=>request
|
|
14
|
+
})
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
context,
|
|
18
|
+
request
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function buildCallHandler() {
|
|
22
|
+
return {
|
|
23
|
+
handle: jest.fn().mockReturnValue((0, _rxjs.of)('next-result'))
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
describe('Slug interceptor', ()=>{
|
|
27
|
+
let utilsService;
|
|
28
|
+
let interceptor;
|
|
29
|
+
beforeEach(()=>{
|
|
30
|
+
utilsService = {
|
|
31
|
+
transformToSlug: jest.fn((value)=>value.toLowerCase().replace(/\s+/g, '-'))
|
|
32
|
+
};
|
|
33
|
+
interceptor = new _sluginterceptor.Slug(utilsService);
|
|
34
|
+
});
|
|
35
|
+
it('should generate a slug from name on a single object body without one', ()=>{
|
|
36
|
+
const { context, request } = buildContext({
|
|
37
|
+
name: 'My Product'
|
|
38
|
+
});
|
|
39
|
+
interceptor.intercept(context, buildCallHandler());
|
|
40
|
+
expect(utilsService.transformToSlug).toHaveBeenCalledWith('My Product');
|
|
41
|
+
expect(request.body).toEqual({
|
|
42
|
+
name: 'My Product',
|
|
43
|
+
slug: 'my-product'
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
it('should not overwrite an existing slug on a single object body', ()=>{
|
|
47
|
+
const { context, request } = buildContext({
|
|
48
|
+
name: 'My Product',
|
|
49
|
+
slug: 'custom-slug'
|
|
50
|
+
});
|
|
51
|
+
interceptor.intercept(context, buildCallHandler());
|
|
52
|
+
expect(utilsService.transformToSlug).not.toHaveBeenCalled();
|
|
53
|
+
expect(request.body).toEqual({
|
|
54
|
+
name: 'My Product',
|
|
55
|
+
slug: 'custom-slug'
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it('should not generate a slug when there is no name', ()=>{
|
|
59
|
+
const { context, request } = buildContext({
|
|
60
|
+
description: 'no name here'
|
|
61
|
+
});
|
|
62
|
+
interceptor.intercept(context, buildCallHandler());
|
|
63
|
+
expect(utilsService.transformToSlug).not.toHaveBeenCalled();
|
|
64
|
+
expect(request.body).toEqual({
|
|
65
|
+
description: 'no name here'
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
it('should generate slugs for each array item missing one', ()=>{
|
|
69
|
+
const { context, request } = buildContext([
|
|
70
|
+
{
|
|
71
|
+
name: 'Item One'
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'Item Two',
|
|
75
|
+
slug: 'existing'
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
description: 'no name'
|
|
79
|
+
}
|
|
80
|
+
]);
|
|
81
|
+
interceptor.intercept(context, buildCallHandler());
|
|
82
|
+
expect(request.body).toEqual([
|
|
83
|
+
{
|
|
84
|
+
name: 'Item One',
|
|
85
|
+
slug: 'item-one'
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'Item Two',
|
|
89
|
+
slug: 'existing'
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
description: 'no name'
|
|
93
|
+
}
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
it('should not throw when body is undefined', ()=>{
|
|
97
|
+
const { context, request } = buildContext(undefined);
|
|
98
|
+
expect(()=>interceptor.intercept(context, buildCallHandler())).not.toThrow();
|
|
99
|
+
expect(request.body).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
it('should return next.handle() observable', (done)=>{
|
|
102
|
+
const { context } = buildContext({
|
|
103
|
+
name: 'X'
|
|
104
|
+
});
|
|
105
|
+
interceptor.intercept(context, buildCallHandler()).subscribe((value)=>{
|
|
106
|
+
expect(value).toBe('next-result');
|
|
107
|
+
done();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", {
|
|
3
|
+
value: true
|
|
4
|
+
});
|
|
5
|
+
const mockGetLogConfig = jest.fn();
|
|
6
|
+
jest.mock('@flusys/nestjs-core/config', ()=>({
|
|
7
|
+
envConfig: {
|
|
8
|
+
getLogConfig: (...args)=>mockGetLogConfig(...args)
|
|
9
|
+
}
|
|
10
|
+
}));
|
|
11
|
+
const mockLoggerInfo = jest.fn();
|
|
12
|
+
const mockLoggerWarn = jest.fn();
|
|
13
|
+
const mockLoggerError = jest.fn();
|
|
14
|
+
const mockLoggerLog = jest.fn();
|
|
15
|
+
jest.mock('../classes/winston.logger.class', ()=>({
|
|
16
|
+
instance: {
|
|
17
|
+
info: (...args)=>mockLoggerInfo(...args),
|
|
18
|
+
warn: (...args)=>mockLoggerWarn(...args),
|
|
19
|
+
error: (...args)=>mockLoggerError(...args),
|
|
20
|
+
log: (...args)=>mockLoggerLog(...args)
|
|
21
|
+
}
|
|
22
|
+
}));
|
|
23
|
+
let uuidCounter = 0;
|
|
24
|
+
jest.mock('uuid', ()=>({
|
|
25
|
+
v4: ()=>`generated-uuid-${++uuidCounter}`
|
|
26
|
+
}));
|
|
27
|
+
function loadMiddleware(logConfig) {
|
|
28
|
+
jest.resetModules();
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
uuidCounter = 0;
|
|
31
|
+
mockGetLogConfig.mockReturnValue({
|
|
32
|
+
dir: 'logs',
|
|
33
|
+
level: 'info',
|
|
34
|
+
maxSize: '20m',
|
|
35
|
+
maxFiles: '14d',
|
|
36
|
+
disableHttpLogging: false,
|
|
37
|
+
...logConfig
|
|
38
|
+
});
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
|
+
return require('./logger.middleware');
|
|
41
|
+
}
|
|
42
|
+
function makeReqRes(overrides = {}) {
|
|
43
|
+
const req = {
|
|
44
|
+
headers: {},
|
|
45
|
+
method: 'GET',
|
|
46
|
+
originalUrl: '/api/users',
|
|
47
|
+
path: '/api/users',
|
|
48
|
+
query: {},
|
|
49
|
+
body: undefined,
|
|
50
|
+
socket: {
|
|
51
|
+
remoteAddress: '10.0.0.1'
|
|
52
|
+
},
|
|
53
|
+
...overrides
|
|
54
|
+
};
|
|
55
|
+
const res = {
|
|
56
|
+
statusCode: 200,
|
|
57
|
+
statusMessage: 'OK',
|
|
58
|
+
setHeader: jest.fn(),
|
|
59
|
+
getHeader: jest.fn().mockReturnValue(undefined),
|
|
60
|
+
send: jest.fn(function(body) {
|
|
61
|
+
return body;
|
|
62
|
+
}),
|
|
63
|
+
json: jest.fn(function(body) {
|
|
64
|
+
return body;
|
|
65
|
+
}),
|
|
66
|
+
end: jest.fn(function() {
|
|
67
|
+
return this;
|
|
68
|
+
}),
|
|
69
|
+
on: jest.fn()
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
req,
|
|
73
|
+
res
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
describe('LoggerMiddleware', ()=>{
|
|
77
|
+
describe('request correlation', ()=>{
|
|
78
|
+
it('should generate a request id when none is provided and echo it back on the response', ()=>{
|
|
79
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
80
|
+
const middleware = new LoggerMiddleware();
|
|
81
|
+
const { req, res } = makeReqRes();
|
|
82
|
+
const next = jest.fn();
|
|
83
|
+
middleware.use(req, res, next);
|
|
84
|
+
expect(res.setHeader).toHaveBeenCalledWith('x-request-id', 'generated-uuid-1');
|
|
85
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
86
|
+
});
|
|
87
|
+
it('should reuse an incoming x-request-id header instead of generating one', ()=>{
|
|
88
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
89
|
+
const middleware = new LoggerMiddleware();
|
|
90
|
+
const { req, res } = makeReqRes({
|
|
91
|
+
headers: {
|
|
92
|
+
'x-request-id': 'incoming-id'
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const next = jest.fn();
|
|
96
|
+
middleware.use(req, res, next);
|
|
97
|
+
expect(res.setHeader).toHaveBeenCalledWith('x-request-id', 'incoming-id');
|
|
98
|
+
});
|
|
99
|
+
it('should expose requestId/tenantId via context accessors inside the request scope', ()=>{
|
|
100
|
+
const mod = loadMiddleware({});
|
|
101
|
+
const middleware = new mod.LoggerMiddleware();
|
|
102
|
+
const { req, res } = makeReqRes({
|
|
103
|
+
headers: {
|
|
104
|
+
'x-request-id': 'req-1',
|
|
105
|
+
'x-tenant-id': 'tenant-1'
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
let seenDuringRequest;
|
|
109
|
+
const next = jest.fn(()=>{
|
|
110
|
+
seenDuringRequest = {
|
|
111
|
+
requestId: mod.getRequestId(),
|
|
112
|
+
tenantId: mod.getTenantId()
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
middleware.use(req, res, next);
|
|
116
|
+
expect(seenDuringRequest).toEqual({
|
|
117
|
+
requestId: 'req-1',
|
|
118
|
+
tenantId: 'tenant-1'
|
|
119
|
+
});
|
|
120
|
+
// outside the request scope the store is gone
|
|
121
|
+
expect(mod.getRequestId()).toBeUndefined();
|
|
122
|
+
expect(mod.getTenantId()).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
it('should leave tenantId undefined when no x-tenant-id header is present', ()=>{
|
|
125
|
+
const mod = loadMiddleware({});
|
|
126
|
+
const middleware = new mod.LoggerMiddleware();
|
|
127
|
+
const { req, res } = makeReqRes();
|
|
128
|
+
let tenantIdSeen;
|
|
129
|
+
const next = jest.fn(()=>{
|
|
130
|
+
tenantIdSeen = mod.getTenantId();
|
|
131
|
+
});
|
|
132
|
+
middleware.use(req, res, next);
|
|
133
|
+
expect(tenantIdSeen).toBeUndefined();
|
|
134
|
+
});
|
|
135
|
+
it('getUserId/getCompanyId should return undefined when not explicitly set on the context', ()=>{
|
|
136
|
+
const mod = loadMiddleware({});
|
|
137
|
+
const middleware = new mod.LoggerMiddleware();
|
|
138
|
+
const { req, res } = makeReqRes();
|
|
139
|
+
let seen;
|
|
140
|
+
const next = jest.fn(()=>{
|
|
141
|
+
seen = {
|
|
142
|
+
userId: mod.getUserId(),
|
|
143
|
+
companyId: mod.getCompanyId()
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
middleware.use(req, res, next);
|
|
147
|
+
expect(seen).toEqual({
|
|
148
|
+
userId: undefined,
|
|
149
|
+
companyId: undefined
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('excluded / disabled logging', ()=>{
|
|
154
|
+
it.each([
|
|
155
|
+
'/health',
|
|
156
|
+
'/metrics',
|
|
157
|
+
'/favicon.ico'
|
|
158
|
+
])('should skip request logging for excluded path %s', (path)=>{
|
|
159
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
160
|
+
const middleware = new LoggerMiddleware();
|
|
161
|
+
const { req, res } = makeReqRes({
|
|
162
|
+
originalUrl: path,
|
|
163
|
+
path
|
|
164
|
+
});
|
|
165
|
+
const next = jest.fn();
|
|
166
|
+
middleware.use(req, res, next);
|
|
167
|
+
expect(mockLoggerInfo).not.toHaveBeenCalled();
|
|
168
|
+
expect(next).toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
it('should skip request logging entirely when DISABLE_HTTP_LOGGING is true', ()=>{
|
|
171
|
+
const { LoggerMiddleware } = loadMiddleware({
|
|
172
|
+
disableHttpLogging: true
|
|
173
|
+
});
|
|
174
|
+
const middleware = new LoggerMiddleware();
|
|
175
|
+
const { req, res } = makeReqRes();
|
|
176
|
+
const next = jest.fn();
|
|
177
|
+
middleware.use(req, res, next);
|
|
178
|
+
expect(mockLoggerInfo).not.toHaveBeenCalled();
|
|
179
|
+
expect(next).toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
it('should still set up request correlation even when logging is disabled', ()=>{
|
|
182
|
+
const mod = loadMiddleware({
|
|
183
|
+
disableHttpLogging: true
|
|
184
|
+
});
|
|
185
|
+
const middleware = new mod.LoggerMiddleware();
|
|
186
|
+
const { req, res } = makeReqRes({
|
|
187
|
+
headers: {
|
|
188
|
+
'x-request-id': 'req-1'
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
let seen;
|
|
192
|
+
const next = jest.fn(()=>{
|
|
193
|
+
seen = mod.getRequestId();
|
|
194
|
+
});
|
|
195
|
+
middleware.use(req, res, next);
|
|
196
|
+
expect(seen).toBe('req-1');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
describe('request logging', ()=>{
|
|
200
|
+
it('should log a descriptive start message with method/url/ip', ()=>{
|
|
201
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
202
|
+
const middleware = new LoggerMiddleware();
|
|
203
|
+
const { req, res } = makeReqRes({
|
|
204
|
+
method: 'POST',
|
|
205
|
+
originalUrl: '/api/orders'
|
|
206
|
+
});
|
|
207
|
+
middleware.use(req, res, jest.fn());
|
|
208
|
+
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('Started processing request "POST" "/api/orders" from "10.0.0.1"'), expect.objectContaining({
|
|
209
|
+
context: 'HTTP',
|
|
210
|
+
method: 'POST',
|
|
211
|
+
url: '/api/orders'
|
|
212
|
+
}));
|
|
213
|
+
});
|
|
214
|
+
it('should prefer x-forwarded-for over the socket remote address', ()=>{
|
|
215
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
216
|
+
const middleware = new LoggerMiddleware();
|
|
217
|
+
const { req, res } = makeReqRes({
|
|
218
|
+
headers: {
|
|
219
|
+
'x-forwarded-for': '203.0.113.5, 10.0.0.1'
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
middleware.use(req, res, jest.fn());
|
|
223
|
+
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('from "203.0.113.5"'), expect.anything());
|
|
224
|
+
});
|
|
225
|
+
it('should fall back to "unknown" ip when neither header nor socket is available', ()=>{
|
|
226
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
227
|
+
const middleware = new LoggerMiddleware();
|
|
228
|
+
const { req, res } = makeReqRes({
|
|
229
|
+
socket: undefined
|
|
230
|
+
});
|
|
231
|
+
middleware.use(req, res, jest.fn());
|
|
232
|
+
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.stringContaining('from "unknown"'), expect.anything());
|
|
233
|
+
});
|
|
234
|
+
it('should include the query object only when it has keys', ()=>{
|
|
235
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
236
|
+
const middleware = new LoggerMiddleware();
|
|
237
|
+
const { req, res } = makeReqRes({
|
|
238
|
+
query: {
|
|
239
|
+
page: '1'
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
middleware.use(req, res, jest.fn());
|
|
243
|
+
expect(mockLoggerInfo).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
244
|
+
query: {
|
|
245
|
+
page: '1'
|
|
246
|
+
}
|
|
247
|
+
}));
|
|
248
|
+
});
|
|
249
|
+
it('should omit the query field entirely for an empty query object', ()=>{
|
|
250
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
251
|
+
const middleware = new LoggerMiddleware();
|
|
252
|
+
const { req, res } = makeReqRes({
|
|
253
|
+
query: {}
|
|
254
|
+
});
|
|
255
|
+
middleware.use(req, res, jest.fn());
|
|
256
|
+
const logData = mockLoggerInfo.mock.calls[0][1];
|
|
257
|
+
expect(logData.query).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
it('should not include a body summary when not in debug mode', ()=>{
|
|
260
|
+
const { LoggerMiddleware } = loadMiddleware({
|
|
261
|
+
level: 'info'
|
|
262
|
+
});
|
|
263
|
+
const middleware = new LoggerMiddleware();
|
|
264
|
+
const { req, res } = makeReqRes({
|
|
265
|
+
body: {
|
|
266
|
+
pagination: {
|
|
267
|
+
currentPage: 2
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
middleware.use(req, res, jest.fn());
|
|
272
|
+
const logData = mockLoggerInfo.mock.calls[0][1];
|
|
273
|
+
expect(logData.body).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
it('should include a summarized body when in debug mode', ()=>{
|
|
276
|
+
const { LoggerMiddleware } = loadMiddleware({
|
|
277
|
+
level: 'debug'
|
|
278
|
+
});
|
|
279
|
+
const middleware = new LoggerMiddleware();
|
|
280
|
+
const { req, res } = makeReqRes({
|
|
281
|
+
body: {
|
|
282
|
+
pagination: {
|
|
283
|
+
currentPage: 2,
|
|
284
|
+
pageSize: 10
|
|
285
|
+
},
|
|
286
|
+
filter: {
|
|
287
|
+
active: true
|
|
288
|
+
},
|
|
289
|
+
id: 'x-1'
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
middleware.use(req, res, jest.fn());
|
|
293
|
+
const logData = mockLoggerInfo.mock.calls[0][1];
|
|
294
|
+
expect(JSON.parse(logData.body)).toEqual({
|
|
295
|
+
page: 2,
|
|
296
|
+
pageSize: 10,
|
|
297
|
+
filterKeys: [
|
|
298
|
+
'active'
|
|
299
|
+
],
|
|
300
|
+
id: 'x-1'
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
it('should truncate an overly long non-object body in debug mode', ()=>{
|
|
304
|
+
const { LoggerMiddleware } = loadMiddleware({
|
|
305
|
+
level: 'debug'
|
|
306
|
+
});
|
|
307
|
+
const middleware = new LoggerMiddleware();
|
|
308
|
+
const longBody = 'x'.repeat(1500);
|
|
309
|
+
const { req, res } = makeReqRes({
|
|
310
|
+
body: longBody
|
|
311
|
+
});
|
|
312
|
+
middleware.use(req, res, jest.fn());
|
|
313
|
+
const logData = mockLoggerInfo.mock.calls[0][1];
|
|
314
|
+
expect(logData.body.endsWith('...')).toBe(true);
|
|
315
|
+
expect(logData.body.length).toBe(1003);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
describe('response logging', ()=>{
|
|
319
|
+
it('should log once even when send/json/end are all invoked for the same response', ()=>{
|
|
320
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
321
|
+
const middleware = new LoggerMiddleware();
|
|
322
|
+
const { req, res } = makeReqRes();
|
|
323
|
+
middleware.use(req, res, jest.fn());
|
|
324
|
+
mockLoggerLog.mockClear();
|
|
325
|
+
res.json({
|
|
326
|
+
success: true
|
|
327
|
+
});
|
|
328
|
+
res.send({
|
|
329
|
+
success: true
|
|
330
|
+
});
|
|
331
|
+
res.end();
|
|
332
|
+
expect(mockLoggerLog).toHaveBeenCalledTimes(1);
|
|
333
|
+
});
|
|
334
|
+
it('should log at "info" for 2xx responses', ()=>{
|
|
335
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
336
|
+
const middleware = new LoggerMiddleware();
|
|
337
|
+
const { req, res } = makeReqRes();
|
|
338
|
+
res.statusCode = 200;
|
|
339
|
+
middleware.use(req, res, jest.fn());
|
|
340
|
+
mockLoggerLog.mockClear();
|
|
341
|
+
res.json({
|
|
342
|
+
success: true
|
|
343
|
+
});
|
|
344
|
+
expect(mockLoggerLog).toHaveBeenCalledWith('info', expect.any(String), expect.any(Object));
|
|
345
|
+
});
|
|
346
|
+
it('should log at "warn" for 4xx responses and include a response body summary', ()=>{
|
|
347
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
348
|
+
const middleware = new LoggerMiddleware();
|
|
349
|
+
const { req, res } = makeReqRes();
|
|
350
|
+
res.statusCode = 404;
|
|
351
|
+
middleware.use(req, res, jest.fn());
|
|
352
|
+
mockLoggerLog.mockClear();
|
|
353
|
+
res.json({
|
|
354
|
+
success: false,
|
|
355
|
+
message: 'Not found'
|
|
356
|
+
});
|
|
357
|
+
expect(mockLoggerLog).toHaveBeenCalledWith('warn', expect.any(String), expect.objectContaining({
|
|
358
|
+
responseBody: expect.stringContaining('Not found')
|
|
359
|
+
}));
|
|
360
|
+
});
|
|
361
|
+
it('should log at "error" for 5xx responses', ()=>{
|
|
362
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
363
|
+
const middleware = new LoggerMiddleware();
|
|
364
|
+
const { req, res } = makeReqRes();
|
|
365
|
+
res.statusCode = 500;
|
|
366
|
+
middleware.use(req, res, jest.fn());
|
|
367
|
+
mockLoggerLog.mockClear();
|
|
368
|
+
res.json({
|
|
369
|
+
success: false
|
|
370
|
+
});
|
|
371
|
+
expect(mockLoggerLog).toHaveBeenCalledWith('error', expect.any(String), expect.any(Object));
|
|
372
|
+
});
|
|
373
|
+
it('should not include a response body summary for successful non-debug responses', ()=>{
|
|
374
|
+
const { LoggerMiddleware } = loadMiddleware({
|
|
375
|
+
level: 'info'
|
|
376
|
+
});
|
|
377
|
+
const middleware = new LoggerMiddleware();
|
|
378
|
+
const { req, res } = makeReqRes();
|
|
379
|
+
res.statusCode = 200;
|
|
380
|
+
middleware.use(req, res, jest.fn());
|
|
381
|
+
mockLoggerLog.mockClear();
|
|
382
|
+
res.json({
|
|
383
|
+
success: true
|
|
384
|
+
});
|
|
385
|
+
const logData = mockLoggerLog.mock.calls[0][2];
|
|
386
|
+
expect(logData.responseBody).toBeUndefined();
|
|
387
|
+
});
|
|
388
|
+
it('should include a response body summary for successful responses in debug mode', ()=>{
|
|
389
|
+
const { LoggerMiddleware } = loadMiddleware({
|
|
390
|
+
level: 'debug'
|
|
391
|
+
});
|
|
392
|
+
const middleware = new LoggerMiddleware();
|
|
393
|
+
const { req, res } = makeReqRes();
|
|
394
|
+
res.statusCode = 200;
|
|
395
|
+
middleware.use(req, res, jest.fn());
|
|
396
|
+
mockLoggerLog.mockClear();
|
|
397
|
+
res.json({
|
|
398
|
+
success: true,
|
|
399
|
+
data: [
|
|
400
|
+
1,
|
|
401
|
+
2,
|
|
402
|
+
3
|
|
403
|
+
]
|
|
404
|
+
});
|
|
405
|
+
const logData = mockLoggerLog.mock.calls[0][2];
|
|
406
|
+
expect(JSON.parse(logData.responseBody)).toEqual({
|
|
407
|
+
success: true,
|
|
408
|
+
dataCount: 3
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
it('should warn about slow requests over the 3s threshold on successful responses', ()=>{
|
|
412
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
413
|
+
const middleware = new LoggerMiddleware();
|
|
414
|
+
const { req, res } = makeReqRes();
|
|
415
|
+
res.statusCode = 200;
|
|
416
|
+
const realNow = Date.now;
|
|
417
|
+
let callCount = 0;
|
|
418
|
+
jest.spyOn(Date, 'now').mockImplementation(()=>{
|
|
419
|
+
callCount += 1;
|
|
420
|
+
// first call = startTime, subsequent calls happen after the "slow" work
|
|
421
|
+
return callCount === 1 ? 1_000 : 5_000;
|
|
422
|
+
});
|
|
423
|
+
middleware.use(req, res, jest.fn());
|
|
424
|
+
res.json({
|
|
425
|
+
success: true
|
|
426
|
+
});
|
|
427
|
+
expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('Slow request detected'), expect.objectContaining({
|
|
428
|
+
threshold: '3000ms'
|
|
429
|
+
}));
|
|
430
|
+
Date.now.mockRestore?.() ?? (Date.now = realNow);
|
|
431
|
+
});
|
|
432
|
+
it('should not warn about slow requests when the response was an error', ()=>{
|
|
433
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
434
|
+
const middleware = new LoggerMiddleware();
|
|
435
|
+
const { req, res } = makeReqRes();
|
|
436
|
+
res.statusCode = 500;
|
|
437
|
+
let callCount = 0;
|
|
438
|
+
jest.spyOn(Date, 'now').mockImplementation(()=>{
|
|
439
|
+
callCount += 1;
|
|
440
|
+
return callCount === 1 ? 1_000 : 5_000;
|
|
441
|
+
});
|
|
442
|
+
middleware.use(req, res, jest.fn());
|
|
443
|
+
res.json({
|
|
444
|
+
success: false
|
|
445
|
+
});
|
|
446
|
+
expect(mockLoggerWarn).not.toHaveBeenCalled();
|
|
447
|
+
Date.now.mockRestore();
|
|
448
|
+
});
|
|
449
|
+
it('should log response errors emitted on the res "error" event', ()=>{
|
|
450
|
+
const { LoggerMiddleware } = loadMiddleware({});
|
|
451
|
+
const middleware = new LoggerMiddleware();
|
|
452
|
+
const { req, res } = makeReqRes();
|
|
453
|
+
middleware.use(req, res, jest.fn());
|
|
454
|
+
const errorHandler = res.on.mock.calls.find(([event])=>event === 'error')?.[1];
|
|
455
|
+
expect(errorHandler).toBeDefined();
|
|
456
|
+
errorHandler(new Error('stream broke'));
|
|
457
|
+
expect(mockLoggerError).toHaveBeenCalledWith('Response error', expect.objectContaining({
|
|
458
|
+
error: 'stream broke'
|
|
459
|
+
}));
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
});
|