diff --git a/package-lock.json b/package-lock.json index 197ec6e4..500b846e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -372,6 +372,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.726.0.tgz", "integrity": "sha512-5JzTX9jwev7+y2Jkzjz0pd1wobB5JQfPOQF3N2DrJ5Pao0/k6uRYwE4NqB0p0HlGrMTDm7xNq7OSPPIPG575Jw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -425,6 +426,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.726.1.tgz", "integrity": "sha512-qh9Q9Vu1hrM/wMBOBIaskwnE4GTFaZu26Q6WHwyWNfj7J8a40vBxpW16c2vYXHLBtwRKM1be8uRLkmDwghpiNw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1305,7 +1307,8 @@ "version": "4.20251126.0", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251126.0.tgz", "integrity": "sha512-DSeI1Q7JYmh5/D/tw5eZCjrKY34v69rwj63hHt60nSQW5QLwWCbj/lLtNz9f2EPa+JCACwpLXHgCXfzJ29x66w==", - "license": "MIT OR Apache-2.0" + "license": "MIT OR Apache-2.0", + "peer": true }, "node_modules/@colors/colors": { "version": "1.6.0", @@ -2596,6 +2599,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4021,6 +4025,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4064,6 +4069,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5587,6 +5593,7 @@ "integrity": "sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7880,6 +7887,7 @@ "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/mobx" @@ -8929,6 +8937,7 @@ "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8939,6 +8948,7 @@ "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -10060,6 +10070,7 @@ "integrity": "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", @@ -10524,6 +10535,7 @@ "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -10782,6 +10794,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, diff --git a/src/storage/version/put.js b/src/storage/version/put.js index 0fb36b55..553b7f26 100644 --- a/src/storage/version/put.js +++ b/src/storage/version/put.js @@ -56,6 +56,13 @@ export async function putVersion(config, { } } +function shouldCreateVersion(contentType) { + // Only create versions for HTML and JSON files + if (!contentType) return false; + const type = contentType.toLowerCase(); + return type.startsWith('text/html') || type.startsWith('application/json'); +} + function buildInput({ bucket, org, key, body, type, contentLength, }) { @@ -88,6 +95,10 @@ export async function putObjectWithVersion( return { status: 409, metadata: { id: ID } }; } + // Only create versions for HTML and JSON files + const contentType = update.type || current.contentType; + const createVersion = shouldCreateVersion(contentType); + const Version = current.metadata?.version || crypto.randomUUID(); const Users = JSON.stringify(getUsersForMetadata(daCtx.users)); const input = buildInput(update); @@ -124,15 +135,20 @@ export async function putObjectWithVersion( } if (current.status === 404) { + const metadata = { + ID, Users, Timestamp, Path, + }; + // Only include Version metadata for files that support versioning + if (createVersion) { + metadata.Version = Version; + } // 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: { - ID, Version, Users, Timestamp, Path, - }, + Metadata: metadata, }); try { const resp = await client.send(command); @@ -164,46 +180,54 @@ export async function putObjectWithVersion( } const pps = current.metadata?.preparsingstore || '0'; - - // Store the body if preparsingstore is not defined, so a once-off store let storeBody = !body && pps === '0'; - const Preparsingstore = storeBody ? Timestamp : pps; + let Preparsingstore = storeBody ? Timestamp : pps; let Label = storeBody ? 'Collab Parse' : update.label; - if (daCtx.method === 'PUT' - && daCtx.ext === 'html' - && current.contentLength > EMPTY_DOC_SIZE - && (!update.body || update.body.size <= EMPTY_DOC_SIZE)) { - // we are about to empty the document body - // this should almost never happen but it does in some unexpectedcases - // we want then to store a version of the full document as a Restore Point - // eslint-disable-next-line no-console - console.warn(`Empty body, creating a restore point (${current.contentLength} / ${update.body?.size})`); - storeBody = true; - Label = 'Restore Point'; - } + if (createVersion) { + if (daCtx.method === 'PUT' + && daCtx.ext === 'html' + && current.contentLength > EMPTY_DOC_SIZE + && (!update.body || update.body.size <= EMPTY_DOC_SIZE)) { + // we are about to empty the document body + // this should almost never happen but it does in some unexpectedcases + // we want then to store a version of the full document as a Restore Point + // eslint-disable-next-line no-console + console.warn(`Empty body, creating a restore point (${current.contentLength} / ${update.body?.size})`); + storeBody = true; + Label = 'Restore Point'; + Preparsingstore = Timestamp; + } - const versionResp = await putVersion(config, { - Bucket: input.Bucket, - Org: daCtx.org, - Body: (body || storeBody ? current.body : ''), - ContentLength: (body || storeBody ? current.contentLength : undefined), - ContentType: current.contentType, - ID, - Version, - Ext: daCtx.ext, - Metadata: { - Users: current.metadata?.users || JSON.stringify([{ email: 'anonymous' }]), - Timestamp: current.metadata?.timestamp || Timestamp, - Path: current.metadata?.path || Path, - Label, - }, - }); + const versionResp = await putVersion(config, { + Bucket: input.Bucket, + Org: daCtx.org, + Body: (body || storeBody ? current.body : ''), + ContentLength: (body || storeBody ? current.contentLength : undefined), + ContentType: current.contentType, + ID, + Version, + Ext: daCtx.ext, + Metadata: { + Users: current.metadata?.users || JSON.stringify([{ email: 'anonymous' }]), + Timestamp: current.metadata?.timestamp || Timestamp, + Path: current.metadata?.path || Path, + Label, + }, + }); - if (versionResp.status !== 200 && versionResp.status !== 412) { - return { status: versionResp.status, metadata: { id: ID } }; + if (versionResp.status !== 200 && versionResp.status !== 412) { + return { status: versionResp.status, metadata: { id: ID } }; + } } + const metadata = { + ID, Users, Timestamp, Path, Preparsingstore, + }; + // Only include Version metadata for files that support versioning + if (createVersion) { + metadata.Version = crypto.randomUUID(); + } // 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 @@ -216,9 +240,7 @@ export async function putObjectWithVersion( const client = ifMatch(config, matchValue); const command = new PutObjectCommand({ ...input, - Metadata: { - ID, Version: crypto.randomUUID(), Users, Timestamp, Path, Preparsingstore, - }, + Metadata: metadata, }); try { const resp = await client.send(command); diff --git a/test/routes/source.test.js b/test/routes/source.test.js index af6ffb25..09399673 100644 --- a/test/routes/source.test.js +++ b/test/routes/source.test.js @@ -334,4 +334,112 @@ describe('Source Route', () => { assert.strictEqual('some data', putCalled[0].o.data); assert.strictEqual(202, resp.status); }); + + it('Test postSource with binary image file via FormData', async () => { + const putCalled = []; + const putCall = (e, c, o) => { + putCalled.push({ e, c, o }); + return { status: 201, metadata: { id: 'image-guid-123' } }; + }; + + const ctx = { key: 'images/photo.jpg', ext: 'jpg', isFile: true }; + const hasPermission = () => true; + + const { postSource } = await esmock('../../src/routes/source.js', { + '../../src/storage/object/put.js': { + default: putCall, + }, + '../../src/utils/auth.js': { + hasPermission, + }, + }); + + // Create a binary image file (JPEG signature) + const jpegData = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46]); + const imageFile = new File([jpegData], 'photo.jpg', { type: 'image/jpeg' }); + + const body = new FormData(); + body.append('data', imageFile); + body.append('guid', 'image-guid-123'); + + const opts = { body, method: 'POST' }; + const req = new Request('https://da.live/source/org/repo/images/photo.jpg', opts); + + const resp = await postSource({ req, env: {}, daCtx: ctx }); + assert.strictEqual(1, putCalled.length); + assert.strictEqual('image-guid-123', putCalled[0].o.guid); + assert(putCalled[0].o.data instanceof File); + assert.strictEqual('image/jpeg', putCalled[0].o.data.type); + assert.strictEqual(201, resp.status); + }); + + it('Test postSource with PNG image via FormData', async () => { + const putCalled = []; + const putCall = (e, c, o) => { + putCalled.push({ e, c, o }); + return { status: 201, metadata: { id: 'png-guid-456' } }; + }; + + const ctx = { key: 'images/graphic.png', ext: 'png', isFile: true }; + const hasPermission = () => true; + + const { postSource } = await esmock('../../src/routes/source.js', { + '../../src/storage/object/put.js': { + default: putCall, + }, + '../../src/utils/auth.js': { + hasPermission, + }, + }); + + const pngData = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + const pngFile = new File([pngData], 'graphic.png', { type: 'image/png' }); + + const body = new FormData(); + body.append('data', pngFile); + + const opts = { body, method: 'POST' }; + const req = new Request('https://da.live/source/org/repo/images/graphic.png', opts); + + const resp = await postSource({ req, env: {}, daCtx: ctx }); + assert.strictEqual(1, putCalled.length); + assert(putCalled[0].o.data instanceof File); + assert.strictEqual('image/png', putCalled[0].o.data.type); + assert.strictEqual(201, resp.status); + }); + + it('Test postSource with PDF via FormData', async () => { + const putCalled = []; + const putCall = (e, c, o) => { + putCalled.push({ e, c, o }); + return { status: 201, metadata: { id: 'pdf-guid-abc' } }; + }; + + const ctx = { key: 'docs/report.pdf', ext: 'pdf', isFile: true }; + const hasPermission = () => true; + + const { postSource } = await esmock('../../src/routes/source.js', { + '../../src/storage/object/put.js': { + default: putCall, + }, + '../../src/utils/auth.js': { + hasPermission, + }, + }); + + const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]); + const pdfFile = new File([pdfData], 'report.pdf', { type: 'application/pdf' }); + + const body = new FormData(); + body.append('data', pdfFile); + + const opts = { body, method: 'POST' }; + const req = new Request('https://da.live/source/org/repo/docs/report.pdf', opts); + + const resp = await postSource({ req, env: {}, daCtx: ctx }); + assert.strictEqual(1, putCalled.length); + assert(putCalled[0].o.data instanceof File); + assert.strictEqual('application/pdf', putCalled[0].o.data.type); + assert.strictEqual(201, resp.status); + }); }); diff --git a/test/storage/object/put.test.js b/test/storage/object/put.test.js index 731ce103..433eadbc 100644 --- a/test/storage/object/put.test.js +++ b/test/storage/object/put.test.js @@ -61,4 +61,436 @@ describe('Object storage', () => { assert.strictEqual(resp.status, 201); }); }); + + describe('Binary file versioning', () => { + it('Creates version for JPEG image upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-jpeg-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create a mock JPEG file (using a simple byte array to simulate binary data) + const jpegData = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46]); + const jpegFile = new File([jpegData], 'test-image.jpg', { type: 'image/jpeg' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'images/test-image.jpg', + pathname: '/images/test-image', + propsKey: 'images/test-image.jpg.props', + ext: 'jpg', + }; + + const obj = { data: jpegFile, guid: 'jpeg-guid-123' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-jpeg-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'image/jpeg'); + assert.strictEqual(versionCalls[0].guid, 'jpeg-guid-123'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for PNG image upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-png-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create a mock PNG file (PNG signature) + const pngData = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + const pngFile = new File([pngData], 'test-image.png', { type: 'image/png' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'images/test-image.png', + pathname: '/images/test-image', + propsKey: 'images/test-image.png.props', + ext: 'png', + }; + + const obj = { data: pngFile }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-png-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'image/png'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for MP4 video upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-video-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create a mock MP4 file (ftyp box signature) + const mp4Data = new Uint8Array([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]); + const mp4File = new File([mp4Data], 'test-video.mp4', { type: 'video/mp4' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'videos/test-video.mp4', + pathname: '/videos/test-video', + propsKey: 'videos/test-video.mp4.props', + ext: 'mp4', + }; + + const obj = { data: mp4File, guid: 'video-guid-456' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-video-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'video/mp4'); + assert.strictEqual(versionCalls[0].guid, 'video-guid-456'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for SVG image upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-svg-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create an SVG file (text-based but still an image) + const svgContent = ''; + const svgFile = new File([svgContent], 'test-image.svg', { type: 'image/svg+xml' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'images/test-image.svg', + pathname: '/images/test-image', + propsKey: 'images/test-image.svg.props', + ext: 'svg', + }; + + const obj = { data: svgFile, guid: 'svg-guid-789' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-svg-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'image/svg+xml'); + assert.strictEqual(versionCalls[0].guid, 'svg-guid-789'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for PDF document upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-pdf-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create a mock PDF file (PDF signature: %PDF) + const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]); + const pdfFile = new File([pdfData], 'document.pdf', { type: 'application/pdf' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'documents/report.pdf', + pathname: '/documents/report', + propsKey: 'documents/report.pdf.props', + ext: 'pdf', + }; + + const obj = { data: pdfFile, guid: 'pdf-guid-abc' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-pdf-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'application/pdf'); + assert.strictEqual(versionCalls[0].guid, 'pdf-guid-abc'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for ZIP archive upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-zip-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create a mock ZIP file (ZIP signature: PK) + const zipData = new Uint8Array([0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00]); + const zipFile = new File([zipData], 'archive.zip', { type: 'application/zip' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'downloads/archive.zip', + pathname: '/downloads/archive', + propsKey: 'downloads/archive.zip.props', + ext: 'zip', + }; + + const obj = { data: zipFile, guid: 'zip-guid-def' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-zip-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'application/zip'); + assert.strictEqual(versionCalls[0].guid, 'zip-guid-def'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for generic binary file upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-binary-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create a generic binary file with application/octet-stream (fallback content type) + const binaryData = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]); + const binaryFile = new File([binaryData], 'data.bin', { type: 'application/octet-stream' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'files/data.bin', + pathname: '/files/data', + propsKey: 'files/data.bin.props', + ext: 'bin', + }; + + const obj = { data: binaryFile, guid: 'binary-guid-ghi' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-binary-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'application/octet-stream'); + assert.strictEqual(versionCalls[0].guid, 'binary-guid-ghi'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for audio file upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-audio-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create a mock MP3 file (ID3v2 signature) + const mp3Data = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]); + const mp3File = new File([mp3Data], 'audio.mp3', { type: 'audio/mpeg' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'media/audio.mp3', + pathname: '/media/audio', + propsKey: 'media/audio.mp3.props', + ext: 'mp3', + }; + + const obj = { data: mp3File, guid: 'audio-guid-jkl' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-audio-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'audio/mpeg'); + assert.strictEqual(versionCalls[0].guid, 'audio-guid-jkl'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for HTML file upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-html-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create an HTML file with typical content + const htmlContent = ` + + + + Test Page + + +

