@defra/lis-infra-ui-services 0.20.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "./nunjucks-context": "./src/nunjucks-context.js",
31
31
  "./proxy/setup-proxy": "./src/proxy/setup-proxy.js",
32
32
  "./redis-client": "./src/redis-client.js",
33
+ "./referrer-link": "./src/referrer-link.js",
33
34
  "./session-cache": "./src/session-cache.js",
34
35
  "./session-cache/cache-engine": "./src/session-cache/cache-engine.js",
35
36
  "./static-files": "./src/static-files.js",
package/src/duration.js CHANGED
@@ -17,6 +17,7 @@ export const milliseconds = {
17
17
  tenSeconds: TEN * MILLIS_IN_SECOND,
18
18
  thirtySeconds: THIRTY * MILLIS_IN_SECOND,
19
19
  fiveMinutes: FIVE * SECONDS_IN_MINUTE * MILLIS_IN_SECOND,
20
+ oneHour: MINUTES_IN_HOUR * SECONDS_IN_MINUTE * MILLIS_IN_SECOND,
20
21
  fourHours: FOUR * MINUTES_IN_HOUR * SECONDS_IN_MINUTE * MILLIS_IN_SECOND,
21
22
  oneDay: HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE * MILLIS_IN_SECOND,
22
23
  oneWeek:
@@ -55,6 +55,28 @@ describe('searchForm()', () => {
55
55
  expect(html).not.toContain('aria-describedby')
56
56
  })
57
57
 
58
+ test('carries hidden fields inside the form so they survive a submit', () => {
59
+ // Act
60
+ const html = render({
61
+ label: 'Search',
62
+ hiddenFields: [{ name: 'referrer', value: 'abc-123' }]
63
+ })
64
+
65
+ // Assert
66
+ expect(html).toContain(
67
+ '<input type="hidden" name="referrer" value="abc-123">'
68
+ )
69
+ expect(html.indexOf('<form')).toBeLessThan(html.indexOf('type="hidden"'))
70
+ })
71
+
72
+ test('renders no hidden fields when none are given', () => {
73
+ // Act
74
+ const html = render({ label: 'Search' })
75
+
76
+ // Assert
77
+ expect(html).not.toContain('type="hidden"')
78
+ })
79
+
58
80
  test('defaults the input name and id to "search"', () => {
59
81
  // Act
60
82
  const html = render({ label: 'Search' })
@@ -12,6 +12,9 @@
12
12
  {%- endif -%}
13
13
 
14
14
  <form method="GET" class="lis-search-form">
15
+ {%- for field in params.hiddenFields -%}
16
+ <input type="hidden" name="{{ field.name }}" value="{{ field.value }}">
17
+ {%- endfor -%}
15
18
  <div class="lis-search-form__wrapper">
16
19
  <input
17
20
  class="govuk-input lis-search-form__input"
@@ -0,0 +1,159 @@
1
+ /** @import { Request, Server } from '@hapi/hapi' */
2
+ import { randomUUID } from 'node:crypto'
3
+
4
+ import { milliseconds } from './duration.js'
5
+
6
+ const idPattern =
7
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
8
+
9
+ /**
10
+ * The browser sends the hub's public URL as the referrer, and the hub proxy
11
+ * passes the host it was called on as x-forwarded-host, so the two are
12
+ * compared to keep a link built from the referrer on the current domain.
13
+ *
14
+ * @param {Request} request
15
+ * @returns {string | undefined} the referrer's path and query, when it is on the same host as the request
16
+ */
17
+ export function getSameHostReferrerPath(request) {
18
+ const { referer } = request.headers
19
+ if (!referer) {
20
+ return undefined
21
+ }
22
+
23
+ let refererUrl
24
+ try {
25
+ refererUrl = new URL(referer)
26
+ } catch {
27
+ return undefined
28
+ }
29
+
30
+ const requestHost = (
31
+ request.headers['x-forwarded-host'] ??
32
+ request.headers.host ??
33
+ ''
34
+ )
35
+ .split(',')[0]
36
+ .trim()
37
+
38
+ // A path starting with // would make the link protocol-relative, i.e.
39
+ // another site.
40
+ if (refererUrl.host !== requestHost || refererUrl.pathname.startsWith('//')) {
41
+ return undefined
42
+ }
43
+
44
+ return `${refererUrl.pathname}${refererUrl.search}`
45
+ }
46
+
47
+ /**
48
+ * @param {string} href
49
+ * @param {string} queryParam
50
+ * @param {string | null} id
51
+ * @returns {string} href with the referrer id appended, or unchanged when there is no id
52
+ */
53
+ function appendReferrerId(href, queryParam, id) {
54
+ if (!id) {
55
+ return href
56
+ }
57
+ const separator = href.includes('?') ? '&' : '?'
58
+ return `${href}${separator}${queryParam}=${id}`
59
+ }
60
+
61
+ /**
62
+ * @param {Request} request
63
+ * @returns {string | null} the signed-in user's subject, which keys what is remembered for them
64
+ */
65
+ function userKey(request) {
66
+ const sub = request.auth?.credentials?.user?.sub
67
+ return typeof sub === 'string' && sub.length > 0 ? sub : null
68
+ }
69
+
70
+ /**
71
+ * Remembers where a user came from so pages reached from a list can link
72
+ * back to it, including its query, after moving between related pages.
73
+ *
74
+ * On a request whose same-host referrer the caller allows, the referrer path
75
+ * is stored in the server cache under a new id, keyed with the user, and the
76
+ * page links to its related pages carrying `?<queryParam>=<id>`. A request
77
+ * carrying an id resolves it from the cache, for the same user only.
78
+ *
79
+ * @typedef {object} ReferrerLink
80
+ * @property {string | null} id the id to carry on links to related pages, or null when there is nothing to carry
81
+ * @property {string} href the back link: the remembered referrer, or the fallback
82
+ */
83
+
84
+ /**
85
+ * @param {Server} server
86
+ * @param {object} options
87
+ * @param {string} options.cacheName a cache provisioned on the server
88
+ * @param {(path: string) => boolean} options.allow whether a same-host referrer path is one worth remembering
89
+ * @param {string} options.fallbackHref the back link when nothing is remembered
90
+ * @param {(request: Request) => boolean} [options.when] whether the request gets a back link at all; defaults to always
91
+ * @param {string} [options.queryParam] query parameter carrying the id; defaults to "referrer"
92
+ * @param {string} [options.segment] cache segment; defaults to "referrer-link"
93
+ * @param {number} [options.expiresIn] how long a remembered referrer lasts, in milliseconds; defaults to one hour
94
+ * @returns {{ resolve: (request: Request) => Promise<ReferrerLink | null>, link: (href: string, id: string | null) => string }}
95
+ */
96
+ export function createReferrerLink(
97
+ server,
98
+ {
99
+ cacheName,
100
+ allow,
101
+ fallbackHref,
102
+ when = () => true,
103
+ queryParam = 'referrer',
104
+ segment = 'referrer-link',
105
+ expiresIn = milliseconds.oneHour
106
+ }
107
+ ) {
108
+ if (typeof cacheName !== 'string' || cacheName.length === 0) {
109
+ throw new TypeError('createReferrerLink() requires a cacheName')
110
+ }
111
+ if (typeof allow !== 'function') {
112
+ throw new TypeError('createReferrerLink() requires an allow function')
113
+ }
114
+ if (typeof fallbackHref !== 'string' || fallbackHref.length === 0) {
115
+ throw new TypeError('createReferrerLink() requires a fallbackHref')
116
+ }
117
+
118
+ const store = server.cache({ cache: cacheName, segment, expiresIn })
119
+
120
+ /**
121
+ * A route pre method: resolves the back link for the request. Null when
122
+ * `when` rules the request out, so the page shows no back link.
123
+ * @param {Request} request
124
+ * @returns {Promise<ReferrerLink | null>}
125
+ */
126
+ async function resolve(request) {
127
+ if (!when(request)) {
128
+ return null
129
+ }
130
+
131
+ const user = userKey(request)
132
+ const requestedId = request.query?.[queryParam]
133
+
134
+ if (
135
+ user &&
136
+ typeof requestedId === 'string' &&
137
+ idPattern.test(requestedId)
138
+ ) {
139
+ const remembered = await store.get(`${user}:${requestedId}`)
140
+ if (typeof remembered === 'string') {
141
+ return { id: requestedId, href: remembered }
142
+ }
143
+ }
144
+
145
+ const referrerPath = getSameHostReferrerPath(request)
146
+ if (user && referrerPath && allow(referrerPath)) {
147
+ const id = randomUUID()
148
+ await store.set(`${user}:${id}`, referrerPath)
149
+ return { id, href: referrerPath }
150
+ }
151
+
152
+ return { id: null, href: fallbackHref }
153
+ }
154
+
155
+ return {
156
+ resolve,
157
+ link: (href, id) => appendReferrerId(href, queryParam, id)
158
+ }
159
+ }
@@ -0,0 +1,280 @@
1
+ import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest'
2
+
3
+ import { createReferrerLink, getSameHostReferrerPath } from './referrer-link.js'
4
+
5
+ const uuidPattern =
6
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
7
+
8
+ const mocks = {
9
+ get: vi.fn(),
10
+ set: vi.fn(),
11
+ cache: vi.fn()
12
+ }
13
+
14
+ const server = { cache: mocks.cache }
15
+
16
+ function createRequest({ query = {}, headers = {}, sub = 'user-1' } = {}) {
17
+ return {
18
+ query,
19
+ headers: { 'x-forwarded-host': 'back-office.lis.defra', ...headers },
20
+ auth: { credentials: { user: sub ? { sub } : undefined } }
21
+ }
22
+ }
23
+
24
+ describe('getSameHostReferrerPath()', () => {
25
+ test('it returns the path and query of a referrer on the forwarded host', () => {
26
+ // Arrange
27
+ const request = {
28
+ headers: {
29
+ referer: 'https://back-office.lis.defra/holdings?search=oak&page=2',
30
+ 'x-forwarded-host': 'back-office.lis.defra',
31
+ host: 'localhost:3221'
32
+ }
33
+ }
34
+
35
+ // Act
36
+ const result = getSameHostReferrerPath(request)
37
+
38
+ // Assert
39
+ expect(result).toBe('/holdings?search=oak&page=2')
40
+ })
41
+
42
+ test('it ignores a referrer on another host', () => {
43
+ // Arrange
44
+ const request = {
45
+ headers: {
46
+ referer: 'https://evil.example/holdings',
47
+ 'x-forwarded-host': 'back-office.lis.defra'
48
+ }
49
+ }
50
+
51
+ // Act
52
+ const result = getSameHostReferrerPath(request)
53
+
54
+ // Assert
55
+ expect(result).toBeUndefined()
56
+ })
57
+
58
+ test('it ignores a referrer path that would point at another site', () => {
59
+ // Arrange
60
+ const request = {
61
+ headers: {
62
+ referer: 'https://back-office.lis.defra//evil.example',
63
+ 'x-forwarded-host': 'back-office.lis.defra'
64
+ }
65
+ }
66
+
67
+ // Act
68
+ const result = getSameHostReferrerPath(request)
69
+
70
+ // Assert
71
+ expect(result).toBeUndefined()
72
+ })
73
+
74
+ test('it returns undefined when there is no referrer or it is not a URL', () => {
75
+ // Arrange
76
+ const noReferrer = { headers: { host: 'back-office.lis.defra' } }
77
+ const badReferrer = {
78
+ headers: { referer: 'not a url', host: 'back-office.lis.defra' }
79
+ }
80
+
81
+ // Act
82
+ const first = getSameHostReferrerPath(noReferrer)
83
+ const second = getSameHostReferrerPath(badReferrer)
84
+
85
+ // Assert
86
+ expect(first).toBeUndefined()
87
+ expect(second).toBeUndefined()
88
+ })
89
+ })
90
+
91
+ describe('createReferrerLink()', () => {
92
+ beforeAll(() => {
93
+ mocks.cache.mockReturnValue({ get: mocks.get, set: mocks.set })
94
+ mocks.get.mockResolvedValue(null)
95
+ mocks.set.mockResolvedValue(undefined)
96
+ })
97
+
98
+ afterEach(() => {
99
+ vi.clearAllMocks()
100
+ })
101
+
102
+ function createSubject(overrides = {}) {
103
+ return createReferrerLink(server, {
104
+ cacheName: 'test-cache',
105
+ allow: (path) => path === '/holdings' || path.startsWith('/holdings?'),
106
+ fallbackHref: '/holdings',
107
+ ...overrides
108
+ })
109
+ }
110
+
111
+ test('it provisions a cache segment with the given name and expiry', () => {
112
+ // Arrange
113
+ const expiresIn = 1234
114
+
115
+ // Act
116
+ createSubject({ segment: 'list-referrer', expiresIn })
117
+
118
+ // Assert
119
+ expect(mocks.cache).toHaveBeenCalledWith({
120
+ cache: 'test-cache',
121
+ segment: 'list-referrer',
122
+ expiresIn
123
+ })
124
+ })
125
+
126
+ test('it throws when the cache name, allow rule or fallback is missing', () => {
127
+ // Arrange
128
+ const incomplete = [
129
+ { allow: () => true, fallbackHref: '/x' },
130
+ { cacheName: 'c', fallbackHref: '/x' },
131
+ { cacheName: 'c', allow: () => true }
132
+ ]
133
+
134
+ // Act
135
+ const errors = incomplete.map((options) => {
136
+ try {
137
+ createReferrerLink(server, options)
138
+ return undefined
139
+ } catch (e) {
140
+ return e
141
+ }
142
+ })
143
+
144
+ // Assert
145
+ expect(errors.map((e) => e?.message)).toEqual([
146
+ 'createReferrerLink() requires a cacheName',
147
+ 'createReferrerLink() requires an allow function',
148
+ 'createReferrerLink() requires a fallbackHref'
149
+ ])
150
+ })
151
+
152
+ test('it remembers an allowed same-host referrer under a new id for the user', async () => {
153
+ // Arrange
154
+ const subject = createSubject()
155
+ const request = createRequest({
156
+ headers: {
157
+ referer: 'https://back-office.lis.defra/holdings?search=oak&page=2'
158
+ }
159
+ })
160
+
161
+ // Act
162
+ const result = await subject.resolve(request)
163
+
164
+ // Assert
165
+ expect(result.href).toBe('/holdings?search=oak&page=2')
166
+ expect(result.id).toMatch(uuidPattern)
167
+ expect(mocks.set).toHaveBeenCalledWith(
168
+ `user-1:${result.id}`,
169
+ '/holdings?search=oak&page=2'
170
+ )
171
+ })
172
+
173
+ test('it resolves a carried id from the cache for the same user', async () => {
174
+ // Arrange
175
+ const subject = createSubject()
176
+ const id = '0f0f0f0f-0f0f-4f0f-8f0f-0f0f0f0f0f0f'
177
+ mocks.get.mockResolvedValueOnce('/holdings?search=oak')
178
+ const request = createRequest({ query: { referrer: id } })
179
+
180
+ // Act
181
+ const result = await subject.resolve(request)
182
+
183
+ // Assert
184
+ expect(mocks.get).toHaveBeenCalledWith(`user-1:${id}`)
185
+ expect(result).toEqual({ id, href: '/holdings?search=oak' })
186
+ expect(mocks.set).not.toHaveBeenCalled()
187
+ })
188
+
189
+ test('it falls back when a carried id is unknown, expired or not a uuid', async () => {
190
+ // Arrange
191
+ const subject = createSubject()
192
+ const unknown = createRequest({
193
+ query: { referrer: '0f0f0f0f-0f0f-4f0f-8f0f-0f0f0f0f0f0f' }
194
+ })
195
+ const malformed = createRequest({ query: { referrer: '../etc' } })
196
+
197
+ // Act
198
+ const first = await subject.resolve(unknown)
199
+ const second = await subject.resolve(malformed)
200
+
201
+ // Assert
202
+ expect(first).toEqual({ id: null, href: '/holdings' })
203
+ expect(second).toEqual({ id: null, href: '/holdings' })
204
+ expect(mocks.get).toHaveBeenCalledTimes(1)
205
+ })
206
+
207
+ test('it falls back when the referrer is not allowed, off-host or missing', async () => {
208
+ // Arrange
209
+ const subject = createSubject()
210
+ const notAllowed = createRequest({
211
+ headers: { referer: 'https://back-office.lis.defra/cattle/holdings/1' }
212
+ })
213
+ const offHost = createRequest({
214
+ headers: { referer: 'https://evil.example/holdings' }
215
+ })
216
+ const missing = createRequest()
217
+
218
+ // Act
219
+ const results = await Promise.all(
220
+ [notAllowed, offHost, missing].map((request) => subject.resolve(request))
221
+ )
222
+
223
+ // Assert
224
+ expect(results).toEqual([
225
+ { id: null, href: '/holdings' },
226
+ { id: null, href: '/holdings' },
227
+ { id: null, href: '/holdings' }
228
+ ])
229
+ expect(mocks.set).not.toHaveBeenCalled()
230
+ })
231
+
232
+ test('it never remembers or resolves anything for a request without a user', async () => {
233
+ // Arrange
234
+ const subject = createSubject()
235
+ const request = createRequest({
236
+ sub: null,
237
+ query: { referrer: '0f0f0f0f-0f0f-4f0f-8f0f-0f0f0f0f0f0f' },
238
+ headers: { referer: 'https://back-office.lis.defra/holdings' }
239
+ })
240
+
241
+ // Act
242
+ const result = await subject.resolve(request)
243
+
244
+ // Assert
245
+ expect(result).toEqual({ id: null, href: '/holdings' })
246
+ expect(mocks.get).not.toHaveBeenCalled()
247
+ expect(mocks.set).not.toHaveBeenCalled()
248
+ })
249
+
250
+ test('it returns null when the request is ruled out by "when"', async () => {
251
+ // Arrange
252
+ const subject = createSubject({ when: () => false })
253
+ const request = createRequest({
254
+ headers: { referer: 'https://back-office.lis.defra/holdings' }
255
+ })
256
+
257
+ // Act
258
+ const result = await subject.resolve(request)
259
+
260
+ // Assert
261
+ expect(result).toBeNull()
262
+ expect(mocks.set).not.toHaveBeenCalled()
263
+ })
264
+
265
+ test('it appends the id to a link, after any existing query', () => {
266
+ // Arrange
267
+ const subject = createSubject({ queryParam: 'from' })
268
+ const id = '0f0f0f0f-0f0f-4f0f-8f0f-0f0f0f0f0f0f'
269
+
270
+ // Act
271
+ const plain = subject.link('/cattle/holdings/22/001/0001', id)
272
+ const withQuery = subject.link('/cattle/holdings/x/animals?page=2', id)
273
+ const noId = subject.link('/cattle/holdings/22/001/0001', null)
274
+
275
+ // Assert
276
+ expect(plain).toBe(`/cattle/holdings/22/001/0001?from=${id}`)
277
+ expect(withQuery).toBe(`/cattle/holdings/x/animals?page=2&from=${id}`)
278
+ expect(noId).toBe('/cattle/holdings/22/001/0001')
279
+ })
280
+ })