@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.18.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",
@@ -1,6 +1,50 @@
1
1
  import path from 'node:path'
2
2
 
3
- import { getModuleById } from '@defra/lis-hubs-infra-registry'
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
+ })
@@ -1,3 +1,4 @@
1
1
  const govukRebrand = true
2
2
 
3
3
  export { govukRebrand }
4
+ export { PERMISSIONS } from '@defra/lis-hubs-infra-access/authorization'
@@ -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
  })