From bae0a8216003d267dbcea531509cacc7055899e6 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Tue, 18 Aug 2026 16:35:18 +0200 Subject: [PATCH] Revert "fix: ask the config service whether a site exists, not head.html (#270)" This reverts commit f4f1ff4ab2093bfae2f7bcff6d2c7bda545d9843. --- .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, 562 insertions(+), 1595 deletions(-) delete mode 100644 dev/lookup-shim.js delete mode 100644 dev/lookup-shim.toml delete mode 100644 src/storage/site.js create mode 100644 src/storage/source-bus.js delete mode 100644 src/utils/upstream.js delete mode 100644 test/routes/cookie.test.js delete mode 100644 test/storage/site.test.js create mode 100644 test/storage/source-bus.test.js delete mode 100644 test/utils/upstream.test.js diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index f54932c9..054d9499 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@v4 + uses: cloudflare/wrangler-action@v3 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@v4 + uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/README.md b/README.md index a6f5ef49..5205fd84 100644 --- a/README.md +++ b/README.md @@ -11,26 +11,17 @@ 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: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. In a terminal, run `npm run dev` 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 run deploy:stage` to test it in a real worker environment. +You can deploy da-universal on Cloudflare stage via `npm 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 deleted file mode 100644 index 5bbaf0dc..00000000 --- a/dev/lookup-shim.js +++ /dev/null @@ -1,47 +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. - */ - -// 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 deleted file mode 100644 index 79e3d4a2..00000000 --- a/dev/lookup-shim.toml +++ /dev/null @@ -1,9 +0,0 @@ -# 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 abbd1c37..1c5ff742 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 883d9923..15c6fb3f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "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 df429f3d..591cada5 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, env, daCtx }); + if (path.startsWith('/gimme_cookie')) return getCookie({ req, daCtx }); const resourceRegex = /\.(css|js|js\.map|json|xml|woff|woff2|otf|ttf|plain\.html|html)$/i; if (resourceRegex.test(path)) { @@ -35,14 +35,8 @@ export default async function getHandler({ req, env, daCtx }) { handleAEMProxyRequest({ req, env, daCtx }), ]); - // 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'); + const storeRes = daSourceGetRes.status === 'fulfilled' ? daSourceGetRes.value : undefined; + const aemRes = aemProxyRes.status === 'fulfilled' ? aemProxyRes.value : undefined; let response; if (storeRes?.status === 200) { diff --git a/src/responses/index.js b/src/responses/index.js index c07a3abb..41758933 100644 --- a/src/responses/index.js +++ b/src/responses/index.js @@ -77,10 +77,6 @@ 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 1559ecf7..352f348f 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(env, org, site, accessToken) { +async function exchangeSiteToken(org, site, accessToken) { try { - const response = await fetch(new URL('/auth/adobe/exchange', env.AEM_API), { + const response = await fetch('https://admin.hlx.page/auth/adobe/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -48,7 +48,7 @@ async function exchangeSiteToken(env, org, site, accessToken) { } } -export async function getCookie({ req, env, daCtx }) { +export async function getCookie({ req, 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, env, daCtx }) { // Try to exchange for site token if (org && site && !daCtx.siteToken) { - const siteTokenData = await exchangeSiteToken(env, org, site, cookieValue); + const siteTokenData = await exchangeSiteToken(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 931424cb..d2484b6f 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -22,60 +22,39 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head404, head503, post404, post405, post503, + daResp, get401, get404, get415, get503, head401, head503, 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_FAILED_HTML_MESSAGE, - SOURCE_FAILED_MESSAGE, + SOURCE_UNREACHABLE_HTML_MESSAGE, + SOURCE_UNREACHABLE_MESSAGE, UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; -import { getEditorConfig } from '../storage/config.js'; -import getSiteConfig from '../storage/site.js'; +import { getSiteConfig } from '../storage/config.js'; +import isSourceBus from '../storage/source-bus.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; -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, -}; - /** - * 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. + * Renders a failure for the `x-error` header. */ -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); +function causeOf(e) { + return `${e?.name ?? 'Error'}: ${e?.message ?? e}` + .replace(/[^\x20-\x7e]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 1024); +} + +function probeFailed(e, method, sourcePath) { + const cause = `/ping failed: ${causeOf(e)}`; + console.warn(`503 ${method} ${sourcePath}, ${cause}`); + return cause; } export function isHtmlPostType(type) { @@ -94,8 +73,12 @@ 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 withUpstream(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); + let config; + try { + config = await getSiteConfig(env, daCtx); + } catch (e) { + return DEFAULT_HTML_TEMPLATE; + } // Search whether a template is configured for this path const matchingTemplates = config @@ -112,7 +95,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { } const templatePath = matchingTemplates[0].template; - const templateHtml = await withUpstream(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); + const templateHtml = await getAEMHtml(aemCtx, templatePath); if (templateHtml) { return templateHtml; } @@ -121,30 +104,39 @@ async function getPageTemplate(env, daCtx, aemCtx) { } /** - * Reads the document from the store that holds the site. + * Sends a request to a store and reports why it could not be reached at all. * - * Sets `noSuchSite` when there is no such site, `response` otherwise. + * @returns {Promise<{response?: Response, error?: string}>} + */ +async function reachStore(store, send) { + try { + return { response: await send() }; + } catch (e) { + const error = causeOf(e); + console.warn(`503 ${store.url}, the store could not be reached: ${error}`); + return { error }; + } +} + +/** + * Reads from the store that holds the site. * - * @returns {Promise<{response?: Response, noSuchSite?: boolean}>} - * @throws {UpstreamError} when the lookup or the store fails + * @returns {Promise<{response?: Response, error?: string}>} `error` says why there is no response */ async function readSource(env, daCtx, init) { - 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 }; + let onSourceBus; + try { + onSourceBus = await isSourceBus(env, daCtx); + } catch (e) { + return { error: probeFailed(e, init.method, daCtx.sourcePath) }; } - const store = getStore(env, daCtx, site.onSourceBus); + const store = getStore(env, daCtx, onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); - return { - response: await withUpstream(CONTENT_STORE, () => store.fetch(store.url, init)), - head: site.head, - }; + return reachStore(store, () => store.fetch(store.url, init)); } -async function sourceGet({ req, env, daCtx }) { +export async function daSourceGet({ req, env, daCtx }) { const { ext, authToken } = daCtx; // check if Authorization header is present @@ -166,28 +158,27 @@ async function sourceGet({ 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, noSuchSite } = await readSource(env, daCtx, { method: 'GET', headers }); - if (noSuchSite) return get404(); + const { response, error } = await readSource(env, daCtx, { method: 'GET', headers }); + if (!response) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, error); console.log(`<- ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } + // the store lookup costs a round trip, so it runs alongside head.html rather than after it const aemCtx = getAemCtx(env, daCtx); - const { response: sourceResp, noSuchSite, head: headHtml } = await readSource( - env, - daCtx, - { method: 'GET', headers }, - ); - - if (noSuchSite) { + const [headHtml, { response: sourceResp, error: sourceError }] = await Promise.all([ + getAEMHtml(aemCtx, '/head.html'), + readSource(env, daCtx, { method: 'GET', headers }), + ]); + if (!headHtml) { // quick-edit still needs a working shell (with the import map) so the editor - // can load into this page, even when the site does not exist. + // can load into this page, even when the AEM branch doesn't exist yet. if (isQuickEdit) { return buildQuickEditNotFoundResponse(); } - return get404(SITE_NOT_FOUND_HTML_MESSAGE); + return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); } - + if (!sourceResp) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, sourceError); console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); // the store is the only thing to see the token, and the authorbus extension recovers off the @@ -205,10 +196,10 @@ 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); + : await getPageTemplate(env, daCtx, aemCtx, 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 ?? ''); + // compose the page the same way for every request type + const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml); // layer the request-specific instrumentation on top of the composed page const extraHeaders = []; @@ -234,16 +225,7 @@ async function sourceGet({ req, 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 }) { +export async function daSourceHead({ env, daCtx }) { const { authToken } = daCtx; if (!authToken) { @@ -253,22 +235,13 @@ async function sourceHead({ env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); - const { response, noSuchSite } = await readSource(env, daCtx, { method: 'HEAD', headers }); - if (noSuchSite) return head404(); + const { response, error } = await readSource(env, daCtx, { method: 'HEAD', headers }); + if (!response) return head503(error); console.log(`<- HEAD ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return new Response(null, { status: response.status, headers: response.headers }); } -/** 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 }) { +export async function daSourcePost({ req, env, daCtx }) { const { sourcePath, ext, authToken } = daCtx; // the body is rewritten as HTML below, so anything but an HTML document would be @@ -302,14 +275,13 @@ 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); + // the payload is settled, so the only question left is where it goes + let onSourceBus; + try { + onSourceBus = await isSourceBus(env, daCtx); + } catch (e) { + const cause = probeFailed(e, 'POST', sourcePath); + return post503(SOURCE_UNDETERMINED_MESSAGE, cause); } if (onSourceBus) { @@ -322,26 +294,15 @@ 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 withUpstream(CONTENT_STORE, () => store.fetch(new Request(store.url, { + const { response, error } = await reachStore(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 7147054c..8f87ef32 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); - // 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`); + if (!res.ok) { 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 getEditorConfig(env, daCtx) { +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/src/storage/site.js b/src/storage/site.js deleted file mode 100644 index 79626c45..00000000 --- a/src/storage/site.js +++ /dev/null @@ -1,58 +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 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 new file mode 100644 index 00000000..9cfe6b89 --- /dev/null +++ b/src/storage/source-bus.js @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const TIMEOUT_MS = 5 * 1000; +const UPGRADE_HEADER = 'x-api-upgrade-available'; + +/** + * Asks `/ping` whether a site is on the source bus. + * + * An answer without the header is legacy: helix-admin sets it when config resolution succeeded and + * named the API. A probe that cannot answer throws, so the caller refuses with the cause rather + * than picking a store. + * + * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes + * @param {Object} daCtx + * @returns {Promise} + */ +export default async function isSourceBus(env, daCtx) { + const { org, site } = daCtx; + // an unparseable hostname leaves org and site undefined, and there is no site to ask about + if (!org || !site) return false; + + const url = new URL(`/ping/${org}/${site}`, env.HLX_ADMIN); + const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); + return response.headers.get(UPGRADE_HEADER) !== null; +} diff --git a/src/utils/aemCtx.js b/src/utils/aemCtx.js index b02c2b84..54ddaa25 100644 --- a/src/utils/aemCtx.js +++ b/src/utils/aemCtx.js @@ -48,15 +48,8 @@ export function withAemAuth(aemCtx, init = {}) { export async function getAEMHtml(aemCtx, path) { const { previewUrl } = aemCtx; - 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 resp = await fetch(`${previewUrl}${path}`, withAemAuth(aemCtx)); + if (!resp.ok) return undefined; const headHtml = await resp.text(); return headHtml; } diff --git a/src/utils/constants.js b/src/utils/constants.js index 5093c3a5..69706aed 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -49,21 +49,13 @@ export const UNAUTHORIZED_HTML_MESSAGE = ` export const DEFAULT_HTML_TEMPLATE = '
'; -export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found

