From c31263deababbf25c8c484acbee4695225756a48 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 11 Aug 2026 09:47:12 +0200 Subject: [PATCH 01/77] test: a missing site is the 404, and an upstream that cannot answer is the 503 red. head.html still decides whether a site exists, so a site not yet previewed on the requested ref is refused at 404 with its document already read and discarded. an upstream that cannot be reached still reports the failure in a return value, and a preview host that throws still reaches the browser as a 500 with no body. the full suite aborts on load, because src/storage/site.js does not exist yet. --- test/index.test.js | 2 +- test/routes/da-admin.test.js | 79 ++-- test/routes/source-read.test.js | 620 +++++++++++++++++++++++++++---- test/routes/source-write.test.js | 68 ++-- test/storage/config.test.js | 31 +- test/storage/site.test.js | 209 +++++++++++ test/utils/aemCtx.test.js | 33 +- 7 files changed, 896 insertions(+), 146 deletions(-) create mode 100644 test/storage/site.test.js diff --git a/test/index.test.js b/test/index.test.js index 0bdacba0..3ba9d9ba 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -189,7 +189,7 @@ describe('worker fetch handler', () => { // 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 }, + '../src/storage/site.js': { default: async () => ({ exists: true, onSourceBus: true }) }, })).default; const uePost = (origin) => { diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index aad41fc2..b0aa0de3 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -33,7 +33,7 @@ const recorder = () => { const env = { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', - HLX_ADMIN: 'https://admin.hlx.page', + HLX_CONFIG_SERVICE: 'https://config.aem.page', daadmin: { fetch: async (input) => { fetched.push(input instanceof Request ? input.url : input.href); @@ -44,23 +44,26 @@ 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 = []) => { +// stands in for config.aem.page, the only lookup the routes make +// answers that any site exists; `upgraded` lists the `org/site` keys on the source bus +const stubConfig = (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 }); + const [, site, org] = new URL(url).pathname.split('/')[1].split('--'); + const source = upgraded.includes(`${org}/${site}`) + ? `https://api.aem.live/${org}/sites/${site}/` + : `https://content.da.live/${org}/${site}/`; + const body = JSON.stringify({ content: { source: { url: source, type: 'markup' } } }); + return new Response(body, { status: 200 }); }; return asked; }; const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { - default: async () => false, + '../../src/storage/site.js': { + default: async () => ({ exists: true, onSourceBus: false }), }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), @@ -111,10 +114,11 @@ describe('daSourceGet', () => { // `{ headHtml: undefined }` actually simulates a missing head.html, instead // of being masked by the default parameter value. const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; + const site = overrides.site ?? { exists: true, onSourceBus: false }; calls = { compose: [], ue: 0, quickEdit: 0 }; return (await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { - default: async () => false, + '../../src/storage/site.js': { + default: async () => site, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), @@ -138,7 +142,8 @@ describe('daSourceGet', () => { buildQuickEditCookie: (p) => `da-quick-edit=${encodeURIComponent(p)}; Path=/`, }, '../../src/storage/config.js': { - getSiteConfig: async () => { throw new Error('no config'); }, + // da-admin answers a site with no config with a 404, which getSiteConfig reports as null + getSiteConfig: async () => null, }, })).daSourceGet; }; @@ -233,8 +238,8 @@ describe('daSourceGet', () => { assert.strictEqual(await res.text(), 'composed'); }); - it('returns a working 404 shell for quick-edit when head.html is missing', async () => { - const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + it('returns a working 404 shell for quick-edit when there is no such site', async () => { + const daSourceGet = await mockDaSourceGet({ site: { exists: false, onSourceBus: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); const daCtx = getDaCtx(req); @@ -245,11 +250,11 @@ describe('daSourceGet', () => { assert.strictEqual(calls.compose.length, 0); const html = await res.text(); assert.ok(html.includes('importmap')); - assert.ok(!html.includes('Unable to retrieve AEM branch')); + assert.ok(!html.includes('There is no site at this address')); }); - it('still returns branch-not-found for non-quick-edit when head.html is missing', async () => { - const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + it('returns not-found for non-quick-edit when there is no such site', async () => { + const daSourceGet = await mockDaSourceGet({ site: { exists: false, onSourceBus: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const daCtx = getDaCtx(req); @@ -259,15 +264,26 @@ describe('daSourceGet', () => { assert.strictEqual(calls.compose.length, 0); assert.strictEqual(calls.ue, 0); const html = await res.text(); - assert.ok(html.includes('Unable to retrieve AEM branch')); + assert.ok(html.includes('There is no site at this address')); + }); + + it('composes the page when the site has no head.html', async () => { + const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + const daCtx = getDaCtx(req); + + const res = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(calls.compose.length, 1); + assert.strictEqual(calls.ue, 1); }); }); 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 + // answers the unmocked lookup with a da-admin source, the legacy store these tests describe beforeEach(() => { - stubPing(); + stubConfig(); }); afterEach(() => { @@ -402,17 +418,16 @@ 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 + // answers the unmocked lookup with a da-admin source, the legacy store these tests describe beforeEach(() => { - stubPing(); + stubConfig(); }); afterEach(() => { delete globalThis.fetch; }); - describe('on a site /ping reports as enrolled', () => { + describe('on a site the lookup 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); @@ -420,7 +435,7 @@ describe('daSourcePost', () => { }; it('is refused with 405 and nothing is written', async () => { - stubPing(['org/refused']); + stubConfig(['org/refused']); const { env, fetched } = recorder(); const res = await write('refused', env); @@ -432,16 +447,16 @@ describe('daSourcePost', () => { // 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']); + it('looks the site up once per write', async () => { + const asked = stubConfig(['org/lookedupeach']); const { env } = recorder(); - await write('probedeach', env); - await write('probedeach', env); + await write('lookedupeach', env); + await write('lookedupeach', env); assert.deepStrictEqual(asked, [ - 'https://admin.hlx.page/ping/org/probedeach', - 'https://admin.hlx.page/ping/org/probedeach', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', ]); }); }); diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index d51cb27b..8f3f8bd0 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -14,9 +14,17 @@ import assert from 'assert'; import esmock from 'esmock'; import { getDaCtx } from '../../src/utils/daCtx.js'; +// a namespace import, so a body pinned to a constant this branch does not export yet fails +// its own assertion rather than taking the file down at load +import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); +// what the site lookup answers +const SOURCE_BUS = { exists: true, onSourceBus: true }; +const LEGACY_STORE = { exists: true, onSourceBus: false }; +const NO_SITE = { exists: false, onSourceBus: false }; + /** * 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 @@ -27,13 +35,19 @@ const build = async (overrides = {}) => { 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 + // `in overrides` rather than a destructured default on these, so an explicit undefined reads + // as a missing head.html, a template the preview host does not have, or a failed lookup 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 templateHtml = 'templateHtml' in overrides + ? overrides.templateHtml + : 'from the template'; + const site = 'site' in overrides ? overrides.site : LEGACY_STORE; + const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); + const { + headError, templateError, configError, composeError, config = null, + } = overrides; const seen = { - bus: [], legacy: [], ue: 0, lookups: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -52,33 +66,50 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { + '../../src/storage/site.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; + // throws for undefined, which is how the lookup reports a failure + if (site === undefined) throw lookupError; + return site; }, }, '../../src/utils/aemCtx.js': { - getAemCtx: () => ({}), - getAEMHtml: async () => headHtml, + getAemCtx: () => ({ previewUrl: 'https://main--site--org.aem.page' }), + // answers both preview host reads, and fails them separately so a test can reach the + // template read with head.html answering + getAEMHtml: async (aemCtx, path) => { + const isHead = path === '/head.html'; + if (isHead && headError) throw headError; + if (!isHead && templateError) throw templateError; + seen.aem.push(path); + return isHead ? headHtml : templateHtml; + }, }, '../../src/render/compose.js': { - composeHtml: async (daCtx, aemCtx, bodyHtml) => ({ bodyHtml }), + // returns a hast root, so quick-edit can walk what was built + composeHtml: async (daCtx, aemCtx, bodyHtml, head) => { + if (composeError) throw composeError; + seen.head.push(head); + return { type: 'root', children: [], 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'); }, + // da-admin answers a site with no config with a 404, which getSiteConfig reports as null + getSiteConfig: async () => { + if (configError) throw configError; + return config; + }, }, }); return { ...mod, env, seen }; }; -describe('when /ping cannot say which store holds the site', () => { +describe('when the lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); @@ -86,7 +117,7 @@ describe('when /ping cannot say which store holds the site', () => { // 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 { daSourceGet, env, seen } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -96,7 +127,7 @@ describe('when /ping cannot say which store holds the site', () => { }); it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ onSourceBus: undefined }); + const { daSourceGet, env } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -104,8 +135,21 @@ describe('when /ping cannot say which store holds the site', () => { assert.ok(Number(res.headers.get('Retry-After')) > 0); }); + // the preview iframe renders this body, and the store answered nothing here: it was never + // asked, since which store to ask is what could not be determined + it('says the store could not be determined, not that it did not answer', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + const body = await res.text(); + assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(body, messages.SOURCE_UNDETERMINED_HTML_MESSAGE); + }); + it('refuses a non-html read too', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: undefined }); + const { daSourceGet, env, seen } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -115,7 +159,7 @@ describe('when /ping cannot say which store holds the site', () => { }); it('refuses a HEAD with 503 and no body', async () => { - const { daSourceHead, env, seen } = await build({ onSourceBus: undefined }); + const { daSourceHead, env, seen } = await build({ site: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -125,61 +169,60 @@ describe('when /ping cannot say which store holds the site', () => { 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 }); + // both 503s share a status and an unparsed body, so only the header separates them + it('names the failed lookup in x-error', async () => { + const { daSourceGet, env } = await build({ site: 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/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('names the failed probe on a HEAD too', async () => { - const { daSourceHead, env } = await build({ onSourceBus: undefined }); + it('names the failed lookup on a HEAD too', async () => { + const { daSourceHead, env } = await build({ site: 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/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); // 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 () => { + it('names the lookup cause, not a category', async () => { const { daSourceGet, env } = await build({ - onSourceBus: undefined, - probeError: new DOMException('timed out', 'TimeoutError'), + site: undefined, + lookupError: 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'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup 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 { daSourceGet, env } = await build({ site: undefined, lookupError: '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'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: Error: boom'); }); - it('tells a probe failure apart from a store failure', async () => { + it('tells a lookup failure apart from a store failure', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: fetch failed'); }); }); @@ -190,7 +233,7 @@ describe('reading from the store that holds the site', () => { 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 { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -201,7 +244,7 @@ describe('reading from the store that holds the site', () => { }); it('composes the source-bus document, not da-admin\'s copy of it', async () => { - const { daSourceGet, env } = await build({ onSourceBus: true }); + const { daSourceGet, env } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -210,7 +253,7 @@ describe('reading from the store that holds the site', () => { }); it('forwards the author token to the source bus', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -234,7 +277,7 @@ describe('reading from the store that holds the site', () => { 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 { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -247,7 +290,7 @@ describe('reading from the store that holds the site', () => { 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, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -259,7 +302,7 @@ describe('reading from the store that holds the site', () => { it('answers 503 rather than throwing on a non-html read', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -269,7 +312,7 @@ describe('reading from the store that holds the site', () => { it('answers 503 rather than throwing on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -288,7 +331,7 @@ describe('reading from the store that holds the site', () => { it('asks the caller to retry', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -302,7 +345,7 @@ describe('reading from the store that holds the site', () => { // 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, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const html = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -311,13 +354,13 @@ describe('reading from the store that holds the site', () => { 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/); + assert.strictEqual(await htmlRes.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await assetRes.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); }); it('answers 503 with no body on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -332,38 +375,38 @@ describe('reading from the store that holds the site', () => { // 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, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); it('names the cause on a non-html read', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); it('names the cause on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TimeoutError: The operation timed out'); }); it('names the cause when da-admin is unreachable', async () => { @@ -374,21 +417,21 @@ describe('reading from the store that holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: 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, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: lost at fetch'); }); }); @@ -398,7 +441,7 @@ describe('reading from the store that holds the site', () => { [401, 403, 429, 500, 502].forEach((status) => { it(`keeps the store's ${status} as the status`, async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('upstream said no', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -413,7 +456,7 @@ describe('reading from the store that holds the site', () => { [429, 500, 502].forEach((status) => { it(`passes the store's body through on a ${status}`, async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('upstream said no', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -424,12 +467,13 @@ describe('reading from the store that holds the site', () => { }); }); - // /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 + // the lookup does not send the author token, so the store is the only place a 401 can come + // from. the authorbus extension matches this sentinel 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, + site: SOURCE_BUS, bus: () => new Response('', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -443,7 +487,7 @@ describe('reading from the store that holds the site', () => { it('does not ask the caller to retry a 401, since retrying cannot help', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -455,7 +499,7 @@ describe('reading from the store that holds the site', () => { it('passes a store 401 through bare on a non-html read, which renders nothing', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -468,7 +512,7 @@ describe('reading from the store that holds the site', () => { it('answers a store 401 on a HEAD with no body', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -479,11 +523,11 @@ describe('reading from the store that holds the site', () => { 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 + // hands the author the starter template over whatever da-admin still has, with no second + // store to retry against it('composes the starter template on a 404 without asking the other store', async () => { const { daSourceGet, env, seen } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -498,7 +542,7 @@ describe('reading from the store that holds the site', () => { describe('UE instrumentation', () => { it('is applied on a UE host', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -507,7 +551,7 @@ describe('reading from the store that holds the site', () => { }); it('is not applied on a preview host', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.preview.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -534,7 +578,7 @@ describe('reading from the store that holds the site', () => { 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/storage/site.js': { default: async () => ({ exists: true, onSourceBus: true }) }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), getAEMHtml: async () => '', @@ -552,7 +596,7 @@ describe('reading from the store that holds the site', () => { describe('a non-html read', () => { it('goes to the source bus fully normalized', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/Media/Holiday.PNG'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -572,7 +616,7 @@ describe('reading from the store that holds the site', () => { describe('a HEAD', () => { it('goes to the source bus on a source-bus site', async () => { - const { daSourceHead, env, seen } = await build({ onSourceBus: true }); + const { daSourceHead, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -594,7 +638,7 @@ describe('reading from the store that holds the site', () => { it('passes a 404 from the store through, since HEAD composes nothing', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -692,7 +736,7 @@ describe('reading from the store that holds the site', () => { // 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, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); @@ -703,7 +747,7 @@ describe('reading from the store that holds the site', () => { }); it('reads a video from the source bus on a source-bus site', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -712,19 +756,435 @@ describe('reading from the store that holds the site', () => { }); }); - 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 () => { + describe('the order of the three things that can fail', () => { + // answers the settled 404 ahead of the retryable 503 + it('reports no such site even when the store did not answer', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, - headHtml: undefined, + site: NO_SITE, bus: () => { throw new TypeError('fetch failed'); }, + legacy: () => { 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); + }); + + // names which failure it was: a site that does not exist has no preview host either + it('reports no such site even when the preview host did not answer', async () => { + const { daSourceGet, env } = await build({ + site: NO_SITE, + headError: 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.status, 404); + }); + + it('reports an unreachable store on a site with no head.html', async () => { + const { daSourceGet, env } = await build({ + headHtml: undefined, + legacy: () => { 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); + }); + }); + + // #258: head.html does not say whether a site exists, so a missing head.html is not a 404 + describe('when the site serves no head.html', () => { + it('reads the document anyway', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: 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, 200); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(await res.text(), 'from da-admin'); + }); + + // serves the page without the project's css and js rather than not at all + it('composes with no project head entries', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.head.length, 1); + assert.strictEqual(seen.head[0] ?? '', ''); + }); + + it('composes the starter template when the document is missing too', async () => { + const { daSourceGet, env } = await build({ + headHtml: undefined, + legacy: () => 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.match(await res.text(), /
/); + }); + + it('instruments UE without a head', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: 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, 200); + assert.strictEqual(seen.ue, 1); + }); + + // sets no cookie: the cookie names the entry script, and no head.html means no entry script + it('serves quick-edit the page without an entry-script cookie', async () => { + const { daSourceGet, env } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get('Set-Cookie'), null); + }); + + it('is not answered 404 by a missing head.html alone', async () => { + const { daSourceGet, env } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.notStrictEqual(res.status, 404); + }); + }); + + describe('when there is no such site', () => { + it('refuses an html read with 404 and touches neither store', async () => { + const { daSourceGet, env, seen } = await build({ site: NO_SITE }); + 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); + assert.match(await res.text(), /Site not found/); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // nothing renders an image, so a body would only corrupt it + it('refuses a non-html read with 404 and no body', async () => { + const { daSourceGet, env, seen } = await build({ site: NO_SITE }); + 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, 404); + assert.strictEqual(await res.text(), ''); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + it('refuses a HEAD with 404 and no body', async () => { + const { daSourceHead, env, seen } = await build({ site: NO_SITE }); + 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); + assert.strictEqual(await res.text(), ''); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // needs a shell with the import map, since the editor loads into this page + it('gives quick-edit its shell', async () => { + const { daSourceGet, env } = await build({ site: NO_SITE }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.match(await res.text(), /importmap/); + }); + }); + + // a throw out of getAEMHtml used to reach the worker's catch as a 500 with no body + describe('when the preview host cannot be reached', () => { + const dead = () => new TypeError('Network connection lost'); + + it('answers 503 rather than throwing', async () => { + const { daSourceGet, env } = await build({ headError: dead() }); + 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('names the cause in x-error', async () => { + const { daSourceGet, env } = await build({ headError: dead() }); + 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'), 'preview host failed: TypeError: Network connection lost'); + }); + + it('asks the caller to retry', async () => { + const { daSourceGet, env } = await build({ + headError: new DOMException('The operation 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.ok(Number(res.headers.get('Retry-After')) > 0); }); + + // says what happened, since the preview iframe renders the refusal + it('says the preview host did not answer, not the store', async () => { + const { daSourceGet, env } = await build({ headError: dead() }); + 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(), messages.PREVIEW_UNREACHABLE_HTML_MESSAGE); + }); + + // a 404 would say the site is gone, which is #258 again, this time on the preview host + it('does not answer 404', async () => { + const { daSourceGet, env } = await build({ headError: dead() }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.notStrictEqual(res.status, 404); + }); + }); +}); + +describe('when the site config cannot be reached', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + const missing = () => ({ + legacy: () => new Response('', { status: 404 }), + configError: new TypeError('fetch failed'), + }); + + // the config names the template built over a document that is not there, so a store that did + // not answer would hand the author the wrong blank page to save over + it('refuses with 503 rather than composing the starter template', async () => { + const { daSourceGet, env } = await build(missing()); + 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); + }); + + // da-admin serves the config as well as the document, so the store is what did not answer. + // x-error is what separates the two reads + it('says the store did not answer', async () => { + const { daSourceGet, env } = await build(missing()); + 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(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + }); + + it('names the site config in x-error', async () => { + const { daSourceGet, env } = await build(missing()); + 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'), 'site config failed: TypeError: fetch failed'); + }); + + it('asks the caller to retry', async () => { + const { daSourceGet, env } = await build(missing()); + 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); + }); + + // da-admin answers a site with no config with a 404, not a throw, and that is the ordinary case + it('composes the starter template when there is no config at all', async () => { + const { daSourceGet, env } = await build({ legacy: () => 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.match(await res.text(), /
/); + }); +}); + +// getAEMHtml is not stubbed here: a stub hides a preview host that answers, but with something +// other than head.html +describe('when the preview host refuses head.html', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + const readWithPreviewStatus = async (status) => { + globalThis.fetch = async () => new Response('preview said no', { status }); + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, + }; + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/site.js': { default: async () => LEGACY_STORE }, + }); + // a preview host rather than a UE host, so nothing is instrumented onto the composed page + const req = authedReq('https://main--site--org.preview.da.live/folder/content'); + return daSourceGet({ req, env, daCtx: getDaCtx(req) }); + }; + + // a 200 with no head.html is a page with no stylesheet, no scripts and no entry script + it('refuses a 500 with 503 rather than composing an empty project head', async () => { + const res = await readWithPreviewStatus(500); + + assert.strictEqual(res.status, 503); + }); + + it('names the preview host in x-error', async () => { + const res = await readWithPreviewStatus(500); + + assert.match(res.headers.get('x-error'), /preview host failed/); + }); + + // a host behind Helix auth refuses without a site token, and a retry answers the same + it('composes the page without a head on a 401', async () => { + const res = await readWithPreviewStatus(401); + + assert.strictEqual(res.status, 200); + }); + + // a ref that was never previewed has no head.html, which is not a failure + it('composes the page without a head on a 404', async () => { + const res = await readWithPreviewStatus(404); + + assert.strictEqual(res.status, 200); + }); +}); + +describe('a read that carries no token', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // the authorbus extension recovers off the da:401 meta rather than off the status + it('is refused with the da:401 shell, and nothing is looked up', async () => { + const { daSourceGet, env, seen } = await build(); + const req = new Request('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(await res.text(), messages.UNAUTHORIZED_HTML_MESSAGE); + assert.strictEqual(seen.lookups, 0); + }); +}); + +describe('a path the site config gives a template', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // the template is composed over a document that is not there, so the store answers 404 + const missingDoc = (rows) => ({ + legacy: () => new Response('', { status: 404 }), + config: rows.map((value) => ({ key: 'editor.ue.template', value })), + }); + + it('composes the configured template rather than the starter', async () => { + const { daSourceGet, env, seen } = await build(missingDoc(['/folder=/scripts/tpl.html'])); + 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.strictEqual(await res.text(), 'from the template'); + assert.ok(seen.aem.includes('/scripts/tpl.html')); + }); + + // unwrapped, this read escapes as the bodyless 500 that #258 is about + it('refuses with 503 when the template read cannot be reached', async () => { + const { daSourceGet, env } = await build({ + ...missingDoc(['/folder=/scripts/tpl.html']), + templateError: 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); + assert.match(res.headers.get('x-error'), /preview host failed/); + }); + + it('takes the longest matching prefix', async () => { + const { daSourceGet, env, seen } = await build(missingDoc([ + '/=/scripts/site.html', + '/folder=/scripts/folder.html', + ])); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(seen.aem.includes('/scripts/folder.html')); + assert.ok(!seen.aem.includes('/scripts/site.html')); + }); + + it('ignores a template configured for another path', async () => { + const { daSourceGet, env, seen } = await build(missingDoc(['/other=/scripts/other.html'])); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.match(await res.text(), /
/); + assert.strictEqual(seen.aem.length, 1); + }); + + // the config names a path the preview host answers 404 for, which leaves the starter + it('falls back to the starter when the preview host has no such template', async () => { + const { daSourceGet, env } = await build({ + ...missingDoc(['/folder=/scripts/gone.html']), + templateHtml: 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, 200); + assert.match(await res.text(), /
/); + }); +}); + +describe('when the worker itself has a bug', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // only an unreachable upstream is retryable, and a 503 would keep the throw out of the log + // the worker boundary writes + it('lets the throw through rather than rendering it as a 503', async () => { + const { daSourceGet, env } = await build({ composeError: new TypeError('tree is not iterable') }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await assert.rejects( + () => daSourceGet({ req, env, daCtx: getDaCtx(req) }), + /tree is not iterable/, + ); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index ec9ce54f..cd0b7297 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -19,6 +19,11 @@ import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../.. const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; +// what the site lookup answers +const SOURCE_BUS = { exists: true, onSourceBus: true }; +const LEGACY_STORE = { exists: true, onSourceBus: false }; +const NO_SITE = { exists: false, onSourceBus: false }; + /** The shape the Universal Editor Service posts: a `data` blob in a multipart form. */ const uePost = (url, html = DOC) => { const body = new FormData(); @@ -28,10 +33,9 @@ const uePost = (url, html = DOC) => { 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'); + // `in overrides` rather than a destructured default, so an explicit undefined reaches here + const site = 'site' in overrides ? overrides.site : LEGACY_STORE; + const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); const seen = { bus: [], legacy: [], lookups: 0, order: [], }; @@ -69,13 +73,13 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { + '../../src/storage/site.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; + // throws for undefined, which is how the lookup reports a failure + if (site === undefined) throw lookupError; + return site; }, }, }); @@ -98,7 +102,7 @@ describe('writing to the store that holds the site', () => { // 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 }); + const { res, seen } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.status, 405); assert.strictEqual(seen.bus.length, 0); @@ -106,13 +110,13 @@ describe('writing to the store that holds the site', () => { }); it('names the methods that are left', async () => { - const { res } = await post({ onSourceBus: true }); + const { res } = await post({ site: SOURCE_BUS }); 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 }); + const { res } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.headers.get('Retry-After'), null); }); @@ -120,7 +124,7 @@ describe('writing to the store that holds the site', () => { // 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 }); + const { res } = await post({ site: SOURCE_BUS }); assert.match(res.headers.get('Content-Type'), /^text\/plain/); assert.strictEqual(await res.text(), SOURCE_BUS_READ_ONLY_MESSAGE); @@ -129,9 +133,9 @@ describe('writing to the store that holds the site', () => { // 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', () => { + describe('when the lookup cannot say which store holds the site', () => { it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ onSourceBus: undefined }); + const { res, seen } = await post({ site: undefined }); assert.strictEqual(res.status, 503); assert.strictEqual(seen.bus.length, 0); @@ -139,30 +143,42 @@ describe('writing to the store that holds the site', () => { }); it('asks the caller to retry, unlike the source-bus refusal', async () => { - const { res } = await post({ onSourceBus: undefined }); + const { res } = await post({ site: 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 }); + const { res } = await post({ site: undefined }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); - it('names the failed probe in x-error', async () => { - const { res } = await post({ onSourceBus: undefined }); + it('names the failed lookup in x-error', async () => { + const { res } = await post({ site: undefined }); - assert.match(res.headers.get('x-error'), /ping/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('carries the probe cause, not a category', async () => { + it('names the lookup cause, not a category', async () => { const { res } = await post({ - onSourceBus: undefined, - probeError: new DOMException('timed out', 'TimeoutError'), + site: undefined, + lookupError: new DOMException('timed out', 'TimeoutError'), }); - assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + }); + }); + + // a 404 from the config service says there is no AEM site config, not that the DA org and site + // are bogus. a read of the same path answers 404, so the editor cannot reach this state + describe('a site the lookup says does not exist', () => { + it('is written to da-admin all the same', async () => { + const { res, seen } = await post({ site: NO_SITE }); + + assert.strictEqual(seen.bus.length, 0); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(res.status, 201); }); }); @@ -257,7 +273,7 @@ describe('writing to the store that holds the site', () => { }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { - const { seen } = await post({ onSourceBus: true }); + const { seen } = await post({ site: SOURCE_BUS }); assert.strictEqual(seen.lookups, 1); }); @@ -287,7 +303,7 @@ describe('writing to the store that holds the site', () => { const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); }); @@ -295,7 +311,7 @@ describe('writing to the store that holds the site', () => { // 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 { daSourcePost, env, seen } = await build({ site: SOURCE_BUS }); const req = uePost('https://main--site--org.ue.da.live/folder/data.json'); const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); diff --git a/test/storage/config.test.js b/test/storage/config.test.js index 843f6dca..a0336a1a 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -127,13 +127,36 @@ describe('Config Module', () => { assert.deepStrictEqual(result, multiSheet.data.data); }); - it('should return null when fetch fails', async () => { - mockFetch.nextResponse = { ok: false }; + it('should return null when there is no config', async () => { + mockFetch.nextResponse = { ok: false, status: 404 }; const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); assert.strictEqual(result, null); }); + + // da-admin answers 403 when the author may not read the site config, and a retry answers the + // same, so the starter template is used rather than refusing + [401, 403].forEach((status) => { + it(`should return null when the store answers ${status}`, async () => { + mockFetch.nextResponse = { ok: false, status }; + + assert.strictEqual(await configModule.getSiteConfig(mockEnv, mockDaCtx), null); + }); + }); + + // a store that could not answer is not the same as a site with no config, and reading it as + // one hands the author a blank page to save over a document that exists + [429, 500, 502].forEach((status) => { + it(`should throw when the store answers ${status}`, async () => { + mockFetch.nextResponse = { ok: false, status }; + + await assert.rejects( + () => configModule.getSiteConfig(mockEnv, mockDaCtx), + new RegExp(String(status)), + ); + }); + }); }); describe('getOrgConfig', () => { @@ -194,8 +217,8 @@ describe('Config Module', () => { assert.deepStrictEqual(result, multiSheet.data.data); }); - it('should return null when fetch fails', async () => { - mockFetch.nextResponse = { ok: false }; + it('should return null when there is no config', async () => { + mockFetch.nextResponse = { ok: false, status: 404 }; const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); diff --git a/test/storage/site.test.js b/test/storage/site.test.js new file mode 100644 index 00000000..db6e4807 --- /dev/null +++ b/test/storage/site.test.js @@ -0,0 +1,209 @@ +/* + * 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: getSite } = await import('../../src/storage/site.js'); + +const env = { + AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-token', +}; + +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); + }; +}; + +// has the two fields the lookup reads; the service also answers with admin roles and secrets +const config = (sourceUrl) => () => new Response( + JSON.stringify({ content: { source: { url: sourceUrl, type: 'markup' } } }), + { status: 200 }, +); +const legacy = config('https://content.da.live/org/site/'); +const sourceBus = config('https://api.aem.live/org/sites/site/'); +const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); + +describe('getSite', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks the config service for the admin-scoped site config', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=admin'); + }); + + it('takes the config service from env, so dev can point elsewhere', async () => { + stubFetch(legacy); + + await getSite({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); + + assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=admin'); + }); + + it('names the ref the request came in on', async () => { + stubFetch(legacy); + + await getSite(env, daCtx({ ref: 'feature' })); + + assert.match(calls[0].url, /\/feature--site--org\//); + }); + + it('sends the shared token', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.strictEqual(calls[0].init.headers['x-access-token'], 'shared-token'); + }); + + // the edge answers 400 without it, and that failure reads like a bad path + it('sends the backend type', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.strictEqual(calls[0].init.headers['x-backend-type'], 'aws'); + }); + + // the author's token has no business at a service-to-service endpoint + it('sends no author token', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.strictEqual(calls[0].init.headers.Authorization, undefined); + }); + + it('gives up rather than hanging', async () => { + stubFetch(legacy); + + await getSite(env, daCtx()); + + assert.ok(calls[0].init.signal); + }); + }); + + describe('which store holds the site', () => { + it('reads the source bus off the content source url', async () => { + stubFetch(sourceBus); + + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, onSourceBus: true }); + }); + + it('reads a da-admin site as legacy', async () => { + stubFetch(legacy); + + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, onSourceBus: false }); + }); + + it('tolerates a trailing slash on the configured source bus', async () => { + stubFetch(sourceBus); + + const site = await getSite({ ...env, AEM_API: 'https://api.aem.live/' }, daCtx()); + + assert.strictEqual(site.onSourceBus, true); + }); + + // a prefix match on the bare string would take api.aem.live.evil.example for the source bus + it('does not take a lookalike host for the source bus', async () => { + stubFetch(config('https://api.aem.live.evil.example/org/sites/site/')); + + const site = await getSite(env, daCtx()); + + assert.strictEqual(site.onSourceBus, false); + }); + + it('reads a config with no content source as legacy', async () => { + stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); + + const site = await getSite(env, daCtx()); + + assert.strictEqual(site.onSourceBus, false); + }); + }); + + describe('when there is no such site', () => { + it('says so on a 404', async () => { + stubFetch(absent); + + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: false, onSourceBus: false }); + }); + + // an unparseable hostname leaves org and site undefined, so there is nothing to ask about + it('says so without asking when there is no org or site', async () => { + stubFetch(absent); + + const site = await getSite(env, daCtx({ site: undefined })); + + assert.strictEqual(site.exists, false); + assert.strictEqual(calls.length, 0); + }); + }); + + // a refusal leaves both answers unknown, and a guess reads the wrong store at 200 + describe('when the lookup cannot answer', () => { + [401, 403, 429, 500, 502].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects(() => getSite(env, daCtx()), /502|500|429|403|401/); + }); + }); + + it('throws when the config service cannot be reached', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(() => getSite(env, daCtx()), /fetch failed/); + }); + + it('names the status it got', async () => { + stubFetch(() => new Response('', { status: 401 })); + + await assert.rejects(() => getSite(env, daCtx()), /401/); + }); + + // reads a 200 with an error page as a store it does not know, not as a legacy site + it('throws when the body is not JSON', async () => { + stubFetch(() => new Response('the edge said no', { status: 200 })); + + await assert.rejects(() => getSite(env, daCtx())); + }); + + // a misconfigured worker gets no answer, and calling that a missing site would 404 the pages + it('throws when the config service host is missing, without asking', async () => { + stubFetch(legacy); + + await assert.rejects(() => getSite({ ...env, HLX_CONFIG_SERVICE: undefined }, daCtx())); + assert.strictEqual(calls.length, 0); + }); + }); +}); diff --git a/test/utils/aemCtx.test.js b/test/utils/aemCtx.test.js index 8b4432d2..2f17923b 100644 --- a/test/utils/aemCtx.test.js +++ b/test/utils/aemCtx.test.js @@ -70,11 +70,14 @@ describe('AEM context', () => { const mockAemCtx = { previewUrl: 'https://main--site--org.aem.page', }; + let status; beforeEach(async () => { + status = 200; // Mock global fetch - global.fetch = async (url) => ({ - ok: url.includes('success'), + global.fetch = async () => ({ + ok: status === 200, + status, text: async () => 'test content', }); }); @@ -88,10 +91,34 @@ describe('AEM context', () => { assert.strictEqual(html, 'test content'); }); - it('should return undefined for failed request', async () => { + // a ref that was never previewed has no head.html, and the page is composed without it + it('should return undefined for a 404', async () => { + status = 404; const html = await getAEMHtml(mockAemCtx, '/fail-path'); assert.strictEqual(html, undefined); }); + + // a preview host behind Helix authentication refuses without a site token, and a retry + // answers the same, so the page is built without the project head + [401, 403].forEach((code) => { + it(`should return undefined for a ${code}`, async () => { + status = code; + const html = await getAEMHtml(mockAemCtx, '/fail-path'); + assert.strictEqual(html, undefined); + }); + }); + + // a preview host that could not answer is not a ref that was never previewed, and reading it + // as one serves the page at 200 with no stylesheet, no scripts and no entry script + [429, 500, 502].forEach((code) => { + it(`should throw on a ${code}`, async () => { + status = code; + await assert.rejects( + () => getAEMHtml(mockAemCtx, '/fail-path'), + new RegExp(String(code)), + ); + }); + }); }); describe('fixUrlsWhenLocalDev', () => { From 4686062659ad816b62675cacab503544101962d7 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 11 Aug 2026 13:22:38 +0200 Subject: [PATCH 02/77] fix: ask the config service what exists, and stop guessing what silence means head.html decided whether a site exists, so a site not yet previewed on the requested ref was refused at 404 with its document already read and discarded. config.aem.page answers both questions in one read: whether there is such a site, and which store holds its content. content.source.url is the field helix-admin sets x-api-upgrade-available from, so it is the boolean /ping conveyed. the /ping probe and HLX_ADMIN are gone. three upstreams reported a failure by returning a value, and two swallowed one. reach() names the upstream and rethrows a typed UpstreamError, and each route entry point catches once and builds the 503 its method takes. x-error names the upstream every time, not only on the site lookup. a 401 or 403 cannot be retried, so it is not a 503. neither is a 404. the preview host and the config store fall back on all three and log the refusal, and only a status that means no answer becomes an UpstreamError. a throw that is not an UpstreamError reaches the worker boundary, which logs it and answers 500. Relates to #258 --- src/handlers/get.js | 10 +- src/routes/da-admin.js | 173 ++++++++++++++++------------ src/storage/config.js | 6 +- src/storage/site.js | 53 +++++++++ src/storage/source-bus.js | 35 ------ src/utils/aemCtx.js | 11 +- src/utils/constants.js | 6 +- src/utils/quick-edit.js | 8 +- src/utils/upstream.js | 62 ++++++++++ test/storage/source-bus.test.js | 194 -------------------------------- 10 files changed, 248 insertions(+), 310 deletions(-) create mode 100644 src/storage/site.js delete mode 100644 src/storage/source-bus.js create mode 100644 src/utils/upstream.js delete mode 100644 test/storage/source-bus.test.js diff --git a/src/handlers/get.js b/src/handlers/get.js index 591cada5..85e8f02f 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -35,8 +35,14 @@ 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; + // logs a rejection rather than rethrowing it, since the other read may still answer + const settled = (result, read) => { + if (result.status === 'fulfilled') return result.value; + console.error(`${read} threw on ${path}`, result.reason); + return undefined; + }; + const storeRes = settled(daSourceGetRes, 'the store read'); + const aemRes = settled(aemProxyRes, 'the aem proxy'); let response; if (storeRes?.status === 200) { diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index d2484b6f..a7c3be68 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -22,39 +22,57 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head503, post405, post503, + daResp, get401, get404, get415, get503, head401, head404, head503, post405, post503, } from '../responses/index.js'; import { - BRANCH_NOT_FOUND_HTML_MESSAGE, DEFAULT_HTML_TEMPLATE, + PREVIEW_UNREACHABLE_HTML_MESSAGE, + SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, + SOURCE_UNDETERMINED_HTML_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 getSite from '../storage/site.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; +import { + CONTENT_STORE, + PREVIEW_HOST, + SITE_CONFIG, + SITE_LOOKUP, + UpstreamError, + reach, +} from '../utils/upstream.js'; const HTML_POST_TYPE = 'text/html'; +const HEAD_HTML_PATH = '/head.html'; /** - * Renders a failure for the `x-error` header. + * Overrides the store's body for the upstreams that need their own. SITE_CONFIG is read off + * da-admin, so it takes the default body and `x-error` is what tells the two reads apart. */ -function causeOf(e) { - return `${e?.name ?? 'Error'}: ${e?.message ?? e}` - .replace(/[^\x20-\x7e]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 1024); -} +const UNREACHABLE_HTML = { + [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, + [SITE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, +}; +const UNREACHABLE_TEXT = { [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; -function probeFailed(e, method, sourcePath) { - const cause = `/ping failed: ${causeOf(e)}`; - console.warn(`503 ${method} ${sourcePath}, ${cause}`); - return cause; +/** + * Only an upstream that could not be reached is retryable. Anything else reaches the worker + * boundary in src/index.js, which logs it and answers 500. + */ +function refuseUnreachable(e, method, sourcePath) { + if (!(e instanceof UpstreamError)) throw e; + console.warn(`503 ${method} ${sourcePath}, ${e.message}`); + if (method === 'HEAD') return head503(e.message); + if (method === 'POST') { + return post503(UNREACHABLE_TEXT[e.upstream] ?? SOURCE_UNREACHABLE_MESSAGE, e.message); + } + return get503(UNREACHABLE_HTML[e.upstream] ?? SOURCE_UNREACHABLE_HTML_MESSAGE, e.message); } export function isHtmlPostType(type) { @@ -73,12 +91,8 @@ function getTextBody(data) { } async function getPageTemplate(env, daCtx, aemCtx) { - let config; - try { - config = await getSiteConfig(env, daCtx); - } catch (e) { - return DEFAULT_HTML_TEMPLATE; - } + // answers null for a site with no config, so a store that refuses or is unreachable throws + const config = await reach(SITE_CONFIG, () => getSiteConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config @@ -95,7 +109,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { } const templatePath = matchingTemplates[0].template; - const templateHtml = await getAEMHtml(aemCtx, templatePath); + const templateHtml = await reach(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); if (templateHtml) { return templateHtml; } @@ -104,39 +118,27 @@ async function getPageTemplate(env, daCtx, aemCtx) { } /** - * Sends a request to a store and reports why it could not be reached at all. + * Reads the document from the store that holds the site. * - * @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. + * Sets `noSuchSite` when there is no such site, `response` otherwise. * - * @returns {Promise<{response?: Response, error?: string}>} `error` says why there is no response + * @returns {Promise<{response?: Response, noSuchSite?: boolean}>} + * @throws {UpstreamError} when the lookup or the store could not be reached */ 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 site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + + if (!site.exists) { + console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return { noSuchSite: true }; } - const store = getStore(env, daCtx, onSourceBus); + const store = getStore(env, daCtx, site.onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); - return reachStore(store, () => store.fetch(store.url, init)); + return { response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)) }; } -export async function daSourceGet({ req, env, daCtx }) { +async function sourceGet({ req, env, daCtx }) { const { ext, authToken } = daCtx; // check if Authorization header is present @@ -158,27 +160,35 @@ export async function daSourceGet({ req, env, daCtx }) { if (ext !== 'html') { // 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); + const { response, noSuchSite } = await readSource(env, daCtx, { method: 'GET', headers }); + if (noSuchSite) return get404(); console.log(`<- ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } - // the store lookup costs a round trip, so it runs alongside head.html rather than after it + // runs the lookup alongside head.html, since it costs a round trip + // settles both rather than racing, so a dead preview host cannot preempt the no-such-site 404 const aemCtx = getAemCtx(env, daCtx); - const [headHtml, { response: sourceResp, error: sourceError }] = await Promise.all([ - getAEMHtml(aemCtx, '/head.html'), + const [preview, source] = await Promise.allSettled([ + reach(PREVIEW_HOST, () => getAEMHtml(aemCtx, HEAD_HTML_PATH)), readSource(env, daCtx, { method: 'GET', headers }), ]); - if (!headHtml) { + + // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. + // drops the preview failure on purpose: a site that does not exist has no preview host either + if (source.status === 'fulfilled' && source.value.noSuchSite) { // 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. + // can load into this page, even when the site does not exist. if (isQuickEdit) { return buildQuickEditNotFoundResponse(); } - return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); + return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - if (!sourceResp) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, sourceError); + if (preview.status === 'rejected') throw preview.reason; + if (source.status === 'rejected') throw source.reason; + + const headHtml = preview.value; + const { response: sourceResp } = source.value; console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); // the store is the only thing to see the token, and the authorbus extension recovers off the @@ -198,8 +208,8 @@ export async function daSourceGet({ req, env, daCtx }) { ? await sourceResp.text() : await getPageTemplate(env, daCtx, aemCtx, headHtml); - // compose the page the same way for every request type - const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml); + // builds the page without head.html, which a ref that was never previewed does not have + const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); // layer the request-specific instrumentation on top of the composed page const extraHeaders = []; @@ -225,7 +235,16 @@ export async function daSourceGet({ req, env, daCtx }) { }); } -export async function daSourceHead({ env, daCtx }) { +/** Wraps sourceGet, turning an unreachable upstream into a 503 in HTML the editor renders. */ +export async function daSourceGet({ req, env, daCtx }) { + try { + return await sourceGet({ req, env, daCtx }); + } catch (e) { + return refuseUnreachable(e, 'GET', daCtx.sourcePath); + } +} + +async function sourceHead({ env, daCtx }) { const { authToken } = daCtx; if (!authToken) { @@ -235,13 +254,22 @@ export async function daSourceHead({ env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); - const { response, error } = await readSource(env, daCtx, { method: 'HEAD', headers }); - if (!response) return head503(error); + const { response, noSuchSite } = await readSource(env, daCtx, { method: 'HEAD', headers }); + if (noSuchSite) return head404(); 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 }); } -export async function daSourcePost({ req, env, daCtx }) { +/** Wraps sourceHead, turning an unreachable upstream into a bodyless 503. */ +export async function daSourceHead({ env, daCtx }) { + try { + return await sourceHead({ env, daCtx }); + } catch (e) { + return refuseUnreachable(e, 'HEAD', daCtx.sourcePath); + } +} + +async function sourcePost({ req, env, daCtx }) { const { sourcePath, ext, authToken } = daCtx; // the body is rewritten as HTML below, so anything but an HTML document would be @@ -276,13 +304,7 @@ export async function daSourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); // 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); - } + const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); 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.`); @@ -294,15 +316,26 @@ export async function daSourcePost({ req, env, daCtx }) { 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, { + const response = await reach(CONTENT_STORE, () => store.fetch(new Request(store.url, { method: 'POST', body, 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; } return get415(); } + +/** + * Wraps sourcePost, turning an unreachable upstream into the plain text the editor shows the + * author. + */ +export async function daSourcePost({ req, env, daCtx }) { + try { + return await sourcePost({ req, env, daCtx }); + } catch (e) { + return refuseUnreachable(e, 'POST', daCtx.sourcePath); + } +} diff --git a/src/storage/config.js b/src/storage/config.js index 8f87ef32..1b8816af 100644 --- a/src/storage/config.js +++ b/src/storage/config.js @@ -21,9 +21,13 @@ async function fetchConfig(env, daCtx, path) { const configUrl = new URL(path, env.DA_ADMIN); const res = await env.daadmin.fetch(configUrl, opts); - if (!res.ok) { + // a site with no config answers 404, and an author who may not read it is refused 403. both + // answered, so the starter template is used rather than refusing the request + if (res.status === 404 || res.status === 401 || res.status === 403) { + if (res.status !== 404) console.warn(`${configUrl} answered ${res.status}, using no config`); return null; } + if (!res.ok) throw new Error(`${configUrl} answered ${res.status}`); const json = await res.json(); if (!json) return []; const data = getFirstSheet(json); diff --git a/src/storage/site.js b/src/storage/site.js new file mode 100644 index 00000000..5adce1e2 --- /dev/null +++ b/src/storage/site.js @@ -0,0 +1,53 @@ +/* + * 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 NO_SITE = { exists: false, onSourceBus: false }; + +/** + * Asks the config service whether a site exists and which store holds its content. + * + * `content.source.url` decides the store, and helix-admin sets `x-api-upgrade-available` from + * the same field, so a request to /ping would say the same thing. + * + * Throws on any refusal but a 404, which is the only status that means there is no such site. + * Reads the status and the source url only, since the response also has admin roles and + * resolved secrets. + * + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it, `AEM_API` is the source bus the source url is + * compared against + * @param {Object} daCtx + * @returns {Promise<{exists: boolean, onSourceBus: boolean}>} + */ +export default async function getSite(env, daCtx) { + const { org, site, ref } = daCtx; + // an unparseable hostname leaves org and site undefined, and there is no site to ask about + if (!org || !site) return NO_SITE; + + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=admin`, env.HLX_CONFIG_SERVICE); + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (response.status === 404) return NO_SITE; + if (!response.ok) throw new Error(`the config service answered ${response.status}`); + + const { content } = await response.json(); + // the bare prefix would also match a host like api.aem.live.evil.example + const sourceBus = new URL('/', env.AEM_API).href; + return { exists: true, onSourceBus: !!content?.source?.url?.startsWith(sourceBus) }; +} diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js deleted file mode 100644 index 9cfe6b89..00000000 --- a/src/storage/source-bus.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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/utils/aemCtx.js b/src/utils/aemCtx.js index 54ddaa25..b02c2b84 100644 --- a/src/utils/aemCtx.js +++ b/src/utils/aemCtx.js @@ -48,8 +48,15 @@ export function withAemAuth(aemCtx, init = {}) { export async function getAEMHtml(aemCtx, path) { const { previewUrl } = aemCtx; - const resp = await fetch(`${previewUrl}${path}`, withAemAuth(aemCtx)); - if (!resp.ok) return undefined; + const url = `${previewUrl}${path}`; + const resp = await fetch(url, withAemAuth(aemCtx)); + // a ref that was never previewed answers 404, and a host behind Helix auth refuses without a + // site token. both answered, so the page is built without the fragment + if (resp.status === 404 || resp.status === 401 || resp.status === 403) { + if (resp.status !== 404) console.warn(`${url} answered ${resp.status}, using no fragment`); + return undefined; + } + if (!resp.ok) throw new Error(`${url} answered ${resp.status}`); const headHtml = await resp.text(); return headHtml; } diff --git a/src/utils/constants.js b/src/utils/constants.js index 69706aed..6462ee2d 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -49,10 +49,14 @@ export const UNAUTHORIZED_HTML_MESSAGE = ` export const DEFAULT_HTML_TEMPLATE = '
'; -export const BRANCH_NOT_FOUND_HTML_MESSAGE = '

Not found: Unable to retrieve AEM branch

'; +export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found

There is no site at this address.

'; + +export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry.

'; export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

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

'; +export const SOURCE_UNDETERMINED_HTML_MESSAGE = '

503: Content store undetermined

Which store holds this document could not be determined. 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.'; diff --git a/src/utils/quick-edit.js b/src/utils/quick-edit.js index 30be4054..c8cf10f2 100644 --- a/src/utils/quick-edit.js +++ b/src/utils/quick-edit.js @@ -189,14 +189,12 @@ export function prepareQuickEditDocument(html, nonce) { } /** - * Build the quick-edit 404 response for when the AEM branch itself can't be - * resolved (e.g. head.html is missing): a minimal page shell with the import - * map injected (no entry script), status 404, so the editor can still load - * into it. Reuse this anywhere quick-edit needs to degrade the same way. + * Build the quick-edit 404 response for when there is no such site: a minimal page shell with + * the import map injected and no entry script, so the editor can still load into it. * @returns {Response} */ export function buildQuickEditNotFoundResponse() { - console.log('[quick-edit] doc compose: head.html not found on origin, serving a minimal scaffold'); + console.log('[quick-edit] doc compose: no such site, serving a minimal scaffold'); const tree = fromHtml(`${DEFAULT_HTML_TEMPLATE}`); applyQuickEditToDocument(tree, undefined); const body = toHtml(tree, { allowDangerousHtml: true }); diff --git a/src/utils/upstream.js b/src/utils/upstream.js new file mode 100644 index 00000000..0dc46f7b --- /dev/null +++ b/src/utils/upstream.js @@ -0,0 +1,62 @@ +/* + * 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. + */ + +/** Names the upstream in the worker log and in `x-error`. */ +export const PREVIEW_HOST = 'preview host'; +export const CONTENT_STORE = 'content store'; +export const SITE_CONFIG = 'site config'; +export const SITE_LOOKUP = 'site lookup'; + +/** + * Renders a failure for the `x-error` header. + */ +export function causeOf(e) { + return `${e?.name ?? 'Error'}: ${e?.message ?? e}` + .replace(/[^\x20-\x7e]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 1024); +} + +/** + * Thrown when an upstream could not be reached at all. + * + * Not a refusal: an upstream that answered 401 or 404 has answered, and the route decides what + * that means. An UpstreamError means there is no answer to read, and it is retryable. + * + * @property {string} upstream PREVIEW_HOST, CONTENT_STORE, SITE_CONFIG or SITE_LOOKUP + */ +export class UpstreamError extends Error { + constructor(upstream, cause) { + super(`${upstream} failed: ${causeOf(cause)}`, { cause }); + this.name = 'UpstreamError'; + this.upstream = upstream; + } +} + +/** + * Runs `read` and rethrows anything it throws as an UpstreamError naming `upstream`. + * + * @param {string} upstream PREVIEW_HOST, CONTENT_STORE, SITE_CONFIG or SITE_LOOKUP + * @param {() => Promise} read + * @returns {Promise} + * @template T + */ +export async function reach(upstream, read) { + try { + return await read(); + } catch (e) { + // keeps the inner upstream name rather than overwriting it + if (e instanceof UpstreamError) throw e; + throw new UpstreamError(upstream, e); + } +} diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js deleted file mode 100644 index 8093d3fd..00000000 --- a/test/storage/source-bus.test.js +++ /dev/null @@ -1,194 +0,0 @@ -/* - * 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); - }); -}); From b33f8cc8c75ac830cc205d0b735c601f4917e0b4 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 11 Aug 2026 16:05:51 +0200 Subject: [PATCH 03/77] chore: point at the config service, and deploy with the wrangler the repo pins HLX_ADMIN goes out of all three environments: its only reader was the deleted /ping probe. dev points at dev/config-shim.js on 4713, which stands in for config.aem.page so the worker runs locally without the shared secret, and .gitignore gains the glob because npm start runs --env dev and wrangler reads .dev.vars.dev before .dev.vars. the deploy job had no npm ci, so wrangler-action installed its own default, 3.90.0, which has no secrets key and would deploy past a missing HLX_CONFIG_SERVICE_TOKEN. da-admin and da-collab install and run npm run deploy instead of using the action; this matches them. node 24, above the floor wrangler and miniflare declare. --- .github/workflows/deploy.yaml | 31 ++++++++++++++---------- .github/workflows/pull-request.yaml | 2 +- .gitignore | 2 +- README.md | 7 +++++- dev/config-shim.js | 37 +++++++++++++++++++++++++++++ dev/config-shim.toml | 9 +++++++ package.json | 1 + wrangler.toml | 11 ++++++--- 8 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 dev/config-shim.js create mode 100644 dev/config-shim.toml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 054d9499..65a8809d 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - name: Checkout repository uses: actions/checkout@v7 @@ -38,18 +38,25 @@ jobs: needs: test steps: - uses: actions/checkout@v7 - + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24.x + + - name: Install dependencies + run: npm ci + - name: Deploy to Cloudflare Workers (production) if: github.ref_name == 'main' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - + run: npm run deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + - name: Deploy to Cloudflare Workers (stage) if: github.ref_name == 'stage' - uses: cloudflare/wrangler-action@v3 - with: - apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} - accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - environment: stage + run: npm run deploy:stage + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index dc484538..16c6e560 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - name: Checkout repository uses: actions/checkout@v7 diff --git a/.gitignore b/.gitignore index 71619106..c4fbad99 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ jspm_packages/ .env.production.local .env.local -.dev.vars +.dev.vars* .wrangler/ .DS_Store .cursor diff --git a/README.md b/README.md index 5205fd84..c06bdaf5 100644 --- a/README.md +++ b/README.md @@ -11,14 +11,19 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. +Site lookups go to config.aem.page, which needs a shared secret, so local development points at `dev/config-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404. + To run da-universal locally: 1. Clone this repo to your computer. 1. Run `npm install` 1. Use `npx wrangler login` if not done before. Walk through the steps in browser. -1. In a terminal, run `npm run dev` this repo's folder. +1. In a terminal, run `npm run dev:config` to start the stand-in config service on port 4713. +1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 +Anyone who has the shared secret can point `npm run dev` at config.aem.page instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. + ### Run on stage You can deploy da-universal on Cloudflare stage via `npm deploy:stage` to test it in a real worker environment. diff --git a/dev/config-shim.js b/dev/config-shim.js new file mode 100644 index 00000000..75265e58 --- /dev/null +++ b/dev/config-shim.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. + */ + +// stands in for config.aem.page, which needs a shared secret +// answers 200 for a site in SITES, 404 for anything else +const SITES = { + 'org/site': 'https://content.da.live/org/site/', +}; + +export default { + async fetch(req) { + const url = new URL(req.url); + const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); + if (!org || !site) { + return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); + } + + const source = SITES[`${org}/${site}`]; + if (!source) { + return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); + } + + const body = JSON.stringify({ + ref, site, org, content: { source: { type: 'markup', url: source } }, + }); + return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); + }, +}; diff --git a/dev/config-shim.toml b/dev/config-shim.toml new file mode 100644 index 00000000..d3c4fa48 --- /dev/null +++ b/dev/config-shim.toml @@ -0,0 +1,9 @@ +# the stand-in gets its own config, so wrangler dev does not read wrangler.toml: no daadmin +# binding to connect, and no HLX_CONFIG_SERVICE_TOKEN to warn about +name = "da-ue-config-shim" +main = "config-shim.js" +compatibility_date = "2023-11-21" + +[dev] +port = 4713 +inspector_port = 9234 diff --git a/package.json b/package.json index 15c6fb3f..634708dc 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "deploy": "wrangler deploy", "deploy:stage": "wrangler deploy --env stage", "dev": "wrangler dev --local-protocol https --env dev", + "dev:config": "wrangler dev -c dev/config-shim.toml", "start": "wrangler dev --local-protocol https --env dev", "test": "c8 mocha --spec=test/**/*.test.js", "lint": "eslint ." diff --git a/wrangler.toml b/wrangler.toml index b8fdfed1..b3aaafe1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,19 +2,24 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin" }] +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [dev] port = 4712 [env.dev] -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" } +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_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] +# the stand-in needs no token, and without this empty list wrangler warns about a missing +# HLX_CONFIG_SERVICE_TOKEN on npm run dev +secrets = { required = [] } [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", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +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_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin-stage" }] +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage.observability] enabled = true From e5a14cec84ea2f3430136f1a871afcc2fe339565 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 11 Aug 2026 16:05:51 +0200 Subject: [PATCH 04/77] ci: bump wrangler-action to v4, which knows the required-secrets declaration v3 installs its own wrangler 3.90.0, which has no secrets key in its config schema, so `secrets = { required = [...] }` is ignored and a deploy without HLX_CONFIG_SERVICE_TOKEN goes out green. v4 defaults to wrangler 4. same two lines as #214. --- .github/workflows/deploy.yaml | 31 +++++++++++------------------ .github/workflows/pull-request.yaml | 2 +- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 65a8809d..f54932c9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [24.x] + node-version: [20.x] steps: - name: Checkout repository uses: actions/checkout@v7 @@ -38,25 +38,18 @@ jobs: needs: test steps: - uses: actions/checkout@v7 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: 24.x - - - name: Install dependencies - run: npm ci - + - name: Deploy to Cloudflare Workers (production) if: github.ref_name == 'main' - run: npm run deploy - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + - name: Deploy to Cloudflare Workers (stage) if: github.ref_name == 'stage' - run: npm run deploy:stage - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + environment: stage diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 16c6e560..dc484538 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [24.x] + node-version: [20.x] steps: - name: Checkout repository uses: actions/checkout@v7 From 0a29287e1af145d847d21597853a08d8809022c3 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 13:56:59 +0200 Subject: [PATCH 05/77] test: the page head comes from the config service, and a refused read is a 503 --- test/routes/da-admin.test.js | 6 +- test/routes/source-read.test.js | 99 ++++++++++++++++++-------- test/storage/site.test.js | 120 +++++++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 33 deletions(-) diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index b0aa0de3..e4a763cd 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -64,10 +64,11 @@ const stubConfig = (upgraded = []) => { const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { default: async () => ({ exists: true, onSourceBus: false }), + getSiteHead: async () => '', }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => '', + getAEMHtml: async () => 'from the template', }, '../../src/render/compose.js': { composeHtml: async () => ({ tree: true }), @@ -119,10 +120,11 @@ describe('daSourceGet', () => { return (await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { default: async () => site, + getSiteHead: async () => headHtml, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => headHtml, + getAEMHtml: async () => 'from the template', }, '../../src/render/compose.js': { composeHtml: async (daCtx, aemCtx, bodyHtml) => { diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 8f3f8bd0..54e9f9ca 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -36,7 +36,7 @@ const build = async (overrides = {}) => { legacy = () => new Response('from da-admin', { status: 200 }), } = overrides; // `in overrides` rather than a destructured default on these, so an explicit undefined reads - // as a missing head.html, a template the preview host does not have, or a failed lookup + // as a ref with no head.html, a template the preview host does not have, or a failed lookup const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; const templateHtml = 'templateHtml' in overrides ? overrides.templateHtml @@ -47,7 +47,7 @@ const build = async (overrides = {}) => { headError, templateError, configError, composeError, config = null, } = overrides; const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, heads: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -73,17 +73,19 @@ const build = async (overrides = {}) => { if (site === undefined) throw lookupError; return site; }, + getSiteHead: async () => { + if (headError) throw headError; + seen.heads += 1; + return headHtml; + }, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ previewUrl: 'https://main--site--org.aem.page' }), - // answers both preview host reads, and fails them separately so a test can reach the - // template read with head.html answering + // the template is the only preview host read left on this path getAEMHtml: async (aemCtx, path) => { - const isHead = path === '/head.html'; - if (isHead && headError) throw headError; - if (!isHead && templateError) throw templateError; + if (templateError) throw templateError; seen.aem.push(path); - return isHead ? headHtml : templateHtml; + return templateHtml; }, }, '../../src/render/compose.js': { @@ -797,6 +799,40 @@ describe('reading from the store that holds the site', () => { }); }); + // the config service reads head.html off the code bus, so a site behind Helix authentication + // and a ref the preview host will not serve both still get the project's css and js + describe('where the page head comes from', () => { + it('reads it off the config service, and asks the preview host for nothing', 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.heads, 1); + assert.deepStrictEqual(seen.aem, []); + }); + + it('composes the page with it', 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.head[0], ''); + }); + + // one read each, rather than a second lookup to carry the head + it('reads it alongside the lookup', 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.lookups, 1); + assert.strictEqual(seen.heads, 1); + }); + }); + // #258: head.html does not say whether a site exists, so a missing head.html is not a 404 describe('when the site serves no head.html', () => { it('reads the document anyway', async () => { @@ -912,8 +948,8 @@ describe('reading from the store that holds the site', () => { }); }); - // a throw out of getAEMHtml used to reach the worker's catch as a 500 with no body - describe('when the preview host cannot be reached', () => { + // a throw out of the head read used to reach the worker's catch as a 500 with no body + describe('when the head read cannot be reached', () => { const dead = () => new TypeError('Network connection lost'); it('answers 503 rather than throwing', async () => { @@ -931,7 +967,7 @@ describe('reading from the store that holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'preview host failed: TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'page head failed: TypeError: Network connection lost'); }); it('asks the caller to retry', async () => { @@ -946,16 +982,16 @@ describe('reading from the store that holds the site', () => { }); // says what happened, since the preview iframe renders the refusal - it('says the preview host did not answer, not the store', async () => { + it('says the page head did not arrive, not that the store is undetermined', async () => { const { daSourceGet, env } = await build({ headError: dead() }); 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(), messages.PREVIEW_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await res.text(), messages.HEAD_UNREACHABLE_HTML_MESSAGE); }); - // a 404 would say the site is gone, which is #258 again, this time on the preview host + // a 404 would say the site is gone, which is #258 again, this time on the head read it('does not answer 404', async () => { const { daSourceGet, env } = await build({ headError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -1029,18 +1065,20 @@ describe('when the site config cannot be reached', () => { }); }); -// getAEMHtml is not stubbed here: a stub hides a preview host that answers, but with something -// other than head.html -describe('when the preview host refuses head.html', () => { +// getSiteHead is not stubbed here: a stub hides a config service that answers, but with +// something other than a head +describe('when the config service refuses the head read', () => { afterEach(() => { delete globalThis.fetch; }); - const readWithPreviewStatus = async (status) => { - globalThis.fetch = async () => new Response('preview said no', { status }); + const readWithConfigStatus = async (status) => { + globalThis.fetch = async () => new Response('the config service said no', { status }); const env = { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-token', daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { @@ -1051,29 +1089,30 @@ describe('when the preview host refuses head.html', () => { return daSourceGet({ req, env, daCtx: getDaCtx(req) }); }; - // a 200 with no head.html is a page with no stylesheet, no scripts and no entry script + // a 200 with no head is a page with no stylesheet, no scripts and no entry script it('refuses a 500 with 503 rather than composing an empty project head', async () => { - const res = await readWithPreviewStatus(500); + const res = await readWithConfigStatus(500); assert.strictEqual(res.status, 503); }); - it('names the preview host in x-error', async () => { - const res = await readWithPreviewStatus(500); + it('names the page head in x-error', async () => { + const res = await readWithConfigStatus(500); - assert.match(res.headers.get('x-error'), /preview host failed/); + assert.match(res.headers.get('x-error'), /page head failed/); }); - // a host behind Helix auth refuses without a site token, and a retry answers the same - it('composes the page without a head on a 401', async () => { - const res = await readWithPreviewStatus(401); + // the shared secret is the worker's own, so a 401 is a deploy without it rather than a site + // that has no head.html + it('refuses a 401 with 503', async () => { + const res = await readWithConfigStatus(401); - assert.strictEqual(res.status, 200); + assert.strictEqual(res.status, 503); }); - // a ref that was never previewed has no head.html, which is not a failure + // a ref that was never built has no head.html, which is not a failure it('composes the page without a head on a 404', async () => { - const res = await readWithPreviewStatus(404); + const res = await readWithConfigStatus(404); assert.strictEqual(res.status, 200); }); diff --git a/test/storage/site.test.js b/test/storage/site.test.js index db6e4807..3877feb2 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -13,7 +13,7 @@ /* eslint-env mocha */ import assert from 'assert'; -const { default: getSite } = await import('../../src/storage/site.js'); +const { default: getSite, getSiteHead } = await import('../../src/storage/site.js'); const env = { AEM_API: 'https://api.aem.live', @@ -44,6 +44,13 @@ const legacy = config('https://content.da.live/org/site/'); const sourceBus = config('https://api.aem.live/org/sites/site/'); const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); +const STYLESHEET = ''; +// the pipeline scope carries the code bus object, under a lastModified the delivery pipeline reads +const withHead = (html) => () => new Response( + JSON.stringify({ head: { lastModified: 'Mon, 30 Mar 2026 06:42:40 GMT', html } }), + { status: 200 }, +); + describe('getSite', () => { afterEach(() => { delete globalThis.fetch; @@ -207,3 +214,114 @@ describe('getSite', () => { }); }); }); + +describe('getSiteHead', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks the config service for the pipeline-scoped config', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); + }); + + // the code bus holds one head.html per ref, so a branch gets its own + it('names the ref the request came in on', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx({ ref: 'feature' })); + + assert.match(calls[0].url, /\/feature--site--org\//); + }); + + it('sends the shared token', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx()); + + assert.strictEqual(calls[0].init.headers['x-access-token'], 'shared-token'); + }); + + it('sends the backend type', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx()); + + assert.strictEqual(calls[0].init.headers['x-backend-type'], 'aws'); + }); + + it('gives up rather than hanging', async () => { + stubFetch(withHead(STYLESHEET)); + + await getSiteHead(env, daCtx()); + + assert.ok(calls[0].init.signal); + }); + }); + + describe('the head it answers', () => { + it('reads head.html out of the config', async () => { + stubFetch(withHead(STYLESHEET)); + + assert.strictEqual(await getSiteHead(env, daCtx()), STYLESHEET); + }); + + // a ref with no head.html on the code bus answers 200 with an empty head, not a 404 + it('answers nothing when the ref has no head.html', async () => { + stubFetch(() => new Response(JSON.stringify({ head: {} }), { status: 200 })); + + assert.strictEqual(await getSiteHead(env, daCtx()), undefined); + }); + + it('answers nothing when the config carries no head at all', async () => { + stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); + + assert.strictEqual(await getSiteHead(env, daCtx()), undefined); + }); + + // the lookup answers 404 with the site, and one 404 page is enough + it('answers nothing on a 404', async () => { + stubFetch(absent); + + assert.strictEqual(await getSiteHead(env, daCtx()), undefined); + }); + + it('asks nothing when there is no org or site', async () => { + stubFetch(absent); + + assert.strictEqual(await getSiteHead(env, daCtx({ site: undefined })), undefined); + assert.strictEqual(calls.length, 0); + }); + }); + + // the token is the worker's own, so a refusal is a broken worker rather than a site with no + // head.html, and composing an empty project head would serve that as a page + describe('when the read cannot answer', () => { + [401, 403, 429, 500, 502].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects(() => getSiteHead(env, daCtx()), /502|500|429|403|401/); + }); + }); + + it('throws when the config service cannot be reached', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(() => getSiteHead(env, daCtx()), /fetch failed/); + }); + + it('throws when the body is not JSON', async () => { + stubFetch(() => new Response('the edge said no', { status: 200 })); + + await assert.rejects(() => getSiteHead(env, daCtx())); + }); + }); +}); From 1ffeb8151209c2637ee1a2231c3b99c554fe1243 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:00:39 +0200 Subject: [PATCH 06/77] fix: read head.html from the config service, not the preview host --- dev/config-shim.js | 13 ++++++-- src/routes/da-admin.js | 24 ++++++++------- src/storage/site.js | 54 +++++++++++++++++++++++++++------ src/utils/constants.js | 2 ++ src/utils/upstream.js | 1 + test/routes/source-read.test.js | 9 ++++-- 6 files changed, 77 insertions(+), 26 deletions(-) diff --git a/dev/config-shim.js b/dev/config-shim.js index 75265e58..734f0cf8 100644 --- a/dev/config-shim.js +++ b/dev/config-shim.js @@ -16,6 +16,9 @@ const SITES = { 'org/site': 'https://content.da.live/org/site/', }; +// what the code bus holds at {owner}/{repo}/{ref}/head.html, which the pipeline scope carries +const HEAD_HTML = '\n\n'; + export default { async fetch(req) { const url = new URL(req.url); @@ -29,9 +32,13 @@ export default { return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); } - const body = JSON.stringify({ - ref, site, org, content: { source: { type: 'markup', url: source } }, - }); + const body = url.searchParams.get('scope') === 'pipeline' + ? JSON.stringify({ + ref, site, org, head: { html: HEAD_HTML }, + }) + : JSON.stringify({ + ref, site, org, content: { source: { type: 'markup', url: source } }, + }); return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); }, }; diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index a7c3be68..74af0826 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -26,6 +26,7 @@ import { } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, + HEAD_UNREACHABLE_HTML_MESSAGE, PREVIEW_UNREACHABLE_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, @@ -36,11 +37,12 @@ import { UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; -import getSite from '../storage/site.js'; +import getSite, { getSiteHead } from '../storage/site.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { CONTENT_STORE, + PAGE_HEAD, PREVIEW_HOST, SITE_CONFIG, SITE_LOOKUP, @@ -49,7 +51,6 @@ import { } from '../utils/upstream.js'; const HTML_POST_TYPE = 'text/html'; -const HEAD_HTML_PATH = '/head.html'; /** * Overrides the store's body for the upstreams that need their own. SITE_CONFIG is read off @@ -58,6 +59,7 @@ const HEAD_HTML_PATH = '/head.html'; const UNREACHABLE_HTML = { [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, [SITE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, + [PAGE_HEAD]: HEAD_UNREACHABLE_HTML_MESSAGE, }; const UNREACHABLE_TEXT = { [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; @@ -166,16 +168,16 @@ async function sourceGet({ req, env, daCtx }) { return response; } - // runs the lookup alongside head.html, since it costs a round trip - // settles both rather than racing, so a dead preview host cannot preempt the no-such-site 404 + // runs the lookup alongside the head read, since it costs a round trip + // settles both rather than racing, so a failed head read cannot preempt the no-such-site 404 const aemCtx = getAemCtx(env, daCtx); - const [preview, source] = await Promise.allSettled([ - reach(PREVIEW_HOST, () => getAEMHtml(aemCtx, HEAD_HTML_PATH)), + const [head, source] = await Promise.allSettled([ + reach(PAGE_HEAD, () => getSiteHead(env, daCtx)), readSource(env, daCtx, { method: 'GET', headers }), ]); // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. - // drops the preview failure on purpose: a site that does not exist has no preview host either + // drops the head failure on purpose: a site that does not exist has no head.html either if (source.status === 'fulfilled' && source.value.noSuchSite) { // quick-edit still needs a working shell (with the import map) so the editor // can load into this page, even when the site does not exist. @@ -184,10 +186,10 @@ async function sourceGet({ req, env, daCtx }) { } return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - if (preview.status === 'rejected') throw preview.reason; + if (head.status === 'rejected') throw head.reason; if (source.status === 'rejected') throw source.reason; - const headHtml = preview.value; + const headHtml = head.value; const { response: sourceResp } = source.value; console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); @@ -206,9 +208,9 @@ async function sourceGet({ req, env, daCtx }) { // use the stored content when available, otherwise fall back to a template const bodyHtml = sourceResp.status === 200 ? await sourceResp.text() - : await getPageTemplate(env, daCtx, aemCtx, headHtml); + : await getPageTemplate(env, daCtx, aemCtx); - // builds the page without head.html, which a ref that was never previewed does not have + // builds the page without head.html, which a ref that was never built does not have const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); // layer the request-specific instrumentation on top of the composed page diff --git a/src/storage/site.js b/src/storage/site.js index 5adce1e2..8e3485d1 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -13,6 +13,19 @@ const TIMEOUT_MS = 5 * 1000; const NO_SITE = { exists: false, onSourceBus: false }; +/** One read of the config service, at the scope the caller needs. */ +function askConfigService(env, daCtx, scope) { + const { org, site, ref } = daCtx; + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=${scope}`, env.HLX_CONFIG_SERVICE); + return fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); +} + /** * Asks the config service whether a site exists and which store holds its content. * @@ -30,18 +43,11 @@ const NO_SITE = { exists: false, onSourceBus: false }; * @returns {Promise<{exists: boolean, onSourceBus: boolean}>} */ export default async function getSite(env, daCtx) { - const { org, site, ref } = 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 NO_SITE; - const url = new URL(`/${ref}--${site}--${org}/config.json?scope=admin`, env.HLX_CONFIG_SERVICE); - const response = await fetch(url, { - headers: { - 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, - 'x-backend-type': 'aws', - }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); + const response = await askConfigService(env, daCtx, 'admin'); if (response.status === 404) return NO_SITE; if (!response.ok) throw new Error(`the config service answered ${response.status}`); @@ -51,3 +57,33 @@ export default async function getSite(env, daCtx) { const sourceBus = new URL('/', env.AEM_API).href; return { exists: true, onSourceBus: !!content?.source?.url?.startsWith(sourceBus) }; } + +/** + * Reads the site's head.html from the config service. + * + * The pipeline scope carries the code bus object the delivery pipeline renders into every page of + * the site, and the admin scope does not carry it at all. Reading it here rather than from + * `{ref}--{site}--{org}.aem.page/head.html` answers for a ref the preview host never built and + * for a site behind Helix authentication, which refuses that path without a site token. + * + * Throws on any refusal but a 404. The token is the worker's own, so a refusal is a deploy + * without it rather than a site that has no head.html. + * + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the read goes and + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it + * @param {Object} daCtx + * @returns {Promise} undefined when the ref has no head.html + */ +export async function getSiteHead(env, daCtx) { + const { org, site } = daCtx; + if (!org || !site) return undefined; + + const response = await askConfigService(env, daCtx, 'pipeline'); + + // getSite answers the missing site, and one 404 for the page is enough + if (response.status === 404) return undefined; + if (!response.ok) throw new Error(`the config service answered ${response.status}`); + + const { head } = await response.json(); + return head?.html; +} diff --git a/src/utils/constants.js b/src/utils/constants.js index 6462ee2d..57ca8c2c 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -53,6 +53,8 @@ export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found< export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry.

'; +export const HEAD_UNREACHABLE_HTML_MESSAGE = '

503: Page head unreachable

The site\'s head.html could not be read. Please retry.

'; + export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

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

'; export const SOURCE_UNDETERMINED_HTML_MESSAGE = '

503: Content store undetermined

Which store holds this document could not be determined. Please retry.

'; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index 0dc46f7b..baba2a54 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -15,6 +15,7 @@ export const PREVIEW_HOST = 'preview host'; export const CONTENT_STORE = 'content store'; export const SITE_CONFIG = 'site config'; export const SITE_LOOKUP = 'site lookup'; +export const PAGE_HEAD = 'page head'; /** * Renders a failure for the `x-error` header. diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 54e9f9ca..efc928bd 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -580,10 +580,13 @@ describe('reading from the store that holds the site', () => { daadmin: { fetch: async () => new Response('', { status: 200 }) }, }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/site.js': { default: async () => ({ exists: true, onSourceBus: true }) }, + '../../src/storage/site.js': { + default: async () => ({ exists: true, onSourceBus: true }), + getSiteHead: async () => '', + }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), - getAEMHtml: async () => '', + getAEMHtml: async () => 'from the template', }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -1192,7 +1195,7 @@ describe('a path the site config gives a template', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.match(await res.text(), /
/); - assert.strictEqual(seen.aem.length, 1); + assert.deepStrictEqual(seen.aem, []); }); // the config names a path the preview host answers 404 for, which leaves the starter From bd96daad02b7548891578848eb31e218cbe95ad5 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:01:00 +0200 Subject: [PATCH 07/77] test: the store the lookup could not name outranks a failed head read --- test/routes/source-read.test.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index efc928bd..6c3a8e07 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -776,8 +776,8 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // names which failure it was: a site that does not exist has no preview host either - it('reports no such site even when the preview host did not answer', async () => { + // names which failure it was: a site that does not exist has no head.html either + it('reports no such site even when the head read did not answer', async () => { const { daSourceGet, env } = await build({ site: NO_SITE, headError: new TypeError('Network connection lost'), @@ -789,6 +789,21 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); + // one service answers both reads, so an outage fails them together, and the store it could + // not name is the more useful of the two failures + it('reports the undetermined store when the head read failed with it', async () => { + const { daSourceGet, env } = await build({ + site: undefined, + headError: 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.status, 503); + assert.match(res.headers.get('x-error'), /site lookup failed/); + }); + it('reports an unreachable store on a site with no head.html', async () => { const { daSourceGet, env } = await build({ headHtml: undefined, From 01082cd2707a75cf310cb4a6d0fbfe29b5d768fe Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:01:18 +0200 Subject: [PATCH 08/77] fix: report the store the lookup could not name before a failed head read --- src/routes/da-admin.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 74af0826..e0643de9 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -186,8 +186,10 @@ async function sourceGet({ req, env, daCtx }) { } return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - if (head.status === 'rejected') throw head.reason; + // the lookup first: one service answers both reads, and the store it could not name is the + // more useful of the two failures if (source.status === 'rejected') throw source.reason; + if (head.status === 'rejected') throw head.reason; const headHtml = head.value; const { response: sourceResp } = source.value; From fd17d550c8a098f606c369b1ed51a29a2f888c78 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:16:27 +0200 Subject: [PATCH 09/77] fix: declare the config service token in the dev env, so .dev.vars.dev binds it --- README.md | 2 ++ wrangler.toml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c06bdaf5..faf25c28 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ To run da-universal locally: 1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 +Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. + Anyone who has the shared secret can point `npm run dev` at config.aem.page instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. ### Run on stage diff --git a/wrangler.toml b/wrangler.toml index b3aaafe1..39d66735 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -12,9 +12,9 @@ port = 4712 [env.dev] 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_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] -# the stand-in needs no token, and without this empty list wrangler warns about a missing -# HLX_CONFIG_SERVICE_TOKEN on npm run dev -secrets = { required = [] } +# the list is also what wrangler binds from .dev.vars.dev, so an empty one leaves the worker +# with no token and every lookup 401s. running against the stand-in warns that it is missing +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [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", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } From ce03d15f67a08212e036977e1930cc0b1a9bfd94 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 14:37:46 +0200 Subject: [PATCH 10/77] test: pin the head read to the html GET, and keep the preview host 503 body asserted --- README.md | 2 +- src/storage/site.js | 4 ++-- src/utils/upstream.js | 4 ++-- test/routes/da-admin.test.js | 2 -- test/routes/source-read.test.js | 25 +++++++++++++++++-------- test/storage/site.test.js | 8 ++++++++ 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index faf25c28..ad1094c0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Anyone who has the shared secret can point `npm run dev` at config.aem.page inst ### Run on stage -You can deploy da-universal on Cloudflare stage via `npm deploy:stage` to test it in a real worker environment. +You can deploy da-universal on Cloudflare stage via `npm run deploy:stage` to test it in a real worker environment. ## Customer documentation https://docs.da.live/developers/reference/universal-editor diff --git a/src/storage/site.js b/src/storage/site.js index 8e3485d1..230ef6c8 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -63,8 +63,8 @@ export default async function getSite(env, daCtx) { * * The pipeline scope carries the code bus object the delivery pipeline renders into every page of * the site, and the admin scope does not carry it at all. Reading it here rather than from - * `{ref}--{site}--{org}.aem.page/head.html` answers for a ref the preview host never built and - * for a site behind Helix authentication, which refuses that path without a site token. + * `{ref}--{site}--{org}.aem.page/head.html` answers for a site behind Helix authentication, which + * refuses that path without a site token. * * Throws on any refusal but a 404. The token is the worker's own, so a refusal is a deploy * without it rather than a site that has no head.html. diff --git a/src/utils/upstream.js b/src/utils/upstream.js index baba2a54..66ab125b 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -34,7 +34,7 @@ export function causeOf(e) { * Not a refusal: an upstream that answered 401 or 404 has answered, and the route decides what * that means. An UpstreamError means there is no answer to read, and it is retryable. * - * @property {string} upstream PREVIEW_HOST, CONTENT_STORE, SITE_CONFIG or SITE_LOOKUP + * @property {string} upstream one of the names at the top of this file */ export class UpstreamError extends Error { constructor(upstream, cause) { @@ -47,7 +47,7 @@ export class UpstreamError extends Error { /** * Runs `read` and rethrows anything it throws as an UpstreamError naming `upstream`. * - * @param {string} upstream PREVIEW_HOST, CONTENT_STORE, SITE_CONFIG or SITE_LOOKUP + * @param {string} upstream one of the names at the top of this file * @param {() => Promise} read * @returns {Promise} * @template T diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index e4a763cd..b112e196 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -68,7 +68,6 @@ const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => 'from the template', }, '../../src/render/compose.js': { composeHtml: async () => ({ tree: true }), @@ -124,7 +123,6 @@ describe('daSourceGet', () => { }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => 'from the template', }, '../../src/render/compose.js': { composeHtml: async (daCtx, aemCtx, bodyHtml) => { diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 6c3a8e07..850cd5a1 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -586,7 +586,6 @@ describe('reading from the store that holds the site', () => { }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), - getAEMHtml: async () => 'from the template', }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -817,8 +816,8 @@ describe('reading from the store that holds the site', () => { }); }); - // the config service reads head.html off the code bus, so a site behind Helix authentication - // and a ref the preview host will not serve both still get the project's css and js + // the config service reads head.html off the code bus, which a site behind Helix authentication + // serves to the worker's own token while its preview host refuses it describe('where the page head comes from', () => { it('reads it off the config service, and asks the preview host for nothing', async () => { const { daSourceGet, env, seen } = await build(); @@ -839,15 +838,24 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // one read each, rather than a second lookup to carry the head - it('reads it alongside the lookup', async () => { + // nothing composes an image, and a config service that is down would answer the 503 the + // handler prefers over the AEM proxy's answer + it('reads nothing for an asset', async () => { const { daSourceGet, env, seen } = await build(); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.heads, 1); + assert.strictEqual(seen.heads, 0); + }); + + it('reads nothing on a HEAD', 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.heads, 0); }); }); @@ -1188,6 +1196,7 @@ describe('a path the site config gives a template', () => { assert.strictEqual(res.status, 503); assert.match(res.headers.get('x-error'), /preview host failed/); + assert.strictEqual(await res.text(), messages.PREVIEW_UNREACHABLE_HTML_MESSAGE); }); it('takes the longest matching prefix', async () => { diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 3877feb2..cf69ec49 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -318,6 +318,14 @@ describe('getSiteHead', () => { await assert.rejects(() => getSiteHead(env, daCtx()), /fetch failed/); }); + // a worker deployed without the secret and a rate limit share the status and the body, so + // `x-error` is what tells them apart + it('names the status it got', async () => { + stubFetch(() => new Response('', { status: 401 })); + + await assert.rejects(() => getSiteHead(env, daCtx()), /401/); + }); + it('throws when the body is not JSON', async () => { stubFetch(() => new Response('the edge said no', { status: 200 })); From 403c1604130e594599012974fc72fdfcc9bfde9a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 17:35:25 +0200 Subject: [PATCH 11/77] test: /ping decides the store, the config service decides existence and the head red. the head arrives with the existence answer, so one pipeline read replaces the admin scope, and the source-bus flag comes off admin.hlx.page/ping again. --- src/storage/source-bus.js | 15 ++ test/index.test.js | 2 +- test/routes/da-admin.test.js | 52 +++--- test/routes/source-read.test.js | 279 ++++++++++++++++++------------- test/routes/source-write.test.js | 69 ++++---- test/storage/site.test.js | 205 ++++------------------- test/storage/source-bus.test.js | 194 +++++++++++++++++++++ 7 files changed, 479 insertions(+), 337 deletions(-) create mode 100644 src/storage/source-bus.js create mode 100644 test/storage/source-bus.test.js diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js new file mode 100644 index 00000000..b77223b2 --- /dev/null +++ b/src/storage/source-bus.js @@ -0,0 +1,15 @@ +/* + * 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. + */ + +export default async function isSourceBus() { + throw new Error('not implemented'); +} diff --git a/test/index.test.js b/test/index.test.js index 3ba9d9ba..0bdacba0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -189,7 +189,7 @@ describe('worker fetch handler', () => { // 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/site.js': { default: async () => ({ exists: true, onSourceBus: true }) }, + '../src/storage/source-bus.js': { default: async () => true }, })).default; const uePost = (origin) => { diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index b112e196..a8e30904 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -34,6 +34,7 @@ const recorder = () => { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_ADMIN: 'https://admin.hlx.page', daadmin: { fetch: async (input) => { fetched.push(input instanceof Request ? input.url : input.href); @@ -44,18 +45,23 @@ const recorder = () => { return { env, fetched }; }; -// stands in for config.aem.page, the only lookup the routes make -// answers that any site exists; `upgraded` lists the `org/site` keys on the source bus -const stubConfig = (upgraded = []) => { +// stands in for the two lookups the routes make: config.aem.page for whether the site exists, +// admin.hlx.page/ping for which store holds it. Answers that any site exists; `upgraded` lists +// the `org/site` keys /ping reports as enrolled +const stubLookups = (upgraded = []) => { const asked = []; globalThis.fetch = async (input) => { const url = input.toString(); asked.push(url); - const [, site, org] = new URL(url).pathname.split('/')[1].split('--'); - const source = upgraded.includes(`${org}/${site}`) - ? `https://api.aem.live/${org}/sites/${site}/` - : `https://content.da.live/${org}/${site}/`; - const body = JSON.stringify({ content: { source: { url: source, type: 'markup' } } }); + const { pathname } = new URL(url); + if (pathname.startsWith('/ping/')) { + const [, , org, site] = pathname.split('/'); + const headers = upgraded.includes(`${org}/${site}`) + ? { 'x-api-upgrade-available': 'true' } + : {}; + return new Response('', { status: 200, headers }); + } + const body = JSON.stringify({ head: { html: '' } }); return new Response(body, { status: 200 }); }; return asked; @@ -63,9 +69,9 @@ const stubConfig = (upgraded = []) => { const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists: true, onSourceBus: false }), - getSiteHead: async () => '', + default: async () => ({ exists: true, head: '' }), }, + '../../src/storage/source-bus.js': { default: async () => false }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), }, @@ -114,13 +120,13 @@ describe('daSourceGet', () => { // `{ headHtml: undefined }` actually simulates a missing head.html, instead // of being masked by the default parameter value. const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; - const site = overrides.site ?? { exists: true, onSourceBus: false }; + const exists = overrides.site?.exists ?? true; calls = { compose: [], ue: 0, quickEdit: 0 }; return (await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => site, - getSiteHead: async () => headHtml, + default: async () => ({ exists, head: headHtml }), }, + '../../src/storage/source-bus.js': { default: async () => false }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), }, @@ -239,7 +245,7 @@ describe('daSourceGet', () => { }); it('returns a working 404 shell for quick-edit when there is no such site', async () => { - const daSourceGet = await mockDaSourceGet({ site: { exists: false, onSourceBus: false } }); + const daSourceGet = await mockDaSourceGet({ site: { exists: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); const daCtx = getDaCtx(req); @@ -254,7 +260,7 @@ describe('daSourceGet', () => { }); it('returns not-found for non-quick-edit when there is no such site', async () => { - const daSourceGet = await mockDaSourceGet({ site: { exists: false, onSourceBus: false } }); + const daSourceGet = await mockDaSourceGet({ site: { exists: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const daCtx = getDaCtx(req); @@ -281,9 +287,9 @@ describe('daSourceGet', () => { }); describe('source URLs', () => { - // answers the unmocked lookup with a da-admin source, the legacy store these tests describe + // answers the unmocked lookups with a legacy site, the store these tests describe beforeEach(() => { - stubConfig(); + stubLookups(); }); afterEach(() => { @@ -418,9 +424,9 @@ describe('daSourcePost to a non-HTML path', () => { }); describe('daSourcePost', () => { - // answers the unmocked lookup with a da-admin source, the legacy store these tests describe + // answers the unmocked lookups with a legacy site, the store these tests describe beforeEach(() => { - stubConfig(); + stubLookups(); }); afterEach(() => { @@ -435,7 +441,7 @@ describe('daSourcePost', () => { }; it('is refused with 405 and nothing is written', async () => { - stubConfig(['org/refused']); + stubLookups(['org/refused']); const { env, fetched } = recorder(); const res = await write('refused', env); @@ -448,15 +454,15 @@ describe('daSourcePost', () => { // nothing is remembered between requests, so a site enrolled or un-enrolled mid-session takes // effect on the next one it('looks the site up once per write', async () => { - const asked = stubConfig(['org/lookedupeach']); + const asked = stubLookups(['org/lookedupeach']); const { env } = recorder(); await write('lookedupeach', env); await write('lookedupeach', env); assert.deepStrictEqual(asked, [ - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', + 'https://admin.hlx.page/ping/org/lookedupeach', + 'https://admin.hlx.page/ping/org/lookedupeach', ]); }); }); diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 850cd5a1..fc448600 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -20,7 +20,8 @@ import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); -// what the site lookup answers +// what the two lookups answer together: `exists` comes from the config service, `onSourceBus` +// from /ping const SOURCE_BUS = { exists: true, onSourceBus: true }; const LEGACY_STORE = { exists: true, onSourceBus: false }; const NO_SITE = { exists: false, onSourceBus: false }; @@ -44,10 +45,10 @@ const build = async (overrides = {}) => { const site = 'site' in overrides ? overrides.site : LEGACY_STORE; const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); const { - headError, templateError, configError, composeError, config = null, + busError, templateError, configError, composeError, config = null, } = overrides; const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, heads: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, pings: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -71,12 +72,14 @@ const build = async (overrides = {}) => { seen.lookups += 1; // throws for undefined, which is how the lookup reports a failure if (site === undefined) throw lookupError; - return site; + return { exists: site.exists, head: headHtml }; }, - getSiteHead: async () => { - if (headError) throw headError; - seen.heads += 1; - return headHtml; + }, + '../../src/storage/source-bus.js': { + default: async () => { + seen.pings += 1; + if (busError) throw busError; + return site !== undefined && site.onSourceBus; }, }, '../../src/utils/aemCtx.js': { @@ -111,15 +114,17 @@ const build = async (overrides = {}) => { return { ...mod, env, seen }; }; -describe('when the lookup cannot say which store holds the site', () => { +describe('when /ping cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); + const dead = () => new TypeError('fetch failed'); + // 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({ site: undefined }); + const { daSourceGet, env, seen } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -129,7 +134,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ site: undefined }); + const { daSourceGet, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -140,7 +145,7 @@ describe('when the lookup cannot say which store holds the site', () => { // the preview iframe renders this body, and the store answered nothing here: it was never // asked, since which store to ask is what could not be determined it('says the store could not be determined, not that it did not answer', async () => { - const { daSourceGet, env } = await build({ site: undefined }); + const { daSourceGet, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -151,7 +156,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('refuses a non-html read too', async () => { - const { daSourceGet, env, seen } = await build({ site: undefined }); + const { daSourceGet, env, seen } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -161,7 +166,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('refuses a HEAD with 503 and no body', async () => { - const { daSourceHead, env, seen } = await build({ site: undefined }); + const { daSourceHead, env, seen } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -171,51 +176,49 @@ describe('when the lookup cannot say which store holds the site', () => { assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); - // both 503s share a status and an unparsed body, so only the header separates them - it('names the failed lookup in x-error', async () => { - const { daSourceGet, env } = await build({ site: undefined }); + // the two lookups are two upstreams now, and both 503s share a status and an unparsed body + it('names the probe in x-error', async () => { + const { daSourceGet, env } = await build({ busError: dead() }); 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'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /store lookup failed/); }); - it('names the failed lookup on a HEAD too', async () => { - const { daSourceHead, env } = await build({ site: undefined }); + it('names the probe on a HEAD too', async () => { + const { daSourceHead, env } = await build({ busError: dead() }); 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'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /store lookup failed/); }); - // 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('names the lookup cause, not a category', async () => { + // the header is the only thing on the wire that separates a timeout from a dropped connection + it('names the cause, not a category', async () => { const { daSourceGet, env } = await build({ - site: undefined, - lookupError: new DOMException('timed out', 'TimeoutError'), + busError: 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'), 'site lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'store lookup 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({ site: undefined, lookupError: 'boom' }); + const { daSourceGet, env } = await build({ busError: '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'), 'site lookup failed: Error: boom'); + assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: Error: boom'); }); - it('tells a lookup failure apart from a store failure', async () => { + it('tells a probe failure apart from a store failure', async () => { const { daSourceGet, env } = await build({ site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, @@ -228,6 +231,97 @@ describe('when the lookup cannot say which store holds the site', () => { }); }); +// the config service answers whether the site exists and what its head.html is, so an outage +// leaves both unknown +describe('when the config service cannot say whether the site exists', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + it('refuses an html read with 503 and touches neither store', async () => { + const { daSourceGet, env, seen } = await build({ site: 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({ site: 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); + }); + + // a 404 would say the site is gone, which is #258 again, this time on the lookup + it('does not answer 404', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.notStrictEqual(res.status, 404); + }); + + // says what happened, since the preview iframe renders the refusal + it('says the site could not be looked up, not that a store did not answer', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + const body = await res.text(); + assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_UNREACHABLE_HTML_MESSAGE); + }); + + it('refuses a HEAD with 503 and no body', async () => { + const { daSourceHead, env, seen } = await build({ site: 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); + }); + + it('names the failed lookup in x-error', async () => { + const { daSourceGet, env } = await build({ site: 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'), /site lookup failed/); + }); + + it('names the lookup cause, not a category', async () => { + const { daSourceGet, env } = await build({ + site: undefined, + lookupError: 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'), 'site lookup failed: TimeoutError: timed out'); + }); + + it('survives a thrown non-Error', async () => { + const { daSourceGet, env } = await build({ site: undefined, lookupError: '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'), 'site lookup failed: Error: boom'); + }); +}); + describe('reading from the store that holds the site', () => { afterEach(() => { delete globalThis.fetch; @@ -581,9 +675,9 @@ describe('reading from the store that holds the site', () => { }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists: true, onSourceBus: true }), - getSiteHead: async () => '', + default: async () => ({ exists: true, head: '' }), }, + '../../src/storage/source-bus.js': { default: async () => true }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), }, @@ -775,11 +869,11 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // names which failure it was: a site that does not exist has no head.html either - it('reports no such site even when the head read did not answer', async () => { + // the two lookups go out together, and a site that does not exist needs no store + it('reports no such site even when the probe did not answer', async () => { const { daSourceGet, env } = await build({ site: NO_SITE, - headError: new TypeError('Network connection lost'), + busError: new TypeError('Network connection lost'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -788,12 +882,11 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // one service answers both reads, so an outage fails them together, and the store it could - // not name is the more useful of the two failures - it('reports the undetermined store when the head read failed with it', async () => { + // whether there is a site to read at all is the question the other two rest on + it('reports the failed site lookup when the probe failed with it', async () => { const { daSourceGet, env } = await build({ site: undefined, - headError: new TypeError('Network connection lost'), + busError: new TypeError('Network connection lost'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -825,7 +918,7 @@ describe('reading from the store that holds the site', () => { await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(seen.heads, 1); + assert.strictEqual(seen.lookups, 1); assert.deepStrictEqual(seen.aem, []); }); @@ -838,24 +931,39 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // nothing composes an image, and a config service that is down would answer the 503 the - // handler prefers over the AEM proxy's answer - it('reads nothing for an asset', async () => { + // one read of the config service carries both the existence answer and head.html, so a page + // that needs the head pays for no second read + it('reads the config service once for a page', 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.lookups, 1); + assert.strictEqual(seen.pings, 1); + }); + + // nothing composes an image, and the head that arrives with the existence answer is dropped + it('reads the same two lookups for an asset, and no more', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(seen.heads, 0); + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.pings, 1); + assert.deepStrictEqual(seen.head, []); }); - it('reads nothing on a HEAD', async () => { + it('reads the same two lookups on a HEAD, and no more', 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.heads, 0); + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.pings, 1); + assert.deepStrictEqual(seen.head, []); }); }); @@ -973,60 +1081,6 @@ describe('reading from the store that holds the site', () => { assert.match(await res.text(), /importmap/); }); }); - - // a throw out of the head read used to reach the worker's catch as a 500 with no body - describe('when the head read cannot be reached', () => { - const dead = () => new TypeError('Network connection lost'); - - it('answers 503 rather than throwing', async () => { - const { daSourceGet, env } = await build({ headError: dead() }); - 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('names the cause in x-error', async () => { - const { daSourceGet, env } = await build({ headError: dead() }); - 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'), 'page head failed: TypeError: Network connection lost'); - }); - - it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ - headError: new DOMException('The operation 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.ok(Number(res.headers.get('Retry-After')) > 0); - }); - - // says what happened, since the preview iframe renders the refusal - it('says the page head did not arrive, not that the store is undetermined', async () => { - const { daSourceGet, env } = await build({ headError: dead() }); - 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(), messages.HEAD_UNREACHABLE_HTML_MESSAGE); - }); - - // a 404 would say the site is gone, which is #258 again, this time on the head read - it('does not answer 404', async () => { - const { daSourceGet, env } = await build({ headError: dead() }); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); - - const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - - assert.notStrictEqual(res.status, 404); - }); - }); }); describe('when the site config cannot be reached', () => { @@ -1091,9 +1145,9 @@ describe('when the site config cannot be reached', () => { }); }); -// getSiteHead is not stubbed here: a stub hides a config service that answers, but with -// something other than a head -describe('when the config service refuses the head read', () => { +// site.js is not stubbed here: a stub hides a config service that answers, but with something +// other than a config +describe('when the config service refuses the lookup', () => { afterEach(() => { delete globalThis.fetch; }); @@ -1108,39 +1162,38 @@ describe('when the config service refuses the head read', () => { daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/site.js': { default: async () => LEGACY_STORE }, + '../../src/storage/source-bus.js': { default: async () => false }, }); // a preview host rather than a UE host, so nothing is instrumented onto the composed page const req = authedReq('https://main--site--org.preview.da.live/folder/content'); return daSourceGet({ req, env, daCtx: getDaCtx(req) }); }; - // a 200 with no head is a page with no stylesheet, no scripts and no entry script - it('refuses a 500 with 503 rather than composing an empty project head', async () => { + // reading a refusal as a missing site would 404 a page that exists, which is #258 again + it('refuses a 500 with 503 rather than calling the site missing', async () => { const res = await readWithConfigStatus(500); assert.strictEqual(res.status, 503); }); - it('names the page head in x-error', async () => { + it('names the site lookup in x-error', async () => { const res = await readWithConfigStatus(500); - assert.match(res.headers.get('x-error'), /page head failed/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - // the shared secret is the worker's own, so a 401 is a deploy without it rather than a site - // that has no head.html + // the shared secret is the worker's own, so a 401 is a deploy without it rather than an answer it('refuses a 401 with 503', async () => { const res = await readWithConfigStatus(401); assert.strictEqual(res.status, 503); }); - // a ref that was never built has no head.html, which is not a failure - it('composes the page without a head on a 404', async () => { + // the one status that is an answer: the config service knows of no such site + it('answers 404 on a 404', async () => { const res = await readWithConfigStatus(404); - assert.strictEqual(res.status, 200); + assert.strictEqual(res.status, 404); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index cd0b7297..45a8349f 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -19,10 +19,9 @@ import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../.. const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; -// what the site lookup answers -const SOURCE_BUS = { exists: true, onSourceBus: true }; -const LEGACY_STORE = { exists: true, onSourceBus: false }; -const NO_SITE = { exists: false, onSourceBus: false }; +// what /ping answers +const SOURCE_BUS = true; +const LEGACY_STORE = false; /** The shape the Universal Editor Service posts: a `data` blob in a multipart form. */ const uePost = (url, html = DOC) => { @@ -32,12 +31,10 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201 } = overrides; - // `in overrides` rather than a destructured default, so an explicit undefined reaches here - const site = 'site' in overrides ? overrides.site : LEGACY_STORE; - const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); + const { status = 201, busError } = overrides; + const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; const seen = { - bus: [], legacy: [], lookups: 0, order: [], + bus: [], legacy: [], lookups: 0, probes: 0, order: [], }; const capture = async (request) => { const contentType = request.headers.get('Content-Type'); @@ -73,13 +70,20 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { + // a write asks which store, and nothing else, so a call here is the regression '../../src/storage/site.js': { default: async () => { seen.lookups += 1; seen.order.push('lookup'); - // throws for undefined, which is how the lookup reports a failure - if (site === undefined) throw lookupError; - return site; + return { exists: true, head: undefined }; + }, + }, + '../../src/storage/source-bus.js': { + default: async () => { + seen.probes += 1; + seen.order.push('probe'); + if (busError) throw busError; + return onSourceBus; }, }, }); @@ -133,9 +137,11 @@ describe('writing to the store that holds the site', () => { // 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 the lookup cannot say which store holds the site', () => { + describe('when the probe cannot say which store holds the site', () => { + const dead = () => new TypeError('fetch failed'); + it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ site: undefined }); + const { res, seen } = await post({ busError: dead() }); assert.strictEqual(res.status, 503); assert.strictEqual(seen.bus.length, 0); @@ -143,41 +149,40 @@ describe('writing to the store that holds the site', () => { }); it('asks the caller to retry, unlike the source-bus refusal', async () => { - const { res } = await post({ site: undefined }); + const { res } = await post({ busError: dead() }); assert.ok(Number(res.headers.get('Retry-After')) > 0); }); it('says which of the two refusals it is', async () => { - const { res } = await post({ site: undefined }); + const { res } = await post({ busError: dead() }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); - it('names the failed lookup in x-error', async () => { - const { res } = await post({ site: undefined }); + it('names the failed probe in x-error', async () => { + const { res } = await post({ busError: dead() }); - assert.match(res.headers.get('x-error'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /store lookup failed/); }); - it('names the lookup cause, not a category', async () => { + it('names the cause, not a category', async () => { const { res } = await post({ - site: undefined, - lookupError: new DOMException('timed out', 'TimeoutError'), + busError: new DOMException('timed out', 'TimeoutError'), }); - assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: TimeoutError: timed out'); }); }); - // a 404 from the config service says there is no AEM site config, not that the DA org and site - // are bogus. a read of the same path answers 404, so the editor cannot reach this state - describe('a site the lookup says does not exist', () => { - it('is written to da-admin all the same', async () => { - const { res, seen } = await post({ site: NO_SITE }); + // whether the site exists changes nothing about where a write goes, and a read of the same path + // answers 404 first, so the editor cannot reach this state with a site that is not there + describe('what a write asks about the site', () => { + it('asks which store, and not whether the site exists', async () => { + const { res, seen } = await post({}); - assert.strictEqual(seen.bus.length, 0); - assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(seen.probes, 1); + assert.strictEqual(seen.lookups, 0); assert.strictEqual(res.status, 201); }); }); @@ -269,13 +274,13 @@ describe('writing to the store that holds the site', () => { it('happens before anything is sent to a store', async () => { const { seen } = await post({}); - assert.deepStrictEqual(seen.order, ['lookup', 'store']); + assert.deepStrictEqual(seen.order, ['probe', 'store']); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { const { seen } = await post({ site: SOURCE_BUS }); - assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.probes, 1); }); }); diff --git a/test/storage/site.test.js b/test/storage/site.test.js index cf69ec49..6f61b3fb 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -13,7 +13,7 @@ /* eslint-env mocha */ import assert from 'assert'; -const { default: getSite, getSiteHead } = await import('../../src/storage/site.js'); +const { default: getSite } = await import('../../src/storage/site.js'); const env = { AEM_API: 'https://api.aem.live', @@ -35,21 +35,14 @@ const stubFetch = (respond) => { }; }; -// has the two fields the lookup reads; the service also answers with admin roles and secrets -const config = (sourceUrl) => () => new Response( - JSON.stringify({ content: { source: { url: sourceUrl, type: 'markup' } } }), - { status: 200 }, -); -const legacy = config('https://content.da.live/org/site/'); -const sourceBus = config('https://api.aem.live/org/sites/site/'); -const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); - const STYLESHEET = ''; // the pipeline scope carries the code bus object, under a lastModified the delivery pipeline reads const withHead = (html) => () => new Response( JSON.stringify({ head: { lastModified: 'Mon, 30 Mar 2026 06:42:40 GMT', html } }), { status: 200 }, ); +const found = withHead(STYLESHEET); +const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); describe('getSite', () => { afterEach(() => { @@ -57,25 +50,26 @@ describe('getSite', () => { }); describe('the request it makes', () => { - it('asks the config service for the admin-scoped site config', async () => { - stubFetch(legacy); + it('asks the config service for the pipeline-scoped config', async () => { + stubFetch(found); await getSite(env, daCtx()); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=admin'); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); }); it('takes the config service from env, so dev can point elsewhere', async () => { - stubFetch(legacy); + stubFetch(found); await getSite({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); - assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=admin'); + assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=pipeline'); }); + // the code bus holds one head.html per ref, so a branch gets its own it('names the ref the request came in on', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx({ ref: 'feature' })); @@ -83,7 +77,7 @@ describe('getSite', () => { }); it('sends the shared token', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx()); @@ -92,7 +86,7 @@ describe('getSite', () => { // the edge answers 400 without it, and that failure reads like a bad path it('sends the backend type', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx()); @@ -101,7 +95,7 @@ describe('getSite', () => { // the author's token has no business at a service-to-service endpoint it('sends no author token', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx()); @@ -109,50 +103,43 @@ describe('getSite', () => { }); it('gives up rather than hanging', async () => { - stubFetch(legacy); + stubFetch(found); await getSite(env, daCtx()); assert.ok(calls[0].init.signal); }); - }); - - describe('which store holds the site', () => { - it('reads the source bus off the content source url', async () => { - stubFetch(sourceBus); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, onSourceBus: true }); - }); + // the admin scope answers with the site's CDN token and its API key metadata, and one read + // covers both questions this asks + it('reads one scope, and not the admin one', async () => { + stubFetch(found); - it('reads a da-admin site as legacy', async () => { - stubFetch(legacy); + await getSite(env, daCtx()); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, onSourceBus: false }); + assert.strictEqual(calls.length, 1); + assert.doesNotMatch(calls[0].url, /scope=admin/); }); + }); - it('tolerates a trailing slash on the configured source bus', async () => { - stubFetch(sourceBus); - - const site = await getSite({ ...env, AEM_API: 'https://api.aem.live/' }, daCtx()); + describe('the head it answers', () => { + it('reads head.html out of the config', async () => { + stubFetch(found); - assert.strictEqual(site.onSourceBus, true); + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: STYLESHEET }); }); - // a prefix match on the bare string would take api.aem.live.evil.example for the source bus - it('does not take a lookalike host for the source bus', async () => { - stubFetch(config('https://api.aem.live.evil.example/org/sites/site/')); - - const site = await getSite(env, daCtx()); + // a ref with no head.html on the code bus answers 200 with an empty head, not a 404 + it('answers a site with no head.html for the ref', async () => { + stubFetch(() => new Response(JSON.stringify({ head: {} }), { status: 200 })); - assert.strictEqual(site.onSourceBus, false); + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: undefined }); }); - it('reads a config with no content source as legacy', async () => { + it('answers a site whose config carries no head at all', async () => { stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); - const site = await getSite(env, daCtx()); - - assert.strictEqual(site.onSourceBus, false); + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: undefined }); }); }); @@ -160,7 +147,7 @@ describe('getSite', () => { it('says so on a 404', async () => { stubFetch(absent); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: false, onSourceBus: false }); + assert.deepStrictEqual(await getSite(env, daCtx()), { exists: false, head: undefined }); }); // an unparseable hostname leaves org and site undefined, so there is nothing to ask about @@ -174,7 +161,7 @@ describe('getSite', () => { }); }); - // a refusal leaves both answers unknown, and a guess reads the wrong store at 200 + // a refusal leaves existence unknown, and reading that as a missing site 404s a live page describe('when the lookup cannot answer', () => { [401, 403, 429, 500, 502].forEach((status) => { it(`throws on a ${status}`, async () => { @@ -192,13 +179,14 @@ describe('getSite', () => { await assert.rejects(() => getSite(env, daCtx()), /fetch failed/); }); + // a worker deployed without the secret and a rate limit share the status and the body, so + // `x-error` is what tells them apart it('names the status it got', async () => { stubFetch(() => new Response('', { status: 401 })); await assert.rejects(() => getSite(env, daCtx()), /401/); }); - // reads a 200 with an error page as a store it does not know, not as a legacy site it('throws when the body is not JSON', async () => { stubFetch(() => new Response('the edge said no', { status: 200 })); @@ -207,129 +195,10 @@ describe('getSite', () => { // a misconfigured worker gets no answer, and calling that a missing site would 404 the pages it('throws when the config service host is missing, without asking', async () => { - stubFetch(legacy); + stubFetch(found); await assert.rejects(() => getSite({ ...env, HLX_CONFIG_SERVICE: undefined }, daCtx())); assert.strictEqual(calls.length, 0); }); }); }); - -describe('getSiteHead', () => { - afterEach(() => { - delete globalThis.fetch; - }); - - describe('the request it makes', () => { - it('asks the config service for the pipeline-scoped config', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx()); - - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); - }); - - // the code bus holds one head.html per ref, so a branch gets its own - it('names the ref the request came in on', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx({ ref: 'feature' })); - - assert.match(calls[0].url, /\/feature--site--org\//); - }); - - it('sends the shared token', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx()); - - assert.strictEqual(calls[0].init.headers['x-access-token'], 'shared-token'); - }); - - it('sends the backend type', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx()); - - assert.strictEqual(calls[0].init.headers['x-backend-type'], 'aws'); - }); - - it('gives up rather than hanging', async () => { - stubFetch(withHead(STYLESHEET)); - - await getSiteHead(env, daCtx()); - - assert.ok(calls[0].init.signal); - }); - }); - - describe('the head it answers', () => { - it('reads head.html out of the config', async () => { - stubFetch(withHead(STYLESHEET)); - - assert.strictEqual(await getSiteHead(env, daCtx()), STYLESHEET); - }); - - // a ref with no head.html on the code bus answers 200 with an empty head, not a 404 - it('answers nothing when the ref has no head.html', async () => { - stubFetch(() => new Response(JSON.stringify({ head: {} }), { status: 200 })); - - assert.strictEqual(await getSiteHead(env, daCtx()), undefined); - }); - - it('answers nothing when the config carries no head at all', async () => { - stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); - - assert.strictEqual(await getSiteHead(env, daCtx()), undefined); - }); - - // the lookup answers 404 with the site, and one 404 page is enough - it('answers nothing on a 404', async () => { - stubFetch(absent); - - assert.strictEqual(await getSiteHead(env, daCtx()), undefined); - }); - - it('asks nothing when there is no org or site', async () => { - stubFetch(absent); - - assert.strictEqual(await getSiteHead(env, daCtx({ site: undefined })), undefined); - assert.strictEqual(calls.length, 0); - }); - }); - - // the token is the worker's own, so a refusal is a broken worker rather than a site with no - // head.html, and composing an empty project head would serve that as a page - describe('when the read cannot answer', () => { - [401, 403, 429, 500, 502].forEach((status) => { - it(`throws on a ${status}`, async () => { - stubFetch(() => new Response('', { status })); - - await assert.rejects(() => getSiteHead(env, daCtx()), /502|500|429|403|401/); - }); - }); - - it('throws when the config service cannot be reached', async () => { - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - - await assert.rejects(() => getSiteHead(env, daCtx()), /fetch failed/); - }); - - // a worker deployed without the secret and a rate limit share the status and the body, so - // `x-error` is what tells them apart - it('names the status it got', async () => { - stubFetch(() => new Response('', { status: 401 })); - - await assert.rejects(() => getSiteHead(env, daCtx()), /401/); - }); - - it('throws when the body is not JSON', async () => { - stubFetch(() => new Response('the edge said no', { status: 200 })); - - await assert.rejects(() => getSiteHead(env, daCtx())); - }); - }); -}); 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); + }); +}); From 4be8875136144f12443a71be379885f62801a01d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 17:37:29 +0200 Subject: [PATCH 12/77] fix: ask /ping which store holds the site, and the config service what exists green. the pipeline scope answers existence and head.html in one read, so the admin scope goes, and with it the CDN token and api key metadata the worker was reading. a write asks /ping only. --- README.md | 6 +- dev/{config-shim.js => lookup-shim.js} | 36 +++++++--- dev/{config-shim.toml => lookup-shim.toml} | 4 +- package.json | 2 +- src/routes/da-admin.js | 57 ++++++++------- src/storage/site.js | 82 ++++++---------------- src/storage/source-bus.js | 25 ++++++- src/utils/constants.js | 2 +- src/utils/upstream.js | 2 +- wrangler.toml | 6 +- 10 files changed, 113 insertions(+), 109 deletions(-) rename dev/{config-shim.js => lookup-shim.js} (60%) rename dev/{config-shim.toml => lookup-shim.toml} (82%) diff --git a/README.md b/README.md index ad1094c0..d3450162 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,20 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. -Site lookups go to config.aem.page, which needs a shared secret, so local development points at `dev/config-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404. +A read looks the site up twice: config.aem.page says whether it exists and carries its head.html, and admin.hlx.page/ping says which store holds it. The config service needs a shared secret, so local development points both at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. To run da-universal locally: 1. Clone this repo to your computer. 1. Run `npm install` 1. Use `npx wrangler login` if not done before. Walk through the steps in browser. -1. In a terminal, run `npm run dev:config` to start the stand-in config service on port 4713. +1. In a terminal, run `npm run dev:lookups` to start the stand-in lookups on port 4713. 1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. -Anyone who has the shared secret can point `npm run dev` at config.aem.page instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. +Anyone who has the shared secret can point `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page --var HLX_ADMIN:https://admin.hlx.page`. ### Run on stage diff --git a/dev/config-shim.js b/dev/lookup-shim.js similarity index 60% rename from dev/config-shim.js rename to dev/lookup-shim.js index 734f0cf8..dffce369 100644 --- a/dev/config-shim.js +++ b/dev/lookup-shim.js @@ -10,35 +10,49 @@ * governing permissions and limitations under the License. */ -// stands in for config.aem.page, which needs a shared secret -// answers 200 for a site in SITES, 404 for anything else +// stands in for the two lookups a read makes: config.aem.page, which needs a shared secret, and +// admin.hlx.page/ping. A site in SITES exists, and the source url is what makes it source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', }; +const SOURCE_BUS = 'https://api.aem.live/'; + // what the code bus holds at {owner}/{repo}/{ref}/head.html, which the pipeline scope carries const HEAD_HTML = '\n\n'; +/** + * Answers /ping the way helix-admin does: the header is set when the site's content source is the + * source bus, and a site it cannot resolve gets a 200 with no header rather than a 404. + */ +function ping(org, site) { + const headers = SITES[`${org}/${site}`]?.startsWith(SOURCE_BUS) + ? { 'x-api-upgrade-available': 'true' } + : {}; + return new Response('', { status: 200, headers }); +} + export default { async fetch(req) { const url = new URL(req.url); + + if (url.pathname.startsWith('/ping/')) { + const [, , pingOrg, pingSite] = url.pathname.split('/'); + return ping(pingOrg, pingSite); + } + const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); if (!org || !site) { return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); } - const source = SITES[`${org}/${site}`]; - if (!source) { + if (!SITES[`${org}/${site}`]) { return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); } - const body = url.searchParams.get('scope') === 'pipeline' - ? JSON.stringify({ - ref, site, org, head: { html: HEAD_HTML }, - }) - : JSON.stringify({ - ref, site, org, content: { source: { type: 'markup', url: source } }, - }); + const body = JSON.stringify({ + ref, site, org, head: { html: HEAD_HTML }, + }); return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); }, }; diff --git a/dev/config-shim.toml b/dev/lookup-shim.toml similarity index 82% rename from dev/config-shim.toml rename to dev/lookup-shim.toml index d3c4fa48..79e3d4a2 100644 --- a/dev/config-shim.toml +++ b/dev/lookup-shim.toml @@ -1,7 +1,7 @@ # the stand-in gets its own config, so wrangler dev does not read wrangler.toml: no daadmin # binding to connect, and no HLX_CONFIG_SERVICE_TOKEN to warn about -name = "da-ue-config-shim" -main = "config-shim.js" +name = "da-ue-lookup-shim" +main = "lookup-shim.js" compatibility_date = "2023-11-21" [dev] diff --git a/package.json b/package.json index 634708dc..883d9923 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "deploy": "wrangler deploy", "deploy:stage": "wrangler deploy --env stage", "dev": "wrangler dev --local-protocol https --env dev", - "dev:config": "wrangler dev -c dev/config-shim.toml", + "dev:lookups": "wrangler dev -c dev/lookup-shim.toml", "start": "wrangler dev --local-protocol https --env dev", "test": "c8 mocha --spec=test/**/*.test.js", "lint": "eslint ." diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index e0643de9..7d80c2e7 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -26,7 +26,7 @@ import { } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, - HEAD_UNREACHABLE_HTML_MESSAGE, + SITE_UNREACHABLE_HTML_MESSAGE, PREVIEW_UNREACHABLE_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, @@ -37,15 +37,16 @@ import { UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; -import getSite, { getSiteHead } from '../storage/site.js'; +import getSite from '../storage/site.js'; +import isSourceBus from '../storage/source-bus.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { CONTENT_STORE, - PAGE_HEAD, PREVIEW_HOST, SITE_CONFIG, SITE_LOOKUP, + STORE_LOOKUP, UpstreamError, reach, } from '../utils/upstream.js'; @@ -58,10 +59,10 @@ const HTML_POST_TYPE = 'text/html'; */ const UNREACHABLE_HTML = { [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, - [SITE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, - [PAGE_HEAD]: HEAD_UNREACHABLE_HTML_MESSAGE, + [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, + [STORE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, }; -const UNREACHABLE_TEXT = { [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; +const UNREACHABLE_TEXT = { [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; /** * Only an upstream that could not be reached is retryable. Anything else reaches the worker @@ -128,16 +129,29 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + // two services, one round trip: config.aem.page says whether the site exists and what its + // head.html is, /ping says which store holds it + const [site, onSourceBus] = await Promise.allSettled([ + reach(SITE_LOOKUP, () => getSite(env, daCtx)), + reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), + ]); - if (!site.exists) { + // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. + // drops a failed probe on purpose: a site that does not exist is held by no store + if (site.status === 'fulfilled' && !site.value.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); return { noSuchSite: true }; } + // the site lookup first: whether there is anything to read at all is what the other two rest on + if (site.status === 'rejected') throw site.reason; + if (onSourceBus.status === 'rejected') throw onSourceBus.reason; - const store = getStore(env, daCtx, site.onSourceBus); + const store = getStore(env, daCtx, onSourceBus.value); console.log(`-> ${init.method} ${store.url.toString()}`); - return { response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)) }; + return { + response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)), + head: site.value.head, + }; } async function sourceGet({ req, env, daCtx }) { @@ -168,17 +182,14 @@ async function sourceGet({ req, env, daCtx }) { return response; } - // runs the lookup alongside the head read, since it costs a round trip - // settles both rather than racing, so a failed head read cannot preempt the no-such-site 404 const aemCtx = getAemCtx(env, daCtx); - const [head, source] = await Promise.allSettled([ - reach(PAGE_HEAD, () => getSiteHead(env, daCtx)), - readSource(env, daCtx, { method: 'GET', headers }), - ]); + const { response: sourceResp, noSuchSite, head: headHtml } = await readSource( + env, + daCtx, + { method: 'GET', headers }, + ); - // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. - // drops the head failure on purpose: a site that does not exist has no head.html either - if (source.status === 'fulfilled' && source.value.noSuchSite) { + if (noSuchSite) { // quick-edit still needs a working shell (with the import map) so the editor // can load into this page, even when the site does not exist. if (isQuickEdit) { @@ -186,13 +197,7 @@ async function sourceGet({ req, env, daCtx }) { } return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - // the lookup first: one service answers both reads, and the store it could not name is the - // more useful of the two failures - if (source.status === 'rejected') throw source.reason; - if (head.status === 'rejected') throw head.reason; - const headHtml = head.value; - const { response: sourceResp } = source.value; console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); // the store is the only thing to see the token, and the authorbus extension recovers off the @@ -308,7 +313,7 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); // the payload is settled, so the only question left is where it goes - const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + const onSourceBus = await reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)); 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.`); diff --git a/src/storage/site.js b/src/storage/site.js index 230ef6c8..bfe493ac 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -11,79 +11,43 @@ */ const TIMEOUT_MS = 5 * 1000; -const NO_SITE = { exists: false, onSourceBus: false }; - -/** One read of the config service, at the scope the caller needs. */ -function askConfigService(env, daCtx, scope) { - const { org, site, ref } = daCtx; - const url = new URL(`/${ref}--${site}--${org}/config.json?scope=${scope}`, env.HLX_CONFIG_SERVICE); - return fetch(url, { - headers: { - 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, - 'x-backend-type': 'aws', - }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); -} +const NO_SITE = { exists: false, head: undefined }; /** - * Asks the config service whether a site exists and which store holds its content. + * Asks the config service whether a site exists, and reads its head.html from the same answer. * - * `content.source.url` decides the store, and helix-admin sets `x-api-upgrade-available` from - * the same field, so a request to /ping would say the same thing. + * The pipeline scope carries the code bus object the delivery pipeline renders into every page of + * the site. Reading it here rather than from `{ref}--{site}--{org}.aem.page/head.html` answers for + * a site behind Helix authentication, which refuses that path without a site token. The admin + * scope would answer existence too, and carries the site's CDN token and API key metadata with it. * - * Throws on any refusal but a 404, which is the only status that means there is no such site. - * Reads the status and the source url only, since the response also has admin roles and - * resolved secrets. + * Throws on any refusal but a 404, which is the only status that means there is no such site. A + * ref that was never built exists and has no head.html, which is a 200 with an empty head. * - * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, - * `HLX_CONFIG_SERVICE_TOKEN` authorizes it, `AEM_API` is the source bus the source url is - * compared against + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes and + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it * @param {Object} daCtx - * @returns {Promise<{exists: boolean, onSourceBus: boolean}>} + * @returns {Promise<{exists: boolean, head: string|undefined}>} */ export default async function getSite(env, daCtx) { - const { org, site } = daCtx; + const { + org, site, ref, + } = daCtx; // an unparseable hostname leaves org and site undefined, and there is no site to ask about if (!org || !site) return NO_SITE; - const response = await askConfigService(env, daCtx, 'admin'); + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=pipeline`, env.HLX_CONFIG_SERVICE); + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); if (response.status === 404) return NO_SITE; if (!response.ok) throw new Error(`the config service answered ${response.status}`); - const { content } = await response.json(); - // the bare prefix would also match a host like api.aem.live.evil.example - const sourceBus = new URL('/', env.AEM_API).href; - return { exists: true, onSourceBus: !!content?.source?.url?.startsWith(sourceBus) }; -} - -/** - * Reads the site's head.html from the config service. - * - * The pipeline scope carries the code bus object the delivery pipeline renders into every page of - * the site, and the admin scope does not carry it at all. Reading it here rather than from - * `{ref}--{site}--{org}.aem.page/head.html` answers for a site behind Helix authentication, which - * refuses that path without a site token. - * - * Throws on any refusal but a 404. The token is the worker's own, so a refusal is a deploy - * without it rather than a site that has no head.html. - * - * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the read goes and - * `HLX_CONFIG_SERVICE_TOKEN` authorizes it - * @param {Object} daCtx - * @returns {Promise} undefined when the ref has no head.html - */ -export async function getSiteHead(env, daCtx) { - const { org, site } = daCtx; - if (!org || !site) return undefined; - - const response = await askConfigService(env, daCtx, 'pipeline'); - - // getSite answers the missing site, and one 404 for the page is enough - if (response.status === 404) return undefined; - if (!response.ok) throw new Error(`the config service answered ${response.status}`); - const { head } = await response.json(); - return head?.html; + return { exists: true, head: head?.html }; } diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js index b77223b2..7d4f98ea 100644 --- a/src/storage/source-bus.js +++ b/src/storage/source-bus.js @@ -10,6 +10,27 @@ * governing permissions and limitations under the License. */ -export default async function isSourceBus() { - throw new Error('not implemented'); +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, and a Fastly edge dictionary sets it for a site being moved onto the new API + * ahead of its content. 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/utils/constants.js b/src/utils/constants.js index 57ca8c2c..ab505f6f 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -53,7 +53,7 @@ export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found< export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry.

'; -export const HEAD_UNREACHABLE_HTML_MESSAGE = '

503: Page head unreachable

The site\'s head.html could not be read. Please retry.

'; +export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup unreachable

Whether this site exists could not be determined. Please retry.

'; export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

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

'; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index 66ab125b..f1cffe3a 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -15,7 +15,7 @@ export const PREVIEW_HOST = 'preview host'; export const CONTENT_STORE = 'content store'; export const SITE_CONFIG = 'site config'; export const SITE_LOOKUP = 'site lookup'; -export const PAGE_HEAD = 'page head'; +export const STORE_LOOKUP = 'store lookup'; /** * Renders a failure for the `x-error` header. diff --git a/wrangler.toml b/wrangler.toml index 39d66735..def67d4c 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,7 +2,7 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin" }] secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } @@ -10,14 +10,14 @@ secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } port = 4712 [env.dev] -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_CONFIG_SERVICE = "http://localhost:4713" } +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 = "http://localhost:4713", HLX_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] # the list is also what wrangler binds from .dev.vars.dev, so an empty one leaves the worker # with no token and every lookup 401s. running against the stand-in warns that it is missing secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [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", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } +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", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin-stage" }] secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } From dfe6d96063b2657d4c3be61d76a17cedeb3df09d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:07:25 +0200 Subject: [PATCH 13/77] test: a /ping that refuses is no answer, not a legacy site red. header-absent-on-5xx read as legacy, which sends a source-bus write to da-admin where nothing serves it back. --- test/storage/source-bus.test.js | 46 +++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js index 8093d3fd..f3d81b10 100644 --- a/test/storage/source-bus.test.js +++ b/test/storage/source-bus.test.js @@ -102,8 +102,8 @@ describe('isSourceBus', () => { }); }); - // 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 + // 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)); @@ -113,19 +113,39 @@ describe('isSourceBus', () => { }); }); - 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 answered 200 without the header is the legacy answer. helix-admin sets the header from + // the site's content source, and /ping is 200 for a site it routes at all + describe('when /ping says the site is legacy', () => { + it('answers false on a 200 with no header', async () => { + stubFetch(() => ping()); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + }); + }); + + // a refusal carries no decision, and reading it as legacy sends a source-bus write to da-admin, + // where nothing serves it back + describe('when /ping refuses without the header', () => { + [404, 405, 429, 500, 503].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => ping({}, status)); + + await assert.rejects(() => isSourceBus(env, daCtx()), /404|405|429|500|503/); }); }); + + // the header is read first, so an enrolled site survives an origin the edge is shielding + it('answers true on a 429 that still carries the header', async () => { + stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, 429)); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + + it('names the status it got', async () => { + stubFetch(() => ping({}, 503)); + + await assert.rejects(() => isSourceBus(env, daCtx()), /503/); + }); }); // an answer without the header is legacy. no answer is not an answer, and the caller refuses From 5f0ff03beb07df32db0611d7160117d71ce542c2 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:08:41 +0200 Subject: [PATCH 14/77] fix: refuse when /ping cannot answer, instead of reading it as legacy the header is read ahead of the status, so the edge dictionary still answers for an origin that is rate limited. a refusal without it now throws. --- dev/lookup-shim.js | 7 ++++--- src/routes/da-admin.js | 6 +++--- src/storage/site.js | 8 ++++---- src/storage/source-bus.js | 17 ++++++++++++----- test/routes/source-read.test.js | 10 +++++----- test/routes/source-write.test.js | 2 +- test/storage/site.test.js | 6 +++--- 7 files changed, 32 insertions(+), 24 deletions(-) diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js index dffce369..f73852f4 100644 --- a/dev/lookup-shim.js +++ b/dev/lookup-shim.js @@ -11,19 +11,20 @@ */ // stands in for the two lookups a read makes: config.aem.page, which needs a shared secret, and -// admin.hlx.page/ping. A site in SITES exists, and the source url is what makes it source-bus +// admin.hlx.page/ping. A site in SITES exists, and one with a source url on api.aem.live is +// source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', }; const SOURCE_BUS = 'https://api.aem.live/'; -// what the code bus holds at {owner}/{repo}/{ref}/head.html, which the pipeline scope carries +// what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with const HEAD_HTML = '\n\n'; /** * Answers /ping the way helix-admin does: the header is set when the site's content source is the - * source bus, and a site it cannot resolve gets a 200 with no header rather than a 404. + * source bus, and a site it cannot resolve is answered 200 with no header rather than 404. */ function ping(org, site) { const headers = SITES[`${org}/${site}`]?.startsWith(SOURCE_BUS) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 7d80c2e7..138ea4f7 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -129,7 +129,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - // two services, one round trip: config.aem.page says whether the site exists and what its + // both lookups go out together: config.aem.page says whether the site exists and what its // head.html is, /ping says which store holds it const [site, onSourceBus] = await Promise.allSettled([ reach(SITE_LOOKUP, () => getSite(env, daCtx)), @@ -137,12 +137,12 @@ async function readSource(env, daCtx, init) { ]); // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. - // drops a failed probe on purpose: a site that does not exist is held by no store + // drops a failed probe on purpose: a site that does not exist needs no store if (site.status === 'fulfilled' && !site.value.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); return { noSuchSite: true }; } - // the site lookup first: whether there is anything to read at all is what the other two rest on + // the site lookup first, since the store answer is no use on its own if (site.status === 'rejected') throw site.reason; if (onSourceBus.status === 'rejected') throw onSourceBus.reason; diff --git a/src/storage/site.js b/src/storage/site.js index bfe493ac..da0be002 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -16,10 +16,10 @@ const NO_SITE = { exists: false, head: undefined }; /** * Asks the config service whether a site exists, and reads its head.html from the same answer. * - * The pipeline scope carries the code bus object the delivery pipeline renders into every page of - * the site. Reading it here rather than from `{ref}--{site}--{org}.aem.page/head.html` answers for - * a site behind Helix authentication, which refuses that path without a site token. The admin - * scope would answer existence too, and carries the site's CDN token and API key metadata with it. + * The pipeline scope has the code bus object the delivery pipeline renders into every page of the + * site. A site behind Helix authentication refuses `{ref}--{site}--{org}.aem.page/head.html` to a + * request with no site token, and the config service does not. The admin scope answers existence + * too, and its answer has the site's CDN token and API key metadata. * * Throws on any refusal but a 404, which is the only status that means there is no such site. A * ref that was never built exists and has no head.html, which is a 200 with an empty head. diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js index 7d4f98ea..62d113fd 100644 --- a/src/storage/source-bus.js +++ b/src/storage/source-bus.js @@ -16,10 +16,14 @@ 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, and a Fastly edge dictionary sets it for a site being moved onto the new API - * ahead of its content. A probe that cannot answer throws, so the caller refuses with the cause - * rather than picking a store. + * The header is the answer, and da-nx checks the same header for presence in `isHlx6`. It is read + * ahead of the status, since a Fastly edge dictionary sets it in front of an origin that may be + * rate limited or erroring. A refusal without it is no answer at all. Reading a refusal as legacy + * would send a source-bus write to da-admin, where nothing serves it back, so the read fails. + * + * One answer is ambiguous, and this worker cannot resolve it. helix-admin sets the header from the + * site's content source and swallows a config service failure, so a 200 with no header is either a + * legacy site or an origin that could not resolve one. * * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes * @param {Object} daCtx @@ -32,5 +36,8 @@ export default async function isSourceBus(env, daCtx) { 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; + + if (response.headers.get(UPGRADE_HEADER) !== null) return true; + if (!response.ok) throw new Error(`/ping answered ${response.status}`); + return false; } diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index fc448600..c4732a1b 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -20,8 +20,8 @@ import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); -// what the two lookups answer together: `exists` comes from the config service, `onSourceBus` -// from /ping +// what the two lookups answer between them: `exists` comes from the config service, +// `onSourceBus` from /ping const SOURCE_BUS = { exists: true, onSourceBus: true }; const LEGACY_STORE = { exists: true, onSourceBus: false }; const NO_SITE = { exists: false, onSourceBus: false }; @@ -882,7 +882,7 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // whether there is a site to read at all is the question the other two rest on + // both failed, and the store answer is no use on its own it('reports the failed site lookup when the probe failed with it', async () => { const { daSourceGet, env } = await build({ site: undefined, @@ -931,8 +931,8 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // one read of the config service carries both the existence answer and head.html, so a page - // that needs the head pays for no second read + // one read of the config service has both the existence answer and head.html, so a page that + // needs the head pays for no second read it('reads the config service once for a page', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 45a8349f..44a86809 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -70,7 +70,7 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - // a write asks which store, and nothing else, so a call here is the regression + // a write asks which store and nothing else, so anything reaching this mock is the bug '../../src/storage/site.js': { default: async () => { seen.lookups += 1; diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 6f61b3fb..1af6db51 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -36,7 +36,7 @@ const stubFetch = (respond) => { }; const STYLESHEET = ''; -// the pipeline scope carries the code bus object, under a lastModified the delivery pipeline reads +// the pipeline scope has the code bus object, with a lastModified the delivery pipeline reads const withHead = (html) => () => new Response( JSON.stringify({ head: { lastModified: 'Mon, 30 Mar 2026 06:42:40 GMT', html } }), { status: 200 }, @@ -67,7 +67,7 @@ describe('getSite', () => { assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=pipeline'); }); - // the code bus holds one head.html per ref, so a branch gets its own + // the code bus has one head.html per ref, so a branch gets its own it('names the ref the request came in on', async () => { stubFetch(found); @@ -111,7 +111,7 @@ describe('getSite', () => { }); // the admin scope answers with the site's CDN token and its API key metadata, and one read - // covers both questions this asks + // is enough for both questions this asks it('reads one scope, and not the admin one', async () => { stubFetch(found); From bd5bc3509cb0d06ff298d9723fa7bb09f5ff748f Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:48:16 +0200 Subject: [PATCH 15/77] test: a save is refused when the config service cannot answer red. /ping answers 200 with no header for a source-bus site in exactly that window, since helix-admin reads the same config and swallows the failure. --- test/routes/source-write.test.js | 64 ++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 44a86809..c7fa1225 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -31,8 +31,9 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201, busError } = overrides; + const { status = 201, busError, lookupError } = overrides; const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; + const exists = 'exists' in overrides ? overrides.exists : true; const seen = { bus: [], legacy: [], lookups: 0, probes: 0, order: [], }; @@ -70,12 +71,12 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - // a write asks which store and nothing else, so anything reaching this mock is the bug '../../src/storage/site.js': { default: async () => { seen.lookups += 1; seen.order.push('lookup'); - return { exists: true, head: undefined }; + if (lookupError) throw lookupError; + return { exists, head: undefined }; }, }, '../../src/storage/source-bus.js': { @@ -175,14 +176,60 @@ describe('writing to the store that holds the site', () => { }); }); - // whether the site exists changes nothing about where a write goes, and a read of the same path - // answers 404 first, so the editor cannot reach this state with a site that is not there + // a config service that cannot answer is the window where /ping answers 200 with no header for + // a source-bus site: helix-admin sets the header off the same config and swallows the failure. + // so a read that cannot be made is a write that cannot be placed + describe('when the config service cannot answer', () => { + const dead = () => new TypeError('fetch failed'); + + it('is refused with 503 and touches neither store', async () => { + const { res, seen } = await post({ lookupError: dead() }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(seen.bus.length, 0); + assert.strictEqual(seen.legacy.length, 0); + }); + + it('asks the caller to retry', async () => { + const { res } = await post({ lookupError: dead() }); + + assert.ok(Number(res.headers.get('Retry-After')) > 0); + }); + + it('names the failed lookup in x-error', async () => { + const { res } = await post({ lookupError: dead() }); + + assert.match(res.headers.get('x-error'), /site lookup failed/); + }); + + // the probe answered, and it is the answer a save cannot be made without + it('is refused even when /ping said legacy', async () => { + const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(seen.legacy.length, 0); + }); + }); + + // a 404 says there is no AEM site config, not that the DA org and site are bogus. the service + // answered, so the store is known, and refusing here would stop saving on a DA-only site + describe('a site the config service does not know', () => { + it('is written to da-admin all the same', async () => { + const { res, seen } = await post({ exists: false }); + + assert.strictEqual(res.status, 201); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(seen.bus.length, 0); + }); + }); + describe('what a write asks about the site', () => { - it('asks which store, and not whether the site exists', async () => { + it('asks both lookups, and reaches the store after them', async () => { const { res, seen } = await post({}); assert.strictEqual(seen.probes, 1); - assert.strictEqual(seen.lookups, 0); + assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.order[seen.order.length - 1], 'store'); assert.strictEqual(res.status, 201); }); }); @@ -274,7 +321,8 @@ describe('writing to the store that holds the site', () => { it('happens before anything is sent to a store', async () => { const { seen } = await post({}); - assert.deepStrictEqual(seen.order, ['probe', 'store']); + assert.deepStrictEqual(seen.order.slice(-1), ['store']); + assert.ok(seen.order.includes('probe')); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { From a36dab8bc0c5d38c515e22c812e3ea1915325187 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:49:54 +0200 Subject: [PATCH 16/77] test: a save refused on the site lookup says the store is undetermined red. it answered with the store-did-not-answer text, and no store was asked. --- src/routes/da-admin.js | 20 +++++++++++++++----- test/index.test.js | 2 ++ test/routes/da-admin.test.js | 4 +++- test/routes/source-write.test.js | 8 ++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 138ea4f7..e2593a78 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -312,16 +312,26 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes - const onSourceBus = await reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)); - - if (onSourceBus) { + // the payload is settled, so the only question left is where it goes. /ping answers that, and + // the config service is read alongside it because helix-admin sets the /ping header off the + // same config and swallows a failure reading it: while the config service is down, a + // source-bus site answers 200 with no header and reads as legacy. so a config service that + // cannot answer refuses the save rather than misplacing it in da-admin, where nothing serves + // it back. a 404 is an answer, and it means no AEM site config rather than no DA site + const [site, onSourceBus] = await Promise.allSettled([ + reach(SITE_LOOKUP, () => getSite(env, daCtx)), + reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), + ]); + if (site.status === 'rejected') throw site.reason; + if (onSourceBus.status === 'rejected') throw onSourceBus.reason; + + if (onSourceBus.value) { 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 store = getStore(env, daCtx, onSourceBus.value); const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); diff --git a/test/index.test.js b/test/index.test.js index 0bdacba0..aa9c77d7 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -190,6 +190,8 @@ describe('worker fetch handler', () => { 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 }, + // a write reads both lookups, and this env carries no config service to reach + '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined }) }, })).default; const uePost = (origin) => { diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index a8e30904..fe7271ca 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -460,9 +460,11 @@ describe('daSourcePost', () => { await write('lookedupeach', env); await write('lookedupeach', env); - assert.deepStrictEqual(asked, [ + assert.deepStrictEqual(asked.sort(), [ 'https://admin.hlx.page/ping/org/lookedupeach', 'https://admin.hlx.page/ping/org/lookedupeach', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index c7fa1225..29da5cc6 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -202,6 +202,14 @@ describe('writing to the store that holds the site', () => { assert.match(res.headers.get('x-error'), /site lookup failed/); }); + // UES embeds this verbatim, and no store was asked: where the document goes is what could not + // be worked out + it('says the store could not be determined, not that it did not answer', async () => { + const { res } = await post({ lookupError: dead() }); + + assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); + }); + // the probe answered, and it is the answer a save cannot be made without it('is refused even when /ping said legacy', async () => { const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); From 47d66fa5ef0806e1374a27fddff954e1cc7813a2 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 12 Aug 2026 18:53:14 +0200 Subject: [PATCH 17/77] fix: refuse a save when the site lookup cannot answer the store answer comes from the same config, so an outage that hides one hides the other. a wrong store cannot be walked back from, and the 503 now names the destination as undetermined rather than the store as unreachable. --- src/routes/da-admin.js | 17 ++++++++++------- src/storage/source-bus.js | 4 ---- test/index.test.js | 2 +- test/routes/source-write.test.js | 13 ++++++------- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index e2593a78..85546b77 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -62,7 +62,12 @@ const UNREACHABLE_HTML = { [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, [STORE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, }; -const UNREACHABLE_TEXT = { [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE }; +// a write asks both lookups and reaches no store without them, so either one failing leaves the +// destination undetermined rather than unreachable +const UNREACHABLE_TEXT = { + [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, + [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, +}; /** * Only an upstream that could not be reached is retryable. Anything else reaches the worker @@ -312,12 +317,10 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes. /ping answers that, and - // the config service is read alongside it because helix-admin sets the /ping header off the - // same config and swallows a failure reading it: while the config service is down, a - // source-bus site answers 200 with no header and reads as legacy. so a config service that - // cannot answer refuses the save rather than misplacing it in da-admin, where nothing serves - // it back. a 404 is an answer, and it means no AEM site config rather than no DA site + // the payload is settled, so the only question left is where it goes. both lookups have to + // answer: the store answer comes from the config the site lookup reads, and a wrong store + // cannot be walked back from. a 404 is an answer, and it means no AEM site config rather + // than no DA site const [site, onSourceBus] = await Promise.allSettled([ reach(SITE_LOOKUP, () => getSite(env, daCtx)), reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js index 62d113fd..a908e2af 100644 --- a/src/storage/source-bus.js +++ b/src/storage/source-bus.js @@ -21,10 +21,6 @@ const UPGRADE_HEADER = 'x-api-upgrade-available'; * rate limited or erroring. A refusal without it is no answer at all. Reading a refusal as legacy * would send a source-bus write to da-admin, where nothing serves it back, so the read fails. * - * One answer is ambiguous, and this worker cannot resolve it. helix-admin sets the header from the - * site's content source and swallows a config service failure, so a 200 with no header is either a - * legacy site or an origin that could not resolve one. - * * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes * @param {Object} daCtx * @returns {Promise} diff --git a/test/index.test.js b/test/index.test.js index aa9c77d7..66bb54c0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -190,7 +190,7 @@ describe('worker fetch handler', () => { 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 }, - // a write reads both lookups, and this env carries no config service to reach + // a write reads both lookups, and this env names no config service '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined }) }, })).default; diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 29da5cc6..3cdf8fa9 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -176,9 +176,8 @@ describe('writing to the store that holds the site', () => { }); }); - // a config service that cannot answer is the window where /ping answers 200 with no header for - // a source-bus site: helix-admin sets the header off the same config and swallows the failure. - // so a read that cannot be made is a write that cannot be placed + // the store answer comes from the config this read asks for, so a config service that cannot + // answer puts it in doubt as well describe('when the config service cannot answer', () => { const dead = () => new TypeError('fetch failed'); @@ -202,15 +201,15 @@ describe('writing to the store that holds the site', () => { assert.match(res.headers.get('x-error'), /site lookup failed/); }); - // UES embeds this verbatim, and no store was asked: where the document goes is what could not - // be worked out + // UES embeds this verbatim, and no store was asked, so "did not answer" would name the wrong + // failure it('says the store could not be determined, not that it did not answer', async () => { const { res } = await post({ lookupError: dead() }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); - // the probe answered, and it is the answer a save cannot be made without + // the probe answered legacy, and that answer is the one in doubt while the config is down it('is refused even when /ping said legacy', async () => { const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); @@ -220,7 +219,7 @@ describe('writing to the store that holds the site', () => { }); // a 404 says there is no AEM site config, not that the DA org and site are bogus. the service - // answered, so the store is known, and refusing here would stop saving on a DA-only site + // answered, so nothing is in doubt, and refusing here would stop saving on a DA-only site describe('a site the config service does not know', () => { it('is written to da-admin all the same', async () => { const { res, seen } = await post({ exists: false }); From b490ca3a4561605d3568d44bd03fb58301af4459 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Thu, 13 Aug 2026 21:21:58 +0200 Subject: [PATCH 18/77] test: the store comes from the config service content source, not /ping --- test/storage/source-bus.test.js | 155 +++++++++++++++++--------------- 1 file changed, 82 insertions(+), 73 deletions(-) diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js index f3d81b10..e0397ea8 100644 --- a/test/storage/source-bus.test.js +++ b/test/storage/source-bus.test.js @@ -15,7 +15,11 @@ 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 env = { + AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-secret', +}; const daCtx = (over = {}) => ({ org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, @@ -31,8 +35,13 @@ const stubFetch = (respond) => { }; }; -const ping = (headers = {}, status = 200) => new Response('', { status, headers }); -const upgraded = () => ping({ 'x-api-upgrade-available': 'true' }); +const config = (source, status = 200) => new Response( + JSON.stringify({ content: { source, contentBusId: 'abc' } }), + { status, headers: { 'content-type': 'application/json' } }, +); + +const onBus = () => config({ type: 'markup', url: 'https://api.aem.live/org/sites/site/source' }); +const onDa = () => config({ type: 'markup', url: 'https://content.da.live/org/site/' }); describe('isSourceBus', () => { afterEach(() => { @@ -40,36 +49,37 @@ describe('isSourceBus', () => { }); describe('the request it makes', () => { - it('asks /ping on the admin host', async () => { - stubFetch(upgraded); + it('asks the config service for the admin scope', async () => { + stubFetch(onBus); await isSourceBus(env, daCtx()); assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=admin'); }); - it('takes the admin host from env, so stage can point elsewhere', async () => { - stubFetch(upgraded); + it('takes the config service from env, so dev can point at the shim', async () => { + stubFetch(onBus); - await isSourceBus({ ...env, HLX_ADMIN: 'https://admin.stage.example' }, daCtx()); + await isSourceBus({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); - assert.strictEqual(calls[0].url, 'https://admin.stage.example/ping/org/site'); + assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=admin'); }); - // 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); + it('sends the shared secret, which the config service requires', async () => { + stubFetch(onBus); - await isSourceBus(env, daCtx({ ref: 'branch' })); + await isSourceBus(env, daCtx()); - assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + const headers = new Headers(calls[0].init.headers); + assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); + assert.strictEqual(headers.get('x-backend-type'), 'aws'); }); - // /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); + // an author's IMS token is refused by the config service, and sending it as well would only + // hand a user credential to a service that has no use for it + it('sends no author token', async () => { + stubFetch(onBus); await isSourceBus(env, daCtx()); @@ -77,47 +87,62 @@ describe('isSourceBus', () => { }); it('gives up rather than hanging', async () => { - stubFetch(upgraded); + stubFetch(onBus); await isSourceBus(env, daCtx()); - assert.ok(calls[0].init.signal, 'the probe carries an abort signal'); + assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); }); }); - describe('when /ping says the site is upgraded', () => { + // the source is per site, so the ref in the url is only there to address the site + describe('when the content source is on the source bus', () => { it('answers true', async () => { - stubFetch(upgraded); + stubFetch(onBus); 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 })); + it('answers the same for any ref', async () => { + stubFetch(onBus); - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); + assert.strictEqual(await isSourceBus(env, daCtx({ ref: 'branch' })), true); }); - // 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)); + // helix-admin tests the same prefix, so two clients cannot split one site across two stores + it('reads the url, not the type, since both stores are markup', async () => { + stubFetch(() => config({ type: 'markup', url: 'https://api.aem.live/o/sites/s/source' })); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + + it('takes the source bus origin from env', async () => { + stubFetch(() => config({ type: 'markup', url: 'https://api.stage.example/o/sites/s/source' })); + + assert.strictEqual(await isSourceBus({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()), true); + }); + }); - assert.strictEqual(await isSourceBus(env, daCtx()), true); + describe('when the content source is da-admin', () => { + it('answers false', async () => { + stubFetch(onDa); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + }); + + ['https://content.da.live/org/site/', 'https://drive.google.com/x', 'https://example.sharepoint.com/y'].forEach((url) => { + it(`answers false for ${new URL(url).host}`, async () => { + stubFetch(() => config({ type: 'markup', url })); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); }); }); }); - // an answered 200 without the header is the legacy answer. helix-admin sets the header from - // the site's content source, and /ping is 200 for a site it routes at all - describe('when /ping says the site is legacy', () => { - it('answers false on a 200 with no header', async () => { - stubFetch(() => ping()); + // 404 is the one status that means there is no such site, and the site lookup answers that + describe('when there is no such site', () => { + it('answers false', async () => { + stubFetch(() => new Response('', { status: 404 })); assert.strictEqual(await isSourceBus(env, daCtx()), false); }); @@ -125,32 +150,29 @@ describe('isSourceBus', () => { // a refusal carries no decision, and reading it as legacy sends a source-bus write to da-admin, // where nothing serves it back - describe('when /ping refuses without the header', () => { - [404, 405, 429, 500, 503].forEach((status) => { + describe('when the config service refuses', () => { + [401, 403, 429, 500, 503].forEach((status) => { it(`throws on a ${status}`, async () => { - stubFetch(() => ping({}, status)); + stubFetch(() => new Response('', { status })); - await assert.rejects(() => isSourceBus(env, daCtx()), /404|405|429|500|503/); + await assert.rejects(() => isSourceBus(env, daCtx()), new RegExp(`${status}`)); }); }); - // the header is read first, so an enrolled site survives an origin the edge is shielding - it('answers true on a 429 that still carries the header', async () => { - stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, 429)); + it('throws when the answer has no content source', async () => { + stubFetch(() => new Response(JSON.stringify({ ref: 'main' }), { status: 200 })); - assert.strictEqual(await isSourceBus(env, daCtx()), true); + await assert.rejects(() => isSourceBus(env, daCtx()), /content source/); }); - it('names the status it got', async () => { - stubFetch(() => ping({}, 503)); + it('throws when the answer is not json', async () => { + stubFetch(() => new Response('', { status: 200 })); - await assert.rejects(() => isSourceBus(env, daCtx()), /503/); + await assert.rejects(() => isSourceBus(env, daCtx())); }); }); - // 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', () => { + describe('when the config service 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 () => { @@ -161,8 +183,8 @@ describe('isSourceBus', () => { await assert.rejects(isSourceBus(env, daCtx()), { message: 'fetch failed' }); }); - it('lets it through when HLX_ADMIN is unusable, without asking', async () => { - stubFetch(upgraded); + it('lets it through when the config service is unusable, without asking', async () => { + stubFetch(onBus); await assert.rejects(isSourceBus({ AEM_API: 'https://api.aem.live' }, daCtx())); assert.strictEqual(calls.length, 0); @@ -170,7 +192,7 @@ describe('isSourceBus', () => { // 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()); + stubFetch(onDa); assert.strictEqual(await isSourceBus(env, daCtx()), false); stubFetch(() => { @@ -181,7 +203,7 @@ describe('isSourceBus', () => { }); 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 + // either one missing is enough: a half-parsed request would otherwise build a config url with // "undefined" in it [ ['neither', { org: undefined, site: undefined }], @@ -191,24 +213,11 @@ describe('isSourceBus', () => { ['an empty site', { site: '' }], ].forEach(([what, over]) => { it(`answers false without making a request: ${what}`, async () => { - stubFetch(upgraded); + stubFetch(onBus); 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); - }); }); From c2162a47f5c179407bf0c61921cec129725c5e9c Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Thu, 13 Aug 2026 21:26:09 +0200 Subject: [PATCH 19/77] fix: take the store from the config service content source, and drop admin.hlx.page --- README.md | 4 ++-- dev/lookup-shim.js | 33 ++++++++++----------------------- src/storage/source-bus.js | 36 +++++++++++++++++++++--------------- test/routes/da-admin.test.js | 26 +++++++++++++------------- wrangler.toml | 6 +++--- 5 files changed, 49 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index d3450162..98864586 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. -A read looks the site up twice: config.aem.page says whether it exists and carries its head.html, and admin.hlx.page/ping says which store holds it. The config service needs a shared secret, so local development points both at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. +A read asks config.aem.page twice: the pipeline scope says whether the site exists and has its head.html, and the admin scope carries the content source, whose url says which store holds it. The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. To run da-universal locally: @@ -24,7 +24,7 @@ To run da-universal locally: Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. -Anyone who has the shared secret can point `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page --var HLX_ADMIN:https://admin.hlx.page`. +with the shared secret, use `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. ### Run on stage diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js index f73852f4..286933a5 100644 --- a/dev/lookup-shim.js +++ b/dev/lookup-shim.js @@ -10,49 +10,36 @@ * governing permissions and limitations under the License. */ -// stands in for the two lookups a read makes: config.aem.page, which needs a shared secret, and -// admin.hlx.page/ping. A site in SITES exists, and one with a source url on api.aem.live is -// source-bus +// stands in for config.aem.page, which needs a shared secret. a read asks it twice: the pipeline +// scope for whether the site exists and its head.html, the admin scope for the content source. a +// site in SITES exists, and one with a source url on api.aem.live is source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', }; -const SOURCE_BUS = 'https://api.aem.live/'; - // what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with const HEAD_HTML = '\n\n'; -/** - * Answers /ping the way helix-admin does: the header is set when the site's content source is the - * source bus, and a site it cannot resolve is answered 200 with no header rather than 404. - */ -function ping(org, site) { - const headers = SITES[`${org}/${site}`]?.startsWith(SOURCE_BUS) - ? { 'x-api-upgrade-available': 'true' } - : {}; - return new Response('', { status: 200, headers }); -} - export default { async fetch(req) { const url = new URL(req.url); - if (url.pathname.startsWith('/ping/')) { - const [, , pingOrg, pingSite] = url.pathname.split('/'); - return ping(pingOrg, pingSite); - } - const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); if (!org || !site) { return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); } - if (!SITES[`${org}/${site}`]) { + const source = SITES[`${org}/${site}`]; + if (!source) { return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); } + // both stores are `type: markup`, so only the url separates them + const answer = url.searchParams.get('scope') === 'admin' + ? { content: { source: { type: 'markup', url: source } } } + : { head: { html: HEAD_HTML } }; const body = JSON.stringify({ - ref, site, org, head: { html: HEAD_HTML }, + ref, site, org, ...answer, }); return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); }, diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js index a908e2af..59b63af2 100644 --- a/src/storage/source-bus.js +++ b/src/storage/source-bus.js @@ -11,29 +11,35 @@ */ const TIMEOUT_MS = 5 * 1000; -const UPGRADE_HEADER = 'x-api-upgrade-available'; /** - * Asks `/ping` whether a site is on the source bus. + * Asks the config service which store holds a site's content based on `content.source` + * A 404 means there is no such site, which the site lookup reports. Any other refusal is no + * answer at all: reading it as legacy would send a source-bus write to da-admin * - * The header is the answer, and da-nx checks the same header for presence in `isHlx6`. It is read - * ahead of the status, since a Fastly edge dictionary sets it in front of an origin that may be - * rate limited or erroring. A refusal without it is no answer at all. Reading a refusal as legacy - * would send a source-bus write to da-admin, where nothing serves it back, so the read fails. - * - * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it and `AEM_API` is the source bus * @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 + const { org, site, ref } = daCtx; 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) }); + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=admin`, env.HLX_CONFIG_SERVICE); + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (response.status === 404) return false; + if (!response.ok) throw new Error(`the config service answered ${response.status}`); - if (response.headers.get(UPGRADE_HEADER) !== null) return true; - if (!response.ok) throw new Error(`/ping answered ${response.status}`); - return false; + const { content } = await response.json(); + const source = content?.source?.url; + if (!source) throw new Error('the config service named no content source'); + return source.startsWith(`${env.AEM_API}/`); } diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index fe7271ca..30200c77 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -34,7 +34,6 @@ const recorder = () => { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', HLX_CONFIG_SERVICE: 'https://config.aem.page', - HLX_ADMIN: 'https://admin.hlx.page', daadmin: { fetch: async (input) => { fetched.push(input instanceof Request ? input.url : input.href); @@ -45,21 +44,22 @@ const recorder = () => { return { env, fetched }; }; -// stands in for the two lookups the routes make: config.aem.page for whether the site exists, -// admin.hlx.page/ping for which store holds it. Answers that any site exists; `upgraded` lists -// the `org/site` keys /ping reports as enrolled +// stands in for the two config service reads the routes make: the pipeline scope for whether the +// site exists and its head.html, the admin scope for which store holds it. Answers that any site +// exists; `upgraded` lists the `org/site` keys whose content source is the source bus const stubLookups = (upgraded = []) => { const asked = []; globalThis.fetch = async (input) => { const url = input.toString(); asked.push(url); - const { pathname } = new URL(url); - if (pathname.startsWith('/ping/')) { - const [, , org, site] = pathname.split('/'); - const headers = upgraded.includes(`${org}/${site}`) - ? { 'x-api-upgrade-available': 'true' } - : {}; - return new Response('', { status: 200, headers }); + const { pathname, searchParams } = new URL(url); + const [, site, org] = (pathname.split('/')[1] ?? '').split('--'); + if (searchParams.get('scope') === 'admin') { + const source = upgraded.includes(`${org}/${site}`) + ? `https://api.aem.live/${org}/sites/${site}/source` + : `https://content.da.live/${org}/${site}/`; + const body = JSON.stringify({ content: { source: { type: 'markup', url: source } } }); + return new Response(body, { status: 200 }); } const body = JSON.stringify({ head: { html: '' } }); return new Response(body, { status: 200 }); @@ -461,8 +461,8 @@ describe('daSourcePost', () => { await write('lookedupeach', env); assert.deepStrictEqual(asked.sort(), [ - 'https://admin.hlx.page/ping/org/lookedupeach', - 'https://admin.hlx.page/ping/org/lookedupeach', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); diff --git a/wrangler.toml b/wrangler.toml index def67d4c..39d66735 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,7 +2,7 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page", HLX_CONFIG_SERVICE = "https://config.aem.page" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin" }] secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } @@ -10,14 +10,14 @@ secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } port = 4712 [env.dev] -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 = "http://localhost:4713", HLX_CONFIG_SERVICE = "http://localhost:4713" } +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_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] # the list is also what wrangler binds from .dev.vars.dev, so an empty one leaves the worker # with no token and every lookup 401s. running against the stand-in warns that it is missing secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [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", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page", HLX_CONFIG_SERVICE = "https://config.aem.page" } +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_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin-stage" }] secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } From e3e9ecfeab0e77c93ed54c9e486f6731bde1668e Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Thu, 13 Aug 2026 21:56:37 +0200 Subject: [PATCH 20/77] chore: name the two config scopes, not /ping, in the comments and tests --- src/routes/da-admin.js | 4 ++-- test/routes/source-read.test.js | 22 +++++++++++----------- test/routes/source-write.test.js | 7 ++++--- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 85546b77..16be3429 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -134,8 +134,8 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - // both lookups go out together: config.aem.page says whether the site exists and what its - // head.html is, /ping says which store holds it + // both lookups go out together: the pipeline scope says whether the site exists and what its + // head.html is, the admin scope says which store holds it const [site, onSourceBus] = await Promise.allSettled([ reach(SITE_LOOKUP, () => getSite(env, daCtx)), reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index c4732a1b..d56cffa1 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -20,8 +20,8 @@ import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); -// what the two lookups answer between them: `exists` comes from the config service, -// `onSourceBus` from /ping +// what the two lookups answer between them: `exists` comes from the pipeline scope, +// `onSourceBus` from the admin scope const SOURCE_BUS = { exists: true, onSourceBus: true }; const LEGACY_STORE = { exists: true, onSourceBus: false }; const NO_SITE = { exists: false, onSourceBus: false }; @@ -48,7 +48,7 @@ const build = async (overrides = {}) => { busError, templateError, configError, composeError, config = null, } = overrides; const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, pings: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, storeLookups: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -77,7 +77,7 @@ const build = async (overrides = {}) => { }, '../../src/storage/source-bus.js': { default: async () => { - seen.pings += 1; + seen.storeLookups += 1; if (busError) throw busError; return site !== undefined && site.onSourceBus; }, @@ -114,7 +114,7 @@ const build = async (overrides = {}) => { return { ...mod, env, seen }; }; -describe('when /ping cannot say which store holds the site', () => { +describe('when the store lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); @@ -931,16 +931,16 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // one read of the config service has both the existence answer and head.html, so a page that - // needs the head pays for no second read - it('reads the config service once for a page', async () => { + // the pipeline scope answers existence and head.html together, so a page that needs the head + // pays for no third read + it('reads each lookup once for a page', 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.lookups, 1); - assert.strictEqual(seen.pings, 1); + assert.strictEqual(seen.storeLookups, 1); }); // nothing composes an image, and the head that arrives with the existence answer is dropped @@ -951,7 +951,7 @@ describe('reading from the store that holds the site', () => { await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.pings, 1); + assert.strictEqual(seen.storeLookups, 1); assert.deepStrictEqual(seen.head, []); }); @@ -962,7 +962,7 @@ describe('reading from the store that holds the site', () => { await daSourceHead({ env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.pings, 1); + assert.strictEqual(seen.storeLookups, 1); assert.deepStrictEqual(seen.head, []); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 3cdf8fa9..fde52b60 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -19,7 +19,7 @@ import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../.. const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; -// what /ping answers +// what the store lookup answers const SOURCE_BUS = true; const LEGACY_STORE = false; @@ -209,8 +209,9 @@ describe('writing to the store that holds the site', () => { assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); - // the probe answered legacy, and that answer is the one in doubt while the config is down - it('is refused even when /ping said legacy', async () => { + // the store lookup answered legacy, and that answer is the one in doubt while the site + // lookup is down + it('is refused even when the store lookup said legacy', async () => { const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); assert.strictEqual(res.status, 503); From dc0fcec9cb2c1938fc7922ab414461160d6e2a72 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 07:21:54 +0200 Subject: [PATCH 21/77] test: a write asks the store lookup only --- test/routes/da-admin.test.js | 2 -- test/routes/source-write.test.js | 53 ++++---------------------------- 2 files changed, 6 insertions(+), 49 deletions(-) diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 30200c77..10669370 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -463,8 +463,6 @@ describe('daSourcePost', () => { assert.deepStrictEqual(asked.sort(), [ 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index fde52b60..984dcc53 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -176,51 +176,8 @@ describe('writing to the store that holds the site', () => { }); }); - // the store answer comes from the config this read asks for, so a config service that cannot - // answer puts it in doubt as well - describe('when the config service cannot answer', () => { - const dead = () => new TypeError('fetch failed'); - - it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ lookupError: dead() }); - - assert.strictEqual(res.status, 503); - assert.strictEqual(seen.bus.length, 0); - assert.strictEqual(seen.legacy.length, 0); - }); - - it('asks the caller to retry', async () => { - const { res } = await post({ lookupError: dead() }); - - assert.ok(Number(res.headers.get('Retry-After')) > 0); - }); - - it('names the failed lookup in x-error', async () => { - const { res } = await post({ lookupError: dead() }); - - assert.match(res.headers.get('x-error'), /site lookup failed/); - }); - - // UES embeds this verbatim, and no store was asked, so "did not answer" would name the wrong - // failure - it('says the store could not be determined, not that it did not answer', async () => { - const { res } = await post({ lookupError: dead() }); - - assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); - }); - - // the store lookup answered legacy, and that answer is the one in doubt while the site - // lookup is down - it('is refused even when the store lookup said legacy', async () => { - const { res, seen } = await post({ lookupError: dead(), site: LEGACY_STORE }); - - assert.strictEqual(res.status, 503); - assert.strictEqual(seen.legacy.length, 0); - }); - }); - - // a 404 says there is no AEM site config, not that the DA org and site are bogus. the service - // answered, so nothing is in doubt, and refusing here would stop saving on a DA-only site + // the store lookup answers 404 for a site the config service does not know, and 404 is an + // answer: it means no AEM site config rather than no DA site, so the write goes to da-admin describe('a site the config service does not know', () => { it('is written to da-admin all the same', async () => { const { res, seen } = await post({ exists: false }); @@ -232,11 +189,13 @@ describe('writing to the store that holds the site', () => { }); describe('what a write asks about the site', () => { - it('asks both lookups, and reaches the store after them', async () => { + // the site lookup and the store lookup read the same service, and a write never reads the + // pipeline scope's answer, so asking it twice buys nothing + it('asks the store lookup only, and reaches the store after it', async () => { const { res, seen } = await post({}); assert.strictEqual(seen.probes, 1); - assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.lookups, 0); assert.strictEqual(seen.order[seen.order.length - 1], 'store'); assert.strictEqual(res.status, 201); }); From 11ae4583309c98898e44abe65dd4b454f02955d1 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 07:22:30 +0200 Subject: [PATCH 22/77] fix: ask one lookup on a write, since the write never reads the other's answer --- src/routes/da-admin.js | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 16be3429..21511fd0 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -62,10 +62,9 @@ const UNREACHABLE_HTML = { [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, [STORE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, }; -// a write asks both lookups and reaches no store without them, so either one failing leaves the -// destination undetermined rather than unreachable +// a write reaches no store until the lookup answers, so a failed lookup leaves the destination +// undetermined rather than unreachable const UNREACHABLE_TEXT = { - [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, }; @@ -317,24 +316,18 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes. both lookups have to - // answer: the store answer comes from the config the site lookup reads, and a wrong store - // cannot be walked back from. a 404 is an answer, and it means no AEM site config rather - // than no DA site - const [site, onSourceBus] = await Promise.allSettled([ - reach(SITE_LOOKUP, () => getSite(env, daCtx)), - reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), - ]); - if (site.status === 'rejected') throw site.reason; - if (onSourceBus.status === 'rejected') throw onSourceBus.reason; - - if (onSourceBus.value) { + // the payload is settled, so the only question left is where it goes + // a 404 is an answer, and it means no AEM site config rather than no DA + // site, so the write goes to da-admin + const onSourceBus = await reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)); + + 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.value); + const store = getStore(env, daCtx, onSourceBus); const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); From 46b5c5f5454c606c20f93f9de741380222ec418d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 07:31:14 +0200 Subject: [PATCH 23/77] test: drop the knobs a write no longer reaches, and name what the assertion proves --- test/routes/da-admin.test.js | 2 +- test/routes/source-write.test.js | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 10669370..29cc9159 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -453,7 +453,7 @@ describe('daSourcePost', () => { // nothing is remembered between requests, so a site enrolled or un-enrolled mid-session takes // effect on the next one - it('looks the site up once per write', async () => { + it('looks the store up once per write, and asks nothing else', async () => { const asked = stubLookups(['org/lookedupeach']); const { env } = recorder(); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 984dcc53..15ed4495 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -31,9 +31,8 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201, busError, lookupError } = overrides; + const { status = 201, busError } = overrides; const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; - const exists = 'exists' in overrides ? overrides.exists : true; const seen = { bus: [], legacy: [], lookups: 0, probes: 0, order: [], }; @@ -71,12 +70,12 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { + // still mocked, so a write that reached for it would be counted rather than hitting the network '../../src/storage/site.js': { default: async () => { seen.lookups += 1; seen.order.push('lookup'); - if (lookupError) throw lookupError; - return { exists, head: undefined }; + return { exists: true, head: undefined }; }, }, '../../src/storage/source-bus.js': { @@ -176,11 +175,11 @@ describe('writing to the store that holds the site', () => { }); }); - // the store lookup answers 404 for a site the config service does not know, and 404 is an - // answer: it means no AEM site config rather than no DA site, so the write goes to da-admin + // the store lookup answers false for a site the config service does not know, since a 404 means + // no AEM site config rather than no DA site describe('a site the config service does not know', () => { it('is written to da-admin all the same', async () => { - const { res, seen } = await post({ exists: false }); + const { res, seen } = await post({ site: LEGACY_STORE }); assert.strictEqual(res.status, 201); assert.strictEqual(seen.legacy.length, 1); From 81c566ccd7587251dafe2c1d9ff3b4608a4139fd Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 07:36:17 +0200 Subject: [PATCH 24/77] test: drop the site lookup stub from the write case, which no longer reaches it --- test/index.test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index 66bb54c0..0bdacba0 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -190,8 +190,6 @@ describe('worker fetch handler', () => { 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 }, - // a write reads both lookups, and this env names no config service - '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined }) }, })).default; const uePost = (origin) => { From c646d216653d4c452e0fbecc55fb2eecfed9c5dc Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 10:07:59 +0200 Subject: [PATCH 25/77] test: cover the cookie route, and point the exchange at the source bus api --- test/routes/cookie.test.js | 152 +++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 test/routes/cookie.test.js diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js new file mode 100644 index 00000000..e0ad43f5 --- /dev/null +++ b/test/routes/cookie.test.js @@ -0,0 +1,152 @@ +/* + * 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 { getCookie } = await import('../../src/routes/cookie.js'); + +const env = { AEM_API: 'https://api.aem.live' }; + +const daCtx = (over = {}) => ({ org: 'org', site: 'site', ...over }); + +const req = (over = {}) => new Request('https://main--site--org.ue.da.live/gimme_cookie', { + headers: { + Origin: over.origin ?? 'https://da.live', + ...(over.noAuth ? {} : { Authorization: 'Bearer thetoken' }), + }, +}); + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(); + }; +}; + +const noAuthNeeded = () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); +const mints = () => new Response( + JSON.stringify({ siteToken: 'sitetok', siteTokenExpiry: Date.now() + 3600_000 }), + { status: 200, headers: { 'content-type': 'application/json' } }, +); + +const cookies = (res) => res.headers.getSetCookie(); + +describe('getCookie', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the exchange it asks for', () => { + // one service behind two hostnames: minted in the same second, admin.hlx.page and api.aem.live + // answer a byte-identical token, so the worker asks the one it uses for everything else + it('goes to the source bus api', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://api.aem.live/auth/adobe/exchange'); + }); + + it('takes the api from env, so stage can point elsewhere', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env: { AEM_API: 'https://api.stage.example' }, daCtx: daCtx() }); + + assert.strictEqual(calls[0].url, 'https://api.stage.example/auth/adobe/exchange'); + }); + + it('sends the org, the site and the caller token', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.deepStrictEqual(JSON.parse(calls[0].init.body), { + org: 'org', site: 'site', accessToken: 'thetoken', + }); + }); + }); + + describe('what it sets', () => { + it('sets the auth cookie for a site that needs no site token', async () => { + stubFetch(noAuthNeeded); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 200); + const set = cookies(res); + assert.strictEqual(set.length, 1); + assert.ok(set[0].startsWith('auth_token=thetoken;')); + }); + + it('adds the site token for a site behind helix authentication', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + const set = cookies(res); + assert.strictEqual(set.length, 2); + assert.ok(set[1].startsWith('site_token=sitetok;')); + assert.match(set[1], /Max-Age=\d+/); + }); + + // the author is signed in either way, so a refused exchange keeps the auth cookie + it('keeps the auth cookie when the exchange refuses', async () => { + stubFetch(() => new Response('', { status: 403 })); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(cookies(res).length, 1); + }); + + it('keeps the auth cookie when the exchange cannot be reached', async () => { + stubFetch(() => { throw new TypeError('fetch failed'); }); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(cookies(res).length, 1); + }); + + it('asks for no exchange when the caller already has a site token', async () => { + stubFetch(mints); + + await getCookie({ req: req(), env, daCtx: daCtx({ siteToken: 'already' }) }); + + assert.strictEqual(calls.length, 0); + }); + }); + + describe('what it refuses', () => { + it('refuses an untrusted origin', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req({ origin: 'https://evil.example' }), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(calls.length, 0); + }); + + it('answers 401 without an Authorization header', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req({ noAuth: true }), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(calls.length, 0); + }); + }); +}); From 8c01ab9c5135fe3a728e1f05a7c7c4673c4b1fa9 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 10:08:34 +0200 Subject: [PATCH 26/77] fix: exchange the site token on the source bus api, from env --- src/handlers/get.js | 2 +- src/routes/cookie.js | 8 ++++---- test/routes/cookie.test.js | 4 +++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/handlers/get.js b/src/handlers/get.js index 85e8f02f..df429f3d 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -21,7 +21,7 @@ export default async function getHandler({ req, env, daCtx }) { if (path.startsWith('/favicon.ico')) return get404(); if (path.startsWith('/robots.txt')) return getRobots(); - if (path.startsWith('/gimme_cookie')) return getCookie({ req, daCtx }); + if (path.startsWith('/gimme_cookie')) return getCookie({ req, env, daCtx }); const resourceRegex = /\.(css|js|js\.map|json|xml|woff|woff2|otf|ttf|plain\.html|html)$/i; if (resourceRegex.test(path)) { diff --git a/src/routes/cookie.js b/src/routes/cookie.js index 352f348f..1559ecf7 100644 --- a/src/routes/cookie.js +++ b/src/routes/cookie.js @@ -12,9 +12,9 @@ import { daResp, get401 } from '../responses/index.js'; import { isTrustedOrigin } from '../utils/constants.js'; -async function exchangeSiteToken(org, site, accessToken) { +async function exchangeSiteToken(env, org, site, accessToken) { try { - const response = await fetch('https://admin.hlx.page/auth/adobe/exchange', { + const response = await fetch(new URL('/auth/adobe/exchange', env.AEM_API), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -48,7 +48,7 @@ async function exchangeSiteToken(org, site, accessToken) { } } -export async function getCookie({ req, daCtx }) { +export async function getCookie({ req, env, daCtx }) { const { headers } = req; if (!isTrustedOrigin(headers.get('Origin'))) return daResp({ body: '403 Forbidden', status: 403, contentType: 'text/plain' }); @@ -67,7 +67,7 @@ export async function getCookie({ req, daCtx }) { // Try to exchange for site token if (org && site && !daCtx.siteToken) { - const siteTokenData = await exchangeSiteToken(org, site, cookieValue); + const siteTokenData = await exchangeSiteToken(env, org, site, cookieValue); if (siteTokenData) { // Calculate Max-Age based on token expiry time (siteTokenExpiry is in milliseconds) const now = Date.now(); diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js index e0ad43f5..a5a69f75 100644 --- a/test/routes/cookie.test.js +++ b/test/routes/cookie.test.js @@ -113,7 +113,9 @@ describe('getCookie', () => { }); it('keeps the auth cookie when the exchange cannot be reached', async () => { - stubFetch(() => { throw new TypeError('fetch failed'); }); + stubFetch(() => { + throw new TypeError('fetch failed'); + }); const res = await getCookie({ req: req(), env, daCtx: daCtx() }); From 708c0d188e9f5cbf93ba7854892f78c1f09a42d2 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 10:56:06 +0200 Subject: [PATCH 27/77] test: one config read answers existence, head.html and the store --- test/storage/site.test.js | 197 +++++++++++++--------------- test/storage/source-bus.test.js | 223 -------------------------------- 2 files changed, 90 insertions(+), 330 deletions(-) delete mode 100644 test/storage/source-bus.test.js diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 1af6db51..8da98aa0 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -18,31 +18,32 @@ const { default: getSite } = await import('../../src/storage/site.js'); const env = { AEM_API: 'https://api.aem.live', HLX_CONFIG_SERVICE: 'https://config.aem.page', - HLX_CONFIG_SERVICE_TOKEN: 'shared-token', + HLX_CONFIG_SERVICE_TOKEN: 'shared-secret', }; const daCtx = (over = {}) => ({ - org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, + org: 'org', site: 'site', ref: 'main', ...over, }); +const HEAD = ''; + let calls; const stubFetch = (respond) => { calls = []; globalThis.fetch = async (input, init) => { calls.push({ url: input.toString(), init }); - return respond(input.toString(), init); + return respond(); }; }; -const STYLESHEET = ''; -// the pipeline scope has the code bus object, with a lastModified the delivery pipeline reads -const withHead = (html) => () => new Response( - JSON.stringify({ head: { lastModified: 'Mon, 30 Mar 2026 06:42:40 GMT', html } }), - { status: 200 }, -); -const found = withHead(STYLESHEET); -const absent = () => new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); +const config = (over = {}) => new Response(JSON.stringify({ + head: { html: HEAD }, + contentSource: { type: 'markup', url: 'https://content.da.live/org/site/' }, + ...over, +}), { status: 200, headers: { 'content-type': 'application/json' } }); + +const onBus = () => config({ contentSource: { type: 'markup', url: 'https://api.aem.live/org/sites/site/source' } }); describe('getSite', () => { afterEach(() => { @@ -50,8 +51,8 @@ describe('getSite', () => { }); describe('the request it makes', () => { - it('asks the config service for the pipeline-scoped config', async () => { - stubFetch(found); + it('asks the config service once, for the pipeline scope', async () => { + stubFetch(config); await getSite(env, daCtx()); @@ -59,146 +60,128 @@ describe('getSite', () => { assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); }); - it('takes the config service from env, so dev can point elsewhere', async () => { - stubFetch(found); - - await getSite({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); - - assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=pipeline'); - }); - - // the code bus has one head.html per ref, so a branch gets its own - it('names the ref the request came in on', async () => { - stubFetch(found); - - await getSite(env, daCtx({ ref: 'feature' })); - - assert.match(calls[0].url, /\/feature--site--org\//); - }); - - it('sends the shared token', async () => { - stubFetch(found); + it('sends the shared secret', async () => { + stubFetch(config); await getSite(env, daCtx()); - assert.strictEqual(calls[0].init.headers['x-access-token'], 'shared-token'); + const headers = new Headers(calls[0].init.headers); + assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); + assert.strictEqual(headers.get('x-backend-type'), 'aws'); }); - // the edge answers 400 without it, and that failure reads like a bad path - it('sends the backend type', async () => { - stubFetch(found); - - await getSite(env, daCtx()); - - assert.strictEqual(calls[0].init.headers['x-backend-type'], 'aws'); - }); - - // the author's token has no business at a service-to-service endpoint - it('sends no author token', async () => { - stubFetch(found); + it('gives up rather than hanging', async () => { + stubFetch(config); await getSite(env, daCtx()); - assert.strictEqual(calls[0].init.headers.Authorization, undefined); + assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); }); + }); - it('gives up rather than hanging', async () => { - stubFetch(found); + describe('what one answer carries', () => { + it('answers existence, head.html and the store together', async () => { + stubFetch(onBus); - await getSite(env, daCtx()); - - assert.ok(calls[0].init.signal); + assert.deepStrictEqual(await getSite(env, daCtx()), { + exists: true, head: HEAD, onSourceBus: true, + }); }); - // the admin scope answers with the site's CDN token and its API key metadata, and one read - // is enough for both questions this asks - it('reads one scope, and not the admin one', async () => { - stubFetch(found); + it('reads the url, not the type, since both stores are markup', async () => { + stubFetch(config); - await getSite(env, daCtx()); - - assert.strictEqual(calls.length, 1); - assert.doesNotMatch(calls[0].url, /scope=admin/); + const { onSourceBus } = await getSite(env, daCtx()); + assert.strictEqual(onSourceBus, false); }); - }); - describe('the head it answers', () => { - it('reads head.html out of the config', async () => { - stubFetch(found); + it('takes the source bus origin from env', async () => { + stubFetch(() => config({ contentSource: { type: 'markup', url: 'https://api.stage.example/o/sites/s/source' } })); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: STYLESHEET }); + const { onSourceBus } = await getSite({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()); + assert.strictEqual(onSourceBus, true); }); - // a ref with no head.html on the code bus answers 200 with an empty head, not a 404 - it('answers a site with no head.html for the ref', async () => { - stubFetch(() => new Response(JSON.stringify({ head: {} }), { status: 200 })); + ['https://drive.google.com/x', 'https://example.sharepoint.com/y'].forEach((url) => { + it(`answers legacy for ${new URL(url).host}`, async () => { + stubFetch(() => config({ contentSource: { type: 'markup', url } })); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: undefined }); + const { onSourceBus } = await getSite(env, daCtx()); + assert.strictEqual(onSourceBus, false); + }); }); - it('answers a site whose config carries no head at all', async () => { - stubFetch(() => new Response(JSON.stringify({}), { status: 200 })); + // a ref that was never built exists and has no head.html + it('answers a missing head as undefined, and still names the store', async () => { + stubFetch(() => config({ head: undefined })); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: true, head: undefined }); + const { exists, head, onSourceBus } = await getSite(env, daCtx()); + assert.strictEqual(exists, true); + assert.strictEqual(head, undefined); + assert.strictEqual(onSourceBus, false); }); }); describe('when there is no such site', () => { - it('says so on a 404', async () => { - stubFetch(absent); + it('answers no-site on a 404', async () => { + stubFetch(() => new Response('', { status: 404 })); - assert.deepStrictEqual(await getSite(env, daCtx()), { exists: false, head: undefined }); + assert.deepStrictEqual(await getSite(env, daCtx()), { + exists: false, head: undefined, onSourceBus: false, + }); }); - // an unparseable hostname leaves org and site undefined, so there is nothing to ask about - it('says so without asking when there is no org or site', async () => { - stubFetch(absent); + [ + ['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 no-site without asking: ${what}`, async () => { + stubFetch(config); + + assert.deepStrictEqual(await getSite(env, daCtx(over)), { + exists: false, head: undefined, onSourceBus: false, + }); + assert.strictEqual(calls.length, 0); + }); + }); + }); - const site = await getSite(env, daCtx({ site: undefined })); + // the config service has served `contentSource` since 2026-08-13, and a site whose config has not + // been rewritten since then is answered from a cache that predates it. guessing a store from a + // config that does not name one would send a source-bus write to da-admin + describe('when the answer names no content source', () => { + it('throws rather than guessing', async () => { + stubFetch(() => config({ contentSource: undefined })); - assert.strictEqual(site.exists, false); - assert.strictEqual(calls.length, 0); + await assert.rejects(() => getSite(env, daCtx()), /content source/); }); }); - // a refusal leaves existence unknown, and reading that as a missing site 404s a live page - describe('when the lookup cannot answer', () => { - [401, 403, 429, 500, 502].forEach((status) => { + describe('when the config service refuses', () => { + [401, 403, 429, 500, 503].forEach((status) => { it(`throws on a ${status}`, async () => { stubFetch(() => new Response('', { status })); - await assert.rejects(() => getSite(env, daCtx()), /502|500|429|403|401/); + await assert.rejects(() => getSite(env, daCtx()), new RegExp(`${status}`)); }); }); - it('throws when the config service cannot be reached', async () => { - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - - await assert.rejects(() => getSite(env, daCtx()), /fetch failed/); - }); - - // a worker deployed without the secret and a rate limit share the status and the body, so - // `x-error` is what tells them apart - it('names the status it got', async () => { - stubFetch(() => new Response('', { status: 401 })); - - await assert.rejects(() => getSite(env, daCtx()), /401/); - }); - - it('throws when the body is not JSON', async () => { - stubFetch(() => new Response('the edge said no', { status: 200 })); + it('throws when the answer is not json', async () => { + stubFetch(() => new Response('', { status: 200 })); await assert.rejects(() => getSite(env, daCtx())); }); - // a misconfigured worker gets no answer, and calling that a missing site would 404 the pages - it('throws when the config service host is missing, without asking', async () => { - stubFetch(found); + // the cause reaches the caller, which reports it on the 503 as `x-error` + it('lets a failure through', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); - await assert.rejects(() => getSite({ ...env, HLX_CONFIG_SERVICE: undefined }, daCtx())); - assert.strictEqual(calls.length, 0); + await assert.rejects(getSite(env, daCtx()), { message: 'fetch failed' }); }); }); }); diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js deleted file mode 100644 index e0397ea8..00000000 --- a/test/storage/source-bus.test.js +++ /dev/null @@ -1,223 +0,0 @@ -/* - * 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_CONFIG_SERVICE: 'https://config.aem.page', - HLX_CONFIG_SERVICE_TOKEN: 'shared-secret', -}; - -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 config = (source, status = 200) => new Response( - JSON.stringify({ content: { source, contentBusId: 'abc' } }), - { status, headers: { 'content-type': 'application/json' } }, -); - -const onBus = () => config({ type: 'markup', url: 'https://api.aem.live/org/sites/site/source' }); -const onDa = () => config({ type: 'markup', url: 'https://content.da.live/org/site/' }); - -describe('isSourceBus', () => { - afterEach(() => { - delete globalThis.fetch; - }); - - describe('the request it makes', () => { - it('asks the config service for the admin scope', async () => { - stubFetch(onBus); - - await isSourceBus(env, daCtx()); - - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=admin'); - }); - - it('takes the config service from env, so dev can point at the shim', async () => { - stubFetch(onBus); - - await isSourceBus({ ...env, HLX_CONFIG_SERVICE: 'http://localhost:4713' }, daCtx()); - - assert.strictEqual(calls[0].url, 'http://localhost:4713/main--site--org/config.json?scope=admin'); - }); - - it('sends the shared secret, which the config service requires', async () => { - stubFetch(onBus); - - await isSourceBus(env, daCtx()); - - const headers = new Headers(calls[0].init.headers); - assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); - assert.strictEqual(headers.get('x-backend-type'), 'aws'); - }); - - // an author's IMS token is refused by the config service, and sending it as well would only - // hand a user credential to a service that has no use for it - it('sends no author token', async () => { - stubFetch(onBus); - - await isSourceBus(env, daCtx()); - - assert.strictEqual(new Headers(calls[0].init.headers).get('Authorization'), null); - }); - - it('gives up rather than hanging', async () => { - stubFetch(onBus); - - await isSourceBus(env, daCtx()); - - assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); - }); - }); - - // the source is per site, so the ref in the url is only there to address the site - describe('when the content source is on the source bus', () => { - it('answers true', async () => { - stubFetch(onBus); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - - it('answers the same for any ref', async () => { - stubFetch(onBus); - - assert.strictEqual(await isSourceBus(env, daCtx({ ref: 'branch' })), true); - }); - - // helix-admin tests the same prefix, so two clients cannot split one site across two stores - it('reads the url, not the type, since both stores are markup', async () => { - stubFetch(() => config({ type: 'markup', url: 'https://api.aem.live/o/sites/s/source' })); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - - it('takes the source bus origin from env', async () => { - stubFetch(() => config({ type: 'markup', url: 'https://api.stage.example/o/sites/s/source' })); - - assert.strictEqual(await isSourceBus({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()), true); - }); - }); - - describe('when the content source is da-admin', () => { - it('answers false', async () => { - stubFetch(onDa); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - }); - - ['https://content.da.live/org/site/', 'https://drive.google.com/x', 'https://example.sharepoint.com/y'].forEach((url) => { - it(`answers false for ${new URL(url).host}`, async () => { - stubFetch(() => config({ type: 'markup', url })); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - }); - }); - }); - - // 404 is the one status that means there is no such site, and the site lookup answers that - describe('when there is no such site', () => { - it('answers false', async () => { - stubFetch(() => new Response('', { status: 404 })); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - }); - }); - - // a refusal carries no decision, and reading it as legacy sends a source-bus write to da-admin, - // where nothing serves it back - describe('when the config service refuses', () => { - [401, 403, 429, 500, 503].forEach((status) => { - it(`throws on a ${status}`, async () => { - stubFetch(() => new Response('', { status })); - - await assert.rejects(() => isSourceBus(env, daCtx()), new RegExp(`${status}`)); - }); - }); - - it('throws when the answer has no content source', async () => { - stubFetch(() => new Response(JSON.stringify({ ref: 'main' }), { status: 200 })); - - await assert.rejects(() => isSourceBus(env, daCtx()), /content source/); - }); - - it('throws when the answer is not json', async () => { - stubFetch(() => new Response('', { status: 200 })); - - await assert.rejects(() => isSourceBus(env, daCtx())); - }); - }); - - describe('when the config service 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 the config service is unusable, without asking', async () => { - stubFetch(onBus); - - 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(onDa); - 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 config 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(onBus); - - assert.strictEqual(await isSourceBus(env, daCtx(over)), false); - assert.strictEqual(calls.length, 0); - }); - }); - }); -}); From b8b63eecbe9efd47ae504909053bb298261ea930 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 10:59:42 +0200 Subject: [PATCH 28/77] fix: take existence, head.html and the store from one config read --- README.md | 2 +- dev/lookup-shim.js | 15 ++++---- src/routes/da-admin.js | 30 +++++----------- src/storage/site.js | 38 +++++++++++--------- src/storage/source-bus.js | 45 ------------------------ src/utils/constants.js | 12 +++---- src/utils/upstream.js | 1 - test/index.test.js | 2 +- test/routes/da-admin.test.js | 36 +++++++++---------- test/routes/source-read.test.js | 59 +++++++++----------------------- test/routes/source-write.test.js | 30 ++++++---------- 11 files changed, 88 insertions(+), 182 deletions(-) delete mode 100644 src/storage/source-bus.js diff --git a/README.md b/README.md index 98864586..201ee602 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. -A read asks config.aem.page twice: the pipeline scope says whether the site exists and has its head.html, and the admin scope carries the content source, whose url says which store holds it. The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. +A read asks config.aem.page once, at the pipeline scope, which says whether the site exists, has its head.html and names the content source, whose url says which store holds it. The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. To run da-universal locally: diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js index 286933a5..5bbaf0dc 100644 --- a/dev/lookup-shim.js +++ b/dev/lookup-shim.js @@ -10,9 +10,9 @@ * governing permissions and limitations under the License. */ -// stands in for config.aem.page, which needs a shared secret. a read asks it twice: the pipeline -// scope for whether the site exists and its head.html, the admin scope for the content source. a -// site in SITES exists, and one with a source url on api.aem.live is source-bus +// stands in for config.aem.page, which needs a shared secret. one read of the pipeline scope +// answers whether the site exists, its head.html and which store holds it. a site in SITES exists, +// and one with a source url on api.aem.live is source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', }; @@ -35,11 +35,12 @@ export default { } // both stores are `type: markup`, so only the url separates them - const answer = url.searchParams.get('scope') === 'admin' - ? { content: { source: { type: 'markup', url: source } } } - : { head: { html: HEAD_HTML } }; const body = JSON.stringify({ - ref, site, org, ...answer, + ref, + site, + org, + head: { html: HEAD_HTML }, + contentSource: { type: 'markup', url: source }, }); return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); }, diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 21511fd0..99f232b0 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -30,7 +30,6 @@ import { PREVIEW_UNREACHABLE_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, - SOURCE_UNDETERMINED_HTML_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, SOURCE_UNREACHABLE_HTML_MESSAGE, SOURCE_UNREACHABLE_MESSAGE, @@ -38,7 +37,6 @@ import { } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; import getSite from '../storage/site.js'; -import isSourceBus from '../storage/source-bus.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { @@ -46,7 +44,6 @@ import { PREVIEW_HOST, SITE_CONFIG, SITE_LOOKUP, - STORE_LOOKUP, UpstreamError, reach, } from '../utils/upstream.js'; @@ -60,12 +57,11 @@ const HTML_POST_TYPE = 'text/html'; const UNREACHABLE_HTML = { [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, - [STORE_LOOKUP]: SOURCE_UNDETERMINED_HTML_MESSAGE, }; // a write reaches no store until the lookup answers, so a failed lookup leaves the destination // undetermined rather than unreachable const UNREACHABLE_TEXT = { - [STORE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, + [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, }; /** @@ -133,28 +129,20 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - // both lookups go out together: the pipeline scope says whether the site exists and what its - // head.html is, the admin scope says which store holds it - const [site, onSourceBus] = await Promise.allSettled([ - reach(SITE_LOOKUP, () => getSite(env, daCtx)), - reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)), - ]); - - // answers no-such-site ahead of either 503, which would ask for a retry that cannot help. - // drops a failed probe on purpose: a site that does not exist needs no store - if (site.status === 'fulfilled' && !site.value.exists) { + // one read of the pipeline scope answers whether the site exists, what its head.html is and + // which store holds it + const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + + if (!site.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); return { noSuchSite: true }; } - // the site lookup first, since the store answer is no use on its own - if (site.status === 'rejected') throw site.reason; - if (onSourceBus.status === 'rejected') throw onSourceBus.reason; - const store = getStore(env, daCtx, onSourceBus.value); + const store = getStore(env, daCtx, site.onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); return { response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)), - head: site.value.head, + head: site.head, }; } @@ -319,7 +307,7 @@ async function sourcePost({ req, env, daCtx }) { // the payload is settled, so the only question left is where it goes // a 404 is an answer, and it means no AEM site config rather than no DA // site, so the write goes to da-admin - const onSourceBus = await reach(STORE_LOOKUP, () => isSourceBus(env, daCtx)); + const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); 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.`); diff --git a/src/storage/site.js b/src/storage/site.js index da0be002..6d9ab7ac 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -11,29 +11,25 @@ */ const TIMEOUT_MS = 5 * 1000; -const NO_SITE = { exists: false, head: undefined }; +const NO_SITE = { exists: false, head: undefined, onSourceBus: false }; /** - * Asks the config service whether a site exists, and reads its head.html from the same answer. + * Asks the config service whether a site exists, what its head.html is and which store holds it. * - * The pipeline scope has the code bus object the delivery pipeline renders into every page of the - * site. A site behind Helix authentication refuses `{ref}--{site}--{org}.aem.page/head.html` to a - * request with no site token, and the config service does not. The admin scope answers existence - * too, and its answer has the site's CDN token and API key metadata. + * The pipeline scope answers all three. A site behind Helix authentication refuses + * `{ref}--{site}--{org}.aem.page/head.html` without a site token, and the config service does not. + * `contentSource.url` names the store, since both stores are `type: markup`. * - * Throws on any refusal but a 404, which is the only status that means there is no such site. A - * ref that was never built exists and has no head.html, which is a 200 with an empty head. + * Throws on any refusal but a 404, which is the only status that means there is no such site. A ref + * that was never built exists and has no head.html, which is a 200 with an empty head. * - * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes and - * `HLX_CONFIG_SERVICE_TOKEN` authorizes it + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it and `AEM_API` is the source bus * @param {Object} daCtx - * @returns {Promise<{exists: boolean, head: string|undefined}>} + * @returns {Promise<{exists: boolean, head: string|undefined, onSourceBus: boolean}>} */ export default async function getSite(env, daCtx) { - const { - org, site, ref, - } = daCtx; - // an unparseable hostname leaves org and site undefined, and there is no site to ask about + const { org, site, ref } = daCtx; if (!org || !site) return NO_SITE; const url = new URL(`/${ref}--${site}--${org}/config.json?scope=pipeline`, env.HLX_CONFIG_SERVICE); @@ -48,6 +44,14 @@ export default async function getSite(env, daCtx) { if (response.status === 404) return NO_SITE; if (!response.ok) throw new Error(`the config service answered ${response.status}`); - const { head } = await response.json(); - return { exists: true, head: head?.html }; + const { head, contentSource } = await response.json(); + // a config cached from before the service served contentSource names no store, and guessing one + // would send a source-bus write to da-admin + if (!contentSource?.url) throw new Error('the config service named no content source'); + + return { + exists: true, + head: head?.html, + onSourceBus: contentSource.url.startsWith(`${env.AEM_API}/`), + }; } diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js deleted file mode 100644 index 59b63af2..00000000 --- a/src/storage/source-bus.js +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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; - -/** - * Asks the config service which store holds a site's content based on `content.source` - * A 404 means there is no such site, which the site lookup reports. Any other refusal is no - * answer at all: reading it as legacy would send a source-bus write to da-admin - * - * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, - * `HLX_CONFIG_SERVICE_TOKEN` authorizes it and `AEM_API` is the source bus - * @param {Object} daCtx - * @returns {Promise} - */ -export default async function isSourceBus(env, daCtx) { - const { org, site, ref } = daCtx; - if (!org || !site) return false; - - const url = new URL(`/${ref}--${site}--${org}/config.json?scope=admin`, env.HLX_CONFIG_SERVICE); - const response = await fetch(url, { - headers: { - 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, - 'x-backend-type': 'aws', - }, - signal: AbortSignal.timeout(TIMEOUT_MS), - }); - - if (response.status === 404) return false; - if (!response.ok) throw new Error(`the config service answered ${response.status}`); - - const { content } = await response.json(); - const source = content?.source?.url; - if (!source) throw new Error('the config service named no content source'); - return source.startsWith(`${env.AEM_API}/`); -} diff --git a/src/utils/constants.js b/src/utils/constants.js index ab505f6f..4336b500 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,17 +51,15 @@ export const DEFAULT_HTML_TEMPLATE = '

404: Site not found

There is no site at this address.

'; -export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry.

'; +export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry, or contact your project admin if it persists.

'; -export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup unreachable

Whether this site exists could not be determined. Please retry.

'; +export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup unreachable

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; -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_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNDETERMINED_HTML_MESSAGE = '

503: Content store undetermined

Which store holds this document could not be determined. Please retry.

'; +export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; -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_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry, or contact your project admin if it persists.'; 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.'; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index f1cffe3a..eff0bd50 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -15,7 +15,6 @@ export const PREVIEW_HOST = 'preview host'; export const CONTENT_STORE = 'content store'; export const SITE_CONFIG = 'site config'; export const SITE_LOOKUP = 'site lookup'; -export const STORE_LOOKUP = 'store lookup'; /** * Renders a failure for the `x-error` header. diff --git a/test/index.test.js b/test/index.test.js index 0bdacba0..ccc058cd 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -189,7 +189,7 @@ describe('worker fetch handler', () => { // 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 }, + '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined, onSourceBus: true }) }, })).default; const uePost = (origin) => { diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 29cc9159..817397cc 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -44,24 +44,22 @@ const recorder = () => { return { env, fetched }; }; -// stands in for the two config service reads the routes make: the pipeline scope for whether the -// site exists and its head.html, the admin scope for which store holds it. Answers that any site -// exists; `upgraded` lists the `org/site` keys whose content source is the source bus +// stands in for the one config service read the routes make: the pipeline scope answers whether +// the site exists, its head.html and which store holds it. Answers that any site exists; +// `upgraded` lists the `org/site` keys whose content source is the source bus const stubLookups = (upgraded = []) => { const asked = []; globalThis.fetch = async (input) => { const url = input.toString(); asked.push(url); - const { pathname, searchParams } = new URL(url); - const [, site, org] = (pathname.split('/')[1] ?? '').split('--'); - if (searchParams.get('scope') === 'admin') { - const source = upgraded.includes(`${org}/${site}`) - ? `https://api.aem.live/${org}/sites/${site}/source` - : `https://content.da.live/${org}/${site}/`; - const body = JSON.stringify({ content: { source: { type: 'markup', url: source } } }); - return new Response(body, { status: 200 }); - } - const body = JSON.stringify({ head: { html: '' } }); + const [, site, org] = ((new URL(url)).pathname.split('/')[1] ?? '').split('--'); + const source = upgraded.includes(`${org}/${site}`) + ? `https://api.aem.live/${org}/sites/${site}/source` + : `https://content.da.live/${org}/${site}/`; + const body = JSON.stringify({ + head: { html: '' }, + contentSource: { type: 'markup', url: source }, + }); return new Response(body, { status: 200 }); }; return asked; @@ -69,9 +67,8 @@ const stubLookups = (upgraded = []) => { const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists: true, head: '' }), + default: async () => ({ exists: true, head: '', onSourceBus: false }), }, - '../../src/storage/source-bus.js': { default: async () => false }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), }, @@ -124,9 +121,8 @@ describe('daSourceGet', () => { calls = { compose: [], ue: 0, quickEdit: 0 }; return (await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists, head: headHtml }), + default: async () => ({ exists, head: headHtml, onSourceBus: false }), }, - '../../src/storage/source-bus.js': { default: async () => false }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), }, @@ -453,7 +449,7 @@ describe('daSourcePost', () => { // nothing is remembered between requests, so a site enrolled or un-enrolled mid-session takes // effect on the next one - it('looks the store up once per write, and asks nothing else', async () => { + it('looks the site up once per write, and asks nothing else', async () => { const asked = stubLookups(['org/lookedupeach']); const { env } = recorder(); @@ -461,8 +457,8 @@ describe('daSourcePost', () => { await write('lookedupeach', env); assert.deepStrictEqual(asked.sort(), [ - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', - 'https://config.aem.page/main--lookedupeach--org/config.json?scope=admin', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); }); }); diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index d56cffa1..26b0981f 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -48,7 +48,7 @@ const build = async (overrides = {}) => { busError, templateError, configError, composeError, config = null, } = overrides; const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, storeLookups: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -72,14 +72,8 @@ const build = async (overrides = {}) => { seen.lookups += 1; // throws for undefined, which is how the lookup reports a failure if (site === undefined) throw lookupError; - return { exists: site.exists, head: headHtml }; - }, - }, - '../../src/storage/source-bus.js': { - default: async () => { - seen.storeLookups += 1; if (busError) throw busError; - return site !== undefined && site.onSourceBus; + return { exists: site.exists, head: headHtml, onSourceBus: site.onSourceBus }; }, }, '../../src/utils/aemCtx.js': { @@ -114,7 +108,7 @@ const build = async (overrides = {}) => { return { ...mod, env, seen }; }; -describe('when the store lookup cannot say which store holds the site', () => { +describe('when the lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); @@ -142,9 +136,9 @@ describe('when the store lookup cannot say which store holds the site', () => { assert.ok(Number(res.headers.get('Retry-After')) > 0); }); - // the preview iframe renders this body, and the store answered nothing here: it was never - // asked, since which store to ask is what could not be determined - it('says the store could not be determined, not that it did not answer', async () => { + // the preview iframe renders this body, and no store was asked: the one read that names the + // store is what failed + it('says the site could not be looked up, not that a store did not answer', async () => { const { daSourceGet, env } = await build({ busError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -152,7 +146,7 @@ describe('when the store lookup cannot say which store holds the site', () => { const body = await res.text(); assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); - assert.strictEqual(body, messages.SOURCE_UNDETERMINED_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_UNREACHABLE_HTML_MESSAGE); }); it('refuses a non-html read too', async () => { @@ -177,22 +171,22 @@ describe('when the store lookup cannot say which store holds the site', () => { }); // the two lookups are two upstreams now, and both 503s share a status and an unparsed body - it('names the probe in x-error', async () => { + it('names the lookup in x-error', async () => { const { daSourceGet, env } = await build({ busError: dead() }); 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'), /store lookup failed/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('names the probe on a HEAD too', async () => { + it('names the lookup on a HEAD too', async () => { const { daSourceHead, env } = await build({ busError: dead() }); 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'), /store lookup failed/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); // the header is the only thing on the wire that separates a timeout from a dropped connection @@ -204,7 +198,7 @@ describe('when the store lookup cannot say which store holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); }); // rendering a thrown non-Error as "undefined: undefined" would leave the 503 saying nothing @@ -215,7 +209,7 @@ describe('when the store lookup cannot say which store holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 503); - assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: Error: boom'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: Error: boom'); }); it('tells a probe failure apart from a store failure', async () => { @@ -675,9 +669,8 @@ describe('reading from the store that holds the site', () => { }; const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { - default: async () => ({ exists: true, head: '' }), + default: async () => ({ exists: true, head: '', onSourceBus: true }), }, - '../../src/storage/source-bus.js': { default: async () => true }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), }, @@ -870,18 +863,6 @@ describe('reading from the store that holds the site', () => { }); // the two lookups go out together, and a site that does not exist needs no store - it('reports no such site even when the probe did not answer', async () => { - const { daSourceGet, env } = await build({ - site: NO_SITE, - busError: 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.status, 404); - }); - // both failed, and the store answer is no use on its own it('reports the failed site lookup when the probe failed with it', async () => { const { daSourceGet, env } = await build({ @@ -931,16 +912,14 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.head[0], ''); }); - // the pipeline scope answers existence and head.html together, so a page that needs the head - // pays for no third read - it('reads each lookup once for a page', async () => { + // the pipeline scope answers existence, head.html and the store together + it('reads the config service once for a page', 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.lookups, 1); - assert.strictEqual(seen.storeLookups, 1); }); // nothing composes an image, and the head that arrives with the existence answer is dropped @@ -951,7 +930,6 @@ describe('reading from the store that holds the site', () => { await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.storeLookups, 1); assert.deepStrictEqual(seen.head, []); }); @@ -962,7 +940,6 @@ describe('reading from the store that holds the site', () => { await daSourceHead({ env, daCtx: getDaCtx(req) }); assert.strictEqual(seen.lookups, 1); - assert.strictEqual(seen.storeLookups, 1); assert.deepStrictEqual(seen.head, []); }); }); @@ -1161,9 +1138,7 @@ describe('when the config service refuses the lookup', () => { HLX_CONFIG_SERVICE_TOKEN: 'shared-token', daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, }; - const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { default: async () => false }, - }); + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', {}); // a preview host rather than a UE host, so nothing is instrumented onto the composed page const req = authedReq('https://main--site--org.preview.da.live/folder/content'); return daSourceGet({ req, env, daCtx: getDaCtx(req) }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 15ed4495..75c6c5b9 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -34,7 +34,7 @@ const build = async (overrides = {}) => { const { status = 201, busError } = overrides; const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; const seen = { - bus: [], legacy: [], lookups: 0, probes: 0, order: [], + bus: [], legacy: [], probes: 0, order: [], }; const capture = async (request) => { const contentType = request.headers.get('Content-Type'); @@ -70,20 +70,12 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - // still mocked, so a write that reached for it would be counted rather than hitting the network '../../src/storage/site.js': { - default: async () => { - seen.lookups += 1; - seen.order.push('lookup'); - return { exists: true, head: undefined }; - }, - }, - '../../src/storage/source-bus.js': { default: async () => { seen.probes += 1; - seen.order.push('probe'); + seen.order.push('lookup'); if (busError) throw busError; - return onSourceBus; + return { exists: true, head: undefined, onSourceBus }; }, }, }); @@ -163,7 +155,7 @@ describe('writing to the store that holds the site', () => { it('names the failed probe in x-error', async () => { const { res } = await post({ busError: dead() }); - assert.match(res.headers.get('x-error'), /store lookup failed/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); it('names the cause, not a category', async () => { @@ -171,7 +163,7 @@ describe('writing to the store that holds the site', () => { busError: new DOMException('timed out', 'TimeoutError'), }); - assert.strictEqual(res.headers.get('x-error'), 'store lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); }); }); @@ -188,13 +180,11 @@ describe('writing to the store that holds the site', () => { }); describe('what a write asks about the site', () => { - // the site lookup and the store lookup read the same service, and a write never reads the - // pipeline scope's answer, so asking it twice buys nothing - it('asks the store lookup only, and reaches the store after it', async () => { + // one read of the pipeline scope answers where the document goes + it('asks one lookup, and reaches the store after it', async () => { const { res, seen } = await post({}); assert.strictEqual(seen.probes, 1); - assert.strictEqual(seen.lookups, 0); assert.strictEqual(seen.order[seen.order.length - 1], 'store'); assert.strictEqual(res.status, 201); }); @@ -281,14 +271,14 @@ describe('writing to the store that holds the site', () => { }); }); - describe('the store lookup on a write', () => { + describe('the 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.slice(-1), ['store']); - assert.ok(seen.order.includes('probe')); + assert.ok(seen.order.includes('lookup')); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { @@ -336,7 +326,7 @@ describe('writing to the store that holds the site', () => { const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 415); - assert.strictEqual(seen.lookups, 0); + assert.strictEqual(seen.probes, 0); assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); }); From 2bc12f408c1ee7085918c4930fe71ad604540775 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 11:03:57 +0200 Subject: [PATCH 29/77] test: a config that names no content source reads as legacy, and says so --- test/storage/site.test.js | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 8da98aa0..54f2eea4 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -149,14 +149,39 @@ describe('getSite', () => { }); }); - // the config service has served `contentSource` since 2026-08-13, and a site whose config has not - // been rewritten since then is answered from a cache that predates it. guessing a store from a - // config that does not name one would send a source-bus write to da-admin describe('when the answer names no content source', () => { - it('throws rather than guessing', async () => { + it('reads as legacy, since that is where a site without one has always been', async () => { stubFetch(() => config({ contentSource: undefined })); - await assert.rejects(() => getSite(env, daCtx()), /content source/); + assert.deepStrictEqual(await getSite(env, daCtx()), { + exists: true, head: HEAD, onSourceBus: false, + }); + }); + + // a source-bus site read as legacy is served the wrong document and written to the wrong store, + // so the site says so in the log rather than only in an author's lost edits + it('names the site in a warning', async () => { + stubFetch(() => config({ contentSource: undefined })); + const warnings = []; + const saved = console.warn; + console.warn = (m) => warnings.push(m); + + try { + await getSite(env, daCtx()); + } finally { + console.warn = saved; + } + + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /org\/site/); + assert.match(warnings[0], /content source/); + }); + + it('answers the same when the source carries no url', async () => { + stubFetch(() => config({ contentSource: { type: 'markup' } })); + + const { onSourceBus } = await getSite(env, daCtx()); + assert.strictEqual(onSourceBus, false); }); }); From 461236ec318660542442289f3aedb69ef713d378 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 11:04:13 +0200 Subject: [PATCH 30/77] fix: read a config with no content source as legacy, and warn --- src/storage/site.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/storage/site.js b/src/storage/site.js index 6d9ab7ac..aabee841 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -16,9 +16,9 @@ const NO_SITE = { exists: false, head: undefined, onSourceBus: false }; /** * Asks the config service whether a site exists, what its head.html is and which store holds it. * - * The pipeline scope answers all three. A site behind Helix authentication refuses - * `{ref}--{site}--{org}.aem.page/head.html` without a site token, and the config service does not. - * `contentSource.url` names the store, since both stores are `type: markup`. + * A site behind Helix authentication refuses `{ref}--{site}--{org}.aem.page/head.html` without a + * site token, and the config service does not. `contentSource.url` names the store, since both + * stores are `type: markup`. * * Throws on any refusal but a 404, which is the only status that means there is no such site. A ref * that was never built exists and has no head.html, which is a 200 with an empty head. @@ -45,13 +45,16 @@ export default async function getSite(env, daCtx) { if (!response.ok) throw new Error(`the config service answered ${response.status}`); const { head, contentSource } = await response.json(); - // a config cached from before the service served contentSource names no store, and guessing one - // would send a source-bus write to da-admin - if (!contentSource?.url) throw new Error('the config service named no content source'); + // a config that names no store is read as legacy, which is where a site without one has always + // been. the warning is there because a source-bus site read that way is written to the store the + // site does not serve + if (!contentSource?.url) { + console.warn(`${url} named no content source, reading ${org}/${site} as legacy`); + } return { exists: true, head: head?.html, - onSourceBus: contentSource.url.startsWith(`${env.AEM_API}/`), + onSourceBus: Boolean(contentSource?.url?.startsWith(`${env.AEM_API}/`)), }; } From 39b57305479798cfae6d23ca6c1d9ee71dc4910d Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 11:17:16 +0200 Subject: [PATCH 31/77] chore: plainer wording for the config read in the readme --- README.md | 4 +++- src/routes/da-admin.js | 5 ----- src/storage/site.js | 4 +--- test/routes/da-admin.test.js | 2 +- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 201ee602..a6f5ef49 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,9 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. -A read asks config.aem.page once, at the pipeline scope, which says whether the site exists, has its head.html and names the content source, whose url says which store holds it. The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to the `SITES` table in that file; a site missing from it is answered 404, and one whose source url is on api.aem.live reads as a source-bus site. +One read of config.aem.page, pipeline scope, answers existence, head.html and `contentSource`. Its url names the store. + +The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to its `SITES` table. A site missing from the table is answered 404. A source url on api.aem.live reads as source-bus. To run da-universal locally: diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 99f232b0..0ff56050 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -129,8 +129,6 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store could not be reached */ async function readSource(env, daCtx, init) { - // one read of the pipeline scope answers whether the site exists, what its head.html is and - // which store holds it const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); if (!site.exists) { @@ -304,9 +302,6 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes - // a 404 is an answer, and it means no AEM site config rather than no DA - // site, so the write goes to da-admin const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); if (onSourceBus) { diff --git a/src/storage/site.js b/src/storage/site.js index aabee841..fd55df7a 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -45,9 +45,7 @@ export default async function getSite(env, daCtx) { if (!response.ok) throw new Error(`the config service answered ${response.status}`); const { head, contentSource } = await response.json(); - // a config that names no store is read as legacy, which is where a site without one has always - // been. the warning is there because a source-bus site read that way is written to the store the - // site does not serve + // a config that names no store is read as legacy if (!contentSource?.url) { console.warn(`${url} named no content source, reading ${org}/${site} as legacy`); } diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 817397cc..03286b75 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -44,7 +44,7 @@ const recorder = () => { return { env, fetched }; }; -// stands in for the one config service read the routes make: the pipeline scope answers whether +// substitute for the config service read the routes make: the pipeline scope answers whether // the site exists, its head.html and which store holds it. Answers that any site exists; // `upgraded` lists the `org/site` keys whose content source is the source bus const stubLookups = (upgraded = []) => { From f7de96e8c730ffc2183ffd4a2ce73faf179c8649 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 13:58:59 +0200 Subject: [PATCH 32/77] test: pin the ctx get.js hands to getCookie --- test/handlers/get.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/handlers/get.test.js b/test/handlers/get.test.js index 86c7634f..dd385729 100644 --- a/test/handlers/get.test.js +++ b/test/handlers/get.test.js @@ -63,13 +63,18 @@ describe('GET handler', () => { describe('gimme_cookie', () => { let getHandler; + let getCookieArgs; beforeEach(async () => { + getCookieArgs = undefined; getHandler = (await esmock('../../src/handlers/get.js', { '../../src/routes/da-admin.js': { daSourceGet: async () => new Response() }, '../../src/routes/aem-proxy.js': { handleAEMProxyRequest: async () => new Response() }, '../../src/routes/cookie.js': { - getCookie: async () => new Response('cookie-set', { status: 200 }), + getCookie: async (args) => { + getCookieArgs = args; + return new Response('cookie-set', { status: 200 }); + }, }, })).default; }); @@ -83,6 +88,9 @@ describe('GET handler', () => { assert.strictEqual(res.status, 200); assert.strictEqual(await res.text(), 'cookie-set'); + assert.strictEqual(getCookieArgs.req, req); + assert.strictEqual(getCookieArgs.env, env); + assert.strictEqual(getCookieArgs.daCtx, daCtx); }); }); From 0fcd36373bb6b8596626cbe626812a274a42c502 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 13:58:59 +0200 Subject: [PATCH 33/77] chore: say what throws an UpstreamError --- src/routes/da-admin.js | 4 ++-- src/utils/upstream.js | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 0ff56050..4c3de03e 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -65,8 +65,8 @@ const UNREACHABLE_TEXT = { }; /** - * Only an upstream that could not be reached is retryable. Anything else reaches the worker - * boundary in src/index.js, which logs it and answers 500. + * An UpstreamError is retryable, so it is answered 503. Anything else reaches the worker boundary + * in src/index.js, which logs it and answers 500. */ function refuseUnreachable(e, method, sourcePath) { if (!(e instanceof UpstreamError)) throw e; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index eff0bd50..56e87924 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -28,10 +28,8 @@ export function causeOf(e) { } /** - * Thrown when an upstream could not be reached at all. - * - * Not a refusal: an upstream that answered 401 or 404 has answered, and the route decides what - * that means. An UpstreamError means there is no answer to read, and it is retryable. + * Thrown when a read has no answer to use: the upstream was not reached, or answered a status the + * reader threw on. A store response is handed back whatever its status, and the route decides. * * @property {string} upstream one of the names at the top of this file */ From fba8ff604e639081add3cb961fbcf383608aa3a9 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 13:59:31 +0200 Subject: [PATCH 34/77] fix: a 503 heading says the read failed, not that the upstream was unreachable --- src/utils/constants.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/constants.js b/src/utils/constants.js index 4336b500..51d6246a 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,11 +51,11 @@ export const DEFAULT_HTML_TEMPLATE = '

404: Site not found

There is no site at this address.

'; -export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host unreachable

The site\'s preview host did not answer. Please retry, or contact your project admin if it persists.

'; +export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; -export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup unreachable

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; +export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

The store that holds this document did not answer. Please retry, or contact your project admin if it persists.

'; +export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; From d9d9df227b95eaf2bb26052fddf324d820bc0770 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:00:28 +0200 Subject: [PATCH 35/77] refactor: name the 503 path for a failed read, not an unreachable upstream --- src/routes/da-admin.js | 42 ++++++++++++++++----------------- src/utils/constants.js | 8 +++---- test/routes/source-read.test.js | 18 +++++++------- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 4c3de03e..79cd0045 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -26,13 +26,13 @@ import { } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, - SITE_UNREACHABLE_HTML_MESSAGE, - PREVIEW_UNREACHABLE_HTML_MESSAGE, + SITE_LOOKUP_FAILED_HTML_MESSAGE, + PREVIEW_FAILED_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, - SOURCE_UNREACHABLE_HTML_MESSAGE, - SOURCE_UNREACHABLE_MESSAGE, + SOURCE_FAILED_HTML_MESSAGE, + SOURCE_FAILED_MESSAGE, UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; import { getSiteConfig } from '../storage/config.js'; @@ -54,13 +54,13 @@ const HTML_POST_TYPE = 'text/html'; * Overrides the store's body for the upstreams that need their own. SITE_CONFIG is read off * da-admin, so it takes the default body and `x-error` is what tells the two reads apart. */ -const UNREACHABLE_HTML = { - [PREVIEW_HOST]: PREVIEW_UNREACHABLE_HTML_MESSAGE, - [SITE_LOOKUP]: SITE_UNREACHABLE_HTML_MESSAGE, +const UPSTREAM_FAILURE_HTML = { + [PREVIEW_HOST]: PREVIEW_FAILED_HTML_MESSAGE, + [SITE_LOOKUP]: SITE_LOOKUP_FAILED_HTML_MESSAGE, }; -// a write reaches no store until the lookup answers, so a failed lookup leaves the destination -// undetermined rather than unreachable -const UNREACHABLE_TEXT = { +// a write reaches no store until the lookup answers, so a failed lookup says the destination is +// undetermined, not that the store failed +const UPSTREAM_FAILURE_TEXT = { [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, }; @@ -68,14 +68,14 @@ const UNREACHABLE_TEXT = { * An UpstreamError is retryable, so it is answered 503. Anything else reaches the worker boundary * in src/index.js, which logs it and answers 500. */ -function refuseUnreachable(e, method, sourcePath) { +function refuseUpstreamFailure(e, method, sourcePath) { if (!(e instanceof UpstreamError)) throw e; console.warn(`503 ${method} ${sourcePath}, ${e.message}`); if (method === 'HEAD') return head503(e.message); if (method === 'POST') { - return post503(UNREACHABLE_TEXT[e.upstream] ?? SOURCE_UNREACHABLE_MESSAGE, e.message); + return post503(UPSTREAM_FAILURE_TEXT[e.upstream] ?? SOURCE_FAILED_MESSAGE, e.message); } - return get503(UNREACHABLE_HTML[e.upstream] ?? SOURCE_UNREACHABLE_HTML_MESSAGE, e.message); + return get503(UPSTREAM_FAILURE_HTML[e.upstream] ?? SOURCE_FAILED_HTML_MESSAGE, e.message); } export function isHtmlPostType(type) { @@ -94,7 +94,7 @@ function getTextBody(data) { } async function getPageTemplate(env, daCtx, aemCtx) { - // answers null for a site with no config, so a store that refuses or is unreachable throws + // answers null for a site with no config, so any other failure throws const config = await reach(SITE_CONFIG, () => getSiteConfig(env, daCtx)); // Search whether a template is configured for this path @@ -126,7 +126,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { * Sets `noSuchSite` when there is no such site, `response` otherwise. * * @returns {Promise<{response?: Response, noSuchSite?: boolean}>} - * @throws {UpstreamError} when the lookup or the store could not be reached + * @throws {UpstreamError} when the lookup or the store fails */ async function readSource(env, daCtx, init) { const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); @@ -234,12 +234,12 @@ async function sourceGet({ req, env, daCtx }) { }); } -/** Wraps sourceGet, turning an unreachable upstream into a 503 in HTML the editor renders. */ +/** Wraps sourceGet, turning a failed upstream read into a 503 in HTML the editor renders. */ export async function daSourceGet({ req, env, daCtx }) { try { return await sourceGet({ req, env, daCtx }); } catch (e) { - return refuseUnreachable(e, 'GET', daCtx.sourcePath); + return refuseUpstreamFailure(e, 'GET', daCtx.sourcePath); } } @@ -259,12 +259,12 @@ async function sourceHead({ env, daCtx }) { return new Response(null, { status: response.status, headers: response.headers }); } -/** Wraps sourceHead, turning an unreachable upstream into a bodyless 503. */ +/** Wraps sourceHead, turning a failed upstream read into a bodyless 503. */ export async function daSourceHead({ env, daCtx }) { try { return await sourceHead({ env, daCtx }); } catch (e) { - return refuseUnreachable(e, 'HEAD', daCtx.sourcePath); + return refuseUpstreamFailure(e, 'HEAD', daCtx.sourcePath); } } @@ -327,13 +327,13 @@ async function sourcePost({ req, env, daCtx }) { } /** - * Wraps sourcePost, turning an unreachable upstream into the plain text the editor shows the + * Wraps sourcePost, turning a failed upstream read into the plain text the editor shows the * author. */ export async function daSourcePost({ req, env, daCtx }) { try { return await sourcePost({ req, env, daCtx }); } catch (e) { - return refuseUnreachable(e, 'POST', daCtx.sourcePath); + return refuseUpstreamFailure(e, 'POST', daCtx.sourcePath); } } diff --git a/src/utils/constants.js b/src/utils/constants.js index 51d6246a..d064362b 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,13 +51,13 @@ export const DEFAULT_HTML_TEMPLATE = '

404: Site not found

There is no site at this address.

'; -export const PREVIEW_UNREACHABLE_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; +export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; -export const SITE_UNREACHABLE_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; +export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; +export const SOURCE_FAILED_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; +export const SOURCE_FAILED_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry, or contact your project admin if it persists.'; diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 26b0981f..4bdab303 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -145,8 +145,8 @@ describe('when the lookup cannot say which store holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); const body = await res.text(); - assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); - assert.strictEqual(body, messages.SITE_UNREACHABLE_HTML_MESSAGE); + assert.notStrictEqual(body, messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_LOOKUP_FAILED_HTML_MESSAGE); }); it('refuses a non-html read too', async () => { @@ -269,8 +269,8 @@ describe('when the config service cannot say whether the site exists', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); const body = await res.text(); - assert.notStrictEqual(body, messages.SOURCE_UNREACHABLE_HTML_MESSAGE); - assert.strictEqual(body, messages.SITE_UNREACHABLE_HTML_MESSAGE); + assert.notStrictEqual(body, messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_LOOKUP_FAILED_HTML_MESSAGE); }); it('refuses a HEAD with 503 and no body', async () => { @@ -444,8 +444,8 @@ describe('reading from the store that holds the site', () => { const htmlRes = await daSourceGet({ req: html, env, daCtx: getDaCtx(html) }); const assetRes = await daSourceGet({ req: asset, env, daCtx: getDaCtx(asset) }); - assert.strictEqual(await htmlRes.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); - assert.strictEqual(await assetRes.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await htmlRes.text(), messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(await assetRes.text(), messages.SOURCE_FAILED_HTML_MESSAGE); }); it('answers 503 with no body on a HEAD', async () => { @@ -1089,7 +1089,7 @@ describe('when the site config cannot be reached', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(await res.text(), messages.SOURCE_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await res.text(), messages.SOURCE_FAILED_HTML_MESSAGE); }); it('names the site config in x-error', async () => { @@ -1224,7 +1224,7 @@ describe('a path the site config gives a template', () => { assert.strictEqual(res.status, 503); assert.match(res.headers.get('x-error'), /preview host failed/); - assert.strictEqual(await res.text(), messages.PREVIEW_UNREACHABLE_HTML_MESSAGE); + assert.strictEqual(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); }); it('takes the longest matching prefix', async () => { @@ -1270,7 +1270,7 @@ describe('when the worker itself has a bug', () => { delete globalThis.fetch; }); - // only an unreachable upstream is retryable, and a 503 would keep the throw out of the log + // only a failed upstream read is retryable, and a 503 would keep the throw out of the log // the worker boundary writes it('lets the throw through rather than rendering it as a 503', async () => { const { daSourceGet, env } = await build({ composeError: new TypeError('tree is not iterable') }); From f0fd2bd126e71d6eb4d935212f9ede853ae26021 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:47:03 +0200 Subject: [PATCH 36/77] test: a write is refused for a site the config service does not know --- test/routes/source-write.test.js | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 75c6c5b9..2da31cf3 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -31,7 +31,7 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201, busError } = overrides; + const { status = 201, busError, exists = true } = overrides; const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; const seen = { bus: [], legacy: [], probes: 0, order: [], @@ -75,7 +75,7 @@ const build = async (overrides = {}) => { seen.probes += 1; seen.order.push('lookup'); if (busError) throw busError; - return { exists: true, head: undefined, onSourceBus }; + return { exists, head: undefined, onSourceBus }; }, }, }); @@ -167,16 +167,32 @@ describe('writing to the store that holds the site', () => { }); }); - // the store lookup answers false for a site the config service does not know, since a 404 means - // no AEM site config rather than no DA site + // a read of the same path answers 404, and a write the reader cannot get back is worse than a + // refusal the author sees describe('a site the config service does not know', () => { - it('is written to da-admin all the same', async () => { - const { res, seen } = await post({ site: LEGACY_STORE }); + it('is refused with 404 and touches neither store', async () => { + const { res, seen } = await post({ exists: false }); - assert.strictEqual(res.status, 201); - assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(res.status, 404); + assert.strictEqual(seen.legacy.length, 0); assert.strictEqual(seen.bus.length, 0); }); + + it('says what happened in plain text', async () => { + const { res } = await post({ exists: false }); + + assert.match(res.headers.get('Content-Type'), /^text\/plain/); + assert.strictEqual( + await res.text(), + 'There is no site at this address, so nothing was written.', + ); + }); + + it('does not ask the caller to retry, since the site will not appear', async () => { + const { res } = await post({ exists: false }); + + assert.strictEqual(res.headers.get('Retry-After'), null); + }); }); describe('what a write asks about the site', () => { From 3cafda2db7bb65bdd25b1909a3bdc7c9c84425e9 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:47:35 +0200 Subject: [PATCH 37/77] fix: refuse a write for a site the config service does not know --- src/responses/index.js | 4 ++++ src/routes/da-admin.js | 10 ++++++++-- src/utils/constants.js | 2 ++ test/routes/source-write.test.js | 11 ++++++----- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/responses/index.js b/src/responses/index.js index 41758933..c07a3abb 100644 --- a/src/responses/index.js +++ b/src/responses/index.js @@ -77,6 +77,10 @@ export function post503(message = '', error = '') { }); } +export function post404(message = '') { + return daResp({ body: message, status: 404, contentType: 'text/plain; charset=utf-8' }); +} + // 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({ diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 79cd0045..de1fcde4 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -22,13 +22,14 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head404, head503, post405, post503, + daResp, get401, get404, get415, get503, head401, head404, head503, post404, post405, post503, } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, SITE_LOOKUP_FAILED_HTML_MESSAGE, PREVIEW_FAILED_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, + SITE_NOT_FOUND_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, SOURCE_FAILED_HTML_MESSAGE, @@ -302,7 +303,12 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - const { onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + const { exists, onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + + if (!exists) { + console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return post404(SITE_NOT_FOUND_MESSAGE); + } 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.`); diff --git a/src/utils/constants.js b/src/utils/constants.js index d064362b..aa27bcfb 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -51,6 +51,8 @@ export const DEFAULT_HTML_TEMPLATE = '

404: Site not found

There is no site at this address.

'; +export const SITE_NOT_FOUND_MESSAGE = 'There is no site at this address, so nothing was written.'; + export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index 2da31cf3..ca255f2e 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -14,7 +14,11 @@ 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'; +import { + SITE_NOT_FOUND_MESSAGE, + 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

'; @@ -182,10 +186,7 @@ describe('writing to the store that holds the site', () => { const { res } = await post({ exists: false }); assert.match(res.headers.get('Content-Type'), /^text\/plain/); - assert.strictEqual( - await res.text(), - 'There is no site at this address, so nothing was written.', - ); + assert.strictEqual(await res.text(), SITE_NOT_FOUND_MESSAGE); }); it('does not ask the caller to retry, since the site will not appear', async () => { From 46ed7ecc5df0630019afd5655281ff1c1fa02efd Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:47:57 +0200 Subject: [PATCH 38/77] test: a failed site config read says so, instead of naming the store --- test/routes/source-read.test.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 4bdab303..52217e92 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -1081,15 +1081,18 @@ describe('when the site config cannot be reached', () => { assert.strictEqual(res.status, 503); }); - // da-admin serves the config as well as the document, so the store is what did not answer. - // x-error is what separates the two reads - it('says the store did not answer', async () => { + // the config is read off da-admin while the document can be on the source bus, so naming the + // store would name a system that answered + it('says the site config failed, not the store', async () => { const { daSourceGet, env } = await build(missing()); 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(), messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual( + await res.text(), + '

503: Site config failed

The site\'s configuration could not be read. Please retry, or contact your project admin if it persists.

', + ); }); it('names the site config in x-error', async () => { From 411ac04df992d09323b63d28de1f529b64a8194c Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:48:29 +0200 Subject: [PATCH 39/77] fix: name the site config in its own 503 body --- src/routes/da-admin.js | 7 +++---- src/utils/constants.js | 2 ++ test/routes/source-read.test.js | 5 +---- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index de1fcde4..159449e0 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -28,6 +28,7 @@ import { DEFAULT_HTML_TEMPLATE, SITE_LOOKUP_FAILED_HTML_MESSAGE, PREVIEW_FAILED_HTML_MESSAGE, + SITE_CONFIG_FAILED_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SITE_NOT_FOUND_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, @@ -51,12 +52,10 @@ import { const HTML_POST_TYPE = 'text/html'; -/** - * Overrides the store's body for the upstreams that need their own. SITE_CONFIG is read off - * da-admin, so it takes the default body and `x-error` is what tells the two reads apart. - */ +/** Names the upstream that failed, since the default body names the store. */ const UPSTREAM_FAILURE_HTML = { [PREVIEW_HOST]: PREVIEW_FAILED_HTML_MESSAGE, + [SITE_CONFIG]: SITE_CONFIG_FAILED_HTML_MESSAGE, [SITE_LOOKUP]: SITE_LOOKUP_FAILED_HTML_MESSAGE, }; // a write reaches no store until the lookup answers, so a failed lookup says the destination is diff --git a/src/utils/constants.js b/src/utils/constants.js index aa27bcfb..de496586 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -55,6 +55,8 @@ export const SITE_NOT_FOUND_MESSAGE = 'There is no site at this address, so noth export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; +export const SITE_CONFIG_FAILED_HTML_MESSAGE = '

503: Site config failed

The site\'s configuration could not be read. Please retry, or contact your project admin if it persists.

'; + export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; export const SOURCE_FAILED_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 52217e92..29ecd28f 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -1089,10 +1089,7 @@ describe('when the site config cannot be reached', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual( - await res.text(), - '

503: Site config failed

The site\'s configuration could not be read. Please retry, or contact your project admin if it persists.

', - ); + assert.strictEqual(await res.text(), messages.SITE_CONFIG_FAILED_HTML_MESSAGE); }); it('names the site config in x-error', async () => { From 1e73ef331d83aef7fb73c83b043131efe1bcaddf Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:49:39 +0200 Subject: [PATCH 40/77] fix: ignore the wrangler build dir the dev shim creates under dev/ --- eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 1c5ff742..abbd1c37 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,7 @@ import { defineConfig, globalIgnores } from '@eslint/config-helpers'; import { recommended, source, test } from '@adobe/eslint-config-helix'; export default defineConfig([ - globalIgnores(['.vscode/*', '.wrangler/*', 'coverage/*']), + globalIgnores(['.vscode/*', '**/.wrangler/**', 'coverage/*']), { languageOptions: { ...recommended.languageOptions, From 62ec448e0b4b2efc885dc4e6217b96d0effeeeeb Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 14:51:43 +0200 Subject: [PATCH 41/77] test: say what the one lookup answers, and drop the knob for the probe that is gone --- test/routes/source-read.test.js | 54 ++++++++++++--------------------- 1 file changed, 19 insertions(+), 35 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 29ecd28f..c8245284 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -20,8 +20,7 @@ import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); -// what the two lookups answer between them: `exists` comes from the pipeline scope, -// `onSourceBus` from the admin scope +// what the one read of the pipeline scope answers const SOURCE_BUS = { exists: true, onSourceBus: true }; const LEGACY_STORE = { exists: true, onSourceBus: false }; const NO_SITE = { exists: false, onSourceBus: false }; @@ -43,9 +42,8 @@ const build = async (overrides = {}) => { ? overrides.templateHtml : 'from the template'; const site = 'site' in overrides ? overrides.site : LEGACY_STORE; - const lookupError = 'lookupError' in overrides ? overrides.lookupError : new TypeError('fetch failed'); const { - busError, templateError, configError, composeError, config = null, + lookupError, templateError, configError, composeError, config = null, } = overrides; const seen = { bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, @@ -70,15 +68,16 @@ const build = async (overrides = {}) => { '../../src/storage/site.js': { default: async () => { seen.lookups += 1; - // throws for undefined, which is how the lookup reports a failure - if (site === undefined) throw lookupError; - if (busError) throw busError; + // the lookup reports a failure by throwing + if (lookupError) throw lookupError; + if (site === undefined) throw new TypeError('fetch failed'); return { exists: site.exists, head: headHtml, onSourceBus: site.onSourceBus }; }, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ previewUrl: 'https://main--site--org.aem.page' }), - // the template is the only preview host read left on this path + // the template is the preview host read this stub owns. composeHtml reads metadata.json off + // the same host, and it is stubbed below, so the suite never sees that one getAEMHtml: async (aemCtx, path) => { if (templateError) throw templateError; seen.aem.push(path); @@ -118,7 +117,7 @@ describe('when the lookup cannot say which store holds the site', () => { // 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({ busError: dead() }); + const { daSourceGet, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -128,7 +127,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ busError: dead() }); + const { daSourceGet, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -139,7 +138,7 @@ describe('when the lookup cannot say which store holds the site', () => { // the preview iframe renders this body, and no store was asked: the one read that names the // store is what failed it('says the site could not be looked up, not that a store did not answer', async () => { - const { daSourceGet, env } = await build({ busError: dead() }); + const { daSourceGet, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -150,7 +149,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('refuses a non-html read too', async () => { - const { daSourceGet, env, seen } = await build({ busError: dead() }); + const { daSourceGet, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -160,7 +159,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('refuses a HEAD with 503 and no body', async () => { - const { daSourceHead, env, seen } = await build({ busError: dead() }); + const { daSourceHead, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -170,9 +169,9 @@ describe('when the lookup cannot say which store holds the site', () => { assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); - // the two lookups are two upstreams now, and both 503s share a status and an unparsed body + // every 503 shares a status and an unparsed body, so x-error is what names the upstream it('names the lookup in x-error', async () => { - const { daSourceGet, env } = await build({ busError: dead() }); + const { daSourceGet, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -181,7 +180,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('names the lookup on a HEAD too', async () => { - const { daSourceHead, env } = await build({ busError: dead() }); + const { daSourceHead, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -192,7 +191,7 @@ describe('when the lookup cannot say which store holds the site', () => { // the header is the only thing on the wire that separates a timeout from a dropped connection it('names the cause, not a category', async () => { const { daSourceGet, env } = await build({ - busError: new DOMException('timed out', 'TimeoutError'), + lookupError: new DOMException('timed out', 'TimeoutError'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -203,7 +202,7 @@ describe('when the lookup cannot say which store holds the site', () => { // 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({ busError: 'boom' }); + const { daSourceGet, env } = await build({ lookupError: 'boom' }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -862,21 +861,6 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); - // the two lookups go out together, and a site that does not exist needs no store - // both failed, and the store answer is no use on its own - it('reports the failed site lookup when the probe failed with it', async () => { - const { daSourceGet, env } = await build({ - site: undefined, - busError: 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.status, 503); - assert.match(res.headers.get('x-error'), /site lookup failed/); - }); - it('reports an unreachable store on a site with no head.html', async () => { const { daSourceGet, env } = await build({ headHtml: undefined, @@ -923,7 +907,7 @@ describe('reading from the store that holds the site', () => { }); // nothing composes an image, and the head that arrives with the existence answer is dropped - it('reads the same two lookups for an asset, and no more', async () => { + it('reads the config service once for an asset, and no more', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -933,7 +917,7 @@ describe('reading from the store that holds the site', () => { assert.deepStrictEqual(seen.head, []); }); - it('reads the same two lookups on a HEAD, and no more', async () => { + it('reads the config service once on a HEAD, and no more', async () => { const { daSourceHead, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); From e473c889e3a1a043dab2a3f9f770f10dbf54161b Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 15:31:35 +0200 Subject: [PATCH 42/77] chore: drop getOrgConfig, which nothing imports --- src/storage/config.js | 4 --- test/storage/config.test.js | 67 ------------------------------------- 2 files changed, 71 deletions(-) diff --git a/src/storage/config.js b/src/storage/config.js index 1b8816af..5d163793 100644 --- a/src/storage/config.js +++ b/src/storage/config.js @@ -37,7 +37,3 @@ async function fetchConfig(env, daCtx, path) { export async function getSiteConfig(env, daCtx) { return fetchConfig(env, daCtx, `/config/${daCtx.org}/${daCtx.site}`); } - -export async function getOrgConfig(env, daCtx) { - return fetchConfig(env, daCtx, `/config/${daCtx.org}`); -} diff --git a/test/storage/config.test.js b/test/storage/config.test.js index a0336a1a..b042189d 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -159,73 +159,6 @@ describe('Config Module', () => { }); }); - describe('getOrgConfig', () => { - it('should fetch org config successfully (single-sheet)', async () => { - const mockData = [ - { key: 'editor.ue.template', value: '/content=/templates' }, - { key: 'editor.ue.template', value: '/components=/blocks' }, - { key: 'editor.ue.template', value: '/assets=/media' }, - ]; - setMockResponse({ data: mockData }); - - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - - const expectedCall = { - url: 'https://admin.da.live/config/test-org', - opts: { - headers: new Headers({ - authorization: 'test-token', - }), - }, - }; - assert.strictEqual(mockFetch.lastCall.url, expectedCall.url); - assert.deepStrictEqual( - Object.fromEntries(mockFetch.lastCall.opts.headers.entries()), - Object.fromEntries(expectedCall.opts.headers.entries()), - ); - assert.deepStrictEqual(result, mockData); - }); - - it('should fetch org config successfully (multi-sheet)', async () => { - const multiSheet = { - data: { - total: 2, - limit: 2, - offset: 0, - data: [ - { key: 'org.setting', value: 'org-value' }, - { key: 'editor.ue.template', value: '/org=/org-templates.html' }, - ], - }, - library: { - total: 1, - limit: 1, - offset: 0, - data: [ - { - title: 'Org Blocks', path: 'https://content.da.live/org/library/blocks.json', format: '', ref: '', icon: '', experience: '', - }, - ], - }, - ':names': ['data', 'library'], - ':version': 3, - ':type': 'multi-sheet', - }; - setMockResponse(multiSheet); - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - // Should return only the first sheet's data array - assert.deepStrictEqual(result, multiSheet.data.data); - }); - - it('should return null when there is no config', async () => { - mockFetch.nextResponse = { ok: false, status: 404 }; - - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - - assert.strictEqual(result, null); - }); - }); - describe('Authorization handling', () => { it('should not include authorization header when authToken is not provided', async () => { const ctxWithoutToken = { org: 'test-org', site: 'test-site' }; From 6bd7be1df33dde0344dcc0efd2e13fd1fdf37b8e Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 15:50:01 +0200 Subject: [PATCH 43/77] test: the editor config names itself when it fails --- src/routes/da-admin.js | 10 ++++----- src/storage/config.js | 2 +- src/storage/site.js | 2 +- test/routes/da-admin.test.js | 4 ++-- test/routes/source-read.test.js | 16 +++++++-------- test/storage/config.test.js | 14 ++++++------- test/storage/site.test.js | 36 ++++++++++++++++----------------- 7 files changed, 42 insertions(+), 42 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 159449e0..9439e194 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -37,8 +37,8 @@ import { SOURCE_FAILED_MESSAGE, UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; -import { getSiteConfig } from '../storage/config.js'; -import getSite from '../storage/site.js'; +import { getEditorConfig } from '../storage/config.js'; +import getSiteConfig from '../storage/site.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { @@ -95,7 +95,7 @@ function getTextBody(data) { async function getPageTemplate(env, daCtx, aemCtx) { // answers null for a site with no config, so any other failure throws - const config = await reach(SITE_CONFIG, () => getSiteConfig(env, daCtx)); + const config = await reach(SITE_CONFIG, () => getEditorConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config @@ -129,7 +129,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store fails */ async function readSource(env, daCtx, init) { - const site = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + const site = await reach(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); if (!site.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); @@ -302,7 +302,7 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - const { exists, onSourceBus } = await reach(SITE_LOOKUP, () => getSite(env, daCtx)); + const { exists, onSourceBus } = await reach(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); if (!exists) { console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); diff --git a/src/storage/config.js b/src/storage/config.js index 5d163793..7147054c 100644 --- a/src/storage/config.js +++ b/src/storage/config.js @@ -34,6 +34,6 @@ async function fetchConfig(env, daCtx, path) { return data; } -export async function getSiteConfig(env, daCtx) { +export async function getEditorConfig(env, daCtx) { return fetchConfig(env, daCtx, `/config/${daCtx.org}/${daCtx.site}`); } diff --git a/src/storage/site.js b/src/storage/site.js index fd55df7a..79626c45 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -28,7 +28,7 @@ const NO_SITE = { exists: false, head: undefined, onSourceBus: false }; * @param {Object} daCtx * @returns {Promise<{exists: boolean, head: string|undefined, onSourceBus: boolean}>} */ -export default async function getSite(env, daCtx) { +export default async function getSiteConfig(env, daCtx) { const { org, site, ref } = daCtx; if (!org || !site) return NO_SITE; diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 03286b75..75840c8a 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -144,8 +144,8 @@ describe('daSourceGet', () => { buildQuickEditCookie: (p) => `da-quick-edit=${encodeURIComponent(p)}; Path=/`, }, '../../src/storage/config.js': { - // da-admin answers a site with no config with a 404, which getSiteConfig reports as null - getSiteConfig: async () => null, + // da-admin answers a site with no config with a 404, which getEditorConfig reports as null + getEditorConfig: async () => null, }, })).daSourceGet; }; diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index c8245284..4df056cd 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -97,8 +97,8 @@ const build = async (overrides = {}) => { applyUEInstrumentation: async () => { seen.ue += 1; }, }, '../../src/storage/config.js': { - // da-admin answers a site with no config with a 404, which getSiteConfig reports as null - getSiteConfig: async () => { + // da-admin answers a site with no config with a 404, which getEditorConfig reports as null + getEditorConfig: async () => { if (configError) throw configError; return config; }, @@ -1044,7 +1044,7 @@ describe('reading from the store that holds the site', () => { }); }); -describe('when the site config cannot be reached', () => { +describe('when the editor config cannot be reached', () => { afterEach(() => { delete globalThis.fetch; }); @@ -1067,22 +1067,22 @@ describe('when the site config cannot be reached', () => { // the config is read off da-admin while the document can be on the source bus, so naming the // store would name a system that answered - it('says the site config failed, not the store', async () => { + it('says the editor config failed, not the store', async () => { const { daSourceGet, env } = await build(missing()); 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(), messages.SITE_CONFIG_FAILED_HTML_MESSAGE); + assert.strictEqual(await res.text(), '

503: Editor config failed

The editor configuration for this site could not be read. Please retry, or contact your project admin if it persists.

'); }); - it('names the site config in x-error', async () => { + it('names the editor config in x-error', async () => { const { daSourceGet, env } = await build(missing()); 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'), 'site config failed: TypeError: fetch failed'); + assert.strictEqual(res.headers.get('x-error'), 'editor config failed: TypeError: fetch failed'); }); it('asks the caller to retry', async () => { @@ -1174,7 +1174,7 @@ describe('a read that carries no token', () => { }); }); -describe('a path the site config gives a template', () => { +describe('a path the editor config gives a template', () => { afterEach(() => { delete globalThis.fetch; }); diff --git a/test/storage/config.test.js b/test/storage/config.test.js index b042189d..73549234 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -67,7 +67,7 @@ describe('Config Module', () => { } }; - describe('getSiteConfig', () => { + describe('getEditorConfig', () => { it('should fetch site config successfully (single-sheet)', async () => { const mockData = [ { key: 'editor.ue.template', value: '/content=/templates' }, @@ -75,7 +75,7 @@ describe('Config Module', () => { ]; setMockResponse({ data: mockData }); - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', @@ -122,7 +122,7 @@ describe('Config Module', () => { ':type': 'multi-sheet', }; setMockResponse(multiSheet); - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); // Should return only the first sheet's data array assert.deepStrictEqual(result, multiSheet.data.data); }); @@ -130,7 +130,7 @@ describe('Config Module', () => { it('should return null when there is no config', async () => { mockFetch.nextResponse = { ok: false, status: 404 }; - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); assert.strictEqual(result, null); }); @@ -141,7 +141,7 @@ describe('Config Module', () => { it(`should return null when the store answers ${status}`, async () => { mockFetch.nextResponse = { ok: false, status }; - assert.strictEqual(await configModule.getSiteConfig(mockEnv, mockDaCtx), null); + assert.strictEqual(await configModule.getEditorConfig(mockEnv, mockDaCtx), null); }); }); @@ -152,7 +152,7 @@ describe('Config Module', () => { mockFetch.nextResponse = { ok: false, status }; await assert.rejects( - () => configModule.getSiteConfig(mockEnv, mockDaCtx), + () => configModule.getEditorConfig(mockEnv, mockDaCtx), new RegExp(String(status)), ); }); @@ -166,7 +166,7 @@ describe('Config Module', () => { { key: 'editor.ue.template', value: '/content=/templates' }, ]); - await configModule.getSiteConfig(mockEnv, ctxWithoutToken); + await configModule.getEditorConfig(mockEnv, ctxWithoutToken); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 54f2eea4..78ae49c1 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -13,7 +13,7 @@ /* eslint-env mocha */ import assert from 'assert'; -const { default: getSite } = await import('../../src/storage/site.js'); +const { default: getSiteConfig } = await import('../../src/storage/site.js'); const env = { AEM_API: 'https://api.aem.live', @@ -45,7 +45,7 @@ const config = (over = {}) => new Response(JSON.stringify({ const onBus = () => config({ contentSource: { type: 'markup', url: 'https://api.aem.live/org/sites/site/source' } }); -describe('getSite', () => { +describe('getSiteConfig', () => { afterEach(() => { delete globalThis.fetch; }); @@ -54,7 +54,7 @@ describe('getSite', () => { it('asks the config service once, for the pipeline scope', async () => { stubFetch(config); - await getSite(env, daCtx()); + await getSiteConfig(env, daCtx()); assert.strictEqual(calls.length, 1); assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); @@ -63,7 +63,7 @@ describe('getSite', () => { it('sends the shared secret', async () => { stubFetch(config); - await getSite(env, daCtx()); + await getSiteConfig(env, daCtx()); const headers = new Headers(calls[0].init.headers); assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); @@ -73,7 +73,7 @@ describe('getSite', () => { it('gives up rather than hanging', async () => { stubFetch(config); - await getSite(env, daCtx()); + await getSiteConfig(env, daCtx()); assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); }); @@ -83,7 +83,7 @@ describe('getSite', () => { it('answers existence, head.html and the store together', async () => { stubFetch(onBus); - assert.deepStrictEqual(await getSite(env, daCtx()), { + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { exists: true, head: HEAD, onSourceBus: true, }); }); @@ -91,14 +91,14 @@ describe('getSite', () => { it('reads the url, not the type, since both stores are markup', async () => { stubFetch(config); - const { onSourceBus } = await getSite(env, daCtx()); + const { onSourceBus } = await getSiteConfig(env, daCtx()); assert.strictEqual(onSourceBus, false); }); it('takes the source bus origin from env', async () => { stubFetch(() => config({ contentSource: { type: 'markup', url: 'https://api.stage.example/o/sites/s/source' } })); - const { onSourceBus } = await getSite({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()); + const { onSourceBus } = await getSiteConfig({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()); assert.strictEqual(onSourceBus, true); }); @@ -106,7 +106,7 @@ describe('getSite', () => { it(`answers legacy for ${new URL(url).host}`, async () => { stubFetch(() => config({ contentSource: { type: 'markup', url } })); - const { onSourceBus } = await getSite(env, daCtx()); + const { onSourceBus } = await getSiteConfig(env, daCtx()); assert.strictEqual(onSourceBus, false); }); }); @@ -115,7 +115,7 @@ describe('getSite', () => { it('answers a missing head as undefined, and still names the store', async () => { stubFetch(() => config({ head: undefined })); - const { exists, head, onSourceBus } = await getSite(env, daCtx()); + const { exists, head, onSourceBus } = await getSiteConfig(env, daCtx()); assert.strictEqual(exists, true); assert.strictEqual(head, undefined); assert.strictEqual(onSourceBus, false); @@ -126,7 +126,7 @@ describe('getSite', () => { it('answers no-site on a 404', async () => { stubFetch(() => new Response('', { status: 404 })); - assert.deepStrictEqual(await getSite(env, daCtx()), { + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { exists: false, head: undefined, onSourceBus: false, }); }); @@ -141,7 +141,7 @@ describe('getSite', () => { it(`answers no-site without asking: ${what}`, async () => { stubFetch(config); - assert.deepStrictEqual(await getSite(env, daCtx(over)), { + assert.deepStrictEqual(await getSiteConfig(env, daCtx(over)), { exists: false, head: undefined, onSourceBus: false, }); assert.strictEqual(calls.length, 0); @@ -153,7 +153,7 @@ describe('getSite', () => { it('reads as legacy, since that is where a site without one has always been', async () => { stubFetch(() => config({ contentSource: undefined })); - assert.deepStrictEqual(await getSite(env, daCtx()), { + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { exists: true, head: HEAD, onSourceBus: false, }); }); @@ -167,7 +167,7 @@ describe('getSite', () => { console.warn = (m) => warnings.push(m); try { - await getSite(env, daCtx()); + await getSiteConfig(env, daCtx()); } finally { console.warn = saved; } @@ -180,7 +180,7 @@ describe('getSite', () => { it('answers the same when the source carries no url', async () => { stubFetch(() => config({ contentSource: { type: 'markup' } })); - const { onSourceBus } = await getSite(env, daCtx()); + const { onSourceBus } = await getSiteConfig(env, daCtx()); assert.strictEqual(onSourceBus, false); }); }); @@ -190,14 +190,14 @@ describe('getSite', () => { it(`throws on a ${status}`, async () => { stubFetch(() => new Response('', { status })); - await assert.rejects(() => getSite(env, daCtx()), new RegExp(`${status}`)); + await assert.rejects(() => getSiteConfig(env, daCtx()), new RegExp(`${status}`)); }); }); it('throws when the answer is not json', async () => { stubFetch(() => new Response('', { status: 200 })); - await assert.rejects(() => getSite(env, daCtx())); + await assert.rejects(() => getSiteConfig(env, daCtx())); }); // the cause reaches the caller, which reports it on the 503 as `x-error` @@ -206,7 +206,7 @@ describe('getSite', () => { throw new TypeError('fetch failed'); }); - await assert.rejects(getSite(env, daCtx()), { message: 'fetch failed' }); + await assert.rejects(getSiteConfig(env, daCtx()), { message: 'fetch failed' }); }); }); }); From e96aae5f81358af6e39aa43921b4d2669d525f54 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 14 Aug 2026 15:50:21 +0200 Subject: [PATCH 44/77] fix: name the editor config read for what it reads --- src/routes/da-admin.js | 8 ++++---- src/utils/constants.js | 2 +- src/utils/upstream.js | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 9439e194..491b722a 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -28,7 +28,7 @@ import { DEFAULT_HTML_TEMPLATE, SITE_LOOKUP_FAILED_HTML_MESSAGE, PREVIEW_FAILED_HTML_MESSAGE, - SITE_CONFIG_FAILED_HTML_MESSAGE, + EDITOR_CONFIG_FAILED_HTML_MESSAGE, SITE_NOT_FOUND_HTML_MESSAGE, SITE_NOT_FOUND_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, @@ -44,7 +44,7 @@ import { restoreAbsoluteImages } from '../render/rewrite-images.js'; import { CONTENT_STORE, PREVIEW_HOST, - SITE_CONFIG, + EDITOR_CONFIG, SITE_LOOKUP, UpstreamError, reach, @@ -55,7 +55,7 @@ const HTML_POST_TYPE = 'text/html'; /** Names the upstream that failed, since the default body names the store. */ const UPSTREAM_FAILURE_HTML = { [PREVIEW_HOST]: PREVIEW_FAILED_HTML_MESSAGE, - [SITE_CONFIG]: SITE_CONFIG_FAILED_HTML_MESSAGE, + [EDITOR_CONFIG]: EDITOR_CONFIG_FAILED_HTML_MESSAGE, [SITE_LOOKUP]: SITE_LOOKUP_FAILED_HTML_MESSAGE, }; // a write reaches no store until the lookup answers, so a failed lookup says the destination is @@ -95,7 +95,7 @@ function getTextBody(data) { async function getPageTemplate(env, daCtx, aemCtx) { // answers null for a site with no config, so any other failure throws - const config = await reach(SITE_CONFIG, () => getEditorConfig(env, daCtx)); + const config = await reach(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config diff --git a/src/utils/constants.js b/src/utils/constants.js index de496586..5093c3a5 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -55,7 +55,7 @@ export const SITE_NOT_FOUND_MESSAGE = 'There is no site at this address, so noth export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; -export const SITE_CONFIG_FAILED_HTML_MESSAGE = '

503: Site config failed

The site\'s configuration could not be read. Please retry, or contact your project admin if it persists.

'; +export const EDITOR_CONFIG_FAILED_HTML_MESSAGE = '

503: Editor config failed

The editor configuration for this site could not be read. Please retry, or contact your project admin if it persists.

'; export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; diff --git a/src/utils/upstream.js b/src/utils/upstream.js index 56e87924..ec83b926 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -13,7 +13,7 @@ /** Names the upstream in the worker log and in `x-error`. */ export const PREVIEW_HOST = 'preview host'; export const CONTENT_STORE = 'content store'; -export const SITE_CONFIG = 'site config'; +export const EDITOR_CONFIG = 'editor config'; export const SITE_LOOKUP = 'site lookup'; /** From c2268bc5718ceb3ae5773cddfd371004d76b429a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:28:19 +0200 Subject: [PATCH 45/77] chore: clarify naming Co-authored-by: Tobias Bocanegra --- src/utils/upstream.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/utils/upstream.js b/src/utils/upstream.js index ec83b926..f1ac8f5f 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -42,16 +42,16 @@ export class UpstreamError extends Error { } /** - * Runs `read` and rethrows anything it throws as an UpstreamError naming `upstream`. + * Runs `call` and rethrows anything it throws as an UpstreamError naming `upstream`. * * @param {string} upstream one of the names at the top of this file - * @param {() => Promise} read + * @param {() => Promise} call * @returns {Promise} * @template T */ -export async function reach(upstream, read) { +export async function withUpstream(upstream, call) { try { - return await read(); + return await call(); } catch (e) { // keeps the inner upstream name rather than overwriting it if (e instanceof UpstreamError) throw e; From 52637bdcd5a8fb330750d3be3b82c5efef1f05f8 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:42:36 +0200 Subject: [PATCH 46/77] fix: keep latin-1 characters in x-error instead of blanking them Co-authored-by: Tobias Bocanegra --- src/utils/upstream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/upstream.js b/src/utils/upstream.js index f1ac8f5f..5dad13ba 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -21,7 +21,7 @@ export const SITE_LOOKUP = 'site lookup'; */ export function causeOf(e) { return `${e?.name ?? 'Error'}: ${e?.message ?? e}` - .replace(/[^\x20-\x7e]/g, ' ') + .replace(/[^\t\u0020-\u007E\u0080-\u00FF]/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 1024); From c2364ea3eadbb5748d521019c188d10a41ab18ac Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:53:03 +0200 Subject: [PATCH 47/77] fix: finish the withUpstream rename in da-admin, the branch did not load --- src/routes/da-admin.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 491b722a..931424cb 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -47,7 +47,7 @@ import { EDITOR_CONFIG, SITE_LOOKUP, UpstreamError, - reach, + withUpstream, } from '../utils/upstream.js'; const HTML_POST_TYPE = 'text/html'; @@ -95,7 +95,7 @@ function getTextBody(data) { async function getPageTemplate(env, daCtx, aemCtx) { // answers null for a site with no config, so any other failure throws - const config = await reach(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); + const config = await withUpstream(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config @@ -112,7 +112,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { } const templatePath = matchingTemplates[0].template; - const templateHtml = await reach(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); + const templateHtml = await withUpstream(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); if (templateHtml) { return templateHtml; } @@ -129,7 +129,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { * @throws {UpstreamError} when the lookup or the store fails */ async function readSource(env, daCtx, init) { - const site = await reach(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); + const site = await withUpstream(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); if (!site.exists) { console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); @@ -139,7 +139,7 @@ async function readSource(env, daCtx, init) { const store = getStore(env, daCtx, site.onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); return { - response: await reach(CONTENT_STORE, () => store.fetch(store.url, init)), + response: await withUpstream(CONTENT_STORE, () => store.fetch(store.url, init)), head: site.head, }; } @@ -302,7 +302,10 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - const { exists, onSourceBus } = await reach(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); + const { exists, onSourceBus } = await withUpstream( + SITE_LOOKUP, + () => getSiteConfig(env, daCtx), + ); if (!exists) { console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); @@ -319,7 +322,7 @@ async function sourcePost({ req, env, daCtx }) { const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); - const response = await reach(CONTENT_STORE, () => store.fetch(new Request(store.url, { + const response = await withUpstream(CONTENT_STORE, () => store.fetch(new Request(store.url, { method: 'POST', body, headers: { Authorization: authToken }, From 6f2363ff0c4bcd194647cce503c465abd16e76ec Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:53:03 +0200 Subject: [PATCH 48/77] test: causeOf keeps latin-1 and blanks the controls --- test/utils/upstream.test.js | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 test/utils/upstream.test.js diff --git a/test/utils/upstream.test.js b/test/utils/upstream.test.js new file mode 100644 index 00000000..20d7698f --- /dev/null +++ b/test/utils/upstream.test.js @@ -0,0 +1,57 @@ +/* + * 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 { describe, it } from 'mocha'; +import { causeOf } from '../../src/utils/upstream.js'; + +describe('causeOf', () => { + it('names the error and its message', () => { + assert.strictEqual(causeOf(new TypeError('Network lost')), 'TypeError: Network lost'); + }); + + it('keeps latin-1 characters, which a header value can carry', () => { + assert.strictEqual(causeOf(new Error('café ÿ')), 'Error: café ÿ'); + }); + + it('replaces a character a header value cannot carry', () => { + assert.strictEqual(causeOf(new Error('a ’ b')), 'Error: a b'); + }); + + it('replaces the controls that are not a header value character', () => { + // tab, newline, carriage return, NUL, DEL and a C1 control + const controls = [0x09, 0x0a, 0x0d, 0x00, 0x7f, 0x85] + .map((c) => String.fromCharCode(c)) + .join(''); + assert.strictEqual(causeOf(new Error(`a${controls}b`)), 'Error: a b'); + }); + + it('caps the length', () => { + assert.strictEqual(causeOf(new Error('x'.repeat(2000))).length, 1024); + }); + + it('renders what is thrown when it is not an Error', () => { + assert.strictEqual(causeOf('boom'), 'Error: boom'); + assert.strictEqual(causeOf(undefined), 'Error: undefined'); + }); + + // the point of the sanitizing: whatever comes back is settable, so no failure path throws while + // reporting a failure + it('answers a string that Headers accepts, for any code point', () => { + for (let i = 0; i <= 0x2fff; i += 1) { + const value = causeOf(new Error(`a${String.fromCharCode(i)}b`)); + assert.doesNotThrow(() => new Headers({ 'x-error': value }), `code point 0x${i.toString(16)}`); + } + }); +}); From be3a7bb28e516d035489b6001edeaf10bee51ae6 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Mon, 17 Aug 2026 15:53:03 +0200 Subject: [PATCH 49/77] fix: blank the c1 controls, invisible in x-error --- src/utils/upstream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/upstream.js b/src/utils/upstream.js index 5dad13ba..cf73bb0e 100644 --- a/src/utils/upstream.js +++ b/src/utils/upstream.js @@ -21,7 +21,7 @@ export const SITE_LOOKUP = 'site lookup'; */ export function causeOf(e) { return `${e?.name ?? 'Error'}: ${e?.message ?? e}` - .replace(/[^\t\u0020-\u007E\u0080-\u00FF]/g, ' ') + .replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, 1024); From 96f3560c0db8f032698c1550f1360c3ad8f89625 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 14:45:14 +0200 Subject: [PATCH 50/77] fix: ask the config service whether a site exists, not head.html (#270) --- .github/workflows/deploy.yaml | 4 +- README.md | 13 +- dev/lookup-shim.js | 47 +++ dev/lookup-shim.toml | 9 + eslint.config.js | 2 +- package.json | 1 + src/handlers/get.js | 12 +- src/responses/index.js | 4 + src/routes/cookie.js | 8 +- src/routes/da-admin.js | 193 +++++---- src/storage/config.js | 12 +- src/storage/site.js | 58 +++ src/storage/source-bus.js | 35 -- src/utils/aemCtx.js | 11 +- src/utils/constants.js | 16 +- src/utils/quick-edit.js | 8 +- src/utils/upstream.js | 60 +++ test/handlers/get.test.js | 10 +- test/index.test.js | 2 +- test/routes/cookie.test.js | 154 +++++++ test/routes/da-admin.test.js | 87 ++-- test/routes/source-read.test.js | 702 +++++++++++++++++++++++++++---- test/routes/source-write.test.js | 108 +++-- test/storage/config.test.js | 94 ++--- test/storage/site.test.js | 212 ++++++++++ test/storage/source-bus.test.js | 194 --------- test/utils/aemCtx.test.js | 33 +- test/utils/upstream.test.js | 57 +++ wrangler.toml | 11 +- 29 files changed, 1595 insertions(+), 562 deletions(-) create mode 100644 dev/lookup-shim.js create mode 100644 dev/lookup-shim.toml create mode 100644 src/storage/site.js delete mode 100644 src/storage/source-bus.js create mode 100644 src/utils/upstream.js create mode 100644 test/routes/cookie.test.js create mode 100644 test/storage/site.test.js delete mode 100644 test/storage/source-bus.test.js create mode 100644 test/utils/upstream.test.js diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 054d9499..f54932c9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -41,14 +41,14 @@ jobs: - name: Deploy to Cloudflare Workers (production) if: github.ref_name == 'main' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - name: Deploy to Cloudflare Workers (stage) if: github.ref_name == 'stage' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/README.md b/README.md index 5205fd84..a6f5ef49 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,26 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. +One read of config.aem.page, pipeline scope, answers existence, head.html and `contentSource`. Its url names the store. + +The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to its `SITES` table. A site missing from the table is answered 404. A source url on api.aem.live reads as source-bus. + To run da-universal locally: 1. Clone this repo to your computer. 1. Run `npm install` 1. Use `npx wrangler login` if not done before. Walk through the steps in browser. -1. In a terminal, run `npm run dev` this repo's folder. +1. In a terminal, run `npm run dev:lookups` to start the stand-in lookups on port 4713. +1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 +Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. + +with the shared secret, use `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. + ### Run on stage -You can deploy da-universal on Cloudflare stage via `npm deploy:stage` to test it in a real worker environment. +You can deploy da-universal on Cloudflare stage via `npm run deploy:stage` to test it in a real worker environment. ## Customer documentation https://docs.da.live/developers/reference/universal-editor diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js new file mode 100644 index 00000000..5bbaf0dc --- /dev/null +++ b/dev/lookup-shim.js @@ -0,0 +1,47 @@ +/* + * 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. + */ + +// stands in for config.aem.page, which needs a shared secret. one read of the pipeline scope +// answers whether the site exists, its head.html and which store holds it. a site in SITES exists, +// and one with a source url on api.aem.live is source-bus +const SITES = { + 'org/site': 'https://content.da.live/org/site/', +}; + +// what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with +const HEAD_HTML = '\n\n'; + +export default { + async fetch(req) { + const url = new URL(req.url); + + const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); + if (!org || !site) { + return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); + } + + const source = SITES[`${org}/${site}`]; + if (!source) { + return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); + } + + // both stores are `type: markup`, so only the url separates them + const body = JSON.stringify({ + ref, + site, + org, + head: { html: HEAD_HTML }, + contentSource: { type: 'markup', url: source }, + }); + return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); + }, +}; diff --git a/dev/lookup-shim.toml b/dev/lookup-shim.toml new file mode 100644 index 00000000..79e3d4a2 --- /dev/null +++ b/dev/lookup-shim.toml @@ -0,0 +1,9 @@ +# the stand-in gets its own config, so wrangler dev does not read wrangler.toml: no daadmin +# binding to connect, and no HLX_CONFIG_SERVICE_TOKEN to warn about +name = "da-ue-lookup-shim" +main = "lookup-shim.js" +compatibility_date = "2023-11-21" + +[dev] +port = 4713 +inspector_port = 9234 diff --git a/eslint.config.js b/eslint.config.js index 1c5ff742..abbd1c37 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,7 @@ import { defineConfig, globalIgnores } from '@eslint/config-helpers'; import { recommended, source, test } from '@adobe/eslint-config-helix'; export default defineConfig([ - globalIgnores(['.vscode/*', '.wrangler/*', 'coverage/*']), + globalIgnores(['.vscode/*', '**/.wrangler/**', 'coverage/*']), { languageOptions: { ...recommended.languageOptions, diff --git a/package.json b/package.json index 15c6fb3f..883d9923 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "deploy": "wrangler deploy", "deploy:stage": "wrangler deploy --env stage", "dev": "wrangler dev --local-protocol https --env dev", + "dev:lookups": "wrangler dev -c dev/lookup-shim.toml", "start": "wrangler dev --local-protocol https --env dev", "test": "c8 mocha --spec=test/**/*.test.js", "lint": "eslint ." diff --git a/src/handlers/get.js b/src/handlers/get.js index 591cada5..df429f3d 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -21,7 +21,7 @@ export default async function getHandler({ req, env, daCtx }) { if (path.startsWith('/favicon.ico')) return get404(); if (path.startsWith('/robots.txt')) return getRobots(); - if (path.startsWith('/gimme_cookie')) return getCookie({ req, daCtx }); + if (path.startsWith('/gimme_cookie')) return getCookie({ req, env, daCtx }); const resourceRegex = /\.(css|js|js\.map|json|xml|woff|woff2|otf|ttf|plain\.html|html)$/i; if (resourceRegex.test(path)) { @@ -35,8 +35,14 @@ 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; + // logs a rejection rather than rethrowing it, since the other read may still answer + const settled = (result, read) => { + if (result.status === 'fulfilled') return result.value; + console.error(`${read} threw on ${path}`, result.reason); + return undefined; + }; + const storeRes = settled(daSourceGetRes, 'the store read'); + const aemRes = settled(aemProxyRes, 'the aem proxy'); let response; if (storeRes?.status === 200) { diff --git a/src/responses/index.js b/src/responses/index.js index 41758933..c07a3abb 100644 --- a/src/responses/index.js +++ b/src/responses/index.js @@ -77,6 +77,10 @@ export function post503(message = '', error = '') { }); } +export function post404(message = '') { + return daResp({ body: message, status: 404, contentType: 'text/plain; charset=utf-8' }); +} + // 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({ diff --git a/src/routes/cookie.js b/src/routes/cookie.js index 352f348f..1559ecf7 100644 --- a/src/routes/cookie.js +++ b/src/routes/cookie.js @@ -12,9 +12,9 @@ import { daResp, get401 } from '../responses/index.js'; import { isTrustedOrigin } from '../utils/constants.js'; -async function exchangeSiteToken(org, site, accessToken) { +async function exchangeSiteToken(env, org, site, accessToken) { try { - const response = await fetch('https://admin.hlx.page/auth/adobe/exchange', { + const response = await fetch(new URL('/auth/adobe/exchange', env.AEM_API), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -48,7 +48,7 @@ async function exchangeSiteToken(org, site, accessToken) { } } -export async function getCookie({ req, daCtx }) { +export async function getCookie({ req, env, daCtx }) { const { headers } = req; if (!isTrustedOrigin(headers.get('Origin'))) return daResp({ body: '403 Forbidden', status: 403, contentType: 'text/plain' }); @@ -67,7 +67,7 @@ export async function getCookie({ req, daCtx }) { // Try to exchange for site token if (org && site && !daCtx.siteToken) { - const siteTokenData = await exchangeSiteToken(org, site, cookieValue); + const siteTokenData = await exchangeSiteToken(env, org, site, cookieValue); if (siteTokenData) { // Calculate Max-Age based on token expiry time (siteTokenExpiry is in milliseconds) const now = Date.now(); diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index d2484b6f..931424cb 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -22,39 +22,60 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head503, post405, post503, + daResp, get401, get404, get415, get503, head401, head404, head503, post404, post405, post503, } from '../responses/index.js'; import { - BRANCH_NOT_FOUND_HTML_MESSAGE, DEFAULT_HTML_TEMPLATE, + SITE_LOOKUP_FAILED_HTML_MESSAGE, + PREVIEW_FAILED_HTML_MESSAGE, + EDITOR_CONFIG_FAILED_HTML_MESSAGE, + SITE_NOT_FOUND_HTML_MESSAGE, + SITE_NOT_FOUND_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, - SOURCE_UNREACHABLE_HTML_MESSAGE, - SOURCE_UNREACHABLE_MESSAGE, + SOURCE_FAILED_HTML_MESSAGE, + SOURCE_FAILED_MESSAGE, UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; -import { getSiteConfig } from '../storage/config.js'; -import isSourceBus from '../storage/source-bus.js'; +import { getEditorConfig } from '../storage/config.js'; +import getSiteConfig from '../storage/site.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; +import { + CONTENT_STORE, + PREVIEW_HOST, + EDITOR_CONFIG, + SITE_LOOKUP, + UpstreamError, + withUpstream, +} from '../utils/upstream.js'; const HTML_POST_TYPE = 'text/html'; +/** Names the upstream that failed, since the default body names the store. */ +const UPSTREAM_FAILURE_HTML = { + [PREVIEW_HOST]: PREVIEW_FAILED_HTML_MESSAGE, + [EDITOR_CONFIG]: EDITOR_CONFIG_FAILED_HTML_MESSAGE, + [SITE_LOOKUP]: SITE_LOOKUP_FAILED_HTML_MESSAGE, +}; +// a write reaches no store until the lookup answers, so a failed lookup says the destination is +// undetermined, not that the store failed +const UPSTREAM_FAILURE_TEXT = { + [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, +}; + /** - * Renders a failure for the `x-error` header. + * An UpstreamError is retryable, so it is answered 503. Anything else reaches the worker boundary + * in src/index.js, which logs it and answers 500. */ -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; +function refuseUpstreamFailure(e, method, sourcePath) { + if (!(e instanceof UpstreamError)) throw e; + console.warn(`503 ${method} ${sourcePath}, ${e.message}`); + if (method === 'HEAD') return head503(e.message); + if (method === 'POST') { + return post503(UPSTREAM_FAILURE_TEXT[e.upstream] ?? SOURCE_FAILED_MESSAGE, e.message); + } + return get503(UPSTREAM_FAILURE_HTML[e.upstream] ?? SOURCE_FAILED_HTML_MESSAGE, e.message); } export function isHtmlPostType(type) { @@ -73,12 +94,8 @@ function getTextBody(data) { } async function getPageTemplate(env, daCtx, aemCtx) { - let config; - try { - config = await getSiteConfig(env, daCtx); - } catch (e) { - return DEFAULT_HTML_TEMPLATE; - } + // answers null for a site with no config, so any other failure throws + const config = await withUpstream(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config @@ -95,7 +112,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { } const templatePath = matchingTemplates[0].template; - const templateHtml = await getAEMHtml(aemCtx, templatePath); + const templateHtml = await withUpstream(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); if (templateHtml) { return templateHtml; } @@ -104,39 +121,30 @@ async function getPageTemplate(env, daCtx, aemCtx) { } /** - * Sends a request to a store and reports why it could not be reached at all. + * Reads the document from the store that holds the site. * - * @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. + * Sets `noSuchSite` when there is no such site, `response` otherwise. * - * @returns {Promise<{response?: Response, error?: string}>} `error` says why there is no response + * @returns {Promise<{response?: Response, noSuchSite?: boolean}>} + * @throws {UpstreamError} when the lookup or the store fails */ 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 site = await withUpstream(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); + + if (!site.exists) { + console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return { noSuchSite: true }; } - const store = getStore(env, daCtx, onSourceBus); + const store = getStore(env, daCtx, site.onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); - return reachStore(store, () => store.fetch(store.url, init)); + return { + response: await withUpstream(CONTENT_STORE, () => store.fetch(store.url, init)), + head: site.head, + }; } -export async function daSourceGet({ req, env, daCtx }) { +async function sourceGet({ req, env, daCtx }) { const { ext, authToken } = daCtx; // check if Authorization header is present @@ -158,27 +166,28 @@ export async function daSourceGet({ req, env, daCtx }) { if (ext !== 'html') { // 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); + const { response, noSuchSite } = await readSource(env, daCtx, { method: 'GET', headers }); + if (noSuchSite) return get404(); console.log(`<- ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } - // the store lookup costs a round trip, so it runs alongside head.html rather than after it const aemCtx = getAemCtx(env, daCtx); - const [headHtml, { response: sourceResp, error: sourceError }] = await Promise.all([ - getAEMHtml(aemCtx, '/head.html'), - readSource(env, daCtx, { method: 'GET', headers }), - ]); - if (!headHtml) { + const { response: sourceResp, noSuchSite, head: headHtml } = await readSource( + env, + daCtx, + { method: 'GET', headers }, + ); + + if (noSuchSite) { // 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. + // can load into this page, even when the site does not exist. if (isQuickEdit) { return buildQuickEditNotFoundResponse(); } - return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); + return get404(SITE_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 }); // the store is the only thing to see the token, and the authorbus extension recovers off the @@ -196,10 +205,10 @@ export async function daSourceGet({ req, env, daCtx }) { // use the stored content when available, otherwise fall back to a template const bodyHtml = sourceResp.status === 200 ? await sourceResp.text() - : await getPageTemplate(env, daCtx, aemCtx, headHtml); + : await getPageTemplate(env, daCtx, aemCtx); - // compose the page the same way for every request type - const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml); + // builds the page without head.html, which a ref that was never built does not have + const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); // layer the request-specific instrumentation on top of the composed page const extraHeaders = []; @@ -225,7 +234,16 @@ export async function daSourceGet({ req, env, daCtx }) { }); } -export async function daSourceHead({ env, daCtx }) { +/** Wraps sourceGet, turning a failed upstream read into a 503 in HTML the editor renders. */ +export async function daSourceGet({ req, env, daCtx }) { + try { + return await sourceGet({ req, env, daCtx }); + } catch (e) { + return refuseUpstreamFailure(e, 'GET', daCtx.sourcePath); + } +} + +async function sourceHead({ env, daCtx }) { const { authToken } = daCtx; if (!authToken) { @@ -235,13 +253,22 @@ export async function daSourceHead({ env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); - const { response, error } = await readSource(env, daCtx, { method: 'HEAD', headers }); - if (!response) return head503(error); + const { response, noSuchSite } = await readSource(env, daCtx, { method: 'HEAD', headers }); + if (noSuchSite) return head404(); 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 }); } -export async function daSourcePost({ req, env, daCtx }) { +/** Wraps sourceHead, turning a failed upstream read into a bodyless 503. */ +export async function daSourceHead({ env, daCtx }) { + try { + return await sourceHead({ env, daCtx }); + } catch (e) { + return refuseUpstreamFailure(e, 'HEAD', daCtx.sourcePath); + } +} + +async function sourcePost({ req, env, daCtx }) { const { sourcePath, ext, authToken } = daCtx; // the body is rewritten as HTML below, so anything but an HTML document would be @@ -275,13 +302,14 @@ export async function daSourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // 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); + const { exists, onSourceBus } = await withUpstream( + SITE_LOOKUP, + () => getSiteConfig(env, daCtx), + ); + + if (!exists) { + console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return post404(SITE_NOT_FOUND_MESSAGE); } if (onSourceBus) { @@ -294,15 +322,26 @@ export async function daSourcePost({ req, env, daCtx }) { 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, { + const response = await withUpstream(CONTENT_STORE, () => store.fetch(new Request(store.url, { method: 'POST', body, 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; } return get415(); } + +/** + * Wraps sourcePost, turning a failed upstream read into the plain text the editor shows the + * author. + */ +export async function daSourcePost({ req, env, daCtx }) { + try { + return await sourcePost({ req, env, daCtx }); + } catch (e) { + return refuseUpstreamFailure(e, 'POST', daCtx.sourcePath); + } +} diff --git a/src/storage/config.js b/src/storage/config.js index 8f87ef32..7147054c 100644 --- a/src/storage/config.js +++ b/src/storage/config.js @@ -21,19 +21,19 @@ async function fetchConfig(env, daCtx, path) { const configUrl = new URL(path, env.DA_ADMIN); const res = await env.daadmin.fetch(configUrl, opts); - if (!res.ok) { + // a site with no config answers 404, and an author who may not read it is refused 403. both + // answered, so the starter template is used rather than refusing the request + if (res.status === 404 || res.status === 401 || res.status === 403) { + if (res.status !== 404) console.warn(`${configUrl} answered ${res.status}, using no config`); return null; } + if (!res.ok) throw new Error(`${configUrl} answered ${res.status}`); const json = await res.json(); if (!json) return []; const data = getFirstSheet(json); return data; } -export async function getSiteConfig(env, daCtx) { +export async function getEditorConfig(env, daCtx) { return fetchConfig(env, daCtx, `/config/${daCtx.org}/${daCtx.site}`); } - -export async function getOrgConfig(env, daCtx) { - return fetchConfig(env, daCtx, `/config/${daCtx.org}`); -} diff --git a/src/storage/site.js b/src/storage/site.js new file mode 100644 index 00000000..79626c45 --- /dev/null +++ b/src/storage/site.js @@ -0,0 +1,58 @@ +/* + * 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 NO_SITE = { exists: false, head: undefined, onSourceBus: false }; + +/** + * Asks the config service whether a site exists, what its head.html is and which store holds it. + * + * A site behind Helix authentication refuses `{ref}--{site}--{org}.aem.page/head.html` without a + * site token, and the config service does not. `contentSource.url` names the store, since both + * stores are `type: markup`. + * + * Throws on any refusal but a 404, which is the only status that means there is no such site. A ref + * that was never built exists and has no head.html, which is a 200 with an empty head. + * + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it and `AEM_API` is the source bus + * @param {Object} daCtx + * @returns {Promise<{exists: boolean, head: string|undefined, onSourceBus: boolean}>} + */ +export default async function getSiteConfig(env, daCtx) { + const { org, site, ref } = daCtx; + if (!org || !site) return NO_SITE; + + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=pipeline`, env.HLX_CONFIG_SERVICE); + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (response.status === 404) return NO_SITE; + if (!response.ok) throw new Error(`the config service answered ${response.status}`); + + const { head, contentSource } = await response.json(); + // a config that names no store is read as legacy + if (!contentSource?.url) { + console.warn(`${url} named no content source, reading ${org}/${site} as legacy`); + } + + return { + exists: true, + head: head?.html, + onSourceBus: Boolean(contentSource?.url?.startsWith(`${env.AEM_API}/`)), + }; +} diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js deleted file mode 100644 index 9cfe6b89..00000000 --- a/src/storage/source-bus.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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/utils/aemCtx.js b/src/utils/aemCtx.js index 54ddaa25..b02c2b84 100644 --- a/src/utils/aemCtx.js +++ b/src/utils/aemCtx.js @@ -48,8 +48,15 @@ export function withAemAuth(aemCtx, init = {}) { export async function getAEMHtml(aemCtx, path) { const { previewUrl } = aemCtx; - const resp = await fetch(`${previewUrl}${path}`, withAemAuth(aemCtx)); - if (!resp.ok) return undefined; + const url = `${previewUrl}${path}`; + const resp = await fetch(url, withAemAuth(aemCtx)); + // a ref that was never previewed answers 404, and a host behind Helix auth refuses without a + // site token. both answered, so the page is built without the fragment + if (resp.status === 404 || resp.status === 401 || resp.status === 403) { + if (resp.status !== 404) console.warn(`${url} answered ${resp.status}, using no fragment`); + return undefined; + } + if (!resp.ok) throw new Error(`${url} answered ${resp.status}`); const headHtml = await resp.text(); return headHtml; } diff --git a/src/utils/constants.js b/src/utils/constants.js index 69706aed..5093c3a5 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -49,13 +49,21 @@ export const UNAUTHORIZED_HTML_MESSAGE = ` export const DEFAULT_HTML_TEMPLATE = '
'; -export const BRANCH_NOT_FOUND_HTML_MESSAGE = '

Not found: Unable to retrieve AEM branch

'; +export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found

There is no site at this address.

'; -export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

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

'; +export const SITE_NOT_FOUND_MESSAGE = 'There is no site at this address, so nothing was written.'; -export const SOURCE_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry.'; +export const PREVIEW_FAILED_HTML_MESSAGE = '

503: Preview host failed

The site\'s preview host could not be read. Please retry, or contact your project admin if it persists.

'; -export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry.'; +export const EDITOR_CONFIG_FAILED_HTML_MESSAGE = '

503: Editor config failed

The editor configuration for this site could not be read. Please retry, or contact your project admin if it persists.

'; + +export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

Whether this site exists could not be determined. Please retry, or contact your project admin if it persists.

'; + +export const SOURCE_FAILED_HTML_MESSAGE = '

503: Content store failed

The store that holds this document could not be read. Please retry, or contact your project admin if it persists.

'; + +export const SOURCE_FAILED_MESSAGE = 'The store that holds this document did not answer, so nothing was written. Please retry, or contact your project admin if it persists.'; + +export const SOURCE_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry, or contact your project admin if it persists.'; 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.'; diff --git a/src/utils/quick-edit.js b/src/utils/quick-edit.js index 30be4054..c8cf10f2 100644 --- a/src/utils/quick-edit.js +++ b/src/utils/quick-edit.js @@ -189,14 +189,12 @@ export function prepareQuickEditDocument(html, nonce) { } /** - * Build the quick-edit 404 response for when the AEM branch itself can't be - * resolved (e.g. head.html is missing): a minimal page shell with the import - * map injected (no entry script), status 404, so the editor can still load - * into it. Reuse this anywhere quick-edit needs to degrade the same way. + * Build the quick-edit 404 response for when there is no such site: a minimal page shell with + * the import map injected and no entry script, so the editor can still load into it. * @returns {Response} */ export function buildQuickEditNotFoundResponse() { - console.log('[quick-edit] doc compose: head.html not found on origin, serving a minimal scaffold'); + console.log('[quick-edit] doc compose: no such site, serving a minimal scaffold'); const tree = fromHtml(`${DEFAULT_HTML_TEMPLATE}`); applyQuickEditToDocument(tree, undefined); const body = toHtml(tree, { allowDangerousHtml: true }); diff --git a/src/utils/upstream.js b/src/utils/upstream.js new file mode 100644 index 00000000..cf73bb0e --- /dev/null +++ b/src/utils/upstream.js @@ -0,0 +1,60 @@ +/* + * 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. + */ + +/** Names the upstream in the worker log and in `x-error`. */ +export const PREVIEW_HOST = 'preview host'; +export const CONTENT_STORE = 'content store'; +export const EDITOR_CONFIG = 'editor config'; +export const SITE_LOOKUP = 'site lookup'; + +/** + * Renders a failure for the `x-error` header. + */ +export function causeOf(e) { + return `${e?.name ?? 'Error'}: ${e?.message ?? e}` + .replace(/[^\u0020-\u007E\u00A0-\u00FF]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 1024); +} + +/** + * Thrown when a read has no answer to use: the upstream was not reached, or answered a status the + * reader threw on. A store response is handed back whatever its status, and the route decides. + * + * @property {string} upstream one of the names at the top of this file + */ +export class UpstreamError extends Error { + constructor(upstream, cause) { + super(`${upstream} failed: ${causeOf(cause)}`, { cause }); + this.name = 'UpstreamError'; + this.upstream = upstream; + } +} + +/** + * Runs `call` and rethrows anything it throws as an UpstreamError naming `upstream`. + * + * @param {string} upstream one of the names at the top of this file + * @param {() => Promise} call + * @returns {Promise} + * @template T + */ +export async function withUpstream(upstream, call) { + try { + return await call(); + } catch (e) { + // keeps the inner upstream name rather than overwriting it + if (e instanceof UpstreamError) throw e; + throw new UpstreamError(upstream, e); + } +} diff --git a/test/handlers/get.test.js b/test/handlers/get.test.js index 86c7634f..dd385729 100644 --- a/test/handlers/get.test.js +++ b/test/handlers/get.test.js @@ -63,13 +63,18 @@ describe('GET handler', () => { describe('gimme_cookie', () => { let getHandler; + let getCookieArgs; beforeEach(async () => { + getCookieArgs = undefined; getHandler = (await esmock('../../src/handlers/get.js', { '../../src/routes/da-admin.js': { daSourceGet: async () => new Response() }, '../../src/routes/aem-proxy.js': { handleAEMProxyRequest: async () => new Response() }, '../../src/routes/cookie.js': { - getCookie: async () => new Response('cookie-set', { status: 200 }), + getCookie: async (args) => { + getCookieArgs = args; + return new Response('cookie-set', { status: 200 }); + }, }, })).default; }); @@ -83,6 +88,9 @@ describe('GET handler', () => { assert.strictEqual(res.status, 200); assert.strictEqual(await res.text(), 'cookie-set'); + assert.strictEqual(getCookieArgs.req, req); + assert.strictEqual(getCookieArgs.env, env); + assert.strictEqual(getCookieArgs.daCtx, daCtx); }); }); diff --git a/test/index.test.js b/test/index.test.js index 0bdacba0..ccc058cd 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -189,7 +189,7 @@ describe('worker fetch handler', () => { // 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 }, + '../src/storage/site.js': { default: async () => ({ exists: true, head: undefined, onSourceBus: true }) }, })).default; const uePost = (origin) => { diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js new file mode 100644 index 00000000..a5a69f75 --- /dev/null +++ b/test/routes/cookie.test.js @@ -0,0 +1,154 @@ +/* + * 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 { getCookie } = await import('../../src/routes/cookie.js'); + +const env = { AEM_API: 'https://api.aem.live' }; + +const daCtx = (over = {}) => ({ org: 'org', site: 'site', ...over }); + +const req = (over = {}) => new Request('https://main--site--org.ue.da.live/gimme_cookie', { + headers: { + Origin: over.origin ?? 'https://da.live', + ...(over.noAuth ? {} : { Authorization: 'Bearer thetoken' }), + }, +}); + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(); + }; +}; + +const noAuthNeeded = () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); +const mints = () => new Response( + JSON.stringify({ siteToken: 'sitetok', siteTokenExpiry: Date.now() + 3600_000 }), + { status: 200, headers: { 'content-type': 'application/json' } }, +); + +const cookies = (res) => res.headers.getSetCookie(); + +describe('getCookie', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the exchange it asks for', () => { + // one service behind two hostnames: minted in the same second, admin.hlx.page and api.aem.live + // answer a byte-identical token, so the worker asks the one it uses for everything else + it('goes to the source bus api', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://api.aem.live/auth/adobe/exchange'); + }); + + it('takes the api from env, so stage can point elsewhere', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env: { AEM_API: 'https://api.stage.example' }, daCtx: daCtx() }); + + assert.strictEqual(calls[0].url, 'https://api.stage.example/auth/adobe/exchange'); + }); + + it('sends the org, the site and the caller token', async () => { + stubFetch(noAuthNeeded); + + await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.deepStrictEqual(JSON.parse(calls[0].init.body), { + org: 'org', site: 'site', accessToken: 'thetoken', + }); + }); + }); + + describe('what it sets', () => { + it('sets the auth cookie for a site that needs no site token', async () => { + stubFetch(noAuthNeeded); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 200); + const set = cookies(res); + assert.strictEqual(set.length, 1); + assert.ok(set[0].startsWith('auth_token=thetoken;')); + }); + + it('adds the site token for a site behind helix authentication', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + const set = cookies(res); + assert.strictEqual(set.length, 2); + assert.ok(set[1].startsWith('site_token=sitetok;')); + assert.match(set[1], /Max-Age=\d+/); + }); + + // the author is signed in either way, so a refused exchange keeps the auth cookie + it('keeps the auth cookie when the exchange refuses', async () => { + stubFetch(() => new Response('', { status: 403 })); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(cookies(res).length, 1); + }); + + it('keeps the auth cookie when the exchange cannot be reached', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + const res = await getCookie({ req: req(), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(cookies(res).length, 1); + }); + + it('asks for no exchange when the caller already has a site token', async () => { + stubFetch(mints); + + await getCookie({ req: req(), env, daCtx: daCtx({ siteToken: 'already' }) }); + + assert.strictEqual(calls.length, 0); + }); + }); + + describe('what it refuses', () => { + it('refuses an untrusted origin', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req({ origin: 'https://evil.example' }), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 403); + assert.strictEqual(calls.length, 0); + }); + + it('answers 401 without an Authorization header', async () => { + stubFetch(mints); + + const res = await getCookie({ req: req({ noAuth: true }), env, daCtx: daCtx() }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(calls.length, 0); + }); + }); +}); diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index aad41fc2..75840c8a 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -33,7 +33,7 @@ const recorder = () => { const env = { DA_ADMIN: 'https://admin.da.live', AEM_API: 'https://api.aem.live', - HLX_ADMIN: 'https://admin.hlx.page', + HLX_CONFIG_SERVICE: 'https://config.aem.page', daadmin: { fetch: async (input) => { fetched.push(input instanceof Request ? input.url : input.href); @@ -44,27 +44,33 @@ 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 = []) => { +// substitute for the config service read the routes make: the pipeline scope answers whether +// the site exists, its head.html and which store holds it. Answers that any site exists; +// `upgraded` lists the `org/site` keys whose content source is the source bus +const stubLookups = (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 }); + const [, site, org] = ((new URL(url)).pathname.split('/')[1] ?? '').split('--'); + const source = upgraded.includes(`${org}/${site}`) + ? `https://api.aem.live/${org}/sites/${site}/source` + : `https://content.da.live/${org}/${site}/`; + const body = JSON.stringify({ + head: { html: '' }, + contentSource: { type: 'markup', url: source }, + }); + return new Response(body, { status: 200 }); }; return asked; }; const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { - default: async () => false, + '../../src/storage/site.js': { + default: async () => ({ exists: true, head: '', onSourceBus: false }), }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => '', }, '../../src/render/compose.js': { composeHtml: async () => ({ tree: true }), @@ -111,14 +117,14 @@ describe('daSourceGet', () => { // `{ headHtml: undefined }` actually simulates a missing head.html, instead // of being masked by the default parameter value. const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; + const exists = overrides.site?.exists ?? true; calls = { compose: [], ue: 0, quickEdit: 0 }; return (await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { - default: async () => false, + '../../src/storage/site.js': { + default: async () => ({ exists, head: headHtml, onSourceBus: false }), }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), - getAEMHtml: async () => headHtml, }, '../../src/render/compose.js': { composeHtml: async (daCtx, aemCtx, bodyHtml) => { @@ -138,7 +144,8 @@ describe('daSourceGet', () => { buildQuickEditCookie: (p) => `da-quick-edit=${encodeURIComponent(p)}; Path=/`, }, '../../src/storage/config.js': { - getSiteConfig: async () => { throw new Error('no config'); }, + // da-admin answers a site with no config with a 404, which getEditorConfig reports as null + getEditorConfig: async () => null, }, })).daSourceGet; }; @@ -233,8 +240,8 @@ describe('daSourceGet', () => { assert.strictEqual(await res.text(), 'composed'); }); - it('returns a working 404 shell for quick-edit when head.html is missing', async () => { - const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + it('returns a working 404 shell for quick-edit when there is no such site', async () => { + const daSourceGet = await mockDaSourceGet({ site: { exists: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); const daCtx = getDaCtx(req); @@ -245,11 +252,11 @@ describe('daSourceGet', () => { assert.strictEqual(calls.compose.length, 0); const html = await res.text(); assert.ok(html.includes('importmap')); - assert.ok(!html.includes('Unable to retrieve AEM branch')); + assert.ok(!html.includes('There is no site at this address')); }); - it('still returns branch-not-found for non-quick-edit when head.html is missing', async () => { - const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + it('returns not-found for non-quick-edit when there is no such site', async () => { + const daSourceGet = await mockDaSourceGet({ site: { exists: false } }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const daCtx = getDaCtx(req); @@ -259,15 +266,26 @@ describe('daSourceGet', () => { assert.strictEqual(calls.compose.length, 0); assert.strictEqual(calls.ue, 0); const html = await res.text(); - assert.ok(html.includes('Unable to retrieve AEM branch')); + assert.ok(html.includes('There is no site at this address')); + }); + + it('composes the page when the site has no head.html', async () => { + const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + const daCtx = getDaCtx(req); + + const res = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(calls.compose.length, 1); + assert.strictEqual(calls.ue, 1); }); }); 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 + // answers the unmocked lookups with a legacy site, the store these tests describe beforeEach(() => { - stubPing(); + stubLookups(); }); afterEach(() => { @@ -402,17 +420,16 @@ 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 + // answers the unmocked lookups with a legacy site, the store these tests describe beforeEach(() => { - stubPing(); + stubLookups(); }); afterEach(() => { delete globalThis.fetch; }); - describe('on a site /ping reports as enrolled', () => { + describe('on a site the lookup 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); @@ -420,7 +437,7 @@ describe('daSourcePost', () => { }; it('is refused with 405 and nothing is written', async () => { - stubPing(['org/refused']); + stubLookups(['org/refused']); const { env, fetched } = recorder(); const res = await write('refused', env); @@ -432,16 +449,16 @@ describe('daSourcePost', () => { // 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']); + it('looks the site up once per write, and asks nothing else', async () => { + const asked = stubLookups(['org/lookedupeach']); const { env } = recorder(); - await write('probedeach', env); - await write('probedeach', env); + await write('lookedupeach', env); + await write('lookedupeach', env); - assert.deepStrictEqual(asked, [ - 'https://admin.hlx.page/ping/org/probedeach', - 'https://admin.hlx.page/ping/org/probedeach', + assert.deepStrictEqual(asked.sort(), [ + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', + 'https://config.aem.page/main--lookedupeach--org/config.json?scope=pipeline', ]); }); }); diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index d51cb27b..4df056cd 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -14,9 +14,17 @@ import assert from 'assert'; import esmock from 'esmock'; import { getDaCtx } from '../../src/utils/daCtx.js'; +// a namespace import, so a body pinned to a constant this branch does not export yet fails +// its own assertion rather than taking the file down at load +import * as messages from '../../src/utils/constants.js'; const authedReq = (url) => new Request(url, { headers: { Authorization: 'Bearer t' } }); +// what the one read of the pipeline scope answers +const SOURCE_BUS = { exists: true, onSourceBus: true }; +const LEGACY_STORE = { exists: true, onSourceBus: false }; +const NO_SITE = { exists: false, onSourceBus: false }; + /** * 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 @@ -27,13 +35,18 @@ const build = async (overrides = {}) => { 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 + // `in overrides` rather than a destructured default on these, so an explicit undefined reads + // as a ref with no head.html, a template the preview host does not have, or a failed lookup 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 templateHtml = 'templateHtml' in overrides + ? overrides.templateHtml + : 'from the template'; + const site = 'site' in overrides ? overrides.site : LEGACY_STORE; + const { + lookupError, templateError, configError, composeError, config = null, + } = overrides; const seen = { - bus: [], legacy: [], ue: 0, lookups: 0, + bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -52,41 +65,59 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { + '../../src/storage/site.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; + // the lookup reports a failure by throwing + if (lookupError) throw lookupError; + if (site === undefined) throw new TypeError('fetch failed'); + return { exists: site.exists, head: headHtml, onSourceBus: site.onSourceBus }; }, }, '../../src/utils/aemCtx.js': { - getAemCtx: () => ({}), - getAEMHtml: async () => headHtml, + getAemCtx: () => ({ previewUrl: 'https://main--site--org.aem.page' }), + // the template is the preview host read this stub owns. composeHtml reads metadata.json off + // the same host, and it is stubbed below, so the suite never sees that one + getAEMHtml: async (aemCtx, path) => { + if (templateError) throw templateError; + seen.aem.push(path); + return templateHtml; + }, }, '../../src/render/compose.js': { - composeHtml: async (daCtx, aemCtx, bodyHtml) => ({ bodyHtml }), + // returns a hast root, so quick-edit can walk what was built + composeHtml: async (daCtx, aemCtx, bodyHtml, head) => { + if (composeError) throw composeError; + seen.head.push(head); + return { type: 'root', children: [], 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'); }, + // da-admin answers a site with no config with a 404, which getEditorConfig reports as null + getEditorConfig: async () => { + if (configError) throw configError; + return config; + }, }, }); return { ...mod, env, seen }; }; -describe('when /ping cannot say which store holds the site', () => { +describe('when the lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); + const dead = () => new TypeError('fetch failed'); + // 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 { daSourceGet, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -96,7 +127,7 @@ describe('when /ping cannot say which store holds the site', () => { }); it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ onSourceBus: undefined }); + const { daSourceGet, env } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -104,8 +135,21 @@ describe('when /ping cannot say which store holds the site', () => { assert.ok(Number(res.headers.get('Retry-After')) > 0); }); + // the preview iframe renders this body, and no store was asked: the one read that names the + // store is what failed + it('says the site could not be looked up, not that a store did not answer', async () => { + const { daSourceGet, env } = await build({ lookupError: dead() }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + const body = await res.text(); + assert.notStrictEqual(body, messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_LOOKUP_FAILED_HTML_MESSAGE); + }); + it('refuses a non-html read too', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: undefined }); + const { daSourceGet, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -115,7 +159,7 @@ describe('when /ping cannot say which store holds the site', () => { }); it('refuses a HEAD with 503 and no body', async () => { - const { daSourceHead, env, seen } = await build({ onSourceBus: undefined }); + const { daSourceHead, env, seen } = await build({ lookupError: dead() }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -125,61 +169,149 @@ describe('when /ping cannot say which store holds the site', () => { 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 }); + // every 503 shares a status and an unparsed body, so x-error is what names the upstream + it('names the lookup in x-error', async () => { + const { daSourceGet, env } = await build({ lookupError: dead() }); 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/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('names the failed probe on a HEAD too', async () => { - const { daSourceHead, env } = await build({ onSourceBus: undefined }); + it('names the lookup on a HEAD too', async () => { + const { daSourceHead, env } = await build({ lookupError: dead() }); 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/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - // 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 () => { + // the header is the only thing on the wire that separates a timeout from a dropped connection + it('names the cause, not a category', async () => { const { daSourceGet, env } = await build({ - onSourceBus: undefined, - probeError: new DOMException('timed out', 'TimeoutError'), + lookupError: 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'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup 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 { daSourceGet, env } = await build({ lookupError: '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'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: Error: boom'); }); it('tells a probe failure apart from a store failure', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: fetch failed'); + }); +}); + +// the config service answers whether the site exists and what its head.html is, so an outage +// leaves both unknown +describe('when the config service cannot say whether the site exists', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + it('refuses an html read with 503 and touches neither store', async () => { + const { daSourceGet, env, seen } = await build({ site: 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({ site: 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); + }); + + // a 404 would say the site is gone, which is #258 again, this time on the lookup + it('does not answer 404', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.notStrictEqual(res.status, 404); + }); + + // says what happened, since the preview iframe renders the refusal + it('says the site could not be looked up, not that a store did not answer', async () => { + const { daSourceGet, env } = await build({ site: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + const body = await res.text(); + assert.notStrictEqual(body, messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(body, messages.SITE_LOOKUP_FAILED_HTML_MESSAGE); + }); + + it('refuses a HEAD with 503 and no body', async () => { + const { daSourceHead, env, seen } = await build({ site: 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); + }); + + it('names the failed lookup in x-error', async () => { + const { daSourceGet, env } = await build({ site: 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'), /site lookup failed/); + }); + + it('names the lookup cause, not a category', async () => { + const { daSourceGet, env } = await build({ + site: undefined, + lookupError: 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'), 'site lookup failed: TimeoutError: timed out'); + }); + + it('survives a thrown non-Error', async () => { + const { daSourceGet, env } = await build({ site: undefined, lookupError: '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'), 'site lookup failed: Error: boom'); }); }); @@ -190,7 +322,7 @@ describe('reading from the store that holds the site', () => { 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 { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -201,7 +333,7 @@ describe('reading from the store that holds the site', () => { }); it('composes the source-bus document, not da-admin\'s copy of it', async () => { - const { daSourceGet, env } = await build({ onSourceBus: true }); + const { daSourceGet, env } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -210,7 +342,7 @@ describe('reading from the store that holds the site', () => { }); it('forwards the author token to the source bus', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -234,7 +366,7 @@ describe('reading from the store that holds the site', () => { 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 { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -247,7 +379,7 @@ describe('reading from the store that holds the site', () => { 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, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -259,7 +391,7 @@ describe('reading from the store that holds the site', () => { it('answers 503 rather than throwing on a non-html read', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -269,7 +401,7 @@ describe('reading from the store that holds the site', () => { it('answers 503 rather than throwing on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -288,7 +420,7 @@ describe('reading from the store that holds the site', () => { it('asks the caller to retry', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -302,7 +434,7 @@ describe('reading from the store that holds the site', () => { // 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, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const html = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -311,13 +443,13 @@ describe('reading from the store that holds the site', () => { 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/); + assert.strictEqual(await htmlRes.text(), messages.SOURCE_FAILED_HTML_MESSAGE); + assert.strictEqual(await assetRes.text(), messages.SOURCE_FAILED_HTML_MESSAGE); }); it('answers 503 with no body on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -332,38 +464,38 @@ describe('reading from the store that holds the site', () => { // 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, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); it('names the cause on a non-html read', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); it('names the cause on a HEAD', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TimeoutError: The operation timed out'); }); it('names the cause when da-admin is unreachable', async () => { @@ -374,21 +506,21 @@ describe('reading from the store that holds the site', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: 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, + site: SOURCE_BUS, 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'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: lost at fetch'); }); }); @@ -398,7 +530,7 @@ describe('reading from the store that holds the site', () => { [401, 403, 429, 500, 502].forEach((status) => { it(`keeps the store's ${status} as the status`, async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('upstream said no', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -413,7 +545,7 @@ describe('reading from the store that holds the site', () => { [429, 500, 502].forEach((status) => { it(`passes the store's body through on a ${status}`, async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('upstream said no', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -424,12 +556,13 @@ describe('reading from the store that holds the site', () => { }); }); - // /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 + // the lookup does not send the author token, so the store is the only place a 401 can come + // from. the authorbus extension matches this sentinel 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, + site: SOURCE_BUS, bus: () => new Response('', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -443,7 +576,7 @@ describe('reading from the store that holds the site', () => { it('does not ask the caller to retry a 401, since retrying cannot help', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -455,7 +588,7 @@ describe('reading from the store that holds the site', () => { it('passes a store 401 through bare on a non-html read, which renders nothing', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -468,7 +601,7 @@ describe('reading from the store that holds the site', () => { it('answers a store 401 on a HEAD with no body', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -479,11 +612,11 @@ describe('reading from the store that holds the site', () => { 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 + // hands the author the starter template over whatever da-admin still has, with no second + // store to retry against it('composes the starter template on a 404 without asking the other store', async () => { const { daSourceGet, env, seen } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -498,7 +631,7 @@ describe('reading from the store that holds the site', () => { describe('UE instrumentation', () => { it('is applied on a UE host', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -507,7 +640,7 @@ describe('reading from the store that holds the site', () => { }); it('is not applied on a preview host', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.preview.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -534,10 +667,11 @@ describe('reading from the store that holds the site', () => { 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/storage/site.js': { + default: async () => ({ exists: true, head: '', onSourceBus: 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'); @@ -552,7 +686,7 @@ describe('reading from the store that holds the site', () => { describe('a non-html read', () => { it('goes to the source bus fully normalized', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/Media/Holiday.PNG'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -572,7 +706,7 @@ describe('reading from the store that holds the site', () => { describe('a HEAD', () => { it('goes to the source bus on a source-bus site', async () => { - const { daSourceHead, env, seen } = await build({ onSourceBus: true }); + const { daSourceHead, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -594,7 +728,7 @@ describe('reading from the store that holds the site', () => { it('passes a 404 from the store through, since HEAD composes nothing', async () => { const { daSourceHead, env } = await build({ - onSourceBus: true, + site: SOURCE_BUS, bus: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -692,7 +826,7 @@ describe('reading from the store that holds the site', () => { // 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, + site: SOURCE_BUS, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); @@ -703,7 +837,7 @@ describe('reading from the store that holds the site', () => { }); it('reads a video from the source bus on a source-bus site', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -712,13 +846,13 @@ describe('reading from the store that holds the site', () => { }); }); - 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 () => { + describe('the order of the three things that can fail', () => { + // answers the settled 404 ahead of the retryable 503 + it('reports no such site even when the store did not answer', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, - headHtml: undefined, + site: NO_SITE, bus: () => { throw new TypeError('fetch failed'); }, + legacy: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -726,5 +860,409 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); }); + + it('reports an unreachable store on a site with no head.html', async () => { + const { daSourceGet, env } = await build({ + headHtml: undefined, + legacy: () => { 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); + }); + }); + + // the config service reads head.html off the code bus, which a site behind Helix authentication + // serves to the worker's own token while its preview host refuses it + describe('where the page head comes from', () => { + it('reads it off the config service, and asks the preview host for nothing', 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.lookups, 1); + assert.deepStrictEqual(seen.aem, []); + }); + + it('composes the page with it', 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.head[0], ''); + }); + + // the pipeline scope answers existence, head.html and the store together + it('reads the config service once for a page', 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.lookups, 1); + }); + + // nothing composes an image, and the head that arrives with the existence answer is dropped + it('reads the config service once for an asset, and no more', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.lookups, 1); + assert.deepStrictEqual(seen.head, []); + }); + + it('reads the config service once on a HEAD, and no more', 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.lookups, 1); + assert.deepStrictEqual(seen.head, []); + }); + }); + + // #258: head.html does not say whether a site exists, so a missing head.html is not a 404 + describe('when the site serves no head.html', () => { + it('reads the document anyway', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: 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, 200); + assert.strictEqual(seen.legacy.length, 1); + assert.strictEqual(await res.text(), 'from da-admin'); + }); + + // serves the page without the project's css and js rather than not at all + it('composes with no project head entries', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.head.length, 1); + assert.strictEqual(seen.head[0] ?? '', ''); + }); + + it('composes the starter template when the document is missing too', async () => { + const { daSourceGet, env } = await build({ + headHtml: undefined, + legacy: () => 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.match(await res.text(), /
/); + }); + + it('instruments UE without a head', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: 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, 200); + assert.strictEqual(seen.ue, 1); + }); + + // sets no cookie: the cookie names the entry script, and no head.html means no entry script + it('serves quick-edit the page without an entry-script cookie', async () => { + const { daSourceGet, env } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.headers.get('Set-Cookie'), null); + }); + + it('is not answered 404 by a missing head.html alone', async () => { + const { daSourceGet, env } = await build({ headHtml: undefined }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.notStrictEqual(res.status, 404); + }); + }); + + describe('when there is no such site', () => { + it('refuses an html read with 404 and touches neither store', async () => { + const { daSourceGet, env, seen } = await build({ site: NO_SITE }); + 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); + assert.match(await res.text(), /Site not found/); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // nothing renders an image, so a body would only corrupt it + it('refuses a non-html read with 404 and no body', async () => { + const { daSourceGet, env, seen } = await build({ site: NO_SITE }); + 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, 404); + assert.strictEqual(await res.text(), ''); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + it('refuses a HEAD with 404 and no body', async () => { + const { daSourceHead, env, seen } = await build({ site: NO_SITE }); + 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); + assert.strictEqual(await res.text(), ''); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // needs a shell with the import map, since the editor loads into this page + it('gives quick-edit its shell', async () => { + const { daSourceGet, env } = await build({ site: NO_SITE }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.match(await res.text(), /importmap/); + }); + }); +}); + +describe('when the editor config cannot be reached', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + const missing = () => ({ + legacy: () => new Response('', { status: 404 }), + configError: new TypeError('fetch failed'), + }); + + // the config names the template built over a document that is not there, so a store that did + // not answer would hand the author the wrong blank page to save over + it('refuses with 503 rather than composing the starter template', async () => { + const { daSourceGet, env } = await build(missing()); + 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); + }); + + // the config is read off da-admin while the document can be on the source bus, so naming the + // store would name a system that answered + it('says the editor config failed, not the store', async () => { + const { daSourceGet, env } = await build(missing()); + 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(), '

503: Editor config failed

The editor configuration for this site could not be read. Please retry, or contact your project admin if it persists.

'); + }); + + it('names the editor config in x-error', async () => { + const { daSourceGet, env } = await build(missing()); + 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'), 'editor config failed: TypeError: fetch failed'); + }); + + it('asks the caller to retry', async () => { + const { daSourceGet, env } = await build(missing()); + 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); + }); + + // da-admin answers a site with no config with a 404, not a throw, and that is the ordinary case + it('composes the starter template when there is no config at all', async () => { + const { daSourceGet, env } = await build({ legacy: () => 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.match(await res.text(), /
/); + }); +}); + +// site.js is not stubbed here: a stub hides a config service that answers, but with something +// other than a config +describe('when the config service refuses the lookup', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + const readWithConfigStatus = async (status) => { + globalThis.fetch = async () => new Response('the config service said no', { status }); + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-token', + daadmin: { fetch: async () => new Response('from da-admin', { status: 200 }) }, + }; + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', {}); + // a preview host rather than a UE host, so nothing is instrumented onto the composed page + const req = authedReq('https://main--site--org.preview.da.live/folder/content'); + return daSourceGet({ req, env, daCtx: getDaCtx(req) }); + }; + + // reading a refusal as a missing site would 404 a page that exists, which is #258 again + it('refuses a 500 with 503 rather than calling the site missing', async () => { + const res = await readWithConfigStatus(500); + + assert.strictEqual(res.status, 503); + }); + + it('names the site lookup in x-error', async () => { + const res = await readWithConfigStatus(500); + + assert.match(res.headers.get('x-error'), /site lookup failed/); + }); + + // the shared secret is the worker's own, so a 401 is a deploy without it rather than an answer + it('refuses a 401 with 503', async () => { + const res = await readWithConfigStatus(401); + + assert.strictEqual(res.status, 503); + }); + + // the one status that is an answer: the config service knows of no such site + it('answers 404 on a 404', async () => { + const res = await readWithConfigStatus(404); + + assert.strictEqual(res.status, 404); + }); +}); + +describe('a read that carries no token', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // the authorbus extension recovers off the da:401 meta rather than off the status + it('is refused with the da:401 shell, and nothing is looked up', async () => { + const { daSourceGet, env, seen } = await build(); + const req = new Request('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 401); + assert.strictEqual(await res.text(), messages.UNAUTHORIZED_HTML_MESSAGE); + assert.strictEqual(seen.lookups, 0); + }); +}); + +describe('a path the editor config gives a template', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // the template is composed over a document that is not there, so the store answers 404 + const missingDoc = (rows) => ({ + legacy: () => new Response('', { status: 404 }), + config: rows.map((value) => ({ key: 'editor.ue.template', value })), + }); + + it('composes the configured template rather than the starter', async () => { + const { daSourceGet, env, seen } = await build(missingDoc(['/folder=/scripts/tpl.html'])); + 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.strictEqual(await res.text(), 'from the template'); + assert.ok(seen.aem.includes('/scripts/tpl.html')); + }); + + // unwrapped, this read escapes as the bodyless 500 that #258 is about + it('refuses with 503 when the template read cannot be reached', async () => { + const { daSourceGet, env } = await build({ + ...missingDoc(['/folder=/scripts/tpl.html']), + templateError: 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); + assert.match(res.headers.get('x-error'), /preview host failed/); + assert.strictEqual(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); + }); + + it('takes the longest matching prefix', async () => { + const { daSourceGet, env, seen } = await build(missingDoc([ + '/=/scripts/site.html', + '/folder=/scripts/folder.html', + ])); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(seen.aem.includes('/scripts/folder.html')); + assert.ok(!seen.aem.includes('/scripts/site.html')); + }); + + it('ignores a template configured for another path', async () => { + const { daSourceGet, env, seen } = await build(missingDoc(['/other=/scripts/other.html'])); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.match(await res.text(), /
/); + assert.deepStrictEqual(seen.aem, []); + }); + + // the config names a path the preview host answers 404 for, which leaves the starter + it('falls back to the starter when the preview host has no such template', async () => { + const { daSourceGet, env } = await build({ + ...missingDoc(['/folder=/scripts/gone.html']), + templateHtml: 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, 200); + assert.match(await res.text(), /
/); + }); +}); + +describe('when the worker itself has a bug', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // only a failed upstream read is retryable, and a 503 would keep the throw out of the log + // the worker boundary writes + it('lets the throw through rather than rendering it as a 503', async () => { + const { daSourceGet, env } = await build({ composeError: new TypeError('tree is not iterable') }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await assert.rejects( + () => daSourceGet({ req, env, daCtx: getDaCtx(req) }), + /tree is not iterable/, + ); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index ec9ce54f..ca255f2e 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -14,11 +14,19 @@ 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'; +import { + SITE_NOT_FOUND_MESSAGE, + 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

'; +// what the store lookup answers +const SOURCE_BUS = true; +const LEGACY_STORE = false; + /** The shape the Universal Editor Service posts: a `data` blob in a multipart form. */ const uePost = (url, html = DOC) => { const body = new FormData(); @@ -27,13 +35,10 @@ const uePost = (url, html = DOC) => { }; 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 { status = 201, busError, exists = true } = overrides; + const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; const seen = { - bus: [], legacy: [], lookups: 0, order: [], + bus: [], legacy: [], probes: 0, order: [], }; const capture = async (request) => { const contentType = request.headers.get('Content-Type'); @@ -69,13 +74,12 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { + '../../src/storage/site.js': { default: async () => { - seen.lookups += 1; + seen.probes += 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; + if (busError) throw busError; + return { exists, head: undefined, onSourceBus }; }, }, }); @@ -98,7 +102,7 @@ describe('writing to the store that holds the site', () => { // 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 }); + const { res, seen } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.status, 405); assert.strictEqual(seen.bus.length, 0); @@ -106,13 +110,13 @@ describe('writing to the store that holds the site', () => { }); it('names the methods that are left', async () => { - const { res } = await post({ onSourceBus: true }); + const { res } = await post({ site: SOURCE_BUS }); 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 }); + const { res } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.headers.get('Retry-After'), null); }); @@ -120,7 +124,7 @@ describe('writing to the store that holds the site', () => { // 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 }); + const { res } = await post({ site: SOURCE_BUS }); assert.match(res.headers.get('Content-Type'), /^text\/plain/); assert.strictEqual(await res.text(), SOURCE_BUS_READ_ONLY_MESSAGE); @@ -129,9 +133,11 @@ describe('writing to the store that holds the site', () => { // 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', () => { + describe('when the probe cannot say which store holds the site', () => { + const dead = () => new TypeError('fetch failed'); + it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ onSourceBus: undefined }); + const { res, seen } = await post({ busError: dead() }); assert.strictEqual(res.status, 503); assert.strictEqual(seen.bus.length, 0); @@ -139,30 +145,65 @@ describe('writing to the store that holds the site', () => { }); it('asks the caller to retry, unlike the source-bus refusal', async () => { - const { res } = await post({ onSourceBus: undefined }); + const { res } = await post({ busError: dead() }); 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 }); + const { res } = await post({ busError: dead() }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); it('names the failed probe in x-error', async () => { - const { res } = await post({ onSourceBus: undefined }); + const { res } = await post({ busError: dead() }); - assert.match(res.headers.get('x-error'), /ping/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('carries the probe cause, not a category', async () => { + it('names the cause, not a category', async () => { const { res } = await post({ - onSourceBus: undefined, - probeError: new DOMException('timed out', 'TimeoutError'), + busError: new DOMException('timed out', 'TimeoutError'), }); - assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + }); + }); + + // a read of the same path answers 404, and a write the reader cannot get back is worse than a + // refusal the author sees + describe('a site the config service does not know', () => { + it('is refused with 404 and touches neither store', async () => { + const { res, seen } = await post({ exists: false }); + + assert.strictEqual(res.status, 404); + assert.strictEqual(seen.legacy.length, 0); + assert.strictEqual(seen.bus.length, 0); + }); + + it('says what happened in plain text', async () => { + const { res } = await post({ exists: false }); + + assert.match(res.headers.get('Content-Type'), /^text\/plain/); + assert.strictEqual(await res.text(), SITE_NOT_FOUND_MESSAGE); + }); + + it('does not ask the caller to retry, since the site will not appear', async () => { + const { res } = await post({ exists: false }); + + assert.strictEqual(res.headers.get('Retry-After'), null); + }); + }); + + describe('what a write asks about the site', () => { + // one read of the pipeline scope answers where the document goes + it('asks one lookup, and reaches the store after it', async () => { + const { res, seen } = await post({}); + + assert.strictEqual(seen.probes, 1); + assert.strictEqual(seen.order[seen.order.length - 1], 'store'); + assert.strictEqual(res.status, 201); }); }); @@ -247,19 +288,20 @@ describe('writing to the store that holds the site', () => { }); }); - describe('the store lookup on a write', () => { + describe('the 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']); + assert.deepStrictEqual(seen.order.slice(-1), ['store']); + assert.ok(seen.order.includes('lookup')); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { - const { seen } = await post({ onSourceBus: true }); + const { seen } = await post({ site: SOURCE_BUS }); - assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.probes, 1); }); }); @@ -287,7 +329,7 @@ describe('writing to the store that holds the site', () => { const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); }); @@ -295,13 +337,13 @@ describe('writing to the store that holds the site', () => { // 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 { daSourcePost, env, seen } = await build({ site: SOURCE_BUS }); 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.probes, 0); assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); }); diff --git a/test/storage/config.test.js b/test/storage/config.test.js index 843f6dca..73549234 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -67,7 +67,7 @@ describe('Config Module', () => { } }; - describe('getSiteConfig', () => { + describe('getEditorConfig', () => { it('should fetch site config successfully (single-sheet)', async () => { const mockData = [ { key: 'editor.ue.template', value: '/content=/templates' }, @@ -75,7 +75,7 @@ describe('Config Module', () => { ]; setMockResponse({ data: mockData }); - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', @@ -122,84 +122,40 @@ describe('Config Module', () => { ':type': 'multi-sheet', }; setMockResponse(multiSheet); - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); // Should return only the first sheet's data array assert.deepStrictEqual(result, multiSheet.data.data); }); - it('should return null when fetch fails', async () => { - mockFetch.nextResponse = { ok: false }; + it('should return null when there is no config', async () => { + mockFetch.nextResponse = { ok: false, status: 404 }; - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); assert.strictEqual(result, null); }); - }); - - describe('getOrgConfig', () => { - it('should fetch org config successfully (single-sheet)', async () => { - const mockData = [ - { key: 'editor.ue.template', value: '/content=/templates' }, - { key: 'editor.ue.template', value: '/components=/blocks' }, - { key: 'editor.ue.template', value: '/assets=/media' }, - ]; - setMockResponse({ data: mockData }); - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); + // da-admin answers 403 when the author may not read the site config, and a retry answers the + // same, so the starter template is used rather than refusing + [401, 403].forEach((status) => { + it(`should return null when the store answers ${status}`, async () => { + mockFetch.nextResponse = { ok: false, status }; - const expectedCall = { - url: 'https://admin.da.live/config/test-org', - opts: { - headers: new Headers({ - authorization: 'test-token', - }), - }, - }; - assert.strictEqual(mockFetch.lastCall.url, expectedCall.url); - assert.deepStrictEqual( - Object.fromEntries(mockFetch.lastCall.opts.headers.entries()), - Object.fromEntries(expectedCall.opts.headers.entries()), - ); - assert.deepStrictEqual(result, mockData); + assert.strictEqual(await configModule.getEditorConfig(mockEnv, mockDaCtx), null); + }); }); - it('should fetch org config successfully (multi-sheet)', async () => { - const multiSheet = { - data: { - total: 2, - limit: 2, - offset: 0, - data: [ - { key: 'org.setting', value: 'org-value' }, - { key: 'editor.ue.template', value: '/org=/org-templates.html' }, - ], - }, - library: { - total: 1, - limit: 1, - offset: 0, - data: [ - { - title: 'Org Blocks', path: 'https://content.da.live/org/library/blocks.json', format: '', ref: '', icon: '', experience: '', - }, - ], - }, - ':names': ['data', 'library'], - ':version': 3, - ':type': 'multi-sheet', - }; - setMockResponse(multiSheet); - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - // Should return only the first sheet's data array - assert.deepStrictEqual(result, multiSheet.data.data); - }); - - it('should return null when fetch fails', async () => { - mockFetch.nextResponse = { ok: false }; - - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - - assert.strictEqual(result, null); + // a store that could not answer is not the same as a site with no config, and reading it as + // one hands the author a blank page to save over a document that exists + [429, 500, 502].forEach((status) => { + it(`should throw when the store answers ${status}`, async () => { + mockFetch.nextResponse = { ok: false, status }; + + await assert.rejects( + () => configModule.getEditorConfig(mockEnv, mockDaCtx), + new RegExp(String(status)), + ); + }); }); }); @@ -210,7 +166,7 @@ describe('Config Module', () => { { key: 'editor.ue.template', value: '/content=/templates' }, ]); - await configModule.getSiteConfig(mockEnv, ctxWithoutToken); + await configModule.getEditorConfig(mockEnv, ctxWithoutToken); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', diff --git a/test/storage/site.test.js b/test/storage/site.test.js new file mode 100644 index 00000000..78ae49c1 --- /dev/null +++ b/test/storage/site.test.js @@ -0,0 +1,212 @@ +/* + * 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: getSiteConfig } = await import('../../src/storage/site.js'); + +const env = { + AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-secret', +}; + +const daCtx = (over = {}) => ({ + org: 'org', site: 'site', ref: 'main', ...over, +}); + +const HEAD = ''; + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(); + }; +}; + +const config = (over = {}) => new Response(JSON.stringify({ + head: { html: HEAD }, + contentSource: { type: 'markup', url: 'https://content.da.live/org/site/' }, + ...over, +}), { status: 200, headers: { 'content-type': 'application/json' } }); + +const onBus = () => config({ contentSource: { type: 'markup', url: 'https://api.aem.live/org/sites/site/source' } }); + +describe('getSiteConfig', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks the config service once, for the pipeline scope', async () => { + stubFetch(config); + + await getSiteConfig(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); + }); + + it('sends the shared secret', async () => { + stubFetch(config); + + await getSiteConfig(env, daCtx()); + + const headers = new Headers(calls[0].init.headers); + assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); + assert.strictEqual(headers.get('x-backend-type'), 'aws'); + }); + + it('gives up rather than hanging', async () => { + stubFetch(config); + + await getSiteConfig(env, daCtx()); + + assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); + }); + }); + + describe('what one answer carries', () => { + it('answers existence, head.html and the store together', async () => { + stubFetch(onBus); + + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { + exists: true, head: HEAD, onSourceBus: true, + }); + }); + + it('reads the url, not the type, since both stores are markup', async () => { + stubFetch(config); + + const { onSourceBus } = await getSiteConfig(env, daCtx()); + assert.strictEqual(onSourceBus, false); + }); + + it('takes the source bus origin from env', async () => { + stubFetch(() => config({ contentSource: { type: 'markup', url: 'https://api.stage.example/o/sites/s/source' } })); + + const { onSourceBus } = await getSiteConfig({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()); + assert.strictEqual(onSourceBus, true); + }); + + ['https://drive.google.com/x', 'https://example.sharepoint.com/y'].forEach((url) => { + it(`answers legacy for ${new URL(url).host}`, async () => { + stubFetch(() => config({ contentSource: { type: 'markup', url } })); + + const { onSourceBus } = await getSiteConfig(env, daCtx()); + assert.strictEqual(onSourceBus, false); + }); + }); + + // a ref that was never built exists and has no head.html + it('answers a missing head as undefined, and still names the store', async () => { + stubFetch(() => config({ head: undefined })); + + const { exists, head, onSourceBus } = await getSiteConfig(env, daCtx()); + assert.strictEqual(exists, true); + assert.strictEqual(head, undefined); + assert.strictEqual(onSourceBus, false); + }); + }); + + describe('when there is no such site', () => { + it('answers no-site on a 404', async () => { + stubFetch(() => new Response('', { status: 404 })); + + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { + exists: false, head: undefined, onSourceBus: false, + }); + }); + + [ + ['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 no-site without asking: ${what}`, async () => { + stubFetch(config); + + assert.deepStrictEqual(await getSiteConfig(env, daCtx(over)), { + exists: false, head: undefined, onSourceBus: false, + }); + assert.strictEqual(calls.length, 0); + }); + }); + }); + + describe('when the answer names no content source', () => { + it('reads as legacy, since that is where a site without one has always been', async () => { + stubFetch(() => config({ contentSource: undefined })); + + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { + exists: true, head: HEAD, onSourceBus: false, + }); + }); + + // a source-bus site read as legacy is served the wrong document and written to the wrong store, + // so the site says so in the log rather than only in an author's lost edits + it('names the site in a warning', async () => { + stubFetch(() => config({ contentSource: undefined })); + const warnings = []; + const saved = console.warn; + console.warn = (m) => warnings.push(m); + + try { + await getSiteConfig(env, daCtx()); + } finally { + console.warn = saved; + } + + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /org\/site/); + assert.match(warnings[0], /content source/); + }); + + it('answers the same when the source carries no url', async () => { + stubFetch(() => config({ contentSource: { type: 'markup' } })); + + const { onSourceBus } = await getSiteConfig(env, daCtx()); + assert.strictEqual(onSourceBus, false); + }); + }); + + describe('when the config service refuses', () => { + [401, 403, 429, 500, 503].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects(() => getSiteConfig(env, daCtx()), new RegExp(`${status}`)); + }); + }); + + it('throws when the answer is not json', async () => { + stubFetch(() => new Response('', { status: 200 })); + + await assert.rejects(() => getSiteConfig(env, daCtx())); + }); + + // the cause reaches the caller, which reports it on the 503 as `x-error` + it('lets a failure through', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(getSiteConfig(env, daCtx()), { message: 'fetch failed' }); + }); + }); +}); diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js deleted file mode 100644 index 8093d3fd..00000000 --- a/test/storage/source-bus.test.js +++ /dev/null @@ -1,194 +0,0 @@ -/* - * 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/utils/aemCtx.test.js b/test/utils/aemCtx.test.js index 8b4432d2..2f17923b 100644 --- a/test/utils/aemCtx.test.js +++ b/test/utils/aemCtx.test.js @@ -70,11 +70,14 @@ describe('AEM context', () => { const mockAemCtx = { previewUrl: 'https://main--site--org.aem.page', }; + let status; beforeEach(async () => { + status = 200; // Mock global fetch - global.fetch = async (url) => ({ - ok: url.includes('success'), + global.fetch = async () => ({ + ok: status === 200, + status, text: async () => 'test content', }); }); @@ -88,10 +91,34 @@ describe('AEM context', () => { assert.strictEqual(html, 'test content'); }); - it('should return undefined for failed request', async () => { + // a ref that was never previewed has no head.html, and the page is composed without it + it('should return undefined for a 404', async () => { + status = 404; const html = await getAEMHtml(mockAemCtx, '/fail-path'); assert.strictEqual(html, undefined); }); + + // a preview host behind Helix authentication refuses without a site token, and a retry + // answers the same, so the page is built without the project head + [401, 403].forEach((code) => { + it(`should return undefined for a ${code}`, async () => { + status = code; + const html = await getAEMHtml(mockAemCtx, '/fail-path'); + assert.strictEqual(html, undefined); + }); + }); + + // a preview host that could not answer is not a ref that was never previewed, and reading it + // as one serves the page at 200 with no stylesheet, no scripts and no entry script + [429, 500, 502].forEach((code) => { + it(`should throw on a ${code}`, async () => { + status = code; + await assert.rejects( + () => getAEMHtml(mockAemCtx, '/fail-path'), + new RegExp(String(code)), + ); + }); + }); }); describe('fixUrlsWhenLocalDev', () => { diff --git a/test/utils/upstream.test.js b/test/utils/upstream.test.js new file mode 100644 index 00000000..20d7698f --- /dev/null +++ b/test/utils/upstream.test.js @@ -0,0 +1,57 @@ +/* + * 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 { describe, it } from 'mocha'; +import { causeOf } from '../../src/utils/upstream.js'; + +describe('causeOf', () => { + it('names the error and its message', () => { + assert.strictEqual(causeOf(new TypeError('Network lost')), 'TypeError: Network lost'); + }); + + it('keeps latin-1 characters, which a header value can carry', () => { + assert.strictEqual(causeOf(new Error('café ÿ')), 'Error: café ÿ'); + }); + + it('replaces a character a header value cannot carry', () => { + assert.strictEqual(causeOf(new Error('a ’ b')), 'Error: a b'); + }); + + it('replaces the controls that are not a header value character', () => { + // tab, newline, carriage return, NUL, DEL and a C1 control + const controls = [0x09, 0x0a, 0x0d, 0x00, 0x7f, 0x85] + .map((c) => String.fromCharCode(c)) + .join(''); + assert.strictEqual(causeOf(new Error(`a${controls}b`)), 'Error: a b'); + }); + + it('caps the length', () => { + assert.strictEqual(causeOf(new Error('x'.repeat(2000))).length, 1024); + }); + + it('renders what is thrown when it is not an Error', () => { + assert.strictEqual(causeOf('boom'), 'Error: boom'); + assert.strictEqual(causeOf(undefined), 'Error: undefined'); + }); + + // the point of the sanitizing: whatever comes back is settable, so no failure path throws while + // reporting a failure + it('answers a string that Headers accepts, for any code point', () => { + for (let i = 0; i <= 0x2fff; i += 1) { + const value = causeOf(new Error(`a${String.fromCharCode(i)}b`)); + assert.doesNotThrow(() => new Headers({ 'x-error': value }), `code point 0x${i.toString(16)}`); + } + }); +}); diff --git a/wrangler.toml b/wrangler.toml index b8fdfed1..39d66735 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,19 +2,24 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin" }] +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [dev] port = 4712 [env.dev] -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" } +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_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] +# the list is also what wrangler binds from .dev.vars.dev, so an empty one leaves the worker +# with no token and every lookup 401s. running against the stand-in warns that it is missing +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [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", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +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_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin-stage" }] +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage.observability] enabled = true From fe2d7ac960cdf6032e5e458590e452cddb19ecfa Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 23:41:12 +0200 Subject: [PATCH 51/77] test: on UE, head.html comes from the preview host the config service returns the raw code-bus head.html, which UE cannot serve: the CSP meta lands as a header claiming 'nonce-aem' and the injected UE scripts carry no nonce, so the browser refuses them. the preview host runs the pipeline that rewrites those. non-UE hosts render the composed page as-is, so the raw head is fine there. --- test/routes/source-read.test.js | 149 +++++++++++++++++++++++++++----- 1 file changed, 127 insertions(+), 22 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 4df056cd..3453dfc7 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -37,13 +37,14 @@ const build = async (overrides = {}) => { } = overrides; // `in overrides` rather than a destructured default on these, so an explicit undefined reads // as a ref with no head.html, a template the preview host does not have, or a failed lookup - const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; + const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; + const previewHead = 'previewHead' in overrides ? overrides.previewHead : ''; const templateHtml = 'templateHtml' in overrides ? overrides.templateHtml : 'from the template'; const site = 'site' in overrides ? overrides.site : LEGACY_STORE; const { - lookupError, templateError, configError, composeError, config = null, + lookupError, templateError, previewHeadError, configError, composeError, config = null, } = overrides; const seen = { bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, @@ -76,11 +77,15 @@ const build = async (overrides = {}) => { }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ previewUrl: 'https://main--site--org.aem.page' }), - // the template is the preview host read this stub owns. composeHtml reads metadata.json off - // the same host, and it is stubbed below, so the suite never sees that one + // the preview host serves both head.html and a template. composeHtml reads metadata.json + // off the same host, and it is stubbed below, so the suite never sees that one getAEMHtml: async (aemCtx, path) => { - if (templateError) throw templateError; seen.aem.push(path); + if (path === '/head.html') { + if (previewHeadError) throw previewHeadError; + return previewHead; + } + if (templateError) throw templateError; return templateHtml; }, }, @@ -863,7 +868,7 @@ describe('reading from the store that holds the site', () => { it('reports an unreachable store on a site with no head.html', async () => { const { daSourceGet, env } = await build({ - headHtml: undefined, + previewHead: undefined, legacy: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -876,27 +881,54 @@ describe('reading from the store that holds the site', () => { // the config service reads head.html off the code bus, which a site behind Helix authentication // serves to the worker's own token while its preview host refuses it + // the config service returns the raw code-bus head.html: the CSP meta still carries + // move-to-http-header and 'nonce-aem', and the script tags still carry nonce="aem". UE + // injects its own scripts on top, and none of those carry that nonce, so the browser + // refuses them. the preview host runs the pipeline that rewrites the nonces. non-UE hosts + // render the composed page as-is, so the raw meta and the raw nonces match and the config + // service's head is fine there, one fewer round-trip describe('where the page head comes from', () => { - it('reads it off the config service, and asks the preview host for nothing', async () => { + it('is the preview host on a UE host', 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.lookups, 1); - assert.deepStrictEqual(seen.aem, []); + assert.strictEqual(seen.head[0], ''); + assert.ok(seen.aem.includes('/head.html')); }); - it('composes the page with it', async () => { + it('is the preview host on a stage UE host too', async () => { const { daSourceGet, env, seen } = await build(); - const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + const req = authedReq('https://main--site--org.stage-ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.head[0], ''); + assert.ok(seen.aem.includes('/head.html')); + }); + + it('is the config service on a preview host', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.preview.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.head[0], ''); + assert.ok(!seen.aem.includes('/head.html')); + }); + + it('is the config service on localhost', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('http://localhost:8787/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(seen.head[0], ''); + assert.strictEqual(seen.head[0], ''); + assert.ok(!seen.aem.includes('/head.html')); }); - // the pipeline scope answers existence, head.html and the store together + // the pipeline scope answers existence and the store together it('reads the config service once for a page', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -906,8 +938,8 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.lookups, 1); }); - // nothing composes an image, and the head that arrives with the existence answer is dropped - it('reads the config service once for an asset, and no more', async () => { + // nothing composes an image, and neither head is read for a non-html read + it('is not read for an asset', async () => { const { daSourceGet, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -915,9 +947,10 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.lookups, 1); assert.deepStrictEqual(seen.head, []); + assert.ok(!seen.aem.includes('/head.html')); }); - it('reads the config service once on a HEAD, and no more', async () => { + it('is not read on a HEAD', async () => { const { daSourceHead, env, seen } = await build(); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -925,13 +958,14 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(seen.lookups, 1); assert.deepStrictEqual(seen.head, []); + assert.ok(!seen.aem.includes('/head.html')); }); }); // #258: head.html does not say whether a site exists, so a missing head.html is not a 404 describe('when the site serves no head.html', () => { it('reads the document anyway', async () => { - const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const { daSourceGet, env, seen } = await build({ previewHead: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -943,7 +977,7 @@ describe('reading from the store that holds the site', () => { // serves the page without the project's css and js rather than not at all it('composes with no project head entries', async () => { - const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const { daSourceGet, env, seen } = await build({ previewHead: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -954,7 +988,7 @@ describe('reading from the store that holds the site', () => { it('composes the starter template when the document is missing too', async () => { const { daSourceGet, env } = await build({ - headHtml: undefined, + previewHead: undefined, legacy: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -966,7 +1000,7 @@ describe('reading from the store that holds the site', () => { }); it('instruments UE without a head', async () => { - const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const { daSourceGet, env, seen } = await build({ previewHead: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -977,7 +1011,7 @@ describe('reading from the store that holds the site', () => { // sets no cookie: the cookie names the entry script, and no head.html means no entry script it('serves quick-edit the page without an entry-script cookie', async () => { - const { daSourceGet, env } = await build({ headHtml: undefined }); + const { daSourceGet, env } = await build({ previewHead: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -987,7 +1021,7 @@ describe('reading from the store that holds the site', () => { }); it('is not answered 404 by a missing head.html alone', async () => { - const { daSourceGet, env } = await build({ headHtml: undefined }); + const { daSourceGet, env } = await build({ previewHead: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -996,6 +1030,77 @@ describe('reading from the store that holds the site', () => { }); }); + // the preview host answers /head.html separately from the store, and the fetch runs alongside + // the source read so a UE page is still one round-trip + describe('when the preview host cannot answer head.html on a UE host', () => { + it('refuses an html read with 503', async () => { + const { daSourceGet, env } = await build({ + previewHeadError: 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('names the preview host in x-error', async () => { + const { daSourceGet, env } = await build({ + previewHeadError: 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.match(res.headers.get('x-error'), /preview host failed/); + }); + + it('renders the preview-host failure body', async () => { + const { daSourceGet, env } = await build({ + previewHeadError: 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(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); + }); + + it('names the cause, not a category', async () => { + const { daSourceGet, env } = await build({ + previewHeadError: 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'), 'preview host failed: TimeoutError: timed out'); + }); + + // getAEMHtml returns undefined for 404, so a ref that was never previewed does not fail + it('answers 200 with an empty head when the preview host has no head.html', async () => { + const { daSourceGet, env, seen } = await build({ previewHead: 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, 200); + assert.strictEqual(seen.head[0] ?? '', ''); + }); + + // the preview host is only read on UE, so the non-UE path cannot fail on it + it('does not fail a preview-host read on a non-UE host', async () => { + const { daSourceGet, env } = await build({ + previewHeadError: new TypeError('fetch failed'), + }); + const req = authedReq('https://main--site--org.preview.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + }); + }); + describe('when there is no such site', () => { it('refuses an html read with 404 and touches neither store', async () => { const { daSourceGet, env, seen } = await build({ site: NO_SITE }); From 09c3f5f6d32c4cc4c74ed52095220686d01fe5d8 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 23:43:58 +0200 Subject: [PATCH 52/77] fix: on UE, read head.html from the preview host the config service returns head.html unrewritten, so the CSP meta lands as a header claiming 'nonce-aem' and the injected UE scripts do not match it. the preview host runs the pipeline that rewrites both. non-UE renders the composed page as-is, so #270's config service head still fits there. the fetch runs alongside the source read, so a UE page is still one round-trip. allSettled, not all, so a preview-host failure on a nonexistent site does not overwrite the 404 with a 503. --- src/routes/da-admin.js | 18 +++++--- test/routes/da-admin.test.js | 3 ++ test/routes/source-read.test.js | 74 ++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 931424cb..2c5c8e55 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -173,11 +173,16 @@ async function sourceGet({ req, env, daCtx }) { } const aemCtx = getAemCtx(env, daCtx); - const { response: sourceResp, noSuchSite, head: headHtml } = await readSource( - env, - daCtx, - { method: 'GET', headers }, - ); + // UE needs pipeline-rendered head.html, getting that from preview host. + // allSettled: a preview-host failure on a nonexistent site must not overwrite the 404 with 503 + const [sourceResult, previewResult] = await Promise.allSettled([ + readSource(env, daCtx, { method: 'GET', headers }), + isUE + ? withUpstream(PREVIEW_HOST, () => getAEMHtml(aemCtx, '/head.html')) + : Promise.resolve(undefined), + ]); + if (sourceResult.status === 'rejected') throw sourceResult.reason; + const { response: sourceResp, noSuchSite, head: siteHead } = sourceResult.value; if (noSuchSite) { // quick-edit still needs a working shell (with the import map) so the editor @@ -188,6 +193,9 @@ async function sourceGet({ req, env, daCtx }) { return get404(SITE_NOT_FOUND_HTML_MESSAGE); } + if (previewResult.status === 'rejected') throw previewResult.reason; + const headHtml = isUE ? previewResult.value : siteHead; + console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); // the store is the only thing to see the token, and the authorbus extension recovers off the diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 75840c8a..23e38899 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -125,6 +125,9 @@ describe('daSourceGet', () => { }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), + // the UE branch reads /head.html off the preview host; stub it so this + // suite's focus (UE / quick-edit / composeHtml wiring) is not tangled with it + getAEMHtml: async () => undefined, }, '../../src/render/compose.js': { composeHtml: async (daCtx, aemCtx, bodyHtml) => { diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 3453dfc7..7246b353 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -687,6 +687,48 @@ describe('reading from the store that holds the site', () => { assert.ok(!html.includes('ab-src'), 'no marker on the connection uri'); assert.ok(!html.includes('content-store'), 'no marker on the connection uri'); }); + + // a preview host behind Helix authentication refuses /head.html without the site token, so + // the token has to reach the fetch. the fixture-level tests mock getAEMHtml directly and + // never inspect the aemCtx it was given, which leaves this delegation unpinned. an inline + // esmock keeps the real getAEMHtml, so a future refactor that swaps it for a bare fetch or + // drops the aemCtx argument fails here rather than silently at runtime + it('carries the site token on the preview-host /head.html fetch', async () => { + const seenReq = []; + globalThis.fetch = async (input, init) => { + const url = typeof input === 'string' ? input : input.url; + const rawHeaders = init?.headers ?? (input instanceof Request ? input.headers : undefined); + const headers = new Headers(rawHeaders); + seenReq.push({ url, auth: headers.get('Authorization') }); + return new Response('', { status: 200 }); + }; + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + daadmin: { fetch: async () => new Response('', { status: 200 }) }, + }; + // real aemCtx.js, so withAemAuth is exercised. compose is stubbed to keep + // metadata.json out of the picture so seenReq holds only the /head.html fetch + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/site.js': { + default: async () => ({ exists: true, head: '', onSourceBus: false }), + }, + '../../src/render/compose.js': { + composeHtml: async () => ({ type: 'root', children: [] }), + serializeHtml: () => 'composed', + }, + '../../src/ue/ue.js': { applyUEInstrumentation: async () => {} }, + }); + const req = new Request('https://main--site--org.ue.da.live/folder/content', { + headers: { Authorization: 'Bearer t', 'x-site-token': 'siteTok' }, + }); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + const headFetch = seenReq.find((r) => r.url.endsWith('/head.html')); + assert.ok(headFetch, '/head.html was fetched'); + assert.strictEqual(headFetch.auth, 'siteTok'); + }); }); describe('a non-html read', () => { @@ -1146,6 +1188,35 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(res.status, 404); assert.match(await res.text(), /importmap/); }); + + // the preview host fetch runs alongside the source read, and the site's absence has to win + // over a preview host that could not answer, else a page that will never work reads as one + // that a retry might + it('is 404, not 503, when the preview host fails too on a UE host', async () => { + const { daSourceGet, env } = await build({ + site: NO_SITE, + previewHeadError: new TypeError('fetch failed'), + }); + const req = authedReq('https://main--gone--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.match(await res.text(), /Site not found/); + }); + + it('serves the quick-edit shell, not a 503, when the preview host fails too', async () => { + const { daSourceGet, env } = await build({ + site: NO_SITE, + previewHeadError: new TypeError('fetch failed'), + }); + const req = authedReq('https://main--gone--org.ue.da.live/folder/content?quick-edit'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.match(await res.text(), /importmap/); + }); }); }); @@ -1336,7 +1407,8 @@ describe('a path the editor config gives a template', () => { const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.match(await res.text(), /
/); - assert.deepStrictEqual(seen.aem, []); + // the preview host is also asked for /head.html on a UE host, so filter that out here + assert.deepStrictEqual(seen.aem.filter((p) => p !== '/head.html'), []); }); // the config names a path the preview host answers 404 for, which leaves the starter From 0a3da6cfdb9334962396f766ed7f626d5dadcc95 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Wed, 19 Aug 2026 02:17:25 +0200 Subject: [PATCH 53/77] fix: trust experience-stage.adobe.com, for a first-class stage UE entry the stage experience shell (experience-stage.adobe.com) needs to reach the stage worker so /gimme_cookie mints against a stage IMS identity. without this, the stage UE path is unreachable except by minting the cookie from prod experience.adobe.com with a stage token side-loaded. --- src/utils/constants.js | 1 + test/routes/cookie.test.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/utils/constants.js b/src/utils/constants.js index 5093c3a5..36468685 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -13,6 +13,7 @@ export const TRUSTED_ORIGINS = [ 'https://da.live', 'https://experience.adobe.com', + 'https://experience-stage.adobe.com', 'https://localhost.corp.adobe.com:8080', ]; diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js index a5a69f75..23557e04 100644 --- a/test/routes/cookie.test.js +++ b/test/routes/cookie.test.js @@ -142,6 +142,20 @@ describe('getCookie', () => { assert.strictEqual(calls.length, 0); }); + // the stage experience shell has to reach the stage worker so the stage UE path has a + // first-class entry point + it('trusts the stage experience shell', async () => { + stubFetch(mints); + + const res = await getCookie({ + req: req({ origin: 'https://experience-stage.adobe.com' }), + env, + daCtx: daCtx(), + }); + + assert.notStrictEqual(res.status, 403); + }); + it('answers 401 without an Authorization header', async () => { stubFetch(mints); From e2188284a721113e7205839dcec9cc0b2fdcd504 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Thu, 20 Aug 2026 15:06:14 +0200 Subject: [PATCH 54/77] test: cover composed CSP nonce forwarding Pin the nonce rewrite and require each injected script to receive the composed policy nonce without widening author content. --- test/render/csp.test.js | 127 ++++++++++++++++++++++++++++++++++ test/routes/da-admin.test.js | 17 ++++- test/ue/scaffold.test.js | 15 ++++ test/ue/ue.test.js | 9 ++- test/utils/quick-edit.test.js | 18 +++-- 5 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 test/render/csp.test.js diff --git a/test/render/csp.test.js b/test/render/csp.test.js new file mode 100644 index 00000000..9c97dc99 --- /dev/null +++ b/test/render/csp.test.js @@ -0,0 +1,127 @@ +/* + * 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 { fromHtml } from 'hast-util-from-html'; +import { select, selectAll } from 'hast-util-select'; +import applyCsp from '../../src/render/csp.js'; + +const cspMeta = (content, extra = '') => ``; + +describe('applyCsp', () => { + it('rewrites the CSP meta and re-stamps head scripts with one nonce', () => { + const tree = fromHtml(` + ${cspMeta("script-src 'nonce-aem' 'strict-dynamic'; object-src 'none';", 'move-to-http-header="true" move-as-header="true"')} + + `); + + const nonce = applyCsp(tree); + const meta = select('meta[http-equiv="content-security-policy"]', tree); + const script = select('script', tree); + + assert.ok(nonce); + assert.ok(meta.properties.content.includes(`'nonce-${nonce}'`)); + assert.ok(!meta.properties.content.includes("'nonce-aem'")); + assert.strictEqual(meta.properties['move-to-http-header'], undefined); + assert.strictEqual(meta.properties['move-as-header'], undefined); + assert.strictEqual(script.properties.nonce, nonce); + }); + + it('re-stamps only elements covered by a nonce-bearing directive', () => { + const tree = fromHtml(` + ${cspMeta("script-src 'nonce-aem'; style-src 'nonce-aem'")} + + + + + `); + + const nonce = applyCsp(tree); + const stamped = selectAll('[nonce]', tree); + + assert.strictEqual(stamped.length, 4); + assert.ok(stamped.every((node) => node.properties.nonce === nonce)); + }); + + it('removes require-trusted-types-for without changing other directives', () => { + const warnings = []; + const saved = console.warn; + console.warn = (message) => warnings.push(message); + try { + const tree = fromHtml(` + ${cspMeta("script-src 'nonce-aem' 'strict-dynamic'; object-src 'none'; require-trusted-types-for 'script'; trusted-types editor;")} + `); + + const nonce = applyCsp(tree); + const content = select('meta', tree).properties.content; + + assert.ok(content.includes(`script-src 'nonce-${nonce}' 'strict-dynamic'`)); + assert.ok(content.includes("object-src 'none'")); + assert.ok(content.includes('trusted-types editor')); + assert.ok(!content.includes('require-trusted-types-for')); + assert.ok(warnings.some((message) => message.includes('require-trusted-types-for'))); + } finally { + console.warn = saved; + } + }); + + it('leaves a policy without the nonce sentinel byte-for-byte unchanged', () => { + const content = "default-src 'self'"; + const tree = fromHtml(` + ${cspMeta(content, 'move-to-http-header="true"')} + + `); + + assert.strictEqual(applyCsp(tree), undefined); + const meta = select('meta', tree); + assert.strictEqual(meta.properties.content, content); + assert.strictEqual(meta.properties['move-to-http-header'], 'true'); + assert.strictEqual(select('script', tree).properties.nonce, 'aem'); + }); + + it('ignores report-only metas', () => { + const tree = fromHtml(` + + + `); + + assert.strictEqual(applyCsp(tree), undefined); + assert.strictEqual(select('script', tree).properties.nonce, 'aem'); + }); + + it('adds no nonce when the page has no CSP meta', () => { + const tree = fromHtml(''); + + assert.strictEqual(applyCsp(tree), undefined); + assert.strictEqual(select('script', tree).properties.nonce, undefined); + }); + + it('returns no script nonce for a style-only policy', () => { + const tree = fromHtml(` + ${cspMeta("style-src 'nonce-aem'")} + + + `); + + assert.strictEqual(applyCsp(tree), undefined); + assert.strictEqual(select('script', tree).properties.nonce, 'aem'); + assert.notStrictEqual(select('style', tree).properties.nonce, 'aem'); + }); + + it('mints a different nonce for each composed document', () => { + const first = fromHtml(`${cspMeta("script-src 'nonce-aem'")}`); + const second = fromHtml(`${cspMeta("script-src 'nonce-aem'")}`); + + assert.notStrictEqual(applyCsp(first), applyCsp(second)); + }); +}); diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index 23e38899..8b4afd4d 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -118,7 +118,9 @@ describe('daSourceGet', () => { // of being masked by the default parameter value. const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; const exists = overrides.site?.exists ?? true; - calls = { compose: [], ue: 0, quickEdit: 0 }; + calls = { + compose: [], ue: 0, ueNonce: undefined, quickEdit: 0, quickEditNonce: undefined, + }; return (await esmock('../../src/routes/da-admin.js', { '../../src/storage/site.js': { default: async () => ({ exists, head: headHtml, onSourceBus: false }), @@ -136,12 +138,19 @@ describe('daSourceGet', () => { }, serializeHtml: () => 'composed', }, + '../../src/render/csp.js': { + default: () => 'abc123', + }, '../../src/ue/ue.js': { - applyUEInstrumentation: async () => { calls.ue += 1; }, + applyUEInstrumentation: async (documentTree, daCtx, aemCtx, nonce) => { + calls.ue += 1; + calls.ueNonce = nonce; + }, }, '../../src/utils/quick-edit.js': { - applyQuickEditToDocument: () => { + applyQuickEditToDocument: (documentTree, nonce) => { calls.quickEdit += 1; + calls.quickEditNonce = nonce; return '/scripts/scripts.js'; }, buildQuickEditCookie: (p) => `da-quick-edit=${encodeURIComponent(p)}; Path=/`, @@ -162,6 +171,7 @@ describe('daSourceGet', () => { assert.strictEqual(res.status, 200); assert.strictEqual(calls.ue, 1); + assert.strictEqual(calls.ueNonce, 'abc123'); assert.strictEqual(calls.quickEdit, 0); assert.strictEqual(res.headers.get('Set-Cookie'), null); }); @@ -200,6 +210,7 @@ describe('daSourceGet', () => { assert.strictEqual(res.status, 200); assert.strictEqual(calls.quickEdit, 1); + assert.strictEqual(calls.quickEditNonce, 'abc123'); assert.strictEqual(calls.ue, 0); assert.ok(res.headers.get('Set-Cookie')?.includes('da-quick-edit=%2Fscripts%2Fscripts.js')); }); diff --git a/test/ue/scaffold.test.js b/test/ue/scaffold.test.js index 2c95b91b..005231a3 100644 --- a/test/ue/scaffold.test.js +++ b/test/ue/scaffold.test.js @@ -111,6 +111,21 @@ describe('UE scaffold', () => { ); }); + it('stamps every injected script when a CSP nonce is provided', () => { + const entries = scaffold.getUEHtmlHeadEntries(daCtx, aemCtx, 'abc123'); + const scriptTags = entries.filter((entry) => entry.tagName === 'script'); + + assert.strictEqual(scriptTags.length, 4); + assert.ok(scriptTags.every((tag) => tag.properties.nonce === 'abc123')); + }); + + it('does not stamp injected scripts when no CSP nonce is provided', () => { + const entries = scaffold.getUEHtmlHeadEntries(daCtx, aemCtx); + const scriptTags = entries.filter((entry) => entry.tagName === 'script'); + + assert.ok(scriptTags.every((tag) => tag.properties.nonce === undefined)); + }); + it('generates correct head entries for local environment', () => { daCtx.isLocal = true; daCtx.hostname = 'localhost'; diff --git a/test/ue/ue.test.js b/test/ue/ue.test.js index 210eb4fe..7fc63dee 100644 --- a/test/ue/ue.test.js +++ b/test/ue/ue.test.js @@ -22,9 +22,13 @@ describe('applyUEInstrumentation', () => { it('adds UE head entries and UE body attributes to a composed tree', async () => { let ueConfigArg; let injectedBody; + let scaffoldNonce; const { applyUEInstrumentation } = await esmock('../../src/ue/ue.js', { '../../src/ue/scaffold.js': { - getUEHtmlHeadEntries: () => [h('meta', { name: 'urn:adobe:aue:system:ab', content: 'x' })], + getUEHtmlHeadEntries: (daCtx, aemCtx, nonce) => { + scaffoldNonce = nonce; + return [h('meta', { name: 'urn:adobe:aue:system:ab', content: 'x' })]; + }, getUEConfig: async () => { ueConfigArg = 'config'; return { ok: true }; @@ -36,7 +40,7 @@ describe('applyUEInstrumentation', () => { }); const tree = fromHtml('
content
'); - await applyUEInstrumentation(tree, { org: 'o', site: 's' }, {}); + await applyUEInstrumentation(tree, { org: 'o', site: 's' }, {}, 'abc123'); // UE head entry was pushed into the head node const head = select('head', tree); @@ -46,5 +50,6 @@ describe('applyUEInstrumentation', () => { // UE attributes were applied to the body node, using the fetched config assert.strictEqual(ueConfigArg, 'config'); assert.strictEqual(injectedBody, select('body', tree)); + assert.strictEqual(scaffoldNonce, 'abc123'); }); }); diff --git a/test/utils/quick-edit.test.js b/test/utils/quick-edit.test.js index 74c72a5e..495539e6 100644 --- a/test/utils/quick-edit.test.js +++ b/test/utils/quick-edit.test.js @@ -115,22 +115,30 @@ describe('quick-edit script transform', () => { assert.ok(out.includes('"da-lit"')); }); - it('applies CSP nonce to all script tags', () => { + it('applies the CSP nonce only to an import map it creates', () => { const html = ''; const { html: out } = quickEdit.prepareQuickEditDocument(html, 'abc123'); - assert.ok(out.includes('nonce="abc123"')); + const tree = fromHtml(out); + assert.strictEqual(tree.children[0].children[0].children[0].properties.nonce, 'abc123'); + assert.strictEqual(tree.children[0].children[0].children[1].properties.nonce, undefined); + }); + + it('does not apply the CSP nonce to an existing author import map', () => { + const html = ''; + const { html: out } = quickEdit.prepareQuickEditDocument(html, 'abc123'); + assert.ok(!out.includes('nonce=')); }); }); describe('applyQuickEditToDocument', () => { - it('injects the import map, finds the entry script, and stamps the nonce', () => { + it('injects a nonced import map and finds the entry script', () => { const tree = fromHtml(''); const entryPath = quickEdit.applyQuickEditToDocument(tree, 'abc123'); const out = toHtml(tree, { allowDangerousHtml: true }); assert.strictEqual(entryPath, '/scripts/scripts.js'); - assert.ok(out.includes(''; + const body = '
'; + + const tree = await composeHtml(daCtx, aemCtx, body, cspHead); + const nonce = applyCsp(tree); + + assert.ok(select('head meta[http-equiv]', tree).properties.content.includes(`'nonce-${nonce}'`)); + assert.strictEqual(select('head script', tree).properties.nonce, nonce); + assert.strictEqual(select('body script', tree).properties.nonce, 'aem'); + }); }); diff --git a/test/render/csp.test.js b/test/render/csp.test.js index ae022056..57ffa214 100644 --- a/test/render/csp.test.js +++ b/test/render/csp.test.js @@ -30,6 +30,7 @@ describe('applyCsp', () => { const script = select('script', tree); assert.ok(nonce); + assert.match(nonce, /^[A-Za-z0-9+/]{24}$/); assert.ok(meta.properties.content.includes(`'nonce-${nonce}'`)); assert.ok(!meta.properties.content.includes("'nonce-aem'")); assert.strictEqual(meta.properties['move-to-http-header'], undefined); @@ -37,6 +38,28 @@ describe('applyCsp', () => { assert.strictEqual(script.properties.nonce, nonce); }); + it('rewrites only the case-insensitive policy and placeholders in the head', () => { + const bodyContent = "script-src 'nonce-aem'; object-src 'none'"; + const tree = fromHtml(` + + + + + + `); + + const nonce = applyCsp(tree); + const headMeta = select('head meta', tree); + const headScript = select('head script', tree); + const bodyMeta = select('body meta', tree); + const bodyScript = select('body script', tree); + + assert.ok(headMeta.properties.content.includes(`'nonce-${nonce}'`)); + assert.strictEqual(headScript.properties.nonce, nonce); + assert.strictEqual(bodyMeta.properties.content, bodyContent); + assert.strictEqual(bodyScript.properties.nonce, 'aem'); + }); + it('re-stamps only elements covered by a nonce-bearing directive', () => { const tree = fromHtml(` ${cspMeta("script-src 'nonce-aem'; style-src 'nonce-aem'")} diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index fac18bde..c545c8aa 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -1210,6 +1210,18 @@ describe('a path the editor config gives a template', () => { assert.strictEqual(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); }); + it('names the template failure cause in x-error', async () => { + const { daSourceGet, env } = await build({ + ...missingDoc(['/folder=/scripts/tpl.html']), + templateError: 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'), 'preview host failed: TimeoutError: timed out'); + }); + it('takes the longest matching prefix', async () => { const { daSourceGet, env, seen } = await build(missingDoc([ '/=/scripts/site.html', diff --git a/test/utils/aemCtx.test.js b/test/utils/aemCtx.test.js index 2f17923b..eea36b23 100644 --- a/test/utils/aemCtx.test.js +++ b/test/utils/aemCtx.test.js @@ -19,6 +19,7 @@ import { getDaCtx } from '../../src/utils/daCtx.js'; describe('AEM context', () => { let getAemCtx; let getAEMHtml; + let withAemAuth; let fixUrlsWhenLocalDev; let aemCtx; @@ -26,6 +27,7 @@ describe('AEM context', () => { const mod = await esmock('../../src/utils/aemCtx.js'); getAemCtx = mod.getAemCtx; getAEMHtml = mod.getAEMHtml; + withAemAuth = mod.withAemAuth; fixUrlsWhenLocalDev = mod.fixUrlsWhenLocalDev; }); @@ -66,6 +68,26 @@ describe('AEM context', () => { }); }); + describe('withAemAuth', () => { + it('adds the site token without dropping existing request headers', () => { + const init = withAemAuth( + { siteToken: 'site-token' }, + { method: 'GET', headers: { Accept: 'text/html' } }, + ); + + assert.strictEqual(init.method, 'GET'); + assert.strictEqual(init.headers.get('Accept'), 'text/html'); + assert.strictEqual(init.headers.get('Authorization'), 'site-token'); + }); + + it('leaves authorization unset when there is no site token', () => { + const init = withAemAuth({}, { headers: { Accept: 'text/html' } }); + + assert.strictEqual(init.headers.get('Accept'), 'text/html'); + assert.strictEqual(init.headers.get('Authorization'), null); + }); + }); + describe('getAEMHtml function', () => { const mockAemCtx = { previewUrl: 'https://main--site--org.aem.page', From 9f77783198957335873895c087a1f35e1cc77af1 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Thu, 20 Aug 2026 17:34:45 +0200 Subject: [PATCH 61/77] fix: scope composed CSP handling to head Keep author body policies and placeholder nonces untouched while matching the pipeline's case-insensitive CSP meta selection. --- src/render/csp.js | 7 ++++--- src/routes/da-admin.js | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/render/csp.js b/src/render/csp.js index 9f150bd7..02e0e364 100644 --- a/src/render/csp.js +++ b/src/render/csp.js @@ -48,8 +48,9 @@ function createNonce() { * page carries no policy that asks for one */ export default function applyCsp(documentTree) { - const meta = select('meta[http-equiv="content-security-policy"]', documentTree) - || select('meta[http-equiv="Content-Security-Policy"]', documentTree); + // head.html owns the policy and placeholders; the body is author content that round-trips + const scope = select('head', documentTree) ?? documentTree; + const meta = select('meta[http-equiv="content-security-policy" i]', scope); const content = meta?.properties.content; if (typeof content !== 'string' || !content.includes(NONCE_AEM)) { return undefined; @@ -65,7 +66,7 @@ export default function applyCsp(documentTree) { delete meta.properties['move-to-http-header']; delete meta.properties['move-as-header']; - visit(documentTree, (node) => { + visit(scope, (node) => { if (node.properties?.nonce !== 'aem') return; if (scriptNonce diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 90b75874..3861d89a 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -154,8 +154,8 @@ async function sourceGet({ req, env, daCtx }) { } // determine the request type before `req` is reassigned to the admin request. - // quick-edit takes precedence; UE is gated on the hostname; everything else - // (preview hosts, local dev) renders the composed page as-is. + // quick-edit takes precedence; UE is gated on its hosted suffixes or configured local host; + // preview hosts render the composed page as-is. const url = new URL(req.url); const isQuickEdit = url.searchParams.has('quick-edit'); const isUE = url.hostname.endsWith('.ue.da.live') From fc12c04ab311c0d27894f552f39e8b8554e1ad09 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 11:31:39 +0200 Subject: [PATCH 62/77] test: pin the composed read and the write guards Cover the preview-host read composeHtml makes, the store body that stops arriving mid-read, a document parse5 leaves without a body, and the lookup a write owes before it parses. --- test/routes/source-read.test.js | 135 +++++++++++++++++++++++++++++-- test/routes/source-write.test.js | 53 ++++++++++++ 2 files changed, 182 insertions(+), 6 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index c545c8aa..cd26e711 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -43,7 +43,7 @@ const build = async (overrides = {}) => { : 'from the template'; const site = 'site' in overrides ? overrides.site : LEGACY_STORE; const { - lookupError, templateError, configError, composeError, config = null, + lookupError, templateError, configError, serializeError, config = null, } = overrides; const seen = { bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, @@ -88,11 +88,13 @@ const build = async (overrides = {}) => { '../../src/render/compose.js': { // returns a hast root, so quick-edit can walk what was built composeHtml: async (daCtx, aemCtx, bodyHtml, head) => { - if (composeError) throw composeError; seen.head.push(head); return { type: 'root', children: [], bodyHtml }; }, - serializeHtml: (tree) => `${tree.bodyHtml}`, + serializeHtml: (tree) => { + if (serializeError) throw serializeError; + return `${tree.bodyHtml}`; + }, }, '../../src/ue/ue.js': { applyUEInstrumentation: async () => { seen.ue += 1; }, @@ -108,6 +110,34 @@ const build = async (overrides = {}) => { return { ...mod, env, seen }; }; +/** + * Builds the route with compose.js and metadata.js left real, so the /metadata.json read + * composeHtml makes is the one under test. The site is legacy, so its document comes over the + * service binding and `globalThis.fetch` sees nothing but the preview host. + */ +const buildComposing = async (overrides = {}) => { + const { + metadata = () => new Response('{"data":[]}', { status: 200 }), + legacy = () => new Response('
from da-admin
', { status: 200 }), + } = overrides; + const seen = { metadata: [] }; + globalThis.fetch = async (input, init) => { + seen.metadata.push({ url: String(input), init: init ?? {} }); + return metadata(); + }; + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + daadmin: { fetch: async () => legacy() }, + }; + const mod = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/site.js': { + default: async () => ({ exists: true, head: '', onSourceBus: false }), + }, + }); + return { ...mod, env, seen }; +}; + describe('when the lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; @@ -525,6 +555,38 @@ describe('reading from the store that holds the site', () => { }); }); + // the store answered, so the read is past the fetch; the body arrives after it, and a body + // that never finishes arriving is the same failed read + describe('when the store drops the connection mid-body', () => { + const truncated = () => new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('
')); + controller.error(new TypeError('Network connection lost')); + }, + }), + { status: 200 }, + ); + + it('answers 503 rather than throwing', async () => { + const { daSourceGet, env } = await build({ legacy: truncated }); + 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('names the store in x-error', async () => { + const { daSourceGet, env } = await build({ legacy: truncated }); + 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'), /^content store failed/); + }); + }); + 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 @@ -1260,15 +1322,76 @@ describe('a path the editor config gives a template', () => { }); }); +// composing the page reads /metadata.json off the preview host, and that read is the last one on +// a GET that answered outside the taxonomy: a bodyless 500 where every other upstream says 503 +describe('the read the composed page makes of the preview host', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // a preview host rather than a UE host, so nothing is instrumented onto the composed page + const at = 'https://main--site--org.preview.da.live/folder/content'; + + it('composes the page when the preview host answers', async () => { + const { daSourceGet, env, seen } = await buildComposing(); + const req = authedReq(at); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(seen.metadata[0].url, 'https://main--site--org.aem.page/metadata.json'); + }); + + it('refuses with 503 when the preview host cannot be reached', async () => { + const { daSourceGet, env } = await buildComposing({ + metadata: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq(at); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.match(res.headers.get('x-error'), /^preview host failed/); + assert.strictEqual(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); + }); + + // a preview host serving the 404 page as HTML answers 200, and the sheet read throws on it + it('refuses with 503 when metadata.json answers 200 with something other than json', async () => { + const { daSourceGet, env } = await buildComposing({ + metadata: () => new Response('not a sheet', { status: 200 }), + }); + const req = authedReq(at); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.match(res.headers.get('x-error'), /^preview host failed/); + assert.strictEqual(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); + }); + + // a preview host that accepts the connection and never answers would otherwise hold the page + // open for the whole request budget + it('gives the read a deadline without dropping the auth headers', async () => { + const { daSourceGet, env, seen } = await buildComposing(); + const req = authedReq(at); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(seen.metadata[0].init.signal instanceof AbortSignal); + assert.ok(seen.metadata[0].init.headers instanceof Headers); + }); +}); + describe('when the worker itself has a bug', () => { afterEach(() => { delete globalThis.fetch; }); - // only a failed upstream read is retryable, and a 503 would keep the throw out of the log - // the worker boundary writes + // serializing the composed tree reaches nothing, so a throw there is the worker's own. Only a + // failed upstream read is retryable, and a 503 would keep the throw out of the log the worker + // boundary writes it('lets the throw through rather than rendering it as a 503', async () => { - const { daSourceGet, env } = await build({ composeError: new TypeError('tree is not iterable') }); + const { daSourceGet, env } = await build({ serializeError: new TypeError('tree is not iterable') }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await assert.rejects( diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index ca255f2e..2a227dec 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -22,6 +22,7 @@ import { const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; +const FRAMESET = ''; // what the store lookup answers const SOURCE_BUS = true; @@ -347,4 +348,56 @@ describe('writing to the store that holds the site', () => { assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); }); + + // parse5 gives a frameset document no body element, and the rewrite has nothing to run on + describe('a document with no body', () => { + it('is refused with 415 rather than throwing', async () => { + const { daSourcePost, env, seen } = await build({}); + const req = uePost(AT, FRAMESET); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 415); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + }); + + describe('the order a write refuses in', () => { + // the frameset is what tells the two orderings apart: parsed first it is the 415 above, + // looked up first it is the 404, and only one of them costs a parse and a rewrite + it('answers 404 for a site that does not exist rather than parsing the document', async () => { + const { daSourcePost, env, seen } = await build({ exists: false }); + const req = uePost(AT, FRAMESET); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.strictEqual(await res.text(), SITE_NOT_FOUND_MESSAGE); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // the extension and the part type are read off the request, so they still refuse a write + // before the site is looked up at all + it('refuses a non-html path ahead of a site that does not exist', async () => { + const { daSourcePost, env, seen } = await build({ exists: false }); + 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.probes, 0); + }); + + it('refuses a non-html part ahead of a site that does not exist', async () => { + const { daSourcePost, env, seen } = await build({ exists: false }); + const body = new FormData(); + body.set('data', new File([DOC], 'content.html', { type: 'application/json' })); + const req = new Request(AT, { method: 'POST', body, headers: { Authorization: 'Bearer t' } }); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 415); + assert.strictEqual(seen.probes, 0); + }); + }); }); From 53e012888852a6be2678eb297711a2bf08d8d493 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 11:32:44 +0200 Subject: [PATCH 63/77] fix: name the upstream the composed read failed on Wrap the composed page's preview-host read and the store body that arrives after the status so both answer 503 with a body naming the upstream, give the metadata sheet the same deadline the site lookup has, refuse a document parse5 leaves without a body, and resolve the site before a write parses anything. --- src/render/metadata.js | 6 ++++- src/routes/da-admin.js | 52 +++++++++++++++++++++++++++--------------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/render/metadata.js b/src/render/metadata.js index 8fdf648f..d9e5bded 100644 --- a/src/render/metadata.js +++ b/src/render/metadata.js @@ -14,6 +14,8 @@ import { select } from 'hast-util-select'; import { readBlockConfig } from '../utils/hast.js'; import { withAemAuth } from '../utils/aemCtx.js'; +const TIMEOUT_MS = 5 * 1000; + export function extractLocalMetadata(bodyTree) { const metaBlock = select('div.metadata', bodyTree); let metaConfig = {}; @@ -157,7 +159,9 @@ export class Modifiers { export async function fetchBulkMetadata(aemCtx) { const url = new URL('/metadata.json', aemCtx.previewUrl); - const response = await fetch(url, withAemAuth(aemCtx)); + const response = await fetch(url, withAemAuth(aemCtx, { + signal: AbortSignal.timeout(TIMEOUT_MS), + })); if (response.ok) { const json = await response.json(); diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 3861d89a..fdd18590 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -204,13 +204,18 @@ async function sourceGet({ req, env, daCtx }) { return sourceResp; } - // use the stored content when available, otherwise fall back to a template + // use the stored content when available, otherwise fall back to a template. the body arrives + // after the status, so a read that stops here is still the store's const bodyHtml = sourceResp.status === 200 - ? await sourceResp.text() + ? await withUpstream(CONTENT_STORE, () => sourceResp.text()) : await getPageTemplate(env, daCtx, aemCtx); - // builds the page without head.html, which a ref that was never built does not have - const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); + // builds the page without head.html, which a ref that was never built does not have. + // composing reads the metadata sheet off the preview host, so a failure below is that upstream + const documentTree = await withUpstream( + PREVIEW_HOST, + () => composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''), + ); const nonce = applyCsp(documentTree); // layer the request-specific instrumentation on top of the composed page @@ -287,12 +292,36 @@ async function sourcePost({ req, env, daCtx }) { if (isFile && !isHtmlPostType(obj.data.type)) { return get415(); } + + // resolved before the document is read and rewritten, since a write with nowhere to go is + // refused whatever the document turns out to be + const { exists, onSourceBus } = await withUpstream( + SITE_LOOKUP, + () => getSiteConfig(env, daCtx), + ); + + if (!exists) { + console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return post404(SITE_NOT_FOUND_MESSAGE); + } + + 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); + } + const { body: bodyHtml } = isFile ? await getFileBody(obj.data) : getTextBody(obj.data); const documentTree = fromHtml(bodyHtml); let bodyNode = select('body', documentTree); + // a frameset document parses without a body, and the rewrite below has nothing to run on + if (!bodyNode) { + console.log(`415 POST ${sourcePath}, the document has no body`); + return get415(); + } + // unwrap rich text elements // clean up UE data attributes bodyNode = unwrapParagraphs(bodyNode); @@ -305,21 +334,6 @@ async function sourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - const { exists, onSourceBus } = await withUpstream( - SITE_LOOKUP, - () => getSiteConfig(env, daCtx), - ); - - if (!exists) { - console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); - return post404(SITE_NOT_FOUND_MESSAGE); - } - - 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(); From 6375ea3e0ec3f1e4968e09cba3fcf1c740c9defd Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 11:39:07 +0200 Subject: [PATCH 64/77] test: pin the route's CSP nonce ordering Run the real compose, csp and UE modules through the route so the nonce applyCsp mints is asserted where it has to land, on the injected UE scripts and the quick-edit import map, with no placeholder left in the response. --- test/routes/da-admin.test.js | 77 ++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/test/routes/da-admin.test.js b/test/routes/da-admin.test.js index f96c4c7d..aec92cb2 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -13,6 +13,8 @@ /* eslint-env mocha */ import assert from 'assert'; import esmock from 'esmock'; +import { fromHtml } from 'hast-util-from-html'; +import { select, selectAll } from 'hast-util-select'; import reqs from '../mocks/req.js'; const { getDaCtx } = await import('../../src/utils/daCtx.js'); @@ -324,6 +326,81 @@ describe('daSourceGet', () => { }); }); +// the suite above stubs csp.js, so it can only pin which value reached the injectors. this one +// runs the real compose / csp / ue modules: applyCsp mints the nonce and rewrites the head.html +// placeholders, and everything injected afterwards has to carry that same value. +describe('daSourceGet CSP nonce', () => { + const cspHead = '' + + ''; + + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + UE_HOST: 'ue.da.live', + daadmin: { + fetch: async () => new Response('

stored

', { status: 200 }), + }, + }; + + // composing reads the metadata sheet and the UE scaffold reads the component JSON, both off the + // preview host; a 404 is what each of them takes as "nothing to merge" + beforeEach(() => { + globalThis.fetch = async () => new Response('not found', { status: 404 }); + }); + + afterEach(() => { + delete globalThis.fetch; + }); + + const serve = async (url) => { + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/site.js': { + default: async () => ({ exists: true, head: cspHead, onSourceBus: false }), + }, + }); + const req = authedReq(url); + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + return res.text(); + }; + + const mintedNonce = (tree) => { + const meta = select('head meta[http-equiv="content-security-policy" i]', tree); + return /'nonce-([^']+)'/.exec(meta.properties.content)?.[1]; + }; + + it('stamps the minted nonce on the injected UE scripts', async () => { + const html = await serve('https://main--site--org.ue.da.live/folder/content'); + const tree = fromHtml(html); + const nonce = mintedNonce(tree); + + assert.ok(nonce); + assert.notStrictEqual(nonce, 'aem'); + assert.strictEqual( + select('head script[src="https://universal-editor-service.adobe.io/cors.js"]', tree) + .properties.nonce, + nonce, + ); + const componentScripts = selectAll('head script[src^="/component-"]', tree); + assert.strictEqual(componentScripts.length, 3); + componentScripts.forEach((script) => assert.strictEqual(script.properties.nonce, nonce)); + assert.ok(!html.includes('nonce="aem"')); + }); + + it('stamps the minted nonce on the injected quick-edit import map', async () => { + const html = await serve('https://main--site--org.ue.da.live/folder/content?quick-edit'); + const tree = fromHtml(html); + const nonce = mintedNonce(tree); + + assert.ok(nonce); + assert.notStrictEqual(nonce, 'aem'); + assert.strictEqual(select('head script[type="importmap"]', tree).properties.nonce, nonce); + assert.ok(!html.includes('nonce="aem"')); + }); +}); + describe('source URLs', () => { // answers the unmocked lookups with a legacy site, the store these tests describe beforeEach(() => { From 48fcfcdef6269f74bbe282b4463db3184640ebc2 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 11:41:04 +0200 Subject: [PATCH 65/77] test: separate a missing config service token from an outage Pin the log line an unset HLX_CONFIG_SERVICE_TOKEN owes before the lookup goes out, that the lookup still goes out, that a set token stays quiet, and that a 401 or 403 names whether the token was there. --- test/storage/site.test.js | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/test/storage/site.test.js b/test/storage/site.test.js index 78ae49c1..ff6f286e 100644 --- a/test/storage/site.test.js +++ b/test/storage/site.test.js @@ -37,6 +37,18 @@ const stubFetch = (respond) => { }; }; +const capturingErrors = async (run) => { + const errors = []; + const saved = console.error; + console.error = (m) => errors.push(m); + try { + await run(); + } finally { + console.error = saved; + } + return errors; +}; + const config = (over = {}) => new Response(JSON.stringify({ head: { html: HEAD }, contentSource: { type: 'markup', url: 'https://content.da.live/org/site/' }, @@ -185,6 +197,31 @@ describe('getSiteConfig', () => { }); }); + // an unset token goes out as the string "undefined" and comes back a refusal, which reads + // exactly like an outage unless the worker says which of the two it is + describe('when the worker carries no config service token', () => { + it('says the worker is misconfigured, and asks anyway', async () => { + stubFetch(config); + + const errors = await capturingErrors( + () => getSiteConfig({ ...env, HLX_CONFIG_SERVICE_TOKEN: undefined }, daCtx()), + ); + + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /HLX_CONFIG_SERVICE_TOKEN/); + assert.match(errors[0], /every site lookup/); + assert.strictEqual(calls.length, 1); + }); + + it('says nothing when the token is there', async () => { + stubFetch(config); + + const errors = await capturingErrors(() => getSiteConfig(env, daCtx())); + + assert.deepStrictEqual(errors, []); + }); + }); + describe('when the config service refuses', () => { [401, 403, 429, 500, 503].forEach((status) => { it(`throws on a ${status}`, async () => { @@ -194,6 +231,29 @@ describe('getSiteConfig', () => { }); }); + // a rotated token and an upstream outage are the same status, so the message carries the + // one thing that separates them + [401, 403].forEach((status) => { + it(`names the token as present on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects( + () => getSiteConfig(env, daCtx()), + /HLX_CONFIG_SERVICE_TOKEN present/, + ); + }); + + it(`names the token as missing on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + const noToken = { ...env, HLX_CONFIG_SERVICE_TOKEN: undefined }; + + await capturingErrors(() => assert.rejects( + () => getSiteConfig(noToken, daCtx()), + /HLX_CONFIG_SERVICE_TOKEN missing/, + )); + }); + }); + it('throws when the answer is not json', async () => { stubFetch(() => new Response('', { status: 200 })); From 5de6215a901d1bdcd306efb4e3826dbe218db749 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 11:42:09 +0200 Subject: [PATCH 66/77] fix: say when the config service token is the cause An unset HLX_CONFIG_SERVICE_TOKEN goes out as the string "undefined" and comes back a refusal that looks exactly like an upstream outage, so log the misconfiguration before the lookup and name the token's presence on a 401 or 403. The lookup still goes out and still fails the same way, since short-circuiting it would change behaviour where only the log was missing, and the token value never reaches either line. --- src/storage/site.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/storage/site.js b/src/storage/site.js index 79626c45..254e13ac 100644 --- a/src/storage/site.js +++ b/src/storage/site.js @@ -33,6 +33,11 @@ export default async function getSiteConfig(env, daCtx) { if (!org || !site) return NO_SITE; const url = new URL(`/${ref}--${site}--${org}/config.json?scope=pipeline`, env.HLX_CONFIG_SERVICE); + // an unset token goes out as the string "undefined", so the lookup still runs and still fails, + // and only the log says the worker is the one at fault + if (!env.HLX_CONFIG_SERVICE_TOKEN) { + console.error('no HLX_CONFIG_SERVICE_TOKEN, this worker is misconfigured and every site lookup will be refused'); + } const response = await fetch(url, { headers: { 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, @@ -42,6 +47,10 @@ export default async function getSiteConfig(env, daCtx) { }); if (response.status === 404) return NO_SITE; + if (response.status === 401 || response.status === 403) { + const token = env.HLX_CONFIG_SERVICE_TOKEN ? 'present' : 'missing'; + throw new Error(`the config service answered ${response.status}, HLX_CONFIG_SERVICE_TOKEN ${token}`); + } if (!response.ok) throw new Error(`the config service answered ${response.status}`); const { head, contentSource } = await response.json(); From 4e2d4fb6d47be84cd93c679b8eb4db18fe7d52b3 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 11:44:11 +0200 Subject: [PATCH 67/77] test: pin the CSP meta that stays in the document Assert that a policy asking to move to a header keeps its meta in the head, carrying the rewritten nonce and neither move attribute, so the surviving element is covered rather than only the attributes being gone. --- test/render/csp.test.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/render/csp.test.js b/test/render/csp.test.js index 57ffa214..f7412976 100644 --- a/test/render/csp.test.js +++ b/test/render/csp.test.js @@ -38,6 +38,21 @@ describe('applyCsp', () => { assert.strictEqual(script.properties.nonce, nonce); }); + it('keeps a policy asking for a header in the document', () => { + const tree = fromHtml(` + ${cspMeta("script-src 'nonce-aem'; frame-ancestors 'self';", 'move-to-http-header="true" move-as-header="true"')} + `); + + const nonce = applyCsp(tree); + const meta = select('head meta[http-equiv="content-security-policy" i]', tree); + + assert.ok(meta, 'the meta has to survive, a header would break framing in the editor'); + assert.ok(meta.properties.content.includes(`'nonce-${nonce}'`)); + assert.ok(meta.properties.content.includes("frame-ancestors 'self'")); + assert.strictEqual(meta.properties['move-to-http-header'], undefined); + assert.strictEqual(meta.properties['move-as-header'], undefined); + }); + it('rewrites only the case-insensitive policy and placeholders in the head', () => { const bodyContent = "script-src 'nonce-aem'; object-src 'none'"; const tree = fromHtml(` From 439ad035bd709d51574a0c1f3a058eefe4f61265 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 11:45:03 +0200 Subject: [PATCH 68/77] chore: record why the CSP meta stays in the document The pipeline moves a policy carrying move-to-http-header to a response header and drops the meta, while this branch keeps the meta on purpose, since frame-ancestors delivered as a header would stop the editor framing the page and is ignored in a meta element; note as well that rewriting the head here is what supersedes the preview-host head.html read on UE. --- src/render/csp.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/render/csp.js b/src/render/csp.js index 02e0e364..5050b84e 100644 --- a/src/render/csp.js +++ b/src/render/csp.js @@ -49,6 +49,7 @@ function createNonce() { */ export default function applyCsp(documentTree) { // head.html owns the policy and placeholders; the body is author content that round-trips + // this rewrite supersedes 09c3f5f's preview-host head.html read on UE, reversed by aa1dd76 const scope = select('head', documentTree) ?? documentTree; const meta = select('meta[http-equiv="content-security-policy" i]', scope); const content = meta?.properties.content; @@ -63,6 +64,8 @@ export default function applyCsp(documentTree) { meta.properties.content = removeTrustedTypesRequire( content.replaceAll(NONCE_AEM, `'nonce-${nonce}'`), ); + // the pipeline moves this policy to a response header; we keep it in the document because + // frame-ancestors sent as a header stops the editor framing the page, and a meta ignores it delete meta.properties['move-to-http-header']; delete meta.properties['move-as-header']; From 15466d50992199a979bebe501fa17b8117b1e666 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 11:48:06 +0200 Subject: [PATCH 69/77] fix: make the local stand-in exercise the composed page Serve the policy and its placeholders from the stand-in head, add a site on the source bus, and refuse a request that carries no token. --- README.md | 7 +++++-- dev/lookup-shim.js | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a6f5ef49..a0e64eba 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,21 @@ This worker performs all content operations via [da-admin](https://github.com/ad One read of config.aem.page, pipeline scope, answers existence, head.html and `contentSource`. Its url names the store. -The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to its `SITES` table. A site missing from the table is answered 404. A source url on api.aem.live reads as source-bus. +The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to its `SITES` table. A site missing from the table is answered 404. A source url on api.aem.live reads as source-bus, and the table ships one site of each kind, `org/site` and `org/sourcebus`, so both branches can be driven locally. To run da-universal locally: 1. Clone this repo to your computer. 1. Run `npm install` 1. Use `npx wrangler login` if not done before. Walk through the steps in browser. +1. Put `HLX_CONFIG_SERVICE_TOKEN="local"` in `.dev.vars.dev`, which is gitignored. 1. In a terminal, run `npm run dev:lookups` to start the stand-in lookups on port 4713. 1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 -Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. +The stand-in does not read the token's value, only that there is one. Without it the lookup goes out as the string `undefined` and comes back 401, the way the real service refuses it, and the worker logs that it is the one at fault. + +`npm run dev` sets `UE_HOST` to localhost:4712, so https://localhost:4712 serves the UE-instrumented page rather than the composed page as-is, and points `urn:adobe:aue:config:service` at https://localhost:8000. A Universal Editor service has to be running there for that page to open in the editor. with the shared secret, use `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js index 5bbaf0dc..7c2dfee6 100644 --- a/dev/lookup-shim.js +++ b/dev/lookup-shim.js @@ -15,15 +15,25 @@ // and one with a source url on api.aem.live is source-bus const SITES = { 'org/site': 'https://content.da.live/org/site/', + 'org/sourcebus': 'https://api.aem.live/org/sites/sourcebus/source/', }; -// what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with -const HEAD_HTML = '\n\n'; +// what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with. +// the policy and the placeholders are what applyCsp keys on, so a page served locally exercises +// the nonce rewrite, the trusted-types strip and the move-to-http-header deletion +const HEAD_HTML = '\n\n\n'; export default { async fetch(req) { const url = new URL(req.url); + // an unset HLX_CONFIG_SERVICE_TOKEN reaches the header as the string "undefined", and the + // real service refuses that the same way it refuses no header at all + const token = req.headers.get('x-access-token'); + if (!token || token === 'undefined') { + return new Response('', { status: 401, headers: { 'x-error': 'missing x-access-token.' } }); + } + const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); if (!org || !site) { return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); From 7400ab3e31bfdd8e630abd48f04af5558a43dd78 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 12:01:42 +0200 Subject: [PATCH 70/77] test: pin a throw from composing as the worker's own bug Composing walks the stored document as well as reading the metadata sheet, so pin that a throw from that work still reaches the boundary, and name the deadline test after what it checks. --- test/routes/source-read.test.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index cd26e711..86c97074 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -43,7 +43,7 @@ const build = async (overrides = {}) => { : 'from the template'; const site = 'site' in overrides ? overrides.site : LEGACY_STORE; const { - lookupError, templateError, configError, serializeError, config = null, + lookupError, templateError, configError, composeError, serializeError, config = null, } = overrides; const seen = { bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, @@ -89,6 +89,7 @@ const build = async (overrides = {}) => { // returns a hast root, so quick-edit can walk what was built composeHtml: async (daCtx, aemCtx, bodyHtml, head) => { seen.head.push(head); + if (composeError) throw composeError; return { type: 'root', children: [], bodyHtml }; }, serializeHtml: (tree) => { @@ -1371,7 +1372,7 @@ describe('the read the composed page makes of the preview host', () => { // a preview host that accepts the connection and never answers would otherwise hold the page // open for the whole request budget - it('gives the read a deadline without dropping the auth headers', async () => { + it('gives the read a deadline without replacing the fetch init', async () => { const { daSourceGet, env, seen } = await buildComposing(); const req = authedReq(at); @@ -1399,4 +1400,16 @@ describe('when the worker itself has a bug', () => { /tree is not iterable/, ); }); + + // composing reads the metadata sheet, but the rest of it walks the stored document, and a throw + // from that work is the worker's own however malformed the document was + it('lets a throw from composing through rather than blaming the preview host', async () => { + const { daSourceGet, env } = await build({ composeError: new TypeError('cannot read properties of undefined') }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await assert.rejects( + () => daSourceGet({ req, env, daCtx: getDaCtx(req) }), + /cannot read properties of undefined/, + ); + }); }); From 5dba1a85643484964e6a0af1769e44524f51e8fc Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 12:02:55 +0200 Subject: [PATCH 71/77] fix: name the preview host on the read, not on composing Wrapping the whole compose step answered a bug in the worker's own tree work as a retryable preview-host outage, so the wrap now sits on the metadata read alone. --- src/render/compose.js | 6 ++++-- src/routes/da-admin.js | 10 ++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/render/compose.js b/src/render/compose.js index 5e738cd4..8a88a66c 100644 --- a/src/render/compose.js +++ b/src/render/compose.js @@ -20,6 +20,7 @@ import rewriteIcons from './rewrite-icons.js'; import { makeImagesRelative } from './rewrite-images.js'; import extractSectionMetadata from './section-metadata.js'; import { DEFAULT_HTML_TEMPLATE } from '../utils/constants.js'; +import { PREVIEW_HOST, withUpstream } from '../utils/upstream.js'; /** * Injects AEM HTML head entries into the head node of an HTML document. @@ -98,8 +99,9 @@ export async function composeHtml(daCtx, aemCtx, bodyHtmlStr, headHtmlStr) { const bodyTree = fromHtml(bodyHtmlStr, { fragment: true }); bodyNode.children = bodyTree.children; - // fetch bulk metadata, extract metadata block from the body and merge them - const bulkMetadata = await fetchBulkMetadata(aemCtx); + // fetch bulk metadata, extract metadata block from the body and merge them. the sheet is the + // only thing here that leaves the worker, so it is the only step that names an upstream + const bulkMetadata = await withUpstream(PREVIEW_HOST, () => fetchBulkMetadata(aemCtx)); const localMetaData = extractLocalMetadata(bodyTree); const mergedMetaData = { ...localMetaData, diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index fdd18590..4a3efda6 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -210,12 +210,10 @@ async function sourceGet({ req, env, daCtx }) { ? await withUpstream(CONTENT_STORE, () => sourceResp.text()) : await getPageTemplate(env, daCtx, aemCtx); - // builds the page without head.html, which a ref that was never built does not have. - // composing reads the metadata sheet off the preview host, so a failure below is that upstream - const documentTree = await withUpstream( - PREVIEW_HOST, - () => composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''), - ); + // builds the page without head.html, which a ref that was never built does not have. the read + // composing makes of the preview host names that upstream itself, and the rest of composing + // walks the stored document, where a throw is the worker's own + const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); const nonce = applyCsp(documentTree); // layer the request-specific instrumentation on top of the composed page From 6c912183b99df769110a0d86b6e6e7c82c5d0dbe Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 12:07:29 +0200 Subject: [PATCH 72/77] chore: record the head source decision without commit shas The comment named two branch commits that a squash merge would leave unreachable, so it now states the decision itself: the rewrite is what lets the config service answer head.html for the editor, and reading it from the preview host was the alternative it replaced. --- src/render/csp.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/render/csp.js b/src/render/csp.js index 5050b84e..27da9e64 100644 --- a/src/render/csp.js +++ b/src/render/csp.js @@ -49,7 +49,8 @@ function createNonce() { */ export default function applyCsp(documentTree) { // head.html owns the policy and placeholders; the body is author content that round-trips - // this rewrite supersedes 09c3f5f's preview-host head.html read on UE, reversed by aa1dd76 + // rewriting here is what lets the config service answer head.html for UE too. reading it from + // the preview host for the pipeline's own rewrite was the alternative, at a round trip per page const scope = select('head', documentTree) ?? documentTree; const meta = select('meta[http-equiv="content-security-policy" i]', scope); const content = meta?.properties.content; From 3c4e76da4b8ebaed21f85fdf2252b1e7366fd075 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 12:08:42 +0200 Subject: [PATCH 73/77] test: pin the proxy read to the same upstream taxonomy A stylesheet, script or sheet the preview host cannot answer reaches the worker boundary as a bodyless 500, where every other upstream read on the branch answers 503 with Retry-After and an x-error naming what failed. --- test/handlers/get.test.js | 29 +++++++++++++++++++++++++++++ test/handlers/head.test.js | 27 +++++++++++++++++++++++++++ test/routes/aem-proxy.test.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/test/handlers/get.test.js b/test/handlers/get.test.js index dd385729..1f8ce9fc 100644 --- a/test/handlers/get.test.js +++ b/test/handlers/get.test.js @@ -16,6 +16,7 @@ import esmock from 'esmock'; import reqs from '../mocks/req.js'; const { getDaCtx } = await import('../../src/utils/daCtx.js'); +const { PREVIEW_HOST, UpstreamError } = await import('../../src/utils/upstream.js'); describe('GET handler', () => { describe('early returns', () => { @@ -305,3 +306,31 @@ describe('GET handler', () => { }); }); }); + +describe('GET handler resource reads', () => { + let getHandler; + + beforeEach(async () => { + getHandler = (await esmock('../../src/handlers/get.js', { + '../../src/routes/da-admin.js': { daSourceGet: async () => new Response() }, + '../../src/routes/aem-proxy.js': { + handleAEMProxyRequest: async () => { + throw new UpstreamError(PREVIEW_HOST, new TypeError('fetch failed')); + }, + }, + })).default; + }); + + it('refuses a stylesheet the preview host could not answer with a bodyless 503', async () => { + const req = new Request('https://main--site--org.ue.da.live/styles/styles.css'); + const daCtx = getDaCtx(req); + + const res = await getHandler({ req, env: {}, daCtx }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(res.headers.get('Retry-After'), '5'); + assert.match(res.headers.get('x-error'), /preview host failed/); + // an HTML shell would only corrupt a stylesheet the browser is parsing as CSS + assert.strictEqual(await res.text(), ''); + }); +}); diff --git a/test/handlers/head.test.js b/test/handlers/head.test.js index 285338fd..d3893569 100644 --- a/test/handlers/head.test.js +++ b/test/handlers/head.test.js @@ -16,6 +16,7 @@ import esmock from 'esmock'; import reqs from '../mocks/req.js'; const { getDaCtx } = await import('../../src/utils/daCtx.js'); +const { PREVIEW_HOST, UpstreamError } = await import('../../src/utils/upstream.js'); describe('HEAD handler', () => { describe('early returns', () => { @@ -400,3 +401,29 @@ describe('HEAD handler', () => { }); }); }); + +describe('HEAD handler resource reads', () => { + let headHandler; + + beforeEach(async () => { + headHandler = (await esmock('../../src/handlers/head.js', { + '../../src/routes/da-admin.js': { daSourceHead: async () => new Response(null, { status: 200 }) }, + '../../src/routes/aem-proxy.js': { + handleAEMProxyRequest: async () => { + throw new UpstreamError(PREVIEW_HOST, new TypeError('fetch failed')); + }, + }, + })).default; + }); + + it('refuses a stylesheet the preview host could not answer with a 503', async () => { + const req = new Request('https://main--site--org.ue.da.live/styles/styles.css', { method: 'HEAD' }); + const daCtx = getDaCtx(req); + + const res = await headHandler({ req, env: {}, daCtx }); + + assert.strictEqual(res.status, 503); + assert.strictEqual(res.headers.get('Retry-After'), '5'); + assert.match(res.headers.get('x-error'), /preview host failed/); + }); +}); diff --git a/test/routes/aem-proxy.test.js b/test/routes/aem-proxy.test.js index 9738b0aa..effd89f0 100644 --- a/test/routes/aem-proxy.test.js +++ b/test/routes/aem-proxy.test.js @@ -14,6 +14,7 @@ import assert from 'assert'; import esmock from 'esmock'; import { getDaCtx } from '../../src/utils/daCtx.js'; +import { PREVIEW_HOST, UpstreamError } from '../../src/utils/upstream.js'; describe('AEM proxy quick-edit', () => { let handleAEMProxyRequest; @@ -82,3 +83,31 @@ describe('AEM proxy quick-edit', () => { assert.strictEqual(res.headers.get('Set-Cookie'), null); }); }); + +describe('AEM proxy upstream failures', () => { + let handleAEMProxyRequest; + const env = { UE_HOST: 'test-host', UE_SERVICE: 'test-service' }; + + beforeEach(async () => { + const mod = await esmock('../../src/routes/aem-proxy.js'); + handleAEMProxyRequest = mod.handleAEMProxyRequest; + }); + + afterEach(() => { + delete globalThis.fetch; + }); + + it('names the preview host when the read cannot be made', async () => { + const req = new Request('https://main--site--org.ue.da.live/styles/styles.css'); + const daCtx = getDaCtx(req); + + globalThis.fetch = async () => { + throw new TypeError('fetch failed'); + }; + + await assert.rejects( + () => handleAEMProxyRequest({ req, env, daCtx }), + (e) => e instanceof UpstreamError && e.upstream === PREVIEW_HOST, + ); + }); +}); From af14e2ef140999f22d001ea1d2fb6fce24eab635 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 12:09:22 +0200 Subject: [PATCH 74/77] fix: refuse a resource read the preview host could not answer The proxy read now names the preview host, and the two handlers that call it directly answer 503 with Retry-After instead of letting the throw reach the worker boundary as a bodyless 500. The refusal carries no body, since an HTML shell would corrupt a stylesheet or a sheet; the asset paths still fall back through allSettled unchanged. --- src/handlers/get.js | 11 +++++++++-- src/handlers/head.js | 11 +++++++++-- src/responses/index.js | 6 ++++++ src/routes/aem-proxy.js | 3 ++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/handlers/get.js b/src/handlers/get.js index df429f3d..a3ee6978 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -9,10 +9,11 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { get404, getRobots } from '../responses/index.js'; +import { get404, getRobots, resource503 } from '../responses/index.js'; import { handleAEMProxyRequest } from '../routes/aem-proxy.js'; import { getCookie } from '../routes/cookie.js'; import { daSourceGet } from '../routes/da-admin.js'; +import { UpstreamError } from '../utils/upstream.js'; export default async function getHandler({ req, env, daCtx }) { const { path } = daCtx; @@ -25,7 +26,13 @@ export default async function getHandler({ req, env, daCtx }) { const resourceRegex = /\.(css|js|js\.map|json|xml|woff|woff2|otf|ttf|plain\.html|html)$/i; if (resourceRegex.test(path)) { - return handleAEMProxyRequest({ req, env, daCtx }); + try { + return await handleAEMProxyRequest({ req, env, daCtx }); + } catch (e) { + if (!(e instanceof UpstreamError)) throw e; + console.warn(`503 GET ${path}, ${e.message}`); + return resource503(e.message); + } } const assetRegex = /\.(png|jpg|jpeg|webp|gif|svg|ico|avif)$/i; diff --git a/src/handlers/head.js b/src/handlers/head.js index aeb62746..402ff2c5 100644 --- a/src/handlers/head.js +++ b/src/handlers/head.js @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import { getRobots, head404 } from '../responses/index.js'; +import { getRobots, head404, resource503 } from '../responses/index.js'; import { handleAEMProxyRequest } from '../routes/aem-proxy.js'; import { daSourceHead } from '../routes/da-admin.js'; +import { UpstreamError } from '../utils/upstream.js'; // for AEM we reuse the handleAEMProxyRequest for now as GETs are cheap here // TODO refine and review for later for a full HEAD requests on AEM @@ -31,7 +32,13 @@ export default async function headHandler({ req, env, daCtx }) { const resourceRegex = /\.(css|js|js\.map|json|xml|woff|woff2|otf|ttf|plain\.html|html)$/i; if (resourceRegex.test(path)) { - return aemHead({ req, env, daCtx }); + try { + return await aemHead({ req, env, daCtx }); + } catch (e) { + if (!(e instanceof UpstreamError)) throw e; + console.warn(`503 HEAD ${path}, ${e.message}`); + return resource503(e.message); + } } const assetRegex = /\.(png|jpg|jpeg|webp|gif|svg|ico|avif)$/i; diff --git a/src/responses/index.js b/src/responses/index.js index c07a3abb..8f815104 100644 --- a/src/responses/index.js +++ b/src/responses/index.js @@ -99,6 +99,12 @@ export function head503(error = '') { return new Response(null, { status: 503, headers: retryHeaders(error) }); } +// a refused stylesheet, script or sheet carries no body: nothing renders CSS, so the HTML shell a +// page gets would only corrupt what the browser is parsing. +export function resource503(error = '') { + return new Response(null, { status: 503, headers: retryHeaders(error) }); +} + export function head404() { return new Response(null, { status: 404 }); } diff --git a/src/routes/aem-proxy.js b/src/routes/aem-proxy.js index f8812ead..789bdea7 100644 --- a/src/routes/aem-proxy.js +++ b/src/routes/aem-proxy.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ import { getAemCtx } from '../utils/aemCtx.js'; +import { PREVIEW_HOST, withUpstream } from '../utils/upstream.js'; import { applyQuickEditToScript, getQuickEditCookiePath, @@ -47,7 +48,7 @@ export async function handleAEMProxyRequest({ req, env, daCtx }) { } console.log(`-> ${aemUrl.toString()}`); - let response = await fetch(req, { cf: { cacheTtl: 0 } }); + let response = await withUpstream(PREVIEW_HOST, () => fetch(req, { cf: { cacheTtl: 0 } })); console.log(`<- ${aemUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); const contentType = (response.headers.get('Content-Type') || '').toLowerCase(); From d50e1e9a666586b1cf6d3e1494ae77dd695c4858 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 14:18:35 +0200 Subject: [PATCH 75/77] test: separate a refused site token exchange from a public site A site that needs no site token and an exchange the service refused both end with no site token and no log, so an author on an authenticated site loses the token with nothing anywhere saying why. --- test/routes/cookie.test.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js index 23557e04..703cbda5 100644 --- a/test/routes/cookie.test.js +++ b/test/routes/cookie.test.js @@ -123,6 +123,40 @@ describe('getCookie', () => { assert.strictEqual(cookies(res).length, 1); }); + // a public site and a refused exchange both end with no site token, so only the log tells + // an operator which of the two happened + it('says which status refused the exchange', async () => { + stubFetch(() => new Response('', { status: 404 })); + const warnings = []; + const saved = console.warn; + console.warn = (m) => warnings.push(m); + + try { + await getCookie({ req: req(), env, daCtx: daCtx() }); + } finally { + console.warn = saved; + } + + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /org\/site/); + assert.match(warnings[0], /404/); + }); + + it('says nothing when the site simply needs no site token', async () => { + stubFetch(noAuthNeeded); + const warnings = []; + const saved = console.warn; + console.warn = (m) => warnings.push(m); + + try { + await getCookie({ req: req(), env, daCtx: daCtx() }); + } finally { + console.warn = saved; + } + + assert.strictEqual(warnings.length, 0); + }); + it('asks for no exchange when the caller already has a site token', async () => { stubFetch(mints); From d7da2339e1608c7ca17515e3d4eb140ff26895b2 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Fri, 21 Aug 2026 14:19:03 +0200 Subject: [PATCH 76/77] fix: say which status refused the site token exchange The non-ok branch folded a 400, a 401, a 404 and any 5xx into the same silent null the public-site answer produces, so the refusal now names the site and the status and the fail-open behaviour is unchanged. --- src/routes/cookie.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/routes/cookie.js b/src/routes/cookie.js index 1559ecf7..47e5735a 100644 --- a/src/routes/cookie.js +++ b/src/routes/cookie.js @@ -27,7 +27,8 @@ async function exchangeSiteToken(env, org, site, accessToken) { }); if (!response.ok) { - // 401/403 error cases + // a public site answers 200 with nothing, so a refusal is the one case with no other signal + console.warn(`the site token exchange for ${org}/${site} answered ${response.status}`); return null; } From 730a9b2858362c3a111a73a9cf5dbfaa3a04ed2a Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 25 Aug 2026 18:01:50 +0200 Subject: [PATCH 77/77] refactor: fold resource503 into empty503, one body-less 503 helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/handlers/get.js | 4 ++-- src/handlers/head.js | 4 ++-- src/responses/index.js | 8 +------- src/routes/da-admin.js | 4 ++-- 4 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/handlers/get.js b/src/handlers/get.js index a3ee6978..9e7ab950 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -9,7 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { get404, getRobots, resource503 } from '../responses/index.js'; +import { empty503, get404, getRobots } from '../responses/index.js'; import { handleAEMProxyRequest } from '../routes/aem-proxy.js'; import { getCookie } from '../routes/cookie.js'; import { daSourceGet } from '../routes/da-admin.js'; @@ -31,7 +31,7 @@ export default async function getHandler({ req, env, daCtx }) { } catch (e) { if (!(e instanceof UpstreamError)) throw e; console.warn(`503 GET ${path}, ${e.message}`); - return resource503(e.message); + return empty503(e.message); } } diff --git a/src/handlers/head.js b/src/handlers/head.js index 402ff2c5..e275261f 100644 --- a/src/handlers/head.js +++ b/src/handlers/head.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { getRobots, head404, resource503 } from '../responses/index.js'; +import { empty503, getRobots, head404 } from '../responses/index.js'; import { handleAEMProxyRequest } from '../routes/aem-proxy.js'; import { daSourceHead } from '../routes/da-admin.js'; import { UpstreamError } from '../utils/upstream.js'; @@ -37,7 +37,7 @@ export default async function headHandler({ req, env, daCtx }) { } catch (e) { if (!(e instanceof UpstreamError)) throw e; console.warn(`503 HEAD ${path}, ${e.message}`); - return resource503(e.message); + return empty503(e.message); } } diff --git a/src/responses/index.js b/src/responses/index.js index 8f815104..6ca52b3b 100644 --- a/src/responses/index.js +++ b/src/responses/index.js @@ -95,13 +95,7 @@ export function head401() { return new Response(null, { status: 401 }); } -export function head503(error = '') { - return new Response(null, { status: 503, headers: retryHeaders(error) }); -} - -// a refused stylesheet, script or sheet carries no body: nothing renders CSS, so the HTML shell a -// page gets would only corrupt what the browser is parsing. -export function resource503(error = '') { +export function empty503(error = '') { return new Response(null, { status: 503, headers: retryHeaders(error) }); } diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index 4a3efda6..36f3d6a4 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -23,7 +23,7 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head404, head503, post404, post405, post503, + daResp, empty503, get401, get404, get415, get503, head401, head404, post404, post405, post503, } from '../responses/index.js'; import { DEFAULT_HTML_TEMPLATE, @@ -72,7 +72,7 @@ const UPSTREAM_FAILURE_TEXT = { function refuseUpstreamFailure(e, method, sourcePath) { if (!(e instanceof UpstreamError)) throw e; console.warn(`503 ${method} ${sourcePath}, ${e.message}`); - if (method === 'HEAD') return head503(e.message); + if (method === 'HEAD') return empty503(e.message); if (method === 'POST') { return post503(UPSTREAM_FAILURE_TEXT[e.upstream] ?? SOURCE_FAILED_MESSAGE, e.message); }