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.
- package/lib/ContentModule.js +13 -1
- package/package.json +1 -1
- package/tests/ContentModule.spec.js +75 -1
package/lib/ContentModule.js
CHANGED
|
@@ -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
|
@@ -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', () => {
|