diff --git a/src/handlers/get.js b/src/handlers/get.js index 24dee4d7..591cada5 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -35,11 +35,18 @@ export default async function getHandler({ req, env, daCtx }) { handleAEMProxyRequest({ req, env, daCtx }), ]); + const storeRes = daSourceGetRes.status === 'fulfilled' ? daSourceGetRes.value : undefined; + const aemRes = aemProxyRes.status === 'fulfilled' ? aemProxyRes.value : undefined; + let response; - if (daSourceGetRes.status === 'fulfilled' && daSourceGetRes.value.status === 200) { - response = daSourceGetRes.value; - } else if (aemProxyRes.status === 'fulfilled') { - response = aemProxyRes.value; + if (storeRes?.status === 200) { + response = storeRes; + } else if (aemRes?.status === 200) { + response = aemRes; + } else if (storeRes && storeRes.status >= 500) { + response = storeRes; + } else if (aemRes) { + response = aemRes; } else { return get404(); } diff --git a/src/handlers/head.js b/src/handlers/head.js index cc6a88f9..aeb62746 100644 --- a/src/handlers/head.js +++ b/src/handlers/head.js @@ -41,10 +41,19 @@ export default async function headHandler({ req, env, daCtx }) { aemHead({ req, env, daCtx }), ]); - if (daSourceHeadRes.status === 'fulfilled' && daSourceHeadRes.value.status === 200) { - return daSourceHeadRes.value; - } + const storeRes = daSourceHeadRes.status === 'fulfilled' ? daSourceHeadRes.value : undefined; const aemResponse = aemHeadRes.status === 'fulfilled' ? aemHeadRes.value : null; + + if (storeRes?.status === 200) { + return storeRes; + } + if (aemResponse?.status === 200) { + return aemResponse; + } + // the store could not answer, so neither can we; the proxy's 404 would claim it does not exist + if (storeRes && storeRes.status >= 500) { + return storeRes; + } if (aemResponse && aemResponse.status < 500) { return aemResponse; } diff --git a/src/handlers/post.js b/src/handlers/post.js index ca12c050..c32bc5c8 100644 --- a/src/handlers/post.js +++ b/src/handlers/post.js @@ -9,9 +9,12 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +import { get404 } from '../responses/index.js'; import { daSourcePost } from '../routes/da-admin.js'; export default async function postHandler({ req, env, daCtx }) { + if (!daCtx.site) return get404(); + // for now forward all POST requests to the da-admin return daSourcePost({ req, env, daCtx }); } diff --git a/src/index.js b/src/index.js index eb5d1e3c..e6367675 100644 --- a/src/index.js +++ b/src/index.js @@ -49,21 +49,26 @@ export default { const daCtx = getDaCtx(req); let resp; - switch (req.method) { - case 'OPTIONS': - resp = await optionsHandler({ req }); - break; - case 'HEAD': - resp = await headHandler({ req, env, daCtx }); - break; - case 'GET': - resp = await getHandler({ req, env, daCtx }); - break; - case 'POST': - resp = await postHandlers({ req, env, daCtx }); - break; - default: - resp = unknownHandler(); + try { + switch (req.method) { + case 'OPTIONS': + resp = await optionsHandler({ req }); + break; + case 'HEAD': + resp = await headHandler({ req, env, daCtx }); + break; + case 'GET': + resp = await getHandler({ req, env, daCtx }); + break; + case 'POST': + resp = await postHandlers({ req, env, daCtx }); + break; + default: + resp = unknownHandler(); + } + } catch (e) { + console.error(`500 ${req.method} ${url.pathname}: ${e.name}: ${e.message}`, e); + resp = new Response(null, { status: 500 }); } return withCorsHeaders(resp, req); }, diff --git a/src/responses/index.js b/src/responses/index.js index 1c34e53e..41758933 100644 --- a/src/responses/index.js +++ b/src/responses/index.js @@ -11,6 +11,16 @@ */ import { DEFAULT_UNAUTHORIZED_HTML_MESSAGE } from '../utils/constants.js'; +const RETRY_AFTER_SECONDS = '5'; + +// a 503 says the store did not answer and the body says it again. Only `x-error` says which of a +// rate limit, a timeout or a dropped connection it was, without reading the worker log. +function retryHeaders(error) { + const headers = [['Retry-After', RETRY_AFTER_SECONDS]]; + if (error) headers.push(['x-error', error]); + return headers; +} + export function daResp({ body, status, contentType, contentLength, headers: extraHeaders, }) { @@ -47,10 +57,44 @@ export function get415(message = '') { return daResp({ body: message, status: 415, contentType: 'text/html' }); } +export function get503(message = '', error = '') { + return daResp({ + body: message, + status: 503, + contentType: 'text/html', + headers: retryHeaders(error), + }); +} + +// a refused write is never rendered. The Universal Editor Service embeds the body verbatim in +// its problem+json error string, so plain text is what an author is shown. +export function post503(message = '', error = '') { + return daResp({ + body: message, + status: 503, + contentType: 'text/plain; charset=utf-8', + headers: retryHeaders(error), + }); +} + +// RFC 9110 requires an Allow header on a 405, and reads are what is left once the write is gone. +export function post405(message = '') { + return daResp({ + body: message, + status: 405, + contentType: 'text/plain; charset=utf-8', + headers: [['Allow', 'GET, HEAD, OPTIONS']], + }); +} + export function head401() { return new Response(null, { status: 401 }); } +export function head503(error = '') { + return new Response(null, { status: 503, headers: retryHeaders(error) }); +} + export function head404() { return new Response(null, { status: 404 }); } diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 65f2508e..d2484b6f 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -22,23 +22,46 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, head401, + daResp, get401, get404, get415, get503, head401, head503, post405, post503, } from '../responses/index.js'; -import { BRANCH_NOT_FOUND_HTML_MESSAGE, DEFAULT_HTML_TEMPLATE, UNAUTHORIZED_HTML_MESSAGE } from '../utils/constants.js'; +import { + BRANCH_NOT_FOUND_HTML_MESSAGE, + DEFAULT_HTML_TEMPLATE, + SOURCE_BUS_READ_ONLY_MESSAGE, + SOURCE_UNDETERMINED_MESSAGE, + SOURCE_UNREACHABLE_HTML_MESSAGE, + SOURCE_UNREACHABLE_MESSAGE, + UNAUTHORIZED_HTML_MESSAGE, +} from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; +import isSourceBus from '../storage/source-bus.js'; +import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; const HTML_POST_TYPE = 'text/html'; +/** + * Renders a failure for the `x-error` header. + */ +function causeOf(e) { + return `${e?.name ?? 'Error'}: ${e?.message ?? e}` + .replace(/[^\x20-\x7e]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 1024); +} + +function probeFailed(e, method, sourcePath) { + const cause = `/ping failed: ${causeOf(e)}`; + console.warn(`503 ${method} ${sourcePath}, ${cause}`); + return cause; +} + export function isHtmlPostType(type) { if (!type) return true; return type.split(';')[0].trim().toLowerCase() === HTML_POST_TYPE; } -function getSourceUrl(env, { org, site, sourcePath }) { - return new URL(`/source/${org}/${site}${sourcePath}`, env.DA_ADMIN); -} - async function getFileBody(data) { const text = await data.text(); return { body: text, type: data.type }; @@ -80,6 +103,39 @@ async function getPageTemplate(env, daCtx, aemCtx) { return DEFAULT_HTML_TEMPLATE; } +/** + * Sends a request to a store and reports why it could not be reached at all. + * + * @returns {Promise<{response?: Response, error?: string}>} + */ +async function reachStore(store, send) { + try { + return { response: await send() }; + } catch (e) { + const error = causeOf(e); + console.warn(`503 ${store.url}, the store could not be reached: ${error}`); + return { error }; + } +} + +/** + * Reads from the store that holds the site. + * + * @returns {Promise<{response?: Response, error?: string}>} `error` says why there is no response + */ +async function readSource(env, daCtx, init) { + let onSourceBus; + try { + onSourceBus = await isSourceBus(env, daCtx); + } catch (e) { + return { error: probeFailed(e, init.method, daCtx.sourcePath) }; + } + + const store = getStore(env, daCtx, onSourceBus); + console.log(`-> ${init.method} ${store.url.toString()}`); + return reachStore(store, () => store.fetch(store.url, init)); +} + export async function daSourceGet({ req, env, daCtx }) { const { ext, authToken } = daCtx; @@ -100,17 +156,20 @@ export async function daSourceGet({ req, env, daCtx }) { headers.set('Authorization', authToken); if (ext !== 'html') { - // for non-HTML files, simply proxy the request without processing - const adminUrl = getSourceUrl(env, daCtx); - console.log(`-> ${adminUrl.toString()}`); - const response = await env.daadmin.fetch(adminUrl, { method: 'GET', headers }); - console.log(`<- ${adminUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + // for non-HTML files, simply proxy the request without processing. A refusal is passed on as + // itself: nothing renders an image, so the da:401 shell would only corrupt it. + const { response, error } = await readSource(env, daCtx, { method: 'GET', headers }); + if (!response) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, error); + console.log(`<- ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } - // get the AEM parts (head.html) + // the store lookup costs a round trip, so it runs alongside head.html rather than after it const aemCtx = getAemCtx(env, daCtx); - const headHtml = await getAEMHtml(aemCtx, '/head.html'); + const [headHtml, { response: sourceResp, error: sourceError }] = await Promise.all([ + getAEMHtml(aemCtx, '/head.html'), + readSource(env, daCtx, { method: 'GET', headers }), + ]); if (!headHtml) { // quick-edit still needs a working shell (with the import map) so the editor // can load into this page, even when the AEM branch doesn't exist yet. @@ -119,22 +178,24 @@ export async function daSourceGet({ req, env, daCtx }) { } return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); } + if (!sourceResp) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, sourceError); + console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); - // get the content from DA admin - const adminUrl = getSourceUrl(env, daCtx); + // the store is the only thing to see the token, and the authorbus extension recovers off the + // da:401 meta rather than the status, so a refusal from the store gets that shell + if (sourceResp.status === 401 || sourceResp.status === 403) { + return daResp({ body: UNAUTHORIZED_HTML_MESSAGE, status: sourceResp.status, contentType: 'text/html' }); + } - // eslint-disable-next-line no-param-reassign - req = new Request(adminUrl, { - method: 'GET', - headers, - }); - console.log(`-> ${adminUrl.toString()}`); - const daAdminResp = await env.daadmin.fetch(req); - console.log(`<- ${adminUrl.toString()}. ${daAdminResp.status} ${daAdminResp.statusText}`, { status: daAdminResp.status, statusText: daAdminResp.statusText }); + // only a 404 means "this document is not here". Composing the starter template over anything + // else hands the author a blank page to save over a document that exists. + if (sourceResp.status !== 200 && sourceResp.status !== 404) { + return sourceResp; + } // use the stored content when available, otherwise fall back to a template - const bodyHtml = daAdminResp && daAdminResp.status === 200 - ? await daAdminResp.text() + const bodyHtml = sourceResp.status === 200 + ? await sourceResp.text() : await getPageTemplate(env, daCtx, aemCtx, headHtml); // compose the page the same way for every request type @@ -174,10 +235,9 @@ export async function daSourceHead({ env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); - const adminUrl = getSourceUrl(env, daCtx); - console.log(`-> HEAD ${adminUrl.toString()}`); - const response = await env.daadmin.fetch(adminUrl, { method: 'HEAD', headers }); - console.log(`<- HEAD ${adminUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + const { response, error } = await readSource(env, daCtx, { method: 'HEAD', headers }); + if (!response) return head503(error); + console.log(`<- HEAD ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return new Response(null, { status: response.status, headers: response.headers }); } @@ -213,22 +273,34 @@ export async function daSourcePost({ req, env, daCtx }) { minifyWhitespace(bodyNode); - // create new POST request with the body content - const body = new FormData(); const bodyContent = toHtml(bodyNode); - const data = new Blob([bodyContent], { type: 'text/html' }); - body.set('data', data); - const headers = { Authorization: authToken }; - const adminUrl = getSourceUrl(env, daCtx); - // eslint-disable-next-line no-param-reassign - req = new Request(adminUrl, { + + // the payload is settled, so the only question left is where it goes + let onSourceBus; + try { + onSourceBus = await isSourceBus(env, daCtx); + } catch (e) { + const cause = probeFailed(e, 'POST', sourcePath); + return post503(SOURCE_UNDETERMINED_MESSAGE, cause); + } + + if (onSourceBus) { + console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); + return post405(SOURCE_BUS_READ_ONLY_MESSAGE); + } + + // da-admin takes the document as a `data` form part + const store = getStore(env, daCtx, onSourceBus); + const body = new FormData(); + body.set('data', new Blob([bodyContent], { type: 'text/html' })); + console.log(`-> ${store.url.toString()}`); + const { response, error } = await reachStore(store, () => store.fetch(new Request(store.url, { method: 'POST', body, - headers, - }); - console.log(`-> ${adminUrl.toString()}`); - const response = await env.daadmin.fetch(req); - console.log(`<- ${adminUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + headers: { Authorization: authToken }, + }))); + if (!response) return post503(SOURCE_UNREACHABLE_MESSAGE, error); + console.log(`<- ${store.url.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js new file mode 100644 index 00000000..9cfe6b89 --- /dev/null +++ b/src/storage/source-bus.js @@ -0,0 +1,35 @@ +/* + * Copyright 2026 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. + */ + +const TIMEOUT_MS = 5 * 1000; +const UPGRADE_HEADER = 'x-api-upgrade-available'; + +/** + * Asks `/ping` whether a site is on the source bus. + * + * An answer without the header is legacy: helix-admin sets it when config resolution succeeded and + * named the API. A probe that cannot answer throws, so the caller refuses with the cause rather + * than picking a store. + * + * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes + * @param {Object} daCtx + * @returns {Promise} + */ +export default async function isSourceBus(env, daCtx) { + const { org, site } = daCtx; + // an unparseable hostname leaves org and site undefined, and there is no site to ask about + if (!org || !site) return false; + + const url = new URL(`/ping/${org}/${site}`, env.HLX_ADMIN); + const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); + return response.headers.get(UPGRADE_HEADER) !== null; +} diff --git a/src/storage/store.js b/src/storage/store.js new file mode 100644 index 00000000..e3873de6 --- /dev/null +++ b/src/storage/store.js @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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. + */ +/** + * Picks the store that holds a site's content, the url the document has in it, and the way to + * reach it. + * + * da-admin answers over a service binding and the source bus over the public network, so one + * fetch cannot serve both and the caller cannot pick the transport for itself. + * + * @param {Object} env worker env + * @param {Object} daCtx + * @param {boolean} onSourceBus whether the site is enrolled on the source bus + */ +export default function getStore(env, daCtx, onSourceBus) { + const { org, site, sourcePath } = daCtx; + + if (onSourceBus) { + return { + url: new URL(`/${org}/sites/${site}/source${sourcePath}`, env.AEM_API), + fetch: (input, init) => fetch(input, init), + }; + } + + return { + url: new URL(`/source/${org}/${site}${sourcePath}`, env.DA_ADMIN), + fetch: (input, init) => env.daadmin.fetch(input, init), + }; +} diff --git a/src/utils/constants.js b/src/utils/constants.js index 138c246a..69706aed 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,4 +51,12 @@ export const DEFAULT_HTML_TEMPLATE = '

