adapt-authoring-content 3.2.0 → 3.2.2

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.
@@ -571,6 +571,8 @@ class ContentModule extends AbstractApiModule {
571
571
  if (originalDoc._courseId?.toString() !== payloads[0]._courseId?.toString()) {
572
572
  await this.updateEnabledPlugins(payloads[0])
573
573
  }
574
+ // place the clone at its requested _sortOrder and renumber siblings (no-op for course/config)
575
+ await this.updateSortOrder(payloads[0], payloads[0])
574
576
 
575
577
  return payloads[0]
576
578
  }
@@ -665,8 +667,18 @@ class ContentModule extends AbstractApiModule {
665
667
  const course = await this.findOne(
666
668
  { _type: 'course', _courseId },
667
669
  { validate: false },
668
- { projection: { updatedAt: 1 } }
670
+ { projection: { updatedAt: 1, _type: 1, createdBy: 1, _isShared: 1, _shareWithUsers: 1, userGroups: 1 } }
669
671
  )
672
+ if (!course) {
673
+ throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _courseId })
674
+ }
675
+ // this custom handler bypasses the standard per-item access check, so apply
676
+ // it here: the tree is readable only if the user can access the course
677
+ // (owner / _isShared / _shareWithUsers / shared group). 404 (not 403) so we
678
+ // don't leak the course's existence. Supers are exempt.
679
+ if (!req.auth.isSuper && this.accessCheckHook.hasObservers && !(await this.accessCheckHook.invoke(req, course)).every(Boolean)) {
680
+ throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _courseId })
681
+ }
670
682
  const lastModified = new Date(course.updatedAt)
671
683
  lastModified.setMilliseconds(0) // HTTP dates are second-precision; must match before comparing
672
684
  const ifModifiedSince = req.headers['if-modified-since'] && new Date(req.headers['if-modified-since'])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-content",
3
- "version": "3.2.0",
3
+ "version": "3.2.2",
4
4
  "description": "Module for managing Adapt content",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-content",
6
6
  "license": "GPL-3.0",
@@ -384,6 +384,7 @@ describe('ContentModule', () => {
384
384
  }),
385
385
  getSchema: mock.fn(async () => ({})),
386
386
  updateEnabledPlugins: mock.fn(async () => {}),
387
+ updateSortOrder: mock.fn(async () => {}),
387
388
  preCloneHook: { invoke: mock.fn(async () => {}) },
388
389
  preInsertHook: { invoke: mock.fn(async () => {}) },
389
390
  postInsertHook: { invoke: mock.fn(async () => {}) },
@@ -460,6 +461,25 @@ describe('ContentModule', () => {
460
461
  assert.equal(result._type, 'course')
461
462
  })
462
463
 