Welcome to DA

+

This is a test HTML document for versioning.

+ +`; + const htmlFile = new File([htmlContent], 'index.html', { type: 'text/html' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'pages/index.html', + pathname: '/pages/index', + propsKey: 'pages/index.html.props', + ext: 'html', + }; + + const obj = { data: htmlFile, guid: 'html-guid-mno' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-html-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'text/html'); + assert.strictEqual(versionCalls[0].guid, 'html-guid-mno'); + assert(versionCalls[0].update.body instanceof File); + }); + + it('Creates version for JSON file upload', async () => { + const versionCalls = []; + const mockPutObjectWithVersion = async (e, daCtx, update, body, guid) => { + versionCalls.push({ + e, daCtx, update, body, guid, + }); + return { status: 201, metadata: { id: 'test-json-id' } }; + }; + + const putObjectWithVersioning = await esmock('../../../src/storage/object/put.js', { + '../../../src/storage/version/put.js': { + putObjectWithVersion: mockPutObjectWithVersion, + postObjectVersion, + }, + }); + + // Create a JSON file with typical structured data + const jsonContent = JSON.stringify({ + title: 'Test Configuration', + version: '1.0.0', + settings: { + enabled: true, + options: ['option1', 'option2', 'option3'], + }, + metadata: { + author: 'test@example.com', + created: '2024-01-01T00:00:00Z', + }, + }, null, 2); + const jsonFile = new File([jsonContent], 'config.json', { type: 'application/json' }); + + const daCtx = { + org: 'testorg', + site: 'testsite', + isFile: true, + key: 'config/settings.json', + pathname: '/config/settings', + propsKey: 'config/settings.json.props', + ext: 'json', + }; + + const obj = { data: jsonFile, guid: 'json-guid-pqr' }; + const resp = await putObjectWithVersioning(env, daCtx, obj); + + assert.strictEqual(resp.status, 201); + assert.strictEqual(resp.metadata.id, 'test-json-id'); + assert.strictEqual(versionCalls.length, 1); + assert.strictEqual(versionCalls[0].update.type, 'application/json'); + assert.strictEqual(versionCalls[0].guid, 'json-guid-pqr'); + assert(versionCalls[0].update.body instanceof File); + }); + }); }); diff --git a/test/storage/version/put.test.js b/test/storage/version/put.test.js index 0259f313..d6662a4f 100644 --- a/test/storage/version/put.test.js +++ b/test/storage/version/put.test.js @@ -227,6 +227,7 @@ describe('Version Put', () => { if (!h) { return { body: 'prevbody', + contentType: 'text/html', metadata: { id: 'x123', version: 'aaa-bbb', @@ -296,6 +297,7 @@ describe('Version Put', () => { if (!h) { return { body: 'prevbody', + contentType: 'text/html', metadata: { id: 'q123-456', preparsingstore: Date.now(), @@ -339,7 +341,7 @@ describe('Version Put', () => { bucket: 'bbb', org: 'myorg', ext: 'html', users: [{ email: 'foo@acme.org' }, { email: 'bar@acme.org' }], }; const update = { - bucket: 'bbb', body: 'new-body', org: 'myorg', key: 'a/x.html', + bucket: 'bbb', body: 'new-body', org: 'myorg', key: 'a/x.html', contentType: 'text/html', }; const resp = await putObjectWithVersion(env, daCtx, update, false); assert.equal(202, resp.status); @@ -398,7 +400,12 @@ describe('Version Put', () => { const env = {}; const daCtx = { bucket: 'b-b', org: 'myorg' }; - const update = { bucket: 'b-b', org: 'myorg', key: 'a/b/c' }; + const update = { + bucket: 'b-b', + org: 'myorg', + key: 'a/b/c', + type: 'text/html', + }; const resp = await putObjectWithVersion(env, daCtx, update, true); assert.equal(201, resp.status); assert(resp.metadata.id, 'The ID should be set'); @@ -576,7 +583,7 @@ describe('Version Put', () => { }, }); - const resp = await putObjectWithVersion({}, { method: 'HEAD' }, {}); + const resp = await putObjectWithVersion({}, { method: 'HEAD' }, { type: 'text/html' }); assert.equal(1, sentToS3.length); const { input } = sentToS3[0]; assert.equal('', input.Body, 'Empty body for HEAD'); @@ -594,7 +601,12 @@ describe('Version Put', () => { path: '/qwerty', timestamp: 1234, }; - return { body: 'Somebody...', metadata, contentLength: 616 }; + return { + body: 'Somebody...', + contentType: 'text/html', + metadata, + contentLength: 616, + }; }; const sentToS3 = []; @@ -637,7 +649,7 @@ describe('Version Put', () => { org: 'o1', body: 'foobar', key: 'mypath', - type: 'test/plain', + type: 'text/html', }; const ctx = { org: 'o1', @@ -656,7 +668,7 @@ describe('Version Put', () => { const input2 = sentToS3_2[0].input; assert.equal('foobar', input2.Body); assert.equal(6, input2.ContentLength); - assert.equal('test/plain', input2.ContentType); + assert.equal('text/html', input2.ContentType); assert.equal('o1/mypath', input2.Key); assert.equal('mypath', input2.Metadata.Path); assert.equal('[{"email":"hi@acme.com"}]', input2.Metadata.Users); @@ -714,7 +726,7 @@ describe('Version Put', () => { org: 'o1', body: '', key: 'mypath', - type: 'test/plain', + type: 'text/html', }; const ctx = { org: 'o1', @@ -739,7 +751,7 @@ describe('Version Put', () => { const input2 = sentToS3_2[0].input; assert.equal('', input2.Body); assert.equal(0, input2.ContentLength); - assert.equal('test/plain', input2.ContentType); + assert.equal('text/html', input2.ContentType); assert.equal('o1/mypath', input2.Key); assert.equal('mypath', input2.Metadata.Path); assert.equal('[{"email":"hi@acme.com"}]', input2.Metadata.Users); @@ -842,7 +854,7 @@ describe('Version Put', () => { status: 200, body: 'test body', contentLength: 9, - contentType: 'text/plain', + contentType: 'text/html', metadata: { existing: 'metadata' }, }); @@ -852,6 +864,7 @@ describe('Version Put', () => { }, '../../../src/storage/utils/version.js': { ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, generateId: () => 'generated-id', generateVersion: () => 'generated-version', }, @@ -864,12 +877,1222 @@ describe('Version Put', () => { users: [{ email: 'test@example.com' }], }; - await putObjectWithVersion(env, daCtx, { key: 'test-file.txt' }, 'test body', 'test-guid'); + await putObjectWithVersion(env, daCtx, { key: 'test-file.html', type: 'text/html' }, 'test body', 'test-guid'); - assert.strictEqual(sentCommands.length, 1); - const putCommand = sentCommands[0]; - assert.strictEqual(putCommand.input.ContentType, 'text/plain'); + assert.strictEqual(sentCommands.length, 2); // Version + main file + const putCommand = sentCommands[0]; // First command is the version + assert.strictEqual(putCommand.input.ContentType, 'text/html'); assert.strictEqual(putCommand.input.Body, 'test body'); assert.strictEqual(putCommand.input.ContentLength, 9); }); + + it('Test putVersion with JPEG binary content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Simulate JPEG binary data + const jpegData = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46]); + const jpegFile = new File([jpegData], 'image.jpg', { type: 'image/jpeg' }); + + const testParams = { + Bucket: 'media-bucket', + Org: 'testorg', + Body: jpegFile, + ID: 'jpeg-id-123', + Version: 'jpeg-version-1', + Ext: 'jpg', + Metadata: { Users: '["user@example.com"]', Path: 'images/photo.jpg' }, + ContentType: 'image/jpeg', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'media-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/jpeg-id-123/jpeg-version-1.jpg'); + assert.strictEqual(putCommand.input.Body, jpegFile); + assert.strictEqual(putCommand.input.ContentType, 'image/jpeg'); + assert.strictEqual(putCommand.input.Metadata.Users, '["user@example.com"]'); + assert.strictEqual(putCommand.input.Metadata.Path, 'images/photo.jpg'); + }); + + it('Test putVersion with PNG binary content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Simulate PNG binary data + const pngData = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + const pngFile = new File([pngData], 'image.png', { type: 'image/png' }); + + const testParams = { + Bucket: 'media-bucket', + Org: 'testorg', + Body: pngFile, + ID: 'png-id-456', + Version: 'png-version-1', + Ext: 'png', + Metadata: { Users: '["user@example.com"]', Path: 'images/graphic.png' }, + ContentType: 'image/png', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'media-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/png-id-456/png-version-1.png'); + assert.strictEqual(putCommand.input.Body, pngFile); + assert.strictEqual(putCommand.input.ContentType, 'image/png'); + }); + + it('Test putVersion with MP4 video content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Simulate MP4 binary data + const mp4Data = new Uint8Array([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]); + const mp4File = new File([mp4Data], 'video.mp4', { type: 'video/mp4' }); + + const testParams = { + Bucket: 'media-bucket', + Org: 'testorg', + Body: mp4File, + ID: 'video-id-789', + Version: 'video-version-1', + Ext: 'mp4', + Metadata: { Users: '["user@example.com"]', Path: 'videos/demo.mp4' }, + ContentType: 'video/mp4', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'media-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/video-id-789/video-version-1.mp4'); + assert.strictEqual(putCommand.input.Body, mp4File); + assert.strictEqual(putCommand.input.ContentType, 'video/mp4'); + }); + + it('Test putVersion with SVG image content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // SVG is text-based but still an image + const svgContent = ''; + const svgFile = new File([svgContent], 'graphic.svg', { type: 'image/svg+xml' }); + + const testParams = { + Bucket: 'media-bucket', + Org: 'testorg', + Body: svgFile, + ID: 'svg-id-abc', + Version: 'svg-version-1', + Ext: 'svg', + Metadata: { Users: '["user@example.com"]', Path: 'images/icon.svg' }, + ContentType: 'image/svg+xml', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'media-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/svg-id-abc/svg-version-1.svg'); + assert.strictEqual(putCommand.input.Body, svgFile); + assert.strictEqual(putCommand.input.ContentType, 'image/svg+xml'); + }); + + it('Test putObjectWithVersion with JPEG preserves binary content on update', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + // Simulate existing JPEG file + const existingJpegData = new Uint8Array([ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, + ]); + const mockGetObject = async () => ({ + status: 200, + body: existingJpegData, + contentLength: existingJpegData.length, + contentType: 'image/jpeg', + etag: 'test-etag', + metadata: { + id: 'jpeg-id-existing', + version: 'jpeg-version-old', + timestamp: '1234567890', + users: '["olduser@example.com"]', + path: 'images/photo.jpg', + }, + }); + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const env = {}; + const daCtx = { + org: 'testorg', + ext: 'jpg', + users: [{ email: 'newuser@example.com' }], + }; + + // New JPEG data to upload + const newJpegData = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x10, 0x4A, 0x46]); + const newJpegFile = new File([newJpegData], 'photo.jpg', { type: 'image/jpeg' }); + + const update = { + bucket: 'media-bucket', + org: 'testorg', + key: 'images/photo.jpg', + body: newJpegFile, + type: 'image/jpeg', + }; + + const result = await putObjectWithVersion(env, daCtx, update, true); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.metadata.id, 'jpeg-id-existing'); + + // Binary files (like JPEG) do NOT create versions, only 1 command for main object + assert.strictEqual(sentCommands.length, 1); + + // Only command should store the new main object (no version for binaries) + const mainCommand = sentCommands[0]; + assert.strictEqual(mainCommand.input.Bucket, 'media-bucket'); + assert.strictEqual(mainCommand.input.Key, 'testorg/images/photo.jpg'); + assert.strictEqual(mainCommand.input.Body, newJpegFile); + assert.strictEqual(mainCommand.input.ContentType, 'image/jpeg'); + }); + + it('Test putVersion with PDF document content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Simulate PDF binary data + const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]); + const pdfFile = new File([pdfData], 'document.pdf', { type: 'application/pdf' }); + + const testParams = { + Bucket: 'docs-bucket', + Org: 'testorg', + Body: pdfFile, + ID: 'pdf-id-123', + Version: 'pdf-version-1', + Ext: 'pdf', + Metadata: { Users: '["user@example.com"]', Path: 'documents/report.pdf' }, + ContentType: 'application/pdf', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'docs-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/pdf-id-123/pdf-version-1.pdf'); + assert.strictEqual(putCommand.input.Body, pdfFile); + assert.strictEqual(putCommand.input.ContentType, 'application/pdf'); + }); + + it('Test putVersion with ZIP archive content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Simulate ZIP binary data + const zipData = new Uint8Array([0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00]); + const zipFile = new File([zipData], 'archive.zip', { type: 'application/zip' }); + + const testParams = { + Bucket: 'files-bucket', + Org: 'testorg', + Body: zipFile, + ID: 'zip-id-456', + Version: 'zip-version-1', + Ext: 'zip', + Metadata: { Users: '["user@example.com"]', Path: 'downloads/archive.zip' }, + ContentType: 'application/zip', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'files-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/zip-id-456/zip-version-1.zip'); + assert.strictEqual(putCommand.input.Body, zipFile); + assert.strictEqual(putCommand.input.ContentType, 'application/zip'); + }); + + it('Test putVersion with generic binary content (octet-stream)', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Generic binary data + const binaryData = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE]); + const binaryFile = new File([binaryData], 'data.bin', { type: 'application/octet-stream' }); + + const testParams = { + Bucket: 'storage-bucket', + Org: 'testorg', + Body: binaryFile, + ID: 'binary-id-789', + Version: 'binary-version-1', + Ext: 'bin', + Metadata: { Users: '["user@example.com"]', Path: 'files/data.bin' }, + ContentType: 'application/octet-stream', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'storage-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/binary-id-789/binary-version-1.bin'); + assert.strictEqual(putCommand.input.Body, binaryFile); + assert.strictEqual(putCommand.input.ContentType, 'application/octet-stream'); + }); + + it('Test putVersion with audio file content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Simulate MP3 audio data + const mp3Data = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]); + const mp3File = new File([mp3Data], 'audio.mp3', { type: 'audio/mpeg' }); + + const testParams = { + Bucket: 'media-bucket', + Org: 'testorg', + Body: mp3File, + ID: 'audio-id-abc', + Version: 'audio-version-1', + Ext: 'mp3', + Metadata: { Users: '["user@example.com"]', Path: 'media/song.mp3' }, + ContentType: 'audio/mpeg', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'media-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/audio-id-abc/audio-version-1.mp3'); + assert.strictEqual(putCommand.input.Body, mp3File); + assert.strictEqual(putCommand.input.ContentType, 'audio/mpeg'); + }); + + it('Test putVersion with HTML file content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Create HTML content + const htmlContent = ` + +Test +

