@defra/lis-infra-ui-services 0.35.0 → 0.36.0-lreg-504.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -279,6 +279,29 @@ await server.register(
279
279
 
280
280
  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.
281
281
 
282
+ ### Cookies
283
+
284
+ The base layout shows the GOV.UK cookie banner until the visitor has made a choice, and its footer links to a cookies page at `/cookies`. The choice is kept for a year in a `cookies_policy` cookie (URL-encoded JSON, readable by client-side scripts so analytics can check it before loading), set by the banner's JavaScript or, without JavaScript, by posting the banner's form to `/cookies`. Each hub registers the page with the essential cookies it sets, which the page lists:
285
+
286
+ ```js
287
+ // server.js, after @hapi/vision is registered
288
+ import { createCookiesPlugin } from '@defra/lis-infra-ui-services/cookies'
289
+
290
+ await server.register(
291
+ createCookiesPlugin({
292
+ essentialCookies: [
293
+ {
294
+ name: config.get('session.cache.name'),
295
+ purpose: 'Keeps you signed in as you move between pages',
296
+ expires: 'After 4 hours, or when you sign out'
297
+ }
298
+ ]
299
+ })
300
+ )
301
+ ```
302
+
303
+ `GET /cookies` shows the page, with a form to change the choice. `POST /cookies` records a choice (`analytics=yes|no`), ignoring cross-site posts, and redirects to the page with a confirmation and a link back to the same-host page the visitor came from. The `CookieBanner` component is part of `layoutComponents` (see [Layout scripts](#layout-scripts)), so a hub that already initialises those needs no further client-side change. `getCookiesPolicy(request)` from `@defra/lis-infra-ui-services/cookies/cookies-policy` reads the recorded choice server-side.
304
+
282
305
  ### Environment variables
283
306
 
284
307
  The package reads no environment variables itself. It takes all its settings from the `config` object the consuming app passes in. In the example above, `MAINTENANCE_AVAILABLE_FROM` is a variable the hub defines in its own config.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.35.0",
3
+ "version": "0.36.0-lreg-504.alpha.1",
4
4
  "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -10,7 +10,10 @@
10
10
  "exports": {
11
11
  ".": "./src/index.js",
12
12
  "./base-path": "./src/base-path.js",
13
+ "./cookies": "./src/cookies/index.js",
14
+ "./cookies/cookies-policy": "./src/cookies/cookies-policy.js",
13
15
  "./components/autocomplete": "./src/components/autocomplete.js",
16
+ "./components/cookie-banner": "./src/components/cookie-banner.js",
14
17
  "./components/error-pages": "./src/components/error-pages.js",
15
18
  "./components/layout": "./src/components/layout.js",
16
19
  "./components/scrollable-table": "./src/components/scrollable-table.js",
@@ -0,0 +1,83 @@
1
+ import { Component } from 'govuk-frontend'
2
+
3
+ import {
4
+ COOKIES_POLICY_COOKIE_NAME,
5
+ createCookiesPolicy,
6
+ serialiseCookiesPolicy
7
+ } from '../cookies/cookies-policy.js'
8
+ import { seconds } from '../duration.js'
9
+
10
+ /**
11
+ * Progressive enhancement for the cookie banner: records the choice in the
12
+ * consent cookie without a round trip and swaps the question for the
13
+ * confirmation. Without JavaScript the form posts to the cookies page.
14
+ */
15
+ export class CookieBanner extends Component {
16
+ static moduleName = 'lis-cookie-banner'
17
+
18
+ /**
19
+ * @param {object} $root - the banner region
20
+ */
21
+ constructor($root) {
22
+ super($root)
23
+
24
+ this.$question = this.$root.querySelector('[data-cookie-banner-question]')
25
+ this.$confirmation = this.$root.querySelector(
26
+ '[data-cookie-banner-confirmation]'
27
+ )
28
+ this.$decision = this.$root.querySelector('[data-cookie-banner-decision]')
29
+
30
+ this.$question?.addEventListener('submit', (event) => this.onSubmit(event))
31
+ this.$root
32
+ .querySelector('[data-cookie-banner-hide]')
33
+ ?.addEventListener('click', () => this.hide())
34
+ }
35
+
36
+ /**
37
+ * @param {object} event - the question form's submit event
38
+ */
39
+ onSubmit(event) {
40
+ const analytics = event.submitter?.value
41
+
42
+ if (analytics !== 'yes' && analytics !== 'no') {
43
+ return
44
+ }
45
+
46
+ event.preventDefault()
47
+ this.setCookiesPolicy(analytics === 'yes')
48
+ this.confirm(analytics === 'yes')
49
+ }
50
+
51
+ /**
52
+ * @param {boolean} analytics
53
+ */
54
+ setCookiesPolicy(analytics) {
55
+ const value = serialiseCookiesPolicy(createCookiesPolicy(analytics))
56
+
57
+ document.cookie = `${COOKIES_POLICY_COOKIE_NAME}=${value}; path=/; max-age=${seconds.oneYear}; secure; samesite=lax`
58
+ }
59
+
60
+ /**
61
+ * @param {boolean} analytics
62
+ */
63
+ confirm(analytics) {
64
+ if (this.$decision) {
65
+ this.$decision.textContent = analytics ? 'accepted' : 'rejected'
66
+ }
67
+ this.$question?.setAttribute('hidden', '')
68
+ if (this.$confirmation) {
69
+ this.$confirmation.removeAttribute('hidden')
70
+ // A div is not focusable until it has a tabindex; -1 keeps it out of
71
+ // the tab order, matching GOV.UK's own component.
72
+ this.$confirmation.setAttribute('tabindex', '-1')
73
+ this.$confirmation.focus()
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Removes the banner once the confirmation has been read.
79
+ */
80
+ hide() {
81
+ this.$root.setAttribute('hidden', '')
82
+ }
83
+ }
@@ -0,0 +1,115 @@
1
+ // @vitest-environment jsdom
2
+ // @vitest-environment-options { "url": "https://lis.test/" }
3
+ import { createAll } from 'govuk-frontend'
4
+ import { afterEach, beforeAll, describe, expect, test } from 'vitest'
5
+
6
+ import { CookieBanner } from './cookie-banner.js'
7
+
8
+ function renderBanner() {
9
+ document.body.innerHTML = `
10
+ <div class="govuk-cookie-banner" data-module="lis-cookie-banner">
11
+ <form method="post" action="/cookies" data-cookie-banner-question>
12
+ <button type="submit" name="analytics" value="yes">Accept</button>
13
+ <button type="submit" name="analytics" value="no">Reject</button>
14
+ </form>
15
+ <div hidden data-cookie-banner-confirmation>
16
+ <p>You’ve <span data-cookie-banner-decision>accepted</span> analytics cookies.</p>
17
+ <button type="button" data-cookie-banner-hide>Hide</button>
18
+ </div>
19
+ </div>
20
+ `
21
+ createAll(CookieBanner)
22
+
23
+ return {
24
+ $root: document.querySelector('.govuk-cookie-banner'),
25
+ $question: document.querySelector('[data-cookie-banner-question]'),
26
+ $confirmation: document.querySelector('[data-cookie-banner-confirmation]'),
27
+ $decision: document.querySelector('[data-cookie-banner-decision]'),
28
+ $accept: document.querySelector('button[value="yes"]'),
29
+ $reject: document.querySelector('button[value="no"]'),
30
+ $hide: document.querySelector('[data-cookie-banner-hide]')
31
+ }
32
+ }
33
+
34
+ function submitVia($button) {
35
+ const event = new window.SubmitEvent('submit', {
36
+ bubbles: true,
37
+ cancelable: true,
38
+ submitter: $button
39
+ })
40
+ $button.form.dispatchEvent(event)
41
+
42
+ return event
43
+ }
44
+
45
+ describe('CookieBanner', () => {
46
+ beforeAll(() => {
47
+ document.body.classList.add('govuk-frontend-supported')
48
+ })
49
+
50
+ afterEach(() => {
51
+ document.body.innerHTML = ''
52
+ document.cookie = 'cookies_policy=; max-age=0; path=/'
53
+ })
54
+
55
+ test('accepting sets the consent cookie and shows the confirmation', () => {
56
+ // Arrange
57
+ const { $question, $confirmation, $decision, $accept } = renderBanner()
58
+
59
+ // Act
60
+ const event = submitVia($accept)
61
+
62
+ // Assert
63
+ expect(event.defaultPrevented).toBe(true)
64
+ expect(document.cookie).toContain(
65
+ 'cookies_policy=%7B%22essential%22%3Atrue%2C%22analytics%22%3Atrue%7D'
66
+ )
67
+ expect($question.hidden).toBe(true)
68
+ expect($confirmation.hidden).toBe(false)
69
+ expect($confirmation.getAttribute('tabindex')).toBe('-1')
70
+ expect(document.activeElement).toBe($confirmation)
71
+ expect($decision.textContent).toBe('accepted')
72
+ })
73
+
74
+ test('rejecting records analytics as refused', () => {
75
+ // Arrange
76
+ const { $decision, $reject } = renderBanner()
77
+
78
+ // Act
79
+ submitVia($reject)
80
+
81
+ // Assert
82
+ expect(document.cookie).toContain(
83
+ 'cookies_policy=%7B%22essential%22%3Atrue%2C%22analytics%22%3Afalse%7D'
84
+ )
85
+ expect($decision.textContent).toBe('rejected')
86
+ })
87
+
88
+ test('hide removes the banner', () => {
89
+ // Arrange
90
+ const { $root, $accept, $hide } = renderBanner()
91
+ submitVia($accept)
92
+
93
+ // Act
94
+ $hide.click()
95
+
96
+ // Assert
97
+ expect($root.hidden).toBe(true)
98
+ })
99
+
100
+ test('a submit without a recognised choice is left to the browser', () => {
101
+ // Arrange
102
+ const { $question } = renderBanner()
103
+
104
+ // Act
105
+ const event = new window.SubmitEvent('submit', {
106
+ bubbles: true,
107
+ cancelable: true
108
+ })
109
+ $question.dispatchEvent(event)
110
+
111
+ // Assert
112
+ expect(event.defaultPrevented).toBe(false)
113
+ expect(document.cookie).not.toContain('cookies_policy')
114
+ })
115
+ })
@@ -1,3 +1,10 @@
1
- import { ServiceNavigation, SkipLink } from 'govuk-frontend'
1
+ import { NotificationBanner, ServiceNavigation, SkipLink } from 'govuk-frontend'
2
2
 
3
- export const layoutComponents = [ServiceNavigation, SkipLink]
3
+ import { CookieBanner } from './cookie-banner.js'
4
+
5
+ export const layoutComponents = [
6
+ ServiceNavigation,
7
+ SkipLink,
8
+ NotificationBanner,
9
+ CookieBanner
10
+ ]
@@ -1,13 +1,19 @@
1
1
  // @vitest-environment jsdom
2
- import { ServiceNavigation, SkipLink } from 'govuk-frontend'
2
+ import { NotificationBanner, ServiceNavigation, SkipLink } from 'govuk-frontend'
3
3
  import { describe, expect, test } from 'vitest'
4
4
 
5
+ import { CookieBanner } from './cookie-banner.js'
5
6
  import { layoutComponents } from './layout.js'
6
7
 
7
8
  describe('layoutComponents', () => {
8
9
  test('contains the layout components in order', () => {
9
10
  // Arrange
10
- const expected = [ServiceNavigation, SkipLink]
11
+ const expected = [
12
+ ServiceNavigation,
13
+ SkipLink,
14
+ NotificationBanner,
15
+ CookieBanner
16
+ ]
11
17
 
12
18
  // Act
13
19
  const actual = layoutComponents
@@ -0,0 +1,74 @@
1
+ /** @import { Request } from '@hapi/hapi' */
2
+ import { milliseconds } from '../duration.js'
3
+
4
+ export const COOKIES_POLICY_COOKIE_NAME = 'cookies_policy'
5
+ export const COOKIES_PATH = '/cookies'
6
+
7
+ /**
8
+ * Hapi state options for the consent cookie. It is readable by client-side
9
+ * JavaScript so the banner can set it and analytics can later check it.
10
+ */
11
+ export const cookiesPolicyStateOptions = {
12
+ ttl: milliseconds.oneYear,
13
+ path: '/',
14
+ isSecure: true,
15
+ isHttpOnly: false,
16
+ isSameSite: 'Lax',
17
+ encoding: 'none',
18
+ clearInvalid: true,
19
+ ignoreErrors: true
20
+ }
21
+
22
+ /**
23
+ * @typedef {object} CookiesPolicy
24
+ * @property {boolean} essential - always true; essential cookies cannot be refused
25
+ * @property {boolean} analytics - whether analytics cookies may be set
26
+ */
27
+
28
+ /**
29
+ * @param {boolean} analytics
30
+ * @returns {CookiesPolicy}
31
+ */
32
+ export function createCookiesPolicy(analytics) {
33
+ return { essential: true, analytics: analytics === true }
34
+ }
35
+
36
+ /**
37
+ * Encodes a policy as the cookie value: URL-encoded JSON, so the value only
38
+ * contains cookie-safe characters and client-side code can decode it.
39
+ * @param {CookiesPolicy} policy
40
+ * @returns {string}
41
+ */
42
+ export function serialiseCookiesPolicy(policy) {
43
+ return encodeURIComponent(JSON.stringify(policy))
44
+ }
45
+
46
+ /**
47
+ * @param {string | undefined} value - the raw cookie value
48
+ * @returns {CookiesPolicy | null} null when absent or malformed
49
+ */
50
+ export function parseCookiesPolicy(value) {
51
+ if (typeof value !== 'string' || value === '') {
52
+ return null
53
+ }
54
+
55
+ try {
56
+ const parsed = JSON.parse(decodeURIComponent(value))
57
+
58
+ if (typeof parsed?.analytics !== 'boolean') {
59
+ return null
60
+ }
61
+
62
+ return createCookiesPolicy(parsed.analytics)
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+
68
+ /**
69
+ * @param {Request} [request]
70
+ * @returns {CookiesPolicy | null}
71
+ */
72
+ export function getCookiesPolicy(request) {
73
+ return parseCookiesPolicy(request?.state?.[COOKIES_POLICY_COOKIE_NAME])
74
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import {
4
+ createCookiesPolicy,
5
+ getCookiesPolicy,
6
+ parseCookiesPolicy,
7
+ serialiseCookiesPolicy
8
+ } from './cookies-policy.js'
9
+
10
+ describe('createCookiesPolicy()', () => {
11
+ test('always keeps essential cookies on', () => {
12
+ // Arrange
13
+ // Act
14
+ const policy = createCookiesPolicy(false)
15
+
16
+ // Assert
17
+ expect(policy).toEqual({ essential: true, analytics: false })
18
+ })
19
+
20
+ test('treats anything but true as refusing analytics', () => {
21
+ // Arrange
22
+ // Act
23
+ const policy = createCookiesPolicy('yes')
24
+
25
+ // Assert
26
+ expect(policy.analytics).toBe(false)
27
+ })
28
+ })
29
+
30
+ describe('serialiseCookiesPolicy()', () => {
31
+ test('URL-encodes the policy as JSON', () => {
32
+ // Arrange
33
+ const policy = { essential: true, analytics: true }
34
+
35
+ // Act
36
+ const value = serialiseCookiesPolicy(policy)
37
+
38
+ // Assert
39
+ expect(value).toBe('%7B%22essential%22%3Atrue%2C%22analytics%22%3Atrue%7D')
40
+ })
41
+ })
42
+
43
+ describe('parseCookiesPolicy()', () => {
44
+ test('round-trips a serialised policy', () => {
45
+ // Arrange
46
+ const value = serialiseCookiesPolicy(createCookiesPolicy(true))
47
+
48
+ // Act
49
+ const policy = parseCookiesPolicy(value)
50
+
51
+ // Assert
52
+ expect(policy).toEqual({ essential: true, analytics: true })
53
+ })
54
+
55
+ test.each([
56
+ ['undefined', undefined],
57
+ ['empty', ''],
58
+ ['not JSON', 'yes'],
59
+ ['bad encoding', '%E0%A4%A'],
60
+ ['missing analytics', '%7B%22essential%22%3Atrue%7D'],
61
+ ['analytics not boolean', '%7B%22analytics%22%3A%22yes%22%7D']
62
+ ])('returns null for a %s value', (_label, value) => {
63
+ // Arrange
64
+ // Act
65
+ const policy = parseCookiesPolicy(value)
66
+
67
+ // Assert
68
+ expect(policy).toBeNull()
69
+ })
70
+ })
71
+
72
+ describe('getCookiesPolicy()', () => {
73
+ test('reads the cookies_policy cookie from request state', () => {
74
+ // Arrange
75
+ const request = {
76
+ state: {
77
+ cookies_policy: serialiseCookiesPolicy(createCookiesPolicy(false))
78
+ }
79
+ }
80
+
81
+ // Act
82
+ const policy = getCookiesPolicy(request)
83
+
84
+ // Assert
85
+ expect(policy).toEqual({ essential: true, analytics: false })
86
+ })
87
+
88
+ test('returns null without a request', () => {
89
+ // Arrange
90
+ // Act
91
+ const policy = getCookiesPolicy(undefined)
92
+
93
+ // Assert
94
+ expect(policy).toBeNull()
95
+ })
96
+ })
@@ -0,0 +1,185 @@
1
+ /** @import { Plugin, Request, ResponseToolkit } from '@hapi/hapi' */
2
+ import { getSameHostReferrerPath } from '../referrer-link.js'
3
+ import {
4
+ COOKIES_PATH,
5
+ COOKIES_POLICY_COOKIE_NAME,
6
+ cookiesPolicyStateOptions,
7
+ createCookiesPolicy,
8
+ getCookiesPolicy,
9
+ serialiseCookiesPolicy
10
+ } from './cookies-policy.js'
11
+
12
+ const PLUGIN_NAME = 'lis-cookies'
13
+ const PAGE_TITLE = 'Cookies'
14
+ const VIEW = 'cookies/index'
15
+ const CONSENT_VALUES = new Set(['yes', 'no'])
16
+ // Only used to resolve a candidate path; never appears in output.
17
+ const PROBE_ORIGIN = 'https://same-origin.invalid'
18
+
19
+ /**
20
+ * @typedef {object} EssentialCookie
21
+ * @property {string} name
22
+ * @property {string} purpose
23
+ * @property {string} expires
24
+ */
25
+
26
+ /**
27
+ * Keeps a candidate on this origin by resolving it the way a browser would:
28
+ * `//host`, `/\\host` and absolute URLs all resolve elsewhere and are refused.
29
+ * @param {unknown} candidate
30
+ * @returns {string} the path when it is on this origin, otherwise the root
31
+ */
32
+ export function toSameOriginPath(candidate) {
33
+ if (typeof candidate !== 'string' || !candidate.startsWith('/')) {
34
+ return '/'
35
+ }
36
+
37
+ try {
38
+ const url = new URL(candidate, PROBE_ORIGIN)
39
+
40
+ // A normalised path starting with // would be protocol-relative in a link.
41
+ if (url.origin !== PROBE_ORIGIN || url.pathname.startsWith('//')) {
42
+ return '/'
43
+ }
44
+
45
+ return `${url.pathname}${url.search}`
46
+ } catch {
47
+ return '/'
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Returns the same-host path the user came from, so the cookies page can
53
+ * link back to it after a choice made from the banner without JavaScript.
54
+ * Anything else, including another host or the cookies page itself, falls
55
+ * back to the service root.
56
+ * @param {Request} request
57
+ * @returns {string}
58
+ */
59
+ export function getReturnPath(request) {
60
+ const referrerPath = getSameHostReferrerPath(request)
61
+
62
+ if (!referrerPath || referrerPath.split('?')[0] === COOKIES_PATH) {
63
+ return '/'
64
+ }
65
+
66
+ return toSameOriginPath(referrerPath)
67
+ }
68
+
69
+ /**
70
+ * @param {Request} request
71
+ * @returns {string} the host the browser addressed, as the hub proxy forwards it
72
+ */
73
+ function getRequestHost(request) {
74
+ return (request.headers['x-forwarded-host'] ?? request.headers.host ?? '')
75
+ .split(',')[0]
76
+ .trim()
77
+ }
78
+
79
+ /**
80
+ * Whether a consent POST came from this site. Hapi has no CSRF token here, so
81
+ * the browser's own signals are used: Sec-Fetch-Site when present, otherwise
82
+ * the Origin header, which browsers always send on a cross-site form POST.
83
+ * @param {Request} request
84
+ * @returns {boolean}
85
+ */
86
+ export function isSameSiteRequest(request) {
87
+ const fetchSite = request.headers['sec-fetch-site']
88
+
89
+ if (typeof fetchSite === 'string') {
90
+ return fetchSite === 'same-origin' || fetchSite === 'same-site'
91
+ }
92
+
93
+ const origin = request.headers.origin
94
+
95
+ if (typeof origin !== 'string' || origin === '') {
96
+ return true
97
+ }
98
+
99
+ try {
100
+ return new URL(origin).host === getRequestHost(request)
101
+ } catch {
102
+ return false
103
+ }
104
+ }
105
+
106
+ /**
107
+ * @param {EssentialCookie[]} essentialCookies
108
+ * @returns {(request: Request, h: ResponseToolkit) => unknown}
109
+ */
110
+ function createGetHandler(essentialCookies) {
111
+ return function getCookiesPage(request, h) {
112
+ const cookiesPolicy = getCookiesPolicy(request)
113
+ const saved = request.query?.saved === 'true'
114
+
115
+ return h.view(VIEW, {
116
+ pageTitle: PAGE_TITLE,
117
+ essentialCookieRows: essentialCookies.map((cookie) => [
118
+ { text: cookie.name },
119
+ { text: cookie.purpose },
120
+ { text: cookie.expires }
121
+ ]),
122
+ analytics: cookiesPolicy?.analytics === true,
123
+ saved,
124
+ returnPath: saved ? toSameOriginPath(request.query?.returnPath) : null
125
+ })
126
+ }
127
+ }
128
+
129
+ /**
130
+ * @param {Request} request
131
+ * @param {ResponseToolkit} h
132
+ * @returns {unknown}
133
+ */
134
+ function postConsent(request, h) {
135
+ const analytics = request.payload?.analytics
136
+
137
+ if (!CONSENT_VALUES.has(analytics) || !isSameSiteRequest(request)) {
138
+ return h.redirect(COOKIES_PATH)
139
+ }
140
+
141
+ const policy = createCookiesPolicy(analytics === 'yes')
142
+ const query = new URLSearchParams({
143
+ saved: 'true',
144
+ returnPath: getReturnPath(request)
145
+ })
146
+
147
+ return h
148
+ .redirect(`${COOKIES_PATH}?${query}`)
149
+ .state(COOKIES_POLICY_COOKIE_NAME, serialiseCookiesPolicy(policy))
150
+ }
151
+
152
+ /**
153
+ * Registers the consent cookie and the cookies page: GET shows the page, POST
154
+ * records the choice (from the page's form or the banner without JavaScript)
155
+ * and redirects back to the page with a confirmation.
156
+ * @param {{ essentialCookies: EssentialCookie[] }} options
157
+ * @returns {Plugin<void>}
158
+ */
159
+ export function createCookiesPlugin({ essentialCookies } = {}) {
160
+ if (!Array.isArray(essentialCookies) || essentialCookies.length === 0) {
161
+ throw new Error('createCookiesPlugin requires essentialCookies')
162
+ }
163
+
164
+ return {
165
+ name: PLUGIN_NAME,
166
+ register(server) {
167
+ server.state(COOKIES_POLICY_COOKIE_NAME, cookiesPolicyStateOptions)
168
+
169
+ server.route([
170
+ {
171
+ method: 'GET',
172
+ path: COOKIES_PATH,
173
+ options: { auth: { mode: 'try' } },
174
+ handler: createGetHandler(essentialCookies)
175
+ },
176
+ {
177
+ method: 'POST',
178
+ path: COOKIES_PATH,
179
+ options: { auth: { mode: 'try' } },
180
+ handler: postConsent
181
+ }
182
+ ])
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,323 @@
1
+ import path from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import hapi from '@hapi/hapi'
6
+ import hapiVision from '@hapi/vision'
7
+ import nunjucks from 'nunjucks'
8
+ import { afterEach, describe, expect, test } from 'vitest'
9
+
10
+ import {
11
+ createCookiesPlugin,
12
+ getReturnPath,
13
+ isSameSiteRequest,
14
+ toSameOriginPath
15
+ } from './index.js'
16
+
17
+ const packageRoot = path.resolve(
18
+ path.dirname(fileURLToPath(import.meta.url)),
19
+ '../..'
20
+ )
21
+ const govukFrontendRoot = path.dirname(
22
+ createRequire(import.meta.url).resolve('govuk-frontend/package.json')
23
+ )
24
+ const environment = new nunjucks.Environment(
25
+ new nunjucks.FileSystemLoader([
26
+ path.join(govukFrontendRoot, 'dist'),
27
+ path.join(packageRoot, 'src/nunjucks/routes'),
28
+ path.join(packageRoot, 'test/fixtures/nunjucks/layouts/..')
29
+ ]),
30
+ { autoescape: true }
31
+ )
32
+
33
+ const essentialCookies = [
34
+ {
35
+ name: 'front-office-session',
36
+ purpose: 'Keeps you signed in',
37
+ expires: 'After 4 hours'
38
+ }
39
+ ]
40
+
41
+ let server
42
+
43
+ async function createServer() {
44
+ server = hapi.server()
45
+ await server.register({
46
+ plugin: hapiVision,
47
+ options: {
48
+ engines: {
49
+ njk: {
50
+ compile(src) {
51
+ const template = nunjucks.compile(src, environment)
52
+ return (context) => template.render(context)
53
+ }
54
+ }
55
+ },
56
+ path: path.join(packageRoot, 'src/nunjucks/routes'),
57
+ context: { cookiesPath: '/cookies' }
58
+ }
59
+ })
60
+ server.auth.scheme('none', () => ({
61
+ authenticate: (_request, h) => h.unauthenticated(new Error('no session'))
62
+ }))
63
+ server.auth.strategy('none', 'none')
64
+ server.auth.default('none')
65
+ await server.register(createCookiesPlugin({ essentialCookies }))
66
+ await server.initialize()
67
+
68
+ return server
69
+ }
70
+
71
+ afterEach(async () => {
72
+ await server?.stop()
73
+ server = undefined
74
+ })
75
+
76
+ describe('createCookiesPlugin()', () => {
77
+ test('throws without essential cookies to list', () => {
78
+ // Arrange
79
+ // Act
80
+ const act = () => createCookiesPlugin({})
81
+
82
+ // Assert
83
+ expect(act).toThrow('createCookiesPlugin requires essentialCookies')
84
+ })
85
+
86
+ test('GET /cookies lists the essential cookies with analytics unchecked by default', async () => {
87
+ // Arrange
88
+ await createServer()
89
+
90
+ // Act
91
+ const response = await server.inject({ method: 'GET', url: '/cookies' })
92
+
93
+ // Assert
94
+ expect(response.statusCode).toBe(200)
95
+ expect(response.result).toContain(
96
+ '<h1 class="govuk-heading-l">Cookies</h1>'
97
+ )
98
+ expect(response.result).toContain('front-office-session')
99
+ expect(response.result).toContain('Keeps you signed in')
100
+ expect(response.result).toMatch(/value="no"[^>]*checked/)
101
+ expect(response.result).not.toContain('govuk-notification-banner')
102
+ })
103
+
104
+ test('GET /cookies reflects an accepted policy from the cookie', async () => {
105
+ // Arrange
106
+ await createServer()
107
+
108
+ // Act
109
+ const response = await server.inject({
110
+ method: 'GET',
111
+ url: '/cookies',
112
+ headers: {
113
+ cookie:
114
+ 'cookies_policy=%7B%22essential%22%3Atrue%2C%22analytics%22%3Atrue%7D'
115
+ }
116
+ })
117
+
118
+ // Assert
119
+ expect(response.result).toMatch(/value="yes"[^>]*checked/)
120
+ })
121
+
122
+ test('GET /cookies?saved=true shows the confirmation with a same-origin return link', async () => {
123
+ // Arrange
124
+ await createServer()
125
+
126
+ // Act
127
+ const response = await server.inject({
128
+ method: 'GET',
129
+ url: '/cookies?saved=true&returnPath=%2Fcattle%2Fhome%3Fcph%3D1'
130
+ })
131
+
132
+ // Assert
133
+ expect(response.result).toContain('You’ve set your cookie preferences.')
134
+ expect(response.result).toContain('href="/cattle/home?cph=1"')
135
+ })
136
+
137
+ test('GET /cookies?saved=true falls back to the root for a foreign return path', async () => {
138
+ // Arrange
139
+ await createServer()
140
+
141
+ // Act
142
+ const response = await server.inject({
143
+ method: 'GET',
144
+ url: '/cookies?saved=true&returnPath=//evil.example/x'
145
+ })
146
+
147
+ // Assert
148
+ expect(response.result).toContain('href="/"')
149
+ })
150
+
151
+ test('POST /cookies records acceptance and redirects to the confirmation', async () => {
152
+ // Arrange
153
+ await createServer()
154
+
155
+ // Act
156
+ const response = await server.inject({
157
+ method: 'POST',
158
+ url: '/cookies',
159
+ headers: { host: 'lis.test', referer: 'https://lis.test/cattle/home' },
160
+ payload: { analytics: 'yes' }
161
+ })
162
+
163
+ // Assert
164
+ expect(response.statusCode).toBe(302)
165
+ expect(response.headers.location).toBe(
166
+ '/cookies?saved=true&returnPath=%2Fcattle%2Fhome'
167
+ )
168
+ const setCookie = response.headers['set-cookie'][0]
169
+ expect(setCookie).toContain(
170
+ 'cookies_policy=%7B%22essential%22%3Atrue%2C%22analytics%22%3Atrue%7D'
171
+ )
172
+ expect(setCookie).toContain('Secure')
173
+ expect(setCookie).toContain('SameSite=Lax')
174
+ expect(setCookie).toContain('Path=/')
175
+ expect(setCookie).not.toContain('HttpOnly')
176
+ })
177
+
178
+ test('POST /cookies records refusal', async () => {
179
+ // Arrange
180
+ await createServer()
181
+
182
+ // Act
183
+ const response = await server.inject({
184
+ method: 'POST',
185
+ url: '/cookies',
186
+ payload: { analytics: 'no' }
187
+ })
188
+
189
+ // Assert
190
+ expect(response.headers['set-cookie'][0]).toContain(
191
+ 'cookies_policy=%7B%22essential%22%3Atrue%2C%22analytics%22%3Afalse%7D'
192
+ )
193
+ expect(response.headers.location).toBe('/cookies?saved=true&returnPath=%2F')
194
+ })
195
+
196
+ test('POST /cookies with an invalid choice sets nothing and returns to the page', async () => {
197
+ // Arrange
198
+ await createServer()
199
+
200
+ // Act
201
+ const response = await server.inject({
202
+ method: 'POST',
203
+ url: '/cookies',
204
+ payload: { analytics: 'maybe' }
205
+ })
206
+
207
+ // Assert
208
+ expect(response.statusCode).toBe(302)
209
+ expect(response.headers.location).toBe('/cookies')
210
+ expect(response.headers['set-cookie']).toBeUndefined()
211
+ })
212
+ })
213
+
214
+ describe('toSameOriginPath()', () => {
215
+ test.each([
216
+ ['/holdings', '/holdings'],
217
+ ['/a?b=c', '/a?b=c'],
218
+ ['//evil.example', '/'],
219
+ ['/\\evil.example/x', '/'],
220
+ ['/a/../..//evil.example', '/'],
221
+ ['https://evil.example/', '/'],
222
+ ['holdings', '/'],
223
+ [undefined, '/']
224
+ ])('maps %s to %s', (candidate, expected) => {
225
+ // Arrange
226
+ // Act
227
+ const actual = toSameOriginPath(candidate)
228
+
229
+ // Assert
230
+ expect(actual).toBe(expected)
231
+ })
232
+ })
233
+
234
+ describe('getReturnPath()', () => {
235
+ test.each([
236
+ ['no referer', {}, '/'],
237
+ [
238
+ 'same-host referer',
239
+ { referer: 'https://lis.test/cattle?x=1' },
240
+ '/cattle?x=1'
241
+ ],
242
+ [
243
+ 'a forwarded host',
244
+ {
245
+ host: 'internal:3001',
246
+ 'x-forwarded-host': 'lis.test',
247
+ referer: 'https://lis.test/cattle'
248
+ },
249
+ '/cattle'
250
+ ],
251
+ ['cross-host referer', { referer: 'https://evil.example/cattle' }, '/'],
252
+ ['the cookies page itself', { referer: 'https://lis.test/cookies' }, '/'],
253
+ [
254
+ 'the cookies page with a query',
255
+ { referer: 'https://lis.test/cookies?saved=true' },
256
+ '/'
257
+ ],
258
+ ['an unparseable referer', { referer: 'not a url' }, '/']
259
+ ])('handles %s', (_label, headers, expected) => {
260
+ // Arrange
261
+ const request = { headers: { host: 'lis.test', ...headers } }
262
+
263
+ // Act
264
+ const actual = getReturnPath(request)
265
+
266
+ // Assert
267
+ expect(actual).toBe(expected)
268
+ })
269
+ })
270
+
271
+ describe('isSameSiteRequest()', () => {
272
+ test.each([
273
+ ['sec-fetch-site same-origin', { 'sec-fetch-site': 'same-origin' }, true],
274
+ ['sec-fetch-site same-site', { 'sec-fetch-site': 'same-site' }, true],
275
+ ['sec-fetch-site cross-site', { 'sec-fetch-site': 'cross-site' }, false],
276
+ ['sec-fetch-site none', { 'sec-fetch-site': 'none' }, false],
277
+ ['no signals at all', {}, true],
278
+ ['matching origin', { origin: 'https://lis.test' }, true],
279
+ ['foreign origin', { origin: 'https://evil.example' }, false],
280
+ ['unparseable origin', { origin: 'not a url' }, false],
281
+ [
282
+ 'origin matching the forwarded host',
283
+ {
284
+ host: 'internal:3001',
285
+ 'x-forwarded-host': 'lis.test',
286
+ origin: 'https://lis.test'
287
+ },
288
+ true
289
+ ]
290
+ ])('handles %s', (_label, headers, expected) => {
291
+ // Arrange
292
+ const request = { headers: { host: 'lis.test', ...headers } }
293
+
294
+ // Act
295
+ const actual = isSameSiteRequest(request)
296
+
297
+ // Assert
298
+ expect(actual).toBe(expected)
299
+ })
300
+ })
301
+
302
+ describe('POST /cookies from another site', () => {
303
+ test.each([
304
+ ['sec-fetch-site cross-site', { 'sec-fetch-site': 'cross-site' }],
305
+ ['a foreign origin', { origin: 'https://evil.example' }]
306
+ ])('ignores a consent POST with %s', async (_label, headers) => {
307
+ // Arrange
308
+ await createServer()
309
+
310
+ // Act
311
+ const response = await server.inject({
312
+ method: 'POST',
313
+ url: '/cookies',
314
+ headers,
315
+ payload: { analytics: 'yes' }
316
+ })
317
+
318
+ // Assert
319
+ expect(response.statusCode).toBe(302)
320
+ expect(response.headers.location).toBe('/cookies')
321
+ expect(response.headers['set-cookie']).toBeUndefined()
322
+ })
323
+ })
package/src/duration.js CHANGED
@@ -4,6 +4,7 @@ const MILLIS_IN_SECOND = 1000
4
4
 
5
5
  const HOURS_IN_DAY = 24
6
6
  const DAYS_IN_WEEK = 7
7
+ const DAYS_IN_YEAR = 365
7
8
 
8
9
  const TWO = 2
9
10
  const FOUR = 4
@@ -25,6 +26,12 @@ export const milliseconds = {
25
26
  HOURS_IN_DAY *
26
27
  MINUTES_IN_HOUR *
27
28
  SECONDS_IN_MINUTE *
29
+ MILLIS_IN_SECOND,
30
+ oneYear:
31
+ DAYS_IN_YEAR *
32
+ HOURS_IN_DAY *
33
+ MINUTES_IN_HOUR *
34
+ SECONDS_IN_MINUTE *
28
35
  MILLIS_IN_SECOND
29
36
  }
30
37
 
@@ -36,7 +43,8 @@ export const seconds = {
36
43
  oneHour: MINUTES_IN_HOUR * SECONDS_IN_MINUTE,
37
44
  eightHours: EIGHT * MINUTES_IN_HOUR * SECONDS_IN_MINUTE,
38
45
  oneDay: HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE,
39
- oneWeek: DAYS_IN_WEEK * HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE
46
+ oneWeek: DAYS_IN_WEEK * HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE,
47
+ oneYear: DAYS_IN_YEAR * HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE
40
48
  }
41
49
 
42
50
  /**
@@ -3,6 +3,8 @@ import path from 'node:path'
3
3
  import { hasPermission } from '@defra/lis-hubs-infra-access/authorization'
4
4
  import { HUBS, getModuleById } from '@defra/lis-hubs-infra-registry'
5
5
 
6
+ import { COOKIES_PATH, getCookiesPolicy } from '../cookies/cookies-policy.js'
7
+
6
8
  const HUB_SERVICE_NAME_PREFIX = 'lis-hubs-'
7
9
 
8
10
  export const BCMS_CONTACT_HREF =
@@ -108,6 +110,9 @@ export function createNunjucksContextBuilder({
108
110
  isBackOffice: requestHubId === 'back-office',
109
111
  hasPermission: createPermissionCheck(request),
110
112
  logoutUrl: '/signout',
113
+ cookiesPath: COOKIES_PATH,
114
+ cookiesPolicy: getCookiesPolicy(request),
115
+ isCookiesPage: request?.path === COOKIES_PATH,
111
116
  errorPages: errorPageLinks,
112
117
  getAssetPath(asset) {
113
118
  const viteAssetPath = viteManifest?.[asset]?.file
@@ -201,6 +201,52 @@ test('createNunjucksContextBuilder marks the user signed out without credentials
201
201
  assert.equal(context.isSignedIn, false)
202
202
  })
203
203
 
204
+ describe('createNunjucksContextBuilder() cookies', () => {
205
+ test('exposes the cookies page path and no policy before consent', () => {
206
+ // Arrange
207
+ const contextBuilder = createSignedInContextBuilder()
208
+
209
+ // Act
210
+ const context = contextBuilder({ headers: {}, path: '/', state: {} })
211
+
212
+ // Assert
213
+ assert.equal(context.cookiesPath, '/cookies')
214
+ assert.equal(context.cookiesPolicy, null)
215
+ assert.equal(context.isCookiesPage, false)
216
+ })
217
+
218
+ test('exposes the consent recorded in the cookies_policy cookie', () => {
219
+ // Arrange
220
+ const contextBuilder = createSignedInContextBuilder()
221
+
222
+ // Act
223
+ const context = contextBuilder({
224
+ headers: {},
225
+ path: '/',
226
+ state: {
227
+ cookies_policy: '%7B%22essential%22%3Atrue%2C%22analytics%22%3Afalse%7D'
228
+ }
229
+ })
230
+
231
+ // Assert
232
+ assert.deepEqual(context.cookiesPolicy, {
233
+ essential: true,
234
+ analytics: false
235
+ })
236
+ })
237
+
238
+ test('flags the cookies page so the layout can hide the banner on it', () => {
239
+ // Arrange
240
+ const contextBuilder = createSignedInContextBuilder()
241
+
242
+ // Act
243
+ const context = contextBuilder({ headers: {}, path: '/cookies', state: {} })
244
+
245
+ // Assert
246
+ assert.equal(context.isCookiesPage, true)
247
+ })
248
+ })
249
+
204
250
  describe('createNunjucksContextBuilder() hub flags', () => {
205
251
  test('marks the request as front office when the caller is the front-office hub', () => {
206
252
  // Arrange
@@ -0,0 +1,67 @@
1
+ {% extends 'layouts/page.njk' %}
2
+
3
+ {% from "govuk/components/button/macro.njk" import govukButton %}
4
+ {% from "govuk/components/notification-banner/macro.njk" import govukNotificationBanner %}
5
+ {% from "govuk/components/radios/macro.njk" import govukRadios %}
6
+ {% from "govuk/components/table/macro.njk" import govukTable %}
7
+
8
+ {% block content %}
9
+ <div class="govuk-grid-row">
10
+ <div class="govuk-grid-column-two-thirds">
11
+ {% if saved %}
12
+ {% set savedHtml %}
13
+ <h3 class="govuk-notification-banner__heading">You’ve set your cookie preferences.</h3>
14
+ <p class="govuk-body"><a class="govuk-notification-banner__link" href="{{ returnPath }}">Go back to the page you were looking at</a></p>
15
+ {% endset %}
16
+ {{ govukNotificationBanner({
17
+ type: "success",
18
+ html: savedHtml
19
+ }) }}
20
+ {% endif %}
21
+
22
+ <h1 class="govuk-heading-l">Cookies</h1>
23
+
24
+ <p class="govuk-body">Cookies are small files saved on your phone, tablet or computer when you visit a website.</p>
25
+
26
+ <p class="govuk-body">We use cookies to make this service work and to collect information about how you use it.</p>
27
+
28
+ <h2 class="govuk-heading-m">Essential cookies</h2>
29
+
30
+ <p class="govuk-body">Essential cookies keep your information secure while you use this service. We do not need to ask permission to use them.</p>
31
+
32
+ {{ govukTable({
33
+ firstCellIsHeader: false,
34
+ head: [
35
+ { text: "Name" },
36
+ { text: "Purpose" },
37
+ { text: "Expires" }
38
+ ],
39
+ rows: essentialCookieRows
40
+ }) }}
41
+
42
+ <h2 class="govuk-heading-m">Analytics cookies (optional)</h2>
43
+
44
+ <p class="govuk-body">With your permission, we may use analytics software to collect anonymised information about how you use this service, for example which pages you visit and how long you spend on them. This helps us improve the service.</p>
45
+
46
+ <p class="govuk-body">We do not currently set any analytics cookies. Your choice is saved so that, if we add analytics later, it is respected from the start.</p>
47
+
48
+ <form method="post" action="{{ cookiesPath }}" novalidate>
49
+ {{ govukRadios({
50
+ name: "analytics",
51
+ fieldset: {
52
+ legend: {
53
+ text: "Do you want to accept analytics cookies?",
54
+ classes: "govuk-fieldset__legend--s"
55
+ }
56
+ },
57
+ items: [
58
+ { value: "yes", text: "Yes", checked: analytics },
59
+ { value: "no", text: "No", checked: not analytics }
60
+ ]
61
+ }) }}
62
+
63
+ {{ govukButton({ text: "Save cookie settings" }) }}
64
+ </form>
65
+ </div>
66
+ </div>
67
+ {% endblock %}
@@ -38,6 +38,44 @@
38
38
  <link href="{{ getAssetPath('src/client/stylesheets/application.scss') }}" rel="stylesheet">
39
39
  {% endblock %}
40
40
 
41
+ {% block bodyStart %}
42
+ {% if not cookiesPolicy and not isCookiesPage %}
43
+ {# Hand-written rather than govukCookieBanner so the question is a form
44
+ that works without JavaScript; the markup matches the GOV.UK component. #}
45
+ <div class="govuk-cookie-banner" data-nosnippet role="region" aria-label="Cookies on Livestock Information" data-module="lis-cookie-banner">
46
+ <form method="post" action="{{ cookiesPath }}" class="govuk-cookie-banner__message govuk-width-container" data-cookie-banner-question>
47
+ <div class="govuk-grid-row">
48
+ <div class="govuk-grid-column-two-thirds">
49
+ <h2 class="govuk-cookie-banner__heading govuk-heading-m">Cookies on Livestock Information</h2>
50
+ <div class="govuk-cookie-banner__content">
51
+ <p class="govuk-body">We use some essential cookies to make this service work.</p>
52
+ <p class="govuk-body">We’d also like to use analytics cookies so we can understand how you use the service and make improvements.</p>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ <div class="govuk-button-group">
57
+ {{ govukButton({ text: "Accept analytics cookies", type: "submit", name: "analytics", value: "yes" }) }}
58
+ {{ govukButton({ text: "Reject analytics cookies", type: "submit", name: "analytics", value: "no" }) }}
59
+ <a class="govuk-link" href="{{ cookiesPath }}">View cookies</a>
60
+ </div>
61
+ </form>
62
+
63
+ <div class="govuk-cookie-banner__message govuk-width-container" role="alert" hidden data-cookie-banner-confirmation>
64
+ <div class="govuk-grid-row">
65
+ <div class="govuk-grid-column-two-thirds">
66
+ <div class="govuk-cookie-banner__content">
67
+ <p class="govuk-body">You’ve <span data-cookie-banner-decision>accepted</span> analytics cookies. You can <a class="govuk-link" href="{{ cookiesPath }}">change your cookie settings</a> at any time.</p>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <div class="govuk-button-group">
72
+ {{ govukButton({ text: "Hide cookie message", type: "button", attributes: { "data-cookie-banner-hide": "" } }) }}
73
+ </div>
74
+ </div>
75
+ </div>
76
+ {% endif %}
77
+ {% endblock %}
78
+
41
79
  {% block govukHeader %}
42
80
  {{ govukHeader({
43
81
  homepageUrl: "https://www.gov.uk/",
@@ -97,7 +135,7 @@
97
135
  text: "Privacy"
98
136
  },
99
137
  {
100
- href: "https://www.gov.uk/help/cookies",
138
+ href: cookiesPath,
101
139
  text: "Cookies"
102
140
  },
103
141
  {