adapt-authoring-roles 1.1.4 → 1.3.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,15 @@
1
+ name: Tests
2
+ on: push
3
+ jobs:
4
+ default:
5
+ runs-on: ubuntu-latest
6
+ permissions:
7
+ contents: read
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-node@v4
11
+ with:
12
+ node-version: 'lts/*'
13
+ cache: 'npm'
14
+ - run: npm ci
15
+ - run: npm test
@@ -46,8 +46,6 @@
46
46
  "displayName": "Content creator",
47
47
  "extends": "authuser",
48
48
  "scopes": [
49
- "export:adapt",
50
- "import:adapt",
51
49
  "preview:adapt",
52
50
  "publish:adapt",
53
51
  "read:assets",
package/package.json CHANGED
@@ -1,35 +1,14 @@
1
1
  {
2
2
  "name": "adapt-authoring-roles",
3
- "version": "1.1.4",
3
+ "version": "1.3.0",
4
4
  "description": "Module for managing user roles",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-roles",
6
6
  "license": "GPL-3.0",
7
7
  "type": "module",
8
8
  "main": "index.js",
9
9
  "repository": "github:adapt-security/adapt-authoring-roles",
10
- "peerDependencies": {
11
- "adapt-authoring-auth": "^1.0.5",
12
- "adapt-authoring-auth-local": "^1.0.3",
13
- "adapt-authoring-core": "^1.7.0",
14
- "adapt-authoring-mongodb": "^1.1.3",
15
- "adapt-authoring-users": "^1.0.2"
16
- },
17
- "peerDependenciesMeta": {
18
- "adapt-authoring-auth": {
19
- "optional": true
20
- },
21
- "adapt-authoring-auth-local": {
22
- "optional": true
23
- },
24
- "adapt-authoring-core": {
25
- "optional": true
26
- },
27
- "adapt-authoring-mongodb": {
28
- "optional": true
29
- },
30
- "adapt-authoring-users": {
31
- "optional": true
32
- }
10
+ "scripts": {
11
+ "test": "node --test 'tests/**/*.spec.js'"
33
12
  },
34
13
  "devDependencies": {
35
14
  "@semantic-release/git": "^10.0.1",
@@ -63,5 +42,32 @@
63
42
  }
64
43
  ]
65
44
  ]
45
+ },
46
+ "dependencies": {
47
+ "adapt-authoring-api": "^1.3.2"
48
+ },
49
+ "peerDependencies": {
50
+ "adapt-authoring-auth": "^1.0.7",
51
+ "adapt-authoring-auth-local": "^1.0.3",
52
+ "adapt-authoring-core": "^1.7.0",
53
+ "adapt-authoring-mongodb": "^1.1.3",
54
+ "adapt-authoring-users": "^1.0.2"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "adapt-authoring-auth": {
58
+ "optional": true
59
+ },
60
+ "adapt-authoring-auth-local": {
61
+ "optional": true
62
+ },
63
+ "adapt-authoring-core": {
64
+ "optional": true
65
+ },
66
+ "adapt-authoring-mongodb": {
67
+ "optional": true
68
+ },
69
+ "adapt-authoring-users": {
70
+ "optional": true
71
+ }
66
72
  }
67
73
  }
