@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 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.21.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
+ }
@@ -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 statusCodeMessage(statusCode) {
13
+ function errorPageFor(statusCode) {
7
14
  switch (statusCode) {
8
15
  case statusCodes.notFound:
9
- return 'Page not found'
10
- case statusCodes.forbidden:
11
- return 'Forbidden'
16
+ return pageNotFound
12
17
  case statusCodes.unauthorized:
13
- return 'Unauthorized'
14
- case statusCodes.badRequest:
15
- return 'Bad Request'
18
+ case statusCodes.forbidden:
19
+ return notAuthorised
20
+ case statusCodes.serviceUnavailable:
21
+ return serviceUnavailable
16
22
  default:
17
- return 'Something went wrong'
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 (statusCode >= statusCodes.internalServerError) {
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 genericMessage = statusCodeMessage(statusCode)
52
- const detailedMessage =
53
- response?.message ?? response?.source?.message ?? genericMessage
54
-
55
- const message =
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
  }
@@ -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('catchAll renders the expected not found page', () => {
49
- const request = createBoomRequest(statusCodes.notFound)
50
- const toolkit = createToolkit()
51
-
52
- catchAll(request, toolkit)
53
-
54
- assert.deepEqual(toolkit.view.mock.calls[0], [
55
- 'error/index',
56
- {
57
- pageTitle: 'Page not found',
58
- heading: statusCodes.notFound,
59
- message: 'Page not found'
60
- }
61
- ])
62
- assert.deepEqual(toolkit.code.mock.calls[0], [statusCodes.notFound])
63
- assert.equal(logger.error.mock.calls.length, 0)
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 generic content for server errors', () => {
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/index',
104
+ 'error/problem-with-service',
74
105
  {
75
- pageTitle: 'Something went wrong',
76
- heading: statusCodes.internalServerError,
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/index',
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
+ })
@@ -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
- * @param {{ config: object, buildNavigation: Function, getRequestBasePath: Function, logger: object, readFileSync: Function, moduleId?: string }} options
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
@@ -157,14 +157,16 @@ function buildNunjucksEnvironment({
157
157
  }
158
158
 
159
159
  /**
160
- * @param {{ config: object, logger: object, getRequestBasePath: Function, moduleId?: string }} options
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 %}
@@ -1,13 +0,0 @@
1
- {% extends 'layouts/page.njk' %}
2
-
3
- {% block content %}
4
- {{ appHeading({
5
- text: heading
6
- }) }}
7
-
8
- <div class="govuk-grid-row">
9
- <div class="govuk-grid-column-two-thirds">
10
- <p>{{ message }}</p>
11
- </div>
12
- </div>
13
- {% endblock %}