adapt-authoring-content 3.2.2 → 3.2.4

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.
@@ -2,7 +2,7 @@ import { AbstractApiModule } from 'adapt-authoring-api'
2
2
  import { Hook, stringifyValues } from 'adapt-authoring-core'
3
3
  import { createObjectId, parseObjectId } from 'adapt-authoring-mongodb'
4
4
  import { ObjectId } from 'mongodb'
5
- import { ContentTree, buildAssetUsagePipeline, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, formatFriendlyId, parseMaxSeq } from './utils.js'
5
+ import { ContentTree, buildAssetUsagePipeline, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, fieldsToProjection, formatFriendlyId, parseMaxSeq, treeEtag } from './utils.js'
6
6
  /**
7
7
  * Module which handles course content
8
8
  * @memberof content
@@ -679,19 +679,39 @@ class ContentModule extends AbstractApiModule {
679
679
  if (!req.auth.isSuper && this.accessCheckHook.hasObservers && !(await this.accessCheckHook.invoke(req, course)).every(Boolean)) {
680
680
  throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _courseId })
681
681
  }
682
- const lastModified = new Date(course.updatedAt)
683
- lastModified.setMilliseconds(0) // HTTP dates are second-precision; must match before comparing
684
- const ifModifiedSince = req.headers['if-modified-since'] && new Date(req.headers['if-modified-since'])
685
- if (ifModifiedSince && lastModified <= ifModifiedSince) {
682
+ const treeFields = [
683
+ '_id',
684
+ '_parentId',
685
+ '_courseId',
686
+ '_type',
687
+ '_sortOrder',
688
+ 'title',
689
+ 'displayTitle',
690
+ '_friendlyId',
691
+ '_component',
692
+ '_layout',
693
+ '_menu',
694
+ '_theme',
695
+ '_enabledPlugins',
696
+ '_colorLabel',
697
+ 'heroImage',
698
+ 'updatedAt'
699
+ ]
700
+ // ETag (not Last-Modified): folds the projected field list into the
701
+ // validator so the cache busts when the response shape changes, not just
702
+ // when the course data does — otherwise an added field stays missing for
703
+ // unedited courses whose updatedAt never moves.
704
+ const etag = treeEtag(course.updatedAt, treeFields)
705
+ if (req.headers['if-none-match'] === etag) {
686
706
  return res.status(304).end()
687
707
  }
688
708
  const items = await this.find(
689
709
  { _courseId },
690
710
  { validate: false },
691
- { projection: { _id: 1, _parentId: 1, _courseId: 1, _type: 1, _sortOrder: 1, title: 1, displayTitle: 1, _friendlyId: 1, _component: 1, _layout: 1, _menu: 1, _theme: 1, _enabledPlugins: 1, _colorLabel: 1, updatedAt: 1 } }
711
+ { projection: fieldsToProjection(treeFields) }
692
712
  )
693
713
  const tree = new ContentTree(items)
694
- res.set('Last-Modified', lastModified.toUTCString())
714
+ res.set('ETag', etag)
695
715
  res.json(items.map(item => ({
696
716
  ...item,
697
717
  _children: tree.getChildren(item._id).map(c => c._id)
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Builds a MongoDB inclusion projection from a list of field names, mapping
3
+ * each to `1` (e.g. `['_id', 'title']` -> `{ _id: 1, title: 1 }`). Lets a
4
+ * handler declare its projected fields as a readable one-per-line array.
5
+ * @param {Array<string>} fields Field names to include
6
+ * @return {Object} Inclusion projection
7
+ */
8
+ export default function fieldsToProjection (fields) {
9
+ return Object.fromEntries((fields ?? []).map(field => [field, 1]))
10
+ }
@@ -0,0 +1,16 @@
1
+ import { createHash } from 'crypto'
2
+ /**
3
+ * Builds a weak ETag for the content tree response. Combines the course's
4
+ * `updatedAt` with a hash of the projected field list, so the cache
5
+ * invalidates both when the course data changes AND when the tree response
6
+ * *shape* changes (e.g. a field is added to the projection). Keying solely on
7
+ * `updatedAt` served stale bodies to unedited courses after a shape change —
8
+ * a newly-projected field was missing until the course happened to be edited.
9
+ * @param {Date|string|number} updatedAt Course updatedAt
10
+ * @param {Array<string>} fields Projected tree field names
11
+ * @return {string} Weak ETag value (quoted, `W/`-prefixed)
12
+ */
13
+ export default function treeEtag (updatedAt, fields) {
14
+ const shape = createHash('sha1').update([...(fields ?? [])].sort().join(',')).digest('hex').slice(0, 8)
15
+ return `W/"${new Date(updatedAt).getTime()}-${shape}"`
16
+ }
package/lib/utils.js CHANGED
@@ -3,5 +3,7 @@ export { default as buildAssetUsagePipeline } from './utils/buildAssetUsagePipel
3
3
  export { default as computeSortOrderOps } from './utils/computeSortOrderOps.js'
