adapt-authoring-content 3.2.3 → 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, fieldsToProjection, 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,12 +679,6 @@ 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) {
686
- return res.status(304).end()
687
- }
688
682
  const treeFields = [
689
683
  '_id',
690
684
  '_parentId',
@@ -703,13 +697,21 @@ class ContentModule extends AbstractApiModule {
703
697
  'heroImage',
704
698
  'updatedAt'
705
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) {
706
+ return res.status(304).end()
707
+ }
706
708
  const items = await this.find(
707
709
  { _courseId },
708
710
  { validate: false },
709
711
  { projection: fieldsToProjection(treeFields) }
710
712
  )
711
713
  const tree = new ContentTree(items)
712
- res.set('Last-Modified', lastModified.toUTCString())
714
+ res.set('ETag', etag)
713
715
  res.json(items.map(item => ({
714
716
  ...item,
715
717
  _children: tree.getChildren(item._id).map(c => c._id)
@@ -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
@@ -6,3 +6,4 @@ export { default as contentTypeToSchemaName } from './utils/contentTypeToSchemaN
6
6
  export { default as fieldsToProjection } from './utils/fieldsToProjection.js'
7
7
  export { default as formatFriendlyId } from './utils/formatFriendlyId.js'
8
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.3",
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,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
+ })