@defra/lis-infra-ui-services 0.50.0 → 0.51.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.
@@ -0,0 +1,172 @@
1
+ // @vitest-environment jsdom
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import hapi from '@hapi/hapi'
6
+ import { afterEach, describe, expect, test } from 'vitest'
7
+
8
+ import { CSRF_TOKEN_HEADER, csrfPlugin } from '../../../csrf/index.js'
9
+ import { createNunjucksConfig } from '../../plugin.js'
10
+
11
+ const projectRoot = path.resolve(
12
+ path.dirname(fileURLToPath(import.meta.url)),
13
+ '../../../../test/fixtures/form-app'
14
+ )
15
+ const configValues = {
16
+ root: projectRoot,
17
+ assetPath: '/public',
18
+ serviceName: 'Example service',
19
+ isProduction: false,
20
+ 'nunjucks.watch': false,
21
+ 'nunjucks.noCache': true
22
+ }
23
+ const config = { get: (key) => configValues[key] }
24
+
25
+ let server
26
+
27
+ async function render({ view = 'form', form, token }) {
28
+ server = hapi.server()
29
+ await server.register(createNunjucksConfig({ config, logger: {} }))
30
+ await server.register({
31
+ plugin: csrfPlugin,
32
+ options: { tokenSource: 'header' }
33
+ })
34
+ server.route({
35
+ method: 'GET',
36
+ path: '/form',
37
+ handler: (_request, h) => h.view(view, { form })
38
+ })
39
+ await server.initialize()
40
+
41
+ const response = await server.inject({
42
+ method: 'GET',
43
+ url: '/form',
44
+ headers: token === undefined ? {} : { [CSRF_TOKEN_HEADER]: token }
45
+ })
46
+ document.body.innerHTML = response.result
47
+
48
+ return document.querySelector('form')
49
+ }
50
+
51
+ describe('lisForm()', () => {
52
+ afterEach(async () => {
53
+ document.body.innerHTML = ''
54
+ await server?.stop()
55
+ server = undefined
56
+ })
57
+
58
+ test('renders a novalidate POST form with the request token and the caller body', async () => {
59
+ // Act
60
+ const form = await render({
61
+ form: { action: '/holdings/12/345/6789/edit' },
62
+ token: 'request-token'
63
+ })
64
+
65
+ // Assert
66
+ expect(form.getAttribute('action')).toBe('/holdings/12/345/6789/edit')
67
+ expect(form.getAttribute('method')).toBe('post')
68
+ expect(form.hasAttribute('novalidate')).toBe(true)
69
+ expect(form.querySelector('input[type="hidden"]').name).toBe('_csrf')
70
+ expect(form.querySelector('input[name="_csrf"]').value).toBe(
71
+ 'request-token'
72
+ )
73
+ expect(form.querySelector('button').textContent).toBe('Save')
74
+ })
75
+
76
+ test('escapes the token, action, classes and attributes', async () => {
77
+ // Arrange
78
+ const hostile = '"><script>alert(1)</script>'
79
+
80
+ // Act
81
+ const form = await render({
82
+ form: {
83
+ action: `/edit${hostile}`,
84
+ classes: `lis-form${hostile}`,
85
+ attributes: { 'data-module': hostile }
86
+ },
87
+ token: `token${hostile}`
88
+ })
89
+
90
+ // Assert
91
+ expect(document.querySelectorAll('script')).toHaveLength(0)
92
+ expect(form.getAttribute('action')).toBe(`/edit${hostile}`)
93
+ expect(form.className).toBe(`lis-form${hostile}`)
94
+ expect(form.dataset.module).toBe(hostile)
95
+ expect(form.querySelector('input[name="_csrf"]').value).toBe(
96
+ `token${hostile}`
97
+ )
98
+ })
99
+
100
+ test('renders the classes and attributes', async () => {
101
+ // Act
102
+ const form = await render({
103
+ form: {
104
+ action: '/edit',
105
+ classes: 'lis-form govuk-!-margin-bottom-6',
106
+ attributes: { id: 'edit-holding', 'data-module': 'lis-edit' }
107
+ },
108
+ token: 'request-token'
109
+ })
110
+
111
+ // Assert
112
+ expect(form.className).toBe('lis-form govuk-!-margin-bottom-6')
113
+ expect(form.id).toBe('edit-holding')
114
+ expect(form.dataset.module).toBe('lis-edit')
115
+ })
116
+
117
+ test('leaves the action out when none is given, posting to the page itself', async () => {
118
+ // Act
119
+ const form = await render({ form: {}, token: 'request-token' })
120
+
121
+ // Assert
122
+ expect(form.hasAttribute('action')).toBe(false)
123
+ expect(form.getAttribute('method')).toBe('post')
124
+ })
125
+
126
+ test('never puts the token in a GET form', async () => {
127
+ // Act
128
+ const form = await render({
129
+ form: { action: '/search', method: 'GET' },
130
+ token: 'request-token'
131
+ })
132
+
133
+ // Assert
134
+ expect(form.getAttribute('method')).toBe('GET')
135
+ expect(form.querySelector('input[name="_csrf"]')).toBeNull()
136
+ })
137
+
138
+ test('leaves the token field out when the request has no token, so the post is refused', async () => {
139
+ // Act
140
+ const form = await render({ form: { action: '/edit' } })
141
+
142
+ // Assert
143
+ expect(form.getAttribute('method')).toBe('post')
144
+ expect(form.querySelector('input[name="_csrf"]')).toBeNull()
145
+ })
146
+
147
+ test('takes an explicit csrfToken when imported without context', async () => {
148
+ // Act
149
+ const form = await render({
150
+ view: 'form-without-context',
151
+ form: { action: '/edit', csrfToken: 'explicit-token' },
152
+ token: 'request-token'
153
+ })
154
+
155
+ // Assert
156
+ expect(form.querySelector('input[name="_csrf"]').value).toBe(
157
+ 'explicit-token'
158
+ )
159
+ })
160
+
161
+ test('cannot see the request token when imported without context, so renders no token field', async () => {
162
+ // Act
163
+ const form = await render({
164
+ view: 'form-without-context',
165
+ form: { action: '/edit' },
166
+ token: 'request-token'
167
+ })
168
+
169
+ // Assert
170
+ expect(form.querySelector('input[name="_csrf"]')).toBeNull()
171
+ })
172
+ })
@@ -0,0 +1,3 @@
1
+ {% macro lisForm(params = {}) %}
2
+ {%- include "./template.njk" -%}
3
+ {% endmacro %}
@@ -0,0 +1,18 @@
1
+ {#
2
+ Import with context so the token comes from the page:
3
+ {% from "form/macro.njk" import lisForm with context %}
4
+ A GET form never carries the token, which would otherwise end up in the URL.
5
+ With no token the field is left out, so the post is refused: the macro was
6
+ imported without context, or the CSRF plugin is not registered.
7
+ #}
8
+ {% from "govuk/macros/attributes.njk" import govukAttributes %}
9
+
10
+ {%- set method = params.method or "post" -%}
11
+ {%- set token = params.csrfToken if params.csrfToken is defined else csrfToken -%}
12
+
13
+ <form{% if params.action %} action="{{ params.action }}"{% endif %} method="{{ method }}"{% if params.classes %} class="{{ params.classes }}"{% endif %}{{ govukAttributes(params.attributes) }} novalidate>
14
+ {%- if method | lower != "get" and token %}
15
+ <input type="hidden" name="_csrf" value="{{ token }}">
16
+ {%- endif %}
17
+ {{ caller() if caller }}
18
+ </form>
@@ -6,6 +6,7 @@ import { HUBS, getModuleById } from '@defra/lis-hubs-infra-registry'
6
6
  import { getAssetRoot } from '../asset-path.js'
7
7
 
8
8
  import { COOKIES_PATH, getCookiesPolicy } from '../cookies/cookies-policy.js'
9
+ import { CSRF_PLUGIN_NAME } from '../csrf/constants.js'
9
10
 
10
11
  const HUB_SERVICE_NAME_PREFIX = 'lis-hubs-'
11
12
 
@@ -116,6 +117,7 @@ export function createNunjucksContextBuilder({
116
117
  cookiesPath: COOKIES_PATH,
117
118
  cookiesPolicy: getCookiesPolicy(request),
118
119
  isCookiesPage: request?.path === COOKIES_PATH,
120
+ csrfToken: request?.plugins?.[CSRF_PLUGIN_NAME]?.token,
119
121
  errorPages: errorPageLinks,
120
122
  getAssetPath(asset) {
121
123
  const viteAssetPath = viteManifest?.[asset]?.file
@@ -607,3 +607,41 @@ describe('createNunjucksContextBuilder() hasPermission()', () => {
607
607
  expect(error.message).toBe('hasPermission() requires a permission')
608
608
  })
609
609
  })
610
+
611
+ describe('createNunjucksContextBuilder() csrfToken', () => {
612
+ test('exposes the token the CSRF plugin resolved for the request', () => {
613
+ // Arrange
614
+ const contextBuilder = createNunjucksContextBuilder({
615
+ config: createConfig(),
616
+ logger: {},
617
+ readFileSync: () => '{}'
618
+ })
619
+ const request = {
620
+ headers: {},
621
+ path: '/',
622
+ plugins: { 'lis-csrf': { token: 'resolved-token' } }
623
+ }
624
+
625
+ // Act
626
+ const context = contextBuilder(request)
627
+
628
+ // Assert
629
+ expect(context.csrfToken).toBe('resolved-token')
630
+ })
631
+
632
+ test('leaves the token undefined when the CSRF plugin is not registered', () => {
633
+ // Arrange
634
+ const contextBuilder = createNunjucksContextBuilder({
635
+ config: createConfig(),
636
+ logger: {},
637
+ readFileSync: () => '{}'
638
+ })
639
+ const request = { headers: {}, path: '/', plugins: {} }
640
+
641
+ // Act
642
+ const context = contextBuilder(request)
643
+
644
+ // Assert
645
+ expect(context.csrfToken).toBeUndefined()
646
+ })
647
+ })
@@ -0,0 +1,20 @@
1
+ {#
2
+ The Sign out link lands here: a GET never signs out, so the page asks for
3
+ the POST, carrying the CSRF token. Takes signOutHref, the path to post to.
4
+ #}
5
+ {% extends 'layouts/page.njk' %}
6
+ {% from "form/macro.njk" import lisForm with context %}
7
+
8
+ {% block content %}
9
+ <div class="govuk-grid-row">
10
+ <div class="govuk-grid-column-two-thirds">
11
+ <h1 class="govuk-heading-l">Are you sure you want to sign out?</h1>
12
+ {% call lisForm({ action: signOutHref }) %}
13
+ <div class="govuk-button-group">
14
+ <button type="submit" class="govuk-button" data-module="govuk-button">Sign out</button>
15
+ <a class="govuk-link" href="/">Cancel</a>
16
+ </div>
17
+ {% endcall %}
18
+ </div>
19
+ </div>
20
+ {% endblock %}
@@ -0,0 +1,62 @@
1
+ import path from 'node:path'
2
+ import { createRequire } from 'node:module'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ import nunjucks from 'nunjucks'
6
+ import { describe, expect, test } from 'vitest'
7
+
8
+ const routesRoot = path.resolve(
9
+ path.dirname(fileURLToPath(import.meta.url)),
10
+ '..'
11
+ )
12
+ const layoutFixtureRoot = path.resolve(
13
+ routesRoot,
14
+ '../../../test/fixtures/nunjucks'
15
+ )
16
+ const componentsRoot = path.resolve(routesRoot, '../components')
17
+ // The form component imports govuk-frontend's attributes macro.
18
+ const govukRoot = path.join(
19
+ path.dirname(
20
+ createRequire(import.meta.url).resolve('govuk-frontend/package.json')
21
+ ),
22
+ 'dist'
23
+ )
24
+ const environment = new nunjucks.Environment(
25
+ new nunjucks.FileSystemLoader([
26
+ routesRoot,
27
+ layoutFixtureRoot,
28
+ componentsRoot,
29
+ govukRoot
30
+ ]),
31
+ { autoescape: true, trimBlocks: true, lstripBlocks: true }
32
+ )
33
+
34
+ describe('sign out page', () => {
35
+ test('asks the user to confirm with a form posting the CSRF token, or cancel', () => {
36
+ // Arrange
37
+ const context = {
38
+ pageTitle: 'Are you sure you want to sign out?',
39
+ signOutHref: '/signout',
40
+ csrfToken: 'csrf-token'
41
+ }
42
+
43
+ // Act
44
+ const html = environment.render('auth/sign-out.njk', context)
45
+
46
+ // Assert
47
+ expect(html).toContain(
48
+ '<title>Are you sure you want to sign out? - Livestock Information</title>'
49
+ )
50
+ expect(html).toContain(
51
+ '<h1 class="govuk-heading-l">Are you sure you want to sign out?</h1>'
52
+ )
53
+ expect(html).toContain('<form action="/signout" method="post" novalidate>')
54
+ expect(html).toContain(
55
+ '<input type="hidden" name="_csrf" value="csrf-token">'
56
+ )
57
+ expect(html).toContain(
58
+ '<button type="submit" class="govuk-button" data-module="govuk-button">Sign out</button>'
59
+ )
60
+ expect(html).toContain('<a class="govuk-link" href="/">Cancel</a>')
61
+ })
62
+ })