4
4
  export { extractAssetIds } from './utils/extractAssetIds.js'
5
5
  export { default as contentTypeToSchemaName } from './utils/contentTypeToSchemaName.js'
6
+ export { default as fieldsToProjection } from './utils/fieldsToProjection.js'
6
7
  export { default as formatFriendlyId } from './utils/formatFriendlyId.js'
7
8
  export { default as parseMaxSeq } from './utils/parseMaxSeq.js'
9
+ export { default as treeEtag } from './utils/treeEtag.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-content",
3
- "version": "3.2.2",
3
+ "version": "3.2.4",
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",
@@ -572,17 +572,24 @@ describe('ContentModule', () => {
572
572
  })
573
573
 
574
574
  describe('handleTree', () => {
575
- it('should return 304 when content has not been modified', async () => {
576
- const lastModified = new Date('2025-01-01T00:00:00Z')
575
+ it('should return 304 when the ETag matches (content + shape unchanged)', async () => {
577
576
  const inst = createInstance({
578
- findOne: mock.fn(async () => ({ updatedAt: lastModified }))
577
+ findOne: mock.fn(async () => ({ updatedAt: new Date('2025-01-01T00:00:00Z') })),
578
+ find: mock.fn(async () => [])
579
579
  })
580
+ // capture the current ETag from a fresh request...
581
+ let etag
582
+ const firstRes = { set: mock.fn((k, v) => { if (k === 'ETag') etag = v }), json: mock.fn() }
583
+ await ContentModule.prototype.handleTree.call(inst, { auth: { isSuper: true }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }, firstRes, mock.fn())
584
+ assert.match(etag, /^W\/".+"$/)
585
+
586
+ // ...then a conditional request carrying it gets a 304
580
587
  let statusCode
581
588
  let ended = false
582
589
  const req = {
583
590
  auth: { isSuper: true },
584
591
  apiData: { query: { _courseId: COURSE_ID } },
585
- headers: { 'if-modified-since': new Date('2025-01-02T00:00:00Z').toUTCString() }
592
+ headers: { 'if-none-match': etag }
586
593
  }
587
594
  const res = {
588
595
  status: mock.fn(function (code) { statusCode = code; return this }),
@@ -595,6 +602,31 @@ describe('ContentModule', () => {
595
602
  assert.equal(next.mock.callCount(), 0)
596
603
  })
597
604
 
605
+ it('should serve the tree (not 304) when the ETag no longer matches the response shape', async () => {
606
+ // a cached ETag from before a treeFields change must not short-circuit to
607
+ // 304 — otherwise the new field stays missing for unedited courses
608
+ const inst = createInstance({
609
+ findOne: mock.fn(async () => ({ updatedAt: new Date('2025-01-01T00:00:00Z') })),
610
+ find: mock.fn(async () => [{ _id: COURSE_ID, _type: 'course', _courseId: COURSE_ID }])
611
+ })
612
+ let statusCode
613
+ const req = {
614
+ auth: { isSuper: true },
615
+ apiData: { query: { _courseId: COURSE_ID } },
616
+ headers: { 'if-none-match': 'W/"1735689600000-deadbeef"' }
617
+ }
618
+ const res = {
619
+ status: mock.fn(function (code) { statusCode = code; return this }),
620
+ set: mock.fn(),
621
+ json: mock.fn()
622
+ }
623
+ const next = mock.fn()
624
+ await ContentModule.prototype.handleTree.call(inst, req, res, next)
625
+ assert.equal(statusCode, undefined, 'did not 304')
626
+ assert.equal(res.json.mock.callCount(), 1, 'served the tree')
627
+ assert.equal(next.mock.callCount(), 0)
628
+ })
629
+
598
630
  it('should return items with _children when content has been modified', async () => {
599
631
  const lastModified = new Date('2025-01-15T00:00:00Z')
600
632
  const items = [
@@ -612,9 +644,9 @@ describe('ContentModule', () => {
612
644
  headers: {}
613
645
  }
614
646
  let responseData
615
- let lastModifiedHeader
647
+ let etagHeader
616
648
  const res = {
617
- set: mock.fn((key, val) => { if (key === 'Last-Modified') lastModifiedHeader = val }),
649
+ set: mock.fn((key, val) => { if (key === 'ETag') etagHeader = val }),
618
650
  json: mock.fn((data) => { responseData = data })
619
651
  }
620
652
  const next = mock.fn()
@@ -631,8 +663,8 @@ describe('ContentModule', () => {
631
663
  // article should have no children
632
664
  const art = responseData.find(i => i._id === 'art1')
633
665
  assert.deepEqual(art._children, [])
634
- // Last-Modified header should be set
635
- assert.equal(lastModifiedHeader, lastModified.toUTCString())
666
+ // ETag header should be set
667
+ assert.match(etagHeader, /^W\/".+"$/)
636
668
  })
637
669
 
638
670
  it('should call next on error', async () => {
@@ -0,0 +1,22 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fieldsToProjection from '../lib/utils/fieldsToProjection.js'
4
+
5
+ describe('fieldsToProjection', () => {
6
+ it('maps each field name to 1', () => {
7
+ assert.deepEqual(fieldsToProjection(['_id', 'title']), { _id: 1, title: 1 })
8
+ })
9
+
10
+ it('returns an empty projection for an empty array', () => {
11
+ assert.deepEqual(fieldsToProjection([]), {})
12
+ })
13
+
14
+ it('treats a missing argument as empty', () => {
15
+ assert.deepEqual(fieldsToProjection(), {})
16
+ assert.deepEqual(fieldsToProjection(undefined), {})
17
+ })
18
+
19
+ it('collapses duplicate field names to a single key', () => {
20
+ assert.deepEqual(fieldsToProjection(['_id', '_id', 'title']), { _id: 1, title: 1 })
21
+ })
22
+ })
@@ -0,0 +1,38 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import treeEtag from '../lib/utils/treeEtag.js'
4
+
5
+ const updatedAt = new Date('2026-06-18T17:34:00.123Z')
6
+
7
+ describe('treeEtag', () => {
8
+ it('produces a quoted weak ETag', () => {
9
+ assert.match(treeEtag(updatedAt, ['_id', 'title']), /^W\/".+"$/)
10
+ })
11
+
12
+ it('is stable for the same inputs', () => {
13
+ assert.equal(treeEtag(updatedAt, ['_id', 'title']), treeEtag(updatedAt, ['_id', 'title']))
14
+ })
15
+
16
+ it('is independent of field order', () => {
17
+ assert.equal(treeEtag(updatedAt, ['_id', 'title']), treeEtag(updatedAt, ['title', '_id']))
18
+ })
19
+
20
+ it('changes when the field set changes (shape bust)', () => {
21
+ assert.notEqual(treeEtag(updatedAt, ['_id', 'title']), treeEtag(updatedAt, ['_id', 'title', 'heroImage']))
22
+ })
23
+
24
+ it('changes when updatedAt changes (data bust)', () => {
25
+ assert.notEqual(treeEtag(updatedAt, ['_id']), treeEtag(new Date('2026-06-19T00:00:00Z'), ['_id']))
26
+ })
27
+
28
+ it('accepts Date, string and numeric timestamps equivalently', () => {
29
+ const fields = ['_id', 'title']
30
+ assert.equal(treeEtag(updatedAt, fields), treeEtag(updatedAt.toISOString(), fields))
31
+ assert.equal(treeEtag(updatedAt, fields), treeEtag(updatedAt.getTime(), fields))
32
+ })
33
+
34
+ it('treats a missing field list as empty', () => {
35
+ assert.match(treeEtag(updatedAt), /^W\/".+"$/)
36
+ assert.equal(treeEtag(updatedAt), treeEtag(updatedAt, []))
37
+ })
38
+ })