From d25045609daa641629fda60d4ad1394c92b0c299 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Thu, 20 Aug 2026 12:25:52 -0500 Subject: [PATCH 1/5] First pass at tests --- __tests__/core_provider_contract.test.js | 5 + __tests__/routes_mounted.test.js | 5 +- __tests__/utils.test.js | 191 ++++++++++++++- routes/__tests__/id.test.js | 296 ++++++++++++++++++++++- routes/__tests__/route_wrappers.test.js | 11 + 5 files changed, 504 insertions(+), 4 deletions(-) diff --git a/__tests__/core_provider_contract.test.js b/__tests__/core_provider_contract.test.js index c73d1309..6b34a726 100644 --- a/__tests__/core_provider_contract.test.js +++ b/__tests__/core_provider_contract.test.js @@ -199,6 +199,11 @@ const requiredResponseCodes = { // 409 is reachable via slug conflict (utils.createExpressError maps code 11000 → 409). 'PATCH /api/release/{id}': ['200', '400', '401', '403', '404', '409'], 'GET /id/{id}': ['200', '404'], + // The expanded reads are covered in routes/__tests__/id.test.js: 404 on a miss, and a + // POST that reads filters from its body, so it also answers the body-related codes. + 'GET /id/{id}/expanded': ['200', '404'], + 'HEAD /id/{id}/expanded': ['200', '404'], + 'POST /id/{id}/expanded': ['200', '400', '404', '413', '415'], 'GET /since/{id}': ['200', '404'], 'GET /history/{id}': ['200', '404'], // HEAD parity tests in routes/__tests__/{id,since,history,query}.test.js assert 404 on miss; diff --git a/__tests__/routes_mounted.test.js b/__tests__/routes_mounted.test.js index aacb2094..3e40f9aa 100644 --- a/__tests__/routes_mounted.test.js +++ b/__tests__/routes_mounted.test.js @@ -26,7 +26,10 @@ const mountedApiRoutes = [ { name: '/v1/api/delete/{id}', method: 'delete', path: '/v1/api/delete/test-mounted-id' }, { name: '/v1/api/release/{id}', method: 'patch', path: '/v1/api/release/test-mounted-id' }, { name: '/v1/api/search', method: 'post', path: '/v1/api/search', headers: { 'Content-Type': 'text/plain' }, body: 'mounted search' }, - { name: '/v1/api/search/phrase', method: 'post', path: '/v1/api/search/phrase', headers: { 'Content-Type': 'text/plain' }, body: 'mounted phrase search' } + { name: '/v1/api/search/phrase', method: 'post', path: '/v1/api/search/phrase', headers: { 'Content-Type': 'text/plain' }, body: 'mounted phrase search' }, + // A missing record answers 404 here, which an unmounted path would too. PUT is the + // method this route rejects, so a non-404 proves the router is wired up. + { name: '/v1/id/{_id}/expanded', method: 'put', path: '/v1/id/test-mounted-id/expanded' } ] describe('Mounted route surface', () => { diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 900ca7c3..3cb20811 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -9,9 +9,10 @@ import { parseDocumentID, _contextid, idNegotiation, - getPagination + getPagination, + findLeafAnnotationsFor } from '../controllers/utils.js' -import { db, resetMocks } from '../database/index.js' +import { db, resetMocks, createCursor } from '../database/index.js' describe('utils.js auth gates', () => { it('isDeleted returns true only for objects with __deleted', () => { @@ -49,6 +50,56 @@ describe('utils.js configureRerumOptions', () => { ) assert.strictEqual(result.__rerum.generatedBy, 'https://store.rerum.io/v1/id/legitimate-agent') }) + + const AGENT = 'https://store.rerum.io/v1/id/legitimate-agent' + const RECEIVED_ID = 'https://store.rerum.io/v1/id/received-id' + const FORGED = { + history: { prime: 'https://store.rerum.io/v1/id/forged-prime', previous: 'https://store.rerum.io/v1/id/forged-previous', next: ['https://store.rerum.io/v1/id/forged-next'] }, + releases: { previous: 'https://store.rerum.io/v1/id/forged-release', next: [], replaces: '' } + } + + it('ignores a client-supplied __rerum when minting a new object', () => { + const created = utils.configureRerumOptions(AGENT, { '@id': RECEIVED_ID, __rerum: structuredClone(FORGED) }, false, false) + assert.strictEqual(created.__rerum.history.prime, 'root') + assert.strictEqual(created.__rerum.history.previous, '') + assert.deepStrictEqual(created.__rerum.history.next, []) + assert.strictEqual(created.__rerum.releases.previous, '') + + // An external object imported through an update is also a root, but it remembers its external self. + const imported = utils.configureRerumOptions(AGENT, { '@id': 'https://elsewhere.example.org/thing', __rerum: structuredClone(FORGED) }, false, true) + assert.strictEqual(imported.__rerum.history.prime, 'root') + assert.strictEqual(imported.__rerum.history.previous, 'https://elsewhere.example.org/thing') + assert.strictEqual(imported.__rerum.releases.previous, '') + }) + + it('carries the version and release chain forward when updating', () => { + const fromRoot = utils.configureRerumOptions( + AGENT, + { '@id': RECEIVED_ID, __rerum: { history: { prime: 'root', previous: '', next: [] } } }, + true, + false + ) + assert.strictEqual(fromRoot.__rerum.history.prime, RECEIVED_ID, 'the root object cannot pass "root" on as the prime') + assert.strictEqual(fromRoot.__rerum.history.previous, RECEIVED_ID) + + const PRIME = 'https://store.rerum.io/v1/id/prime-id' + const RELEASE = 'https://store.rerum.io/v1/id/released-id' + const fromDescendant = utils.configureRerumOptions( + AGENT, + { + '@id': RECEIVED_ID, + __rerum: { + history: { prime: PRIME, previous: 'https://store.rerum.io/v1/id/older-id', next: [] }, + releases: { previous: RELEASE, next: [], replaces: '' } + } + }, + true, + false + ) + assert.strictEqual(fromDescendant.__rerum.history.prime, PRIME, 'an object that knows its prime passes it on') + assert.strictEqual(fromDescendant.__rerum.history.previous, RECEIVED_ID) + assert.strictEqual(fromDescendant.__rerum.releases.previous, RELEASE) + }) }) describe('controllers/utils.js generateSlugId', () => { @@ -219,6 +270,14 @@ describe('controllers/utils.js _contextid', () => { assert.strictEqual(_contextid(123), false) assert.strictEqual(_contextid({}), false) }) + + it('skips non-string members of an array, such as an inline term definition', () => { + assert.strictEqual( + _contextid([{ '@vocab': 'http://example.org/terms#' }, 'http://www.w3.org/ns/anno.jsonld']), + true + ) + assert.strictEqual(_contextid([{ '@vocab': 'http://example.org/terms#' }]), false) + }) }) describe('controllers/utils.js idNegotiation edge cases', () => { @@ -288,3 +347,131 @@ describe('controllers/utils.js getPagination', () => { assert.ok(result.limit < huge, `limit should be clamped below ${huge}`) }) }) + +describe('utils.js isContainerType', () => { + it('detects a container type in a string or a JSON-LD Array of types', () => { + assert.strictEqual(utils.isContainerType({ '@type': 'AnnotationPage' }), true) + assert.strictEqual(utils.isContainerType({ type: 'sc:AnnotationList' }), true, 'prefixed spellings still match') + assert.strictEqual(utils.isContainerType({ type: ['Manifest', 'Collection'] }), true) + assert.strictEqual(utils.isContainerType({ type: [null, 42, 'AnnotationPage'] }), true, 'non-string members are skipped, not thrown on') + assert.strictEqual(utils.isContainerType({ type: ['Manifest', 'Image'] }), false) + assert.strictEqual(utils.isContainerType({}), false) + }) +}) + +describe('controllers/utils.js findLeafAnnotationsFor', () => { + const ENTITY_URI = 'https://store.rerum.io/v1/id/entity-id' + const SLUG_URI = 'https://store.rerum.io/v1/id/entity-slug' + const TARGET_KEYS = ['target', 'target.@id', 'target.id', 'target.source', 'target.source.@id', 'target.source.id'] + + let capturedQuery + let findCalls + + /** + * Point db.find() at a cursor over the given documents and record the filter it was called with. + * + * @param docs The Annotation documents the cursor will yield. + * @return The cursor double, so a test can inspect it. + */ + function armFind(docs = []) { + resetMocks() + capturedQuery = undefined + findCalls = 0 + const cursor = createCursor(docs) + db.find.mockImplementation(query => { + findCalls++ + capturedQuery = query + return cursor + }) + return cursor + } + + // $and[0] holds the target conditions, $and[1] the Annotation type conditions. + const targetConditions = () => capturedQuery.$and[0].$or + + it('constrains the query to the leaf versions, every target key, and every type spelling', async () => { + armFind() + + await findLeafAnnotationsFor([ENTITY_URI, SLUG_URI, ENTITY_URI, undefined, '']) + + assert.deepStrictEqual(capturedQuery['__rerum.history.next'], { $exists: true, $size: 0 }) + for (const targetKey of TARGET_KEYS) { + const values = targetConditions() + .filter(condition => Object.hasOwn(condition, targetKey)) + .map(condition => condition[targetKey]) + assert.strictEqual(values.length, 8, `${targetKey}: two URIs, each in two schemes and as a fragment`) + assert.ok(values.includes(ENTITY_URI) && values.includes(ENTITY_URI.replace(/^https/, 'http'))) + assert.ok(values.includes(SLUG_URI), 'every URI the entity answers to is targeted') + const patterns = values.filter(value => value instanceof RegExp) + assert.ok(patterns.some(pattern => pattern.test(`${ENTITY_URI}#xywh=0,0,100,100`)), 'a fragment of the URI is a match') + assert.ok( + patterns.every(pattern => !pattern.test('https://storeXrerum.io/v1/id/entity-id#xywh=0,0,100,100')), + 'the URI is escaped, so its dots are not wildcards' + ) + } + assert.deepStrictEqual(capturedQuery.$and[1].$or, [ + { type: 'Annotation' }, + { type: 'oa:Annotation' }, + { type: 'http://www.w3.org/ns/oa#Annotation' }, + { type: 'https://www.w3.org/ns/oa#Annotation' }, + { '@type': 'Annotation' }, + { '@type': 'oa:Annotation' }, + { '@type': 'http://www.w3.org/ns/oa#Annotation' }, + { '@type': 'https://www.w3.org/ns/oa#Annotation' } + ]) + + armFind() + await findLeafAnnotationsFor('bare-slug') + assert.strictEqual(targetConditions().length, TARGET_KEYS.length, 'a non-URI target has no scheme or fragment to anticipate') + }) + + it('ANDs in the supplied filters, doubling the URI scheme for a generator or creator', async () => { + armFind() + + await findLeafAnnotationsFor(ENTITY_URI, { + '__rerum.generatedBy': 'https://store.rerum.io/v1/id/agent007', + creator: 'Fred', + motivation: 'describing' + }) + + assert.deepStrictEqual(capturedQuery.$and.slice(2), [ + { + $or: [ + { '__rerum.generatedBy': 'http://store.rerum.io/v1/id/agent007' }, + { '__rerum.generatedBy': 'https://store.rerum.io/v1/id/agent007' } + ] + }, + { creator: 'Fred' }, + { motivation: 'describing' } + ]) + }) + + it('returns the matches sorted by _id, with _id dropped, read in batched strides', async () => { + const cursor = armFind([ + { _id: 'ccc', order: 'third' }, + { _id: 'aaa', order: 'first' }, + { _id: 'bbb', order: 'second' } + ]) + let requestedBatchSize + const chainable = cursor.batchSize + cursor.batchSize = size => { + requestedBatchSize = size + return chainable.call(cursor, size) + } + + const result = await findLeafAnnotationsFor(ENTITY_URI) + + assert.deepStrictEqual(result.map(match => match.order), ['first', 'second', 'third']) + assert.deepStrictEqual(result.map(match => Object.hasOwn(match, '_id')), [false, false, false]) + assert.ok(requestedBatchSize > 0, 'the driver must be told how big a stride to transfer') + }) + + it('returns an empty Array without querying when there is no target', async () => { + armFind([{ _id: 'anno1' }]) + + const result = await findLeafAnnotationsFor([null, '', undefined]) + + assert.deepStrictEqual(result, []) + assert.strictEqual(findCalls, 0, 'an empty $or is a MongoDB error, not an empty result') + }) +}) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index e6701fc6..522d64ba 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -9,6 +9,10 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) +// The /expanded sub-path is mounted first because the prefix-matching /id/:_id mount below would +// otherwise swallow it. This mirrors the route order in routes/id.js. +routeTester.use("/id/:_id/expanded", controller.idExpanded) + // Mount our own /id route without auth, matching routes/id.js: GET only, no HEAD handler. routeTester.use("/id/:_id", controller.id) @@ -31,7 +35,7 @@ const mockDoc = { } // Import db mock so we can configure per-test behaviour -import { db, resetMocks } from '../../database/index.js' +import { db, resetMocks, createCursor } from '../../database/index.js' beforeEach(() => { resetMocks() @@ -121,3 +125,293 @@ describe('id route overwrite headers', () => { assert.strictEqual(response.headers['current-overwritten-version'], '') }) }) + +// Fixtures for GET|POST /id/:_id/expanded. This record's '@context' is not one of the known +// id-negotiation contexts, so it keeps its '@id' through the response. +const EXPAND_ID = "expandme123" +const EXPAND_URI = `${MOCK_PREFIX}${EXPAND_ID}` +const EVIL_URI = "https://evil.example.org/hijacked" + +const expandableDoc = { + _id: EXPAND_ID, + "@id": EXPAND_URI, + "@context": "http://www.loc.gov/mods", + "@type": "named-gloss", + title: "A Gloss", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +let annoCount = 0 + +/** + * Build a leaf Annotation targeting the expandable record. Each one needs its own '_id' because + * findLeafAnnotationsFor() sorts on it before dropping it, which fixes the merge order. + * + * @param props The Annotation properties under test, usually a body. + * @return An Annotation document. + */ +function anno(props) { + annoCount++ + return { + _id: `anno${String(annoCount).padStart(3, "0")}`, + type: "Annotation", + target: EXPAND_URI, + ...props + } +} + +// The MongoDB filter the controller built for the last expansion, or undefined when it never queried. +let capturedQuery + +/** + * Arm the database double for a single /expanded request. + * + * @param record The document db.findOne() will answer with. + * @param annos The Annotation documents the cursor will yield. + */ +function armExpansion(record, annos = []) { + capturedQuery = undefined + db.findOne.mockResolvedValueOnce(structuredClone(record)) + db.find.mockImplementationOnce(query => { + capturedQuery = query + return createCursor(annos) + }) +} + +describe('GET /id/:id/expanded', () => { + it("'/id/:id/expanded' route functions", async () => { + armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 200) + assert.strictEqual(response.body["@id"], EXPAND_URI) + assert.strictEqual(response.body.subject, "history") + assert.strictEqual(response.body._id, undefined) + assert.strictEqual(Object.keys(response.body).at(-1), '__rerum', '__rerum stays last') + assert.strictEqual(response.headers['annotations-gathered'], '1') + assert.strictEqual(response.headers['annotations-merged'], '1') + assert.strictEqual(response.headers['cache-control'], 'max-age=86400, must-revalidate') + assert.strictEqual(response.headers['current-overwritten-version'], '') + }) + + it('merges the anticipated body formats, collecting collisions into an Array', async () => { + const textualBody = { type: "TextualBody", value: "bare spelling", format: "text/plain", language: "en" } + const prefixedBody = { "@type": "oa:TextualBody", value: "oa spelling" } + const arrayTypedBody = { type: ["TextualBody"], value: "Array-typed spelling" } + armExpansion(expandableDoc, [ + anno({ body: { subject: "history" } }), + anno({ body: [{ era: "medieval" }] }), + anno({ bodyValue: "the W3C shorthand" }), + anno({ body: textualBody }), + anno({ body: prefixedBody }), + anno({ body: [arrayTypedBody] }), + anno({ body: { title: "An Annotated Title" } }), + anno({ body: { colors: ["red", "blue"] } }), + anno({ body: { colors: ["black"] } }) + ]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.body.subject, "history", 'a single-key body merges as a raw value') + assert.strictEqual(response.body.era, "medieval", 'a one-element Array is the same body unwrapped') + // A TextualBody is kept whole so its format and language survive, whatever the type spelling. + assert.deepStrictEqual(response.body.bodyValue, + ["the W3C shorthand", textualBody, prefixedBody, arrayTypedBody]) + assert.deepStrictEqual(response.body.title, ["A Gloss", "An Annotated Title"], 'the record value comes first') + assert.deepStrictEqual(response.body.colors, ["red", "blue", "black"], 'Array contributions flatten') + }) + + it('does not merge Annotations that make no single assertion', async () => { + armExpansion(expandableDoc, [ + anno({ body: [{ first: "one" }, { second: "two" }] }), + anno({ body: { subject: "history", note: "extra" } }), + anno({ body: "https://store.rerum.io/v1/id/an-external-body" }), + anno({ motivation: "bookmarking" }), + anno({ body: { merged: "yes" } }) + ]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + for (const key of ['first', 'second', 'subject', 'note', 'motivation']) { + assert.strictEqual(response.body[key], undefined, `${key} must not be merged`) + } + assert.strictEqual(response.body.merged, "yes") + assert.strictEqual(response.headers['annotations-gathered'], '5') + assert.strictEqual(response.headers['annotations-merged'], '1', 'only the contributing Annotation counts') + }) + + it('never lets an Annotation body overwrite identity or system properties', async () => { + armExpansion(expandableDoc, [ + anno({ body: { "@id": EVIL_URI } }), + anno({ body: { id: EVIL_URI } }), + anno({ body: { _id: "evil-id" } }), + anno({ body: { __rerum: { evil: true } } }), + anno({ body: { __deleted: { time: "2025-01-01T00:00:00.000" } } }), + anno({ body: { "@context": "https://evil.example.org/context.json" } }), + // An object literal with a __proto__ key sets the prototype instead of creating an own + // property. JSON.parse creates the own property, which is what a MongoDB document has. + anno({ body: JSON.parse('{"__proto__":{"polluted":"yes"}}') }) + ]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.body["@id"], EXPAND_URI) + assert.strictEqual(response.body.id, undefined) + assert.strictEqual(response.body._id, undefined) + assert.strictEqual(response.body.__rerum.evil, undefined) + assert.strictEqual(response.body.__rerum.generatedBy, MOCK_AGENT) + assert.strictEqual(response.body.__deleted, undefined) + assert.strictEqual(response.body["@context"], "http://www.loc.gov/mods") + assert.strictEqual(Object.hasOwn(response.body, '__proto__'), false) + assert.strictEqual({}.polluted, undefined, 'Object.prototype must not be polluted') + assert.strictEqual(response.headers['annotations-merged'], '0') + }) + + it('negotiates the id form from the record @context before merging', async () => { + const negotiable = structuredClone(expandableDoc) + negotiable["@context"] = "http://iiif.io/api/presentation/3/context.json" + armExpansion(negotiable, [anno({ body: { subject: "history" } })]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + const negotiatedURI = `${process.env.RERUM_ID_PREFIX}${EXPAND_ID}` + assert.strictEqual(response.body.id, negotiatedURI) + assert.strictEqual(response.body["@id"], undefined) + assert.strictEqual(response.headers.location, negotiatedURI) + assert.strictEqual(response.body.subject, "history", 'the expansion still happens') + }) + + it('gathers Annotations that target the record by its Slug', async () => { + const slugged = structuredClone(expandableDoc) + slugged.__rerum.slug = "my-slug" + armExpansion(slugged, []) + + await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + const exactTargets = capturedQuery.$and[0].$or + .filter(condition => typeof condition.target === "string") + .map(condition => condition.target) + assert.ok(exactTargets.includes(EXPAND_URI), 'the _id URI must be targeted') + assert.ok(exactTargets.includes(`${MOCK_PREFIX}my-slug`), 'the slug URI must be targeted too') + }) + + it('returns the tombstone unexpanded for a deleted record', async () => { + const deletedDoc = structuredClone(expandableDoc) + deletedDoc.__deleted = { time: "2025-01-01T00:00:00.000", deletor: MOCK_AGENT } + armExpansion(deletedDoc, [anno({ body: { subject: "history" } })]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 200) + assert.ok(response.body.__deleted) + assert.strictEqual(response.body.subject, undefined) + assert.strictEqual(response.headers['annotations-gathered'], '0') + assert.strictEqual(capturedQuery, undefined, 'a deleted record is not queried for Annotations') + }) + + it('returns 404 when the object is not in RERUM', async () => { + db.findOne.mockResolvedValueOnce(null) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 404) + }) + + it('filters the expansion by the generator and creator parameters', async () => { + armExpansion(expandableDoc, []) + + await request(routeTester).get(`/id/${EXPAND_ID}/expanded?generator=${MOCK_AGENT}&creator=Fred`) + + // $and[0] is the target condition and $and[1] the Annotation type condition. + assert.deepStrictEqual(capturedQuery.$and.slice(2), [ + { + $or: [ + { "__rerum.generatedBy": MOCK_AGENT.replace(/^https/, "http") }, + { "__rerum.generatedBy": MOCK_AGENT } + ] + }, + { creator: "Fred" } + ]) + + armExpansion(expandableDoc, []) + await request(routeTester).get(`/id/${EXPAND_ID}/expanded?generator=one&generator=two&creator=&limit=5`) + assert.deepStrictEqual(capturedQuery.$and.slice(2), [], + 'repeated, empty, and unrelated parameters are not filters') + }) +}) + +describe('POST /id/:id/expanded', () => { + it('reads literal filter keys from the body and ignores the ones the endpoint owns', async () => { + armExpansion(expandableDoc, []) + + const response = await request(routeTester) + .post(`/id/${EXPAND_ID}/expanded?generator=${MOCK_AGENT}`) + .set('Content-Type', 'application/json') + .send({ + target: EVIL_URI, + "target.id": EVIL_URI, + type: "Dataset", + "@type": "Dataset", + "__rerum.history": { next: [] }, + "__rerum.history.next": { $size: 3 }, + motivation: "describing" + }) + + assert.strictEqual(response.statusCode, 200) + assert.deepStrictEqual(capturedQuery.$and.slice(2), [{ motivation: "describing" }], + 'the reserved keys and the URL parameters are not filters') + assert.strictEqual(response.headers['cache-control'], undefined, + 'a filtered read is not browser-cacheable') + }) + + it('expands unfiltered when no body is supplied', async () => { + armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) + + const response = await request(routeTester).post(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 200) + assert.strictEqual(response.body.subject, "history") + assert.deepStrictEqual(capturedQuery.$and.slice(2), []) + }) + + it('returns 400 when the body is an Array instead of a filter object', async () => { + db.findOne.mockResolvedValueOnce(structuredClone(expandableDoc)) + + const response = await request(routeTester) + .post(`/id/${EXPAND_ID}/expanded`) + .set('Content-Type', 'application/json') + .send([{ motivation: "describing" }]) + + assert.strictEqual(response.statusCode, 400) + }) +}) + +// RFC 9110 s9.3.2: HEAD sends the same headers a GET would. +describe('HEAD /id/:id/expanded', () => { + it('sends the same headers and Content-Length as the GET, with no body', async () => { + armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) + const getResp = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) + const headResp = await request(routeTester).head(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(headResp.statusCode, 200) + assert.ok(Number(getResp.headers['content-length']) > 0, 'GET must report a Content-Length') + assert.strictEqual(headResp.headers['content-length'], getResp.headers['content-length']) + assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against') + for (const header of ['cache-control', 'etag', 'content-type', 'link', 'allow', + 'current-overwritten-version', 'location', 'annotations-gathered', 'annotations-merged']) { + assert.strictEqual(headResp.headers[header], getResp.headers[header], + `HEAD and GET must agree on ${header}`) + } + assert.ok(!headResp.body || Object.keys(headResp.body).length === 0) + }) +}) diff --git a/routes/__tests__/route_wrappers.test.js b/routes/__tests__/route_wrappers.test.js index e4522a83..47cd4c0f 100644 --- a/routes/__tests__/route_wrappers.test.js +++ b/routes/__tests__/route_wrappers.test.js @@ -22,6 +22,7 @@ import searchRouter from '../search.js' import queryRouter from '../query.js' import releaseRouter from '../release.js' import apiRoutesRouter from '../api-routes.js' +import rest from '../../rest.js' import gogFragmentsRouter from '../_gog_fragments_from_manuscript.js' import gogGlossesRouter from '../_gog_glosses_from_manuscript.js' @@ -208,6 +209,7 @@ describe('unsupported-method 405 fallbacks', () => { { label: '/delete/:_id', router: deleteRouter, path: '/:_id' }, { label: '/history/:_id', router: historyRouter, path: '/:_id' }, { label: '/id/:_id', router: idRouter, path: '/:_id' }, + { label: '/id/:_id/expanded', router: idRouter, path: '/:_id/expanded' }, { label: '/since/:_id', router: sinceRouter, path: '/:_id' }, { label: '/_gog_fragments_from_manuscript', router: gogFragmentsRouter, path: '/' }, { label: '/_gog_glosses_from_manuscript', router: gogGlossesRouter, path: '/' } @@ -220,6 +222,15 @@ describe('unsupported-method 405 fallbacks', () => { } }) +describe('id route content-type wiring', () => { + it('runs the JSON content-type check before the expanded controller on POST', () => { + const postLayers = getMethodLayers(idRouter, '/:_id/expanded', 'post') + + assert.strictEqual(postLayers.length, 2, 'Expected a middleware layer and a controller layer') + assert.strictEqual(postLayers[0].handle, rest.verifyJsonContentType) + }) +}) + describe('api routes discovery', () => { it('directly serves the welcome page from the static route handler', () => { const handler = getMethodLayers(staticRouter, '/', 'get').at(-1) From 6a039b3a06634423f7b8c4d1f577f3abb36c8e20 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Thu, 20 Aug 2026 12:49:52 -0500 Subject: [PATCH 2/5] Reduce and simplify --- __tests__/routes_mounted.test.js | 2 - __tests__/utils.test.js | 84 +++---------------------- routes/__tests__/id.test.js | 46 ++++++-------- routes/__tests__/route_wrappers.test.js | 10 --- 4 files changed, 27 insertions(+), 115 deletions(-) diff --git a/__tests__/routes_mounted.test.js b/__tests__/routes_mounted.test.js index 3e40f9aa..abc16814 100644 --- a/__tests__/routes_mounted.test.js +++ b/__tests__/routes_mounted.test.js @@ -27,8 +27,6 @@ const mountedApiRoutes = [ { name: '/v1/api/release/{id}', method: 'patch', path: '/v1/api/release/test-mounted-id' }, { name: '/v1/api/search', method: 'post', path: '/v1/api/search', headers: { 'Content-Type': 'text/plain' }, body: 'mounted search' }, { name: '/v1/api/search/phrase', method: 'post', path: '/v1/api/search/phrase', headers: { 'Content-Type': 'text/plain' }, body: 'mounted phrase search' }, - // A missing record answers 404 here, which an unmounted path would too. PUT is the - // method this route rejects, so a non-404 proves the router is wired up. { name: '/v1/id/{_id}/expanded', method: 'put', path: '/v1/id/test-mounted-id/expanded' } ] diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 3cb20811..d57fa28c 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -72,7 +72,7 @@ describe('utils.js configureRerumOptions', () => { assert.strictEqual(imported.__rerum.releases.previous, '') }) - it('carries the version and release chain forward when updating', () => { + it('carries the version and release chain forward when updating an existing object', () => { const fromRoot = utils.configureRerumOptions( AGENT, { '@id': RECEIVED_ID, __rerum: { history: { prime: 'root', previous: '', next: [] } } }, @@ -263,6 +263,12 @@ describe('controllers/utils.js _contextid', () => { _contextid(['http://example.com/other', 'http://iiif.io/api/presentation/3/context.json']), true ) + // An inline term definition object, or any other non-string member, names no context. + assert.strictEqual( + _contextid([{ '@vocab': 'http://example.org/terms#' }, 'http://www.w3.org/ns/anno.jsonld']), + true + ) + assert.strictEqual(_contextid([{ '@vocab': 'http://example.org/terms#' }]), false) }) it('returns false for non-string, non-array input', () => { @@ -270,14 +276,6 @@ describe('controllers/utils.js _contextid', () => { assert.strictEqual(_contextid(123), false) assert.strictEqual(_contextid({}), false) }) - - it('skips non-string members of an array, such as an inline term definition', () => { - assert.strictEqual( - _contextid([{ '@vocab': 'http://example.org/terms#' }, 'http://www.w3.org/ns/anno.jsonld']), - true - ) - assert.strictEqual(_contextid([{ '@vocab': 'http://example.org/terms#' }]), false) - }) }) describe('controllers/utils.js idNegotiation edge cases', () => { @@ -348,42 +346,25 @@ describe('controllers/utils.js getPagination', () => { }) }) -describe('utils.js isContainerType', () => { - it('detects a container type in a string or a JSON-LD Array of types', () => { - assert.strictEqual(utils.isContainerType({ '@type': 'AnnotationPage' }), true) - assert.strictEqual(utils.isContainerType({ type: 'sc:AnnotationList' }), true, 'prefixed spellings still match') - assert.strictEqual(utils.isContainerType({ type: ['Manifest', 'Collection'] }), true) - assert.strictEqual(utils.isContainerType({ type: [null, 42, 'AnnotationPage'] }), true, 'non-string members are skipped, not thrown on') - assert.strictEqual(utils.isContainerType({ type: ['Manifest', 'Image'] }), false) - assert.strictEqual(utils.isContainerType({}), false) - }) -}) - describe('controllers/utils.js findLeafAnnotationsFor', () => { const ENTITY_URI = 'https://store.rerum.io/v1/id/entity-id' const SLUG_URI = 'https://store.rerum.io/v1/id/entity-slug' const TARGET_KEYS = ['target', 'target.@id', 'target.id', 'target.source', 'target.source.@id', 'target.source.id'] let capturedQuery - let findCalls /** * Point db.find() at a cursor over the given documents and record the filter it was called with. * * @param docs The Annotation documents the cursor will yield. - * @return The cursor double, so a test can inspect it. */ function armFind(docs = []) { resetMocks() capturedQuery = undefined - findCalls = 0 - const cursor = createCursor(docs) db.find.mockImplementation(query => { - findCalls++ capturedQuery = query - return cursor + return createCursor(docs) }) - return cursor } // $and[0] holds the target conditions, $and[1] the Annotation type conditions. @@ -425,53 +406,4 @@ describe('controllers/utils.js findLeafAnnotationsFor', () => { assert.strictEqual(targetConditions().length, TARGET_KEYS.length, 'a non-URI target has no scheme or fragment to anticipate') }) - it('ANDs in the supplied filters, doubling the URI scheme for a generator or creator', async () => { - armFind() - - await findLeafAnnotationsFor(ENTITY_URI, { - '__rerum.generatedBy': 'https://store.rerum.io/v1/id/agent007', - creator: 'Fred', - motivation: 'describing' - }) - - assert.deepStrictEqual(capturedQuery.$and.slice(2), [ - { - $or: [ - { '__rerum.generatedBy': 'http://store.rerum.io/v1/id/agent007' }, - { '__rerum.generatedBy': 'https://store.rerum.io/v1/id/agent007' } - ] - }, - { creator: 'Fred' }, - { motivation: 'describing' } - ]) - }) - - it('returns the matches sorted by _id, with _id dropped, read in batched strides', async () => { - const cursor = armFind([ - { _id: 'ccc', order: 'third' }, - { _id: 'aaa', order: 'first' }, - { _id: 'bbb', order: 'second' } - ]) - let requestedBatchSize - const chainable = cursor.batchSize - cursor.batchSize = size => { - requestedBatchSize = size - return chainable.call(cursor, size) - } - - const result = await findLeafAnnotationsFor(ENTITY_URI) - - assert.deepStrictEqual(result.map(match => match.order), ['first', 'second', 'third']) - assert.deepStrictEqual(result.map(match => Object.hasOwn(match, '_id')), [false, false, false]) - assert.ok(requestedBatchSize > 0, 'the driver must be told how big a stride to transfer') - }) - - it('returns an empty Array without querying when there is no target', async () => { - armFind([{ _id: 'anno1' }]) - - const result = await findLeafAnnotationsFor([null, '', undefined]) - - assert.deepStrictEqual(result, []) - assert.strictEqual(findCalls, 0, 'an empty $or is a MongoDB error, not an empty result') - }) }) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 522d64ba..77c49753 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -126,8 +126,7 @@ describe('id route overwrite headers', () => { }) }) -// Fixtures for GET|POST /id/:_id/expanded. This record's '@context' is not one of the known -// id-negotiation contexts, so it keeps its '@id' through the response. +// Fixtures for GET|POST /id/:_id/expanded. const EXPAND_ID = "expandme123" const EXPAND_URI = `${MOCK_PREFIX}${EXPAND_ID}` const EVIL_URI = "https://evil.example.org/hijacked" @@ -200,13 +199,25 @@ describe('GET /id/:id/expanded', () => { assert.strictEqual(response.headers['annotations-merged'], '1') assert.strictEqual(response.headers['cache-control'], 'max-age=86400, must-revalidate') assert.strictEqual(response.headers['current-overwritten-version'], '') + + // A container-typed record gets the Web Annotation Link header, including when its type is + // serialized as a JSON-LD Array. + const container = structuredClone(expandableDoc) + container["@type"] = ["Manifest", "AnnotationPage"] + armExpansion(container, []) + const containerResp = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + assert.match(containerResp.headers.link, /anno\.jsonld/) + + db.findOne.mockResolvedValueOnce(null) + const missResp = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + assert.strictEqual(missResp.statusCode, 404, 'a record that is not in RERUM has no expansion') }) it('merges the anticipated body formats, collecting collisions into an Array', async () => { const textualBody = { type: "TextualBody", value: "bare spelling", format: "text/plain", language: "en" } const prefixedBody = { "@type": "oa:TextualBody", value: "oa spelling" } const arrayTypedBody = { type: ["TextualBody"], value: "Array-typed spelling" } - armExpansion(expandableDoc, [ + const gathered = [ anno({ body: { subject: "history" } }), anno({ body: [{ era: "medieval" }] }), anno({ bodyValue: "the W3C shorthand" }), @@ -216,7 +227,10 @@ describe('GET /id/:id/expanded', () => { anno({ body: { title: "An Annotated Title" } }), anno({ body: { colors: ["red", "blue"] } }), anno({ body: { colors: ["black"] } }) - ]) + ] + // The query plan promises no order, so hand them over reversed. The expansion sorts by '_id' + // before merging, which is what makes the assembled entity reproducible. + armExpansion(expandableDoc, [...gathered].reverse()) const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) @@ -275,20 +289,6 @@ describe('GET /id/:id/expanded', () => { assert.strictEqual(response.headers['annotations-merged'], '0') }) - it('negotiates the id form from the record @context before merging', async () => { - const negotiable = structuredClone(expandableDoc) - negotiable["@context"] = "http://iiif.io/api/presentation/3/context.json" - armExpansion(negotiable, [anno({ body: { subject: "history" } })]) - - const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) - - const negotiatedURI = `${process.env.RERUM_ID_PREFIX}${EXPAND_ID}` - assert.strictEqual(response.body.id, negotiatedURI) - assert.strictEqual(response.body["@id"], undefined) - assert.strictEqual(response.headers.location, negotiatedURI) - assert.strictEqual(response.body.subject, "history", 'the expansion still happens') - }) - it('gathers Annotations that target the record by its Slug', async () => { const slugged = structuredClone(expandableDoc) slugged.__rerum.slug = "my-slug" @@ -317,14 +317,6 @@ describe('GET /id/:id/expanded', () => { assert.strictEqual(capturedQuery, undefined, 'a deleted record is not queried for Annotations') }) - it('returns 404 when the object is not in RERUM', async () => { - db.findOne.mockResolvedValueOnce(null) - - const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) - - assert.strictEqual(response.statusCode, 404) - }) - it('filters the expansion by the generator and creator parameters', async () => { armExpansion(expandableDoc, []) @@ -382,7 +374,7 @@ describe('POST /id/:id/expanded', () => { assert.deepStrictEqual(capturedQuery.$and.slice(2), []) }) - it('returns 400 when the body is an Array instead of a filter object', async () => { + it('returns 400 when the body is not a filter object', async () => { db.findOne.mockResolvedValueOnce(structuredClone(expandableDoc)) const response = await request(routeTester) diff --git a/routes/__tests__/route_wrappers.test.js b/routes/__tests__/route_wrappers.test.js index 47cd4c0f..c0bb1300 100644 --- a/routes/__tests__/route_wrappers.test.js +++ b/routes/__tests__/route_wrappers.test.js @@ -22,7 +22,6 @@ import searchRouter from '../search.js' import queryRouter from '../query.js' import releaseRouter from '../release.js' import apiRoutesRouter from '../api-routes.js' -import rest from '../../rest.js' import gogFragmentsRouter from '../_gog_fragments_from_manuscript.js' import gogGlossesRouter from '../_gog_glosses_from_manuscript.js' @@ -222,15 +221,6 @@ describe('unsupported-method 405 fallbacks', () => { } }) -describe('id route content-type wiring', () => { - it('runs the JSON content-type check before the expanded controller on POST', () => { - const postLayers = getMethodLayers(idRouter, '/:_id/expanded', 'post') - - assert.strictEqual(postLayers.length, 2, 'Expected a middleware layer and a controller layer') - assert.strictEqual(postLayers[0].handle, rest.verifyJsonContentType) - }) -}) - describe('api routes discovery', () => { it('directly serves the welcome page from the static route handler', () => { const handler = getMethodLayers(staticRouter, '/', 'get').at(-1) From a76195a35fcf6cb69e83ecaeded598c4441134c3 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Thu, 20 Aug 2026 12:52:57 -0500 Subject: [PATCH 3/5] Reduce and simplify --- routes/__tests__/id.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 77c49753..45e4ee71 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -9,8 +9,6 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) -// The /expanded sub-path is mounted first because the prefix-matching /id/:_id mount below would -// otherwise swallow it. This mirrors the route order in routes/id.js. routeTester.use("/id/:_id/expanded", controller.idExpanded) // Mount our own /id route without auth, matching routes/id.js: GET only, no HEAD handler. From 2c1113b263098c2388015b12bdd3cae16761e5cb Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Thu, 20 Aug 2026 13:59:39 -0500 Subject: [PATCH 4/5] changes during review --- __tests__/core_provider_contract.test.js | 2 +- __tests__/utils.test.js | 17 ++++++++- routes/__tests__/id.test.js | 44 +++++++++++++++++++----- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/__tests__/core_provider_contract.test.js b/__tests__/core_provider_contract.test.js index 6b34a726..32b0eb9a 100644 --- a/__tests__/core_provider_contract.test.js +++ b/__tests__/core_provider_contract.test.js @@ -202,13 +202,13 @@ const requiredResponseCodes = { // The expanded reads are covered in routes/__tests__/id.test.js: 404 on a miss, and a // POST that reads filters from its body, so it also answers the body-related codes. 'GET /id/{id}/expanded': ['200', '404'], - 'HEAD /id/{id}/expanded': ['200', '404'], 'POST /id/{id}/expanded': ['200', '400', '404', '413', '415'], 'GET /since/{id}': ['200', '404'], 'GET /history/{id}': ['200', '404'], // HEAD parity tests in routes/__tests__/{id,since,history,query}.test.js assert 404 on miss; // enforce that the contract declares the same so drift on either side is caught. 'HEAD /id/{id}': ['200', '404'], + 'HEAD /id/{id}/expanded': ['200', '404'], 'HEAD /since/{id}': ['200', '404'], 'HEAD /history/{id}': ['200', '404'], 'HEAD /api/query': ['200', '404'], diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index d57fa28c..8ec29a8f 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -54,6 +54,9 @@ describe('utils.js configureRerumOptions', () => { const AGENT = 'https://store.rerum.io/v1/id/legitimate-agent' const RECEIVED_ID = 'https://store.rerum.io/v1/id/received-id' const FORGED = { + generatedBy: 'https://attacker.example/forged', + isReleased: '2020-01-01T00:00:00.000', + isOverwritten: '2020-01-01T00:00:00.000', history: { prime: 'https://store.rerum.io/v1/id/forged-prime', previous: 'https://store.rerum.io/v1/id/forged-previous', next: ['https://store.rerum.io/v1/id/forged-next'] }, releases: { previous: 'https://store.rerum.io/v1/id/forged-release', next: [], replaces: '' } } @@ -64,6 +67,10 @@ describe('utils.js configureRerumOptions', () => { assert.strictEqual(created.__rerum.history.previous, '') assert.deepStrictEqual(created.__rerum.history.next, []) assert.strictEqual(created.__rerum.releases.previous, '') + // isReleased gates release and overwrite, isOverwritten is the optimistic locking token. + assert.strictEqual(created.__rerum.generatedBy, AGENT, 'attribution cannot be forged') + assert.strictEqual(created.__rerum.isReleased, '', 'a client cannot mint a pre-released object') + assert.strictEqual(created.__rerum.isOverwritten, '', 'a client cannot mint a locking token') // An external object imported through an update is also a root, but it remembers its external self. const imported = utils.configureRerumOptions(AGENT, { '@id': 'https://elsewhere.example.org/thing', __rerum: structuredClone(FORGED) }, false, true) @@ -361,7 +368,7 @@ describe('controllers/utils.js findLeafAnnotationsFor', () => { function armFind(docs = []) { resetMocks() capturedQuery = undefined - db.find.mockImplementation(query => { + db.find.mockImplementationOnce(query => { capturedQuery = query return createCursor(docs) }) @@ -406,4 +413,12 @@ describe('controllers/utils.js findLeafAnnotationsFor', () => { assert.strictEqual(targetConditions().length, TARGET_KEYS.length, 'a non-URI target has no scheme or fragment to anticipate') }) + it('gathers nothing rather than querying on an empty $or when there is no target', async () => { + // An empty '$or' is a MongoDB error, not an empty result, so the query is never sent. + armFind([{ _id: 'anno001', type: 'Annotation' }]) + + assert.deepStrictEqual(await findLeafAnnotationsFor([undefined, '', null]), []) + assert.deepStrictEqual(await findLeafAnnotationsFor(undefined), []) + assert.strictEqual(capturedQuery, undefined, 'nothing to target is nothing to gather') + }) }) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 45e4ee71..378f79ca 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -197,18 +197,25 @@ describe('GET /id/:id/expanded', () => { assert.strictEqual(response.headers['annotations-merged'], '1') assert.strictEqual(response.headers['cache-control'], 'max-age=86400, must-revalidate') assert.strictEqual(response.headers['current-overwritten-version'], '') + }) - // A container-typed record gets the Web Annotation Link header, including when its type is - // serialized as a JSON-LD Array. + it('sends the Web Annotation Link header for a container-typed record', async () => { + // The type may be serialized as a JSON-LD Array, which still names a container. const container = structuredClone(expandableDoc) container["@type"] = ["Manifest", "AnnotationPage"] armExpansion(container, []) - const containerResp = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) - assert.match(containerResp.headers.link, /anno\.jsonld/) + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.match(response.headers.link, /anno\.jsonld/) + }) + + it('returns 404 when the object is not in RERUM', async () => { db.findOne.mockResolvedValueOnce(null) - const missResp = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) - assert.strictEqual(missResp.statusCode, 404, 'a record that is not in RERUM has no expansion') + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 404, 'a record that is not in RERUM has no expansion') }) it('merges the anticipated body formats, collecting collisions into an Array', async () => { @@ -365,7 +372,11 @@ describe('POST /id/:id/expanded', () => { it('expands unfiltered when no body is supplied', async () => { armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) - const response = await request(routeTester).post(`/id/${EXPAND_ID}/expanded`) + // routes/id.js gates POST behind rest.verifyJsonContentType, so a client reaches the + // unfiltered expansion by declaring JSON and sending nothing, not by omitting Content-Type. + const response = await request(routeTester) + .post(`/id/${EXPAND_ID}/expanded`) + .set('Content-Type', 'application/json') assert.strictEqual(response.statusCode, 200) assert.strictEqual(response.body.subject, "history") @@ -373,7 +384,8 @@ describe('POST /id/:id/expanded', () => { }) it('returns 400 when the body is not a filter object', async () => { - db.findOne.mockResolvedValueOnce(structuredClone(expandableDoc)) + // Nothing is armed. A rejected body must not reach the record read or the Annotation query. + capturedQuery = undefined const response = await request(routeTester) .post(`/id/${EXPAND_ID}/expanded`) @@ -381,11 +393,20 @@ describe('POST /id/:id/expanded', () => { .send([{ motivation: "describing" }]) assert.strictEqual(response.statusCode, 400) + assert.strictEqual(capturedQuery, undefined, 'a malformed filter body is rejected before any read') }) }) // RFC 9110 s9.3.2: HEAD sends the same headers a GET would. describe('HEAD /id/:id/expanded', () => { + it('returns 404 when the object is not in RERUM', async () => { + db.findOne.mockResolvedValueOnce(null) + + const response = await request(routeTester).head(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 404) + }) + it('sends the same headers and Content-Length as the GET, with no body', async () => { armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) const getResp = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) @@ -397,8 +418,13 @@ describe('HEAD /id/:id/expanded', () => { assert.ok(Number(getResp.headers['content-length']) > 0, 'GET must report a Content-Length') assert.strictEqual(headResp.headers['content-length'], getResp.headers['content-length']) assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against') + // Unlike GET /id/:_id, the expansion sends no Last-Modified. It is assembled from the record + // and its Annotations, which have no single modification time, so revalidate on the ETag. + assert.strictEqual(getResp.headers['last-modified'], undefined, + 'an assembled entity has no single modification time to report') for (const header of ['cache-control', 'etag', 'content-type', 'link', 'allow', - 'current-overwritten-version', 'location', 'annotations-gathered', 'annotations-merged']) { + 'current-overwritten-version', 'location', 'annotations-gathered', 'annotations-merged', + 'last-modified']) { assert.strictEqual(headResp.headers[header], getResp.headers[header], `HEAD and GET must agree on ${header}`) } From 8b856c1e1cb8247401dd915ba10ae41fb7e7e8c7 Mon Sep 17 00:00:00 2001 From: Bryan Haberberger Date: Thu, 20 Aug 2026 14:26:35 -0500 Subject: [PATCH 5/5] changes during review, good to go --- __tests__/core_provider_contract.test.js | 5 +++-- routes/__tests__/id.test.js | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/__tests__/core_provider_contract.test.js b/__tests__/core_provider_contract.test.js index 32b0eb9a..0ebe3c40 100644 --- a/__tests__/core_provider_contract.test.js +++ b/__tests__/core_provider_contract.test.js @@ -199,8 +199,9 @@ const requiredResponseCodes = { // 409 is reachable via slug conflict (utils.createExpressError maps code 11000 → 409). 'PATCH /api/release/{id}': ['200', '400', '401', '403', '404', '409'], 'GET /id/{id}': ['200', '404'], - // The expanded reads are covered in routes/__tests__/id.test.js: 404 on a miss, and a - // POST that reads filters from its body, so it also answers the body-related codes. + // 200/400/404 are asserted in routes/__tests__/id.test.js. 413 comes from the global 5mb + // express.json limit in app.js and 415 from rest.verifyJsonContentType, both covered + // generically in routes/__tests__/{routes_mounted,contentType}.test.js as for the /api routes. 'GET /id/{id}/expanded': ['200', '404'], 'POST /id/{id}/expanded': ['200', '400', '404', '413', '415'], 'GET /since/{id}': ['200', '404'], diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index 378f79ca..5d9f9773 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -37,6 +37,8 @@ import { db, resetMocks, createCursor } from '../../database/index.js' beforeEach(() => { resetMocks() + // Restart the anno() sequence below, so each test's merge order is its own call order. + annoCount = 0 }) it("'/id/:id' route functions", async () => { @@ -325,7 +327,7 @@ describe('GET /id/:id/expanded', () => { it('filters the expansion by the generator and creator parameters', async () => { armExpansion(expandableDoc, []) - await request(routeTester).get(`/id/${EXPAND_ID}/expanded?generator=${MOCK_AGENT}&creator=Fred`) + await request(routeTester).get(`/id/${EXPAND_ID}/expanded?generator=${encodeURIComponent(MOCK_AGENT)}&creator=Fred`) // $and[0] is the target condition and $and[1] the Annotation type condition. assert.deepStrictEqual(capturedQuery.$and.slice(2), [ @@ -350,7 +352,7 @@ describe('POST /id/:id/expanded', () => { armExpansion(expandableDoc, []) const response = await request(routeTester) - .post(`/id/${EXPAND_ID}/expanded?generator=${MOCK_AGENT}`) + .post(`/id/${EXPAND_ID}/expanded?generator=${encodeURIComponent(MOCK_AGENT)}`) .set('Content-Type', 'application/json') .send({ target: EVIL_URI,