Hello World

+`; + const htmlFile = new File([htmlContent], 'page.html', { type: 'text/html' }); + + const testParams = { + Bucket: 'content-bucket', + Org: 'testorg', + Body: htmlFile, + ID: 'html-id-def', + Version: 'html-version-1', + Ext: 'html', + Metadata: { Users: '["user@example.com"]', Path: 'pages/index.html' }, + ContentType: 'text/html', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'content-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/html-id-def/html-version-1.html'); + assert.strictEqual(putCommand.input.Body, htmlFile); + assert.strictEqual(putCommand.input.ContentType, 'text/html'); + }); + + it('Test putVersion with JSON file content', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + const { putVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + }, + }); + + // Create JSON content + const jsonContent = JSON.stringify({ + name: 'Test Config', + version: '2.0', + features: ['feature1', 'feature2'], + }, null, 2); + const jsonFile = new File([jsonContent], 'config.json', { type: 'application/json' }); + + const testParams = { + Bucket: 'data-bucket', + Org: 'testorg', + Body: jsonFile, + ID: 'json-id-ghi', + Version: 'json-version-1', + Ext: 'json', + Metadata: { Users: '["user@example.com"]', Path: 'config/settings.json' }, + ContentType: 'application/json', + }; + + const result = await putVersion({}, testParams); + + assert.strictEqual(result.status, 200); + assert.strictEqual(sentCommands.length, 1); + const putCommand = sentCommands[0]; + assert.strictEqual(putCommand.input.Bucket, 'data-bucket'); + assert.strictEqual(putCommand.input.Key, 'testorg/.da-versions/json-id-ghi/json-version-1.json'); + assert.strictEqual(putCommand.input.Body, jsonFile); + assert.strictEqual(putCommand.input.ContentType, 'application/json'); + }); + + it('Test putObjectWithVersion with HTML preserves content on update', async () => { + const sentCommands = []; + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { + $metadata: { httpStatusCode: 200 }, + }; + }, + }; + + // Simulate existing HTML file + const existingHtmlContent = '

Old Version

'; + const mockGetObject = async () => ({ + status: 200, + body: existingHtmlContent, + contentLength: existingHtmlContent.length, + contentType: 'text/html', + etag: 'test-etag-html', + metadata: { + id: 'html-id-existing', + version: 'html-version-old', + timestamp: '1234567890', + users: '["olduser@example.com"]', + path: 'pages/index.html', + }, + }); + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const env = {}; + const daCtx = { + org: 'testorg', + ext: 'html', + users: [{ email: 'newuser@example.com' }], + }; + + // New HTML content to upload + const newHtmlContent = '

New Version

Updated content

'; + const newHtmlFile = new File([newHtmlContent], 'index.html', { type: 'text/html' }); + + const update = { + bucket: 'content-bucket', + org: 'testorg', + key: 'pages/index.html', + body: newHtmlFile, + type: 'text/html', + }; + + const result = await putObjectWithVersion(env, daCtx, update, true); + + assert.strictEqual(result.status, 200); + assert.strictEqual(result.metadata.id, 'html-id-existing'); + + // Should have 2 commands: one for version, one for main object + assert.strictEqual(sentCommands.length, 2); + + // First command should store the old version + const versionCommand = sentCommands[0]; + assert.strictEqual(versionCommand.input.Bucket, 'content-bucket'); + assert(versionCommand.input.Key.includes('.da-versions/html-id-existing/')); + assert(versionCommand.input.Key.endsWith('.html')); + assert.strictEqual(versionCommand.input.Body, existingHtmlContent); + assert.strictEqual(versionCommand.input.ContentType, 'text/html'); + assert.strictEqual(versionCommand.input.ContentLength, existingHtmlContent.length); + + // Second command should store the new content + const mainCommand = sentCommands[1]; + assert.strictEqual(mainCommand.input.Bucket, 'content-bucket'); + assert.strictEqual(mainCommand.input.Key, 'testorg/pages/index.html'); + assert.strictEqual(mainCommand.input.Body, newHtmlFile); + assert.strictEqual(mainCommand.input.ContentType, 'text/html'); + }); + + describe('Versioning behavior: CREATE vs UPDATE', () => { + it('JPEG: Binary files NEVER create versions (first or second POST)', async () => { + const sentCommands = []; + let callCount = 0; + + const mockGetObject = async () => { + callCount += 1; + // First call: file doesn't exist + if (callCount === 1) { + return { + status: 404, metadata: {}, body: '', contentLength: 0, + }; + } + // Second call: file exists + return { + status: 200, + body: new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0]), + contentLength: 4, + contentType: 'image/jpeg', + etag: 'old-etag', + metadata: { + id: 'jpeg-id-123', + version: 'old-version', + timestamp: '1234567890', + users: '["user@example.com"]', + path: 'images/photo.jpg', + }, + }; + }; + + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const jpegData = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE1, 0x00, 0x10]); + const jpegFile = new File([jpegData], 'photo.jpg', { type: 'image/jpeg' }); + + const env = {}; + const daCtx = { + org: 'testorg', + bucket: 'test-bucket', + key: 'images/photo.jpg', + users: [{ email: 'user@example.com' }], + }; + + // FIRST CALL - file doesn't exist (404) + const update1 = { + bucket: 'test-bucket', + org: 'testorg', + key: 'images/photo.jpg', + body: jpegFile, + contentLength: jpegData.length, + type: 'image/jpeg', + }; + + sentCommands.length = 0; + const result1 = await putObjectWithVersion(env, daCtx, update1); + // File created for first time - could be 200 or 201 + assert(result1.status === 200 || result1.status === 201); + // Only 1 command: PutObject for main file, NO version created + assert.strictEqual(sentCommands.length, 1); + assert.strictEqual(sentCommands[0].input.Key, 'testorg/images/photo.jpg'); + + // SECOND CALL - file exists (200) - STILL NO VERSION for binaries + sentCommands.length = 0; + const result2 = await putObjectWithVersion(env, daCtx, update1); + assert(result2.status === 200 || result2.status === 201); + // Only 1 command: PutObject for main file, NO version for binaries + assert.strictEqual(sentCommands.length, 1); + assert.strictEqual(sentCommands[0].input.Key, 'testorg/images/photo.jpg'); + }); + + it('PNG: Binary files NEVER create versions (first or second POST)', async () => { + const sentCommands = []; + let callCount = 0; + + const mockGetObject = async () => { + callCount += 1; + if (callCount === 1) { + return { + status: 404, metadata: {}, body: '', contentLength: 0, + }; + } + return { + status: 200, + body: new Uint8Array([0x89, 0x50, 0x4E, 0x47]), + contentLength: 4, + contentType: 'image/png', + etag: 'old-etag', + metadata: { + id: 'png-id-456', + version: 'old-version', + timestamp: '1234567890', + users: '["user@example.com"]', + path: 'images/graphic.png', + }, + }; + }; + + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const pngData = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + const pngFile = new File([pngData], 'graphic.png', { type: 'image/png' }); + + const env = {}; + const daCtx = { + org: 'testorg', + bucket: 'test-bucket', + key: 'images/graphic.png', + users: [{ email: 'user@example.com' }], + }; + + const update = { + bucket: 'test-bucket', + org: 'testorg', + key: 'images/graphic.png', + body: pngFile, + contentLength: pngData.length, + type: 'image/png', + }; + + // FIRST CALL - no version + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + + // SECOND CALL - still no version for binaries + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + }); + + it('HTML: New file (404) creates object WITHOUT version, existing file creates version', async () => { + const sentCommands = []; + let callCount = 0; + + const mockGetObject = async () => { + callCount += 1; + if (callCount === 1) { + return { + status: 404, metadata: {}, body: '', contentLength: 0, + }; + } + return { + status: 200, + body: '

Old

', + contentLength: 40, + contentType: 'text/html', + etag: 'old-etag', + metadata: { + id: 'html-id-789', + version: 'old-version', + timestamp: '1234567890', + users: '["user@example.com"]', + path: 'pages/index.html', + }, + }; + }; + + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const htmlContent = '

New

'; + const htmlFile = new File([htmlContent], 'index.html', { type: 'text/html' }); + + const env = {}; + const daCtx = { + org: 'testorg', + bucket: 'test-bucket', + key: 'pages/index.html', + users: [{ email: 'user@example.com' }], + }; + + const update = { + bucket: 'test-bucket', + org: 'testorg', + key: 'pages/index.html', + body: htmlFile, + contentLength: htmlContent.length, + type: 'text/html', + }; + + // FIRST CALL - no version + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + + // SECOND CALL - creates version for HTML + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 2); + }); + + it('JSON: New file (404) creates object WITHOUT version, existing file creates version', async () => { + const sentCommands = []; + let callCount = 0; + + const mockGetObject = async () => { + callCount += 1; + if (callCount === 1) { + return { + status: 404, metadata: {}, body: '', contentLength: 0, + }; + } + return { + status: 200, + body: JSON.stringify({ version: '1.0' }), + contentLength: 20, + contentType: 'application/json', + etag: 'old-etag', + metadata: { + id: 'json-id-abc', + version: 'old-version', + timestamp: '1234567890', + users: '["user@example.com"]', + path: 'config/settings.json', + }, + }; + }; + + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const jsonContent = JSON.stringify({ version: '2.0', features: ['new'] }); + const jsonFile = new File([jsonContent], 'settings.json', { type: 'application/json' }); + + const env = {}; + const daCtx = { + org: 'testorg', + bucket: 'test-bucket', + key: 'config/settings.json', + users: [{ email: 'user@example.com' }], + }; + + const update = { + bucket: 'test-bucket', + org: 'testorg', + key: 'config/settings.json', + body: jsonFile, + contentLength: jsonContent.length, + type: 'application/json', + }; + + // FIRST CALL - no version + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + + // SECOND CALL - creates version for JSON + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 2); + }); + + it('PDF: Binary files NEVER create versions (first or second POST)', async () => { + const sentCommands = []; + let callCount = 0; + + const mockGetObject = async () => { + callCount += 1; + if (callCount === 1) { + return { + status: 404, metadata: {}, body: '', contentLength: 0, + }; + } + return { + status: 200, + body: new Uint8Array([0x25, 0x50, 0x44, 0x46]), + contentLength: 4, + contentType: 'application/pdf', + etag: 'old-etag', + metadata: { + id: 'pdf-id-def', + version: 'old-version', + timestamp: '1234567890', + users: '["user@example.com"]', + path: 'docs/report.pdf', + }, + }; + }; + + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const pdfData = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]); + const pdfFile = new File([pdfData], 'report.pdf', { type: 'application/pdf' }); + + const env = {}; + const daCtx = { + org: 'testorg', + bucket: 'test-bucket', + key: 'docs/report.pdf', + users: [{ email: 'user@example.com' }], + }; + + const update = { + bucket: 'test-bucket', + org: 'testorg', + key: 'docs/report.pdf', + body: pdfFile, + contentLength: pdfData.length, + type: 'application/pdf', + }; + + // FIRST CALL - no version + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + + // SECOND CALL - still no version for binaries + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + }); + + it('MP4: Binary files NEVER create versions (first or second POST)', async () => { + const sentCommands = []; + let callCount = 0; + + const mockGetObject = async () => { + callCount += 1; + if (callCount === 1) { + return { + status: 404, metadata: {}, body: '', contentLength: 0, + }; + } + return { + status: 200, + body: new Uint8Array([0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]), + contentLength: 8, + contentType: 'video/mp4', + etag: 'old-etag', + metadata: { + id: 'mp4-id-ghi', + version: 'old-version', + timestamp: '1234567890', + users: '["user@example.com"]', + path: 'videos/demo.mp4', + }, + }; + }; + + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const mp4Data = new Uint8Array([ + 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6F, 0x6D, + ]); + const mp4File = new File([mp4Data], 'demo.mp4', { type: 'video/mp4' }); + + const env = {}; + const daCtx = { + org: 'testorg', + bucket: 'test-bucket', + key: 'videos/demo.mp4', + users: [{ email: 'user@example.com' }], + }; + + const update = { + bucket: 'test-bucket', + org: 'testorg', + key: 'videos/demo.mp4', + body: mp4File, + contentLength: mp4Data.length, + type: 'video/mp4', + }; + + // FIRST CALL - no version + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + + // SECOND CALL - still no version for binaries + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + }); + + it('SVG: Binary files NEVER create versions (first or second POST)', async () => { + const sentCommands = []; + let callCount = 0; + + const mockGetObject = async () => { + callCount += 1; + if (callCount === 1) { + return { + status: 404, metadata: {}, body: '', contentLength: 0, + }; + } + return { + status: 200, + body: '', + contentLength: 80, + contentType: 'image/svg+xml', + etag: 'old-etag', + metadata: { + id: 'svg-id-jkl', + version: 'old-version', + timestamp: '1234567890', + users: '["user@example.com"]', + path: 'images/icon.svg', + }, + }; + }; + + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const svgContent = ''; + const svgFile = new File([svgContent], 'icon.svg', { type: 'image/svg+xml' }); + + const env = {}; + const daCtx = { + org: 'testorg', + bucket: 'test-bucket', + key: 'images/icon.svg', + users: [{ email: 'user@example.com' }], + }; + + const update = { + bucket: 'test-bucket', + org: 'testorg', + key: 'images/icon.svg', + body: svgFile, + contentLength: svgContent.length, + type: 'image/svg+xml', + }; + + // FIRST CALL - no version + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + + // SECOND CALL - still no version for binaries + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + }); + + it('ZIP: Binary files NEVER create versions (first or second POST)', async () => { + const sentCommands = []; + let callCount = 0; + + const mockGetObject = async () => { + callCount += 1; + if (callCount === 1) { + return { + status: 404, metadata: {}, body: '', contentLength: 0, + }; + } + return { + status: 200, + body: new Uint8Array([0x50, 0x4B, 0x03, 0x04]), + contentLength: 4, + contentType: 'application/zip', + etag: 'old-etag', + metadata: { + id: 'zip-id-mno', + version: 'old-version', + timestamp: '1234567890', + users: '["user@example.com"]', + path: 'archives/data.zip', + }, + }; + }; + + const mockS3Client = { + async send(cmd) { + sentCommands.push(cmd); + return { $metadata: { httpStatusCode: 200 } }; + }, + }; + + const { putObjectWithVersion } = await esmock('../../../src/storage/version/put.js', { + '../../../src/storage/object/get.js': { + default: mockGetObject, + }, + '../../../src/storage/utils/version.js': { + ifNoneMatch: () => mockS3Client, + ifMatch: () => mockS3Client, + }, + }); + + const zipData = new Uint8Array([0x50, 0x4B, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00]); + const zipFile = new File([zipData], 'data.zip', { type: 'application/zip' }); + + const env = {}; + const daCtx = { + org: 'testorg', + bucket: 'test-bucket', + key: 'archives/data.zip', + users: [{ email: 'user@example.com' }], + }; + + const update = { + bucket: 'test-bucket', + org: 'testorg', + key: 'archives/data.zip', + body: zipFile, + contentLength: zipData.length, + type: 'application/zip', + }; + + // FIRST CALL - no version + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + + // SECOND CALL - still no version for binaries + sentCommands.length = 0; + await putObjectWithVersion(env, daCtx, update); + assert.strictEqual(sentCommands.length, 1); + }); + }); });