diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 054d9499..f54932c9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -41,14 +41,14 @@ jobs: - name: Deploy to Cloudflare Workers (production) if: github.ref_name == 'main' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - name: Deploy to Cloudflare Workers (stage) if: github.ref_name == 'stage' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/.gitignore b/.gitignore index 71619106..c4fbad99 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ jspm_packages/ .env.production.local .env.local -.dev.vars +.dev.vars* .wrangler/ .DS_Store .cursor diff --git a/README.md b/README.md index 5205fd84..a6f5ef49 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,26 @@ Prerequisites: This worker performs all content operations via [da-admin](https://github.com/adobe/da-admin). For local development, you will also need to check out and run da-admin locally. +One read of config.aem.page, pipeline scope, answers existence, head.html and `contentSource`. Its url names the store. + +The config service needs a shared secret, so local development points at `dev/lookup-shim.js` instead. Add the org and site to its `SITES` table. A site missing from the table is answered 404. A source url on api.aem.live reads as source-bus. + To run da-universal locally: 1. Clone this repo to your computer. 1. Run `npm install` 1. Use `npx wrangler login` if not done before. Walk through the steps in browser. -1. In a terminal, run `npm run dev` this repo's folder. +1. In a terminal, run `npm run dev:lookups` to start the stand-in lookups on port 4713. +1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 +Running against the stand-in warns that `HLX_CONFIG_SERVICE_TOKEN` is missing, which it is, and nothing asks for it. + +with the shared secret, use `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. + ### Run on stage -You can deploy da-universal on Cloudflare stage via `npm deploy:stage` to test it in a real worker environment. +You can deploy da-universal on Cloudflare stage via `npm run deploy:stage` to test it in a real worker environment. ## Customer documentation https://docs.da.live/developers/reference/universal-editor diff --git a/dev/lookup-shim.js b/dev/lookup-shim.js new file mode 100644 index 00000000..5bbaf0dc --- /dev/null +++ b/dev/lookup-shim.js @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// stands in for config.aem.page, which needs a shared secret. one read of the pipeline scope +// answers whether the site exists, its head.html and which store holds it. a site in SITES exists, +// and one with a source url on api.aem.live is source-bus +const SITES = { + 'org/site': 'https://content.da.live/org/site/', +}; + +// what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with +const HEAD_HTML = '\n\n'; + +export default { + async fetch(req) { + const url = new URL(req.url); + + const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); + if (!org || !site) { + return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); + } + + const source = SITES[`${org}/${site}`]; + if (!source) { + return new Response('', { status: 404, headers: { 'x-error': 'config not found.' } }); + } + + // both stores are `type: markup`, so only the url separates them + const body = JSON.stringify({ + ref, + site, + org, + head: { html: HEAD_HTML }, + contentSource: { type: 'markup', url: source }, + }); + return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }); + }, +}; diff --git a/dev/lookup-shim.toml b/dev/lookup-shim.toml new file mode 100644 index 00000000..79e3d4a2 --- /dev/null +++ b/dev/lookup-shim.toml @@ -0,0 +1,9 @@ +# the stand-in gets its own config, so wrangler dev does not read wrangler.toml: no daadmin +# binding to connect, and no HLX_CONFIG_SERVICE_TOKEN to warn about +name = "da-ue-lookup-shim" +main = "lookup-shim.js" +compatibility_date = "2023-11-21" + +[dev] +port = 4713 +inspector_port = 9234 diff --git a/eslint.config.js b/eslint.config.js index 1c5ff742..abbd1c37 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,7 +14,7 @@ import { defineConfig, globalIgnores } from '@eslint/config-helpers'; import { recommended, source, test } from '@adobe/eslint-config-helix'; export default defineConfig([ - globalIgnores(['.vscode/*', '.wrangler/*', 'coverage/*']), + globalIgnores(['.vscode/*', '**/.wrangler/**', 'coverage/*']), { languageOptions: { ...recommended.languageOptions, diff --git a/package.json b/package.json index 15c6fb3f..883d9923 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "deploy": "wrangler deploy", "deploy:stage": "wrangler deploy --env stage", "dev": "wrangler dev --local-protocol https --env dev", + "dev:lookups": "wrangler dev -c dev/lookup-shim.toml", "start": "wrangler dev --local-protocol https --env dev", "test": "c8 mocha --spec=test/**/*.test.js", "lint": "eslint ." diff --git a/src/handlers/get.js b/src/handlers/get.js index 591cada5..df429f3d 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -21,7 +21,7 @@ export default async function getHandler({ req, env, daCtx }) { if (path.startsWith('/favicon.ico')) return get404(); if (path.startsWith('/robots.txt')) return getRobots(); - if (path.startsWith('/gimme_cookie')) return getCookie({ req, daCtx }); + if (path.startsWith('/gimme_cookie')) return getCookie({ req, env, daCtx }); const resourceRegex = /\.(css|js|js\.map|json|xml|woff|woff2|otf|ttf|plain\.html|html)$/i; if (resourceRegex.test(path)) { @@ -35,8 +35,14 @@ export default async function getHandler({ req, env, daCtx }) { handleAEMProxyRequest({ req, env, daCtx }), ]); - const storeRes = daSourceGetRes.status === 'fulfilled' ? daSourceGetRes.value : undefined; - const aemRes = aemProxyRes.status === 'fulfilled' ? aemProxyRes.value : undefined; + // logs a rejection rather than rethrowing it, since the other read may still answer + const settled = (result, read) => { + if (result.status === 'fulfilled') return result.value; + console.error(`${read} threw on ${path}`, result.reason); + return undefined; + }; + const storeRes = settled(daSourceGetRes, 'the store read'); + const aemRes = settled(aemProxyRes, 'the aem proxy'); let response; if (storeRes?.status === 200) { diff --git a/src/responses/index.js b/src/responses/index.js index 41758933..c07a3abb 100644 --- a/src/responses/index.js +++ b/src/responses/index.js @@ -77,6 +77,10 @@ export function post503(message = '', error = '') { }); } +export function post404(message = '') { + return daResp({ body: message, status: 404, contentType: 'text/plain; charset=utf-8' }); +} + // RFC 9110 requires an Allow header on a 405, and reads are what is left once the write is gone. export function post405(message = '') { return daResp({ diff --git a/src/routes/cookie.js b/src/routes/cookie.js index 352f348f..1559ecf7 100644 --- a/src/routes/cookie.js +++ b/src/routes/cookie.js @@ -12,9 +12,9 @@ import { daResp, get401 } from '../responses/index.js'; import { isTrustedOrigin } from '../utils/constants.js'; -async function exchangeSiteToken(org, site, accessToken) { +async function exchangeSiteToken(env, org, site, accessToken) { try { - const response = await fetch('https://admin.hlx.page/auth/adobe/exchange', { + const response = await fetch(new URL('/auth/adobe/exchange', env.AEM_API), { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -48,7 +48,7 @@ async function exchangeSiteToken(org, site, accessToken) { } } -export async function getCookie({ req, daCtx }) { +export async function getCookie({ req, env, daCtx }) { const { headers } = req; if (!isTrustedOrigin(headers.get('Origin'))) return daResp({ body: '403 Forbidden', status: 403, contentType: 'text/plain' }); @@ -67,7 +67,7 @@ export async function getCookie({ req, daCtx }) { // Try to exchange for site token if (org && site && !daCtx.siteToken) { - const siteTokenData = await exchangeSiteToken(org, site, cookieValue); + const siteTokenData = await exchangeSiteToken(env, org, site, cookieValue); if (siteTokenData) { // Calculate Max-Age based on token expiry time (siteTokenExpiry is in milliseconds) const now = Date.now(); diff --git a/src/routes/da-admin.js b/src/routes/da-admin.js index d2484b6f..931424cb 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -22,39 +22,60 @@ import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head503, post405, post503, + daResp, get401, get404, get415, get503, head401, head404, head503, post404, post405, post503, } from '../responses/index.js'; import { - BRANCH_NOT_FOUND_HTML_MESSAGE, DEFAULT_HTML_TEMPLATE, + SITE_LOOKUP_FAILED_HTML_MESSAGE, + PREVIEW_FAILED_HTML_MESSAGE, + EDITOR_CONFIG_FAILED_HTML_MESSAGE, + SITE_NOT_FOUND_HTML_MESSAGE, + SITE_NOT_FOUND_MESSAGE, SOURCE_BUS_READ_ONLY_MESSAGE, SOURCE_UNDETERMINED_MESSAGE, - SOURCE_UNREACHABLE_HTML_MESSAGE, - SOURCE_UNREACHABLE_MESSAGE, + SOURCE_FAILED_HTML_MESSAGE, + SOURCE_FAILED_MESSAGE, UNAUTHORIZED_HTML_MESSAGE, } from '../utils/constants.js'; -import { getSiteConfig } from '../storage/config.js'; -import isSourceBus from '../storage/source-bus.js'; +import { getEditorConfig } from '../storage/config.js'; +import getSiteConfig from '../storage/site.js'; import getStore from '../storage/store.js'; import { restoreAbsoluteImages } from '../render/rewrite-images.js'; +import { + CONTENT_STORE, + PREVIEW_HOST, + EDITOR_CONFIG, + SITE_LOOKUP, + UpstreamError, + withUpstream, +} from '../utils/upstream.js'; const HTML_POST_TYPE = 'text/html'; +/** Names the upstream that failed, since the default body names the store. */ +const UPSTREAM_FAILURE_HTML = { + [PREVIEW_HOST]: PREVIEW_FAILED_HTML_MESSAGE, + [EDITOR_CONFIG]: EDITOR_CONFIG_FAILED_HTML_MESSAGE, + [SITE_LOOKUP]: SITE_LOOKUP_FAILED_HTML_MESSAGE, +}; +// a write reaches no store until the lookup answers, so a failed lookup says the destination is +// undetermined, not that the store failed +const UPSTREAM_FAILURE_TEXT = { + [SITE_LOOKUP]: SOURCE_UNDETERMINED_MESSAGE, +}; + /** - * Renders a failure for the `x-error` header. + * An UpstreamError is retryable, so it is answered 503. Anything else reaches the worker boundary + * in src/index.js, which logs it and answers 500. */ -function causeOf(e) { - return `${e?.name ?? 'Error'}: ${e?.message ?? e}` - .replace(/[^\x20-\x7e]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 1024); -} - -function probeFailed(e, method, sourcePath) { - const cause = `/ping failed: ${causeOf(e)}`; - console.warn(`503 ${method} ${sourcePath}, ${cause}`); - return cause; +function refuseUpstreamFailure(e, method, sourcePath) { + if (!(e instanceof UpstreamError)) throw e; + console.warn(`503 ${method} ${sourcePath}, ${e.message}`); + if (method === 'HEAD') return head503(e.message); + if (method === 'POST') { + return post503(UPSTREAM_FAILURE_TEXT[e.upstream] ?? SOURCE_FAILED_MESSAGE, e.message); + } + return get503(UPSTREAM_FAILURE_HTML[e.upstream] ?? SOURCE_FAILED_HTML_MESSAGE, e.message); } export function isHtmlPostType(type) { @@ -73,12 +94,8 @@ function getTextBody(data) { } async function getPageTemplate(env, daCtx, aemCtx) { - let config; - try { - config = await getSiteConfig(env, daCtx); - } catch (e) { - return DEFAULT_HTML_TEMPLATE; - } + // answers null for a site with no config, so any other failure throws + const config = await withUpstream(EDITOR_CONFIG, () => getEditorConfig(env, daCtx)); // Search whether a template is configured for this path const matchingTemplates = config @@ -95,7 +112,7 @@ async function getPageTemplate(env, daCtx, aemCtx) { } const templatePath = matchingTemplates[0].template; - const templateHtml = await getAEMHtml(aemCtx, templatePath); + const templateHtml = await withUpstream(PREVIEW_HOST, () => getAEMHtml(aemCtx, templatePath)); if (templateHtml) { return templateHtml; } @@ -104,39 +121,30 @@ async function getPageTemplate(env, daCtx, aemCtx) { } /** - * Sends a request to a store and reports why it could not be reached at all. + * Reads the document from the store that holds the site. * - * @returns {Promise<{response?: Response, error?: string}>} - */ -async function reachStore(store, send) { - try { - return { response: await send() }; - } catch (e) { - const error = causeOf(e); - console.warn(`503 ${store.url}, the store could not be reached: ${error}`); - return { error }; - } -} - -/** - * Reads from the store that holds the site. + * Sets `noSuchSite` when there is no such site, `response` otherwise. * - * @returns {Promise<{response?: Response, error?: string}>} `error` says why there is no response + * @returns {Promise<{response?: Response, noSuchSite?: boolean}>} + * @throws {UpstreamError} when the lookup or the store fails */ async function readSource(env, daCtx, init) { - let onSourceBus; - try { - onSourceBus = await isSourceBus(env, daCtx); - } catch (e) { - return { error: probeFailed(e, init.method, daCtx.sourcePath) }; + const site = await withUpstream(SITE_LOOKUP, () => getSiteConfig(env, daCtx)); + + if (!site.exists) { + console.log(`404 ${init.method} ${daCtx.sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return { noSuchSite: true }; } - const store = getStore(env, daCtx, onSourceBus); + const store = getStore(env, daCtx, site.onSourceBus); console.log(`-> ${init.method} ${store.url.toString()}`); - return reachStore(store, () => store.fetch(store.url, init)); + return { + response: await withUpstream(CONTENT_STORE, () => store.fetch(store.url, init)), + head: site.head, + }; } -export async function daSourceGet({ req, env, daCtx }) { +async function sourceGet({ req, env, daCtx }) { const { ext, authToken } = daCtx; // check if Authorization header is present @@ -158,27 +166,28 @@ export async function daSourceGet({ req, env, daCtx }) { if (ext !== 'html') { // for non-HTML files, simply proxy the request without processing. A refusal is passed on as // itself: nothing renders an image, so the da:401 shell would only corrupt it. - const { response, error } = await readSource(env, daCtx, { method: 'GET', headers }); - if (!response) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, error); + const { response, noSuchSite } = await readSource(env, daCtx, { method: 'GET', headers }); + if (noSuchSite) return get404(); console.log(`<- ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } - // the store lookup costs a round trip, so it runs alongside head.html rather than after it const aemCtx = getAemCtx(env, daCtx); - const [headHtml, { response: sourceResp, error: sourceError }] = await Promise.all([ - getAEMHtml(aemCtx, '/head.html'), - readSource(env, daCtx, { method: 'GET', headers }), - ]); - if (!headHtml) { + const { response: sourceResp, noSuchSite, head: headHtml } = await readSource( + env, + daCtx, + { method: 'GET', headers }, + ); + + if (noSuchSite) { // quick-edit still needs a working shell (with the import map) so the editor - // can load into this page, even when the AEM branch doesn't exist yet. + // can load into this page, even when the site does not exist. if (isQuickEdit) { return buildQuickEditNotFoundResponse(); } - return get404(BRANCH_NOT_FOUND_HTML_MESSAGE); + return get404(SITE_NOT_FOUND_HTML_MESSAGE); } - if (!sourceResp) return get503(SOURCE_UNREACHABLE_HTML_MESSAGE, sourceError); + console.log(`<- ${daCtx.sourcePath}. ${sourceResp.status} ${sourceResp.statusText}`, { status: sourceResp.status, statusText: sourceResp.statusText }); // the store is the only thing to see the token, and the authorbus extension recovers off the @@ -196,10 +205,10 @@ export async function daSourceGet({ req, env, daCtx }) { // use the stored content when available, otherwise fall back to a template const bodyHtml = sourceResp.status === 200 ? await sourceResp.text() - : await getPageTemplate(env, daCtx, aemCtx, headHtml); + : await getPageTemplate(env, daCtx, aemCtx); - // compose the page the same way for every request type - const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml); + // builds the page without head.html, which a ref that was never built does not have + const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); // layer the request-specific instrumentation on top of the composed page const extraHeaders = []; @@ -225,7 +234,16 @@ export async function daSourceGet({ req, env, daCtx }) { }); } -export async function daSourceHead({ env, daCtx }) { +/** Wraps sourceGet, turning a failed upstream read into a 503 in HTML the editor renders. */ +export async function daSourceGet({ req, env, daCtx }) { + try { + return await sourceGet({ req, env, daCtx }); + } catch (e) { + return refuseUpstreamFailure(e, 'GET', daCtx.sourcePath); + } +} + +async function sourceHead({ env, daCtx }) { const { authToken } = daCtx; if (!authToken) { @@ -235,13 +253,22 @@ export async function daSourceHead({ env, daCtx }) { const headers = new Headers(); headers.set('Authorization', authToken); - const { response, error } = await readSource(env, daCtx, { method: 'HEAD', headers }); - if (!response) return head503(error); + const { response, noSuchSite } = await readSource(env, daCtx, { method: 'HEAD', headers }); + if (noSuchSite) return head404(); console.log(`<- HEAD ${daCtx.sourcePath}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return new Response(null, { status: response.status, headers: response.headers }); } -export async function daSourcePost({ req, env, daCtx }) { +/** Wraps sourceHead, turning a failed upstream read into a bodyless 503. */ +export async function daSourceHead({ env, daCtx }) { + try { + return await sourceHead({ env, daCtx }); + } catch (e) { + return refuseUpstreamFailure(e, 'HEAD', daCtx.sourcePath); + } +} + +async function sourcePost({ req, env, daCtx }) { const { sourcePath, ext, authToken } = daCtx; // the body is rewritten as HTML below, so anything but an HTML document would be @@ -275,13 +302,14 @@ export async function daSourcePost({ req, env, daCtx }) { const bodyContent = toHtml(bodyNode); - // the payload is settled, so the only question left is where it goes - let onSourceBus; - try { - onSourceBus = await isSourceBus(env, daCtx); - } catch (e) { - const cause = probeFailed(e, 'POST', sourcePath); - return post503(SOURCE_UNDETERMINED_MESSAGE, cause); + const { exists, onSourceBus } = await withUpstream( + SITE_LOOKUP, + () => getSiteConfig(env, daCtx), + ); + + if (!exists) { + console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return post404(SITE_NOT_FOUND_MESSAGE); } if (onSourceBus) { @@ -294,15 +322,26 @@ export async function daSourcePost({ req, env, daCtx }) { const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); - const { response, error } = await reachStore(store, () => store.fetch(new Request(store.url, { + const response = await withUpstream(CONTENT_STORE, () => store.fetch(new Request(store.url, { method: 'POST', body, headers: { Authorization: authToken }, }))); - if (!response) return post503(SOURCE_UNREACHABLE_MESSAGE, error); console.log(`<- ${store.url.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); return response; } return get415(); } + +/** + * Wraps sourcePost, turning a failed upstream read into the plain text the editor shows the + * author. + */ +export async function daSourcePost({ req, env, daCtx }) { + try { + return await sourcePost({ req, env, daCtx }); + } catch (e) { + return refuseUpstreamFailure(e, 'POST', daCtx.sourcePath); + } +} diff --git a/src/storage/config.js b/src/storage/config.js index 8f87ef32..7147054c 100644 --- a/src/storage/config.js +++ b/src/storage/config.js @@ -21,19 +21,19 @@ async function fetchConfig(env, daCtx, path) { const configUrl = new URL(path, env.DA_ADMIN); const res = await env.daadmin.fetch(configUrl, opts); - if (!res.ok) { + // a site with no config answers 404, and an author who may not read it is refused 403. both + // answered, so the starter template is used rather than refusing the request + if (res.status === 404 || res.status === 401 || res.status === 403) { + if (res.status !== 404) console.warn(`${configUrl} answered ${res.status}, using no config`); return null; } + if (!res.ok) throw new Error(`${configUrl} answered ${res.status}`); const json = await res.json(); if (!json) return []; const data = getFirstSheet(json); return data; } -export async function getSiteConfig(env, daCtx) { +export async function getEditorConfig(env, daCtx) { return fetchConfig(env, daCtx, `/config/${daCtx.org}/${daCtx.site}`); } - -export async function getOrgConfig(env, daCtx) { - return fetchConfig(env, daCtx, `/config/${daCtx.org}`); -} diff --git a/src/storage/site.js b/src/storage/site.js new file mode 100644 index 00000000..79626c45 --- /dev/null +++ b/src/storage/site.js @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const TIMEOUT_MS = 5 * 1000; +const NO_SITE = { exists: false, head: undefined, onSourceBus: false }; + +/** + * Asks the config service whether a site exists, what its head.html is and which store holds it. + * + * A site behind Helix authentication refuses `{ref}--{site}--{org}.aem.page/head.html` without a + * site token, and the config service does not. `contentSource.url` names the store, since both + * stores are `type: markup`. + * + * Throws on any refusal but a 404, which is the only status that means there is no such site. A ref + * that was never built exists and has no head.html, which is a 200 with an empty head. + * + * @param {Object} env worker env. `HLX_CONFIG_SERVICE` is where the lookup goes, + * `HLX_CONFIG_SERVICE_TOKEN` authorizes it and `AEM_API` is the source bus + * @param {Object} daCtx + * @returns {Promise<{exists: boolean, head: string|undefined, onSourceBus: boolean}>} + */ +export default async function getSiteConfig(env, daCtx) { + const { org, site, ref } = daCtx; + if (!org || !site) return NO_SITE; + + const url = new URL(`/${ref}--${site}--${org}/config.json?scope=pipeline`, env.HLX_CONFIG_SERVICE); + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (response.status === 404) return NO_SITE; + if (!response.ok) throw new Error(`the config service answered ${response.status}`); + + const { head, contentSource } = await response.json(); + // a config that names no store is read as legacy + if (!contentSource?.url) { + console.warn(`${url} named no content source, reading ${org}/${site} as legacy`); + } + + return { + exists: true, + head: head?.html, + onSourceBus: Boolean(contentSource?.url?.startsWith(`${env.AEM_API}/`)), + }; +} diff --git a/src/storage/source-bus.js b/src/storage/source-bus.js deleted file mode 100644 index 9cfe6b89..00000000 --- a/src/storage/source-bus.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -const TIMEOUT_MS = 5 * 1000; -const UPGRADE_HEADER = 'x-api-upgrade-available'; - -/** - * Asks `/ping` whether a site is on the source bus. - * - * An answer without the header is legacy: helix-admin sets it when config resolution succeeded and - * named the API. A probe that cannot answer throws, so the caller refuses with the cause rather - * than picking a store. - * - * @param {Object} env worker env, `HLX_ADMIN` is where the probe goes - * @param {Object} daCtx - * @returns {Promise} - */ -export default async function isSourceBus(env, daCtx) { - const { org, site } = daCtx; - // an unparseable hostname leaves org and site undefined, and there is no site to ask about - if (!org || !site) return false; - - const url = new URL(`/ping/${org}/${site}`, env.HLX_ADMIN); - const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) }); - return response.headers.get(UPGRADE_HEADER) !== null; -} diff --git a/src/utils/aemCtx.js b/src/utils/aemCtx.js index 54ddaa25..b02c2b84 100644 --- a/src/utils/aemCtx.js +++ b/src/utils/aemCtx.js @@ -48,8 +48,15 @@ export function withAemAuth(aemCtx, init = {}) { export async function getAEMHtml(aemCtx, path) { const { previewUrl } = aemCtx; - const resp = await fetch(`${previewUrl}${path}`, withAemAuth(aemCtx)); - if (!resp.ok) return undefined; + const url = `${previewUrl}${path}`; + const resp = await fetch(url, withAemAuth(aemCtx)); + // a ref that was never previewed answers 404, and a host behind Helix auth refuses without a + // site token. both answered, so the page is built without the fragment + if (resp.status === 404 || resp.status === 401 || resp.status === 403) { + if (resp.status !== 404) console.warn(`${url} answered ${resp.status}, using no fragment`); + return undefined; + } + if (!resp.ok) throw new Error(`${url} answered ${resp.status}`); const headHtml = await resp.text(); return headHtml; } diff --git a/src/utils/constants.js b/src/utils/constants.js index 69706aed..5093c3a5 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -49,13 +49,21 @@ export const UNAUTHORIZED_HTML_MESSAGE = ` export const DEFAULT_HTML_TEMPLATE = '
'; -export const BRANCH_NOT_FOUND_HTML_MESSAGE = '

Not found: Unable to retrieve AEM branch

'; +export const SITE_NOT_FOUND_HTML_MESSAGE = '

404: Site not found

There is no site at this address.

'; -export const SOURCE_UNREACHABLE_HTML_MESSAGE = '

503: Content store unreachable

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

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

503: Preview host failed

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

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

503: Editor config failed

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

'; + +export const SITE_LOOKUP_FAILED_HTML_MESSAGE = '

503: Site lookup failed

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

'; + +export const SOURCE_FAILED_HTML_MESSAGE = '

503: Content store failed

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

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

503: Editor config failed

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

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

the author typed this

'; +// what the store lookup answers +const SOURCE_BUS = true; +const LEGACY_STORE = false; + /** The shape the Universal Editor Service posts: a `data` blob in a multipart form. */ const uePost = (url, html = DOC) => { const body = new FormData(); @@ -27,13 +35,10 @@ const uePost = (url, html = DOC) => { }; const build = async (overrides = {}) => { - const { status = 201 } = overrides; - // `in overrides` rather than a destructured default, so passing an explicit undefined really - // does simulate a probe that could not answer - const onSourceBus = 'onSourceBus' in overrides ? overrides.onSourceBus : false; - const probeError = 'probeError' in overrides ? overrides.probeError : new TypeError('fetch failed'); + const { status = 201, busError, exists = true } = overrides; + const onSourceBus = 'site' in overrides ? overrides.site : LEGACY_STORE; const seen = { - bus: [], legacy: [], lookups: 0, order: [], + bus: [], legacy: [], probes: 0, order: [], }; const capture = async (request) => { const contentType = request.headers.get('Content-Type'); @@ -69,13 +74,12 @@ const build = async (overrides = {}) => { }, }; const mod = await esmock('../../src/routes/da-admin.js', { - '../../src/storage/source-bus.js': { + '../../src/storage/site.js': { default: async () => { - seen.lookups += 1; + seen.probes += 1; seen.order.push('lookup'); - // the probe reports a failure by throwing, so undefined stands for "could not answer" - if (onSourceBus === undefined) throw probeError; - return onSourceBus; + if (busError) throw busError; + return { exists, head: undefined, onSourceBus }; }, }, }); @@ -98,7 +102,7 @@ describe('writing to the store that holds the site', () => { // the document at 201 for a key nothing serves, so the write is refused rather than misplaced describe('a source-bus site', () => { it('is refused with 405 and touches neither store', async () => { - const { res, seen } = await post({ onSourceBus: true }); + const { res, seen } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.status, 405); assert.strictEqual(seen.bus.length, 0); @@ -106,13 +110,13 @@ describe('writing to the store that holds the site', () => { }); it('names the methods that are left', async () => { - const { res } = await post({ onSourceBus: true }); + const { res } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.headers.get('Allow'), 'GET, HEAD, OPTIONS'); }); it('does not ask the caller to retry, since retrying cannot help', async () => { - const { res } = await post({ onSourceBus: true }); + const { res } = await post({ site: SOURCE_BUS }); assert.strictEqual(res.headers.get('Retry-After'), null); }); @@ -120,7 +124,7 @@ describe('writing to the store that holds the site', () => { // nothing renders a POST body, and UES embeds it verbatim in its problem+json error string, // so the exact text is what the author is shown it('says what happened in plain text', async () => { - const { res } = await post({ onSourceBus: true }); + const { res } = await post({ site: SOURCE_BUS }); assert.match(res.headers.get('Content-Type'), /^text\/plain/); assert.strictEqual(await res.text(), SOURCE_BUS_READ_ONLY_MESSAGE); @@ -129,9 +133,11 @@ describe('writing to the store that holds the site', () => { // a write is the one operation a wrong store cannot be walked back from, so no answer means no // write rather than a guess - describe('when /ping cannot say which store holds the site', () => { + describe('when the probe cannot say which store holds the site', () => { + const dead = () => new TypeError('fetch failed'); + it('is refused with 503 and touches neither store', async () => { - const { res, seen } = await post({ onSourceBus: undefined }); + const { res, seen } = await post({ busError: dead() }); assert.strictEqual(res.status, 503); assert.strictEqual(seen.bus.length, 0); @@ -139,30 +145,65 @@ describe('writing to the store that holds the site', () => { }); it('asks the caller to retry, unlike the source-bus refusal', async () => { - const { res } = await post({ onSourceBus: undefined }); + const { res } = await post({ busError: dead() }); assert.ok(Number(res.headers.get('Retry-After')) > 0); }); it('says which of the two refusals it is', async () => { - const { res } = await post({ onSourceBus: undefined }); + const { res } = await post({ busError: dead() }); assert.strictEqual(await res.text(), SOURCE_UNDETERMINED_MESSAGE); }); it('names the failed probe in x-error', async () => { - const { res } = await post({ onSourceBus: undefined }); + const { res } = await post({ busError: dead() }); - assert.match(res.headers.get('x-error'), /ping/); + assert.match(res.headers.get('x-error'), /site lookup failed/); }); - it('carries the probe cause, not a category', async () => { + it('names the cause, not a category', async () => { const { res } = await post({ - onSourceBus: undefined, - probeError: new DOMException('timed out', 'TimeoutError'), + busError: new DOMException('timed out', 'TimeoutError'), }); - assert.strictEqual(res.headers.get('x-error'), '/ping failed: TimeoutError: timed out'); + assert.strictEqual(res.headers.get('x-error'), 'site lookup failed: TimeoutError: timed out'); + }); + }); + + // a read of the same path answers 404, and a write the reader cannot get back is worse than a + // refusal the author sees + describe('a site the config service does not know', () => { + it('is refused with 404 and touches neither store', async () => { + const { res, seen } = await post({ exists: false }); + + assert.strictEqual(res.status, 404); + assert.strictEqual(seen.legacy.length, 0); + assert.strictEqual(seen.bus.length, 0); + }); + + it('says what happened in plain text', async () => { + const { res } = await post({ exists: false }); + + assert.match(res.headers.get('Content-Type'), /^text\/plain/); + assert.strictEqual(await res.text(), SITE_NOT_FOUND_MESSAGE); + }); + + it('does not ask the caller to retry, since the site will not appear', async () => { + const { res } = await post({ exists: false }); + + assert.strictEqual(res.headers.get('Retry-After'), null); + }); + }); + + describe('what a write asks about the site', () => { + // one read of the pipeline scope answers where the document goes + it('asks one lookup, and reaches the store after it', async () => { + const { res, seen } = await post({}); + + assert.strictEqual(seen.probes, 1); + assert.strictEqual(seen.order[seen.order.length - 1], 'store'); + assert.strictEqual(res.status, 201); }); }); @@ -247,19 +288,20 @@ describe('writing to the store that holds the site', () => { }); }); - describe('the store lookup on a write', () => { + describe('the lookup on a write', () => { // a legacy write is the case that can tell the two orderings apart: the store is reached // either way, so only the sequence says whether the write went out before it was placed it('happens before anything is sent to a store', async () => { const { seen } = await post({}); - assert.deepStrictEqual(seen.order, ['lookup', 'store']); + assert.deepStrictEqual(seen.order.slice(-1), ['store']); + assert.ok(seen.order.includes('lookup')); }); it('happens on a source-bus site too, which is what the refusal rests on', async () => { - const { seen } = await post({ onSourceBus: true }); + const { seen } = await post({ site: SOURCE_BUS }); - assert.strictEqual(seen.lookups, 1); + assert.strictEqual(seen.probes, 1); }); }); @@ -287,7 +329,7 @@ describe('writing to the store that holds the site', () => { const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); - assert.strictEqual(res.headers.get('x-error'), 'TypeError: Network connection lost'); + assert.strictEqual(res.headers.get('x-error'), 'content store failed: TypeError: Network connection lost'); }); }); @@ -295,13 +337,13 @@ describe('writing to the store that holds the site', () => { // driven on a source-bus site, so the 415 has to come from the extension check rather than // from the refusal below it. on a legacy site either ordering would pass. it('is refused before anything is resolved', async () => { - const { daSourcePost, env, seen } = await build({ onSourceBus: true }); + const { daSourcePost, env, seen } = await build({ site: SOURCE_BUS }); const req = uePost('https://main--site--org.ue.da.live/folder/data.json'); const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); assert.strictEqual(res.status, 415); - assert.strictEqual(seen.lookups, 0); + assert.strictEqual(seen.probes, 0); assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); }); diff --git a/test/storage/config.test.js b/test/storage/config.test.js index 843f6dca..73549234 100644 --- a/test/storage/config.test.js +++ b/test/storage/config.test.js @@ -67,7 +67,7 @@ describe('Config Module', () => { } }; - describe('getSiteConfig', () => { + describe('getEditorConfig', () => { it('should fetch site config successfully (single-sheet)', async () => { const mockData = [ { key: 'editor.ue.template', value: '/content=/templates' }, @@ -75,7 +75,7 @@ describe('Config Module', () => { ]; setMockResponse({ data: mockData }); - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', @@ -122,84 +122,40 @@ describe('Config Module', () => { ':type': 'multi-sheet', }; setMockResponse(multiSheet); - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); // Should return only the first sheet's data array assert.deepStrictEqual(result, multiSheet.data.data); }); - it('should return null when fetch fails', async () => { - mockFetch.nextResponse = { ok: false }; + it('should return null when there is no config', async () => { + mockFetch.nextResponse = { ok: false, status: 404 }; - const result = await configModule.getSiteConfig(mockEnv, mockDaCtx); + const result = await configModule.getEditorConfig(mockEnv, mockDaCtx); assert.strictEqual(result, null); }); - }); - - describe('getOrgConfig', () => { - it('should fetch org config successfully (single-sheet)', async () => { - const mockData = [ - { key: 'editor.ue.template', value: '/content=/templates' }, - { key: 'editor.ue.template', value: '/components=/blocks' }, - { key: 'editor.ue.template', value: '/assets=/media' }, - ]; - setMockResponse({ data: mockData }); - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); + // da-admin answers 403 when the author may not read the site config, and a retry answers the + // same, so the starter template is used rather than refusing + [401, 403].forEach((status) => { + it(`should return null when the store answers ${status}`, async () => { + mockFetch.nextResponse = { ok: false, status }; - const expectedCall = { - url: 'https://admin.da.live/config/test-org', - opts: { - headers: new Headers({ - authorization: 'test-token', - }), - }, - }; - assert.strictEqual(mockFetch.lastCall.url, expectedCall.url); - assert.deepStrictEqual( - Object.fromEntries(mockFetch.lastCall.opts.headers.entries()), - Object.fromEntries(expectedCall.opts.headers.entries()), - ); - assert.deepStrictEqual(result, mockData); + assert.strictEqual(await configModule.getEditorConfig(mockEnv, mockDaCtx), null); + }); }); - it('should fetch org config successfully (multi-sheet)', async () => { - const multiSheet = { - data: { - total: 2, - limit: 2, - offset: 0, - data: [ - { key: 'org.setting', value: 'org-value' }, - { key: 'editor.ue.template', value: '/org=/org-templates.html' }, - ], - }, - library: { - total: 1, - limit: 1, - offset: 0, - data: [ - { - title: 'Org Blocks', path: 'https://content.da.live/org/library/blocks.json', format: '', ref: '', icon: '', experience: '', - }, - ], - }, - ':names': ['data', 'library'], - ':version': 3, - ':type': 'multi-sheet', - }; - setMockResponse(multiSheet); - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - // Should return only the first sheet's data array - assert.deepStrictEqual(result, multiSheet.data.data); - }); - - it('should return null when fetch fails', async () => { - mockFetch.nextResponse = { ok: false }; - - const result = await configModule.getOrgConfig(mockEnv, mockDaCtx); - - assert.strictEqual(result, null); + // a store that could not answer is not the same as a site with no config, and reading it as + // one hands the author a blank page to save over a document that exists + [429, 500, 502].forEach((status) => { + it(`should throw when the store answers ${status}`, async () => { + mockFetch.nextResponse = { ok: false, status }; + + await assert.rejects( + () => configModule.getEditorConfig(mockEnv, mockDaCtx), + new RegExp(String(status)), + ); + }); }); }); @@ -210,7 +166,7 @@ describe('Config Module', () => { { key: 'editor.ue.template', value: '/content=/templates' }, ]); - await configModule.getSiteConfig(mockEnv, ctxWithoutToken); + await configModule.getEditorConfig(mockEnv, ctxWithoutToken); const expectedCall = { url: 'https://admin.da.live/config/test-org/test-site', diff --git a/test/storage/site.test.js b/test/storage/site.test.js new file mode 100644 index 00000000..78ae49c1 --- /dev/null +++ b/test/storage/site.test.js @@ -0,0 +1,212 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import assert from 'assert'; + +const { default: getSiteConfig } = await import('../../src/storage/site.js'); + +const env = { + AEM_API: 'https://api.aem.live', + HLX_CONFIG_SERVICE: 'https://config.aem.page', + HLX_CONFIG_SERVICE_TOKEN: 'shared-secret', +}; + +const daCtx = (over = {}) => ({ + org: 'org', site: 'site', ref: 'main', ...over, +}); + +const HEAD = ''; + +let calls; + +const stubFetch = (respond) => { + calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: input.toString(), init }); + return respond(); + }; +}; + +const config = (over = {}) => new Response(JSON.stringify({ + head: { html: HEAD }, + contentSource: { type: 'markup', url: 'https://content.da.live/org/site/' }, + ...over, +}), { status: 200, headers: { 'content-type': 'application/json' } }); + +const onBus = () => config({ contentSource: { type: 'markup', url: 'https://api.aem.live/org/sites/site/source' } }); + +describe('getSiteConfig', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + describe('the request it makes', () => { + it('asks the config service once, for the pipeline scope', async () => { + stubFetch(config); + + await getSiteConfig(env, daCtx()); + + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].url, 'https://config.aem.page/main--site--org/config.json?scope=pipeline'); + }); + + it('sends the shared secret', async () => { + stubFetch(config); + + await getSiteConfig(env, daCtx()); + + const headers = new Headers(calls[0].init.headers); + assert.strictEqual(headers.get('x-access-token'), 'shared-secret'); + assert.strictEqual(headers.get('x-backend-type'), 'aws'); + }); + + it('gives up rather than hanging', async () => { + stubFetch(config); + + await getSiteConfig(env, daCtx()); + + assert.ok(calls[0].init.signal, 'the lookup carries an abort signal'); + }); + }); + + describe('what one answer carries', () => { + it('answers existence, head.html and the store together', async () => { + stubFetch(onBus); + + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { + exists: true, head: HEAD, onSourceBus: true, + }); + }); + + it('reads the url, not the type, since both stores are markup', async () => { + stubFetch(config); + + const { onSourceBus } = await getSiteConfig(env, daCtx()); + assert.strictEqual(onSourceBus, false); + }); + + it('takes the source bus origin from env', async () => { + stubFetch(() => config({ contentSource: { type: 'markup', url: 'https://api.stage.example/o/sites/s/source' } })); + + const { onSourceBus } = await getSiteConfig({ ...env, AEM_API: 'https://api.stage.example' }, daCtx()); + assert.strictEqual(onSourceBus, true); + }); + + ['https://drive.google.com/x', 'https://example.sharepoint.com/y'].forEach((url) => { + it(`answers legacy for ${new URL(url).host}`, async () => { + stubFetch(() => config({ contentSource: { type: 'markup', url } })); + + const { onSourceBus } = await getSiteConfig(env, daCtx()); + assert.strictEqual(onSourceBus, false); + }); + }); + + // a ref that was never built exists and has no head.html + it('answers a missing head as undefined, and still names the store', async () => { + stubFetch(() => config({ head: undefined })); + + const { exists, head, onSourceBus } = await getSiteConfig(env, daCtx()); + assert.strictEqual(exists, true); + assert.strictEqual(head, undefined); + assert.strictEqual(onSourceBus, false); + }); + }); + + describe('when there is no such site', () => { + it('answers no-site on a 404', async () => { + stubFetch(() => new Response('', { status: 404 })); + + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { + exists: false, head: undefined, onSourceBus: false, + }); + }); + + [ + ['neither', { org: undefined, site: undefined }], + ['no org', { org: undefined }], + ['no site', { site: undefined }], + ['an empty org', { org: '' }], + ['an empty site', { site: '' }], + ].forEach(([what, over]) => { + it(`answers no-site without asking: ${what}`, async () => { + stubFetch(config); + + assert.deepStrictEqual(await getSiteConfig(env, daCtx(over)), { + exists: false, head: undefined, onSourceBus: false, + }); + assert.strictEqual(calls.length, 0); + }); + }); + }); + + describe('when the answer names no content source', () => { + it('reads as legacy, since that is where a site without one has always been', async () => { + stubFetch(() => config({ contentSource: undefined })); + + assert.deepStrictEqual(await getSiteConfig(env, daCtx()), { + exists: true, head: HEAD, onSourceBus: false, + }); + }); + + // a source-bus site read as legacy is served the wrong document and written to the wrong store, + // so the site says so in the log rather than only in an author's lost edits + it('names the site in a warning', async () => { + stubFetch(() => config({ contentSource: undefined })); + const warnings = []; + const saved = console.warn; + console.warn = (m) => warnings.push(m); + + try { + await getSiteConfig(env, daCtx()); + } finally { + console.warn = saved; + } + + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /org\/site/); + assert.match(warnings[0], /content source/); + }); + + it('answers the same when the source carries no url', async () => { + stubFetch(() => config({ contentSource: { type: 'markup' } })); + + const { onSourceBus } = await getSiteConfig(env, daCtx()); + assert.strictEqual(onSourceBus, false); + }); + }); + + describe('when the config service refuses', () => { + [401, 403, 429, 500, 503].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects(() => getSiteConfig(env, daCtx()), new RegExp(`${status}`)); + }); + }); + + it('throws when the answer is not json', async () => { + stubFetch(() => new Response('', { status: 200 })); + + await assert.rejects(() => getSiteConfig(env, daCtx())); + }); + + // the cause reaches the caller, which reports it on the 503 as `x-error` + it('lets a failure through', async () => { + stubFetch(() => { + throw new TypeError('fetch failed'); + }); + + await assert.rejects(getSiteConfig(env, daCtx()), { message: 'fetch failed' }); + }); + }); +}); diff --git a/test/storage/source-bus.test.js b/test/storage/source-bus.test.js deleted file mode 100644 index 8093d3fd..00000000 --- a/test/storage/source-bus.test.js +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/* eslint-env mocha */ -import assert from 'assert'; - -const { default: isSourceBus } = await import('../../src/storage/source-bus.js'); - -const env = { AEM_API: 'https://api.aem.live', HLX_ADMIN: 'https://admin.hlx.page' }; - -const daCtx = (over = {}) => ({ - org: 'org', site: 'site', ref: 'main', authToken: 'Bearer t', ...over, -}); - -let calls; - -const stubFetch = (respond) => { - calls = []; - globalThis.fetch = async (input, init) => { - calls.push({ url: input.toString(), init }); - return respond(input.toString(), init); - }; -}; - -const ping = (headers = {}, status = 200) => new Response('', { status, headers }); -const upgraded = () => ping({ 'x-api-upgrade-available': 'true' }); - -describe('isSourceBus', () => { - afterEach(() => { - delete globalThis.fetch; - }); - - describe('the request it makes', () => { - it('asks /ping on the admin host', async () => { - stubFetch(upgraded); - - await isSourceBus(env, daCtx()); - - assert.strictEqual(calls.length, 1); - assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); - }); - - it('takes the admin host from env, so stage can point elsewhere', async () => { - stubFetch(upgraded); - - await isSourceBus({ ...env, HLX_ADMIN: 'https://admin.stage.example' }, daCtx()); - - assert.strictEqual(calls[0].url, 'https://admin.stage.example/ping/org/site'); - }); - - // both stores read one config service and the source is per site, so the branch cannot change - // the answer - it('does not vary by ref', async () => { - stubFetch(upgraded); - - await isSourceBus(env, daCtx({ ref: 'branch' })); - - assert.strictEqual(calls[0].url, 'https://admin.hlx.page/ping/org/site'); - }); - - // /ping is exempt from authorize() in helix-admin and answers the same with or without a token - it('sends no token, since /ping does not read one', async () => { - stubFetch(upgraded); - - await isSourceBus(env, daCtx()); - - assert.strictEqual(new Headers(calls[0].init.headers).get('Authorization'), null); - }); - - it('gives up rather than hanging', async () => { - stubFetch(upgraded); - - await isSourceBus(env, daCtx()); - - assert.ok(calls[0].init.signal, 'the probe carries an abort signal'); - }); - }); - - describe('when /ping says the site is upgraded', () => { - it('answers true', async () => { - stubFetch(upgraded); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - - // presence, not value: da-nx tests the same header with `!== null` (nx2/utils/api.js, - // isHlx6), and two clients reading it differently would split one site across two stores - ['false', '', 'TRUE'].forEach((value) => { - it(`counts any value, including ${JSON.stringify(value)}`, async () => { - stubFetch(() => ping({ 'x-api-upgrade-available': value })); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - }); - - // no status test, for the same reason. the edge sets the header from its dictionary, so a - // rate-limited or erroring origin behind it does not make an enrolled site legacy - [429, 500, 503].forEach((status) => { - it(`counts it on a ${status}, since the header is what carries the answer`, async () => { - stubFetch(() => ping({ 'x-api-upgrade-available': 'true' }, status)); - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - }); - }); - }); - - describe('when /ping does not say so', () => { - [ - ['the header is absent', {}, 200], - ['the header is absent on a 404', {}, 404], - ['the header is absent on a 405', {}, 405], - ['the header is absent on a 500', {}, 500], - ].forEach(([what, headers, status]) => { - it(`answers false: ${what}`, async () => { - stubFetch(() => ping(headers, status)); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - }); - }); - }); - - // an answer without the header is legacy. no answer is not an answer, and the caller refuses - // rather than picking a store on a coin flip - describe('when /ping cannot answer', () => { - // the cause reaches the caller, which reports it on the 503 as `x-error`. swallowing it here - // would leave a timeout and a dropped connection indistinguishable - it('lets the failure through', async () => { - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - - await assert.rejects(isSourceBus(env, daCtx()), { message: 'fetch failed' }); - }); - - it('lets it through when HLX_ADMIN is unusable, without asking', async () => { - stubFetch(upgraded); - - await assert.rejects(isSourceBus({ AEM_API: 'https://api.aem.live' }, daCtx())); - assert.strictEqual(calls.length, 0); - }); - - // the distinction the caller acts on: false is a store, a failure is no store - it('is distinguishable from a legacy answer', async () => { - stubFetch(() => ping()); - assert.strictEqual(await isSourceBus(env, daCtx()), false); - - stubFetch(() => { - throw new TypeError('fetch failed'); - }); - await assert.rejects(isSourceBus(env, daCtx())); - }); - }); - - describe('when there is no site to ask about', () => { - // either one missing is enough: a half-parsed request would otherwise build a ping url with - // "undefined" in it - [ - ['neither', { org: undefined, site: undefined }], - ['no org', { org: undefined }], - ['no site', { site: undefined }], - ['an empty org', { org: '' }], - ['an empty site', { site: '' }], - ].forEach(([what, over]) => { - it(`answers false without making a request: ${what}`, async () => { - stubFetch(upgraded); - - assert.strictEqual(await isSourceBus(env, daCtx(over)), false); - assert.strictEqual(calls.length, 0); - }); - }); - }); - - // nothing is remembered between calls, so an enrolment takes effect on the next read and a - // config blip cannot pin a stale answer - it('probes every time it is asked', async () => { - let enrolled = false; - stubFetch(() => (enrolled ? upgraded() : ping())); - - assert.strictEqual(await isSourceBus(env, daCtx()), false); - enrolled = true; - - assert.strictEqual(await isSourceBus(env, daCtx()), true); - assert.strictEqual(calls.length, 2); - }); -}); diff --git a/test/utils/aemCtx.test.js b/test/utils/aemCtx.test.js index 8b4432d2..2f17923b 100644 --- a/test/utils/aemCtx.test.js +++ b/test/utils/aemCtx.test.js @@ -70,11 +70,14 @@ describe('AEM context', () => { const mockAemCtx = { previewUrl: 'https://main--site--org.aem.page', }; + let status; beforeEach(async () => { + status = 200; // Mock global fetch - global.fetch = async (url) => ({ - ok: url.includes('success'), + global.fetch = async () => ({ + ok: status === 200, + status, text: async () => 'test content', }); }); @@ -88,10 +91,34 @@ describe('AEM context', () => { assert.strictEqual(html, 'test content'); }); - it('should return undefined for failed request', async () => { + // a ref that was never previewed has no head.html, and the page is composed without it + it('should return undefined for a 404', async () => { + status = 404; const html = await getAEMHtml(mockAemCtx, '/fail-path'); assert.strictEqual(html, undefined); }); + + // a preview host behind Helix authentication refuses without a site token, and a retry + // answers the same, so the page is built without the project head + [401, 403].forEach((code) => { + it(`should return undefined for a ${code}`, async () => { + status = code; + const html = await getAEMHtml(mockAemCtx, '/fail-path'); + assert.strictEqual(html, undefined); + }); + }); + + // a preview host that could not answer is not a ref that was never previewed, and reading it + // as one serves the page at 200 with no stylesheet, no scripts and no entry script + [429, 500, 502].forEach((code) => { + it(`should throw on a ${code}`, async () => { + status = code; + await assert.rejects( + () => getAEMHtml(mockAemCtx, '/fail-path'), + new RegExp(String(code)), + ); + }); + }); }); describe('fixUrlsWhenLocalDev', () => { diff --git a/test/utils/upstream.test.js b/test/utils/upstream.test.js new file mode 100644 index 00000000..20d7698f --- /dev/null +++ b/test/utils/upstream.test.js @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ + +import assert from 'assert'; +import { describe, it } from 'mocha'; +import { causeOf } from '../../src/utils/upstream.js'; + +describe('causeOf', () => { + it('names the error and its message', () => { + assert.strictEqual(causeOf(new TypeError('Network lost')), 'TypeError: Network lost'); + }); + + it('keeps latin-1 characters, which a header value can carry', () => { + assert.strictEqual(causeOf(new Error('café ÿ')), 'Error: café ÿ'); + }); + + it('replaces a character a header value cannot carry', () => { + assert.strictEqual(causeOf(new Error('a ’ b')), 'Error: a b'); + }); + + it('replaces the controls that are not a header value character', () => { + // tab, newline, carriage return, NUL, DEL and a C1 control + const controls = [0x09, 0x0a, 0x0d, 0x00, 0x7f, 0x85] + .map((c) => String.fromCharCode(c)) + .join(''); + assert.strictEqual(causeOf(new Error(`a${controls}b`)), 'Error: a b'); + }); + + it('caps the length', () => { + assert.strictEqual(causeOf(new Error('x'.repeat(2000))).length, 1024); + }); + + it('renders what is thrown when it is not an Error', () => { + assert.strictEqual(causeOf('boom'), 'Error: boom'); + assert.strictEqual(causeOf(undefined), 'Error: undefined'); + }); + + // the point of the sanitizing: whatever comes back is settable, so no failure path throws while + // reporting a failure + it('answers a string that Headers accepts, for any code point', () => { + for (let i = 0; i <= 0x2fff; i += 1) { + const value = causeOf(new Error(`a${String.fromCharCode(i)}b`)); + assert.doesNotThrow(() => new Headers({ 'x-error': value }), `code point 0x${i.toString(16)}`); + } + }); +}); diff --git a/wrangler.toml b/wrangler.toml index b8fdfed1..39d66735 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -2,19 +2,24 @@ name = "da-ue" main = "src/index.js" compatibility_date = "2023-11-21" -vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +vars = { UE_HOST = "ue.da.live", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin" }] +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [dev] port = 4712 [env.dev] -vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +vars = { ENVIRONMENT = "dev", UE_HOST = "localhost:4712", UE_SERVICE = "https://localhost:8000", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "http://localhost:4713" } services = [{ binding = "daadmin", service = "da-admin-local" }] +# the list is also what wrangler binds from .dev.vars.dev, so an empty one leaves the worker +# with no token and every lookup 401s. running against the stand-in warns that it is missing +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage] -vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_ADMIN = "https://admin.hlx.page" } +vars = { ENVIRONMENT = "stage", UE_HOST = "stage-ue.da.live", UE_SERVICE = "https://universal-editor-service-dev.adobe.io", DA_ADMIN = "https://admin.da.live", AEM_API = "https://api.aem.live", HLX_CONFIG_SERVICE = "https://config.aem.page" } services = [{ binding = "daadmin", service = "da-admin-stage" }] +secrets = { required = ["HLX_CONFIG_SERVICE_TOKEN"] } [env.stage.observability] enabled = true