diff --git a/src/handlers/unknown.js b/src/handlers/unknown.js deleted file mode 100644 index dbe19289..00000000 --- a/src/handlers/unknown.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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. - */ - -export default function unknownHandler() { - const body = JSON.stringify({ message: 'Unknown method. Please see: https://docs.da.live for more information.' }); - return { body, status: 501 }; -} diff --git a/src/index.js b/src/index.js index 20cfb66b..a848d2b0 100644 --- a/src/index.js +++ b/src/index.js @@ -16,7 +16,6 @@ import headHandler from './handlers/head.js'; import getHandler from './handlers/get.js'; import postHandler from './handlers/post.js'; import deleteHandler from './handlers/delete.js'; -import unknownHandler from './handlers/unknown.js'; export default { /** @@ -29,7 +28,17 @@ export default { return daResp({ status: 204 }); } - const daCtx = await getDaCtx(req, env); + let daCtx; + try { + daCtx = await getDaCtx(req, env); + } catch (e) { + if (e.message === 'Invalid path') { + return daResp({ status: 400 }); + } + console.error('Error computing context', e); + return daResp({ status: 500 }); + } + const { authorized, key } = daCtx; if (!authorized) { const status = daCtx.users[0].email === 'anonymous' ? 401 : 403; @@ -57,7 +66,7 @@ export default { respObj = await deleteHandler({ req, env, daCtx }); break; default: - respObj = unknownHandler(); + respObj = { status: 405 }; } if (!respObj) return daResp({ status: 404 }); diff --git a/src/utils/daCtx.js b/src/utils/daCtx.js index 0e35887f..2964db7c 100644 --- a/src/utils/daCtx.js +++ b/src/utils/daCtx.js @@ -59,6 +59,20 @@ export default async function getDaCtx(req, env) { const path = parts.filter((part) => part !== ''); const keyBase = path.join('/'); + // Compare incoming pathname and intent we understand from it + const pnlc = pathname.toLocaleLowerCase(); + let validPath = ''; + if (api) validPath += `/${api}`; + if (org) validPath += `/${org}`; + if (keyBase) validPath += `/${keyBase}`; + + // Normalize paths by removing trailing slashes before comparison + const normalize = (p) => p.replace(/\/+$/, ''); + if (normalize(pnlc) !== normalize(validPath)) { + // if intent is not what is requested (+/- trailing slash), declare invalid + throw new Error('Invalid path'); + } + // Get the final source name daCtx.filename = path.pop() || ''; diff --git a/test/handlers/unknown.test.js b/test/handlers/unknown.test.js deleted file mode 100644 index cee3586d..00000000 --- a/test/handlers/unknown.test.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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 unknownHandler from '../../src/handlers/unknown.js'; - -describe('unknownHandler', () => { - it('should return unknown response', async () => { - const result = await unknownHandler({}); - assert.strictEqual(result.status, 501); - assert.strictEqual(result.body.includes('Unknown method'), true); - }); -}); diff --git a/test/index.test.js b/test/index.test.js index b86ce7a6..eeae9e4a 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -24,8 +24,8 @@ describe('fetch', () => { }); it('should return a response object for unknown', async () => { - const resp = await handler.fetch({ url: 'https://www.example.com', method: 'BLAH' }, {}); - assert.strictEqual(resp.status, 501); + const resp = await handler.fetch({ url: 'https://www.example.com/endpoint/repo/path/file.html', method: 'BLAH' }, {}); + assert.strictEqual(resp.status, 405); }); it('should return 401 when not authorized and not logged in', async () => { @@ -54,4 +54,49 @@ describe('fetch', () => { const resp = await handler.fetch({ method: 'GET', url: 'http://www.example.com/' }, {}); assert.strictEqual(resp.status, 404); }); + + it('should return 500 when getDaCtx throws unexpected error', async () => { + const hnd = await esmock('../src/index.js', { + '../src/utils/daCtx.js': { + default: async () => { + throw new Error('Unexpected ctx error'); + }, + }, + }); + + const resp = await hnd.fetch({ method: 'GET', url: 'http://www.example.com/source/org/repo/file.html' }, {}); + assert.strictEqual(resp.status, 500); + }); +}); + +describe('invalid routes', () => { + const fetchStatus = async (path, method) => { + const resp = await handler.fetch({ method, url: `http://www.sample.com${path}` }, {}); + return resp.status; + }; + + const test = async (path, status) => { + const methods = ['GET', 'POST', 'PUT', 'DELETE']; + for (const method of methods) { + // eslint-disable-next-line no-await-in-loop + const s = await fetchStatus(path, method); + assert.strictEqual(s, status); + } + }; + + it('return 400 for invalid paths', async () => { + await test('/source//org/repo/path/file.html', 400); + await test('/source/org//repo/path/file.html', 400); + await test('/source/org/repo//path/file.html', 400); + await test('/source/org/repo/path//file.html', 400); + }); + + it('return 404 for unknown paths', async () => { + await test('/unknown/owner/repo/path/file.html', 404); + }); + + it('return 405 for unknown methods', async () => { + const status = await fetchStatus('/source/owner/repo/path/file.html', 'BLAH'); + assert.strictEqual(status, 405); + }); }); diff --git a/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_metadata.json b/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_metadata.json new file mode 100644 index 00000000..3a48491d --- /dev/null +++ b/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_metadata.json @@ -0,0 +1,8 @@ +{ + "content-type": "application/json", + "x-amz-meta-id": "4875c756-42ce-4898-ae86-9b0d88146fbe", + "x-amz-meta-path": "test-repo/test-folder.props", + "x-amz-meta-timestamp": "1764844348129", + "x-amz-meta-users": "[{\"email\":\"anonymous\"}]", + "x-amz-meta-version": "7dd6da34-02a3-4d7f-be02-8170a695199b" +} \ No newline at end of file diff --git a/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_object b/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_object new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_object @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_object.md5 b/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_object.md5 new file mode 100644 index 00000000..a3f71108 --- /dev/null +++ b/test/it/bucket/aem-content-local/test-org/test-repo/test-folder.props._S3rver_object.md5 @@ -0,0 +1 @@ +99914b932bd37a50b983c5e7c90ae93b \ No newline at end of file diff --git a/test/it/smoke.test.js b/test/it/smoke.test.js index ef4c2982..b89709af 100644 --- a/test/it/smoke.test.js +++ b/test/it/smoke.test.js @@ -21,6 +21,9 @@ const SERVER_PORT = 8788; const SERVER_URL = `http://localhost:${SERVER_PORT}`; const S3_DIR = './test/it/bucket'; +const ORG = 'test-org'; +const REPO = 'test-repo'; + describe('Integration Tests: smoke tests', function () { let s3rver; let devServer; @@ -28,6 +31,14 @@ describe('Integration Tests: smoke tests', function () { before(async function () { // Increase timeout for server startup this.timeout(30000); + + // Clear wrangler state to start fresh - needed only for local testing + const fs = await import('fs'); + const wranglerState = path.join(process.cwd(), '.wrangler/state'); + if (fs.existsSync(wranglerState)) { + fs.rmSync(wranglerState, { recursive: true }); + } + s3rver = new S3rver({ port: S3_PORT, address: '127.0.0.1', @@ -100,11 +111,9 @@ describe('Integration Tests: smoke tests', function () { }); it('should get a object via HTTP request', async () => { - const org = 'test-org'; - const repo = 'test-repo'; const pathname = 'test-folder/page1.html'; - const url = `${SERVER_URL}/source/${org}/${repo}/${pathname}`; + const url = `${SERVER_URL}/source/${ORG}/${REPO}/${pathname}`; const resp = await fetch(url); assert.strictEqual(resp.status, 200, `Expected 200 OK, got ${resp.status}`); @@ -114,11 +123,9 @@ describe('Integration Tests: smoke tests', function () { }); it('should list objects via HTTP request', async () => { - const org = 'test-org'; - const repo = 'test-repo'; const key = 'test-folder'; - const url = `${SERVER_URL}/list/${org}/${repo}/${key}`; + const url = `${SERVER_URL}/list/${ORG}/${REPO}/${key}`; const resp = await fetch(url); assert.strictEqual(resp.status, 200, `Expected 200 OK, got ${resp.status}`); @@ -131,8 +138,6 @@ describe('Integration Tests: smoke tests', function () { }); it('should post an object via HTTP request', async () => { - const org = 'test-org'; - const repo = 'test-repo'; const key = 'test-folder/page3'; const ext = '.html'; @@ -142,7 +147,7 @@ describe('Integration Tests: smoke tests', function () { const htmlFile = new File([htmlBlob], 'page3.html', { type: 'text/html' }); formData.append('data', htmlFile); - const url = `${SERVER_URL}/source/${org}/${repo}/${key}${ext}`; + const url = `${SERVER_URL}/source/${ORG}/${REPO}/${key}${ext}`; let resp = await fetch(url, { method: 'POST', body: formData, @@ -151,17 +156,83 @@ describe('Integration Tests: smoke tests', function () { assert.ok([200, 201].includes(resp.status), `Expected 200 or 201, got ${resp.status}`); let body = await resp.json(); - assert.strictEqual(body.source.editUrl, `https://da.live/edit#/${org}/${repo}/${key}`); - assert.strictEqual(body.source.contentUrl, `https://content.da.live/${org}/${repo}/${key}`); - assert.strictEqual(body.aem.previewUrl, `https://main--test-repo--test-org.aem.page/${key}`); - assert.strictEqual(body.aem.liveUrl, `https://main--test-repo--test-org.aem.live/${key}`); + assert.strictEqual(body.source.editUrl, `https://da.live/edit#/${ORG}/${REPO}/${key}`); + assert.strictEqual(body.source.contentUrl, `https://content.da.live/${ORG}/${REPO}/${key}`); + assert.strictEqual(body.aem.previewUrl, `https://main--${REPO}--${ORG}.aem.page/${key}`); + assert.strictEqual(body.aem.liveUrl, `https://main--${REPO}--${ORG}.aem.live/${key}`); // validate page is here (include extension in GET request) - resp = await fetch(`${SERVER_URL}/source/${org}/${repo}/${key}${ext}`); + resp = await fetch(`${SERVER_URL}/source/${ORG}/${REPO}/${key}${ext}`); assert.strictEqual(resp.status, 200, `Expected 200 OK, got ${resp.status}`); body = await resp.text(); assert.strictEqual(body, '