diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 588d9165..7d7aa17d 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -28,6 +28,53 @@ import { BRANCH_NOT_FOUND_HTML_MESSAGE, DEFAULT_HTML_TEMPLATE, UNAUTHORIZED_HTML import { getSiteConfig } from '../storage/config.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; +const AEM_API = 'https://api.aem.live'; +const HLX_ADMIN = 'https://admin.hlx.page'; +const UPGRADE_PROBE_TIMEOUT = 2 * 1000; + +function aemApiSourceUrl(org, site, path) { + return `${AEM_API}/${org}/sites/${site}/source${path}`; +} + +async function probeHlx6(org, site) { + const pingUrl = `${HLX_ADMIN}/ping/${org}/${site}`; + try { + const response = await fetch(pingUrl, { + signal: AbortSignal.timeout(UPGRADE_PROBE_TIMEOUT), + }); + if (response.status !== 200) { + return undefined; + } + return response.headers.get('x-api-upgrade-available') === 'true'; + } catch { + return undefined; + } +} + +/** + * Resolves the write backend after the site probe fails. + * Both backends are HEADed concurrently. A source-bus 200 selects api.aem.live. + * Every other result selects da-admin, including a missing document or failed HEAD. + * + * @returns {Promise} true for api.aem.live, false for da-admin + */ +async function resolveUncertainWriteBackend({ + org, site, sourcePath, authToken, env, +}) { + const headers = new Headers({ Authorization: authToken }); + const aemUrl = aemApiSourceUrl(org, site, sourcePath); + const daUrl = new URL(`/source/${org}/${site}${sourcePath}`, env.DA_ADMIN); + + const [aemResult] = await Promise.allSettled([ + fetch(aemUrl, { method: 'HEAD', headers }), + env.daadmin.fetch(daUrl, { method: 'HEAD', headers }), + ]); + if (aemResult.status === 'fulfilled' && aemResult.value.status === 200) { + return true; + } + return false; +} + async function getFileBody(data) { const text = await data.text(); return { body: text, type: data.type }; @@ -90,11 +137,21 @@ export async function daSourceGet({ req, env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); + const hlx6Promise = probeHlx6(org, site); + if (ext !== 'html') { /* for non-HTML files, simply proxy the request without processing and ensure that extensions are not duplicated */ + const hlx6 = await hlx6Promise; + if (hlx6) { + const sourceUrl = aemApiSourceUrl(org, site, path); + console.log(`-> ${sourceUrl}`); + const response = await fetch(sourceUrl, { headers }); + console.log(`<- ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + return response; + } const adminUrl = new URL(`/source/${org}/${site}${path}`, env.DA_ADMIN); console.log(`-> ${adminUrl.toString()}`); const response = await env.daadmin.fetch(adminUrl, { method: 'GET', headers }); @@ -104,7 +161,10 @@ export async function daSourceGet({ req, env, daCtx }) { // get the AEM parts (head.html) const aemCtx = getAemCtx(env, daCtx); - const headHtml = await getAEMHtml(aemCtx, '/head.html'); + const [headHtml, hlx6] = await Promise.all([ + getAEMHtml(aemCtx, '/head.html'), + hlx6Promise, + ]); 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. @@ -114,24 +174,30 @@ export async function daSourceGet({ req, env, daCtx }) { return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); } - // get the content from DA admin - const adminUrl = new URL( - `/source/${org}/${site}${path}.${ext}`, - env.DA_ADMIN, - ); + let sourceResp; + if (hlx6) { + const sourceUrl = aemApiSourceUrl(org, site, `${path}.${ext}`); + console.log(`-> ${sourceUrl}`); + sourceResp = await fetch(sourceUrl, { headers }); + console.log(`<- ${sourceUrl}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); + } else { + const adminUrl = new URL( + `/source/${org}/${site}${path}.${ext}`, + env.DA_ADMIN, + ); + console.log(`-> ${adminUrl.toString()}`); + const daAdminResp = await env.daadmin.fetch(adminUrl, { headers }); + console.log(`<- ${adminUrl.toString()}. ${daAdminResp.status} ${daAdminResp.statusText}`, { status: daAdminResp.status, statusText: daAdminResp.statusText }); + sourceResp = daAdminResp; + } - // 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 }); + 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,6 +240,15 @@ export async function daSourceHead({ env, daCtx }) { headers.set('Authorization', authToken); const adminPath = ext !== 'html' ? path : `${path}.${ext}`; + const hlx6 = await probeHlx6(org, site); + if (hlx6) { + const sourceUrl = aemApiSourceUrl(org, site, adminPath); + console.log(`-> HEAD ${sourceUrl}`); + const response = await fetch(sourceUrl, { method: 'HEAD', headers }); + console.log(`<- HEAD ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + return response; + } + const adminUrl = new URL(`/source/${org}/${site}${adminPath}`, env.DA_ADMIN); console.log(`-> HEAD ${adminUrl.toString()}`); const response = await env.daadmin.fetch(adminUrl, { method: 'HEAD', headers }); @@ -205,14 +280,42 @@ export async function daSourcePost({ req, env, daCtx }) { minifyWhitespace(bodyNode); + const bodyContent = toHtml(bodyNode); + // daCtx.path contains explicit file extensions; append only inferred HTML. + const sourcePath = ext !== 'html' ? path : `${path}.${ext}`; + let hlx6 = await probeHlx6(org, site); + if (hlx6 === undefined) { + hlx6 = await resolveUncertainWriteBackend({ + org, + site, + sourcePath, + authToken, + env, + }); + } + if (hlx6) { + const sourceUrl = aemApiSourceUrl(org, site, sourcePath); + const headers = { + Authorization: authToken, + 'Content-Type': 'text/html', + }; + console.log(`-> ${sourceUrl}`); + const response = await fetch(sourceUrl, { + method: 'POST', + body: bodyContent, + headers, + }); + console.log(`<- ${sourceUrl}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); + return response; + } + // 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 = new URL( - `/source/${org}/${site}${path}.${ext}`, + `/source/${org}/${site}${sourcePath}`, env.DA_ADMIN, ); // eslint-disable-next-line no-param-reassign diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 2824b9c9..4bc787c6 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -49,6 +49,16 @@ describe('daSourceGet', () => { // record which composition / instrumentation calls happen and with what let calls; + let originalFetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('', { status: 200 }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); const mockDaSourceGet = async (overrides = {}) => { // 'headHtml' in overrides (rather than a destructured default) so passing diff --git a/test/routes/source-routing.test.js b/test/routes/source-routing.test.js new file mode 100644 index 00000000..e8320a3d --- /dev/null +++ b/test/routes/source-routing.test.js @@ -0,0 +1,637 @@ +/* + * 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 ORG = 'org'; +const SITE = 'site'; +const AUTH = 'Bearer test-token'; +const DA_ADMIN = 'https://admin.da.live'; +const HLX_ADMIN = 'https://admin.hlx.page'; +const AEM_API = 'https://api.aem.live'; +const PING_URL = `${HLX_ADMIN}/ping/${ORG}/${SITE}`; + +function callDetails(input, init = {}) { + const request = input instanceof Request ? input : null; + return { + input, + url: request?.url ?? String(input), + method: init.method ?? request?.method ?? 'GET', + headers: new Headers(init.headers ?? request?.headers), + body: init.body, + }; +} + +describe('source backend routing', () => { + let originalFetch; + let originalLog; + let originalWarn; + let fetchCalls; + let daAdminCalls; + let logs; + let warnings; + let fetchResponse; + let daAdminResponse; + let composedBodies; + let env; + + const authedRequest = (path, init = {}) => new Request( + `https://main--${SITE}--${ORG}.preview.da.live${path}`, + { + ...init, + headers: { + Authorization: AUTH, + ...init.headers, + }, + }, + ); + + const loadRoutes = async () => esmock('../../src/routes/da-admin.js', { + '../../src/utils/aemCtx.js': { + getAemCtx: () => ({}), + getAEMHtml: async () => '', + }, + '../../src/render/compose.js': { + composeHtml: async (daCtx, aemCtx, bodyHtml) => { + composedBodies.push(bodyHtml); + return { bodyHtml }; + }, + serializeHtml: ({ bodyHtml }) => `${bodyHtml}`, + }, + '../../src/helpers/source.js': { + default: async () => ({ data: '
edited
' }), + }, + '../../src/ue/attributes.js': { + removeUEAttributes: (node) => node, + unwrapParagraphs: (node) => node, + }, + '../../src/render/rewrite-images.js': { + restoreAbsoluteImages: () => {}, + }, + }); + + beforeEach(() => { + originalFetch = globalThis.fetch; + originalLog = console.log; + originalWarn = console.warn; + fetchCalls = []; + daAdminCalls = []; + logs = []; + warnings = []; + composedBodies = []; + fetchResponse = async () => new Response('', { status: 404 }); + daAdminResponse = async () => new Response('', { status: 404 }); + + globalThis.fetch = async (input, init) => { + const details = callDetails(input, init); + fetchCalls.push(details); + return fetchResponse(details); + }; + console.log = (...args) => logs.push(args); + console.warn = (...args) => warnings.push(args); + env = { + DA_ADMIN, + daadmin: { + fetch: async (input, init) => { + const details = callDetails(input, init); + daAdminCalls.push(details); + return daAdminResponse(details); + }, + }, + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + console.log = originalLog; + console.warn = originalWarn; + }); + + function upgradeResponse(upgraded = true) { + return new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + } + + it('GETs a source-bus asset from api.aem.live', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/image.png`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) return new Response('source image', { status: 200 }); + return new Response('', { status: 500 }); + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(await response.text(), 'source image'); + assert.strictEqual(daAdminCalls.length, 0); + assert.strictEqual(fetchCalls[0].url, PING_URL); + assert.strictEqual(fetchCalls[0].headers.get('Authorization'), null); + assert.strictEqual(fetchCalls[1].url, sourceUrl); + assert.strictEqual(fetchCalls[1].headers.get('Authorization'), AUTH); + assert.deepStrictEqual(logs.map(([message]) => message), [ + `-> ${sourceUrl}`, + `<- ${sourceUrl}. 200 `, + ]); + }); + + it('composes source-bus HTML through the existing preview pipeline', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) { + return new Response('
source page
', { status: 200 }); + } + return new Response('', { status: 500 }); + }; + const req = authedRequest('/page'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(composedBodies, ['
source page
']); + assert.strictEqual(await response.text(), '
source page
'); + assert.strictEqual(daAdminCalls.length, 0); + assert.deepStrictEqual(logs.map(([message]) => message), [ + `-> ${sourceUrl}`, + `<- ${sourceUrl}. 200 `, + ]); + }); + + [401, 403, 500, 503].forEach((status) => { + it(`returns source-bus HTML status ${status} without composing a page`, async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) { + return new Response('source failed', { + status, + headers: { 'X-Error': 'source failed' }, + }); + } + return new Response('', { status: 500 }); + }; + const req = authedRequest('/page'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(response.status, status); + assert.strictEqual(response.headers.get('X-Error'), 'source failed'); + assert.strictEqual(await response.text(), 'source failed'); + assert.deepStrictEqual(composedBodies, []); + }); + }); + + it('does not fall back to legacy content when a source-bus resource is missing', async () => { + fetchResponse = async ({ url }) => ( + url === PING_URL ? upgradeResponse() : new Response('', { status: 404 }) + ); + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/missing.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(response.status, 404); + assert.strictEqual(daAdminCalls.length, 0); + }); + + it('GETs legacy content when ping has no upgrade header', async () => { + fetchResponse = async ({ url }) => ( + url === PING_URL ? upgradeResponse(false) : new Response('', { status: 500 }) + ); + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await response.text(), 'legacy image'); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(daAdminCalls.length, 1); + assert.strictEqual( + daAdminCalls[0].url, + `${DA_ADMIN}/source/${ORG}/${SITE}/image.png`, + ); + assert.strictEqual(daAdminCalls[0].headers.get('Authorization'), AUTH); + }); + + [401, 403, 404, 500].forEach((status) => { + it(`falls back to legacy GET when ping returns ${status}`, async () => { + fetchResponse = async () => new Response('', { status }); + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await response.text(), 'legacy image'); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(daAdminCalls.length, 1); + }); + }); + + [ + new Error('Network connection lost.'), + new DOMException('timed out', 'TimeoutError'), + ].forEach((error) => { + it(`falls back to legacy GET when ping fails with ${error.name}`, async () => { + fetchResponse = async () => { + throw error; + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const response = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await response.text(), 'legacy image'); + assert.strictEqual(daAdminCalls.length, 1); + }); + }); + + it('HEADs a source-bus resource through api.aem.live', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/image.png`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) { + return new Response(null, { + status: 200, + headers: { 'Content-Type': 'image/png', 'Content-Length': '42' }, + }); + } + return new Response('', { status: 500 }); + }; + const req = authedRequest('/image.png', { method: 'HEAD' }); + const daCtx = getDaCtx(req); + const { daSourceHead } = await loadRoutes(); + + const response = await daSourceHead({ env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get('Content-Type'), 'image/png'); + assert.strictEqual(response.headers.get('Content-Length'), '42'); + assert.deepStrictEqual(fetchCalls.map(({ method }) => method), ['GET', 'HEAD']); + assert.strictEqual(daAdminCalls.length, 0); + assert.deepStrictEqual(logs.map(([message]) => message), [ + `-> HEAD ${sourceUrl}`, + `<- HEAD ${sourceUrl}. 200 `, + ]); + }); + + it('POSTs raw HTML to api.aem.live and does not write to da-admin', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(); + if (url === sourceUrl) return new Response('', { status: 201 }); + return new Response('', { status: 500 }); + }; + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 201); + const post = fetchCalls.find(({ method }) => method === 'POST'); + assert.ok(post); + assert.strictEqual(post.url, sourceUrl); + assert.strictEqual(post.headers.get('Authorization'), AUTH); + assert.strictEqual(post.headers.get('Content-Type'), 'text/html'); + assert.strictEqual(post.body, '
edited
'); + assert.strictEqual(daAdminCalls.length, 0); + assert.strictEqual(fetchCalls.filter(({ method }) => method === 'HEAD').length, 0); + assert.deepStrictEqual(logs.map(([message]) => message), [ + `-> ${sourceUrl}`, + `<- ${sourceUrl}. 201 `, + ]); + }); + + it('POSTs FormData to da-admin and does not write to api.aem.live for legacy sites', async () => { + fetchResponse = async () => upgradeResponse(false); + daAdminResponse = async () => new Response('', { status: 200 }); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(fetchCalls.length, 1); + assert.strictEqual(daAdminCalls.length, 1); + assert.strictEqual(daAdminCalls[0].method, 'POST'); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'HEAD').length, 0); + const body = await daAdminCalls[0].input.clone().formData(); + assert.strictEqual( + await body.get('data').text(), + '
edited
', + ); + }); + + [ + { + title: 'uses api.aem.live when only source bus has the document', + aemHead: 200, + daHead: 404, + backend: 'aem', + }, + { + title: 'uses da-admin when only legacy DA has the document', + aemHead: 404, + daHead: 200, + backend: 'da', + }, + { + title: 'uses da-admin for a new document missing from both backends', + aemHead: 404, + daHead: 404, + backend: 'da', + }, + { + title: 'uses api.aem.live when both backends have the document', + aemHead: 200, + daHead: 200, + backend: 'aem', + }, + ].forEach(({ + title, aemHead, daHead, backend, + }) => { + it(title, async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return new Response('', { status: 500 }); + if (url === sourceUrl && method === 'HEAD') { + return new Response(null, { status: aemHead }); + } + if (url === sourceUrl && method === 'POST') { + return new Response('', { status: 201 }); + } + return new Response('', { status: 500 }); + }; + daAdminResponse = async ({ method }) => ( + new Response(null, { status: method === 'HEAD' ? daHead : 200 }) + ); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, backend === 'aem' ? 201 : 200); + assert.strictEqual(fetchCalls.filter(({ method }) => method === 'HEAD').length, 1); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'HEAD').length, 1); + assert.strictEqual( + fetchCalls.filter(({ method }) => method === 'POST').length, + backend === 'aem' ? 1 : 0, + ); + assert.strictEqual( + daAdminCalls.filter(({ method }) => method === 'POST').length, + backend === 'da' ? 1 : 0, + ); + assert.strictEqual(warnings.length, 0); + }); + }); + + it('uses da-admin when a backend HEAD fails', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return new Response('', { status: 500 }); + if (url === sourceUrl && method === 'HEAD') { + throw new Error('Network connection lost.'); + } + return new Response('', { status: 500 }); + }; + daAdminResponse = async ({ method }) => ( + new Response(null, { status: method === 'HEAD' ? 200 : 200 }) + ); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(fetchCalls.filter(({ method }) => method === 'POST').length, 0); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'HEAD').length, 1); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'POST').length, 1); + }); + + it('preserves explicit extensions when an uncertain write uses da-admin', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/sheet.json`; + const adminUrl = `${DA_ADMIN}/source/${ORG}/${SITE}/sheet.json`; + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return new Response('', { status: 500 }); + if (url === sourceUrl && method === 'HEAD') { + return new Response(null, { status: 404 }); + } + return new Response('', { status: 500 }); + }; + daAdminResponse = async ({ method }) => ( + new Response(null, { status: method === 'HEAD' ? 200 : 200 }) + ); + const req = authedRequest('/sheet.json', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual( + daAdminCalls.find(({ method }) => method === 'HEAD').url, + adminUrl, + ); + assert.strictEqual( + daAdminCalls.find(({ method }) => method === 'POST').url, + adminUrl, + ); + }); + + it('uses api.aem.live when its HEAD succeeds and da-admin HEAD fails', async () => { + const sourceUrl = `${AEM_API}/${ORG}/sites/${SITE}/source/page.html`; + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return new Response('', { status: 500 }); + if (url === sourceUrl && method === 'HEAD') { + return new Response(null, { status: 200 }); + } + if (url === sourceUrl && method === 'POST') { + return new Response('', { status: 201 }); + } + return new Response('', { status: 500 }); + }; + daAdminResponse = async ({ method }) => { + if (method === 'HEAD') throw new Error('Network connection lost.'); + return new Response('', { status: 200 }); + }; + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 201); + assert.strictEqual(fetchCalls.filter(({ method }) => method === 'POST').length, 1); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'HEAD').length, 1); + assert.strictEqual(daAdminCalls.filter(({ method }) => method === 'POST').length, 0); + }); + + [401, 403, 404, 500].forEach((status) => { + it(`POSTs to da-admin when ping returns ${status}`, async () => { + fetchResponse = async () => new Response('', { status }); + daAdminResponse = async () => new Response('', { status: 200 }); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(fetchCalls.length, 2); + assert.strictEqual(fetchCalls[0].method, 'GET'); + assert.strictEqual(fetchCalls[1].method, 'HEAD'); + assert.strictEqual(daAdminCalls.length, 2); + assert.strictEqual(daAdminCalls[0].method, 'HEAD'); + assert.strictEqual(daAdminCalls[1].method, 'POST'); + }); + }); + + [ + new Error('Network connection lost.'), + new DOMException('timed out', 'TimeoutError'), + ].forEach((error) => { + it(`POSTs to da-admin when ping fails with ${error.name}`, async () => { + fetchResponse = async () => { + throw error; + }; + daAdminResponse = async () => new Response('', { status: 200 }); + const req = authedRequest('/page', { method: 'POST' }); + const daCtx = getDaCtx(req); + const { daSourcePost } = await loadRoutes(); + + const response = await daSourcePost({ req, env, daCtx }); + + assert.strictEqual(response.status, 200); + assert.strictEqual(fetchCalls.length, 2); + assert.strictEqual(fetchCalls[0].method, 'GET'); + assert.strictEqual(fetchCalls[1].method, 'HEAD'); + assert.strictEqual(daAdminCalls.length, 2); + assert.strictEqual(daAdminCalls[0].method, 'HEAD'); + assert.strictEqual(daAdminCalls[1].method, 'POST'); + }); + }); + + it('falls back after a probe error and probes again on the next request', async () => { + let firstProbe = true; + fetchResponse = async ({ url }) => { + if (url === PING_URL && firstProbe) { + firstProbe = false; + throw new Error('internal error; reference = test'); + } + if (url === PING_URL) return upgradeResponse(); + return new Response('source image', { status: 200 }); + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const first = await daSourceGet({ req, env, daCtx }); + const second = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await first.text(), 'legacy image'); + assert.strictEqual(await second.text(), 'source image'); + assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); + }); + + it('probes before every request so routing changes apply immediately', async () => { + let upgraded = true; + fetchResponse = async ({ url }) => { + if (url === PING_URL) return upgradeResponse(upgraded); + return new Response('source image', { status: 200 }); + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + const first = await daSourceGet({ req, env, daCtx }); + upgraded = false; + const second = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await first.text(), 'source image'); + assert.strictEqual(await second.text(), 'legacy image'); + assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); + assert.strictEqual(daAdminCalls.length, 1); + }); + + it('does not use a stale routing decision when a later ping fails', async () => { + let pingStatus = 200; + fetchResponse = async ({ url }) => { + if (url === PING_URL) { + return pingStatus === 200 + ? upgradeResponse() + : new Response('', { status: pingStatus }); + } + return new Response('source image', { status: 200 }); + }; + daAdminResponse = async () => new Response('legacy image', { status: 200 }); + const req = authedRequest('/image.png'); + const daCtx = getDaCtx(req); + const { daSourceGet } = await loadRoutes(); + + const first = await daSourceGet({ req, env, daCtx }); + pingStatus = 500; + const second = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(await first.text(), 'source image'); + assert.strictEqual(await second.text(), 'legacy image'); + assert.strictEqual(fetchCalls.filter(({ url }) => url === PING_URL).length, 2); + assert.strictEqual(daAdminCalls.length, 1); + }); + + it('probes independently for GET, HEAD, and POST', async () => { + fetchResponse = async ({ url, method }) => { + if (url === PING_URL) return upgradeResponse(); + if (method === 'HEAD') return new Response(null, { status: 200 }); + if (method === 'POST') return new Response('', { status: 201 }); + return new Response('source image', { status: 200 }); + }; + const { daSourceGet, daSourceHead, daSourcePost } = await loadRoutes(); + const getReq = authedRequest('/image.png'); + const headReq = authedRequest('/image.png', { method: 'HEAD' }); + const postReq = authedRequest('/page', { method: 'POST' }); + + await daSourceGet({ req: getReq, env, daCtx: getDaCtx(getReq) }); + await daSourceHead({ env, daCtx: getDaCtx(headReq) }); + await daSourcePost({ req: postReq, env, daCtx: getDaCtx(postReq) }); + + const pingCalls = fetchCalls.filter(({ url }) => url === PING_URL); + assert.strictEqual(pingCalls.length, 3); + assert.ok(pingCalls.every(({ method }) => method === 'GET')); + assert.strictEqual(daAdminCalls.length, 0); + }); +});