@defra/lis-infra-ui-services 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra/lis-infra-ui-services",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "UI-Services shared definitions for the livestock taxonomy topology.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -68,10 +68,12 @@
|
|
|
68
68
|
"undici": "7.24.8"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
|
+
"@defra/lis-hubs-infra-access": ">=0.29.0 <1.0.0",
|
|
71
72
|
"@defra/lis-hubs-infra-core": ">=0.4.0 <1.0.0",
|
|
72
73
|
"eslint": "^9.0.0"
|
|
73
74
|
},
|
|
74
75
|
"devDependencies": {
|
|
76
|
+
"@defra/lis-hubs-infra-access": "^0.29.0",
|
|
75
77
|
"@defra/lis-hubs-infra-core": "^0.12.0",
|
|
76
78
|
"@defra/lis-infra-eslint-config": "^0.6.0",
|
|
77
79
|
"@vitest/coverage-istanbul": "^4.1.10",
|
package/src/nunjucks/context.js
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { hasPermission } from '@defra/lis-hubs-infra-access/authorization'
|
|
4
|
+
import { HUBS, getModuleById } from '@defra/lis-hubs-infra-registry'
|
|
5
|
+
|
|
6
|
+
const HUB_SERVICE_NAME_PREFIX = 'lis-hubs-'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Both auth schemes put the hub's service name on credentials.caller - a
|
|
10
|
+
* spoke gets the hub that called it, a hub gets itself - so it is only
|
|
11
|
+
* known once the request has authenticated.
|
|
12
|
+
* @param {object} [request]
|
|
13
|
+
* @returns {string | null}
|
|
14
|
+
*/
|
|
15
|
+
function resolveHubId(request) {
|
|
16
|
+
const caller = request?.auth?.credentials?.caller
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
HUBS.find(
|
|
20
|
+
(candidateHubId) =>
|
|
21
|
+
caller === `${HUB_SERVICE_NAME_PREFIX}${candidateHubId}`
|
|
22
|
+
) ?? null
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the hasPermission() template helper for a request. It throws on a
|
|
28
|
+
* missing permission or cph because templates render undefined variables as
|
|
29
|
+
* empty, so a typo would otherwise silently hide the content it guards.
|
|
30
|
+
* @param {object} [request]
|
|
31
|
+
* @returns {(params: { permission: string, cph: string }) => boolean}
|
|
32
|
+
*/
|
|
33
|
+
function createPermissionCheck(request) {
|
|
34
|
+
return function hasPermissionForRequest({ permission, cph } = {}) {
|
|
35
|
+
if (typeof permission !== 'string' || permission.length === 0) {
|
|
36
|
+
throw new Error('hasPermission() requires a permission')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof cph !== 'string' || cph.length === 0) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"hasPermission() requires a cph - pass '*' where no holding applies"
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return hasPermission(request?.auth?.credentials?.user, { permission, cph })
|
|
46
|
+
}
|
|
47
|
+
}
|
|
4
48
|
|
|
5
49
|
/**
|
|
6
50
|
* @param {{ config: object, buildNavigation: Function, getRequestBasePath: Function, logger: object, readFileSync: Function, moduleId?: string }} options
|
|
@@ -38,6 +82,7 @@ export function createNunjucksContextBuilder({
|
|
|
38
82
|
|
|
39
83
|
const requestBasePath = getRequestBasePath(request)
|
|
40
84
|
const assetRoot = `${requestBasePath}${config.get('assetPath')}`
|
|
85
|
+
const requestHubId = resolveHubId(request)
|
|
41
86
|
|
|
42
87
|
return {
|
|
43
88
|
assetPath: `${assetRoot}/assets`,
|
|
@@ -47,6 +92,9 @@ export function createNunjucksContextBuilder({
|
|
|
47
92
|
breadcrumbs: [],
|
|
48
93
|
navigation: buildNavigation(request),
|
|
49
94
|
isSignedIn: Boolean(request?.auth?.credentials?.user),
|
|
95
|
+
isFrontOffice: requestHubId === 'front-office',
|
|
96
|
+
isBackOffice: requestHubId === 'back-office',
|
|
97
|
+
hasPermission: createPermissionCheck(request),
|
|
50
98
|
logoutUrl: '/signout',
|
|
51
99
|
getAssetPath(asset) {
|
|
52
100
|
const viteAssetPath = viteManifest?.[asset]?.file
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict'
|
|
2
|
-
import { test } from 'vitest'
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
3
|
|
|
4
4
|
import { createNunjucksContextBuilder } from './context.js'
|
|
5
5
|
|
|
@@ -207,3 +207,320 @@ test('createNunjucksContextBuilder marks the user signed out without credentials
|
|
|
207
207
|
|
|
208
208
|
assert.equal(context.isSignedIn, false)
|
|
209
209
|
})
|
|
210
|
+
|
|
211
|
+
describe('createNunjucksContextBuilder() hub flags', () => {
|
|
212
|
+
test('marks the request as front office when the caller is the front-office hub', () => {
|
|
213
|
+
// Arrange
|
|
214
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
215
|
+
config: createConfig(),
|
|
216
|
+
buildNavigation: () => [],
|
|
217
|
+
getRequestBasePath: () => '',
|
|
218
|
+
logger: {},
|
|
219
|
+
readFileSync: () => '{}',
|
|
220
|
+
moduleId: 'cattle-register'
|
|
221
|
+
})
|
|
222
|
+
const request = {
|
|
223
|
+
headers: {},
|
|
224
|
+
path: '/',
|
|
225
|
+
auth: { credentials: { user: null, caller: 'lis-hubs-front-office' } }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Act
|
|
229
|
+
const context = contextBuilder(request)
|
|
230
|
+
|
|
231
|
+
// Assert
|
|
232
|
+
expect(context.isFrontOffice).toBe(true)
|
|
233
|
+
expect(context.isBackOffice).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('marks the request as back office when the caller is the back-office hub', () => {
|
|
237
|
+
// Arrange
|
|
238
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
239
|
+
config: createConfig(),
|
|
240
|
+
buildNavigation: () => [],
|
|
241
|
+
getRequestBasePath: () => '',
|
|
242
|
+
logger: {},
|
|
243
|
+
readFileSync: () => '{}',
|
|
244
|
+
moduleId: 'cattle-register'
|
|
245
|
+
})
|
|
246
|
+
const request = {
|
|
247
|
+
headers: {},
|
|
248
|
+
path: '/',
|
|
249
|
+
auth: { credentials: { user: null, caller: 'lis-hubs-back-office' } }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Act
|
|
253
|
+
const context = contextBuilder(request)
|
|
254
|
+
|
|
255
|
+
// Assert
|
|
256
|
+
expect(context.isFrontOffice).toBe(false)
|
|
257
|
+
expect(context.isBackOffice).toBe(true)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
test('sets neither flag on a request with no credentials', () => {
|
|
261
|
+
// Arrange
|
|
262
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
263
|
+
config: createConfig(),
|
|
264
|
+
buildNavigation: () => [],
|
|
265
|
+
getRequestBasePath: () => '',
|
|
266
|
+
logger: {},
|
|
267
|
+
readFileSync: () => '{}',
|
|
268
|
+
moduleId: 'cattle-register'
|
|
269
|
+
})
|
|
270
|
+
const request = { headers: {}, path: '/', auth: { credentials: null } }
|
|
271
|
+
|
|
272
|
+
// Act
|
|
273
|
+
const context = contextBuilder(request)
|
|
274
|
+
|
|
275
|
+
// Assert
|
|
276
|
+
expect(context.isFrontOffice).toBe(false)
|
|
277
|
+
expect(context.isBackOffice).toBe(false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('sets neither flag for an unrecognised caller', () => {
|
|
281
|
+
// Arrange
|
|
282
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
283
|
+
config: createConfig(),
|
|
284
|
+
buildNavigation: () => [],
|
|
285
|
+
getRequestBasePath: () => '',
|
|
286
|
+
logger: {},
|
|
287
|
+
readFileSync: () => '{}',
|
|
288
|
+
moduleId: 'cattle-register'
|
|
289
|
+
})
|
|
290
|
+
const request = {
|
|
291
|
+
headers: {},
|
|
292
|
+
path: '/',
|
|
293
|
+
auth: { credentials: { user: null, caller: 'lis-hubs-other' } }
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Act
|
|
297
|
+
const context = contextBuilder(request)
|
|
298
|
+
|
|
299
|
+
// Assert
|
|
300
|
+
expect(context.isFrontOffice).toBe(false)
|
|
301
|
+
expect(context.isBackOffice).toBe(false)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
describe('createNunjucksContextBuilder() hasPermission()', () => {
|
|
306
|
+
test('grants a permission the user holds on the given cph', () => {
|
|
307
|
+
// Arrange
|
|
308
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
309
|
+
config: createConfig(),
|
|
310
|
+
buildNavigation: () => [],
|
|
311
|
+
getRequestBasePath: () => '',
|
|
312
|
+
logger: {},
|
|
313
|
+
readFileSync: () => '{}'
|
|
314
|
+
})
|
|
315
|
+
const user = {
|
|
316
|
+
statements: [
|
|
317
|
+
{
|
|
318
|
+
role: 'keeper',
|
|
319
|
+
cphs: ['12/345/0001'],
|
|
320
|
+
permissions: ['lis-perm-cattle-move-write']
|
|
321
|
+
}
|
|
322
|
+
]
|
|
323
|
+
}
|
|
324
|
+
const request = { headers: {}, path: '/', auth: { credentials: { user } } }
|
|
325
|
+
|
|
326
|
+
// Act
|
|
327
|
+
const result = contextBuilder(request).hasPermission({
|
|
328
|
+
permission: 'lis-perm-cattle-move-write',
|
|
329
|
+
cph: '12/345/0001'
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// Assert
|
|
333
|
+
expect(result).toBe(true)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('refuses a permission the user holds only on another cph', () => {
|
|
337
|
+
// Arrange
|
|
338
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
339
|
+
config: createConfig(),
|
|
340
|
+
buildNavigation: () => [],
|
|
341
|
+
getRequestBasePath: () => '',
|
|
342
|
+
logger: {},
|
|
343
|
+
readFileSync: () => '{}'
|
|
344
|
+
})
|
|
345
|
+
const user = {
|
|
346
|
+
statements: [
|
|
347
|
+
{
|
|
348
|
+
role: 'keeper',
|
|
349
|
+
cphs: ['12/345/0001'],
|
|
350
|
+
permissions: ['lis-perm-cattle-move-write']
|
|
351
|
+
}
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
const request = { headers: {}, path: '/', auth: { credentials: { user } } }
|
|
355
|
+
|
|
356
|
+
// Act
|
|
357
|
+
const result = contextBuilder(request).hasPermission({
|
|
358
|
+
permission: 'lis-perm-cattle-move-write',
|
|
359
|
+
cph: '98/765/0002'
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
// Assert
|
|
363
|
+
expect(result).toBe(false)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test("grants a permission held everywhere when cph is '*'", () => {
|
|
367
|
+
// Arrange
|
|
368
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
369
|
+
config: createConfig(),
|
|
370
|
+
buildNavigation: () => [],
|
|
371
|
+
getRequestBasePath: () => '',
|
|
372
|
+
logger: {},
|
|
373
|
+
readFileSync: () => '{}'
|
|
374
|
+
})
|
|
375
|
+
const user = {
|
|
376
|
+
statements: [
|
|
377
|
+
{
|
|
378
|
+
role: 'caseworker',
|
|
379
|
+
cphs: '*',
|
|
380
|
+
permissions: ['lis-perm-back-office']
|
|
381
|
+
}
|
|
382
|
+
]
|
|
383
|
+
}
|
|
384
|
+
const request = { headers: {}, path: '/', auth: { credentials: { user } } }
|
|
385
|
+
|
|
386
|
+
// Act
|
|
387
|
+
const result = contextBuilder(request).hasPermission({
|
|
388
|
+
permission: 'lis-perm-back-office',
|
|
389
|
+
cph: '*'
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
// Assert
|
|
393
|
+
expect(result).toBe(true)
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
test("refuses a permission held only on specific cphs when cph is '*'", () => {
|
|
397
|
+
// Arrange
|
|
398
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
399
|
+
config: createConfig(),
|
|
400
|
+
buildNavigation: () => [],
|
|
401
|
+
getRequestBasePath: () => '',
|
|
402
|
+
logger: {},
|
|
403
|
+
readFileSync: () => '{}'
|
|
404
|
+
})
|
|
405
|
+
const user = {
|
|
406
|
+
statements: [
|
|
407
|
+
{
|
|
408
|
+
role: 'keeper',
|
|
409
|
+
cphs: ['12/345/0001'],
|
|
410
|
+
permissions: ['lis-perm-cattle-move-write']
|
|
411
|
+
}
|
|
412
|
+
]
|
|
413
|
+
}
|
|
414
|
+
const request = { headers: {}, path: '/', auth: { credentials: { user } } }
|
|
415
|
+
|
|
416
|
+
// Act
|
|
417
|
+
const result = contextBuilder(request).hasPermission({
|
|
418
|
+
permission: 'lis-perm-cattle-move-write',
|
|
419
|
+
cph: '*'
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
// Assert
|
|
423
|
+
expect(result).toBe(false)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('refuses every permission when the user is signed out', () => {
|
|
427
|
+
// Arrange
|
|
428
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
429
|
+
config: createConfig(),
|
|
430
|
+
buildNavigation: () => [],
|
|
431
|
+
getRequestBasePath: () => '',
|
|
432
|
+
logger: {},
|
|
433
|
+
readFileSync: () => '{}'
|
|
434
|
+
})
|
|
435
|
+
const request = { headers: {}, path: '/', auth: { credentials: null } }
|
|
436
|
+
|
|
437
|
+
// Act
|
|
438
|
+
const result = contextBuilder(request).hasPermission({
|
|
439
|
+
permission: 'lis-perm-cattle-move-write',
|
|
440
|
+
cph: '12/345/0001'
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
// Assert
|
|
444
|
+
expect(result).toBe(false)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('throws when no cph is given', () => {
|
|
448
|
+
// Arrange
|
|
449
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
450
|
+
config: createConfig(),
|
|
451
|
+
buildNavigation: () => [],
|
|
452
|
+
getRequestBasePath: () => '',
|
|
453
|
+
logger: {},
|
|
454
|
+
readFileSync: () => '{}'
|
|
455
|
+
})
|
|
456
|
+
const request = { headers: {}, path: '/', auth: { credentials: null } }
|
|
457
|
+
|
|
458
|
+
// Act
|
|
459
|
+
let result, error
|
|
460
|
+
try {
|
|
461
|
+
result = contextBuilder(request).hasPermission({
|
|
462
|
+
permission: 'lis-perm-cattle-move-write',
|
|
463
|
+
cph: undefined
|
|
464
|
+
})
|
|
465
|
+
} catch (e) {
|
|
466
|
+
error = e
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Assert
|
|
470
|
+
expect(result).toBeUndefined()
|
|
471
|
+
expect(error.message).toBe(
|
|
472
|
+
"hasPermission() requires a cph - pass '*' where no holding applies"
|
|
473
|
+
)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
test('throws when no permission is given', () => {
|
|
477
|
+
// Arrange
|
|
478
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
479
|
+
config: createConfig(),
|
|
480
|
+
buildNavigation: () => [],
|
|
481
|
+
getRequestBasePath: () => '',
|
|
482
|
+
logger: {},
|
|
483
|
+
readFileSync: () => '{}'
|
|
484
|
+
})
|
|
485
|
+
const request = { headers: {}, path: '/', auth: { credentials: null } }
|
|
486
|
+
|
|
487
|
+
// Act
|
|
488
|
+
let result, error
|
|
489
|
+
try {
|
|
490
|
+
result = contextBuilder(request).hasPermission({
|
|
491
|
+
permission: undefined,
|
|
492
|
+
cph: '12/345/0001'
|
|
493
|
+
})
|
|
494
|
+
} catch (e) {
|
|
495
|
+
error = e
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Assert
|
|
499
|
+
expect(result).toBeUndefined()
|
|
500
|
+
expect(error.message).toBe('hasPermission() requires a permission')
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test('throws when called with no arguments', () => {
|
|
504
|
+
// Arrange
|
|
505
|
+
const contextBuilder = createNunjucksContextBuilder({
|
|
506
|
+
config: createConfig(),
|
|
507
|
+
buildNavigation: () => [],
|
|
508
|
+
getRequestBasePath: () => '',
|
|
509
|
+
logger: {},
|
|
510
|
+
readFileSync: () => '{}'
|
|
511
|
+
})
|
|
512
|
+
const request = { headers: {}, path: '/', auth: { credentials: null } }
|
|
513
|
+
|
|
514
|
+
// Act
|
|
515
|
+
let result, error
|
|
516
|
+
try {
|
|
517
|
+
result = contextBuilder(request).hasPermission()
|
|
518
|
+
} catch (e) {
|
|
519
|
+
error = e
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Assert
|
|
523
|
+
expect(result).toBeUndefined()
|
|
524
|
+
expect(error.message).toBe('hasPermission() requires a permission')
|
|
525
|
+
})
|
|
526
|
+
})
|
package/src/nunjucks/globals.js
CHANGED
|
@@ -85,4 +85,29 @@ describe('createNunjucksConfig()', () => {
|
|
|
85
85
|
expect(mocks.getBasePathForModule).not.toHaveBeenCalled()
|
|
86
86
|
expect(nunjucksConfig.plugin).toBeDefined()
|
|
87
87
|
})
|
|
88
|
+
|
|
89
|
+
test('exposes PERMISSIONS to templates as a global', () => {
|
|
90
|
+
// Arrange
|
|
91
|
+
const addGlobal = vi.fn()
|
|
92
|
+
mocks.nunjucksConfigure.mockReturnValueOnce({
|
|
93
|
+
addGlobal,
|
|
94
|
+
addFilter: vi.fn()
|
|
95
|
+
})
|
|
96
|
+
const config = {
|
|
97
|
+
get: vi.fn((key) => (key === 'root' ? projectRoot : false))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Act
|
|
101
|
+
createNunjucksConfig({
|
|
102
|
+
config,
|
|
103
|
+
logger: {},
|
|
104
|
+
getRequestBasePath: vi.fn()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
108
|
+
expect(addGlobal).toHaveBeenCalledWith(
|
|
109
|
+
'PERMISSIONS',
|
|
110
|
+
expect.objectContaining({ backOffice: 'lis-perm-back-office' })
|
|
111
|
+
)
|
|
112
|
+
})
|
|
88
113
|
})
|