Not found: Unable to retrieve AEM branch

'; +export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry.

'; + +export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry.'; + +export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry.'; + +export const SOURCE_BUS_READ_ONLY_MESSAGE = 'This site is on the source bus, which this proxy only reads. Nothing was written, and retrying will not help.'; + export const DEFAULT_UNAUTHORIZED_HTML_MESSAGE = '

401: Unauthorized

'; diff --git a/test/handlers/post.test.js b/test/handlers/post.test.js new file mode 100644 index 00000000..9c532796 --- /dev/null +++ b/test/handlers/post.test.js @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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. + */ + +/* eslint-env mocha */ +import assert from 'assert'; +import esmock from 'esmock'; + +const { getDaCtx } = await import('../../src/utils/daCtx.js'); + +describe('POST handler', () => { + let postHandler; + let writes; + + beforeEach(async () => { + writes = 0; + postHandler = (await esmock('../../src/handlers/post.js', { + '../../src/routes/da-admin.js': { + daSourcePost: async () => { + writes += 1; + return new Response('', { status: 201 }); + }, + }, + })).default; + }); + + // the store url is built from org and site, so a hostname naming neither would send the author + // token to https://admin.da.live/source/undefined/undefined/... + it('answers 404 when the hostname named no site, and writes nothing', async () => { + const req = new Request('https://xyz.ue.da.live/content', { method: 'POST' }); + + const res = await postHandler({ req, env: {}, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.strictEqual(writes, 0); + }); + + it('forwards a write on a site it can name', async () => { + const req = new Request('https://main--site--org.ue.da.live/folder/content', { method: 'POST' }); + + const res = await postHandler({ req, env: {}, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(writes, 1); + }); +}); diff --git a/test/index.test.js b/test/index.test.js index 133e1b5e..0bdacba0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -14,13 +14,11 @@ import assert from 'assert'; import esmock from 'esmock'; -const HANDLER_MOCKS = { +// everything but the POST handler, so a write can be driven through the real route +const READ_HANDLER_MOCKS = { '../src/handlers/get.js': { default: async () => new Response('get-handled', { status: 200 }), }, - '../src/handlers/post.js': { - default: async () => new Response('post-handled', { status: 200 }), - }, '../src/handlers/options.js': { default: async () => new Response('options-handled', { status: 204 }), }, @@ -32,7 +30,69 @@ const HANDLER_MOCKS = { }, }; +const HANDLER_MOCKS = { + ...READ_HANDLER_MOCKS, + '../src/handlers/post.js': { + default: async () => new Response('post-handled', { status: 200 }), + }, +}; + +const throwingWorker = async (handler) => (await esmock('../src/index.js', { + ...HANDLER_MOCKS, + [handler]: { + default: async () => { + throw new TypeError('fetch failed'); + }, + }, +})).default; + describe('worker fetch handler', () => { + describe('when a handler throws', () => { + // a throw used to reject worker.fetch, and the runtime answered a bare 500 with no CORS on it, + // which the editor cannot read at all + it('answers 500 rather than rejecting', async () => { + const worker = await throwingWorker('../src/handlers/get.js'); + const req = new Request('https://main--site--org.ue.da.live/some/path'); + + const res = await worker.fetch(req, {}); + + assert.ok(res instanceof Response, 'must return a Response'); + assert.strictEqual(res.status, 500); + }); + + // the assertion that pins the catch inside the switch: wrapped around withCorsHeaders instead, + // the status would still be 500 and the headers would be gone + it('keeps the CORS headers on it', async () => { + const worker = await throwingWorker('../src/handlers/get.js'); + const req = new Request('https://main--site--org.ue.da.live/some/path', { + headers: { Origin: 'https://da.live' }, + }); + + const res = await worker.fetch(req, {}); + + assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), 'https://da.live'); + assert.strictEqual(res.headers.get('Access-Control-Allow-Credentials'), 'true'); + }); + + it('answers the same on a POST', async () => { + const worker = await throwingWorker('../src/handlers/post.js'); + const req = new Request('https://main--site--org.ue.da.live/some/path', { method: 'POST' }); + + const res = await worker.fetch(req, {}); + + assert.strictEqual(res.status, 500); + }); + + it('sends no body, so nothing internal reaches the browser', async () => { + const worker = await throwingWorker('../src/handlers/get.js'); + const req = new Request('https://main--site--org.ue.da.live/some/path'); + + const res = await worker.fetch(req, {}); + + assert.strictEqual(await res.text(), ''); + }); + }); + describe('/.rum/ routing', () => { let worker; @@ -124,4 +184,42 @@ describe('worker fetch handler', () => { assert.strictEqual(res.headers.get('Access-Control-Allow-Headers'), 'Authorization, Content-Type, x-site-token'); }); }); + + // withCorsHeaders rebuilds every response from the handler, so a header the route set only + // reaches the caller if that rebuild carries it + describe('a refused write on a source-bus site', () => { + const busWorker = async () => (await esmock('../src/index.js', READ_HANDLER_MOCKS, { + '../src/storage/source-bus.js': { default: async () => true }, + })).default; + + const uePost = (origin) => { + const body = new FormData(); + body.set('data', new File(['