@@ -0,0 +1,1000 @@
1
+ import { describe, it, mock } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ /**
5
+ * RolesModule extends AbstractApiModule (extends AbstractModule) which
6
+ * requires a full App instance. We replicate each public method and
7
+ * build lightweight stubs for every dependency so we can exercise the
8
+ * logic in isolation.
9
+ */
10
+
11
+ // ── Helpers ──────────────────────────────────────────────────────────
12
+
13
+ /** Build a minimal RolesModule-like instance with sensible stub defaults */
14
+ function createInstance (overrides) {
15
+ const instance = {
16
+ root: undefined,
17
+ schemaName: undefined,
18
+ collectionName: undefined,
19
+ useDefaultRouteConfig: mock.fn(),
20
+ app: {
21
+ waitForModule: mock.fn(async () => ({})),
22
+ errors: {
23
+ UNAUTHORISED: Object.assign(new Error('UNAUTHORISED'), {
24
+ code: 'UNAUTHORISED',
25
+ setData (d) { this.data = d; return this }
26
+ })
27
+ }
28
+ },
29
+ getConfig: mock.fn((key) => {
30
+ const defaults = {
31
+ roleDefinitions: [],
32
+ defaultRoles: [],
33
+ defaultRolesForAuthTypes: {}
34
+ }
35
+ return defaults[key]
36
+ }),
37
+ log: mock.fn(),
38
+ find: mock.fn(async () => []),
39
+ insert: mock.fn(async (data) => data),
40
+ cache: { isEnabled: false },
41
+ ...overrides
42
+ }
43
+ return instance
44
+ }
45
+
46
+ // ── Method references (copied from source for isolated testing) ─────
47
+
48
+ async function setValues () {
49
+ this.root = 'roles'
50
+ this.schemaName = 'role'
51
+ this.collectionName = 'roles'
52
+ this.useDefaultRouteConfig()
53
+ }
54
+
55
+ async function getScopesForRole (_id) {
56
+ const allRoles = await this.find()
57
+ const scopes = []
58
+ let role = allRoles.find(r => r._id.toString() === _id.toString())
59
+ do {
60
+ scopes.push(...role.scopes)
61
+ role = allRoles.find(r => r.shortName === role.extends)
62
+ } while (role)
63
+ return scopes
64
+ }
65
+
66
+ async function shortNamesToIds (roles) {
67
+ return Promise.all(roles.map(async r => {
68
+ const [role] = await this.find({ shortName: r })
69
+ return role._id.toString()
70
+ }))
71
+ }
72
+
73
+ async function getSuperRoleId () {
74
+ const [superRole] = await this.find({ scopes: ['*:*'] })
75
+ return superRole._id.toString()
76
+ }
77
+
78
+ async function isTargetSuper (_id) {
79
+ const users = await this.app.waitForModule('users')
80
+ const [user] = await users.find({ _id }, { projection: { roles: 1 } })
81
+ return user.roles.length === 1 &&
82
+ user.roles[0].toString() === await this.getSuperRoleId()
83
+ }
84
+
85
+ async function onUpdateRoles (req) {
86
+ if (req.apiData?.modifying !== false ||
87
+ (req.method !== 'DELETE' && !req.apiData?.data.roles)) {
88
+ return
89
+ }
90
+ if (!req.auth.isSuper) {
91
+ const reject = reason => {
92
+ this.log('error', 'UNAUTHORISED', req.auth.user._id.toString(), reason)
93
+ throw this.app.errors.UNAUTHORISED
94
+ }
95
+ if (!req.auth.scopes.includes('assign:roles')) {
96
+ reject('assign role')
97
+ }
98
+ if (req.apiData.data.roles.includes(await this.getSuperRoleId())) {
99
+ reject('assign superuser')
100
+ }
101
+ if (await this.isTargetSuper(req.apiData.query._id)) {
102
+ reject('modify superuser')
103
+ }
104
+ }
105
+ if (req.method !== 'POST') {
106
+ const auth = await this.app.waitForModule('auth')
107
+ await auth.authentication.disavowUser({
108
+ userId: req.params._id || req.body._id
109
+ })
110
+ }
111
+ }
112
+
113
+ async function onCheckUserAccess (req) {
114
+ if (req.apiData.modifying &&
115
+ await this.isTargetSuper(req.apiData.query._id)) {
116
+ throw this.app.errors.UNAUTHORISED
117
+ }
118
+ return true
119
+ }
120
+
121
+ async function initConfigRoles () {
122
+ const mongodb = await this.app.waitForModule('mongodb')
123
+ return Promise.allSettled(
124
+ this.getConfig('roleDefinitions').map(async r => {
125
+ const [doc] = await this.find({ shortName: r.shortName })
126
+ if (doc) {
127
+ try {
128
+ await mongodb.replace(this.collectionName, { _id: doc._id }, r)
129
+ this.log('debug', 'REPLACE', this.schemaName, r.shortName)
130
+ } catch (e) {
131
+ if (e.code !== 11000) {
132
+ this.log('warn',
133
+ `failed to update '${r.shortName}' role, ${e.message}`)
134
+ }
135
+ }
136
+ return
137
+ }
138
+ try {
139
+ await this.insert(r)
140
+ this.log('debug', 'INSERT', this.schemaName, r.shortName)
141
+ } catch (e) {
142
+ if (e.code !== 11000) {
143
+ this.log('warn',
144
+ `failed to add '${r.shortName}' role, ${e.message}`)
145
+ }
146
+ }
147
+ })
148
+ )
149
+ }
150
+
151
+ async function initDefaultRoles () {
152
+ const rolesforAll = await this.shortNamesToIds(
153
+ this.getConfig('defaultRoles')
154
+ )
155
+ const rolesForAuth = Object.entries(
156
+ this.getConfig('defaultRolesForAuthTypes')
157
+ ).reduce((m, [k, v]) => {
158
+ return { [m[k]]: this.shortNamesToIds(v) }
159
+ }, {})
160
+ const users = await this.app.waitForModule('users')
161
+ users.preInsertHook.tap(data => {
162
+ if (!data.roles || !data.roles.length) {
163
+ data.roles = rolesForAuth[data.authType] || rolesforAll || []
164
+ }
165
+ })
166
+ }
167
+
168
+ // ── Tests ────────────────────────────────────────────────────────────
169
+
170
+ describe('RolesModule', () => {
171
+ // ── setValues ──────────────────────────────────────────────────────
172
+
173
+ describe('setValues', () => {
174
+ it('should set root to "roles"', async () => {
175
+ const inst = createInstance()
176
+ await setValues.call(inst)
177
+ assert.equal(inst.root, 'roles')
178
+ })
179
+
180
+ it('should set schemaName to "role"', async () => {
181
+ const inst = createInstance()
182
+ await setValues.call(inst)
183
+ assert.equal(inst.schemaName, 'role')
184
+ })
185
+
186
+ it('should set collectionName to "roles"', async () => {
187
+ const inst = createInstance()
188
+ await setValues.call(inst)
189
+ assert.equal(inst.collectionName, 'roles')
190
+ })
191
+
192
+ it('should call useDefaultRouteConfig', async () => {
193
+ const inst = createInstance()
194
+ await setValues.call(inst)
195
+ assert.equal(inst.useDefaultRouteConfig.mock.callCount(), 1)
196
+ })
197
+ })
198
+
199
+ // ── getScopesForRole ───────────────────────────────────────────────
200
+
201
+ describe('getScopesForRole', () => {
202
+ it('should return scopes for a single role', async () => {
203
+ const inst = createInstance({
204
+ find: mock.fn(async () => [
205
+ { _id: 'role1', shortName: 'admin', scopes: ['read:all', 'write:all'] }
206
+ ])
207
+ })
208
+ const result = await getScopesForRole.call(inst, 'role1')
209
+ assert.deepEqual(result, ['read:all', 'write:all'])
210
+ })
211
+
212
+ it('should accumulate scopes through role inheritance', async () => {
213
+ const inst = createInstance({
214
+ find: mock.fn(async () => [
215
+ { _id: 'role1', shortName: 'authuser', scopes: ['read:me'] },
216
+ {
217
+ _id: 'role2',
218
+ shortName: 'editor',
219
+ scopes: ['write:content'],
220
+ extends: 'authuser'
221
+ }
222
+ ])
223
+ })
224
+ const result = await getScopesForRole.call(inst, 'role2')
225
+ assert.deepEqual(result, ['write:content', 'read:me'])
226
+ })
227
+
228
+ it('should handle deep inheritance chains', async () => {
229
+ const inst = createInstance({
230
+ find: mock.fn(async () => [
231
+ { _id: 'r1', shortName: 'base', scopes: ['scope:a'] },
232
+ { _id: 'r2', shortName: 'mid', scopes: ['scope:b'], extends: 'base' },
233
+ { _id: 'r3', shortName: 'top', scopes: ['scope:c'], extends: 'mid' }
234
+ ])
235
+ })
236
+ const result = await getScopesForRole.call(inst, 'r3')
237
+ assert.deepEqual(result, ['scope:c', 'scope:b', 'scope:a'])
238
+ })
239
+
240
+ it('should handle ObjectId-like objects with toString', async () => {
241
+ const objectId = { toString: () => 'abc123' }
242
+ const inst = createInstance({
243
+ find: mock.fn(async () => [
244
+ {
245
+ _id: { toString: () => 'abc123' },
246
+ shortName: 'admin',
247
+ scopes: ['*:*']
248
+ }
249
+ ])
250
+ })
251
+ const result = await getScopesForRole.call(inst, objectId)
252
+ assert.deepEqual(result, ['*:*'])
253
+ })
254
+
255
+ it('should throw if role _id is not found', async () => {
256
+ const inst = createInstance({
257
+ find: mock.fn(async () => [
258
+ { _id: 'role1', shortName: 'admin', scopes: ['read:all'] }
259
+ ])
260
+ })
261
+ await assert.rejects(
262
+ async () => getScopesForRole.call(inst, 'nonexistent'),
263
+ TypeError
264
+ )
265
+ })
266
+ })
267
+
268
+ // ── shortNamesToIds ────────────────────────────────────────────────
269
+
270
+ describe('shortNamesToIds', () => {
271
+ it('should resolve a single role short name to its id', async () => {
272
+ const inst = createInstance({
273
+ find: mock.fn(async () => [{ _id: 'id1', shortName: 'admin' }])
274
+ })
275
+ const result = await shortNamesToIds.call(inst, ['admin'])
276
+ assert.deepEqual(result, ['id1'])
277
+ })
278
+
279
+ it('should resolve multiple role short names to ids', async () => {
280
+ const findMock = mock.fn(async (query) => {
281
+ const map = {
282
+ admin: [{ _id: 'id1', shortName: 'admin' }],
283
+ editor: [{ _id: 'id2', shortName: 'editor' }]
284
+ }
285
+ return map[query.shortName] || []
286
+ })
287
+ const inst = createInstance({ find: findMock })
288
+ const result = await shortNamesToIds.call(inst, ['admin', 'editor'])
289
+ assert.deepEqual(result, ['id1', 'id2'])
290
+ })
291
+
292
+ it('should call toString on _id values', async () => {
293
+ const toStringMock = mock.fn(() => 'stringified')
294
+ const inst = createInstance({
295
+ find: mock.fn(async () => [
296
+ { _id: { toString: toStringMock }, shortName: 'x' }
297
+ ])
298
+ })
299
+ const result = await shortNamesToIds.call(inst, ['x'])
300
+ assert.equal(toStringMock.mock.callCount(), 1)
301
+ assert.deepEqual(result, ['stringified'])
302
+ })
303
+
304
+ it('should return empty array for empty input', async () => {
305
+ const inst = createInstance()
306
+ const result = await shortNamesToIds.call(inst, [])
307
+ assert.deepEqual(result, [])
308
+ })
309
+
310
+ it('should throw if role is not found', async () => {
311
+ const inst = createInstance({
312
+ find: mock.fn(async () => [])
313
+ })
314
+ await assert.rejects(
315
+ async () => shortNamesToIds.call(inst, ['missing']),
316
+ TypeError
317
+ )
318
+ })
319
+ })
320
+
321
+ // ── getSuperRoleId ─────────────────────────────────────────────────
322
+
323
+ describe('getSuperRoleId', () => {
324
+ it('should return the id of the super role', async () => {
325
+ const inst = createInstance({
326
+ find: mock.fn(async () => [{ _id: 'super1', scopes: ['*:*'] }])
327
+ })
328
+ const result = await getSuperRoleId.call(inst)
329
+ assert.equal(result, 'super1')
330
+ })
331
+
332
+ it('should query for scopes ["*:*"]', async () => {
333
+ const findMock = mock.fn(async () => [{ _id: 'x', scopes: ['*:*'] }])
334
+ const inst = createInstance({ find: findMock })
335
+ await getSuperRoleId.call(inst)
336
+ assert.deepEqual(findMock.mock.calls[0].arguments[0], { scopes: ['*:*'] })
337
+ })
338
+
339
+ it('should throw if no super role exists', async () => {
340
+ const inst = createInstance({
341
+ find: mock.fn(async () => [])
342
+ })
343
+ await assert.rejects(
344
+ async () => getSuperRoleId.call(inst),
345
+ TypeError
346
+ )
347
+ })
348
+ })
349
+
350
+ // ── isTargetSuper ──────────────────────────────────────────────────
351
+
352
+ describe('isTargetSuper', () => {
353
+ it('should return true if user has only the super role', async () => {
354
+ const usersModule = {
355
+ find: mock.fn(async () => [{ roles: ['super1'] }])
356
+ }
357
+ const inst = createInstance({
358
+ app: {
359
+ waitForModule: mock.fn(async () => usersModule),
360
+ errors: {}
361
+ },
362
+ getSuperRoleId: mock.fn(async () => 'super1')
363
+ })
364
+ const result = await isTargetSuper.call(inst, 'user1')
365
+ assert.equal(result, true)
366
+ })
367
+
368
+ it('should return false if user has multiple roles', async () => {
369
+ const usersModule = {
370
+ find: mock.fn(async () => [{ roles: ['super1', 'other'] }])
371
+ }
372
+ const inst = createInstance({
373
+ app: {
374
+ waitForModule: mock.fn(async () => usersModule),
375
+ errors: {}
376
+ },
377
+ getSuperRoleId: mock.fn(async () => 'super1')
378
+ })
379
+ const result = await isTargetSuper.call(inst, 'user1')
380
+ assert.equal(result, false)
381
+ })
382
+
383
+ it('should return false if user has a non-super role', async () => {
384
+ const usersModule = {
385
+ find: mock.fn(async () => [{ roles: ['regular1'] }])
386
+ }
387
+ const inst = createInstance({
388
+ app: {
389
+ waitForModule: mock.fn(async () => usersModule),
390
+ errors: {}
391
+ },
392
+ getSuperRoleId: mock.fn(async () => 'super1')
393
+ })
394
+ const result = await isTargetSuper.call(inst, 'user1')
395
+ assert.equal(result, false)
396
+ })
397
+
398
+ it('should return false if user has no roles', async () => {
399
+ const usersModule = {
400
+ find: mock.fn(async () => [{ roles: [] }])
401
+ }
402
+ const inst = createInstance({
403
+ app: {
404
+ waitForModule: mock.fn(async () => usersModule),
405
+ errors: {}
406
+ },
407
+ getSuperRoleId: mock.fn(async () => 'super1')
408
+ })
409
+ const result = await isTargetSuper.call(inst, 'user1')
410
+ assert.equal(result, false)
411
+ })
412
+
413
+ it('should pass projection for roles only', async () => {
414
+ const usersModule = {
415
+ find: mock.fn(async () => [{ roles: ['super1'] }])
416
+ }
417
+ const inst = createInstance({
418
+ app: {
419
+ waitForModule: mock.fn(async () => usersModule),
420
+ errors: {}
421
+ },
422
+ getSuperRoleId: mock.fn(async () => 'super1')
423
+ })
424
+ await isTargetSuper.call(inst, 'user1')
425
+ const findArgs = usersModule.find.mock.calls[0].arguments
426
+ assert.deepEqual(findArgs[1], { projection: { roles: 1 } })
427
+ })
428
+ })
429
+
430
+ // ── onUpdateRoles ──────────────────────────────────────────────────
431
+
432
+ describe('onUpdateRoles', () => {
433
+ function createReq (overrides) {
434
+ return {
435
+ method: 'PUT',
436
+ params: {},
437
+ body: {},
438
+ auth: {
439
+ isSuper: true,
440
+ scopes: [],
441
+ user: { _id: { toString: () => 'user1' } }
442
+ },
443
+ apiData: {
444
+ modifying: false,
445
+ data: { roles: ['role1'] },
446
+ query: { _id: 'target1' }
447
+ },
448
+ ...overrides
449
+ }
450
+ }
451
+
452
+ it('should return early if modifying is not false', async () => {
453
+ const inst = createInstance()
454
+ const req = createReq({
455
+ apiData: { modifying: true, data: { roles: ['r1'] }, query: {} }
456
+ })
457
+ const result = await onUpdateRoles.call(inst, req)
458
+ assert.equal(result, undefined)
459
+ })
460
+
461
+ it('should return early if apiData is undefined', async () => {
462
+ const inst = createInstance()
463
+ const req = { method: 'PUT', apiData: undefined }
464
+ const result = await onUpdateRoles.call(inst, req)
465
+ assert.equal(result, undefined)
466
+ })
467
+
468
+ it('should return early if not DELETE and no roles in data', async () => {
469
+ const inst = createInstance()
470
+ const req = createReq({
471
+ method: 'PUT',
472
+ apiData: { modifying: false, data: {}, query: {} }
473
+ })
474
+ const result = await onUpdateRoles.call(inst, req)
475
+ assert.equal(result, undefined)
476
+ })
477
+
478
+ it('should not return early for DELETE even without roles', async () => {
479
+ const disavowMock = mock.fn(async () => {})
480
+ const authModule = { authentication: { disavowUser: disavowMock } }
481
+ const inst = createInstance({
482
+ app: {
483
+ waitForModule: mock.fn(async () => authModule),
484
+ errors: { UNAUTHORISED: new Error('UNAUTHORISED') }
485
+ }
486
+ })
487
+ const req = createReq({
488
+ method: 'DELETE',
489
+ apiData: { modifying: false, data: {}, query: { _id: 'target1' } },
490
+ auth: {
491
+ isSuper: true,
492
+ scopes: [],
493
+ user: { _id: { toString: () => 'u1' } }
494
+ }
495
+ })
496
+ await onUpdateRoles.call(inst, req)
497
+ assert.equal(disavowMock.mock.callCount(), 1)
498
+ })
499
+
500
+ it('should skip auth checks for super users', async () => {
501
+ const disavowMock = mock.fn(async () => {})
502
+ const authModule = { authentication: { disavowUser: disavowMock } }
503
+ const inst = createInstance({
504
+ app: {
505
+ waitForModule: mock.fn(async () => authModule),
506
+ errors: { UNAUTHORISED: new Error('UNAUTHORISED') }
507
+ }
508
+ })
509
+ const req = createReq({
510
+ auth: {
511
+ isSuper: true,
512
+ scopes: [],
513
+ user: { _id: { toString: () => 'u1' } }
514
+ }
515
+ })
516
+ await onUpdateRoles.call(inst, req)
517
+ assert.equal(disavowMock.mock.callCount(), 1)
518
+ })
519
+
520
+ it('should throw if non-super user lacks assign:roles scope', async () => {
521
+ const inst = createInstance({
522
+ getSuperRoleId: mock.fn(async () => 'super1'),
523
+ isTargetSuper: mock.fn(async () => false)
524
+ })
525
+ const req = createReq({
526
+ auth: {
527
+ isSuper: false,
528
+ scopes: ['read:roles'],
529
+ user: { _id: { toString: () => 'u1' } }
530
+ }
531
+ })
532
+ await assert.rejects(async () => onUpdateRoles.call(inst, req))
533
+ })
534
+
535
+ it('should throw if assigning super role', async () => {
536
+ const inst = createInstance({
537
+ getSuperRoleId: mock.fn(async () => 'super1'),
538
+ isTargetSuper: mock.fn(async () => false)
539
+ })
540
+ const req = createReq({
541
+ auth: {
542
+ isSuper: false,
543
+ scopes: ['assign:roles'],
544
+ user: { _id: { toString: () => 'u1' } }
545
+ },
546
+ apiData: {
547
+ modifying: false,
548
+ data: { roles: ['super1'] },
549
+ query: { _id: 'target1' }
550
+ }
551
+ })
552
+ await assert.rejects(async () => onUpdateRoles.call(inst, req))
553
+ })
554
+
555
+ it('should throw if modifying a super user', async () => {
556
+ const inst = createInstance({
557
+ getSuperRoleId: mock.fn(async () => 'super1'),
558
+ isTargetSuper: mock.fn(async () => true)
559
+ })
560
+ const req = createReq({
561
+ auth: {
562
+ isSuper: false,
563
+ scopes: ['assign:roles'],
564
+ user: { _id: { toString: () => 'u1' } }
565
+ },
566
+ apiData: {
567
+ modifying: false,
568
+ data: { roles: ['regular1'] },
569
+ query: { _id: 'target1' }
570
+ }
571
+ })
572
+ await assert.rejects(async () => onUpdateRoles.call(inst, req))
573
+ })
574
+
575
+ it('should disavow user for non-POST methods', async () => {
576
+ const disavowMock = mock.fn(async () => {})
577
+ const authModule = { authentication: { disavowUser: disavowMock } }
578
+ const inst = createInstance({
579
+ app: {
580
+ waitForModule: mock.fn(async () => authModule),
581
+ errors: { UNAUTHORISED: new Error('UNAUTHORISED') }
582
+ }
583
+ })
584
+ const req = createReq({
585
+ method: 'PUT',
586
+ params: { _id: 'param-id' },
587
+ auth: {
588
+ isSuper: true,
589
+ scopes: [],
590
+ user: { _id: { toString: () => 'u1' } }
591
+ }
592
+ })
593
+ await onUpdateRoles.call(inst, req)
594
+ assert.deepEqual(
595
+ disavowMock.mock.calls[0].arguments[0],
596
+ { userId: 'param-id' }
597
+ )
598
+ })
599
+
600
+ it('should use body._id if params._id is not set', async () => {
601
+ const disavowMock = mock.fn(async () => {})
602
+ const authModule = { authentication: { disavowUser: disavowMock } }
603
+ const inst = createInstance({
604
+ app: {
605
+ waitForModule: mock.fn(async () => authModule),
606
+ errors: { UNAUTHORISED: new Error('UNAUTHORISED') }
607
+ }
608
+ })
609
+ const req = createReq({
610
+ method: 'PUT',
611
+ params: {},
612
+ body: { _id: 'body-id' },
613
+ auth: {
614
+ isSuper: true,
615
+ scopes: [],
616
+ user: { _id: { toString: () => 'u1' } }
617
+ }
618
+ })
619
+ await onUpdateRoles.call(inst, req)
620
+ assert.deepEqual(
621
+ disavowMock.mock.calls[0].arguments[0],
622
+ { userId: 'body-id' }
623
+ )
624
+ })
625
+
626
+ it('should not disavow user for POST method', async () => {
627
+ const disavowMock = mock.fn(async () => {})
628
+ const authModule = { authentication: { disavowUser: disavowMock } }
629
+ const inst = createInstance({
630
+ app: {
631
+ waitForModule: mock.fn(async () => authModule),
632
+ errors: { UNAUTHORISED: new Error('UNAUTHORISED') }
633
+ }
634
+ })
635
+ const req = createReq({
636
+ method: 'POST',
637
+ auth: {
638
+ isSuper: true,
639
+ scopes: [],
640
+ user: { _id: { toString: () => 'u1' } }
641
+ }
642
+ })
643
+ await onUpdateRoles.call(inst, req)
644
+ assert.equal(disavowMock.mock.callCount(), 0)
645
+ })
646
+
647
+ it('should log the unauthorised attempt', async () => {
648
+ const inst = createInstance({
649
+ getSuperRoleId: mock.fn(async () => 'super1'),
650
+ isTargetSuper: mock.fn(async () => false)
651
+ })
652
+ const req = createReq({
653
+ auth: {
654
+ isSuper: false,
655
+ scopes: [],
656
+ user: { _id: { toString: () => 'u1' } }
657
+ }
658
+ })
659
+ try { await onUpdateRoles.call(inst, req) } catch (e) {}
660
+ assert.equal(inst.log.mock.callCount(), 1)
661
+ assert.equal(inst.log.mock.calls[0].arguments[0], 'error')
662
+ assert.equal(inst.log.mock.calls[0].arguments[1], 'UNAUTHORISED')
663
+ })
664
+ })
665
+
666
+ // ── onCheckUserAccess ──────────────────────────────────────────────
667
+
668
+ describe('onCheckUserAccess', () => {
669
+ it('should return true for non-modifying requests', async () => {
670
+ const inst = createInstance({
671
+ isTargetSuper: mock.fn(async () => true)
672
+ })
673
+ const req = { apiData: { modifying: false, query: { _id: 'x' } } }
674
+ const result = await onCheckUserAccess.call(inst, req)
675
+ assert.equal(result, true)
676
+ })
677
+
678
+ it('should return true for non-super targets', async () => {
679
+ const inst = createInstance({
680
+ isTargetSuper: mock.fn(async () => false)
681
+ })
682
+ const req = { apiData: { modifying: true, query: { _id: 'x' } } }
683
+ const result = await onCheckUserAccess.call(inst, req)
684
+ assert.equal(result, true)
685
+ })
686
+
687
+ it('should throw for modifying requests targeting super users', async () => {
688
+ const inst = createInstance({
689
+ isTargetSuper: mock.fn(async () => true)
690
+ })
691
+ const req = { apiData: { modifying: true, query: { _id: 'x' } } }
692
+ await assert.rejects(
693
+ async () => onCheckUserAccess.call(inst, req)
694
+ )
695
+ })
696
+
697
+ it('should not call isTargetSuper for non-modifying requests', async () => {
698
+ const isTargetSuperMock = mock.fn(async () => true)
699
+ const inst = createInstance({ isTargetSuper: isTargetSuperMock })
700
+ const req = { apiData: { modifying: false, query: { _id: 'x' } } }
701
+ await onCheckUserAccess.call(inst, req)
702
+ assert.equal(isTargetSuperMock.mock.callCount(), 0)
703
+ })
704
+ })
705
+
706
+ // ── initConfigRoles ────────────────────────────────────────────────
707
+
708
+ describe('initConfigRoles', () => {
709
+ it('should insert new roles that do not exist', async () => {
710
+ const insertMock = mock.fn(async (data) => data)
711
+ const inst = createInstance({
712
+ find: mock.fn(async () => []),
713
+ insert: insertMock,
714
+ getConfig: mock.fn((key) => {
715
+ if (key === 'roleDefinitions') {
716
+ return [{
717
+ shortName: 'newrole',
718
+ displayName: 'New Role',
719
+ scopes: ['read:all']
720
+ }]
721
+ }
722
+ return []
723
+ }),
724
+ collectionName: 'roles',
725
+ schemaName: 'role',
726
+ app: {
727
+ waitForModule: mock.fn(async () => ({ replace: mock.fn() })),
728
+ errors: {}
729
+ }
730
+ })
731
+ await initConfigRoles.call(inst)
732
+ assert.equal(insertMock.mock.callCount(), 1)
733
+ assert.equal(
734
+ insertMock.mock.calls[0].arguments[0].shortName, 'newrole'
735
+ )
736
+ })
737
+
738
+ it('should replace existing roles', async () => {
739
+ const replaceMock = mock.fn(async () => {})
740
+ const inst = createInstance({
741
+ find: mock.fn(async () => [{ _id: 'existing1', shortName: 'admin' }]),
742
+ insert: mock.fn(async () => {}),
743
+ getConfig: mock.fn((key) => {
744
+ if (key === 'roleDefinitions') {
745
+ return [{
746
+ shortName: 'admin', displayName: 'Admin', scopes: ['*:*']
747
+ }]
748
+ }
749
+ return []
750
+ }),
751
+ collectionName: 'roles',
752
+ schemaName: 'role',
753
+ app: {
754
+ waitForModule: mock.fn(async () => ({ replace: replaceMock })),
755
+ errors: {}
756
+ }
757
+ })
758
+ await initConfigRoles.call(inst)
759
+ assert.equal(replaceMock.mock.callCount(), 1)
760
+ assert.deepEqual(
761
+ replaceMock.mock.calls[0].arguments[1], { _id: 'existing1' }
762
+ )
763
+ })
764
+
765
+ it('should log debug on successful insert', async () => {
766
+ const inst = createInstance({
767
+ find: mock.fn(async () => []),
768
+ insert: mock.fn(async () => {}),
769
+ getConfig: mock.fn((key) => {
770
+ if (key === 'roleDefinitions') {
771
+ return [{
772
+ shortName: 'testrole', displayName: 'Test', scopes: []
773
+ }]
774
+ }
775
+ return []
776
+ }),
777
+ collectionName: 'roles',
778
+ schemaName: 'role',
779
+ app: {
780
+ waitForModule: mock.fn(async () => ({})),
781
+ errors: {}
782
+ }
783
+ })
784
+ await initConfigRoles.call(inst)
785
+ assert.equal(inst.log.mock.calls[0].arguments[0], 'debug')
786
+ assert.equal(inst.log.mock.calls[0].arguments[1], 'INSERT')
787
+ })
788
+
789
+ it('should log debug on successful replace', async () => {
790
+ const inst = createInstance({
791
+ find: mock.fn(async () => [{ _id: 'id1', shortName: 'admin' }]),
792
+ getConfig: mock.fn((key) => {
793
+ if (key === 'roleDefinitions') {
794
+ return [{
795
+ shortName: 'admin', displayName: 'Admin', scopes: []
796
+ }]
797
+ }
798
+ return []
799
+ }),
800
+ collectionName: 'roles',
801
+ schemaName: 'role',
802
+ app: {
803
+ waitForModule: mock.fn(async () => ({
804
+ replace: mock.fn(async () => {})
805
+ })),
806
+ errors: {}
807
+ }
808
+ })
809
+ await initConfigRoles.call(inst)
810
+ assert.equal(inst.log.mock.calls[0].arguments[0], 'debug')
811
+ assert.equal(inst.log.mock.calls[0].arguments[1], 'REPLACE')
812
+ })
813
+
814
+ it('should suppress duplicate key errors on insert', async () => {
815
+ const dupError = new Error('duplicate key')
816
+ dupError.code = 11000
817
+ const inst = createInstance({
818
+ find: mock.fn(async () => []),
819
+ insert: mock.fn(async () => { throw dupError }),
820
+ getConfig: mock.fn((key) => {
821
+ if (key === 'roleDefinitions') {
822
+ return [{
823
+ shortName: 'dup', displayName: 'Dup', scopes: []
824
+ }]
825
+ }
826
+ return []
827
+ }),
828
+ collectionName: 'roles',
829
+ schemaName: 'role',
830
+ app: {
831
+ waitForModule: mock.fn(async () => ({})),
832
+ errors: {}
833
+ }
834
+ })
835
+ await initConfigRoles.call(inst)
836
+ const warnCalls = inst.log.mock.calls.filter(
837
+ c => c.arguments[0] === 'warn'
838
+ )
839
+ assert.equal(warnCalls.length, 0)
840
+ })
841
+
842
+ it('should log warning for non-duplicate insert errors', async () => {
843
+ const error = new Error('some error')
844
+ error.code = 500
845
+ const inst = createInstance({
846
+ find: mock.fn(async () => []),
847
+ insert: mock.fn(async () => { throw error }),
848
+ getConfig: mock.fn((key) => {
849
+ if (key === 'roleDefinitions') {
850
+ return [{
851
+ shortName: 'fail', displayName: 'Fail', scopes: []
852
+ }]
853
+ }
854
+ return []
855
+ }),
856
+ collectionName: 'roles',
857
+ schemaName: 'role',
858
+ app: {
859
+ waitForModule: mock.fn(async () => ({})),
860
+ errors: {}
861
+ }
862
+ })
863
+ await initConfigRoles.call(inst)
864
+ const warnCalls = inst.log.mock.calls.filter(
865
+ c => c.arguments[0] === 'warn'
866
+ )
867
+ assert.equal(warnCalls.length, 1)
868
+ assert.ok(warnCalls[0].arguments[1].includes('fail'))
869
+ })
870
+
871
+ it('should suppress duplicate key errors on replace', async () => {
872
+ const dupError = new Error('duplicate key')
873
+ dupError.code = 11000
874
+ const inst = createInstance({
875
+ find: mock.fn(async () => [{ _id: 'id1', shortName: 'admin' }]),
876
+ getConfig: mock.fn((key) => {
877
+ if (key === 'roleDefinitions') {
878
+ return [{
879
+ shortName: 'admin', displayName: 'Admin', scopes: []
880
+ }]
881
+ }
882
+ return []
883
+ }),
884
+ collectionName: 'roles',
885
+ schemaName: 'role',
886
+ app: {
887
+ waitForModule: mock.fn(async () => ({
888
+ replace: mock.fn(async () => { throw dupError })
889
+ })),
890
+ errors: {}
891
+ }
892
+ })
893
+ await initConfigRoles.call(inst)
894
+ const warnCalls = inst.log.mock.calls.filter(
895
+ c => c.arguments[0] === 'warn'
896
+ )
897
+ assert.equal(warnCalls.length, 0)
898
+ })
899
+
900
+ it('should handle empty roleDefinitions', async () => {
901
+ const inst = createInstance({
902
+ getConfig: mock.fn(() => []),
903
+ app: {
904
+ waitForModule: mock.fn(async () => ({})),
905
+ errors: {}
906
+ }
907
+ })
908
+ const result = await initConfigRoles.call(inst)
909
+ assert.ok(Array.isArray(result))
910
+ assert.equal(result.length, 0)
911
+ })
912
+ })
913
+
914
+ // ── initDefaultRoles ───────────────────────────────────────────────
915
+
916
+ describe('initDefaultRoles', () => {
917
+ // TODO: Bug - initDefaultRoles has a broken reduce in rolesForAuth.
918
+ // The reduce accumulator is {}, but the callback uses m[k] as a
919
+ // property key (where m is the accumulator). In the first iteration
920
+ // m[k] is undefined, so it creates { undefined: Promise }.
921
+ // defaultRolesForAuthTypes never works correctly.
922
+ // Fix: return { ...m, [k]: this.shortNamesToIds(v) }
923
+ it('should tap into users preInsertHook', async () => {
924
+ const tapMock = mock.fn()
925
+ const usersModule = { preInsertHook: { tap: tapMock } }
926
+ const inst = createInstance({
927
+ app: {
928
+ waitForModule: mock.fn(async () => usersModule),
929
+ errors: {}
930
+ },
931
+ shortNamesToIds: mock.fn(async (names) => {
932
+ return names.map(n => 'id-' + n)
933
+ }),
934
+ getConfig: mock.fn((key) => {
935
+ if (key === 'defaultRoles') return ['authuser']
936
+ if (key === 'defaultRolesForAuthTypes') return {}
937
+ return []
938
+ })
939
+ })
940
+ await initDefaultRoles.call(inst)
941
+ assert.equal(tapMock.mock.callCount(), 1)
942
+ })
943
+
944
+ it('should set default roles when no roles present', async () => {
945
+ let tapCallback
946
+ const usersModule = {
947
+ preInsertHook: {
948
+ tap: (cb) => { tapCallback = cb }
949
+ }
950
+ }
951
+ const inst = createInstance({
952
+ app: {
953
+ waitForModule: mock.fn(async () => usersModule),
954
+ errors: {}
955
+ },
956
+ shortNamesToIds: mock.fn(async (names) => {
957
+ return names.map(n => 'id-' + n)
958
+ }),
959
+ getConfig: mock.fn((key) => {
960
+ if (key === 'defaultRoles') return ['authuser']
961
+ if (key === 'defaultRolesForAuthTypes') return {}
962
+ return []
963
+ })
964
+ })
965
+ await initDefaultRoles.call(inst)
966
+
967
+ const userData = { authType: 'local' }
968
+ tapCallback(userData)
969
+ assert.deepEqual(userData.roles, ['id-authuser'])
970
+ })
971
+
972
+ it('should not override existing roles on user data', async () => {
973
+ let tapCallback
974
+ const usersModule = {
975
+ preInsertHook: {
976
+ tap: (cb) => { tapCallback = cb }
977
+ }
978
+ }
979
+ const inst = createInstance({
980
+ app: {
981
+ waitForModule: mock.fn(async () => usersModule),
982
+ errors: {}
983
+ },
984
+ shortNamesToIds: mock.fn(async (names) => {
985
+ return names.map(n => 'id-' + n)
986
+ }),
987
+ getConfig: mock.fn((key) => {
988
+ if (key === 'defaultRoles') return ['authuser']
989
+ if (key === 'defaultRolesForAuthTypes') return {}
990
+ return []
991
+ })
992
+ })
993
+ await initDefaultRoles.call(inst)
994
+
995
+ const userData = { roles: ['existing-role'] }
996
+ tapCallback(userData)
997
+ assert.deepEqual(userData.roles, ['existing-role'])
998
+ })
999
+ })
1000
+ })