adapt-authoring-content 3.2.0 → 3.2.1

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.
@@ -665,8 +665,18 @@ class ContentModule extends AbstractApiModule {
665
665
  const course = await this.findOne(
666
666
  { _type: 'course', _courseId },
667
667
  { validate: false },
668
- { projection: { updatedAt: 1 } }
668
+ { projection: { updatedAt: 1, _type: 1, createdBy: 1, _isShared: 1, _shareWithUsers: 1, userGroups: 1 } }
669
669
  )
670
+ if (!course) {
671
+ throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _courseId })
672
+ }
673
+ // this custom handler bypasses the standard per-item access check, so apply
674
+ // it here: the tree is readable only if the user can access the course
675
+ // (owner / _isShared / _shareWithUsers / shared group). 404 (not 403) so we
676
+ // don't leak the course's existence. Supers are exempt.
677
+ if (!req.auth.isSuper && this.accessCheckHook.hasObservers && !(await this.accessCheckHook.invoke(req, course)).every(Boolean)) {
678
+ throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _courseId })
679
+ }
670
680
  const lastModified = new Date(course.updatedAt)
671
681
  lastModified.setMilliseconds(0) // HTTP dates are second-precision; must match before comparing
672
682
  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.1",
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",
@@ -560,6 +560,7 @@ describe('ContentModule', () => {
560
560
  let statusCode
561
561
  let ended = false
562
562
  const req = {
563
+ auth: { isSuper: true },
563
564
  apiData: { query: { _courseId: COURSE_ID } },
564
565
  headers: { 'if-modified-since': new Date('2025-01-02T00:00:00Z').toUTCString() }
565
566
  }
@@ -586,6 +587,7 @@ describe('ContentModule', () => {
586
587
  find: mock.fn(async () => items)
587
588
  })
588
589
  const req = {
590
+ auth: { isSuper: true },
589
591
  apiData: { query: { _courseId: COURSE_ID } },
590
592
  headers: {}
591
593
  }
@@ -617,13 +619,65 @@ describe('ContentModule', () => {
617
619
  const inst = createInstance({
618
620
  findOne: mock.fn(async () => { throw new Error('db error') })
619
621
  })
620
- const req = { apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
622
+ const req = { auth: { isSuper: true }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
621
623
  const res = {}
622
624
  const next = mock.fn()
623
625
  await ContentModule.prototype.handleTree.call(inst, req, res, next)
624
626
  assert.equal(next.mock.callCount(), 1)
625
627
  assert.equal(next.mock.calls[0].arguments[0].message, 'db error')
626
628
  })
629
+
630
+ it('should run the per-item access check for non-super users and return the tree when allowed', async () => {
631
+ const lastModified = new Date('2025-01-15T00:00:00Z')
632
+ const course = { _id: COURSE_ID, _type: 'course', updatedAt: lastModified }
633
+ const accessCheckHook = { hasObservers: true, invoke: mock.fn(async () => [true]) }
634
+ const inst = createInstance({
635
+ accessCheckHook,
636
+ findOne: mock.fn(async () => course),
637
+ find: mock.fn(async () => [{ _id: COURSE_ID, _type: 'course', _courseId: COURSE_ID }])
638
+ })
639
+ const req = { auth: { isSuper: false, user: {} }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
640
+ const res = { set: mock.fn(), json: mock.fn() }
641
+ const next = mock.fn()
642
+ await ContentModule.prototype.handleTree.call(inst, req, res, next)
643
+
644
+ assert.equal(accessCheckHook.invoke.mock.callCount(), 1, 'access check invoked')
645
+ assert.deepEqual(accessCheckHook.invoke.mock.calls[0].arguments, [req, course], 'check receives req + the course')
646
+ assert.equal(inst.find.mock.callCount(), 1)
647
+ assert.equal(next.mock.callCount(), 0)
648
+ })
649
+
650
+ it('should 404 (not leak existence) when the access check denies the course', async () => {
651
+ const accessCheckHook = { hasObservers: true, invoke: mock.fn(async () => [false]) }
652
+ const inst = createInstance({
653
+ accessCheckHook,
654
+ findOne: mock.fn(async () => ({ _id: COURSE_ID, _type: 'course', updatedAt: new Date() })),
655
+ app: { errors: { NOT_FOUND: { setData: () => new Error('NOT_FOUND') } } }
656
+ })
657
+ const req = { auth: { isSuper: false, user: {} }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
658
+ const next = mock.fn()
659
+ await ContentModule.prototype.handleTree.call(inst, req, {}, next)
660
+
661
+ assert.equal(next.mock.callCount(), 1)
662
+ assert.equal(next.mock.calls[0].arguments[0].message, 'NOT_FOUND')
663
+ assert.equal(inst.find.mock.callCount(), 0, 'should not fetch items for an inaccessible course')
664
+ })
665
+
666
+ it('should skip the access check for super users', async () => {
667
+ const accessCheckHook = { hasObservers: true, invoke: mock.fn(async () => [false]) }
668
+ const inst = createInstance({
669
+ accessCheckHook,
670
+ findOne: mock.fn(async () => ({ _id: COURSE_ID, _type: 'course', updatedAt: new Date('2025-01-15T00:00:00Z') })),
671
+ find: mock.fn(async () => [])
672
+ })
673
+ const req = { auth: { isSuper: true }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
674
+ const res = { set: mock.fn(), json: mock.fn() }
675
+ const next = mock.fn()
676
+ await ContentModule.prototype.handleTree.call(inst, req, res, next)
677
+
678
+ assert.equal(accessCheckHook.invoke.mock.callCount(), 0, 'super bypasses the access check')
679
+ assert.equal(next.mock.callCount(), 0)
680
+ })
627
681
  })
628
682
 
629
683
  describe('enforceAssetNotInUse', () => {