x

'], 'c.html', { type: 'text/html' })); + return new Request('https://main--site--org.ue.da.live/folder/content', { + method: 'POST', + body, + headers: { Authorization: 'Bearer t', Origin: origin }, + }); + }; + + it('keeps the Allow header alongside the CORS headers', async () => { + const worker = await busWorker(); + + const res = await worker.fetch(uePost('https://da.live'), {}); + + assert.strictEqual(res.status, 405); + assert.strictEqual(res.headers.get('Allow'), 'GET, HEAD, OPTIONS'); + assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), 'https://da.live'); + assert.strictEqual(res.headers.get('Access-Control-Allow-Credentials'), 'true'); + }); + + it('keeps it for an untrusted origin too', async () => { + const worker = await busWorker(); + + const res = await worker.fetch(uePost('https://evil.example.com'), {}); + + assert.strictEqual(res.headers.get('Allow'), 'GET, HEAD, OPTIONS'); + assert.strictEqual(res.headers.get('Access-Control-Allow-Origin'), '*'); + }); + }); }); diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 777c3e31..aad41fc2 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -32,6 +32,8 @@ const recorder = () => { const fetched = []; const env = { DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + HLX_ADMIN: 'https://admin.hlx.page', daadmin: { fetch: async (input) => { fetched.push(input instanceof Request ? input.url : input.href); @@ -42,7 +44,24 @@ const recorder = () => { return { env, fetched }; }; +// answers the real /ping, which is the only lookup the routes make. `upgraded` is the set of +// `org/site` keys the probe reports as enrolled. +const stubPing = (upgraded = []) => { + const asked = []; + globalThis.fetch = async (input) => { + const url = input.toString(); + asked.push(url); + const key = url.slice(url.indexOf('/ping/') + '/ping/'.length); + const headers = upgraded.includes(key) ? { 'x-api-upgrade-available': 'true' } : {}; + return new Response('', { status: 200, headers }); + }; + return asked; +}; + const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { + '../../src/storage/source-bus.js': { + default: async () => false, + }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), getAEMHtml: async () => '', @@ -80,6 +99,7 @@ describe('daSourceHead', () => { describe('daSourceGet', () => { const env = { DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', daadmin: { fetch: async () => new Response('stored', { status: 200 }) }, }; @@ -93,6 +113,9 @@ describe('daSourceGet', () => { const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; calls = { compose: [], ue: 0, quickEdit: 0 }; return (await esmock('../../src/routes/da-admin.js', { + '../../src/storage/source-bus.js': { + default: async () => false, + }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), getAEMHtml: async () => headHtml, @@ -241,6 +264,16 @@ describe('daSourceGet', () => { }); describe('source URLs', () => { + // these drive the unmocked module, so the /ping lookup really does reach out; answer it + // without the upgrade header, which is the legacy store these tests describe + beforeEach(() => { + stubPing(); + }); + + afterEach(() => { + delete globalThis.fetch; + }); + it('GET / reads /index.html', async () => { const { daSourceGet } = await mockRoutes(); const { env, fetched } = recorder(); @@ -369,6 +402,50 @@ describe('daSourcePost to a non-HTML path', () => { }); describe('daSourcePost', () => { + // these drive the unmocked module, so the /ping lookup really does reach out; answer it + // without the upgrade header, which is the legacy store these tests describe + beforeEach(() => { + stubPing(); + }); + + afterEach(() => { + delete globalThis.fetch; + }); + + describe('on a site /ping reports as enrolled', () => { + const write = async (site, env) => { + const html = new File(['hello'], 'page.html', { type: 'text/html' }); + const req = formReq(`https://main--${site}--org.ue.da.live/page`, html); + return daSourcePost({ req, env, daCtx: getDaCtx(req) }); + }; + + it('is refused with 405 and nothing is written', async () => { + stubPing(['org/refused']); + const { env, fetched } = recorder(); + + const res = await write('refused', env); + + assert.strictEqual(res.status, 405); + assert.strictEqual(res.headers.get('Allow'), 'GET, HEAD, OPTIONS'); + assert.deepStrictEqual(fetched, []); + }); + + // nothing is remembered between requests, so a site enrolled or un-enrolled mid-session takes + // effect on the next one + it('probes once per write', async () => { + const asked = stubPing(['org/probedeach']); + const { env } = recorder(); + + await write('probedeach', env); + await write('probedeach', env); + + assert.deepStrictEqual(asked, [ + 'https://admin.hlx.page/ping/org/probedeach', + 'https://admin.hlx.page/ping/org/probedeach', + ]); + }); + }); + // on an HTML path, so the path check does not answer first and this exercises // the part-type check it('refuses a binary File with 415 and does not write', async () => { diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js new file mode 100644 index 00000000..d51cb27b --- /dev/null +++ b/test/routes/source-read.test.js @@ -0,0 +1,730 @@ +/* + * Copyright 2026 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. + */ + +/* eslint-env mocha */ +import assert from 'assert'; +import esmock from 'esmock'; +import { getDaCtx } from '../../src/utils/daCtx.js'; + +const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); + +/** + * Builds the route module with the network replaced. `bus` answers the source bus, `legacy` + * answers da-admin, and every request to each is recorded so a test can assert where a read + * went and what it carried. + */ +const build = async (overrides = {}) => { + const { + bus = () => new Response('from the source bus', { status: 200, headers: { etag: '"busetag"' } }), + legacy = () => new Response('from da-admin', { status: 200 }), + } = overrides; + // `in overrides` rather than a destructured default on these two, so passing an explicit + // undefined really does simulate a missing head.html and a probe that could not answer + const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; + const onSourceBus = 'onSourceBus' in overrides ? overrides.onSourceBus : false; + const probeError = 'probeError' in overrides ? overrides.probeError : new TypeError('fetch failed'); + const seen = { + bus: [], legacy: [], ue: 0, lookups: 0, + }; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + seen.bus.push({ url: request.url, method: request.method, headers: request.headers }); + return bus(request); + }; + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + daadmin: { + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + seen.legacy.push({ url: request.url, method: request.method, headers: request.headers }); + return legacy(request); + }, + }, + }; + const mod = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/source-bus.js': { + default: async () => { + seen.lookups += 1; + // the probe reports a failure by throwing, so undefined stands for "could not answer" + if (onSourceBus === undefined) throw probeError; + return onSourceBus; + }, + }, + '../../src/utils/aemCtx.js': { + getAemCtx: () => ({}), + getAEMHtml: async () => headHtml, + }, + '../../src/render/compose.js': { + composeHtml: async (daCtx, aemCtx, bodyHtml) => ({ bodyHtml }), + serializeHtml: (tree) => `${tree.bodyHtml}`, + }, + '../../src/ue/ue.js': { + applyUEInstrumentation: async () => { seen.ue += 1; }, + }, + '../../src/storage/config.js': { + getSiteConfig: async () => { throw new Error('no config'); }, + }, + }); + return { ...mod, env, seen }; +}; + +describe('when /ping cannot say which store holds the site', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // picking a store without an answer is a coin flip, and reading the wrong one hands the author + // the wrong document at 200 + it('refuses an html read with 503 and touches neither store', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + it('asks the caller to retry', async () => { + const { daSourceGet, env } = await build({ onSourceBus: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(Number(res.headers.get('Retry-After')) > 0); + }); + + it('refuses a non-html read too', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + it('refuses a HEAD with 503 and no body', async () => { + const { daSourceHead, env, seen } = await build({ onSourceBus: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(await res.text(), ''); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // both 503s carry the same status and a body nobody parses, so the header is what tells an + // unanswerable probe apart from a store that was picked and then failed + it('names the failed probe in x-error', async () => { + const { daSourceGet, env } = await build({ onSourceBus: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.match(res.headers.get('x-error'), /ping/); + }); + + it('names the failed probe on a HEAD too', async () => { + const { daSourceHead, env } = await build({ onSourceBus: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.match(res.headers.get('x-error'), /ping/); + }); + + // a read answers the same 503 and the same body whichever of the two failed, so the header is + // the only thing on the wire that separates a timeout from a dropped connection + it('carries the probe cause, not a category', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: undefined, + probeError: new DOMException('timed out', 'TimeoutError'), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); + }); + + // rendering a thrown non-Error as "undefined: undefined" would leave the 503 saying nothing + it('survives a thrown non-Error', async () => { + const { daSourceGet, env } = await build({ onSourceBus: undefined, probeError: 'boom' }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(res.headers.get('x-error'), '/ping failed: Error: boom'); + }); + + it('tells a probe failure apart from a store failure', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'TypeError: fetch failed'); + }); +}); + +describe('reading from the store that holds the site', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('an html read on a source-bus site', () => { + it('reads from the source bus', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.legacy.length, 0); + assert.strictEqual(seen.bus.length, 1); + assert.strictEqual(seen.bus[0].url, 'https://api.aem.live/org/sites/site/source/folder/content.html'); + }); + + it('composes the source-bus document, not da-admin\'s copy of it', async () => { + const { daSourceGet, env } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(await res.text(), 'from the source bus'); + }); + + it('forwards the author token to the source bus', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.bus[0].headers.get('Authorization'), 'Bearer t'); + }); + }); + + describe('an html read on a legacy site', () => { + it('reads from da-admin over the service binding', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.bus.length, 0); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(seen.legacy[0].url, 'https://admin.da.live/source/org/site/folder/content.html'); + }); + }); + + describe('the store lookup on a read', () => { + it('happens once, and only the store it named is asked', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.legacy.length, 0); + }); + }); + + describe('when the store cannot be reached at all', () => { + it('answers 503 rather than throwing on an html read', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + }); + + it('answers 503 rather than throwing on a non-html read', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + assert.strictEqual((await daSourceGet({ req, env, daCtx: getDaCtx(req) })).status, 503); + }); + + it('answers 503 rather than throwing on a HEAD', async () => { + const { daSourceHead, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + assert.strictEqual((await daSourceHead({ env, daCtx: getDaCtx(req) })).status, 503); + }); + + it('answers 503 rather than throwing when da-admin is unreachable', async () => { + const { daSourceGet, env } = await build({ + legacy: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + assert.strictEqual((await daSourceGet({ req, env, daCtx: getDaCtx(req) })).status, 503); + }); + + it('asks the caller to retry', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(Number(res.headers.get('Retry-After')) > 0); + }); + + // the read refusals are rendered, by the preview iframe and by quick-edit, so they carry a + // body that says what happened rather than an empty page + it('says what happened, on both GET paths', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const html = authedReq('https://main--site--org.ue.da.live/folder/content'); + const asset = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + const htmlRes = await daSourceGet({ req: html, env, daCtx: getDaCtx(html) }); + const assetRes = await daSourceGet({ req: asset, env, daCtx: getDaCtx(asset) }); + + assert.match(await htmlRes.text(), /503/); + assert.match(await assetRes.text(), /503/); + }); + + it('answers 503 with no body on a HEAD', async () => { + const { daSourceHead, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(await res.text(), ''); + assert.ok(Number(res.headers.get('Retry-After')) > 0); + }); + + // a rate limit, a DNS failure and a timeout all arrive here as the same 503, and the worker + // log is not where the caller is looking + it('names the cause in x-error on an html read', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('Network connection lost'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + }); + + it('names the cause on a non-html read', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('Network connection lost'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + }); + + it('names the cause on a HEAD', async () => { + const { daSourceHead, env } = await build({ + onSourceBus: true, + bus: () => { throw new DOMException('The operation timed out', 'TimeoutError'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'TimeoutError: The operation timed out'); + }); + + it('names the cause when da-admin is unreachable', async () => { + const { daSourceGet, env } = await build({ + legacy: () => { throw new TypeError('Network connection lost'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + }); + + // a header value cannot span lines, so a message carrying a stack would throw where the 503 + // is built and turn the 503 into a 500 + it('collapses a multi-line cause onto one line', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('lost\n at fetch'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'TypeError: lost at fetch'); + }); + }); + + describe('what a store status means', () => { + // turning any of these into the blank starter template at HTTP 200 hands the author an + // empty document to save over a page that exists + [401, 403, 429, 500, 502].forEach((status) => { + it(`keeps the store's ${status} as the status`, async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => new Response('upstream said no', { status }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, status); + }); + }); + + // the body is the store's own except on a refusal, where it is replaced below + [429, 500, 502].forEach((status) => { + it(`passes the store's body through on a ${status}`, async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => new Response('upstream said no', { status }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(await res.text(), 'upstream said no'); + }); + }); + + // /ping reads no token, so the store is the only thing to see one. the authorbus extension + // matches this sentinel exactly and recovers by refetching /gimme_cookie and refreshing + [401, 403].forEach((status) => { + it(`serves the da:401 shell when the store answers ${status} on an html read`, async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => new Response('', { status }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, status); + assert.match(await res.text(), /content="da:401"/); + }); + }); + + it('does not ask the caller to retry a 401, since retrying cannot help', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => new Response('', { status: 401 }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('Retry-After'), null); + }); + + it('passes a store 401 through bare on a non-html read, which renders nothing', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => new Response('', { status: 401 }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(await res.text(), ''); + }); + + it('answers a store 401 on a HEAD with no body', async () => { + const { daSourceHead, env } = await build({ + onSourceBus: true, + bus: () => new Response('', { status: 401 }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(await res.text(), ''); + }); + + // a wrong yes from /ping produces this, and there is no second store to retry against: the + // author is handed the starter template over whatever da-admin still holds + it('composes the starter template on a 404 without asking the other store', async () => { + const { daSourceGet, env, seen } = await build({ + onSourceBus: true, + bus: () => new Response('', { status: 404 }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.ok(!(await res.text()).includes('from the source bus')); + assert.strictEqual(seen.legacy.length, 0); + }); + }); + + describe('UE instrumentation', () => { + it('is applied on a UE host', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.ue, 1); + }); + + it('is not applied on a preview host', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.preview.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.ue, 0); + }); + + // the connection uri names the page and nothing else; the store a read came from is not + // carried on it, so a save resolves the store for itself + it('leaves no marker on the connection uri', async () => { + const seenReq = []; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + seenReq.push(request.url); + if (request.url.startsWith('https://api.aem.live/')) { + return new Response('

