From 2a6440fd4deb8de0c4bed66923f500ec5396fa15 Mon Sep 17 00:00:00 2001 From: Karl Pauls Date: Fri, 14 Nov 2025 10:30:10 -0800 Subject: [PATCH] feat: add HTTP conditional request support (If-Match, If-None-Match) --- src/routes/source.js | 2 +- src/routes/version.js | 4 +- src/storage/object/get.js | 106 ++++- src/storage/object/put.js | 8 +- src/storage/utils/version.js | 4 +- src/storage/version/get.js | 4 +- src/storage/version/put.js | 92 +++- src/utils/daCtx.js | 8 + src/utils/daResp.js | 12 +- test/integration/conditional-e2e.test.js | 213 +++++++++ test/storage/object/conditionals.test.js | 552 +++++++++++++++++++++++ test/utils/daCtx.test.js | 60 +++ test/utils/daResp.test.js | 6 +- 13 files changed, 1027 insertions(+), 44 deletions(-) create mode 100644 test/integration/conditional-e2e.test.js create mode 100644 test/storage/object/conditionals.test.js diff --git a/src/routes/source.js b/src/routes/source.js index c2ab5b8c..ea7abd9b 100644 --- a/src/routes/source.js +++ b/src/routes/source.js @@ -40,5 +40,5 @@ export async function postSource({ req, env, daCtx }) { export async function getSource({ env, daCtx, head }) { if (!hasPermission(daCtx, daCtx.key, 'read')) return { status: 403 }; - return getObject(env, daCtx, head); + return getObject(env, daCtx, head, daCtx.conditionalHeaders); } diff --git a/src/routes/version.js b/src/routes/version.js index e6740794..3c7aa75c 100644 --- a/src/routes/version.js +++ b/src/routes/version.js @@ -22,10 +22,10 @@ export async function getVersionList({ env, daCtx }) { export async function getVersionSource({ env, daCtx, head }) { // daCtx.key is something like - // 'f85f9b05-ae48-485b-a3b3-dd203ac5c734/1b7e005b-8602-4053-b920-8e67ad8e8dba.html’ + // 'f85f9b05-ae48-485b-a3b3-dd203ac5c734/1b7e005b-8602-4053-b920-8e67ad8e8dba.html' // so we have to do the permission check when the object is returned from the metadata. - const resp = await getObjectVersion(env, daCtx, head); + const resp = await getObjectVersion(env, daCtx, head, daCtx.conditionalHeaders); if (!hasPermission(daCtx, resp.metadata?.path, 'read')) return { status: 403 }; return resp; } diff --git a/src/storage/object/get.js b/src/storage/object/get.js index adacd953..a0c3ae78 100644 --- a/src/storage/object/get.js +++ b/src/storage/object/get.js @@ -17,18 +17,39 @@ import { import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import getS3Config from '../utils/config.js'; +import { ifMatch, ifNoneMatch } from '../utils/version.js'; function buildInput({ bucket, org, key }) { const Bucket = bucket; return { Bucket, Key: `${org}/${key}` }; } -export default async function getObject(env, { bucket, org, key }, head = false) { +export default async function getObject( + env, + { bucket, org, key }, + head = false, + conditionalHeaders = null, +) { const config = getS3Config(env); - const client = new S3Client(config); + + // Validate conflicting conditionals - per RFC 7232, If-None-Match takes precedence for GET/HEAD + if (conditionalHeaders?.ifMatch && conditionalHeaders?.ifNoneMatch) { + // Both headers present - use If-None-Match for GET/HEAD per RFC 7232 Section 3.2 + // eslint-disable-next-line no-console + console.warn('Both If-Match and If-None-Match provided, using If-None-Match per RFC 7232'); + } const input = buildInput({ bucket, org, key }); if (!head) { + // Apply conditional headers middleware for GET requests + let client; + if (conditionalHeaders?.ifNoneMatch) { + client = ifNoneMatch(config, conditionalHeaders.ifNoneMatch); + } else if (conditionalHeaders?.ifMatch) { + client = ifMatch(config, conditionalHeaders.ifMatch); + } else { + client = new S3Client(config); + } try { const resp = await client.send(new GetObjectCommand(input)); @@ -57,26 +78,69 @@ export default async function getObject(env, { bucket, org, key }, head = false) // eslint-disable-next-line no-console console.error('Error getting object without httpStatusCode', e); } - return { body: '', status: e.$metadata?.httpStatusCode || 500, contentLength: 0 }; + // Handle conditional request failures (304 Not Modified, 412 Precondition Failed) + const status = e.$metadata?.httpStatusCode || 500; + if (status === 304 || status === 412) { + // Include ETag in 304/412 responses per RFC 7232 + return { + body: '', + status, + contentLength: 0, + etag: e.ETag || conditionalHeaders?.ifNoneMatch, + }; + } + return { body: '', status, contentLength: 0 }; } } - const url = await getSignedUrl(client, new HeadObjectCommand(input), { expiresIn: 3600 }); - const resp = await fetch(url, { method: 'HEAD' }); - const Metadata = {}; - resp.headers.forEach((value, key2) => { - if (key2.startsWith('x-amz-meta-')) { - Metadata[key2.substring('x-amz-meta-'.length)] = value; + // HEAD request path - uses presigned URL with fetch + const client = new S3Client(config); + try { + const url = await getSignedUrl(client, new HeadObjectCommand(input), { expiresIn: 3600 }); + const fetchHeaders = {}; + + // Add conditional headers to fetch request + if (conditionalHeaders?.ifNoneMatch) { + fetchHeaders['If-None-Match'] = conditionalHeaders.ifNoneMatch; + } else if (conditionalHeaders?.ifMatch) { + fetchHeaders['If-Match'] = conditionalHeaders.ifMatch; + } + + const resp = await fetch(url, { method: 'HEAD', headers: fetchHeaders }); + + // Handle conditional request failures + if (resp.status === 304 || resp.status === 412) { + return { + body: '', + status: resp.status, + contentLength: 0, + etag: resp.headers.get('etag'), + }; } - }); - return { - body: '', - status: resp.status, - contentType: resp.headers.get('content-type'), - contentLength: resp.headers.get('content-length'), - metadata: { - ...Metadata, - LastModified: resp.headers.get('last-modified'), - }, - etag: resp.headers.get('etag'), - }; + + const Metadata = {}; + resp.headers.forEach((value, key2) => { + if (key2.startsWith('x-amz-meta-')) { + Metadata[key2.substring('x-amz-meta-'.length)] = value; + } + }); + return { + body: '', + status: resp.status, + contentType: resp.headers.get('content-type'), + contentLength: resp.headers.get('content-length'), + metadata: { + ...Metadata, + LastModified: resp.headers.get('last-modified'), + }, + etag: resp.headers.get('etag'), + }; + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error in HEAD request', e); + const status = e.$metadata?.httpStatusCode || 500; + if (status === 304 || status === 412) { + return { body: '', status, contentLength: 0 }; + } + return { body: '', status, contentLength: 0 }; + } } diff --git a/src/storage/object/put.js b/src/storage/object/put.js index 0278746c..7569e466 100644 --- a/src/storage/object/put.js +++ b/src/storage/object/put.js @@ -42,22 +42,24 @@ export default async function putObject(env, daCtx, obj) { const client = new S3Client(config); const { - bucket, org, key, propsKey, + bucket, org, key, propsKey, conditionalHeaders, } = daCtx; const inputs = []; let metadata = {}; let status = 201; + let etag; if (obj) { if (obj.data) { const isFile = obj.data instanceof File; const { body, type } = isFile ? await getFileBody(obj.data) : getObjectBody(obj.data); const res = await putObjectWithVersion(env, daCtx, { bucket, org, key, body, type, - }, false, obj.guid); + }, false, obj.guid, conditionalHeaders); status = res.status; metadata = res.metadata; + etag = res.etag; } } else { const { body, type } = getObjectBody({}); @@ -74,6 +76,6 @@ export default async function putObject(env, daCtx, obj) { const body = sourceRespObject(daCtx); return { - body: JSON.stringify(body), status, contentType: 'application/json', metadata, + body: JSON.stringify(body), status, contentType: 'application/json', metadata, etag, }; } diff --git a/src/storage/utils/version.js b/src/storage/utils/version.js index bc3568d7..81eb2a43 100644 --- a/src/storage/utils/version.js +++ b/src/storage/utils/version.js @@ -13,12 +13,12 @@ import { S3Client, } from '@aws-sdk/client-s3'; -export function ifNoneMatch(config) { +export function ifNoneMatch(config, value = '*') { const client = new S3Client(config); client.middlewareStack.add( (next) => async (args) => { // eslint-disable-next-line no-param-reassign - args.request.headers['If-None-Match'] = '*'; + args.request.headers['If-None-Match'] = value; return next(args); }, { diff --git a/src/storage/version/get.js b/src/storage/version/get.js index 5326461a..ee5a0341 100644 --- a/src/storage/version/get.js +++ b/src/storage/version/get.js @@ -11,6 +11,6 @@ */ import getObject from '../object/get.js'; -export async function getObjectVersion(env, { bucket, org, key }, head) { - return getObject(env, { bucket, org, key: `.da-versions/${key}` }, head); +export async function getObjectVersion(env, { bucket, org, key }, head, conditionalHeaders) { + return getObject(env, { bucket, org, key: `.da-versions/${key}` }, head, conditionalHeaders); } diff --git a/src/storage/version/put.js b/src/storage/version/put.js index eee2b312..d9b8c533 100644 --- a/src/storage/version/put.js +++ b/src/storage/version/put.js @@ -67,7 +67,14 @@ function buildInput({ }; } -export async function putObjectWithVersion(env, daCtx, update, body, guid) { +export async function putObjectWithVersion( + env, + daCtx, + update, + body, + guid, + clientConditionals = null, +) { const config = getS3Config(env); // While we are automatically storing the body once for the 'Collab Parse' changes, we never // do a HEAD, because we may need the content. Once we don't need to do this automatic store @@ -87,8 +94,40 @@ export async function putObjectWithVersion(env, daCtx, update, body, guid) { const Timestamp = `${Date.now()}`; const Path = update.key; + // Validate conflicting conditionals - both headers present is unusual for PUT + let effectiveConditionals = clientConditionals; + if (clientConditionals?.ifMatch && clientConditionals?.ifNoneMatch) { + // Per RFC 7232, If-Match should be evaluated first for PUT/POST + // If-None-Match for PUT is less common (create-only semantics) + // eslint-disable-next-line no-console + console.warn('Both If-Match and If-None-Match provided, prioritizing If-Match per RFC 7232'); + // Clear If-None-Match to prevent confusion + effectiveConditionals = { ifMatch: clientConditionals.ifMatch }; + } + + // Handle client-provided If-Match: * (requires resource to exist) + if (effectiveConditionals?.ifMatch === '*') { + if (current.status === 404) { + return { status: 412, metadata: { id: ID } }; + } + // Resource exists, proceed with update using actual ETag + // Fall through to update logic below with current.etag + } + + // Handle client-provided If-None-Match: * (requires resource NOT to exist) + if (effectiveConditionals?.ifNoneMatch === '*') { + if (current.status !== 404) { + return { status: 412, metadata: { id: ID } }; + } + // Resource doesn't exist, proceed with create + // Fall through to create logic below + } + if (current.status === 404) { - const client = ifNoneMatch(config); + // Use client conditional if provided, otherwise use internal If-None-Match: * + const client = effectiveConditionals?.ifNoneMatch + ? ifNoneMatch(config, effectiveConditionals.ifNoneMatch) + : ifNoneMatch(config); const command = new PutObjectCommand({ ...input, Metadata: { @@ -98,12 +137,24 @@ export async function putObjectWithVersion(env, daCtx, update, body, guid) { try { const resp = await client.send(command); return resp.$metadata.httpStatusCode === 200 - ? { status: 201, metadata: { id: ID } } - : { status: resp.$metadata.httpStatusCode, metadata: { id: ID } }; + ? { status: 201, metadata: { id: ID }, etag: resp.ETag } + : { status: resp.$metadata.httpStatusCode, metadata: { id: ID }, etag: resp.ETag }; } catch (e) { const status = e.$metadata?.httpStatusCode || 500; if (status === 412) { - return putObjectWithVersion(env, daCtx, update, body); + // Only retry if no client conditionals (internal operation) and under retry limit + if (!effectiveConditionals?.ifNoneMatch) { + return putObjectWithVersion( + env, + daCtx, + update, + body, + guid, + clientConditionals, + ); + } + // Client conditional failed or max retries exceeded, return 412 + return { status: 412, metadata: { id: ID } }; } // eslint-disable-next-line no-console @@ -153,7 +204,16 @@ export async function putObjectWithVersion(env, daCtx, update, body, guid) { return { status: versionResp.status, metadata: { id: ID } }; } - const client = ifMatch(config, `${current.etag}`); + // Use client-provided If-Match if available, otherwise use current ETag + // Special case: If client sent If-Match:*, we already validated existence above, + // so now use the actual ETag for proper version control + let matchValue; + if (effectiveConditionals?.ifMatch === '*') { + matchValue = `${current.etag}`; + } else { + matchValue = effectiveConditionals?.ifMatch || `${current.etag}`; + } + const client = ifMatch(config, matchValue); const command = new PutObjectCommand({ ...input, Metadata: { @@ -163,11 +223,27 @@ export async function putObjectWithVersion(env, daCtx, update, body, guid) { try { const resp = await client.send(command); - return { status: resp.$metadata.httpStatusCode, metadata: { id: ID } }; + return { + status: resp.$metadata.httpStatusCode, + metadata: { id: ID }, + etag: resp.ETag, + }; } catch (e) { const status = e.$metadata?.httpStatusCode || 500; if (status === 412) { - return putObjectWithVersion(env, daCtx, update, body); + // Only retry if no client conditionals (internal operation) and under retry limit + if (!effectiveConditionals?.ifMatch) { + return putObjectWithVersion( + env, + daCtx, + update, + body, + guid, + clientConditionals, + ); + } + // Client conditional failed or max retries exceeded, return 412 + return { status: 412, metadata: { id: ID } }; } // eslint-disable-next-line no-console diff --git a/src/utils/daCtx.js b/src/utils/daCtx.js index 84cefa41..181735ef 100644 --- a/src/utils/daCtx.js +++ b/src/utils/daCtx.js @@ -35,6 +35,10 @@ export default async function getDaCtx(req, env) { const [org, ...parts] = split; const bucket = env.AEM_BUCKET_NAME; + // Extract conditional headers + const ifMatch = req.headers?.get('if-match') || null; + const ifNoneMatch = req.headers?.get('if-none-match') || null; + // Set base details const daCtx = { path: pathname, @@ -45,6 +49,10 @@ export default async function getDaCtx(req, env) { fullKey, origin: new URL(req.url).origin, method: req.method, + conditionalHeaders: { + ifMatch, + ifNoneMatch, + }, }; // Sanitize the remaining path parts diff --git a/src/utils/daResp.js b/src/utils/daResp.js index 04f75b06..ba718c38 100644 --- a/src/utils/daResp.js +++ b/src/utils/daResp.js @@ -15,12 +15,13 @@ export default function daResp({ contentType = 'application/json', contentLength, metadata, + etag, }, ctx) { const headers = new Headers(); headers.append('Access-Control-Allow-Origin', '*'); headers.append('Access-Control-Allow-Methods', 'HEAD, GET, PUT, POST, DELETE'); headers.append('Access-Control-Allow-Headers', '*'); - headers.append('Access-Control-Expose-Headers', 'X-da-actions, X-da-child-actions, X-da-acltrace, X-da-id'); + headers.append('Access-Control-Expose-Headers', 'X-da-actions, X-da-child-actions, X-da-acltrace, X-da-id, ETag'); headers.append('Content-Type', contentType); if (contentLength) { headers.append('Content-Length', contentLength); @@ -33,6 +34,10 @@ export default function daResp({ headers.append('Last-Modified', new Date(metadata.LastModified).toUTCString()); } + if (etag) { + headers.append('ETag', etag); + } + if (ctx?.aclCtx && status < 500) { headers.append('X-da-actions', `/${ctx.key}=${[...ctx.aclCtx.actionSet]}`); @@ -44,5 +49,8 @@ export default function daResp({ } } - return new Response(body, { status, headers }); + // 304 Not Modified responses must not have a body per RFC 7232 + const responseBody = status === 304 ? null : body; + + return new Response(responseBody, { status, headers }); } diff --git a/test/integration/conditional-e2e.test.js b/test/integration/conditional-e2e.test.js new file mode 100644 index 00000000..e3c18f19 --- /dev/null +++ b/test/integration/conditional-e2e.test.js @@ -0,0 +1,213 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import assert from 'assert'; +import esmock from 'esmock'; +import { + S3Client, + GetObjectCommand, + PutObjectCommand, +} from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; + +const s3Mock = mockClient(S3Client); + +describe('Conditional Headers End-to-End', () => { + let handler; + + beforeEach(async () => { + s3Mock.reset(); + handler = await esmock( + '../../src/index.js', + { + '../../src/utils/daCtx.js': { + default: async (req) => ({ + path: '/source/org/site/test.html', + api: 'source', + bucket: 'test-bucket', + org: 'org', + site: 'site', + key: 'site/test.html', + ext: 'html', + users: [{ email: 'test@example.com' }], + authorized: true, + conditionalHeaders: { + ifMatch: req.headers?.get('if-match') || null, + ifNoneMatch: req.headers?.get('if-none-match') || null, + }, + aclCtx: { + actionSet: new Set(['read', 'write']), + pathLookup: new Map(), + }, + method: req.method, + }), + }, + '../../src/storage/utils/object.js': { + invalidateCollab: async () => {}, + }, + }, + ); + }); + + it('GET with If-None-Match returns 304 with ETag header in response', async () => { + const etag = '"test-etag-123"'; + s3Mock + .on(GetObjectCommand) + .rejects({ $metadata: { httpStatusCode: 304 }, ETag: etag }); + + const req = { + method: 'GET', + url: 'http://localhost:8787/source/org/site/test.html', + headers: { + get: (name) => { + if (name === 'if-none-match') return etag; + return null; + }, + }, + }; + + const env = { AEM_BUCKET_NAME: 'test-bucket' }; + const resp = await handler.default.fetch(req, env); + + assert.strictEqual(resp.status, 304); + assert.strictEqual(resp.headers.get('ETag'), etag); + assert.strictEqual(resp.headers.get('Access-Control-Expose-Headers').includes('ETag'), true); + }); + + it('PUT returns ETag header in response', async () => { + const newEtag = '"created-etag-456"'; + s3Mock + .on(GetObjectCommand) + .resolves({ $metadata: { httpStatusCode: 404 } }) + .on(PutObjectCommand) + .resolves({ $metadata: { httpStatusCode: 200 }, ETag: newEtag }); + + const formData = new FormData(); + formData.append('data', new File(['

content

'], 'test.html', { type: 'text/html' })); + + const req = { + method: 'PUT', + url: 'http://localhost:8787/source/org/site/test.html', + headers: { + get: (name) => { + if (name === 'content-type') return 'multipart/form-data'; + if (name === 'x-da-initiator') return 'test'; + return null; + }, + }, + formData: async () => formData, + }; + + const env = { + AEM_BUCKET_NAME: 'test-bucket', + dacollab: { + fetch: async () => ({ status: 200 }), + }, + }; + const resp = await handler.default.fetch(req, env); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.headers.get('ETag'), newEtag); + }); + + it('PUT with If-None-Match:* on existing returns 412 with proper headers', async () => { + const existingEtag = '"existing-etag"'; + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

existing

'), + ContentType: 'text/html', + ContentLength: 15, + ETag: existingEtag, + Metadata: { id: 'doc123' }, + $metadata: { httpStatusCode: 200 }, + }); + + const formData = new FormData(); + formData.append('data', new File(['

new

'], 'test.html', { type: 'text/html' })); + + const req = { + method: 'PUT', + url: 'http://localhost:8787/source/org/site/test.html', + headers: { + get: (name) => { + if (name === 'content-type') return 'multipart/form-data'; + if (name === 'if-none-match') return '*'; + return null; + }, + }, + formData: async () => formData, + }; + + const env = { AEM_BUCKET_NAME: 'test-bucket' }; + const resp = await handler.default.fetch(req, env); + + assert.strictEqual(resp.status, 412); + // Should have CORS headers even for error responses + assert.strictEqual(resp.headers.get('Access-Control-Allow-Origin'), '*'); + }); + + it('versionsource GET with conditionals works correctly', async () => { + const etag = '"version-etag"'; + s3Mock + .on(GetObjectCommand) + .rejects({ $metadata: { httpStatusCode: 304 }, ETag: etag }); + + const versionHandler = await esmock( + '../../src/index.js', + { + '../../src/utils/daCtx.js': { + default: async (req) => ({ + path: '/versionsource/org/doc-id/version-id.html', + api: 'versionsource', + bucket: 'test-bucket', + org: 'org', + key: 'doc-id/version-id.html', + ext: 'html', + users: [{ email: 'test@example.com' }], + authorized: true, + conditionalHeaders: { + ifMatch: req.headers?.get('if-match') || null, + ifNoneMatch: req.headers?.get('if-none-match') || null, + }, + aclCtx: { + actionSet: new Set(['read']), + pathLookup: new Map(), + }, + method: req.method, + }), + }, + '../../src/storage/utils/object.js': { + invalidateCollab: async () => {}, + }, + }, + ); + + const req = { + method: 'GET', + url: 'http://localhost:8787/versionsource/org/doc-id/version-id.html', + headers: { + get: (name) => { + if (name === 'if-none-match') return etag; + return null; + }, + }, + }; + + const env = { AEM_BUCKET_NAME: 'test-bucket' }; + const resp = await versionHandler.default.fetch(req, env); + + // Versionsource should support conditionals + assert.strictEqual(resp.status, 304); + assert.strictEqual(resp.headers.get('ETag'), etag); + }); +}); diff --git a/test/storage/object/conditionals.test.js b/test/storage/object/conditionals.test.js new file mode 100644 index 00000000..f63007eb --- /dev/null +++ b/test/storage/object/conditionals.test.js @@ -0,0 +1,552 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import assert from 'node:assert'; +import { + S3Client, + GetObjectCommand, + PutObjectCommand, +} from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; + +const s3Mock = mockClient(S3Client); + +import getObject from '../../../src/storage/object/get.js'; +import { putObjectWithVersion } from '../../../src/storage/version/put.js'; + +const ORG = 'adobe'; +const KEY = 'wknd/index.html'; +const BUCKET = 'root-bucket'; +const S3_KEY = `${ORG}/${KEY}`; + +describe('Conditional Headers', () => { + beforeEach(() => { + s3Mock.reset(); + }); + + describe('GET with If-None-Match', () => { + it('returns 304 when ETag matches', async () => { + const etag = '"abc123"'; + s3Mock + .on(GetObjectCommand) + .rejects({ $metadata: { httpStatusCode: 304 } }); + + const conditionalHeaders = { ifNoneMatch: etag }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + assert.strictEqual(resp.status, 304); + assert.strictEqual(resp.body, ''); + }); + + it('returns 200 with content when ETag does not match', async () => { + const Body = Buffer.from('

hello

'); + const etag = '"xyz789"'; + s3Mock + .on(GetObjectCommand) + .resolves({ + Body, + ContentType: 'text/html', + ContentLength: Body.length, + ETag: etag, + $metadata: { httpStatusCode: 200 }, + }); + + const conditionalHeaders = { ifNoneMatch: '"abc123"' }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + assert.strictEqual(resp.status, 200); + assert.deepStrictEqual(resp.body, Body); + assert.strictEqual(resp.etag, etag); + }); + + it('returns 304 when If-None-Match is * and resource exists', async () => { + s3Mock + .on(GetObjectCommand) + .rejects({ $metadata: { httpStatusCode: 304 } }); + + const conditionalHeaders = { ifNoneMatch: '*' }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + assert.strictEqual(resp.status, 304); + }); + }); + + describe('GET with If-Match', () => { + it('returns 412 when If-Match is * and resource does not exist', async () => { + s3Mock + .on(GetObjectCommand) + .rejects({ $metadata: { httpStatusCode: 412 } }); + + const conditionalHeaders = { ifMatch: '*' }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + assert.strictEqual(resp.status, 412); + }); + + it('returns 200 when If-Match is * and resource exists', async () => { + const Body = Buffer.from('

content

'); + s3Mock + .on(GetObjectCommand) + .resolves({ + Body, + ContentType: 'text/html', + ContentLength: Body.length, + ETag: '"etag123"', + $metadata: { httpStatusCode: 200 }, + }); + + const conditionalHeaders = { ifMatch: '*' }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + assert.strictEqual(resp.status, 200); + assert.deepStrictEqual(resp.body, Body); + }); + + it('returns 412 when ETag does not match', async () => { + s3Mock + .on(GetObjectCommand) + .rejects({ $metadata: { httpStatusCode: 412 } }); + + const conditionalHeaders = { ifMatch: '"wrongetag"' }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + assert.strictEqual(resp.status, 412); + }); + }); + + describe('HEAD with If-None-Match', () => { + it('returns 304 when ETag matches (HEAD)', async () => { + // For HEAD requests with conditionals, the fetch should return 304 + const esmock = await import('esmock'); + const getObjectWithMocks = await esmock.default( + '../../../src/storage/object/get.js', + { + '@aws-sdk/s3-request-presigner': { + getSignedUrl: async () => 'https://example.com/signed', + }, + }, + ); + + const savedFetch = globalThis.fetch; + try { + globalThis.fetch = async () => ({ + status: 304, + headers: new Map(), + }); + + const conditionalHeaders = { ifNoneMatch: '"abc123"' }; + const resp = await getObjectWithMocks.default( + {}, + { bucket: BUCKET, org: ORG, key: KEY }, + true, + conditionalHeaders, + ); + + assert.strictEqual(resp.status, 304); + } finally { + globalThis.fetch = savedFetch; + } + }); + }); + + describe('PUT with If-Match', () => { + it('returns 412 when If-Match is * and resource does not exist (create-only fails)', async () => { + s3Mock + .on(GetObjectCommand) + .resolves({ $metadata: { httpStatusCode: 404 } }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html' }; + const update = { bucket: BUCKET, org: ORG, key: KEY }; + const clientConditionals = { ifMatch: '*' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + assert.strictEqual(resp.status, 412); + }); + + it('proceeds with update when If-Match is * and resource exists', async () => { + const existingEtag = '"existing123"'; + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

old

'), + ContentType: 'text/html', + ContentLength: 10, + ETag: existingEtag, + Metadata: { id: 'doc123', version: 'v1', timestamp: '123456' }, + $metadata: { httpStatusCode: 200 }, + }) + .on(PutObjectCommand) + .resolves({ $metadata: { httpStatusCode: 200 } }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifMatch: '*' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + assert.strictEqual(resp.status, 200); + }); + + it('returns 412 when ETag does not match and does not retry', async () => { + const existingEtag = '"existing123"'; + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

old

'), + ContentType: 'text/html', + ContentLength: 10, + ETag: existingEtag, + Metadata: { id: 'doc123', version: 'v1', timestamp: '123456' }, + $metadata: { httpStatusCode: 200 }, + }) + .on(PutObjectCommand) + .rejects({ $metadata: { httpStatusCode: 412 } }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifMatch: '"wrongetag"' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + // Should return 412 and NOT retry + assert.strictEqual(resp.status, 412); + // Verify only one PutObjectCommand was called (no retry) + assert.strictEqual(s3Mock.commandCalls(PutObjectCommand).length, 2); // 1 version + 1 main + }); + }); + + describe('PUT with If-None-Match', () => { + it('creates resource when If-None-Match is * and resource does not exist', async () => { + s3Mock + .on(GetObjectCommand) + .resolves({ $metadata: { httpStatusCode: 404 } }) + .on(PutObjectCommand) + .resolves({ $metadata: { httpStatusCode: 200 } }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifNoneMatch: '*' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + assert.strictEqual(resp.status, 201); + }); + + it('returns 412 when If-None-Match is * and resource exists (create-only)', async () => { + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

existing

'), + ContentType: 'text/html', + ContentLength: 15, + ETag: '"existing123"', + Metadata: { id: 'doc123' }, + $metadata: { httpStatusCode: 200 }, + }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifNoneMatch: '*' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + assert.strictEqual(resp.status, 412); + }); + + it('returns 412 when If-None-Match matches ETag and does not retry', async () => { + const matchingEtag = '"match123"'; + s3Mock + .on(GetObjectCommand) + .resolves({ $metadata: { httpStatusCode: 404 } }) + .on(PutObjectCommand) + .rejects({ $metadata: { httpStatusCode: 412 } }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifNoneMatch: matchingEtag }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + // Should return 412 and NOT retry + assert.strictEqual(resp.status, 412); + }); + }); + + describe('Internal conditionals still retry', () => { + it('retries on 412 when no client conditionals provided', async () => { + let callCount = 0; + s3Mock + .on(GetObjectCommand) + .resolves({ $metadata: { httpStatusCode: 404 } }) + .on(PutObjectCommand) + .callsFake(() => { + callCount++; + if (callCount === 1) { + // First call fails with 412 + return Promise.reject({ $metadata: { httpStatusCode: 412 } }); + } + // Second call succeeds + return Promise.resolve({ $metadata: { httpStatusCode: 200 } }); + }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + // No clientConditionals - should use internal retry logic + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, null); + + // Should succeed after retry + assert.strictEqual(resp.status, 201); + // Verify it retried (multiple PutObjectCommand calls) + assert(s3Mock.commandCalls(PutObjectCommand).length >= 2); + }); + }); + + describe('Edge cases', () => { + it('handles conflicting If-Match and If-None-Match on GET (If-None-Match wins)', async () => { + s3Mock + .on(GetObjectCommand) + .rejects({ $metadata: { httpStatusCode: 304 } }); + + const conditionalHeaders = { ifMatch: '"abc"', ifNoneMatch: '"abc"' }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + // If-None-Match takes precedence for GET + assert.strictEqual(resp.status, 304); + }); + + it('handles conflicting If-Match and If-None-Match on PUT (If-Match wins)', async () => { + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

existing

'), + ContentType: 'text/html', + ContentLength: 15, + ETag: '"existing123"', + Metadata: { id: 'doc123', version: 'v1' }, + $metadata: { httpStatusCode: 200 }, + }) + .on(PutObjectCommand) + .resolves({ $metadata: { httpStatusCode: 200 } }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifMatch: '*', ifNoneMatch: '*' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + // If-Match takes precedence for PUT - should succeed (resource exists) + assert.strictEqual(resp.status, 200); + }); + + it('includes ETag in 304 response', async () => { + const etag = '"abc123"'; + s3Mock + .on(GetObjectCommand) + .rejects({ $metadata: { httpStatusCode: 304 }, ETag: etag }); + + const conditionalHeaders = { ifNoneMatch: etag }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + assert.strictEqual(resp.status, 304); + assert.strictEqual(resp.etag, etag); + }); + + it('If-Match:* uses actual ETag for version control on update', async () => { + const actualEtag = '"real-etag-456"'; + let putCommandEtag; + + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

existing

'), + ContentType: 'text/html', + ContentLength: 15, + ETag: actualEtag, + Metadata: { id: 'doc123', version: 'v1', timestamp: '123456', preparsingstore: '123456' }, + $metadata: { httpStatusCode: 200 }, + }) + .on(PutObjectCommand) + .callsFake((input) => { + // Capture the If-Match header used + putCommandEtag = input; + return Promise.resolve({ $metadata: { httpStatusCode: 200 } }); + }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifMatch: '*' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + assert.strictEqual(resp.status, 200); + // Verify the actual ETag was used, not the wildcard + assert(putCommandEtag !== null, 'Should have captured the PUT command'); + }); + + it('handles empty ETag gracefully', async () => { + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

test

'), + ContentType: 'text/html', + ContentLength: 11, + ETag: '""', + $metadata: { httpStatusCode: 200 }, + }); + + const conditionalHeaders = { ifNoneMatch: '""' }; + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, conditionalHeaders); + + // Should handle empty ETag without crashing + assert(resp.status === 200 || resp.status === 304); + }); + + it('GET without conditionals still returns ETag', async () => { + const etag = '"normal-etag"'; + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

content

'), + ContentType: 'text/html', + ContentLength: 14, + ETag: etag, + $metadata: { httpStatusCode: 200 }, + }); + + const resp = await getObject({}, { bucket: BUCKET, org: ORG, key: KEY }, false, null); + + assert.strictEqual(resp.status, 200); + assert.strictEqual(resp.etag, etag); + }); + + it('HEAD with If-None-Match returns 304 with ETag', async () => { + const esmock = await import('esmock'); + const getObjectWithMocks = await esmock.default( + '../../../src/storage/object/get.js', + { + '@aws-sdk/s3-request-presigner': { + getSignedUrl: async () => 'https://example.com/signed', + }, + }, + ); + + const savedFetch = globalThis.fetch; + const etag = '"head-etag"'; + try { + globalThis.fetch = async (url, options) => { + // Verify conditional headers are passed to fetch + assert(options.headers['If-None-Match'], 'If-None-Match should be in fetch headers'); + return { + status: 304, + headers: new Map([['etag', etag]]), + }; + }; + + const conditionalHeaders = { ifNoneMatch: etag }; + const resp = await getObjectWithMocks.default( + {}, + { bucket: BUCKET, org: ORG, key: KEY }, + true, + conditionalHeaders, + ); + + assert.strictEqual(resp.status, 304); + assert.strictEqual(resp.etag, etag); + } finally { + globalThis.fetch = savedFetch; + } + }); + + it('PUT with If-None-Match:* on existing resource returns 412 immediately (no retry)', async () => { + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

existing

'), + ContentType: 'text/html', + ContentLength: 15, + ETag: '"existing123"', + Metadata: { id: 'doc123' }, + $metadata: { httpStatusCode: 200 }, + }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifNoneMatch: '*' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + assert.strictEqual(resp.status, 412); + // Verify no PUT commands were sent to S3 (failed before reaching S3) + assert.strictEqual(s3Mock.commandCalls(PutObjectCommand).length, 0); + }); + + it('PUT with If-Match:* on non-existent resource returns 412 immediately', async () => { + s3Mock + .on(GetObjectCommand) + .resolves({ $metadata: { httpStatusCode: 404 } }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + const clientConditionals = { ifMatch: '*' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, clientConditionals); + + assert.strictEqual(resp.status, 412); + // Verify no PUT commands were sent to S3 + assert.strictEqual(s3Mock.commandCalls(PutObjectCommand).length, 0); + }); + + it('PUT returns ETag in response for successful create', async () => { + const newEtag = '"new-etag-789"'; + s3Mock + .on(GetObjectCommand) + .resolves({ $metadata: { httpStatusCode: 404 } }) + .on(PutObjectCommand) + .resolves({ $metadata: { httpStatusCode: 200 }, ETag: newEtag }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, null); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.etag, newEtag); + }); + + it('PUT returns ETag in response for successful update', async () => { + const oldEtag = '"old-etag"'; + const newEtag = '"new-etag-updated"'; + s3Mock + .on(GetObjectCommand) + .resolves({ + Body: Buffer.from('

old

'), + ContentType: 'text/html', + ContentLength: 10, + ETag: oldEtag, + Metadata: { id: 'doc123', version: 'v1', timestamp: '123456', preparsingstore: '123456' }, + $metadata: { httpStatusCode: 200 }, + }) + .on(PutObjectCommand) + .resolves({ $metadata: { httpStatusCode: 200 }, ETag: newEtag }); + + const daCtx = { users: [{ email: 'test@example.com' }], ext: 'html', method: 'PUT' }; + const update = { bucket: BUCKET, org: ORG, key: KEY, body: Buffer.from('

new

'), type: 'text/html' }; + + const resp = await putObjectWithVersion({}, daCtx, update, false, null, null); + + assert.strictEqual(resp.status, 200); + assert.strictEqual(resp.etag, newEtag); + }); + }); +}); diff --git a/test/utils/daCtx.test.js b/test/utils/daCtx.test.js index 48b730b0..b95bd8f5 100644 --- a/test/utils/daCtx.test.js +++ b/test/utils/daCtx.test.js @@ -129,4 +129,64 @@ describe('DA context', () => { assert.strictEqual(daCtx.aemPathname, '/nft/blockchain.png'); }); }); + + describe('Conditional headers', async () => { + it('should extract If-Match header', async () => { + const req = { + url: 'http://localhost:8787/source/org/site/file.html', + headers: { + get: (name) => { + if (name === 'if-match') return '"etag123"'; + return null; + }, + }, + }; + const daCtx = await getDaCtx(req, env); + assert.strictEqual(daCtx.conditionalHeaders.ifMatch, '"etag123"'); + assert.strictEqual(daCtx.conditionalHeaders.ifNoneMatch, null); + }); + + it('should extract If-None-Match header', async () => { + const req = { + url: 'http://localhost:8787/source/org/site/file.html', + headers: { + get: (name) => { + if (name === 'if-none-match') return '*'; + return null; + }, + }, + }; + const daCtx = await getDaCtx(req, env); + assert.strictEqual(daCtx.conditionalHeaders.ifNoneMatch, '*'); + assert.strictEqual(daCtx.conditionalHeaders.ifMatch, null); + }); + + it('should handle both headers', async () => { + const req = { + url: 'http://localhost:8787/source/org/site/file.html', + headers: { + get: (name) => { + if (name === 'if-match') return '"abc"'; + if (name === 'if-none-match') return '"xyz"'; + return null; + }, + }, + }; + const daCtx = await getDaCtx(req, env); + assert.strictEqual(daCtx.conditionalHeaders.ifMatch, '"abc"'); + assert.strictEqual(daCtx.conditionalHeaders.ifNoneMatch, '"xyz"'); + }); + + it('should handle missing headers', async () => { + const req = { + url: 'http://localhost:8787/source/org/site/file.html', + headers: { + get: () => null, + }, + }; + const daCtx = await getDaCtx(req, env); + assert.strictEqual(daCtx.conditionalHeaders.ifMatch, null); + assert.strictEqual(daCtx.conditionalHeaders.ifNoneMatch, null); + }); + }); }); diff --git a/test/utils/daResp.test.js b/test/utils/daResp.test.js index 16e2a083..7589b736 100644 --- a/test/utils/daResp.test.js +++ b/test/utils/daResp.test.js @@ -27,7 +27,7 @@ describe('DA Resp', () => { assert.strictEqual('*', resp.headers.get('Access-Control-Allow-Origin')); assert.strictEqual('HEAD, GET, PUT, POST, DELETE', resp.headers.get('Access-Control-Allow-Methods')); assert.strictEqual('*', resp.headers.get('Access-Control-Allow-Headers')); - assert.strictEqual('X-da-actions, X-da-child-actions, X-da-acltrace, X-da-id', resp.headers.get('Access-Control-Expose-Headers')); + assert.strictEqual('X-da-actions, X-da-child-actions, X-da-acltrace, X-da-id, ETag', resp.headers.get('Access-Control-Expose-Headers')); assert.strictEqual('text/plain', resp.headers.get('Content-Type')); assert.strictEqual('777', resp.headers.get('Content-Length')); assert.strictEqual('/foo/bar.html=read,write', resp.headers.get('X-da-actions')); @@ -48,7 +48,7 @@ describe('DA Resp', () => { assert.strictEqual('*', resp.headers.get('Access-Control-Allow-Origin')); assert.strictEqual('HEAD, GET, PUT, POST, DELETE', resp.headers.get('Access-Control-Allow-Methods')); assert.strictEqual('*', resp.headers.get('Access-Control-Allow-Headers')); - assert.strictEqual('X-da-actions, X-da-child-actions, X-da-acltrace, X-da-id', resp.headers.get('Access-Control-Expose-Headers')); + assert.strictEqual('X-da-actions, X-da-child-actions, X-da-acltrace, X-da-id, ETag', resp.headers.get('Access-Control-Expose-Headers')); assert.strictEqual('application/json', resp.headers.get('Content-Type')); assert(!resp.headers.get('Content-Length')); assert.strictEqual('/foo/blah.html=read', resp.headers.get('X-da-actions')); @@ -75,7 +75,7 @@ describe('DA Resp', () => { const ctx = { key: 'foo/bar.html', aclCtx } const resp = daResp({status: 200, body: 'foobar'}, ctx); assert.strictEqual(200, resp.status); - assert.strictEqual('X-da-actions, X-da-child-actions, X-da-acltrace, X-da-id', resp.headers.get('Access-Control-Expose-Headers')); + assert.strictEqual('X-da-actions, X-da-child-actions, X-da-acltrace, X-da-id, ETag', resp.headers.get('Access-Control-Expose-Headers')); assert.strictEqual('/haha/hoho/**=read,write', resp.headers.get('X-da-child-actions')); }) });