@defra/lis-infra-ui-services 0.40.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,33 @@ const serveStaticFiles = createStaticFilesPlugin({
95
95
  It serves `/favicon.ico` and the files under each asset path. `/status-codes` exports `statusCodes`.
96
96
  `/duration` exports the `milliseconds` and `seconds` lookups and `unixEpoch()`.
97
97
 
98
+ ### Config formats
99
+
100
+ `/config-formats` exports convict formats for settings the hubs and spokes share. Register each with `convict.addFormat()`; a bad value fails `config.validate()` with the setting named.
101
+
102
+ - `positiveInteger` (`'positive-integer'`): a whole number of at least 1, for timeouts, TTLs and circuit breaker thresholds, where 0 is a misconfiguration rather than "off". Values from environment variables are coerced from strings.
103
+ - `secret` (`'secret'`): a signing or encryption secret of at least 32 characters with no default, for `HUB_JWT_SECRET` and `SESSION_COOKIE_PASSWORD`. The setting must also be marked `sensitive: true`, or convict appends the value to the validation error.
104
+
105
+ ```js
106
+ import convict from 'convict'
107
+ import { positiveInteger, secret } from '@defra/lis-infra-ui-services/config-formats'
108
+
109
+ convict.addFormat(positiveInteger)
110
+ convict.addFormat(secret)
111
+
112
+ timeout: {
113
+ format: 'positive-integer',
114
+ default: 5000,
115
+ env: 'CATTLE_HOME_API_TIMEOUT'
116
+ },
117
+ hubJwtSecret: {
118
+ format: 'secret',
119
+ default: null,
120
+ env: 'HUB_JWT_SECRET',
121
+ sensitive: true
122
+ }
123
+ ```
124
+
98
125
  ### Security headers
99
126
 
100
127
  `securityHeaders` is a hapi plugin that adds HSTS, `x-content-type-options`, `referrer-policy` and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/lis-infra-ui-services",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "UI-Services shared definitions for the livestock taxonomy topology.",
5
5
  "main": "src/index.js",
6
6
  "repository": {
@@ -10,6 +10,7 @@
10
10
  "exports": {
11
11
  ".": "./src/index.js",
12
12
  "./base-path": "./src/base-path.js",
13
+ "./config-formats": "./src/config-formats.js",
13
14
  "./cookies": "./src/cookies/index.js",
14
15
  "./cookies/cookies-policy": "./src/cookies/cookies-policy.js",
15
16
  "./components/autocomplete": "./src/components/autocomplete.js",
@@ -87,6 +88,7 @@
87
88
  "@hapi/hapi": "^21.4.10",
88
89
  "@vitest/coverage-istanbul": "^4.1.10",
89
90
  "audit-ci": "^7.1.0",
91
+ "convict": "^6.2.5",
90
92
  "eslint": "^9.0.0",
91
93
  "govuk-frontend": "6.5.0",
92
94
  "husky": "^9.1.7",
@@ -0,0 +1,56 @@
1
+ /**
2
+ * A convict format for settings that must be a whole number of at least 1,
3
+ * such as timeouts and circuit breaker thresholds, where 0 is a
4
+ * misconfiguration rather than "off". Register it with
5
+ * `convict.addFormat(positiveInteger)` and use `format: 'positive-integer'`.
6
+ */
7
+ export const positiveInteger = {
8
+ name: 'positive-integer',
9
+ /**
10
+ * @param {unknown} value the raw value, a string when it came from an environment variable
11
+ * @returns {unknown} the value as a number when it is numeric, otherwise unchanged so validate() can reject it
12
+ */
13
+ coerce(value) {
14
+ if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
15
+ return Number(value)
16
+ }
17
+ return value
18
+ },
19
+ /**
20
+ * @param {unknown} value the coerced value
21
+ */
22
+ validate(value) {
23
+ if (
24
+ typeof value !== 'number' ||
25
+ !Number.isSafeInteger(value) ||
26
+ value < 1
27
+ ) {
28
+ throw new Error('must be a positive integer')
29
+ }
30
+ }
31
+ }
32
+
33
+ const MINIMUM_SECRET_LENGTH = 32
34
+
35
+ /**
36
+ * A convict format for a signing or encryption secret: a string of at least
37
+ * 32 characters, with no default. The format's own error names the setting,
38
+ * never the value, but convict appends the value to a validation error unless
39
+ * the setting is marked `sensitive: true`. Register it with
40
+ * `convict.addFormat(secret)` and use `format: 'secret'` with
41
+ * `default: null` and `sensitive: true`.
42
+ */
43
+ export const secret = {
44
+ name: 'secret',
45
+ /**
46
+ * @param {unknown} value
47
+ */
48
+ validate(value) {
49
+ if (typeof value !== 'string' || value.length === 0) {
50
+ throw new Error('must be set')
51
+ }
52
+ if (value.length < MINIMUM_SECRET_LENGTH) {
53
+ throw new Error(`must be at least ${MINIMUM_SECRET_LENGTH} characters`)
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,158 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import convict from 'convict'
3
+
4
+ import { positiveInteger, secret } from './config-formats.js'
5
+
6
+ convict.addFormat(positiveInteger)
7
+ convict.addFormat(secret)
8
+
9
+ function createConfig(value) {
10
+ return convict({
11
+ timeout: {
12
+ format: 'positive-integer',
13
+ default: 5000,
14
+ env: 'TEST_TIMEOUT'
15
+ }
16
+ }).load({ timeout: value })
17
+ }
18
+
19
+ describe('positiveInteger', () => {
20
+ test('is registered as the positive-integer format', () => {
21
+ // Assert
22
+ expect(positiveInteger.name).toBe('positive-integer')
23
+ })
24
+
25
+ test.each([1, 30000])('accepts %s', (value) => {
26
+ // Act
27
+ let error
28
+ try {
29
+ createConfig(value).validate({ allowed: 'strict' })
30
+ } catch (e) {
31
+ error = e
32
+ }
33
+
34
+ // Assert
35
+ expect(error).toBeUndefined()
36
+ })
37
+
38
+ test('coerces a numeric string from an environment variable', () => {
39
+ // Arrange
40
+ process.env.TEST_TIMEOUT = '2500'
41
+
42
+ // Act
43
+ let config, error
44
+ try {
45
+ config = convict({
46
+ timeout: {
47
+ format: 'positive-integer',
48
+ default: 5000,
49
+ env: 'TEST_TIMEOUT'
50
+ }
51
+ })
52
+ config.validate({ allowed: 'strict' })
53
+ } catch (e) {
54
+ error = e
55
+ } finally {
56
+ delete process.env.TEST_TIMEOUT
57
+ }
58
+
59
+ // Assert
60
+ expect(error).toBeUndefined()
61
+ expect(config.get('timeout')).toBe(2500)
62
+ })
63
+
64
+ test.each([0, -1, 1.5, '0', 'abc', '', null, true, '99999999999999999999'])(
65
+ 'rejects %j naming the setting',
66
+ (value) => {
67
+ // Act
68
+ let error
69
+ try {
70
+ createConfig(value).validate({ allowed: 'strict' })
71
+ } catch (e) {
72
+ error = e
73
+ }
74
+
75
+ // Assert
76
+ expect(error.message).toContain('timeout: must be a positive integer')
77
+ }
78
+ )
79
+ })
80
+
81
+ describe('secret', () => {
82
+ function createSecretConfig(value) {
83
+ return convict({
84
+ password: {
85
+ format: 'secret',
86
+ default: null,
87
+ sensitive: true
88
+ }
89
+ }).load({ password: value })
90
+ }
91
+
92
+ test('is registered as the secret format', () => {
93
+ // Assert
94
+ expect(secret.name).toBe('secret')
95
+ })
96
+
97
+ test('accepts a string of at least 32 characters', () => {
98
+ // Act
99
+ let error
100
+ try {
101
+ createSecretConfig('a'.repeat(32)).validate({ allowed: 'strict' })
102
+ } catch (e) {
103
+ error = e
104
+ }
105
+
106
+ // Assert
107
+ expect(error).toBeUndefined()
108
+ })
109
+
110
+ test.each([null, '', 42])('rejects %j as unset', (value) => {
111
+ // Act
112
+ let error
113
+ try {
114
+ createSecretConfig(value).validate({ allowed: 'strict' })
115
+ } catch (e) {
116
+ error = e
117
+ }
118
+
119
+ // Assert
120
+ expect(error.message).toContain('password: must be set')
121
+ })
122
+
123
+ test('rejects a short secret without echoing it', () => {
124
+ // Arrange
125
+ const value = 'too-short-secret'
126
+
127
+ // Act
128
+ let error
129
+ try {
130
+ createSecretConfig(value).validate({ allowed: 'strict' })
131
+ } catch (e) {
132
+ error = e
133
+ }
134
+
135
+ // Assert
136
+ expect(error.message).toContain('password: must be at least 32 characters')
137
+ expect(error.message).not.toContain(value)
138
+ })
139
+
140
+ test('relies on sensitive: true to keep the value out of the error', () => {
141
+ // Arrange
142
+ const value = 'too-short-secret'
143
+ const config = convict({
144
+ password: { format: 'secret', default: null }
145
+ }).load({ password: value })
146
+
147
+ // Act
148
+ let error
149
+ try {
150
+ config.validate({ allowed: 'strict' })
151
+ } catch (e) {
152
+ error = e
153
+ }
154
+
155
+ // Assert
156
+ expect(error.message).toContain(value)
157
+ })
158
+ })