@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 +23 -0
- package/package.json +4 -1
- package/src/components/cookie-banner.js +83 -0
- package/src/components/cookie-banner.test.js +115 -0
- package/src/components/layout.js +9 -2
- package/src/components/layout.test.js +8 -2
- package/src/cookies/cookies-policy.js +74 -0
- package/src/cookies/cookies-policy.test.js +96 -0
- package/src/cookies/index.js +185 -0
- package/src/cookies/index.test.js +323 -0
- package/src/duration.js +9 -1
- package/src/nunjucks/context.js +5 -0
- package/src/nunjucks/context.test.js +46 -0
- package/src/nunjucks/routes/cookies/index.njk +67 -0
- package/src/nunjucks/templates/layouts/base.njk +39 -1
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.
|
|
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
|
+
})
|
package/src/components/layout.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
-
import { ServiceNavigation, SkipLink } from 'govuk-frontend'
|
|
1
|
+
import { NotificationBanner, ServiceNavigation, SkipLink } from 'govuk-frontend'
|
|
2
2
|
|
|
3
|
-
|
|
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 = [
|
|
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
|
/**
|
package/src/nunjucks/context.js
CHANGED
|
@@ -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:
|
|
138
|
+
href: cookiesPath,
|
|
101
139
|
text: "Cookies"
|
|
102
140
|
},
|
|
103
141
|
{
|