464
+ it('should renumber siblings for the cloned root so it lands at its _sortOrder', async () => {
465
+ const { inst } = createCloneInstance()
466
+
467
+ const items = [
468
+ { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID },
469
+ { _id: PAGE_OID, _type: 'page', _parentId: COURSE_OID, _courseId: COURSE_OID }
470
+ ]
471
+ const tree = new ContentTree(items)
472
+ const parent = { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID }
473
+ const result = await ContentModule.prototype.clone.call(inst, USER_OID, PAGE_OID, COURSE_OID, { _sortOrder: 1 }, { tree, parent })
474
+
475
+ assert.equal(inst.updateSortOrder.mock.callCount(), 1)
476
+ const [item, updateData] = inst.updateSortOrder.mock.calls[0].arguments
477
+ // called with the root payload (truthy updateData triggers the splice/renumber path)
478
+ assert.equal(item, result)
479
+ assert.ok(updateData)
480
+ assert.equal(item._sortOrder, 1)
481
+ })
482
+
463
483
  it('should remap parent IDs correctly', async () => {
464
484
  const { inst, mongodb } = createCloneInstance()
465
485
 
@@ -560,6 +580,7 @@ describe('ContentModule', () => {
560
580
  let statusCode
561
581
  let ended = false
562
582
  const req = {
583
+ auth: { isSuper: true },
563
584
  apiData: { query: { _courseId: COURSE_ID } },
564
585
  headers: { 'if-modified-since': new Date('2025-01-02T00:00:00Z').toUTCString() }
565
586
  }
@@ -586,6 +607,7 @@ describe('ContentModule', () => {
586
607
  find: mock.fn(async () => items)
587
608
  })
588
609
  const req = {
610
+ auth: { isSuper: true },
589
611
  apiData: { query: { _courseId: COURSE_ID } },
590
612
  headers: {}
591
613
  }
@@ -617,13 +639,65 @@ describe('ContentModule', () => {
617
639
  const inst = createInstance({
618
640
  findOne: mock.fn(async () => { throw new Error('db error') })
619
641
  })
620
- const req = { apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
642
+ const req = { auth: { isSuper: true }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
621
643
  const res = {}
622
644
  const next = mock.fn()
623
645
  await ContentModule.prototype.handleTree.call(inst, req, res, next)
624
646
  assert.equal(next.mock.callCount(), 1)
625
647
  assert.equal(next.mock.calls[0].arguments[0].message, 'db error')
626
648
  })
649
+
650
+ it('should run the per-item access check for non-super users and return the tree when allowed', async () => {
651
+ const lastModified = new Date('2025-01-15T00:00:00Z')
652
+ const course = { _id: COURSE_ID, _type: 'course', updatedAt: lastModified }
653
+ const accessCheckHook = { hasObservers: true, invoke: mock.fn(async () => [true]) }
654
+ const inst = createInstance({
655
+ accessCheckHook,
656
+ findOne: mock.fn(async () => course),
657
+ find: mock.fn(async () => [{ _id: COURSE_ID, _type: 'course', _courseId: COURSE_ID }])
658
+ })
659
+ const req = { auth: { isSuper: false, user: {} }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
660
+ const res = { set: mock.fn(), json: mock.fn() }
661
+ const next = mock.fn()
662
+ await ContentModule.prototype.handleTree.call(inst, req, res, next)
663
+
664
+ assert.equal(accessCheckHook.invoke.mock.callCount(), 1, 'access check invoked')
665
+ assert.deepEqual(accessCheckHook.invoke.mock.calls[0].arguments, [req, course], 'check receives req + the course')
666
+ assert.equal(inst.find.mock.callCount(), 1)
667
+ assert.equal(next.mock.callCount(), 0)
668
+ })
669
+
670
+ it('should 404 (not leak existence) when the access check denies the course', async () => {
671
+ const accessCheckHook = { hasObservers: true, invoke: mock.fn(async () => [false]) }
672
+ const inst = createInstance({
673
+ accessCheckHook,
674
+ findOne: mock.fn(async () => ({ _id: COURSE_ID, _type: 'course', updatedAt: new Date() })),
675
+ app: { errors: { NOT_FOUND: { setData: () => new Error('NOT_FOUND') } } }
676
+ })
677
+ const req = { auth: { isSuper: false, user: {} }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
678
+ const next = mock.fn()
679
+ await ContentModule.prototype.handleTree.call(inst, req, {}, next)
680
+
681
+ assert.equal(next.mock.callCount(), 1)
682
+ assert.equal(next.mock.calls[0].arguments[0].message, 'NOT_FOUND')
683
+ assert.equal(inst.find.mock.callCount(), 0, 'should not fetch items for an inaccessible course')
684
+ })
685
+
686
+ it('should skip the access check for super users', async () => {
687
+ const accessCheckHook = { hasObservers: true, invoke: mock.fn(async () => [false]) }
688
+ const inst = createInstance({
689
+ accessCheckHook,
690
+ findOne: mock.fn(async () => ({ _id: COURSE_ID, _type: 'course', updatedAt: new Date('2025-01-15T00:00:00Z') })),
691
+ find: mock.fn(async () => [])
692
+ })
693
+ const req = { auth: { isSuper: true }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
694
+ const res = { set: mock.fn(), json: mock.fn() }
695
+ const next = mock.fn()
696
+ await ContentModule.prototype.handleTree.call(inst, req, res, next)
697
+
698
+ assert.equal(accessCheckHook.invoke.mock.callCount(), 0, 'super bypasses the access check')
699
+ assert.equal(next.mock.callCount(), 0)
700
+ })
627
701
  })
628
702
 
629
703
  describe('enforceAssetNotInUse', () => {