@defra/lis-infra-ui-services 0.21.0 → 0.22.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 +56 -0
- package/package.json +3 -1
- package/src/errors/error-pages.js +19 -0
- package/src/errors/index.js +24 -22
- package/src/errors/index.test.js +119 -28
- package/src/maintenance.js +131 -0
- package/src/maintenance.test.js +260 -0
- package/src/nunjucks/context.js +19 -2
- package/src/nunjucks/context.test.js +76 -0
- package/src/nunjucks/plugin.js +6 -3
- package/src/nunjucks/plugin.test.js +24 -0
- package/src/nunjucks/routes/error/error-pages.test.js +194 -0
- package/src/nunjucks/routes/error/not-authorised.njk +17 -0
- package/src/nunjucks/routes/error/page-not-found.njk +20 -0
- package/src/nunjucks/routes/error/problem-with-service.njk +14 -0
- package/src/nunjucks/routes/error/service-unavailable.njk +18 -0
- package/src/nunjucks/routes/error/index.njk +0 -13
package/README.md
CHANGED
|
@@ -3,3 +3,59 @@
|
|
|
3
3
|
This package contains the shared topology metadata and route helpers used across the hub, spokes, and services.
|
|
4
4
|
|
|
5
5
|
Install dependencies for this package locally with `npm install` from this directory.
|
|
6
|
+
|
|
7
|
+
## Error pages
|
|
8
|
+
|
|
9
|
+
`catchAll` from `@defra/lis-infra-ui-services/errors`, registered as an `onPreResponse` extension, renders one of four shared pages from `src/nunjucks/routes/error/` by status code:
|
|
10
|
+
|
|
11
|
+
| Status | Page |
|
|
12
|
+
| ------------- | ---------------------- |
|
|
13
|
+
| 404 | `page-not-found` |
|
|
14
|
+
| 401, 403 | `not-authorised` |
|
|
15
|
+
| 503 | `service-unavailable` |
|
|
16
|
+
| anything else | `problem-with-service` |
|
|
17
|
+
|
|
18
|
+
The links on these pages come from the `errorPages` option of `createNunjucksConfig` (or `createNunjucksContextBuilder`), exposed to templates as `errorPages`. Every key is optional:
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
createNunjucksConfig({
|
|
22
|
+
config,
|
|
23
|
+
logger,
|
|
24
|
+
getRequestBasePath,
|
|
25
|
+
errorPages: {
|
|
26
|
+
contactHref:
|
|
27
|
+
'https://www.gov.uk/government/organisations/british-cattle-movement-service', // default
|
|
28
|
+
homeHref: '/', // default, "Home page" on page not found
|
|
29
|
+
getStartedHref: '/' // default, "Get started" on page not found
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Scheduled maintenance
|
|
35
|
+
|
|
36
|
+
Each hub switches maintenance on with its own config value, `maintenance.availableFrom`: the UK local date-time the service is back, in ISO 8601 form without an offset, e.g. `2026-11-19T09:00` or `2026-11-19T09:00:00`. Values with a `Z` or `+hh:mm` offset, and dates without a time, throw at start-up. An empty value leaves the service running as normal.
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
// config.js
|
|
40
|
+
maintenance: {
|
|
41
|
+
availableFrom: {
|
|
42
|
+
doc: 'UK local date-time the service is back from, without an offset; empty when not in maintenance',
|
|
43
|
+
format: String,
|
|
44
|
+
default: '',
|
|
45
|
+
env: 'MAINTENANCE_AVAILABLE_FROM'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// server.js, after @hapi/vision is registered
|
|
50
|
+
import { createMaintenancePlugin } from '@defra/lis-infra-ui-services/maintenance'
|
|
51
|
+
|
|
52
|
+
await server.register(
|
|
53
|
+
createMaintenancePlugin({
|
|
54
|
+
availableFrom: config.get('maintenance.availableFrom'),
|
|
55
|
+
healthPath: '/health', // default
|
|
56
|
+
assetPath: config.get('assetPath') // default '/public'
|
|
57
|
+
})
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
While `availableFrom` is set, every request except the health check and assets under `assetPath` gets the service unavailable page with a 503, showing the time as "9am on Thursday 19 November 2026" and carrying it in a `Retry-After` header.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra/lis-infra-ui-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "UI-Services shared definitions for the livestock taxonomy topology.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./components/sub-navigation.scss": "./src/components/sub-navigation.scss",
|
|
19
19
|
"./duration": "./src/duration.js",
|
|
20
20
|
"./errors": "./src/errors/index.js",
|
|
21
|
+
"./maintenance": "./src/maintenance.js",
|
|
21
22
|
"./navigation": "./src/navigation.js",
|
|
22
23
|
"./nunjucks/build-navigation": "./src/nunjucks/build-navigation.js",
|
|
23
24
|
"./nunjucks/context": "./src/nunjucks/context.js",
|
|
@@ -77,6 +78,7 @@
|
|
|
77
78
|
"@defra/lis-hubs-infra-access": "^0.29.0",
|
|
78
79
|
"@defra/lis-hubs-infra-core": "^0.12.0",
|
|
79
80
|
"@defra/lis-infra-eslint-config": "^0.6.0",
|
|
81
|
+
"@hapi/hapi": "^21.4.10",
|
|
80
82
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
81
83
|
"audit-ci": "^7.1.0",
|
|
82
84
|
"eslint": "^9.0.0",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const pageNotFound = {
|
|
2
|
+
view: 'error/page-not-found',
|
|
3
|
+
pageTitle: 'Page not found'
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const notAuthorised = {
|
|
7
|
+
view: 'error/not-authorised',
|
|
8
|
+
pageTitle: 'Sorry, you are not authorised to view this page'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const serviceUnavailable = {
|
|
12
|
+
view: 'error/service-unavailable',
|
|
13
|
+
pageTitle: 'Sorry, the service is unavailable'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const problemWithService = {
|
|
17
|
+
view: 'error/problem-with-service',
|
|
18
|
+
pageTitle: 'Sorry, there is a problem with the service'
|
|
19
|
+
}
|
package/src/errors/index.js
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
/** @import { Request, ResponseToolkit, Lifecycle } from '@hapi/hapi' */
|
|
2
2
|
import { logger } from '@defra/lis-hubs-infra-core'
|
|
3
3
|
|
|
4
|
+
import { isAnsweredByMaintenance } from '../maintenance.js'
|
|
5
|
+
import {
|
|
6
|
+
notAuthorised,
|
|
7
|
+
pageNotFound,
|
|
8
|
+
problemWithService,
|
|
9
|
+
serviceUnavailable
|
|
10
|
+
} from './error-pages.js'
|
|
4
11
|
import { statusCodes } from '../status-codes.js'
|
|
5
12
|
|
|
6
|
-
function
|
|
13
|
+
function errorPageFor(statusCode) {
|
|
7
14
|
switch (statusCode) {
|
|
8
15
|
case statusCodes.notFound:
|
|
9
|
-
return
|
|
10
|
-
case statusCodes.forbidden:
|
|
11
|
-
return 'Forbidden'
|
|
16
|
+
return pageNotFound
|
|
12
17
|
case statusCodes.unauthorized:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
case statusCodes.forbidden:
|
|
19
|
+
return notAuthorised
|
|
20
|
+
case statusCodes.serviceUnavailable:
|
|
21
|
+
return serviceUnavailable
|
|
16
22
|
default:
|
|
17
|
-
return
|
|
23
|
+
return problemWithService
|
|
18
24
|
}
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -41,25 +47,21 @@ export function catchAll(request, h) {
|
|
|
41
47
|
const { response } = request
|
|
42
48
|
const statusCode = getStatusCode(response)
|
|
43
49
|
|
|
44
|
-
if (
|
|
50
|
+
if (
|
|
51
|
+
statusCode >= statusCodes.internalServerError &&
|
|
52
|
+
!isAnsweredByMaintenance(request)
|
|
53
|
+
) {
|
|
45
54
|
logger.error(getLogMessage(response, statusCode))
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
if (!('isBoom' in response)) {
|
|
49
58
|
return h.continue
|
|
50
59
|
}
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
process.env.NODE_ENV === 'development' ? detailedMessage : genericMessage
|
|
60
|
+
const { view, pageTitle } = errorPageFor(statusCode)
|
|
61
|
+
const developmentMessage =
|
|
62
|
+
process.env.NODE_ENV === 'development'
|
|
63
|
+
? (response?.message ?? response?.source?.message)
|
|
64
|
+
: undefined
|
|
57
65
|
|
|
58
|
-
return h
|
|
59
|
-
.view('error/index', {
|
|
60
|
-
pageTitle: genericMessage,
|
|
61
|
-
heading: statusCode,
|
|
62
|
-
message
|
|
63
|
-
})
|
|
64
|
-
.code(statusCode)
|
|
66
|
+
return h.view(view, { pageTitle, developmentMessage }).code(statusCode)
|
|
65
67
|
}
|
package/src/errors/index.test.js
CHANGED
|
@@ -35,46 +35,76 @@ function createBoomRequest(statusCode) {
|
|
|
35
35
|
|
|
36
36
|
afterEach(() => {
|
|
37
37
|
vi.clearAllMocks()
|
|
38
|
+
vi.unstubAllEnvs()
|
|
38
39
|
})
|
|
39
40
|
|
|
40
41
|
test('catchAll continues when the response is not Boom', () => {
|
|
42
|
+
// Arrange
|
|
41
43
|
const toolkit = createToolkit()
|
|
44
|
+
|
|
45
|
+
// Act
|
|
42
46
|
const result = catchAll({ response: {} }, toolkit)
|
|
43
47
|
|
|
48
|
+
// Assert
|
|
44
49
|
assert.equal(result, toolkit.continue)
|
|
45
50
|
assert.equal(toolkit.view.mock.calls.length, 0)
|
|
46
51
|
})
|
|
47
52
|
|
|
48
|
-
test(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
53
|
+
test.each([
|
|
54
|
+
[statusCodes.notFound, 'error/page-not-found', 'Page not found'],
|
|
55
|
+
[
|
|
56
|
+
statusCodes.unauthorized,
|
|
57
|
+
'error/not-authorised',
|
|
58
|
+
'Sorry, you are not authorised to view this page'
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
statusCodes.forbidden,
|
|
62
|
+
'error/not-authorised',
|
|
63
|
+
'Sorry, you are not authorised to view this page'
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
statusCodes.badRequest,
|
|
67
|
+
'error/problem-with-service',
|
|
68
|
+
'Sorry, there is a problem with the service'
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
statusCodes.serviceUnavailable,
|
|
72
|
+
'error/service-unavailable',
|
|
73
|
+
'Sorry, the service is unavailable'
|
|
74
|
+
]
|
|
75
|
+
])(
|
|
76
|
+
'catchAll renders a %i as the %s page titled "%s"',
|
|
77
|
+
(statusCode, view, pageTitle) => {
|
|
78
|
+
// Arrange
|
|
79
|
+
const request = createBoomRequest(statusCode)
|
|
80
|
+
const toolkit = createToolkit()
|
|
81
|
+
|
|
82
|
+
// Act
|
|
83
|
+
catchAll(request, toolkit)
|
|
84
|
+
|
|
85
|
+
// Assert
|
|
86
|
+
assert.deepEqual(toolkit.view.mock.calls[0], [
|
|
87
|
+
view,
|
|
88
|
+
{ pageTitle, developmentMessage: undefined }
|
|
89
|
+
])
|
|
90
|
+
assert.deepEqual(toolkit.code.mock.calls[0], [statusCode])
|
|
91
|
+
}
|
|
92
|
+
)
|
|
65
93
|
|
|
66
|
-
test('catchAll logs and renders
|
|
94
|
+
test('catchAll logs server errors and renders the problem with the service page', () => {
|
|
95
|
+
// Arrange
|
|
67
96
|
const request = createBoomRequest(statusCodes.internalServerError)
|
|
68
97
|
const toolkit = createToolkit()
|
|
69
98
|
|
|
99
|
+
// Act
|
|
70
100
|
catchAll(request, toolkit)
|
|
71
101
|
|
|
102
|
+
// Assert
|
|
72
103
|
assert.deepEqual(toolkit.view.mock.calls[0], [
|
|
73
|
-
'error/
|
|
104
|
+
'error/problem-with-service',
|
|
74
105
|
{
|
|
75
|
-
pageTitle: '
|
|
76
|
-
|
|
77
|
-
message: 'Something went wrong'
|
|
106
|
+
pageTitle: 'Sorry, there is a problem with the service',
|
|
107
|
+
developmentMessage: undefined
|
|
78
108
|
}
|
|
79
109
|
])
|
|
80
110
|
assert.deepEqual(toolkit.code.mock.calls[0], [
|
|
@@ -83,7 +113,20 @@ test('catchAll logs and renders generic content for server errors', () => {
|
|
|
83
113
|
assert.deepEqual(logger.error.mock.calls[0], ['Mock error stack'])
|
|
84
114
|
})
|
|
85
115
|
|
|
116
|
+
test('catchAll does not log client errors', () => {
|
|
117
|
+
// Arrange
|
|
118
|
+
const request = createBoomRequest(statusCodes.notFound)
|
|
119
|
+
const toolkit = createToolkit()
|
|
120
|
+
|
|
121
|
+
// Act
|
|
122
|
+
catchAll(request, toolkit)
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
125
|
+
assert.equal(logger.error.mock.calls.length, 0)
|
|
126
|
+
})
|
|
127
|
+
|
|
86
128
|
test('catchAll prefers a client-attached statusCode over a boomified 500', () => {
|
|
129
|
+
// Arrange
|
|
87
130
|
const request = {
|
|
88
131
|
response: {
|
|
89
132
|
isBoom: true,
|
|
@@ -94,21 +137,67 @@ test('catchAll prefers a client-attached statusCode over a boomified 500', () =>
|
|
|
94
137
|
}
|
|
95
138
|
const toolkit = createToolkit()
|
|
96
139
|
|
|
140
|
+
// Act
|
|
97
141
|
catchAll(request, toolkit)
|
|
98
142
|
|
|
143
|
+
// Assert
|
|
99
144
|
assert.deepEqual(toolkit.view.mock.calls[0], [
|
|
100
|
-
'error/
|
|
101
|
-
{
|
|
102
|
-
pageTitle: 'Page not found',
|
|
103
|
-
heading: statusCodes.notFound,
|
|
104
|
-
message: 'Page not found'
|
|
105
|
-
}
|
|
145
|
+
'error/page-not-found',
|
|
146
|
+
{ pageTitle: 'Page not found', developmentMessage: undefined }
|
|
106
147
|
])
|
|
107
148
|
assert.deepEqual(toolkit.code.mock.calls[0], [statusCodes.notFound])
|
|
108
149
|
assert.equal(logger.error.mock.calls.length, 0)
|
|
109
150
|
})
|
|
110
151
|
|
|
152
|
+
test("catchAll passes the error's own message to the page in development", () => {
|
|
153
|
+
// Arrange
|
|
154
|
+
vi.stubEnv('NODE_ENV', 'development')
|
|
155
|
+
const request = createBoomRequest(statusCodes.internalServerError)
|
|
156
|
+
request.response.message = 'Upstream timed out'
|
|
157
|
+
const toolkit = createToolkit()
|
|
158
|
+
|
|
159
|
+
// Act
|
|
160
|
+
catchAll(request, toolkit)
|
|
161
|
+
|
|
162
|
+
// Assert
|
|
163
|
+
assert.equal(
|
|
164
|
+
toolkit.view.mock.calls[0][1].developmentMessage,
|
|
165
|
+
'Upstream timed out'
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test("catchAll leaves out the error's own message outside development", () => {
|
|
170
|
+
// Arrange
|
|
171
|
+
vi.stubEnv('NODE_ENV', 'production')
|
|
172
|
+
const request = createBoomRequest(statusCodes.internalServerError)
|
|
173
|
+
request.response.message = 'Upstream timed out'
|
|
174
|
+
const toolkit = createToolkit()
|
|
175
|
+
|
|
176
|
+
// Act
|
|
177
|
+
catchAll(request, toolkit)
|
|
178
|
+
|
|
179
|
+
// Assert
|
|
180
|
+
assert.equal(toolkit.view.mock.calls[0][1].developmentMessage, undefined)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('catchAll does not log the 503 the maintenance plugin answered with', () => {
|
|
184
|
+
// Arrange
|
|
185
|
+
const toolkit = createToolkit()
|
|
186
|
+
const request = {
|
|
187
|
+
plugins: { 'lis-maintenance': { answered: true } },
|
|
188
|
+
response: { statusCode: statusCodes.serviceUnavailable, source: {} }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Act
|
|
192
|
+
const result = catchAll(request, toolkit)
|
|
193
|
+
|
|
194
|
+
// Assert
|
|
195
|
+
assert.equal(result, toolkit.continue)
|
|
196
|
+
assert.equal(logger.error.mock.calls.length, 0)
|
|
197
|
+
})
|
|
198
|
+
|
|
111
199
|
test('catchAll logs plain 500 responses before continuing', () => {
|
|
200
|
+
// Arrange
|
|
112
201
|
const toolkit = createToolkit()
|
|
113
202
|
const request = {
|
|
114
203
|
response: {
|
|
@@ -119,8 +208,10 @@ test('catchAll logs plain 500 responses before continuing', () => {
|
|
|
119
208
|
}
|
|
120
209
|
}
|
|
121
210
|
|
|
211
|
+
// Act
|
|
122
212
|
const result = catchAll(request, toolkit)
|
|
123
213
|
|
|
214
|
+
// Assert
|
|
124
215
|
assert.equal(result, toolkit.continue)
|
|
125
216
|
assert.deepEqual(logger.error.mock.calls[0], ['Plain 500 response'])
|
|
126
217
|
assert.equal(toolkit.view.mock.calls.length, 0)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** @import { Plugin, Request } from '@hapi/hapi' */
|
|
2
|
+
import { format, isValid, parseISO } from 'date-fns'
|
|
3
|
+
|
|
4
|
+
import { serviceUnavailable } from './errors/error-pages.js'
|
|
5
|
+
import { statusCodes } from './status-codes.js'
|
|
6
|
+
|
|
7
|
+
const PLUGIN_NAME = 'lis-maintenance'
|
|
8
|
+
const MIDDAY_HOUR = 12
|
|
9
|
+
const LOCAL_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/
|
|
10
|
+
|
|
11
|
+
function formatTime(date) {
|
|
12
|
+
if (date.getMinutes() === 0) {
|
|
13
|
+
if (date.getHours() === 0) {
|
|
14
|
+
return 'midnight'
|
|
15
|
+
}
|
|
16
|
+
if (date.getHours() === MIDDAY_HOUR) {
|
|
17
|
+
return 'midday'
|
|
18
|
+
}
|
|
19
|
+
return format(date, 'haaa')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return format(date, 'h:mmaaa')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Only a local date-time is accepted: it is read as wall-clock time, so the
|
|
27
|
+
* page shows what was configured whatever the server's time zone, whereas an
|
|
28
|
+
* offset would be converted to the server's zone.
|
|
29
|
+
* @param {string} isoString
|
|
30
|
+
* @returns {Date}
|
|
31
|
+
*/
|
|
32
|
+
function parseAvailableFrom(isoString) {
|
|
33
|
+
const date = LOCAL_DATE_TIME.test(isoString) ? parseISO(isoString) : null
|
|
34
|
+
|
|
35
|
+
if (!date || !isValid(date)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`maintenance.availableFrom must be the UK local date-time without an offset, e.g. 2026-11-19T09:00, got "${isoString}"`
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return date
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Formats a return time in the GOV.UK style, e.g. "9am on Monday 19 November
|
|
46
|
+
* 2026".
|
|
47
|
+
* @param {string} isoString - a local date-time without an offset
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function formatAvailableFrom(isoString) {
|
|
51
|
+
const date = parseAvailableFrom(isoString)
|
|
52
|
+
|
|
53
|
+
return `${formatTime(date)} on ${format(date, 'EEEE d MMMM yyyy')}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether the maintenance plugin answered this request, so the error handler
|
|
58
|
+
* does not log every refused request as a server error.
|
|
59
|
+
* @param {Request} request
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function isAnsweredByMaintenance(request) {
|
|
63
|
+
return request?.plugins?.[PLUGIN_NAME]?.answered === true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function withoutTrailingSlashes(path) {
|
|
67
|
+
let trimmed = path
|
|
68
|
+
|
|
69
|
+
while (trimmed.endsWith('/')) {
|
|
70
|
+
trimmed = trimmed.slice(0, -1)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return trimmed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isExempt(path, { healthPath, assetPrefix }) {
|
|
77
|
+
return path === healthPath || path.startsWith(assetPrefix)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function answerUnavailable(request, h, { availableFrom, retryAfter }) {
|
|
81
|
+
request.plugins[PLUGIN_NAME] = { answered: true }
|
|
82
|
+
|
|
83
|
+
return h
|
|
84
|
+
.view(serviceUnavailable.view, {
|
|
85
|
+
pageTitle: serviceUnavailable.pageTitle,
|
|
86
|
+
availableFrom
|
|
87
|
+
})
|
|
88
|
+
.code(statusCodes.serviceUnavailable)
|
|
89
|
+
.header('Retry-After', retryAfter)
|
|
90
|
+
.takeover()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Answers every request with the service unavailable page while
|
|
95
|
+
* `availableFrom` is set. The health check stays up so the platform does not
|
|
96
|
+
* restart the service, and assets stay up so the page is styled.
|
|
97
|
+
* @param {{ availableFrom?: string, healthPath?: string, assetPath?: string }} options
|
|
98
|
+
* @returns {Plugin<void>}
|
|
99
|
+
*/
|
|
100
|
+
export function createMaintenancePlugin({
|
|
101
|
+
availableFrom,
|
|
102
|
+
healthPath = '/health',
|
|
103
|
+
assetPath = '/public'
|
|
104
|
+
} = {}) {
|
|
105
|
+
const isActive = typeof availableFrom === 'string' && availableFrom !== ''
|
|
106
|
+
const formattedAvailableFrom = isActive
|
|
107
|
+
? formatAvailableFrom(availableFrom)
|
|
108
|
+
: undefined
|
|
109
|
+
const retryAfter = isActive
|
|
110
|
+
? parseAvailableFrom(availableFrom).toUTCString()
|
|
111
|
+
: undefined
|
|
112
|
+
const assetPrefix = `${withoutTrailingSlashes(assetPath)}/`
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
name: PLUGIN_NAME,
|
|
116
|
+
register(server) {
|
|
117
|
+
if (!isActive) {
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
server.ext('onRequest', (request, h) =>
|
|
122
|
+
isExempt(request.path, { healthPath, assetPrefix })
|
|
123
|
+
? h.continue
|
|
124
|
+
: answerUnavailable(request, h, {
|
|
125
|
+
availableFrom: formattedAvailableFrom,
|
|
126
|
+
retryAfter
|
|
127
|
+
})
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
import hapi from '@hapi/hapi'
|
|
5
|
+
import hapiVision from '@hapi/vision'
|
|
6
|
+
import nunjucks from 'nunjucks'
|
|
7
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createMaintenancePlugin,
|
|
11
|
+
formatAvailableFrom,
|
|
12
|
+
isAnsweredByMaintenance
|
|
13
|
+
} from './maintenance.js'
|
|
14
|
+
import { statusCodes } from './status-codes.js'
|
|
15
|
+
|
|
16
|
+
const packageRoot = path.resolve(
|
|
17
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
18
|
+
'..'
|
|
19
|
+
)
|
|
20
|
+
const environment = new nunjucks.Environment(
|
|
21
|
+
new nunjucks.FileSystemLoader([
|
|
22
|
+
path.join(packageRoot, 'src/nunjucks/routes'),
|
|
23
|
+
path.join(packageRoot, 'test/fixtures/nunjucks')
|
|
24
|
+
]),
|
|
25
|
+
{ autoescape: true }
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
let server
|
|
29
|
+
|
|
30
|
+
async function createServer(maintenanceOptions) {
|
|
31
|
+
server = hapi.server()
|
|
32
|
+
await server.register({
|
|
33
|
+
plugin: hapiVision,
|
|
34
|
+
options: {
|
|
35
|
+
engines: {
|
|
36
|
+
njk: {
|
|
37
|
+
compile(src) {
|
|
38
|
+
const template = nunjucks.compile(src, environment)
|
|
39
|
+
return (context) => template.render(context)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
path: path.join(packageRoot, 'src/nunjucks/routes'),
|
|
44
|
+
context: {
|
|
45
|
+
errorPages: { contactHref: 'https://example.com/contact' }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
await server.register(createMaintenancePlugin(maintenanceOptions))
|
|
50
|
+
server.route([
|
|
51
|
+
{ method: 'GET', path: '/health', handler: () => ({ message: 'success' }) },
|
|
52
|
+
{ method: 'GET', path: '/holdings', handler: () => 'Holdings' },
|
|
53
|
+
{ method: 'GET', path: '/public/app.css', handler: () => 'body {}' }
|
|
54
|
+
])
|
|
55
|
+
await server.initialize()
|
|
56
|
+
|
|
57
|
+
return server
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await server?.stop()
|
|
62
|
+
server = undefined
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('formatAvailableFrom()', () => {
|
|
66
|
+
test.each([
|
|
67
|
+
['2026-11-19T09:00', '9am on Thursday 19 November 2026'],
|
|
68
|
+
['2026-11-16T09:30', '9:30am on Monday 16 November 2026'],
|
|
69
|
+
['2026-11-16T17:05:00', '5:05pm on Monday 16 November 2026'],
|
|
70
|
+
['2026-11-16T12:00', 'midday on Monday 16 November 2026'],
|
|
71
|
+
['2026-11-16T00:00', 'midnight on Monday 16 November 2026']
|
|
72
|
+
])('formats %s as "%s"', (isoString, expected) => {
|
|
73
|
+
// Arrange
|
|
74
|
+
// Act
|
|
75
|
+
const formatted = formatAvailableFrom(isoString)
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
expect(formatted).toBe(expected)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('throws on a value that is not an ISO 8601 date-time', () => {
|
|
82
|
+
// Arrange
|
|
83
|
+
const isoString = 'next Tuesday'
|
|
84
|
+
|
|
85
|
+
// Act
|
|
86
|
+
const act = () => formatAvailableFrom(isoString)
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
89
|
+
expect(act).toThrow(
|
|
90
|
+
'maintenance.availableFrom must be the UK local date-time without an offset, e.g. 2026-11-19T09:00, got "next Tuesday"'
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test.each([['2026-11-19T09:00Z'], ['2026-11-19T09:00:00+01:00']])(
|
|
95
|
+
'throws on %s because it carries an offset',
|
|
96
|
+
(isoString) => {
|
|
97
|
+
// Arrange
|
|
98
|
+
// Act
|
|
99
|
+
const act = () => formatAvailableFrom(isoString)
|
|
100
|
+
|
|
101
|
+
// Assert
|
|
102
|
+
expect(act).toThrow('without an offset')
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
test('throws on a date without a time', () => {
|
|
107
|
+
// Arrange
|
|
108
|
+
const isoString = '2026-11-19'
|
|
109
|
+
|
|
110
|
+
// Act
|
|
111
|
+
const act = () => formatAvailableFrom(isoString)
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
114
|
+
expect(act).toThrow('without an offset')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('throws on a date-time that does not exist', () => {
|
|
118
|
+
// Arrange
|
|
119
|
+
const isoString = '2026-13-40T09:00'
|
|
120
|
+
|
|
121
|
+
// Act
|
|
122
|
+
const act = () => formatAvailableFrom(isoString)
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
125
|
+
expect(act).toThrow('without an offset')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('createMaintenancePlugin()', () => {
|
|
130
|
+
test('answers a page request with the service unavailable page and a 503', async () => {
|
|
131
|
+
// Arrange
|
|
132
|
+
await createServer({ availableFrom: '2026-11-16T09:00' })
|
|
133
|
+
|
|
134
|
+
// Act
|
|
135
|
+
const response = await server.inject({ method: 'GET', url: '/holdings' })
|
|
136
|
+
|
|
137
|
+
// Assert
|
|
138
|
+
expect(response.statusCode).toBe(statusCodes.serviceUnavailable)
|
|
139
|
+
expect(response.payload).toContain(
|
|
140
|
+
'<title>Sorry, the service is unavailable - Livestock Information</title>'
|
|
141
|
+
)
|
|
142
|
+
expect(response.payload).toContain(
|
|
143
|
+
'You will be able to use the service from 9am on Monday 16 November 2026.'
|
|
144
|
+
)
|
|
145
|
+
expect(isAnsweredByMaintenance(response.request)).toBe(true)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('tells clients when to retry with a Retry-After HTTP-date', async () => {
|
|
149
|
+
// Arrange
|
|
150
|
+
await createServer({ availableFrom: '2026-11-16T09:00' })
|
|
151
|
+
|
|
152
|
+
// Act
|
|
153
|
+
const response = await server.inject({ method: 'GET', url: '/holdings' })
|
|
154
|
+
|
|
155
|
+
// Assert
|
|
156
|
+
expect(response.headers['retry-after']).toBe(
|
|
157
|
+
new Date(2026, 10, 16, 9, 0).toUTCString()
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('leaves the health check untouched', async () => {
|
|
162
|
+
// Arrange
|
|
163
|
+
await createServer({ availableFrom: '2026-11-16T09:00' })
|
|
164
|
+
|
|
165
|
+
// Act
|
|
166
|
+
const response = await server.inject({ method: 'GET', url: '/health' })
|
|
167
|
+
|
|
168
|
+
// Assert
|
|
169
|
+
expect(response.statusCode).toBe(statusCodes.ok)
|
|
170
|
+
expect(response.result).toEqual({ message: 'success' })
|
|
171
|
+
expect(isAnsweredByMaintenance(response.request)).toBe(false)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('honours a custom health path', async () => {
|
|
175
|
+
// Arrange
|
|
176
|
+
await createServer({
|
|
177
|
+
availableFrom: '2026-11-16T09:00',
|
|
178
|
+
healthPath: '/holdings'
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Act
|
|
182
|
+
const response = await server.inject({ method: 'GET', url: '/holdings' })
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
185
|
+
expect(response.statusCode).toBe(statusCodes.ok)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('leaves static assets untouched so the page is styled', async () => {
|
|
189
|
+
// Arrange
|
|
190
|
+
await createServer({ availableFrom: '2026-11-16T09:00' })
|
|
191
|
+
|
|
192
|
+
// Act
|
|
193
|
+
const response = await server.inject({
|
|
194
|
+
method: 'GET',
|
|
195
|
+
url: '/public/app.css'
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// Assert
|
|
199
|
+
expect(response.statusCode).toBe(statusCodes.ok)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('treats an asset path with a trailing slash the same as one without', async () => {
|
|
203
|
+
// Arrange
|
|
204
|
+
await createServer({
|
|
205
|
+
availableFrom: '2026-11-16T09:00',
|
|
206
|
+
assetPath: '/public/'
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Act
|
|
210
|
+
const assetResponse = await server.inject({
|
|
211
|
+
method: 'GET',
|
|
212
|
+
url: '/public/app.css'
|
|
213
|
+
})
|
|
214
|
+
const pageResponse = await server.inject({
|
|
215
|
+
method: 'GET',
|
|
216
|
+
url: '/holdings'
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// Assert
|
|
220
|
+
expect(assetResponse.statusCode).toBe(statusCodes.ok)
|
|
221
|
+
expect(pageResponse.statusCode).toBe(statusCodes.serviceUnavailable)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test.each([[''], [undefined]])(
|
|
225
|
+
'does nothing when availableFrom is %j',
|
|
226
|
+
async (availableFrom) => {
|
|
227
|
+
// Arrange
|
|
228
|
+
await createServer({ availableFrom })
|
|
229
|
+
|
|
230
|
+
// Act
|
|
231
|
+
const response = await server.inject({ method: 'GET', url: '/holdings' })
|
|
232
|
+
|
|
233
|
+
// Assert
|
|
234
|
+
expect(response.statusCode).toBe(statusCodes.ok)
|
|
235
|
+
expect(response.payload).toBe('Holdings')
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
test('fails at start-up when availableFrom has an offset', () => {
|
|
240
|
+
// Arrange
|
|
241
|
+
const options = { availableFrom: '2026-11-16T09:00Z' }
|
|
242
|
+
|
|
243
|
+
// Act
|
|
244
|
+
const act = () => createMaintenancePlugin(options)
|
|
245
|
+
|
|
246
|
+
// Assert
|
|
247
|
+
expect(act).toThrow('without an offset')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('fails at start-up when availableFrom is not a date-time', () => {
|
|
251
|
+
// Arrange
|
|
252
|
+
const options = { availableFrom: 'soon' }
|
|
253
|
+
|
|
254
|
+
// Act
|
|
255
|
+
const act = () => createMaintenancePlugin(options)
|
|
256
|
+
|
|
257
|
+
// Assert
|
|
258
|
+
expect(act).toThrow('maintenance.availableFrom must be the UK local')
|
|
259
|
+
})
|
|
260
|
+
})
|
package/src/nunjucks/context.js
CHANGED
|
@@ -5,6 +5,9 @@ import { HUBS, getModuleById } from '@defra/lis-hubs-infra-registry'
|
|
|
5
5
|
|
|
6
6
|
const HUB_SERVICE_NAME_PREFIX = 'lis-hubs-'
|
|
7
7
|
|
|
8
|
+
export const BCMS_CONTACT_HREF =
|
|
9
|
+
'https://www.gov.uk/government/organisations/british-cattle-movement-service'
|
|
10
|
+
|
|
8
11
|
/**
|
|
9
12
|
* Both auth schemes put the hub's service name on credentials.caller - a
|
|
10
13
|
* spoke gets the hub that called it, a hub gets itself - so it is only
|
|
@@ -47,7 +50,14 @@ function createPermissionCheck(request) {
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/**
|
|
50
|
-
* @
|
|
53
|
+
* @typedef {object} ErrorPageLinks
|
|
54
|
+
* @property {string} [contactHref] - every error page's contact link; defaults to the BCMS GOV.UK page
|
|
55
|
+
* @property {string} [homeHref] - the page not found "Home page" link; defaults to '/'
|
|
56
|
+
* @property {string} [getStartedHref] - the page not found "Get started" link; defaults to '/'
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{ config: object, buildNavigation: Function, getRequestBasePath: Function, logger: object, readFileSync: Function, moduleId?: string, errorPages?: ErrorPageLinks }} options
|
|
51
61
|
* @returns {Function}
|
|
52
62
|
*/
|
|
53
63
|
export function createNunjucksContextBuilder({
|
|
@@ -56,8 +66,14 @@ export function createNunjucksContextBuilder({
|
|
|
56
66
|
getRequestBasePath,
|
|
57
67
|
logger,
|
|
58
68
|
readFileSync,
|
|
59
|
-
moduleId
|
|
69
|
+
moduleId,
|
|
70
|
+
errorPages = {}
|
|
60
71
|
}) {
|
|
72
|
+
const errorPageLinks = {
|
|
73
|
+
contactHref: errorPages.contactHref ?? BCMS_CONTACT_HREF,
|
|
74
|
+
homeHref: errorPages.homeHref ?? '/',
|
|
75
|
+
getStartedHref: errorPages.getStartedHref ?? '/'
|
|
76
|
+
}
|
|
61
77
|
const sectionName = moduleId ? (getModuleById(moduleId)?.label ?? null) : null
|
|
62
78
|
const manifestPath = path.join(
|
|
63
79
|
config.get('root'),
|
|
@@ -96,6 +112,7 @@ export function createNunjucksContextBuilder({
|
|
|
96
112
|
isBackOffice: requestHubId === 'back-office',
|
|
97
113
|
hasPermission: createPermissionCheck(request),
|
|
98
114
|
logoutUrl: '/signout',
|
|
115
|
+
errorPages: errorPageLinks,
|
|
99
116
|
getAssetPath(asset) {
|
|
100
117
|
const viteAssetPath = viteManifest?.[asset]?.file
|
|
101
118
|
return `${assetRoot}/${viteAssetPath ?? asset}`
|
|
@@ -302,6 +302,82 @@ describe('createNunjucksContextBuilder() hub flags', () => {
|
|
|
302
302
|
})
|
|
303
303
|
})
|
|
304
304
|
|
|
305
|
+
describe('createNunjucksContextBuilder() errorPages', () => {
|
|
306
|
+
test('defaults the contact link to the BCMS GOV.UK page and the other links to the root', () => {
|
|
307
|
+
// Arrange
|
|
308
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
309
|
+
config: createConfig(),
|
|
310
|
+
buildNavigation: () => [],
|
|
311
|
+
getRequestBasePath: () => '',
|
|
312
|
+
logger: {},
|
|
313
|
+
readFileSync: () => '{}'
|
|
314
|
+
})
|
|
315
|
+
const request = { headers: {}, path: '/', auth: { credentials: null } }
|
|
316
|
+
|
|
317
|
+
// Act
|
|
318
|
+
const context = contextBuilder(request)
|
|
319
|
+
|
|
320
|
+
// Assert
|
|
321
|
+
expect(context.errorPages).toEqual({
|
|
322
|
+
contactHref:
|
|
323
|
+
'https://www.gov.uk/government/organisations/british-cattle-movement-service',
|
|
324
|
+
homeHref: '/',
|
|
325
|
+
getStartedHref: '/'
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
test('uses the links given in the errorPages option', () => {
|
|
330
|
+
// Arrange
|
|
331
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
332
|
+
config: createConfig(),
|
|
333
|
+
buildNavigation: () => [],
|
|
334
|
+
getRequestBasePath: () => '',
|
|
335
|
+
logger: {},
|
|
336
|
+
readFileSync: () => '{}',
|
|
337
|
+
errorPages: {
|
|
338
|
+
contactHref: 'https://example.com/contact',
|
|
339
|
+
homeHref: '/home',
|
|
340
|
+
getStartedHref: '/start'
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
const request = { headers: {}, path: '/', auth: { credentials: null } }
|
|
344
|
+
|
|
345
|
+
// Act
|
|
346
|
+
const context = contextBuilder(request)
|
|
347
|
+
|
|
348
|
+
// Assert
|
|
349
|
+
expect(context.errorPages).toEqual({
|
|
350
|
+
contactHref: 'https://example.com/contact',
|
|
351
|
+
homeHref: '/home',
|
|
352
|
+
getStartedHref: '/start'
|
|
353
|
+
})
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('keeps the default for any link the errorPages option leaves out', () => {
|
|
357
|
+
// Arrange
|
|
358
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
359
|
+
config: createConfig(),
|
|
360
|
+
buildNavigation: () => [],
|
|
361
|
+
getRequestBasePath: () => '',
|
|
362
|
+
logger: {},
|
|
363
|
+
readFileSync: () => '{}',
|
|
364
|
+
errorPages: { getStartedHref: '/start', homeHref: undefined }
|
|
365
|
+
})
|
|
366
|
+
const request = { headers: {}, path: '/', auth: { credentials: null } }
|
|
367
|
+
|
|
368
|
+
// Act
|
|
369
|
+
const context = contextBuilder(request)
|
|
370
|
+
|
|
371
|
+
// Assert
|
|
372
|
+
expect(context.errorPages).toEqual({
|
|
373
|
+
contactHref:
|
|
374
|
+
'https://www.gov.uk/government/organisations/british-cattle-movement-service',
|
|
375
|
+
homeHref: '/',
|
|
376
|
+
getStartedHref: '/start'
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
305
381
|
describe('createNunjucksContextBuilder() hasPermission()', () => {
|
|
306
382
|
test('grants a permission the user holds on the given cph', () => {
|
|
307
383
|
// Arrange
|
package/src/nunjucks/plugin.js
CHANGED
|
@@ -157,14 +157,16 @@ function buildNunjucksEnvironment({
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* @
|
|
160
|
+
* @import { ErrorPageLinks } from './context.js'
|
|
161
|
+
* @param {{ config: object, logger: object, getRequestBasePath: Function, moduleId?: string, errorPages?: ErrorPageLinks }} options
|
|
161
162
|
* @returns {object}
|
|
162
163
|
*/
|
|
163
164
|
export function createNunjucksConfig({
|
|
164
165
|
config,
|
|
165
166
|
logger,
|
|
166
167
|
getRequestBasePath,
|
|
167
|
-
moduleId
|
|
168
|
+
moduleId,
|
|
169
|
+
errorPages
|
|
168
170
|
}) {
|
|
169
171
|
const basePath = moduleId ? getBasePathForModule(moduleId) : ''
|
|
170
172
|
const projectRoot = config.get('root')
|
|
@@ -195,7 +197,8 @@ export function createNunjucksConfig({
|
|
|
195
197
|
getRequestBasePath,
|
|
196
198
|
logger,
|
|
197
199
|
readFileSync,
|
|
198
|
-
moduleId
|
|
200
|
+
moduleId,
|
|
201
|
+
errorPages
|
|
199
202
|
})
|
|
200
203
|
|
|
201
204
|
const nunjucksConfig = {
|
|
@@ -110,4 +110,28 @@ describe('createNunjucksConfig()', () => {
|
|
|
110
110
|
expect.objectContaining({ backOffice: 'lis-perm-back-office' })
|
|
111
111
|
)
|
|
112
112
|
})
|
|
113
|
+
|
|
114
|
+
test('passes the errorPages links through to the view context', () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const config = {
|
|
117
|
+
get: vi.fn((key) => (key === 'root' ? projectRoot : false))
|
|
118
|
+
}
|
|
119
|
+
const errorPages = {
|
|
120
|
+
contactHref: 'https://example.com/contact',
|
|
121
|
+
homeHref: '/home',
|
|
122
|
+
getStartedHref: '/start'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Act
|
|
126
|
+
const nunjucksConfig = createNunjucksConfig({
|
|
127
|
+
config,
|
|
128
|
+
logger: {},
|
|
129
|
+
getRequestBasePath: vi.fn(() => ''),
|
|
130
|
+
errorPages
|
|
131
|
+
})
|
|
132
|
+
const context = nunjucksConfig.options.context({ headers: {}, path: '/' })
|
|
133
|
+
|
|
134
|
+
// Assert
|
|
135
|
+
expect(context.errorPages).toEqual(errorPages)
|
|
136
|
+
})
|
|
113
137
|
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
|
|
4
|
+
import nunjucks from 'nunjucks'
|
|
5
|
+
import { describe, expect, test } from 'vitest'
|
|
6
|
+
|
|
7
|
+
const routesRoot = path.resolve(
|
|
8
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
9
|
+
'..'
|
|
10
|
+
)
|
|
11
|
+
const layoutFixtureRoot = path.resolve(
|
|
12
|
+
routesRoot,
|
|
13
|
+
'../../../test/fixtures/nunjucks'
|
|
14
|
+
)
|
|
15
|
+
const environment = new nunjucks.Environment(
|
|
16
|
+
new nunjucks.FileSystemLoader([routesRoot, layoutFixtureRoot]),
|
|
17
|
+
{ autoescape: true, trimBlocks: true, lstripBlocks: true }
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const errorPages = {
|
|
21
|
+
contactHref: 'https://example.com/contact',
|
|
22
|
+
homeHref: '/home',
|
|
23
|
+
getStartedHref: '/start'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function render(view, context = {}) {
|
|
27
|
+
return environment.render(view, {
|
|
28
|
+
errorPages,
|
|
29
|
+
...context
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function textOf(html) {
|
|
34
|
+
return html
|
|
35
|
+
.replaceAll(/<[^<>]*>/g, '')
|
|
36
|
+
.replaceAll(/\s+/g, ' ')
|
|
37
|
+
.trim()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('error pages', () => {
|
|
41
|
+
test('page not found shows the heading, the three links and the support link', () => {
|
|
42
|
+
// Arrange
|
|
43
|
+
const context = { pageTitle: 'Page not found' }
|
|
44
|
+
|
|
45
|
+
// Act
|
|
46
|
+
const html = render('error/page-not-found.njk', context)
|
|
47
|
+
|
|
48
|
+
// Assert
|
|
49
|
+
expect(html).toContain(
|
|
50
|
+
'<title>Page not found - Livestock Information</title>'
|
|
51
|
+
)
|
|
52
|
+
expect(html).toContain('<h1 class="govuk-heading-l">Page not found</h1>')
|
|
53
|
+
expect(textOf(html)).toContain(
|
|
54
|
+
"Sorry, we could not find the page you were looking for. You might find what you're looking for by visiting: Home page Get started GOV.UK If you cannot find the information you need, contact our support team."
|
|
55
|
+
)
|
|
56
|
+
expect(html).toContain('<a class="govuk-link" href="/home">Home page</a>')
|
|
57
|
+
expect(html).toContain(
|
|
58
|
+
'<a class="govuk-link" href="/start">Get started</a>'
|
|
59
|
+
)
|
|
60
|
+
expect(html).toContain(
|
|
61
|
+
'<a class="govuk-link" href="https://www.gov.uk">GOV.UK</a>'
|
|
62
|
+
)
|
|
63
|
+
expect(html).toContain(
|
|
64
|
+
'<a class="govuk-link" href="https://example.com/contact">contact our support team</a>'
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('not authorised offers a sign in button when signInHref is given', () => {
|
|
69
|
+
// Arrange
|
|
70
|
+
const context = { signInHref: '/auth/login' }
|
|
71
|
+
|
|
72
|
+
// Act
|
|
73
|
+
const html = render('error/not-authorised.njk', context)
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
76
|
+
expect(html).toContain(
|
|
77
|
+
'<a href="/auth/login" role="button" draggable="false" class="govuk-button" data-module="govuk-button">Sign in</a>'
|
|
78
|
+
)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('not authorised has no sign in button without signInHref', () => {
|
|
82
|
+
// Arrange
|
|
83
|
+
const context = {}
|
|
84
|
+
|
|
85
|
+
// Act
|
|
86
|
+
const html = render('error/not-authorised.njk', context)
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
89
|
+
expect(html).not.toContain('govuk-button')
|
|
90
|
+
expect(html).not.toContain('Sign in')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('not authorised shows the heading and the support link', () => {
|
|
94
|
+
// Arrange
|
|
95
|
+
const context = {
|
|
96
|
+
pageTitle: 'Sorry, you are not authorised to view this page'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Act
|
|
100
|
+
const html = render('error/not-authorised.njk', context)
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
expect(html).toContain(
|
|
104
|
+
'<title>Sorry, you are not authorised to view this page - Livestock Information</title>'
|
|
105
|
+
)
|
|
106
|
+
expect(html).toContain(
|
|
107
|
+
'<h1 class="govuk-heading-l">Sorry, you are not authorised to view this page.</h1>'
|
|
108
|
+
)
|
|
109
|
+
expect(textOf(html)).toContain(
|
|
110
|
+
'Check your details are correct and try again. If you think this is incorrect contact our support team.'
|
|
111
|
+
)
|
|
112
|
+
expect(html).toContain(
|
|
113
|
+
'<a class="govuk-link" href="https://example.com/contact">contact our support team</a>'
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('problem with the service shows the heading and the BCMS link', () => {
|
|
118
|
+
// Arrange
|
|
119
|
+
const context = { pageTitle: 'Sorry, there is a problem with the service' }
|
|
120
|
+
|
|
121
|
+
// Act
|
|
122
|
+
const html = render('error/problem-with-service.njk', context)
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
125
|
+
expect(html).toContain(
|
|
126
|
+
'<title>Sorry, there is a problem with the service - Livestock Information</title>'
|
|
127
|
+
)
|
|
128
|
+
expect(html).toContain(
|
|
129
|
+
'<h1 class="govuk-heading-l">Sorry, there is a problem with the service</h1>'
|
|
130
|
+
)
|
|
131
|
+
expect(textOf(html)).toContain(
|
|
132
|
+
'Try again later. Contact the British Cattle Movement Service (BCMS) if you need to speak to someone about your account.'
|
|
133
|
+
)
|
|
134
|
+
expect(html).toContain(
|
|
135
|
+
'<a class="govuk-link" href="https://example.com/contact">British Cattle Movement Service (BCMS)</a>'
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('service unavailable shows when the service will be available again', () => {
|
|
140
|
+
// Arrange
|
|
141
|
+
const context = {
|
|
142
|
+
pageTitle: 'Sorry, the service is unavailable',
|
|
143
|
+
availableFrom: '9am on Monday 19 November 2026'
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Act
|
|
147
|
+
const html = render('error/service-unavailable.njk', context)
|
|
148
|
+
|
|
149
|
+
// Assert
|
|
150
|
+
expect(html).toContain(
|
|
151
|
+
'<title>Sorry, the service is unavailable - Livestock Information</title>'
|
|
152
|
+
)
|
|
153
|
+
expect(html).toContain(
|
|
154
|
+
'<h1 class="govuk-heading-l">Sorry, the service is unavailable</h1>'
|
|
155
|
+
)
|
|
156
|
+
expect(textOf(html)).toContain(
|
|
157
|
+
'You will be able to use the service from 9am on Monday 19 November 2026. Contact the British Cattle Movement Service (BCMS) if you need to speak to someone about your account.'
|
|
158
|
+
)
|
|
159
|
+
expect(html).toContain(
|
|
160
|
+
'<a class="govuk-link" href="https://example.com/contact">British Cattle Movement Service (BCMS)</a>'
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('service unavailable says later when no return time is given', () => {
|
|
165
|
+
// Arrange
|
|
166
|
+
const context = { pageTitle: 'Sorry, the service is unavailable' }
|
|
167
|
+
|
|
168
|
+
// Act
|
|
169
|
+
const html = render('error/service-unavailable.njk', context)
|
|
170
|
+
|
|
171
|
+
// Assert
|
|
172
|
+
expect(textOf(html)).toContain('You will be able to use the service later.')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test.each([
|
|
176
|
+
'error/page-not-found.njk',
|
|
177
|
+
'error/not-authorised.njk',
|
|
178
|
+
'error/problem-with-service.njk',
|
|
179
|
+
'error/service-unavailable.njk'
|
|
180
|
+
])('%s shows the development message only when one is given', (view) => {
|
|
181
|
+
// Arrange
|
|
182
|
+
const withMessage = { developmentMessage: 'Upstream timed out' }
|
|
183
|
+
|
|
184
|
+
// Act
|
|
185
|
+
const htmlWithMessage = render(view, withMessage)
|
|
186
|
+
const htmlWithoutMessage = render(view)
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
expect(htmlWithMessage).toContain(
|
|
190
|
+
'<p class="govuk-body">Upstream timed out</p>'
|
|
191
|
+
)
|
|
192
|
+
expect(htmlWithoutMessage).not.toContain('Upstream timed out')
|
|
193
|
+
})
|
|
194
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{% extends 'layouts/page.njk' %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="govuk-grid-row">
|
|
5
|
+
<div class="govuk-grid-column-two-thirds">
|
|
6
|
+
<h1 class="govuk-heading-l">Sorry, you are not authorised to view this page.</h1>
|
|
7
|
+
<p class="govuk-body">Check your details are correct and try again.</p>
|
|
8
|
+
<p class="govuk-body">If you think this is incorrect <a class="govuk-link" href="{{ errorPages.contactHref }}">contact our support team</a>.</p>
|
|
9
|
+
{% if signInHref %}
|
|
10
|
+
<a href="{{ signInHref }}" role="button" draggable="false" class="govuk-button" data-module="govuk-button">Sign in</a>
|
|
11
|
+
{% endif %}
|
|
12
|
+
{% if developmentMessage %}
|
|
13
|
+
<p class="govuk-body">{{ developmentMessage }}</p>
|
|
14
|
+
{% endif %}
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
{% endblock %}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{% extends 'layouts/page.njk' %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="govuk-grid-row">
|
|
5
|
+
<div class="govuk-grid-column-two-thirds">
|
|
6
|
+
<h1 class="govuk-heading-l">Page not found</h1>
|
|
7
|
+
<p class="govuk-body">Sorry, we could not find the page you were looking for.</p>
|
|
8
|
+
<p class="govuk-body">You might find what you're looking for by visiting:</p>
|
|
9
|
+
<ul class="govuk-list">
|
|
10
|
+
<li><a class="govuk-link" href="{{ errorPages.homeHref }}">Home page</a></li>
|
|
11
|
+
<li><a class="govuk-link" href="{{ errorPages.getStartedHref }}">Get started</a></li>
|
|
12
|
+
<li><a class="govuk-link" href="https://www.gov.uk">GOV.UK</a></li>
|
|
13
|
+
</ul>
|
|
14
|
+
<p class="govuk-body">If you cannot find the information you need, <a class="govuk-link" href="{{ errorPages.contactHref }}">contact our support team</a>.</p>
|
|
15
|
+
{% if developmentMessage %}
|
|
16
|
+
<p class="govuk-body">{{ developmentMessage }}</p>
|
|
17
|
+
{% endif %}
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
{% endblock %}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{% extends 'layouts/page.njk' %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="govuk-grid-row">
|
|
5
|
+
<div class="govuk-grid-column-two-thirds">
|
|
6
|
+
<h1 class="govuk-heading-l">Sorry, there is a problem with the service</h1>
|
|
7
|
+
<p class="govuk-body">Try again later.</p>
|
|
8
|
+
<p class="govuk-body">Contact the <a class="govuk-link" href="{{ errorPages.contactHref }}">British Cattle Movement Service (BCMS)</a> if you need to speak to someone about your account.</p>
|
|
9
|
+
{% if developmentMessage %}
|
|
10
|
+
<p class="govuk-body">{{ developmentMessage }}</p>
|
|
11
|
+
{% endif %}
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{% extends 'layouts/page.njk' %}
|
|
2
|
+
|
|
3
|
+
{% block content %}
|
|
4
|
+
<div class="govuk-grid-row">
|
|
5
|
+
<div class="govuk-grid-column-two-thirds">
|
|
6
|
+
<h1 class="govuk-heading-l">Sorry, the service is unavailable</h1>
|
|
7
|
+
{% if availableFrom %}
|
|
8
|
+
<p class="govuk-body">You will be able to use the service from {{ availableFrom }}.</p>
|
|
9
|
+
{% else %}
|
|
10
|
+
<p class="govuk-body">You will be able to use the service later.</p>
|
|
11
|
+
{% endif %}
|
|
12
|
+
<p class="govuk-body">Contact the <a class="govuk-link" href="{{ errorPages.contactHref }}">British Cattle Movement Service (BCMS)</a> if you need to speak to someone about your account.</p>
|
|
13
|
+
{% if developmentMessage %}
|
|
14
|
+
<p class="govuk-body">{{ developmentMessage }}</p>
|
|
15
|
+
{% endif %}
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
{% endblock %}
|