adapt-authoring-content 3.1.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.
- package/errors/errors.json +7 -0
- package/lib/ContentModule.js +37 -1
- package/lib/ContentTree.js +14 -0
- package/package.json +1 -1
- package/tests/ContentModule.spec.js +55 -1
- package/tests/ContentTree.spec.js +49 -0
package/errors/errors.json
CHANGED
|
@@ -21,5 +21,12 @@
|
|
|
21
21
|
},
|
|
22
22
|
"description": "Resource is currently being used in courses",
|
|
23
23
|
"statusCode": 400
|
|
24
|
+
},
|
|
25
|
+
"EMPTY_CONTAINERS": {
|
|
26
|
+
"data": {
|
|
27
|
+
"items": "The childless content items blocking the build"
|
|
28
|
+
},
|
|
29
|
+
"description": "Course cannot be built: one or more pages, articles or blocks have no content",
|
|
30
|
+
"statusCode": 400
|
|
24
31
|
}
|
|
25
32
|
}
|
package/lib/ContentModule.js
CHANGED
|
@@ -59,6 +59,12 @@ class ContentModule extends AbstractApiModule {
|
|
|
59
59
|
|
|
60
60
|
assets.preDeleteHook.tap(this.enforceAssetNotInUse.bind(this))
|
|
61
61
|
|
|
62
|
+
// block builds when the course structure has empty containers. adaptframework depends on
|
|
63
|
+
// content (not vice-versa), so tap its hook once available rather than awaiting it here.
|
|
64
|
+
this.app.waitForModule('adaptframework').then(framework => {
|
|
65
|
+
framework.preBuildHook.tap(this.enforceNoEmptyContainers.bind(this))
|
|
66
|
+
})
|
|
67
|
+
|
|
62
68
|
await mongodb.setIndex(this.collectionName, { _courseId: 1, _parentId: 1, _type: 1 })
|
|
63
69
|
await mongodb.setIndex(this.collectionName, { _parentId: 1 })
|
|
64
70
|
await mongodb.setIndex(this.collectionName, { _type: 1, _courseId: 1 })
|
|
@@ -104,6 +110,26 @@ class ContentModule extends AbstractApiModule {
|
|
|
104
110
|
throw this.app.errors.RESOURCE_IN_USE.setData({ type: 'asset', courses })
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
/**
|
|
114
|
+
* preBuildHook observer: refuses a build when any non-component content item has no children
|
|
115
|
+
* (an empty page, article or block). Components are leaf nodes and config is exempt.
|
|
116
|
+
* @param {AdaptFrameworkBuild} build The build being run
|
|
117
|
+
* @return {Promise}
|
|
118
|
+
*/
|
|
119
|
+
async enforceNoEmptyContainers (build) {
|
|
120
|
+
const _courseId = parseObjectId(build.courseId)
|
|
121
|
+
const items = await this.find(
|
|
122
|
+
{ $or: [{ _id: _courseId }, { _courseId }] },
|
|
123
|
+
{ validate: false },
|
|
124
|
+
{ projection: { _type: 1, _parentId: 1, title: 1, displayTitle: 1 } }
|
|
125
|
+
)
|
|
126
|
+
const empty = new ContentTree(items).getEmptyContainers()
|
|
127
|
+
if (!empty.length) return
|
|
128
|
+
throw this.app.errors.EMPTY_CONTAINERS.setData({
|
|
129
|
+
items: empty.map(i => ({ _id: i._id.toString(), _type: i._type, title: i.displayTitle || i.title }))
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
107
133
|
/**
|
|
108
134
|
* Returns a map of asset _id to the number of distinct courses each asset is referenced by.
|
|
109
135
|
* Reads the indexed `_assetIds` field. Accepts an optional `assetIds` array in the request body to
|
|
@@ -639,8 +665,18 @@ class ContentModule extends AbstractApiModule {
|
|
|
639
665
|
const course = await this.findOne(
|
|
640
666
|
{ _type: 'course', _courseId },
|
|
641
667
|
{ validate: false },
|
|
642
|
-
{ projection: { updatedAt: 1 } }
|
|
668
|
+
{ projection: { updatedAt: 1, _type: 1, createdBy: 1, _isShared: 1, _shareWithUsers: 1, userGroups: 1 } }
|
|
643
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
|
+
}
|
|
644
680
|
const lastModified = new Date(course.updatedAt)
|
|
645
681
|
lastModified.setMilliseconds(0) // HTTP dates are second-precision; must match before comparing
|
|
646
682
|
const ifModifiedSince = req.headers['if-modified-since'] && new Date(req.headers['if-modified-since'])
|
package/lib/ContentTree.js
CHANGED
|
@@ -114,6 +114,20 @@ class ContentTree {
|
|
|
114
114
|
return this.getChildren(item._parentId).filter(c => c._id.toString() !== id)
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Finds container items that have no children. Components are leaf nodes and
|
|
119
|
+
* config is a childless root — both are exempt; every other type (course, menu,
|
|
120
|
+
* page, article, block) is expected to contain at least one child.
|
|
121
|
+
* @returns {Array<Object>} Childless container items
|
|
122
|
+
*/
|
|
123
|
+
getEmptyContainers () {
|
|
124
|
+
return this.items.filter(i =>
|
|
125
|
+
i._type !== 'component' &&
|
|
126
|
+
i._type !== 'config' &&
|
|
127
|
+
this.getChildren(i._id).length === 0
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
/**
|
|
118
132
|
* O(1) — unique component names across the course
|
|
119
133
|
* @returns {Array<string>}
|
package/package.json
CHANGED
|
@@ -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', () => {
|
|
@@ -227,4 +227,53 @@ describe('ContentTree', () => {
|
|
|
227
227
|
assert.deepEqual(tree.getComponentNames(), [])
|
|
228
228
|
})
|
|
229
229
|
})
|
|
230
|
+
|
|
231
|
+
describe('getEmptyContainers', () => {
|
|
232
|
+
it('should return container items that have no children', () => {
|
|
233
|
+
// in the shared fixture the menu (id4) and second article (id6) are childless
|
|
234
|
+
const empty = new ContentTree(items).getEmptyContainers()
|
|
235
|
+
assert.deepEqual(empty.map(i => i._id.toString()).sort(), ['id4', 'id6'])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('should never flag components (leaf nodes)', () => {
|
|
239
|
+
const tree = new ContentTree([
|
|
240
|
+
{ _id: makeId(1), _type: 'block', _parentId: makeId(99) },
|
|
241
|
+
{ _id: makeId(2), _type: 'component', _parentId: makeId(1), _component: 'adapt-contrib-text' }
|
|
242
|
+
])
|
|
243
|
+
assert.equal(tree.getEmptyContainers().length, 0)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('should never flag config (childless root)', () => {
|
|
247
|
+
const tree = new ContentTree([
|
|
248
|
+
{ _id: makeId(1), _type: 'course' },
|
|
249
|
+
{ _id: makeId(2), _type: 'config', _courseId: 'c1' },
|
|
250
|
+
{ _id: makeId(3), _type: 'page', _parentId: makeId(1) },
|
|
251
|
+
{ _id: makeId(4), _type: 'article', _parentId: makeId(3) },
|
|
252
|
+
{ _id: makeId(5), _type: 'block', _parentId: makeId(4) },
|
|
253
|
+
{ _id: makeId(6), _type: 'component', _parentId: makeId(5), _component: 'adapt-contrib-text' }
|
|
254
|
+
])
|
|
255
|
+
assert.deepEqual(tree.getEmptyContainers(), [])
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('should flag a block with no components', () => {
|
|
259
|
+
const tree = new ContentTree([
|
|
260
|
+
{ _id: makeId(1), _type: 'article', _parentId: makeId(99) },
|
|
261
|
+
{ _id: makeId(2), _type: 'block', _parentId: makeId(1) }
|
|
262
|
+
])
|
|
263
|
+
const empty = tree.getEmptyContainers()
|
|
264
|
+
assert.equal(empty.length, 1)
|
|
265
|
+
assert.equal(empty[0]._id.toString(), 'id2')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('should flag a course with no children', () => {
|
|
269
|
+
const tree = new ContentTree([
|
|
270
|
+
{ _id: makeId(1), _type: 'course' }
|
|
271
|
+
])
|
|
272
|
+
assert.deepEqual(tree.getEmptyContainers().map(i => i._type), ['course'])
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it('should return empty array for an empty tree', () => {
|
|
276
|
+
assert.deepEqual(new ContentTree([]).getEmptyContainers(), [])
|
|
277
|
+
})
|
|
278
|
+
})
|
|
230
279
|
})
|