@defra/lis-infra-ui-services 0.36.0 → 0.37.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/README.md CHANGED
@@ -180,6 +180,8 @@ The main entry point (`@defra/lis-infra-ui-services`) exports `buildMicrositePat
180
180
  | 503 | `service-unavailable` |
181
181
  | anything else | `problem-with-service` |
182
182
 
183
+ 5xx responses are logged at `error`, and 4xx responses other than 401, 403 and 404 at `warn`, each with the error object, request method, path and status code. Boom messages are logged, so application code must not put user-supplied text in them.
184
+
183
185
  ```js
184
186
  import { catchAll } from '@defra/lis-infra-ui-services/errors'
185
187
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -28,16 +28,56 @@ function getStatusCode(response) {
28
28
  return response?.statusCode ?? response?.output?.statusCode
29
29
  }
30
30
 
31
- function getLogMessage(response, statusCode) {
31
+ const routineClientErrors = new Set([
32
+ statusCodes.notFound,
33
+ statusCodes.unauthorized,
34
+ statusCodes.forbidden
35
+ ])
36
+
37
+ function getError(response, statusCode) {
38
+ const error = response?.source instanceof Error ? response.source : response
39
+ if (error instanceof Error) {
40
+ return error
41
+ }
42
+ if (typeof response?.source?.message === 'string') {
43
+ return new Error(response.source.message)
44
+ }
45
+ return new Error(`Request failed with status ${statusCode}`)
46
+ }
47
+
48
+ function isLoggableClientError(response, statusCode) {
32
49
  return (
33
- response?.stack ??
34
- response?.message ??
35
- response?.source?.stack ??
36
- response?.source?.message ??
37
- `Request failed with status ${statusCode}`
50
+ response?.isBoom === true &&
51
+ statusCode >= statusCodes.badRequest &&
52
+ statusCode < statusCodes.internalServerError &&
53
+ !routineClientErrors.has(statusCode)
38
54
  )
39
55
  }
40
56
 
57
+ function logFailure(request, statusCode) {
58
+ const { response } = request
59
+ const isServerError =
60
+ statusCode >= statusCodes.internalServerError &&
61
+ !isAnsweredByMaintenance(request)
62
+
63
+ if (!isServerError && !isLoggableClientError(response, statusCode)) {
64
+ return
65
+ }
66
+
67
+ // Keys mirror hapi-pino's shape because the CDP pipeline maps them to ECS fields and drops the rest; request.path deliberately omits the query string
68
+ const context = {
69
+ err: getError(response, statusCode),
70
+ req: { method: request.method, url: request.path },
71
+ res: { statusCode }
72
+ }
73
+
74
+ if (isServerError) {
75
+ logger.error(context, 'Request failed')
76
+ } else {
77
+ logger.warn(context, 'Request rejected')
78
+ }
79
+ }
80
+
41
81
  /**
42
82
  * @param {Request} request
43
83
  * @param {ResponseToolkit} h
@@ -47,12 +87,7 @@ export function catchAll(request, h) {
47
87
  const { response } = request
48
88
  const statusCode = getStatusCode(response)
49
89
 
50
- if (
51
- statusCode >= statusCodes.internalServerError &&
52
- !isAnsweredByMaintenance(request)
53
- ) {
54
- logger.error(getLogMessage(response, statusCode))
55
- }
90
+ logFailure(request, statusCode)
56
91
 
57
92
  if (!('isBoom' in response)) {
58
93
  return h.continue
@@ -1,10 +1,10 @@
1
1
  import assert from 'node:assert/strict'
2
- import { afterEach, test, vi } from 'vitest'
2
+ import { afterEach, expect, test, vi } from 'vitest'
3
3
 
4
4
  import { statusCodes } from '../status-codes.js'
5
5
 
6
6
  const { logger } = vi.hoisted(() => ({
7
- logger: { error: vi.fn() }
7
+ logger: { error: vi.fn(), warn: vi.fn() }
8
8
  }))
9
9
 
10
10
  vi.mock('@defra/lis-hubs-infra-core', () => ({ logger }))
@@ -23,13 +23,11 @@ function createToolkit() {
23
23
  return toolkit
24
24
  }
25
25
 
26
- function createBoomRequest(statusCode) {
26
+ function createBoomRequest(statusCode, error = new Error('Mock error')) {
27
27
  return {
28
- response: {
29
- isBoom: true,
30
- stack: 'Mock error stack',
31
- output: { statusCode }
32
- }
28
+ method: 'get',
29
+ path: '/mock-path',
30
+ response: Object.assign(error, { isBoom: true, output: { statusCode } })
33
31
  }
34
32
  }
35
33
 
@@ -106,19 +104,54 @@ test('catchAll logs server errors and renders the problem with the service page'
106
104
  assert.deepEqual(toolkit.code.mock.calls[0], [
107
105
  statusCodes.internalServerError
108
106
  ])
109
- assert.deepEqual(logger.error.mock.calls[0], ['Mock error stack'])
107
+ assert.deepEqual(logger.error.mock.calls[0], [
108
+ {
109
+ err: request.response,
110
+ req: { method: 'get', url: '/mock-path' },
111
+ res: { statusCode: statusCodes.internalServerError }
112
+ },
113
+ 'Request failed'
114
+ ])
115
+ assert.equal(logger.warn.mock.calls.length, 0)
110
116
  })
111
117
 
112
- test('catchAll does not log client errors', () => {
118
+ test.each([statusCodes.badRequest, statusCodes.unprocessableEntity])(
119
+ 'catchAll warns about a %i with the error',
120
+ (statusCode) => {
121
+ // Arrange
122
+ const request = createBoomRequest(statusCode, new Error('Invalid input'))
123
+ const toolkit = createToolkit()
124
+
125
+ // Act
126
+ catchAll(request, toolkit)
127
+
128
+ // Assert
129
+ assert.deepEqual(logger.warn.mock.calls[0], [
130
+ {
131
+ err: request.response,
132
+ req: { method: 'get', url: '/mock-path' },
133
+ res: { statusCode }
134
+ },
135
+ 'Request rejected'
136
+ ])
137
+ assert.equal(logger.error.mock.calls.length, 0)
138
+ }
139
+ )
140
+
141
+ test.each([
142
+ statusCodes.notFound,
143
+ statusCodes.unauthorized,
144
+ statusCodes.forbidden
145
+ ])('catchAll does not log a routine %i', (statusCode) => {
113
146
  // Arrange
114
- const request = createBoomRequest(statusCodes.notFound)
115
- const toolkit = createToolkit()
147
+ const request = createBoomRequest(statusCode)
116
148
 
117
149
  // Act
118
- catchAll(request, toolkit)
150
+ catchAll(request, createToolkit())
119
151
 
120
152
  // Assert
121
153
  assert.equal(logger.error.mock.calls.length, 0)
154
+ assert.equal(logger.warn.mock.calls.length, 0)
122
155
  })
123
156
 
124
157
  test('catchAll prefers a client-attached statusCode over a boomified 500', () => {
@@ -127,7 +160,6 @@ test('catchAll prefers a client-attached statusCode over a boomified 500', () =>
127
160
  response: {
128
161
  isBoom: true,
129
162
  statusCode: statusCodes.notFound,
130
- stack: 'Mock error stack',
131
163
  output: { statusCode: statusCodes.internalServerError }
132
164
  }
133
165
  }
@@ -161,15 +193,16 @@ test('catchAll does not log the 503 the maintenance plugin answered with', () =>
161
193
  assert.equal(logger.error.mock.calls.length, 0)
162
194
  })
163
195
 
164
- test('catchAll logs plain 500 responses before continuing', () => {
196
+ test('catchAll logs the error behind plain 500 responses before continuing', () => {
165
197
  // Arrange
166
198
  const toolkit = createToolkit()
199
+ const sourceError = new Error('Plain 500 response')
167
200
  const request = {
201
+ method: 'post',
202
+ path: '/plain',
168
203
  response: {
169
204
  statusCode: statusCodes.internalServerError,
170
- source: {
171
- message: 'Plain 500 response'
172
- }
205
+ source: sourceError
173
206
  }
174
207
  }
175
208
 
@@ -178,6 +211,48 @@ test('catchAll logs plain 500 responses before continuing', () => {
178
211
 
179
212
  // Assert
180
213
  assert.equal(result, toolkit.continue)
181
- assert.deepEqual(logger.error.mock.calls[0], ['Plain 500 response'])
214
+ assert.deepEqual(logger.error.mock.calls[0], [
215
+ {
216
+ err: sourceError,
217
+ req: { method: 'post', url: '/plain' },
218
+ res: { statusCode: statusCodes.internalServerError }
219
+ },
220
+ 'Request failed'
221
+ ])
182
222
  assert.equal(toolkit.view.mock.calls.length, 0)
183
223
  })
224
+
225
+ test('catchAll falls back to a generated error when none is attached', () => {
226
+ // Arrange
227
+ const request = {
228
+ method: 'get',
229
+ path: '/x',
230
+ response: { statusCode: statusCodes.internalServerError, source: {} }
231
+ }
232
+
233
+ // Act
234
+ catchAll(request, createToolkit())
235
+
236
+ // Assert
237
+ expect(logger.error.mock.calls[0][0].err.message).toBe(
238
+ 'Request failed with status 500'
239
+ )
240
+ })
241
+
242
+ test('catchAll logs the message of a plain 500 response whose source has one', () => {
243
+ // Arrange
244
+ const request = {
245
+ method: 'get',
246
+ path: '/x',
247
+ response: {
248
+ statusCode: statusCodes.internalServerError,
249
+ source: { message: 'Something broke' }
250
+ }
251
+ }
252
+
253
+ // Act
254
+ catchAll(request, createToolkit())
255
+
256
+ // Assert
257
+ expect(logger.error.mock.calls[0][0].err.message).toBe('Something broke')
258
+ })
@@ -65,7 +65,7 @@ export function buildRedisClient({
65
65
  })
66
66
 
67
67
  redisClient.on('error', (error) => {
68
- logger?.error?.(`Redis connection error ${error}`)
68
+ logger?.error?.({ err: error }, 'Redis connection error')
69
69
  })
70
70
 
71
71
  return redisClient
@@ -130,11 +130,13 @@ test('buildRedisClient uses the default port and logs client events', () => {
130
130
  })
131
131
 
132
132
  handlers.connect()
133
- handlers.error(new Error('connection lost'))
133
+ const connectionError = new Error('connection lost')
134
+ handlers.error(connectionError)
134
135
 
135
136
  assert.deepEqual(logger.info.mock.calls[0], ['Connected to Redis server'])
136
137
  assert.deepEqual(logger.error.mock.calls[0], [
137
- 'Redis connection error Error: connection lost'
138
+ { err: connectionError },
139
+ 'Redis connection error'
138
140
  ])
139
141
  })
140
142
 
@@ -7,6 +7,7 @@ export const statusCodes = {
7
7
  notFound: 404,
8
8
  conflict: 409,
9
9
  imATeapot: 418,
10
+ unprocessableEntity: 422,
10
11
  internalServerError: 500,
11
12
  serviceUnavailable: 503
12
13
  }