x

', { status: 200 }); + } + return new Response('not found', { status: 404 }); + }; + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + UE_HOST: 'ue.da.live', + daadmin: { fetch: async () => new Response('', { status: 200 }) }, + }; + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/source-bus.js': { default: async () => true }, + '../../src/utils/aemCtx.js': { + getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), + getAEMHtml: async () => '', + }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const html = await (await daSourceGet({ req, env, daCtx: getDaCtx(req) })).text(); + + assert.match(html, /urn:adobe:aue:system:ab" content="da:[^"?]*"/); + assert.ok(!html.includes('ab-src'), 'no marker on the connection uri'); + assert.ok(!html.includes('content-store'), 'no marker on the connection uri'); + }); + }); + + describe('a non-html read', () => { + it('goes to the source bus fully normalized', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.ue.da.live/Media/Holiday.PNG'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.bus[0].url, 'https://api.aem.live/org/sites/site/source/media/holiday.png'); + }); + + it('goes to da-admin fully lowercased on a legacy site', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/Media/Holiday.PNG'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.legacy[0].url, 'https://admin.da.live/source/org/site/media/holiday.png'); + }); + }); + + describe('a HEAD', () => { + it('goes to the source bus on a source-bus site', async () => { + const { daSourceHead, env, seen } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.bus.length, 1); + assert.strictEqual(seen.bus[0].method, 'HEAD'); + assert.strictEqual(seen.bus[0].url, 'https://api.aem.live/org/sites/site/source/folder/content.html'); + }); + + it('goes to da-admin on a legacy site', async () => { + const { daSourceHead, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(seen.legacy[0].method, 'HEAD'); + }); + + it('passes a 404 from the store through, since HEAD composes nothing', async () => { + const { daSourceHead, env } = await build({ + onSourceBus: true, + bus: () => new Response('', { status: 404 }), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + }); + }); + + describe('a media read, which the handlers race against the AEM proxy', () => { + // the store answer is preferred at 200 and the published copy otherwise, so an image that is + // published still resolves during a store outage. But when neither answers, a 404 says "this + // image does not exist" where the truth is "we could not find out", so the store's own 503 + // wins over a non-200 from the proxy. + const raced = async (handler, storeStatus, aemStatus) => { + const mod = await esmock(`../../src/handlers/${handler}.js`, { + '../../src/routes/da-admin.js': { + daSourceGet: async () => new Response('', { status: storeStatus }), + daSourceHead: async () => new Response(null, { status: storeStatus }), + }, + '../../src/routes/aem-proxy.js': { + handleAEMProxyRequest: async () => new Response(aemStatus === 200 ? 'the published bytes' : '', { status: aemStatus }), + }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + return (await mod.default({ req, env: {}, daCtx: getDaCtx(req) })).status; + }; + + ['get', 'head'].forEach((handler) => { + it(`prefers the published copy over an unreachable store on a ${handler.toUpperCase()}`, async () => { + assert.strictEqual(await raced(handler, 503, 200), 200); + }); + + it(`answers 503 rather than 404 when neither answers on a ${handler.toUpperCase()}`, async () => { + assert.strictEqual(await raced(handler, 503, 404), 503); + }); + + it(`still answers 404 when the image is simply absent on a ${handler.toUpperCase()}`, async () => { + assert.strictEqual(await raced(handler, 404, 404), 404); + }); + + it(`still prefers the store at 200 on a ${handler.toUpperCase()}`, async () => { + assert.strictEqual(await raced(handler, 200, 404), 200); + }); + + // the reason an undetermined store answers 503 rather than throwing: allSettled turns a + // rejection into "no answer" and the proxy's 404 would win, claiming the image is absent + it(`answers 503 when the store is undetermined on a ${handler.toUpperCase()}`, async () => { + const mod = await esmock(`../../src/handlers/${handler}.js`, { + '../../src/routes/da-admin.js': { + daSourceGet: async () => { throw new TypeError('fetch failed'); }, + daSourceHead: async () => { throw new TypeError('fetch failed'); }, + }, + '../../src/routes/aem-proxy.js': { + handleAEMProxyRequest: async () => new Response('', { status: 404 }), + }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + const thrown = (await mod.default({ req, env: {}, daCtx: getDaCtx(req) })).status; + + assert.strictEqual(thrown, 404, 'a rejection is swallowed and the proxy answers'); + assert.strictEqual(await raced(handler, 503, 404), 503, 'a 503 response is not'); + }); + }); + + // getHandler races an image read against *.aem.page and takes the proxy answer whenever the + // store read is not a 200. So an unreachable store degrades an image to the published copy + // rather than breaking the page, and an image cannot be laundered into a write: a POST to a + // non-html path is refused with 415 before anything is resolved. + it('falls through to the AEM proxy for an image when the store did not answer', async () => { + const seen = []; + globalThis.fetch = async (input) => { + seen.push(input.toString()); + return new Response('the published bytes', { status: 200 }); + }; + const getHandler = (await esmock('../../src/handlers/get.js', { + '../../src/routes/da-admin.js': { + daSourceGet: async () => new Response('', { status: 503 }), + }, + '../../src/routes/aem-proxy.js': { + handleAEMProxyRequest: async () => new Response('the published bytes', { status: 200 }), + }, + })).default; + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + const res = await getHandler({ req, env: {}, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(await res.text(), 'the published bytes'); + }); + + // mp4 is not in the raced extensions, so it has no published copy to fall back to and the + // refusal reaches the caller as itself + it('refuses a video read when the store could not be reached', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + }); + + it('reads a video from the source bus on a source-bus site', async () => { + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.bus[0].url, 'https://api.aem.live/org/sites/site/source/folder/clip.mp4'); + }); + }); + + describe('the order of the two things that can fail', () => { + // a missing AEM branch is answered as it was before, so quick-edit still gets its shell + it('reports a missing AEM branch even when the store did not answer', async () => { + const { daSourceGet, env } = await build({ + onSourceBus: true, + headHtml: undefined, + bus: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + }); + }); +}); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js new file mode 100644 index 00000000..ec9ce54f --- /dev/null +++ b/test/routes/source-write.test.js @@ -0,0 +1,308 @@ +/* + * Copyright 2026 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. + */ + +/* eslint-env mocha */ +import assert from 'assert'; +import esmock from 'esmock'; +import { getDaCtx } from '../../src/utils/daCtx.js'; +import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../../src/utils/constants.js'; + +const AT = 'https://main--site--org.ue.da.live/folder/content'; +const DOC = '

the author typed this

'; + +/** The shape the Universal Editor Service posts: a `data` blob in a multipart form. */ +const uePost = (url, html = DOC) => { + const body = new FormData(); + body.set('data', new File([html], 'content.html', { type: 'text/html' })); + return new Request(url, { method: 'POST', body, headers: { Authorization: 'Bearer t' } }); +}; + +const build = async (overrides = {}) => { + const { status = 201 } = overrides; + // `in overrides` rather than a destructured default, so passing an explicit undefined really + // does simulate a probe that could not answer + const onSourceBus = 'onSourceBus' in overrides ? overrides.onSourceBus : false; + const probeError = 'probeError' in overrides ? overrides.probeError : new TypeError('fetch failed'); + const seen = { + bus: [], legacy: [], lookups: 0, order: [], + }; + const capture = async (request) => { + const contentType = request.headers.get('Content-Type'); + const [body, form] = await Promise.all([ + request.clone().text(), + contentType?.startsWith('multipart/') ? request.clone().formData() : undefined, + ]); + return { + url: request.url, + method: request.method, + headers: request.headers, + body, + form, + contentType, + }; + }; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + seen.order.push('store'); + seen.bus.push(await capture(request)); + return new Response('', { status }); + }; + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + daadmin: { + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + seen.order.push('store'); + seen.legacy.push(await capture(request)); + return new Response('', { status }); + }, + }, + }; + const mod = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/source-bus.js': { + default: async () => { + seen.lookups += 1; + seen.order.push('lookup'); + // the probe reports a failure by throwing, so undefined stands for "could not answer" + if (onSourceBus === undefined) throw probeError; + return onSourceBus; + }, + }, + }); + return { daSourcePost: mod.daSourcePost, env, seen }; +}; + +const post = async (opts, url = AT) => { + const { daSourcePost, env, seen } = await build(opts); + const req = uePost(url); + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + return { res, seen }; +}; + +describe('writing to the store that holds the site', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // the Universal Editor on da.live has never supported source-bus sites, and da-admin would take + // the document at 201 for a key nothing serves, so the write is refused rather than misplaced + describe('a source-bus site', () => { + it('is refused with 405 and touches neither store', async () => { + const { res, seen } = await post({ onSourceBus: true }); + + assert.strictEqual(res.status, 405); + assert.strictEqual(seen.bus.length, 0); + assert.strictEqual(seen.legacy.length, 0); + }); + + it('names the methods that are left', async () => { + const { res } = await post({ onSourceBus: true }); + + assert.strictEqual(res.headers.get('Allow'), 'GET, HEAD, OPTIONS'); + }); + + it('does not ask the caller to retry, since retrying cannot help', async () => { + const { res } = await post({ onSourceBus: true }); + + assert.strictEqual(res.headers.get('Retry-After'), null); + }); + + // nothing renders a POST body, and UES embeds it verbatim in its problem+json error string, + // so the exact text is what the author is shown + it('says what happened in plain text', async () => { + const { res } = await post({ onSourceBus: true }); + + assert.match(res.headers.get('Content-Type'), /^text\/plain/); + assert.strictEqual(await res.text(), SOURCE_BUS_READ_ONLY_MESSAGE); + }); + }); + + // a write is the one operation a wrong store cannot be walked back from, so no answer means no + // write rather than a guess + describe('when /ping cannot say which store holds the site', () => { + it('is refused with 503 and touches neither store', async () => { + const { res, seen } = await post({ onSourceBus: undefined }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(seen.bus.length, 0); + assert.strictEqual(seen.legacy.length, 0); + }); + + it('asks the caller to retry, unlike the source-bus refusal', async () => { + const { res } = await post({ onSourceBus: undefined }); + + assert.ok(Number(res.headers.get('Retry-After')) > 0); + }); + + it('says which of the two refusals it is', async () => { + const { res } = await post({ onSourceBus: undefined }); + + assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); + }); + + it('names the failed probe in x-error', async () => { + const { res } = await post({ onSourceBus: undefined }); + + assert.match(res.headers.get('x-error'), /ping/); + }); + + it('carries the probe cause, not a category', async () => { + const { res } = await post({ + onSourceBus: undefined, + probeError: new DOMException('timed out', 'TimeoutError'), + }); + + assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); + }); + }); + + describe('a legacy site', () => { + it('writes to da-admin over the service binding', async () => { + const { seen } = await post({}); + + assert.strictEqual(seen.bus.length, 0); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(seen.legacy[0].method, 'POST'); + assert.strictEqual(seen.legacy[0].url, 'https://admin.da.live/source/org/site/folder/content.html'); + }); + + // da-admin parses the document out of a `data` form part and ignores a raw body + it('sends the document as a text/html data part', async () => { + const { seen } = await post({}); + + const data = seen.legacy[0].form.get('data'); + + assert.strictEqual(await data.text(), DOC); + assert.strictEqual(data.type, 'text/html'); + }); + + it('authorizes with the caller token', async () => { + const { seen } = await post({}); + + assert.strictEqual(seen.legacy[0].headers.get('Authorization'), 'Bearer t'); + }); + + it('passes the store answer back', async () => { + const { res } = await post({ status: 412 }); + + assert.strictEqual(res.status, 412); + }); + + it('normalizes the whole path', async () => { + const { seen } = await post( + {}, + 'https://main--site--org.ue.da.live/Folder/Content', + ); + + assert.strictEqual(seen.legacy[0].url, 'https://admin.da.live/source/org/site/folder/content.html'); + }); + + it('strips the UE data attributes before the store sees them', async () => { + const { daSourcePost, env, seen } = await build({}); + const req = uePost( + AT, + '

text

', + ); + + await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(!seen.legacy[0].body.includes('data-aue-resource'), seen.legacy[0].body); + }); + }); + + describe('a legacy write carries no precondition', () => { + // nothing round-trips an etag into the save: the editor keeps the connection uri it was + // served at page load and posts back to it for every edit, so a version pin would land the + // first save and refuse the rest with 412 + it('sends no If-Match or If-None-Match', async () => { + const { seen } = await post({}); + + assert.strictEqual(seen.legacy[0].headers.get('If-Match'), null); + assert.strictEqual(seen.legacy[0].headers.get('If-None-Match'), null); + }); + + it('so a UE session can save the same page many times', async () => { + const { daSourcePost, env, seen } = await build({}); + const statuses = []; + + for (let i = 0; i < 4; i += 1) { + const req = uePost(AT, `

edit ${i + 1}

`); + // eslint-disable-next-line no-await-in-loop + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + statuses.push(res.status); + } + + assert.deepStrictEqual(statuses, [201, 201, 201, 201]); + assert.strictEqual(seen.legacy.length, 4); + }); + }); + + describe('the store lookup on a write', () => { + // a legacy write is the case that can tell the two orderings apart: the store is reached + // either way, so only the sequence says whether the write went out before it was placed + it('happens before anything is sent to a store', async () => { + const { seen } = await post({}); + + assert.deepStrictEqual(seen.order, ['lookup', 'store']); + }); + + it('happens on a source-bus site too, which is what the refusal rests on', async () => { + const { seen } = await post({ onSourceBus: true }); + + assert.strictEqual(seen.lookups, 1); + }); + }); + + describe('when da-admin cannot be reached at all', () => { + it('answers 503 rather than throwing', async () => { + const { daSourcePost, env } = await build({}); + env.daadmin.fetch = async () => { + throw new TypeError('fetch failed'); + }; + const req = uePost(AT); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + }); + + // a save that failed on a rate limit and one that failed on a dropped connection are the same + // 503 to the editor, and only one of them is worth retrying at once + it('names the cause in x-error', async () => { + const { daSourcePost, env } = await build({}); + env.daadmin.fetch = async () => { + throw new TypeError('Network connection lost'); + }; + const req = uePost(AT); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + }); + }); + + describe('a non-html path', () => { + // driven on a source-bus site, so the 415 has to come from the extension check rather than + // from the refusal below it. on a legacy site either ordering would pass. + it('is refused before anything is resolved', async () => { + const { daSourcePost, env, seen } = await build({ onSourceBus: true }); + const req = uePost('https://main--site--org.ue.da.live/folder/data.json'); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 415); + assert.strictEqual(seen.lookups, 0); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + }); +}); diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js new file mode 100644 index 00000000..8093d3fd --- /dev/null +++ b/test/storage/source-bus.test.js @@ -0,0 +1,194 @@ +/* + * Copyright 2026 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. + */ + +/* eslint-env mocha */ +import assert from 'assert'; + +const { default: isSourceBus } = await import('../../src/storage/source-bus.js'); + +const env = { AEM_API: 'https://api.aem.live', HLX_ADMIN: 'https://admin.hlx.page' }; + +const daCtx = (over = {}) => ({ + org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, +}); + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(input.toString(), init); + }; +}; + +const ping = (headers = {}, status = 200) => new Response('', { status, headers }); +const upgraded = () => ping({ 'x-api-upgrade-available': 'true' }); + +describe('isSourceBus', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks /ping on the admin host', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + }); + + it('takes the admin host from env, so stage can point elsewhere', async () => { + stubFetch(upgraded); + + await isSourceBus({ ...env, HLX_ADMIN: 'https://admin.stage.example' }, daCtx()); + + assert.strictEqual(calls[0].url, 'https://admin.stage.example/ping/org/site'); + }); + + // both stores read one config service and the source is per site, so the branch cannot change + // the answer + it('does not vary by ref', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx({ ref: 'branch' })); + + assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + }); + + // /ping is exempt from authorize() in helix-admin and answers the same with or without a token + it('sends no token, since /ping does not read one', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.strictEqual(new Headers(calls[0].init.headers).get('Authorization'), null); + }); + + it('gives up rather than hanging', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.ok(calls[0].init.signal, 'the probe carries an abort signal'); + }); + }); + + describe('when /ping says the site is upgraded', () => { + it('answers true', async () => { + stubFetch(upgraded); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + + // presence, not value: da-nx tests the same header with `!== null` (nx2/utils/api.js, + // isHlx6), and two clients reading it differently would split one site across two stores + ['false', '', 'TRUE'].forEach((value) => { + it(`counts any value, including ${JSON.stringify(value)}`, async () => { + stubFetch(() => ping({ 'x-api-upgrade-available': value })); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + }); + + // no status test, for the same reason. the edge sets the header from its dictionary, so a + // rate-limited or erroring origin behind it does not make an enrolled site legacy + [429, 500, 503].forEach((status) => { + it(`counts it on a ${status}, since the header is what carries the answer`, async () => { + stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, status)); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + }); + }); + + describe('when /ping does not say so', () => { + [ + ['the header is absent', {}, 200], + ['the header is absent on a 404', {}, 404], + ['the header is absent on a 405', {}, 405], + ['the header is absent on a 500', {}, 500], + ].forEach(([what, headers, status]) => { + it(`answers false: ${what}`, async () => { + stubFetch(() => ping(headers, status)); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + }); + }); + }); + + // an answer without the header is legacy. no answer is not an answer, and the caller refuses + // rather than picking a store on a coin flip + describe('when /ping cannot answer', () => { + // the cause reaches the caller, which reports it on the 503 as `x-error`. swallowing it here + // would leave a timeout and a dropped connection indistinguishable + it('lets the failure through', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(isSourceBus(env, daCtx()), { message: 'fetch failed' }); + }); + + it('lets it through when HLX_ADMIN is unusable, without asking', async () => { + stubFetch(upgraded); + + await assert.rejects(isSourceBus({ AEM_API: 'https://api.aem.live' }, daCtx())); + assert.strictEqual(calls.length, 0); + }); + + // the distinction the caller acts on: false is a store, a failure is no store + it('is distinguishable from a legacy answer', async () => { + stubFetch(() => ping()); + assert.strictEqual(await isSourceBus(env, daCtx()), false); + + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + await assert.rejects(isSourceBus(env, daCtx())); + }); + }); + + describe('when there is no site to ask about', () => { + // either one missing is enough: a half-parsed request would otherwise build a ping url with + // "undefined" in it + [ + ['neither', { org: undefined, site: undefined }], + ['no org', { org: undefined }], + ['no site', { site: undefined }], + ['an empty org', { org: '' }], + ['an empty site', { site: '' }], + ].forEach(([what, over]) => { + it(`answers false without making a request: ${what}`, async () => { + stubFetch(upgraded); + + assert.strictEqual(await isSourceBus(env, daCtx(over)), false); + assert.strictEqual(calls.length, 0); + }); + }); + }); + + // nothing is remembered between calls, so an enrolment takes effect on the next read and a + // config blip cannot pin a stale answer + it('probes every time it is asked', async () => { + let enrolled = false; + stubFetch(() => (enrolled ? upgraded() : ping())); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + enrolled = true; + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + assert.strictEqual(calls.length, 2); + }); +}); diff --git a/test/storage/store.test.js b/test/storage/store.test.js new file mode 100644 index 00000000..62a5025b --- /dev/null +++ b/test/storage/store.test.js @@ -0,0 +1,89 @@ +/* + * Copyright 2026 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. + */ + +/* eslint-env mocha */ +import assert from 'assert'; +import { getDaCtx } from '../../src/utils/daCtx.js'; + +const { default: getStore } = await import('../../src/storage/store.js'); + +const env = { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live' }; +const ctxFor = (url) => getDaCtx(new Request(url, { headers: { Authorization: 'Bearer t' } })); + +describe('getStore', () => { + describe('the url a document has in the store', () => { + it('builds the source-bus url from the normalized path, not the requested one', () => { + const store = getStore(env, ctxFor('https://main--site--org.ue.da.live/Media/Holiday.PNG'), true); + + assert.strictEqual(store.url.toString(), 'https://api.aem.live/org/sites/site/source/media/holiday.png'); + }); + + it('builds a legacy url under DA_ADMIN from the lowercased source path', () => { + const store = getStore(env, ctxFor('https://main--site--org.ue.da.live/Folder/Doc'), false); + + assert.strictEqual(store.url.toString(), 'https://admin.da.live/source/org/site/folder/doc.html'); + }); + + // helix-api-service refuses a source url naming another org or site, so the request's own org + // and site are the only base it can have + it('builds the source-bus url from the request\'s own org and site', () => { + const store = getStore(env, ctxFor('https://main--other--shared.ue.da.live/folder/doc'), true); + + assert.strictEqual(store.url.toString(), 'https://api.aem.live/shared/sites/other/source/folder/doc.html'); + }); + + it('builds it on AEM_API, so stage moves it', () => { + const bound = { ...env, AEM_API: 'https://api.stage.example' }; + const store = getStore(bound, ctxFor('https://main--site--org.ue.da.live/folder/doc'), true); + + assert.strictEqual(store.url.toString(), 'https://api.stage.example/org/sites/site/source/folder/doc.html'); + }); + + it('tolerates a trailing slash on AEM_API', () => { + const bound = { ...env, AEM_API: 'https://api.aem.live/' }; + const store = getStore(bound, ctxFor('https://main--site--org.ue.da.live/folder/doc'), true); + + assert.strictEqual(store.url.toString(), 'https://api.aem.live/org/sites/site/source/folder/doc.html'); + }); + }); + + describe('how it reaches the store', () => { + it('sends a legacy request over the daadmin service binding', async () => { + const seen = []; + const record = async (input) => { + seen.push(input); + return new Response(''); + }; + const bound = { ...env, daadmin: { fetch: record } }; + const store = getStore(bound, ctxFor('https://main--site--org.ue.da.live/doc'), false); + + await store.fetch(store.url, { method: 'GET' }); + + assert.strictEqual(seen.length, 1); + }); + + it('sends a source-bus request over the public network', async () => { + const seen = []; + globalThis.fetch = async (input) => { + seen.push(input); + return new Response(''); + }; + const bound = { ...env, daadmin: { fetch: async () => assert.fail('used the binding') } }; + const store = getStore(bound, ctxFor('https://main--site--org.ue.da.live/doc'), true); + + await store.fetch(store.url, { method: 'GET' }); + + delete globalThis.fetch; + assert.strictEqual(seen.length, 1); + }); + }); +}); diff --git a/test/utils/daCtx.test.js b/test/utils/daCtx.test.js index 96a4a9ca..fd57c7ee 100644 --- a/test/utils/daCtx.test.js +++ b/test/utils/daCtx.test.js @@ -193,6 +193,18 @@ describe('DA context', () => { assert.strictEqual(ctx.ext, 'png'); }); + it('maps /A/B/c.JSON to /a/b/c.json', () => { + const ctx = ctxFor('/A/B/c.JSON'); + assert.strictEqual(ctx.sourcePath, '/a/b/c.json'); + assert.strictEqual(ctx.ext, 'json'); + }); + + it('maps /folder/Sub-Folder/ to /folder/sub-folder/index.html', () => { + const ctx = ctxFor('/folder/Sub-Folder/'); + assert.strictEqual(ctx.sourcePath, '/folder/sub-folder/index.html'); + assert.strictEqual(ctx.ext, 'html'); + }); + it('maps /sheet.json to /sheet.json', () => { const ctx = ctxFor('/sheet.json'); assert.strictEqual(ctx.sourcePath, '/sheet.json'); diff --git a/wrangler.toml b/wrangler.toml index 3d7fbdd7..799ca37e 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,18 +2,18 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } services = [{ binding = "daadmin", service = "da-admin" }] [dev] port = 4712 [env.dev] -vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live" } +vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } services = [{ binding = "daadmin", service = "da-admin-local" }] [env.stage] -vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live" } +vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } services = [{ binding = "daadmin", service = "da-admin" }] [env.stage.observability]