There is no site at this address.

'; +export const BRANCH_NOT_FOUND_HTML_MESSAGE = '

Not found: Unable to retrieve AEM branch

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

503: Content store unreachable

The store that holds this document did not answer. 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_UNREACHABLE_MESSAGE = 'The store that holds this document did not answer, 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_UNDETERMINED_MESSAGE = 'Which store holds this document could not be determined, so nothing was written. Please retry.'; export const SOURCE_BUS_READ_ONLY_MESSAGE = 'This site is on the source bus, which this proxy only reads. Nothing was written, and retrying will not help.'; diff --git a/src/utils/quick-edit.js b/src/utils/quick-edit.js index c8cf10f2..30be4054 100644 --- a/src/utils/quick-edit.js +++ b/src/utils/quick-edit.js @@ -189,12 +189,14 @@ export function prepareQuickEditDocument(html, nonce) { } /** - * 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. + * 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. * @returns {Response} */ export function buildQuickEditNotFoundResponse() { - console.log('[quick-edit] doc compose: no such site, serving a minimal scaffold'); + console.log('[quick-edit] doc compose: head.html not found on origin, 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 deleted file mode 100644 index cf73bb0e..00000000 --- a/src/utils/upstream.js +++ /dev/null @@ -1,60 +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. - */ - -/** 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 dd385729..86c7634f 100644 --- a/test/handlers/get.test.js +++ b/test/handlers/get.test.js @@ -63,18 +63,13 @@ 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 (args) => { - getCookieArgs = args; - return new Response('cookie-set', { status: 200 }); - }, + getCookie: async () => new Response('cookie-set', { status: 200 }), }, })).default; }); @@ -88,9 +83,6 @@ 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 ccc058cd..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, head: undefined, onSourceBus: true }) }, + '../src/storage/source-bus.js': { default: async () => true }, })).default; const uePost = (origin) => { diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js deleted file mode 100644 index a5a69f75..00000000 --- a/test/routes/cookie.test.js +++ /dev/null @@ -1,154 +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 { 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 75840c8a..aad41fc2 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_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,33 +44,27 @@ const recorder = () => { return { env, fetched }; }; -// 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 = []) => { +// answers the real /ping, which is the only lookup the routes make. `upgraded` is the set of +// `org/site` keys the probe reports as enrolled. +const stubPing = (upgraded = []) => { const asked = []; globalThis.fetch = async (input) => { const url = input.toString(); asked.push(url); - const [, 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 }); + const key = url.slice(url.indexOf('/ping/') + '/ping/'.length); + const headers = upgraded.includes(key) ? { 'x-api-upgrade-available': 'true' } : {}; + return new Response('', { status: 200, headers }); }; return asked; }; const mockRoutes = async () => esmock('../../src/routes/da-admin.js', { - '../../src/storage/site.js': { - default: async () => ({ exists: true, head: '', onSourceBus: false }), + '../../src/storage/source-bus.js': { + default: async () => false, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), + getAEMHtml: async () => '', }, '../../src/render/compose.js': { composeHtml: async () => ({ tree: true }), @@ -117,14 +111,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/site.js': { - default: async () => ({ exists, head: headHtml, onSourceBus: false }), + '../../src/storage/source-bus.js': { + default: async () => false, }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({}), + getAEMHtml: async () => headHtml, }, '../../src/render/compose.js': { composeHtml: async (daCtx, aemCtx, bodyHtml) => { @@ -144,8 +138,7 @@ 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 getEditorConfig reports as null - getEditorConfig: async () => null, + getSiteConfig: async () => { throw new Error('no config'); }, }, })).daSourceGet; }; @@ -240,8 +233,8 @@ describe('daSourceGet', () => { assert.strictEqual(await res.text(), 'composed'); }); - it('returns a working 404 shell for quick-edit when there is no such site', async () => { - const daSourceGet = await mockDaSourceGet({ site: { exists: false } }); + it('returns a working 404 shell for quick-edit when head.html is missing', async () => { + const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); const daCtx = getDaCtx(req); @@ -252,11 +245,11 @@ describe('daSourceGet', () => { assert.strictEqual(calls.compose.length, 0); const html = await res.text(); assert.ok(html.includes('importmap')); - assert.ok(!html.includes('There is no site at this address')); + assert.ok(!html.includes('Unable to retrieve AEM branch')); }); - it('returns not-found for non-quick-edit when there is no such site', async () => { - const daSourceGet = await mockDaSourceGet({ site: { exists: false } }); + it('still returns branch-not-found for non-quick-edit when head.html is missing', async () => { + const daSourceGet = await mockDaSourceGet({ headHtml: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const daCtx = getDaCtx(req); @@ -266,26 +259,15 @@ describe('daSourceGet', () => { assert.strictEqual(calls.compose.length, 0); assert.strictEqual(calls.ue, 0); const html = await res.text(); - 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); + assert.ok(html.includes('Unable to retrieve AEM branch')); }); }); describe('source URLs', () => { - // answers the unmocked lookups with a legacy site, the store these tests describe + // these drive the unmocked module, so the /ping lookup really does reach out; answer it + // without the upgrade header, which is the legacy store these tests describe beforeEach(() => { - stubLookups(); + stubPing(); }); afterEach(() => { @@ -420,16 +402,17 @@ describe('daSourcePost to a non-HTML path', () => { }); describe('daSourcePost', () => { - // answers the unmocked lookups with a legacy site, the store these tests describe + // these drive the unmocked module, so the /ping lookup really does reach out; answer it + // without the upgrade header, which is the legacy store these tests describe beforeEach(() => { - stubLookups(); + stubPing(); }); afterEach(() => { delete globalThis.fetch; }); - describe('on a site the lookup reports as enrolled', () => { + describe('on a site /ping reports as enrolled', () => { const write = async (site, env) => { const html = new File(['hello'], 'page.html', { type: 'text/html' }); const req = formReq(`https://main--${site}--org.ue.da.live/page`, html); @@ -437,7 +420,7 @@ describe('daSourcePost', () => { }; it('is refused with 405 and nothing is written', async () => { - stubLookups(['org/refused']); + stubPing(['org/refused']); const { env, fetched } = recorder(); const res = await write('refused', env); @@ -449,16 +432,16 @@ 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, and asks nothing else', async () => { - const asked = stubLookups(['org/lookedupeach']); + it('probes once per write', async () => { + const asked = stubPing(['org/probedeach']); const { env } = recorder(); - await write('lookedupeach', env); - await write('lookedupeach', env); + await write('probedeach', env); + await write('probedeach', env); - 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', + assert.deepStrictEqual(asked, [ + 'https://admin.hlx.page/ping/org/probedeach', + 'https://admin.hlx.page/ping/org/probedeach', ]); }); }); diff --git a/test/routes/source-read.test.js b/test/routes/source-read.test.js index 4df056cd..d51cb27b 100644 --- a/test/routes/source-read.test.js +++ b/test/routes/source-read.test.js @@ -14,17 +14,9 @@ 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 @@ -35,18 +27,13 @@ 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, so an explicit undefined reads - // as a ref with no head.html, a template the preview host does not have, or a failed lookup + // `in overrides` rather than a destructured default on these two, so passing an explicit + // undefined really does simulate a missing head.html and a probe that could not answer const headHtml = 'headHtml' in overrides ? overrides.headHtml : ''; - const 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 onSourceBus = 'onSourceBus' in overrides ? overrides.onSourceBus : false; + const probeError = 'probeError' in overrides ? overrides.probeError : new TypeError('fetch failed'); const seen = { - bus: [], legacy: [], head: [], aem: [], ue: 0, lookups: 0, + bus: [], legacy: [], ue: 0, lookups: 0, }; globalThis.fetch = async (input, init) => { const request = input instanceof Request ? input : new Request(input, init); @@ -65,59 +52,41 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/site.js': { + '../../src/storage/source-bus.js': { default: async () => { seen.lookups += 1; - // 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 }; + // the probe reports a failure by throwing, so undefined stands for "could not answer" + if (onSourceBus === undefined) throw probeError; + return onSourceBus; }, }, '../../src/utils/aemCtx.js': { - getAemCtx: () => ({ 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; - }, + getAemCtx: () => ({}), + getAEMHtml: async () => headHtml, }, '../../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 }; - }, + composeHtml: async (daCtx, aemCtx, bodyHtml) => ({ bodyHtml }), serializeHtml: (tree) => `${tree.bodyHtml}`, }, '../../src/ue/ue.js': { applyUEInstrumentation: async () => { seen.ue += 1; }, }, '../../src/storage/config.js': { - // da-admin answers a site with no config with a 404, which getEditorConfig reports as null - getEditorConfig: async () => { - if (configError) throw configError; - return config; - }, + getSiteConfig: async () => { throw new Error('no config'); }, }, }); 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({ lookupError: dead() }); + const { daSourceGet, env, seen } = await build({ onSourceBus: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -127,7 +96,7 @@ describe('when the lookup cannot say which store holds the site', () => { }); it('asks the caller to retry', async () => { - const { daSourceGet, env } = await build({ lookupError: dead() }); + const { daSourceGet, env } = await build({ onSourceBus: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -135,21 +104,8 @@ describe('when the lookup 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({ lookupError: dead() }); + const { daSourceGet, env, seen } = await build({ onSourceBus: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -159,7 +115,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({ lookupError: dead() }); + const { daSourceHead, env, seen } = await build({ onSourceBus: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -169,149 +125,61 @@ describe('when the lookup cannot say which store holds the site', () => { assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); - // 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() }); + // both 503s carry the same status and a body nobody parses, so the header is what tells an + // unanswerable probe apart from a store that was picked and then failed + it('names the failed probe in x-error', async () => { + const { daSourceGet, env } = await build({ onSourceBus: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.match(res.headers.get('x-error'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /ping/); }); - it('names the lookup on a HEAD too', async () => { - const { daSourceHead, env } = await build({ lookupError: dead() }); + it('names the failed probe on a HEAD too', async () => { + const { daSourceHead, env } = await build({ onSourceBus: undefined }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); - assert.match(res.headers.get('x-error'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /ping/); }); - // 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 () => { + // a read answers the same 503 and the same body whichever of the two failed, so the header is + // the only thing on the wire that separates a timeout from a dropped connection + it('carries the probe cause, not a category', async () => { const { daSourceGet, env } = await build({ - lookupError: new DOMException('timed out', 'TimeoutError'), + onSourceBus: undefined, + probeError: new DOMException('timed out', 'TimeoutError'), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); }); // rendering a thrown non-Error as "undefined: undefined" would leave the 503 saying nothing it('survives a thrown non-Error', async () => { - const { daSourceGet, env } = await build({ lookupError: 'boom' }); + const { daSourceGet, env } = await build({ onSourceBus: undefined, probeError: 'boom' }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 503); - assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: Error: boom'); + assert.strictEqual(res.headers.get('x-error'), '/ping failed: Error: boom'); }); it('tells a probe failure apart from a store failure', async () => { const { daSourceGet, env } = await build({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), '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'); + assert.strictEqual(res.headers.get('x-error'), 'TypeError: fetch failed'); }); }); @@ -322,7 +190,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({ site: SOURCE_BUS }); + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -333,7 +201,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({ site: SOURCE_BUS }); + const { daSourceGet, env } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -342,7 +210,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({ site: SOURCE_BUS }); + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -366,7 +234,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({ site: SOURCE_BUS }); + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -379,7 +247,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -391,7 +259,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -401,7 +269,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -420,7 +288,7 @@ describe('reading from the store that holds the site', () => { it('asks the caller to retry', async () => { const { daSourceGet, env } = await build({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -434,7 +302,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('fetch failed'); }, }); const html = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -443,13 +311,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.strictEqual(await htmlRes.text(), messages.SOURCE_FAILED_HTML_MESSAGE); - assert.strictEqual(await assetRes.text(), messages.SOURCE_FAILED_HTML_MESSAGE); + assert.match(await htmlRes.text(), /503/); + assert.match(await assetRes.text(), /503/); }); it('answers 503 with no body on a HEAD', async () => { const { daSourceHead, env } = await build({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -464,38 +332,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('Network connection lost'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); }); it('names the cause on a non-html read', async () => { const { daSourceGet, env } = await build({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('Network connection lost'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); }); it('names the cause on a HEAD', async () => { const { daSourceHead, env } = await build({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new DOMException('The operation timed out', 'TimeoutError'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceHead({ env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'content store failed: TimeoutError: The operation timed out'); + assert.strictEqual(res.headers.get('x-error'), 'TimeoutError: The operation timed out'); }); it('names the cause when da-admin is unreachable', async () => { @@ -506,21 +374,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'), 'content store failed: TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); }); // a header value cannot span lines, so a message carrying a stack would throw where the 503 // is built and turn the 503 into a 500 it('collapses a multi-line cause onto one line', async () => { const { daSourceGet, env } = await build({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('lost\n at fetch'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: lost at fetch'); + assert.strictEqual(res.headers.get('x-error'), 'TypeError: lost at fetch'); }); }); @@ -530,7 +398,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => new Response('upstream said no', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -545,7 +413,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => new Response('upstream said no', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -556,13 +424,12 @@ describe('reading from the store that holds the site', () => { }); }); - // 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 + // /ping reads no token, so the store is the only thing to see one. the authorbus extension + // matches this sentinel exactly and recovers by refetching /gimme_cookie and refreshing [401, 403].forEach((status) => { it(`serves the da:401 shell when the store answers ${status} on an html read`, async () => { const { daSourceGet, env } = await build({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => new Response('', { status }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -576,7 +443,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -588,7 +455,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); @@ -601,7 +468,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => new Response('', { status: 401 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -612,11 +479,11 @@ describe('reading from the store that holds the site', () => { assert.strictEqual(await res.text(), ''); }); - // hands the author the starter template over whatever da-admin still has, with no second - // store to retry against + // a wrong yes from /ping produces this, and there is no second store to retry against: the + // author is handed the starter template over whatever da-admin still holds it('composes the starter template on a 404 without asking the other store', async () => { const { daSourceGet, env, seen } = await build({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -631,7 +498,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({ site: SOURCE_BUS }); + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -640,7 +507,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({ site: SOURCE_BUS }); + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.preview.da.live/folder/content'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -667,11 +534,10 @@ 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, head: '', onSourceBus: true }), - }, + '../../src/storage/source-bus.js': { default: async () => true }, '../../src/utils/aemCtx.js': { getAemCtx: () => ({ ueHostname: 'ue.da.live', previewUrl: 'https://p.example' }), + getAEMHtml: async () => '', }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -686,7 +552,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({ site: SOURCE_BUS }); + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.ue.da.live/Media/Holiday.PNG'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -706,7 +572,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({ site: SOURCE_BUS }); + const { daSourceHead, env, seen } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); await daSourceHead({ env, daCtx: getDaCtx(req) }); @@ -728,7 +594,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => new Response('', { status: 404 }), }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); @@ -826,7 +692,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({ - site: SOURCE_BUS, + onSourceBus: true, bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); @@ -837,7 +703,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({ site: SOURCE_BUS }); + const { daSourceGet, env, seen } = await build({ onSourceBus: true }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -846,423 +712,19 @@ describe('reading from the store that holds the site', () => { }); }); - 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({ - 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); - }); - - 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 () => { + describe('the order of the two things that can fail', () => { + // a missing AEM branch is answered as it was before, so quick-edit still gets its shell + it('reports a missing AEM branch even when the store did not answer', async () => { const { daSourceGet, env } = await build({ + onSourceBus: true, headHtml: undefined, - legacy: () => new Response('', { status: 404 }), + bus: () => { throw new TypeError('fetch failed'); }, }); const req = authedReq('https://main--site--org.ue.da.live/folder/content'); const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.status, 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 ca255f2e..ec9ce54f 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -14,19 +14,11 @@ import assert from 'assert'; import esmock from 'esmock'; import { getDaCtx } from '../../src/utils/daCtx.js'; -import { - SITE_NOT_FOUND_MESSAGE, - SOURCE_BUS_READ_ONLY_MESSAGE, - SOURCE_UNDETERMINED_MESSAGE, -} from '../../src/utils/constants.js'; +import { SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE } from '../../src/utils/constants.js'; const AT = 'https://main--site--org.ue.da.live/folder/content'; const DOC = '

the author typed this

'; -// 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(); @@ -35,10 +27,13 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201, busError, exists = true } = overrides; - const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; + const { status = 201 } = overrides; + // `in overrides` rather than a destructured default, so passing an explicit undefined really + // does simulate a probe that could not answer + const onSourceBus = 'onSourceBus' in overrides ? overrides.onSourceBus : false; + const probeError = 'probeError' in overrides ? overrides.probeError : new TypeError('fetch failed'); const seen = { - bus: [], legacy: [], probes: 0, order: [], + bus: [], legacy: [], lookups: 0, order: [], }; const capture = async (request) => { const contentType = request.headers.get('Content-Type'); @@ -74,12 +69,13 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/site.js': { + '../../src/storage/source-bus.js': { default: async () => { - seen.probes += 1; + seen.lookups += 1; seen.order.push('lookup'); - if (busError) throw busError; - return { exists, head: undefined, onSourceBus }; + // the probe reports a failure by throwing, so undefined stands for "could not answer" + if (onSourceBus === undefined) throw probeError; + return onSourceBus; }, }, }); @@ -102,7 +98,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({ site: SOURCE_BUS }); + const { res, seen } = await post({ onSourceBus: true }); assert.strictEqual(res.status, 405); assert.strictEqual(seen.bus.length, 0); @@ -110,13 +106,13 @@ describe('writing to the store that holds the site', () => { }); it('names the methods that are left', async () => { - const { res } = await post({ site: SOURCE_BUS }); + const { res } = await post({ onSourceBus: true }); assert.strictEqual(res.headers.get('Allow'), 'GET, HEAD, OPTIONS'); }); it('does not ask the caller to retry, since retrying cannot help', async () => { - const { res } = await post({ site: SOURCE_BUS }); + const { res } = await post({ onSourceBus: true }); assert.strictEqual(res.headers.get('Retry-After'), null); }); @@ -124,7 +120,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({ site: SOURCE_BUS }); + const { res } = await post({ onSourceBus: true }); assert.match(res.headers.get('Content-Type'), /^text\/plain/); assert.strictEqual(await res.text(), SOURCE_BUS_READ_ONLY_MESSAGE); @@ -133,11 +129,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 the probe cannot say which store holds the site', () => { - const dead = () => new TypeError('fetch failed'); - + describe('when /ping cannot say which store holds the site', () => { it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ busError: dead() }); + const { res, seen } = await post({ onSourceBus: undefined }); assert.strictEqual(res.status, 503); assert.strictEqual(seen.bus.length, 0); @@ -145,65 +139,30 @@ 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({ busError: dead() }); + const { res } = await post({ onSourceBus: undefined }); assert.ok(Number(res.headers.get('Retry-After')) > 0); }); it('says which of the two refusals it is', async () => { - const { res } = await post({ busError: dead() }); + const { res } = await post({ onSourceBus: undefined }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); it('names the failed probe in x-error', async () => { - const { res } = await post({ busError: dead() }); + const { res } = await post({ onSourceBus: undefined }); - assert.match(res.headers.get('x-error'), /site lookup failed/); + assert.match(res.headers.get('x-error'), /ping/); }); - it('names the cause, not a category', async () => { + it('carries the probe cause, not a category', async () => { const { res } = await post({ - busError: new DOMException('timed out', 'TimeoutError'), + onSourceBus: undefined, + probeError: new DOMException('timed out', 'TimeoutError'), }); - 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); + assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); }); }); @@ -288,20 +247,19 @@ describe('writing to the store that holds the site', () => { }); }); - describe('the lookup on a write', () => { + describe('the store lookup on a write', () => { // a legacy write is the case that can tell the two orderings apart: the store is reached // either way, so only the sequence says whether the write went out before it was placed it('happens before anything is sent to a store', async () => { const { seen } = await post({}); - assert.deepStrictEqual(seen.order.slice(-1), ['store']); - assert.ok(seen.order.includes('lookup')); + assert.deepStrictEqual(seen.order, ['lookup', 'store']); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { - const { seen } = await post({ site: SOURCE_BUS }); + const { seen } = await post({ onSourceBus: true }); - assert.strictEqual(seen.probes, 1); + assert.strictEqual(seen.lookups, 1); }); }); @@ -329,7 +287,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'), 'content store failed: TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); }); }); @@ -337,13 +295,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({ site: SOURCE_BUS }); + const { daSourcePost, env, seen } = await build({ onSourceBus: true }); const req = uePost('https://main--site--org.ue.da.live/folder/data.json'); const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 415); - assert.strictEqual(seen.probes, 0); + assert.strictEqual(seen.lookups, 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 73549234..843f6dca 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -67,7 +67,7 @@ describe('Config Module', () => { } }; - describe('getEditorConfig', () => { + describe('getSiteConfig', () => { 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.getEditorConfig(mockEnv, mockDaCtx); + const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', @@ -122,40 +122,84 @@ describe('Config Module', () => { ':type': 'multi-sheet', }; setMockResponse(multiSheet); - const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); + const result = await configModule.getSiteConfig(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 }; + it('should return null when fetch fails', async () => { + mockFetch.nextResponse = { ok: false }; - const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); + const result = await configModule.getSiteConfig(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 }); - // 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 result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - assert.strictEqual(await configModule.getEditorConfig(mockEnv, mockDaCtx), null); - }); + 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); }); - // 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)), - ); - }); + 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); }); }); @@ -166,7 +210,7 @@ describe('Config Module', () => { { key: 'editor.ue.template', value: '/content=/templates' }, ]); - await configModule.getEditorConfig(mockEnv, ctxWithoutToken); + await configModule.getSiteConfig(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 deleted file mode 100644 index 78ae49c1..00000000 --- a/test/storage/site.test.js +++ /dev/null @@ -1,212 +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: 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 new file mode 100644 index 00000000..8093d3fd --- /dev/null +++ b/test/storage/source-bus.test.js @@ -0,0 +1,194 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import assert from 'assert'; + +const { default: isSourceBus } = await import('../../src/storage/source-bus.js'); + +const env = { AEM_API: 'https://api.aem.live', HLX_ADMIN: 'https://admin.hlx.page' }; + +const daCtx = (over = {}) => ({ + org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, +}); + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(input.toString(), init); + }; +}; + +const ping = (headers = {}, status = 200) => new Response('', { status, headers }); +const upgraded = () => ping({ 'x-api-upgrade-available': 'true' }); + +describe('isSourceBus', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks /ping on the admin host', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + }); + + it('takes the admin host from env, so stage can point elsewhere', async () => { + stubFetch(upgraded); + + await isSourceBus({ ...env, HLX_ADMIN: 'https://admin.stage.example' }, daCtx()); + + assert.strictEqual(calls[0].url, 'https://admin.stage.example/ping/org/site'); + }); + + // both stores read one config service and the source is per site, so the branch cannot change + // the answer + it('does not vary by ref', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx({ ref: 'branch' })); + + assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); + }); + + // /ping is exempt from authorize() in helix-admin and answers the same with or without a token + it('sends no token, since /ping does not read one', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.strictEqual(new Headers(calls[0].init.headers).get('Authorization'), null); + }); + + it('gives up rather than hanging', async () => { + stubFetch(upgraded); + + await isSourceBus(env, daCtx()); + + assert.ok(calls[0].init.signal, 'the probe carries an abort signal'); + }); + }); + + describe('when /ping says the site is upgraded', () => { + it('answers true', async () => { + stubFetch(upgraded); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + + // presence, not value: da-nx tests the same header with `!== null` (nx2/utils/api.js, + // isHlx6), and two clients reading it differently would split one site across two stores + ['false', '', 'TRUE'].forEach((value) => { + it(`counts any value, including ${JSON.stringify(value)}`, async () => { + stubFetch(() => ping({ 'x-api-upgrade-available': value })); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + }); + + // no status test, for the same reason. the edge sets the header from its dictionary, so a + // rate-limited or erroring origin behind it does not make an enrolled site legacy + [429, 500, 503].forEach((status) => { + it(`counts it on a ${status}, since the header is what carries the answer`, async () => { + stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, status)); + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + }); + }); + }); + + describe('when /ping does not say so', () => { + [ + ['the header is absent', {}, 200], + ['the header is absent on a 404', {}, 404], + ['the header is absent on a 405', {}, 405], + ['the header is absent on a 500', {}, 500], + ].forEach(([what, headers, status]) => { + it(`answers false: ${what}`, async () => { + stubFetch(() => ping(headers, status)); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + }); + }); + }); + + // an answer without the header is legacy. no answer is not an answer, and the caller refuses + // rather than picking a store on a coin flip + describe('when /ping cannot answer', () => { + // the cause reaches the caller, which reports it on the 503 as `x-error`. swallowing it here + // would leave a timeout and a dropped connection indistinguishable + it('lets the failure through', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(isSourceBus(env, daCtx()), { message: 'fetch failed' }); + }); + + it('lets it through when HLX_ADMIN is unusable, without asking', async () => { + stubFetch(upgraded); + + await assert.rejects(isSourceBus({ AEM_API: 'https://api.aem.live' }, daCtx())); + assert.strictEqual(calls.length, 0); + }); + + // the distinction the caller acts on: false is a store, a failure is no store + it('is distinguishable from a legacy answer', async () => { + stubFetch(() => ping()); + assert.strictEqual(await isSourceBus(env, daCtx()), false); + + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + await assert.rejects(isSourceBus(env, daCtx())); + }); + }); + + describe('when there is no site to ask about', () => { + // either one missing is enough: a half-parsed request would otherwise build a ping url with + // "undefined" in it + [ + ['neither', { org: undefined, site: undefined }], + ['no org', { org: undefined }], + ['no site', { site: undefined }], + ['an empty org', { org: '' }], + ['an empty site', { site: '' }], + ].forEach(([what, over]) => { + it(`answers false without making a request: ${what}`, async () => { + stubFetch(upgraded); + + assert.strictEqual(await isSourceBus(env, daCtx(over)), false); + assert.strictEqual(calls.length, 0); + }); + }); + }); + + // nothing is remembered between calls, so an enrolment takes effect on the next read and a + // config blip cannot pin a stale answer + it('probes every time it is asked', async () => { + let enrolled = false; + stubFetch(() => (enrolled ? upgraded() : ping())); + + assert.strictEqual(await isSourceBus(env, daCtx()), false); + enrolled = true; + + assert.strictEqual(await isSourceBus(env, daCtx()), true); + assert.strictEqual(calls.length, 2); + }); +}); diff --git a/test/utils/aemCtx.test.js b/test/utils/aemCtx.test.js index 2f17923b..8b4432d2 100644 --- a/test/utils/aemCtx.test.js +++ b/test/utils/aemCtx.test.js @@ -70,14 +70,11 @@ describe('AEM context', () => { const mockAemCtx = { previewUrl: 'https://main--site--org.aem.page', }; - let status; beforeEach(async () => { - status = 200; // Mock global fetch - global.fetch = async () => ({ - ok: status === 200, - status, + global.fetch = async (url) => ({ + ok: url.includes('success'), text: async () => 'test content', }); }); @@ -91,34 +88,10 @@ describe('AEM context', () => { assert.strictEqual(html, 'test content'); }); - // 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; + it('should return undefined for failed request', async () => { 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 deleted file mode 100644 index 20d7698f..00000000 --- a/test/utils/upstream.test.js +++ /dev/null @@ -1,57 +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'; -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 39d66735..b8fdfed1 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,24 +2,19 @@ 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" } 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_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 = "https://admin.hlx.page" } 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" } services = [{ binding = "daadmin", service = "da-admin-stage" }] -secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage.observability] enabled = true