diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 054d9499..f54932c9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -41,14 +41,14 @@ jobs: - name: Deploy to Cloudflare Workers (production) if: github.ref_name == 'main' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - name: Deploy to Cloudflare Workers (stage) if: github.ref_name == 'stage' - uses: cloudflare/wrangler-action@v3 + uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/README.md b/README.md index 5205fd84..a0e64eba 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,29 @@ 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, and the table ships one site of each kind, `org/site` and `org/sourcebus`, so both branches can be driven locally. + To run da-universal locally: 1. Clone this repo to your computer. 1. Run `npm install` 1. Use `npx wrangler login` if not done before. Walk through the steps in browser. -1. In a terminal, run `npm run dev` this repo's folder. +1. Put `HLX_CONFIG_SERVICE_TOKEN="local"` in `.dev.vars.dev`, which is gitignored. +1. In a terminal, run `npm run dev:lookups` to start the stand-in lookups on port 4713. +1. In a second terminal, run `npm run dev` in this repo's folder. 1. The da-ue service API is available via https://localhost:4712 +The stand-in does not read the token's value, only that there is one. Without it the lookup goes out as the string `undefined` and comes back 401, the way the real service refuses it, and the worker logs that it is the one at fault. + +`npm run dev` sets `UE_HOST` to localhost:4712, so https://localhost:4712 serves the UE-instrumented page rather than the composed page as-is, and points `urn:adobe:aue:config:service` at https://localhost:8000. A Universal Editor service has to be running there for that page to open in the editor. + +with the shared secret, use `npm run dev` at the real services instead of the stand-in. Put `HLX_CONFIG_SERVICE_TOKEN=""` in `.dev.vars.dev`, which is gitignored, and run `npm run dev -- --var HLX_CONFIG_SERVICE:https://config.aem.page`. + ### 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..7c2dfee6 --- /dev/null +++ b/dev/lookup-shim.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. + */ + +// 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/', + 'org/sourcebus': 'https://api.aem.live/org/sites/sourcebus/source/', +}; + +// what the code bus has at {owner}/{repo}/{ref}/head.html, which the pipeline scope answers with. +// the policy and the placeholders are what applyCsp keys on, so a page served locally exercises +// the nonce rewrite, the trusted-types strip and the move-to-http-header deletion +const HEAD_HTML = '\n\n\n'; + +export default { + async fetch(req) { + const url = new URL(req.url); + + // an unset HLX_CONFIG_SERVICE_TOKEN reaches the header as the string "undefined", and the + // real service refuses that the same way it refuses no header at all + const token = req.headers.get('x-access-token'); + if (!token || token === 'undefined') { + return new Response('', { status: 401, headers: { 'x-error': 'missing x-access-token.' } }); + } + + const [ref, site, org] = (url.pathname.split('/')[1] ?? '').split('--'); + if (!org || !site) { + return new Response('', { status: 400, headers: { 'x-error': 'invalid rso path parameter.' } }); + } + + 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..9e7ab950 100644 --- a/src/handlers/get.js +++ b/src/handlers/get.js @@ -9,10 +9,11 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { get404, getRobots } from '../responses/index.js'; +import { empty503, get404, getRobots } from '../responses/index.js'; import { handleAEMProxyRequest } from '../routes/aem-proxy.js'; import { getCookie } from '../routes/cookie.js'; import { daSourceGet } from '../routes/da-admin.js'; +import { UpstreamError } from '../utils/upstream.js'; export default async function getHandler({ req, env, daCtx }) { const { path } = daCtx; @@ -21,11 +22,17 @@ 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)) { - return handleAEMProxyRequest({ req, env, daCtx }); + try { + return await handleAEMProxyRequest({ req, env, daCtx }); + } catch (e) { + if (!(e instanceof UpstreamError)) throw e; + console.warn(`503 GET ${path}, ${e.message}`); + return empty503(e.message); + } } const assetRegex = /\.(png|jpg|jpeg|webp|gif|svg|ico|avif)$/i; @@ -35,8 +42,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/handlers/head.js b/src/handlers/head.js index aeb62746..e275261f 100644 --- a/src/handlers/head.js +++ b/src/handlers/head.js @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ -import { getRobots, head404 } from '../responses/index.js'; +import { empty503, getRobots, head404 } from '../responses/index.js'; import { handleAEMProxyRequest } from '../routes/aem-proxy.js'; import { daSourceHead } from '../routes/da-admin.js'; +import { UpstreamError } from '../utils/upstream.js'; // for AEM we reuse the handleAEMProxyRequest for now as GETs are cheap here // TODO refine and review for later for a full HEAD requests on AEM @@ -31,7 +32,13 @@ export default async function headHandler({ req, env, daCtx }) { const resourceRegex = /\.(css|js|js\.map|json|xml|woff|woff2|otf|ttf|plain\.html|html)$/i; if (resourceRegex.test(path)) { - return aemHead({ req, env, daCtx }); + try { + return await aemHead({ req, env, daCtx }); + } catch (e) { + if (!(e instanceof UpstreamError)) throw e; + console.warn(`503 HEAD ${path}, ${e.message}`); + return empty503(e.message); + } } const assetRegex = /\.(png|jpg|jpeg|webp|gif|svg|ico|avif)$/i; diff --git a/src/render/compose.js b/src/render/compose.js index 5e738cd4..8a88a66c 100644 --- a/src/render/compose.js +++ b/src/render/compose.js @@ -20,6 +20,7 @@ import rewriteIcons from './rewrite-icons.js'; import { makeImagesRelative } from './rewrite-images.js'; import extractSectionMetadata from './section-metadata.js'; import { DEFAULT_HTML_TEMPLATE } from '../utils/constants.js'; +import { PREVIEW_HOST, withUpstream } from '../utils/upstream.js'; /** * Injects AEM HTML head entries into the head node of an HTML document. @@ -98,8 +99,9 @@ export async function composeHtml(daCtx, aemCtx, bodyHtmlStr, headHtmlStr) { const bodyTree = fromHtml(bodyHtmlStr, { fragment: true }); bodyNode.children = bodyTree.children; - // fetch bulk metadata, extract metadata block from the body and merge them - const bulkMetadata = await fetchBulkMetadata(aemCtx); + // fetch bulk metadata, extract metadata block from the body and merge them. the sheet is the + // only thing here that leaves the worker, so it is the only step that names an upstream + const bulkMetadata = await withUpstream(PREVIEW_HOST, () => fetchBulkMetadata(aemCtx)); const localMetaData = extractLocalMetadata(bodyTree); const mergedMetaData = { ...localMetaData, diff --git a/src/render/csp.js b/src/render/csp.js new file mode 100644 index 00000000..27da9e64 --- /dev/null +++ b/src/render/csp.js @@ -0,0 +1,91 @@ +/* + * 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. + */ + +import { select } from 'hast-util-select'; +import { visit } from 'unist-util-visit'; + +const NONCE_AEM = "'nonce-aem'"; +const TRUSTED_TYPES_REQUIRE = 'require-trusted-types-for'; + +function directiveHasNonce(content, name) { + return content + .split(';') + .some((directive) => { + const [directiveName, ...values] = directive.trim().split(/\s+/); + return directiveName === name && values.includes(NONCE_AEM); + }); +} + +function removeTrustedTypesRequire(content) { + const directives = content.split(';'); + const filtered = directives.filter( + (directive) => directive.trim().split(/\s+/, 1)[0].toLowerCase() !== TRUSTED_TYPES_REQUIRE, + ); + if (filtered.length !== directives.length) { + console.warn(`Removed ${TRUSTED_TYPES_REQUIRE} from the composed CSP meta.`); + } + return filtered.join(';'); +} + +function createNonce() { + const array = new Uint8Array(18); + crypto.getRandomValues(array); + return btoa(String.fromCharCode(...array)); +} + +/** + * @param {import('hast').Root} documentTree the composed document, mutated in place + * @returns {string|undefined} the nonce to stamp on injected scripts, or undefined when the + * page carries no policy that asks for one + */ +export default function applyCsp(documentTree) { + // head.html owns the policy and placeholders; the body is author content that round-trips + // rewriting here is what lets the config service answer head.html for UE too. reading it from + // the preview host for the pipeline's own rewrite was the alternative, at a round trip per page + const scope = select('head', documentTree) ?? documentTree; + const meta = select('meta[http-equiv="content-security-policy" i]', scope); + const content = meta?.properties.content; + if (typeof content !== 'string' || !content.includes(NONCE_AEM)) { + return undefined; + } + + const scriptNonce = directiveHasNonce(content, 'script-src'); + const styleNonce = directiveHasNonce(content, 'style-src'); + const nonce = createNonce(); + + meta.properties.content = removeTrustedTypesRequire( + content.replaceAll(NONCE_AEM, `'nonce-${nonce}'`), + ); + // the pipeline moves this policy to a response header; we keep it in the document because + // frame-ancestors sent as a header stops the editor framing the page, and a meta ignores it + delete meta.properties['move-to-http-header']; + delete meta.properties['move-as-header']; + + visit(scope, (node) => { + if (node.properties?.nonce !== 'aem') return; + + if (scriptNonce + && (node.tagName === 'script' + || (node.tagName === 'link' && node.properties.as === 'script'))) { + node.properties.nonce = nonce; + return; + } + + if (styleNonce + && (node.tagName === 'style' + || (node.tagName === 'link' && node.properties.rel?.includes('stylesheet')))) { + node.properties.nonce = nonce; + } + }); + + return scriptNonce ? nonce : undefined; +} diff --git a/src/render/metadata.js b/src/render/metadata.js index 8fdf648f..d9e5bded 100644 --- a/src/render/metadata.js +++ b/src/render/metadata.js @@ -14,6 +14,8 @@ import { select } from 'hast-util-select'; import { readBlockConfig } from '../utils/hast.js'; import { withAemAuth } from '../utils/aemCtx.js'; +const TIMEOUT_MS = 5 * 1000; + export function extractLocalMetadata(bodyTree) { const metaBlock = select('div.metadata', bodyTree); let metaConfig = {}; @@ -157,7 +159,9 @@ export class Modifiers { export async function fetchBulkMetadata(aemCtx) { const url = new URL('/metadata.json', aemCtx.previewUrl); - const response = await fetch(url, withAemAuth(aemCtx)); + const response = await fetch(url, withAemAuth(aemCtx, { + signal: AbortSignal.timeout(TIMEOUT_MS), + })); if (response.ok) { const json = await response.json(); diff --git a/src/responses/index.js b/src/responses/index.js index 41758933..6ca52b3b 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({ @@ -91,7 +95,7 @@ export function head401() { return new Response(null, { status: 401 }); } -export function head503(error = '') { +export function empty503(error = '') { return new Response(null, { status: 503, headers: retryHeaders(error) }); } diff --git a/src/routes/aem-proxy.js b/src/routes/aem-proxy.js index f8812ead..789bdea7 100644 --- a/src/routes/aem-proxy.js +++ b/src/routes/aem-proxy.js @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ import { getAemCtx } from '../utils/aemCtx.js'; +import { PREVIEW_HOST, withUpstream } from '../utils/upstream.js'; import { applyQuickEditToScript, getQuickEditCookiePath, @@ -47,7 +48,7 @@ export async function handleAEMProxyRequest({ req, env, daCtx }) { } console.log(`-> ${aemUrl.toString()}`); - let response = await fetch(req, { cf: { cacheTtl: 0 } }); + let response = await withUpstream(PREVIEW_HOST, () => fetch(req, { cf: { cacheTtl: 0 } })); console.log(`<- ${aemUrl.toString()}. ${response.status} ${response.statusText}`, { status: response.status, statusText: response.statusText }); const contentType = (response.headers.get('Content-Type') || '').toLowerCase(); diff --git a/src/routes/cookie.js b/src/routes/cookie.js index 352f348f..47e5735a 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', @@ -27,7 +27,8 @@ async function exchangeSiteToken(org, site, accessToken) { }); if (!response.ok) { - // 401/403 error cases + // a public site answers 200 with nothing, so a refusal is the one case with no other signal + console.warn(`the site token exchange for ${org}/${site} answered ${response.status}`); return null; } @@ -48,7 +49,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 +68,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..36f3d6a4 100644 --- a/src/routes/da-admin.js +++ b/src/routes/da-admin.js @@ -17,44 +17,66 @@ import putHelper from '../helpers/source.js'; import { removeUEAttributes, unwrapParagraphs } from '../ue/attributes.js'; import { applyUEInstrumentation } from '../ue/ue.js'; import { composeHtml, serializeHtml } from '../render/compose.js'; +import applyCsp from '../render/csp.js'; import { getAemCtx, getAEMHtml } from '../utils/aemCtx.js'; import { applyQuickEditToDocument, buildQuickEditCookie, buildQuickEditNotFoundResponse, } from '../utils/quick-edit.js'; import { - daResp, get401, get404, get415, get503, head401, head503, post405, post503, + daResp, empty503, get401, get404, get415, get503, head401, head404, 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 empty503(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 +95,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 +113,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 +122,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 @@ -145,12 +154,13 @@ export async function daSourceGet({ req, env, daCtx }) { } // determine the request type before `req` is reassigned to the admin request. - // quick-edit takes precedence; UE is gated on the hostname; everything else - // (preview hosts, local dev) renders the composed page as-is. + // quick-edit takes precedence; UE is gated on its hosted suffixes or configured local host; + // preview hosts render the composed page as-is. const url = new URL(req.url); const isQuickEdit = url.searchParams.has('quick-edit'); const isUE = url.hostname.endsWith('.ue.da.live') - || url.hostname.endsWith('.stage-ue.da.live'); + || url.hostname.endsWith('.stage-ue.da.live') + || url.host === env.UE_HOST; const headers = new Headers(); headers.set('Authorization', authToken); @@ -158,27 +168,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 @@ -193,25 +204,29 @@ export async function daSourceGet({ req, env, daCtx }) { return sourceResp; } - // use the stored content when available, otherwise fall back to a template + // use the stored content when available, otherwise fall back to a template. the body arrives + // after the status, so a read that stops here is still the store's const bodyHtml = sourceResp.status === 200 - ? await sourceResp.text() - : await getPageTemplate(env, daCtx, aemCtx, headHtml); + ? await withUpstream(CONTENT_STORE, () => sourceResp.text()) + : 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. the read + // composing makes of the preview host names that upstream itself, and the rest of composing + // walks the stored document, where a throw is the worker's own + const documentTree = await composeHtml(daCtx, aemCtx, bodyHtml, headHtml ?? ''); + const nonce = applyCsp(documentTree); // layer the request-specific instrumentation on top of the composed page const extraHeaders = []; if (isQuickEdit) { - // no upstream AEM CSP to satisfy here, so no nonce is applied - const entryPath = applyQuickEditToDocument(documentTree, undefined); + // the composed head owns its policy because applyCsp rewrote it + const entryPath = applyQuickEditToDocument(documentTree, nonce); if (entryPath) { console.log(`[quick-edit] doc compose: entry script ${entryPath} found, setting cookie`); extraHeaders.push(['Set-Cookie', buildQuickEditCookie(entryPath)]); } } else if (isUE) { - await applyUEInstrumentation(documentTree, daCtx, aemCtx); + await applyUEInstrumentation(documentTree, daCtx, aemCtx, nonce); } const body = serializeHtml(documentTree); @@ -225,7 +240,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 +259,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 @@ -257,12 +290,36 @@ export async function daSourcePost({ req, env, daCtx }) { if (isFile && !isHtmlPostType(obj.data.type)) { return get415(); } + + // resolved before the document is read and rewritten, since a write with nowhere to go is + // refused whatever the document turns out to be + const { exists, onSourceBus } = await withUpstream( + SITE_LOOKUP, + () => getSiteConfig(env, daCtx), + ); + + if (!exists) { + console.log(`404 POST ${sourcePath}, there is no site ${daCtx.org}/${daCtx.site}`); + return post404(SITE_NOT_FOUND_MESSAGE); + } + + if (onSourceBus) { + console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); + return post405(SOURCE_BUS_READ_ONLY_MESSAGE); + } + const { body: bodyHtml } = isFile ? await getFileBody(obj.data) : getTextBody(obj.data); const documentTree = fromHtml(bodyHtml); let bodyNode = select('body', documentTree); + // a frameset document parses without a body, and the rewrite below has nothing to run on + if (!bodyNode) { + console.log(`415 POST ${sourcePath}, the document has no body`); + return get415(); + } + // unwrap rich text elements // clean up UE data attributes bodyNode = unwrapParagraphs(bodyNode); @@ -275,34 +332,31 @@ 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); - } - - if (onSourceBus) { - console.log(`405 POST ${sourcePath}, writes to the source bus are refused through the preview proxy. write directly to the source bus instead.`); - return post405(SOURCE_BUS_READ_ONLY_MESSAGE); - } - // da-admin takes the document as a `data` form part const store = getStore(env, daCtx, onSourceBus); const body = new FormData(); body.set('data', new Blob([bodyContent], { type: 'text/html' })); console.log(`-> ${store.url.toString()}`); - const { response, error } = await reachStore(store, () => store.fetch(new Request(store.url, { + 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..254e13ac --- /dev/null +++ b/src/storage/site.js @@ -0,0 +1,67 @@ +/* + * 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); + // an unset token goes out as the string "undefined", so the lookup still runs and still fails, + // and only the log says the worker is the one at fault + if (!env.HLX_CONFIG_SERVICE_TOKEN) { + console.error('no HLX_CONFIG_SERVICE_TOKEN, this worker is misconfigured and every site lookup will be refused'); + } + const response = await fetch(url, { + headers: { + 'x-access-token': env.HLX_CONFIG_SERVICE_TOKEN, + 'x-backend-type': 'aws', + }, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + if (response.status === 404) return NO_SITE; + if (response.status === 401 || response.status === 403) { + const token = env.HLX_CONFIG_SERVICE_TOKEN ? 'present' : 'missing'; + throw new Error(`the config service answered ${response.status}, HLX_CONFIG_SERVICE_TOKEN ${token}`); + } + if (!response.ok) throw new Error(`the config service answered ${response.status}`); + + const { head, contentSource } = await response.json(); + // 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/ue/scaffold.js b/src/ue/scaffold.js index 2d673abe..b397332a 100644 --- a/src/ue/scaffold.js +++ b/src/ue/scaffold.js @@ -13,7 +13,7 @@ import { h } from 'hastscript'; import { withAemAuth } from '../utils/aemCtx.js'; -export function getUEHtmlHeadEntries(daCtx, aemCtx) { +export function getUEHtmlHeadEntries(daCtx, aemCtx, nonce) { const { org, site, @@ -49,6 +49,7 @@ export function getUEHtmlHeadEntries(daCtx, aemCtx) { h('script', { src: 'https://universal-editor-service.adobe.io/cors.js', async: '', + ...(nonce === undefined ? {} : { nonce }), }), ); children.push( @@ -57,6 +58,7 @@ export function getUEHtmlHeadEntries(daCtx, aemCtx) { src: orgSiteInPath ? `/${org}/${site}/component-definition.json` : '/component-definition.json', + ...(nonce === undefined ? {} : { nonce }), }), ); children.push( @@ -65,6 +67,7 @@ export function getUEHtmlHeadEntries(daCtx, aemCtx) { src: orgSiteInPath ? `/${org}/${site}/component-models.json` : '/component-models.json', + ...(nonce === undefined ? {} : { nonce }), }), ); children.push( @@ -73,6 +76,7 @@ export function getUEHtmlHeadEntries(daCtx, aemCtx) { src: orgSiteInPath ? `/${org}/${site}/component-filters.json` : '/component-filters.json', + ...(nonce === undefined ? {} : { nonce }), }), ); diff --git a/src/ue/ue.js b/src/ue/ue.js index e03b5118..61aca552 100644 --- a/src/ue/ue.js +++ b/src/ue/ue.js @@ -21,11 +21,12 @@ import { injectUEAttributes } from './attributes.js'; * @param {import('hast').Root} documentTree - The composed document tree (mutated in place). * @param {Object} daCtx - The Dark Alley context object. * @param {Object} aemCtx - The AEM context object. + * @param {string|undefined} nonce - The composed page's CSP nonce. */ -export async function applyUEInstrumentation(documentTree, daCtx, aemCtx) { +export async function applyUEInstrumentation(documentTree, daCtx, aemCtx, nonce) { // add UE head script and meta tags const headNode = select('head', documentTree); - headNode.children.push(...getUEHtmlHeadEntries(daCtx, aemCtx)); + headNode.children.push(...getUEHtmlHeadEntries(daCtx, aemCtx, nonce)); // add data attributes for UE to the body const bodyNode = select('body', documentTree); 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..36468685 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -13,6 +13,7 @@ export const TRUSTED_ORIGINS = [ 'https://da.live', 'https://experience.adobe.com', + 'https://experience-stage.adobe.com', 'https://localhost.corp.adobe.com:8080', ]; @@ -49,13 +50,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..f6a373d5 100644 --- a/src/utils/quick-edit.js +++ b/src/utils/quick-edit.js @@ -95,9 +95,10 @@ function findEntryScriptInTree(tree) { * Inject or update the quick-edit import map in the tree. * Returns true when the tree was modified, false when already satisfied. * @param {import('hast').Root} tree + * @param {string | undefined} nonce * @returns {boolean} */ -function injectImportMap(tree) { +function injectImportMap(tree, nonce) { const existing = select('script[type="importmap"]', tree); if (existing) { const text = (existing.children ?? []).find((c) => c.type === 'text')?.value ?? ''; @@ -112,7 +113,10 @@ function injectImportMap(tree) { existing.children = [{ type: 'text', value: JSON.stringify(merged) }]; return true; } - const node = h('script', { type: 'importmap' }, JSON.stringify(QUICK_EDIT_IMPORT_MAP)); + const node = h('script', { + type: 'importmap', + ...(nonce === undefined ? {} : { nonce }), + }, JSON.stringify(QUICK_EDIT_IMPORT_MAP)); const head = select('head', tree); if (head) { head.children.unshift(node); @@ -122,19 +126,6 @@ function injectImportMap(tree) { return true; } -/** - * Add the CSP nonce attribute to every `'; + const body = '
'; + + const tree = await composeHtml(daCtx, aemCtx, body, cspHead); + const nonce = applyCsp(tree); + + assert.ok(select('head meta[http-equiv]', tree).properties.content.includes(`'nonce-${nonce}'`)); + assert.strictEqual(select('head script', tree).properties.nonce, nonce); + assert.strictEqual(select('body script', tree).properties.nonce, 'aem'); + }); }); diff --git a/test/render/csp.test.js b/test/render/csp.test.js new file mode 100644 index 00000000..f7412976 --- /dev/null +++ b/test/render/csp.test.js @@ -0,0 +1,165 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-env mocha */ +import assert from 'assert'; +import { fromHtml } from 'hast-util-from-html'; +import { select, selectAll } from 'hast-util-select'; +import applyCsp from '../../src/render/csp.js'; + +const cspMeta = (content, extra = '') => ``; + +describe('applyCsp', () => { + it('rewrites the CSP meta and re-stamps head scripts with one nonce', () => { + const tree = fromHtml(` + ${cspMeta("script-src 'nonce-aem' 'strict-dynamic'; object-src 'none';", 'move-to-http-header="true" move-as-header="true"')} + + `); + + const nonce = applyCsp(tree); + const meta = select('meta', tree); + const script = select('script', tree); + + assert.ok(nonce); + assert.match(nonce, /^[A-Za-z0-9+/]{24}$/); + assert.ok(meta.properties.content.includes(`'nonce-${nonce}'`)); + assert.ok(!meta.properties.content.includes("'nonce-aem'")); + assert.strictEqual(meta.properties['move-to-http-header'], undefined); + assert.strictEqual(meta.properties['move-as-header'], undefined); + assert.strictEqual(script.properties.nonce, nonce); + }); + + it('keeps a policy asking for a header in the document', () => { + const tree = fromHtml(` + ${cspMeta("script-src 'nonce-aem'; frame-ancestors 'self';", 'move-to-http-header="true" move-as-header="true"')} + `); + + const nonce = applyCsp(tree); + const meta = select('head meta[http-equiv="content-security-policy" i]', tree); + + assert.ok(meta, 'the meta has to survive, a header would break framing in the editor'); + assert.ok(meta.properties.content.includes(`'nonce-${nonce}'`)); + assert.ok(meta.properties.content.includes("frame-ancestors 'self'")); + assert.strictEqual(meta.properties['move-to-http-header'], undefined); + assert.strictEqual(meta.properties['move-as-header'], undefined); + }); + + it('rewrites only the case-insensitive policy and placeholders in the head', () => { + const bodyContent = "script-src 'nonce-aem'; object-src 'none'"; + const tree = fromHtml(` + + + + + + `); + + const nonce = applyCsp(tree); + const headMeta = select('head meta', tree); + const headScript = select('head script', tree); + const bodyMeta = select('body meta', tree); + const bodyScript = select('body script', tree); + + assert.ok(headMeta.properties.content.includes(`'nonce-${nonce}'`)); + assert.strictEqual(headScript.properties.nonce, nonce); + assert.strictEqual(bodyMeta.properties.content, bodyContent); + assert.strictEqual(bodyScript.properties.nonce, 'aem'); + }); + + it('re-stamps only elements covered by a nonce-bearing directive', () => { + const tree = fromHtml(` + ${cspMeta("script-src 'nonce-aem'; style-src 'nonce-aem'")} + + + + + `); + + const nonce = applyCsp(tree); + const stamped = selectAll('[nonce]', tree); + + assert.strictEqual(stamped.length, 4); + assert.ok(stamped.every((node) => node.properties.nonce === nonce)); + }); + + it('removes require-trusted-types-for without changing other directives', () => { + const warnings = []; + const saved = console.warn; + console.warn = (message) => warnings.push(message); + try { + const tree = fromHtml(` + ${cspMeta("script-src 'nonce-aem' 'strict-dynamic'; object-src 'none'; require-trusted-types-for 'script'; trusted-types editor;")} + `); + + const nonce = applyCsp(tree); + const { content } = select('meta', tree).properties; + + assert.ok(content.includes(`script-src 'nonce-${nonce}' 'strict-dynamic'`)); + assert.ok(content.includes("object-src 'none'")); + assert.ok(content.includes('trusted-types editor')); + assert.ok(!content.includes('require-trusted-types-for')); + assert.ok(warnings.some((message) => message.includes('require-trusted-types-for'))); + } finally { + console.warn = saved; + } + }); + + it('leaves a policy without the nonce sentinel byte-for-byte unchanged', () => { + const content = "default-src 'self'"; + const tree = fromHtml(` + ${cspMeta(content, 'move-to-http-header="true"')} + + `); + + assert.strictEqual(applyCsp(tree), undefined); + const meta = select('meta', tree); + assert.strictEqual(meta.properties.content, content); + assert.strictEqual(meta.properties['move-to-http-header'], 'true'); + assert.strictEqual(select('script', tree).properties.nonce, 'aem'); + }); + + it('ignores report-only metas', () => { + const tree = fromHtml(` + + + `); + + assert.strictEqual(applyCsp(tree), undefined); + assert.strictEqual(select('script', tree).properties.nonce, 'aem'); + }); + + it('adds no nonce when the page has no CSP meta', () => { + const tree = fromHtml(''); + + assert.strictEqual(applyCsp(tree), undefined); + assert.strictEqual(select('script', tree).properties.nonce, undefined); + }); + + it('returns no script nonce for a style-only policy', () => { + const tree = fromHtml(` + ${cspMeta("style-src 'nonce-aem'")} + + + `); + + assert.strictEqual(applyCsp(tree), undefined); + assert.strictEqual(select('script', tree).properties.nonce, 'aem'); + assert.notStrictEqual(select('style', tree).properties.nonce, 'aem'); + }); + + it('mints a different nonce for each composed document', () => { + const first = fromHtml(`${cspMeta("script-src 'nonce-aem'")}`); + const second = fromHtml(`${cspMeta("script-src 'nonce-aem'")}`); + + assert.notStrictEqual(applyCsp(first), applyCsp(second)); + }); +}); diff --git a/test/routes/aem-proxy.test.js b/test/routes/aem-proxy.test.js index 9738b0aa..effd89f0 100644 --- a/test/routes/aem-proxy.test.js +++ b/test/routes/aem-proxy.test.js @@ -14,6 +14,7 @@ import assert from 'assert'; import esmock from 'esmock'; import { getDaCtx } from '../../src/utils/daCtx.js'; +import { PREVIEW_HOST, UpstreamError } from '../../src/utils/upstream.js'; describe('AEM proxy quick-edit', () => { let handleAEMProxyRequest; @@ -82,3 +83,31 @@ describe('AEM proxy quick-edit', () => { assert.strictEqual(res.headers.get('Set-Cookie'), null); }); }); + +describe('AEM proxy upstream failures', () => { + let handleAEMProxyRequest; + const env = { UE_HOST: 'test-host', UE_SERVICE: 'test-service' }; + + beforeEach(async () => { + const mod = await esmock('../../src/routes/aem-proxy.js'); + handleAEMProxyRequest = mod.handleAEMProxyRequest; + }); + + afterEach(() => { + delete globalThis.fetch; + }); + + it('names the preview host when the read cannot be made', async () => { + const req = new Request('https://main--site--org.ue.da.live/styles/styles.css'); + const daCtx = getDaCtx(req); + + globalThis.fetch = async () => { + throw new TypeError('fetch failed'); + }; + + await assert.rejects( + () => handleAEMProxyRequest({ req, env, daCtx }), + (e) => e instanceof UpstreamError && e.upstream === PREVIEW_HOST, + ); + }); +}); diff --git a/test/routes/cookie.test.js b/test/routes/cookie.test.js new file mode 100644 index 00000000..703cbda5 --- /dev/null +++ b/test/routes/cookie.test.js @@ -0,0 +1,202 @@ +/* + * 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); + }); + + // a public site and a refused exchange both end with no site token, so only the log tells + // an operator which of the two happened + it('says which status refused the exchange', async () => { + stubFetch(() => new Response('', { status: 404 })); + const warnings = []; + const saved = console.warn; + console.warn = (m) => warnings.push(m); + + try { + await getCookie({ req: req(), env, daCtx: daCtx() }); + } finally { + console.warn = saved; + } + + assert.strictEqual(warnings.length, 1); + assert.match(warnings[0], /org\/site/); + assert.match(warnings[0], /404/); + }); + + it('says nothing when the site simply needs no site token', async () => { + stubFetch(noAuthNeeded); + const warnings = []; + const saved = console.warn; + console.warn = (m) => warnings.push(m); + + try { + await getCookie({ req: req(), env, daCtx: daCtx() }); + } finally { + console.warn = saved; + } + + assert.strictEqual(warnings.length, 0); + }); + + it('asks for no exchange when the caller already has a site token', async () => { + stubFetch(mints); + + 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); + }); + + // the stage experience shell has to reach the stage worker so the stage UE path has a + // first-class entry point + it('trusts the stage experience shell', async () => { + stubFetch(mints); + + const res = await getCookie({ + req: req({ origin: 'https://experience-stage.adobe.com' }), + env, + daCtx: daCtx(), + }); + + assert.notStrictEqual(res.status, 403); + }); + + it('answers 401 without an Authorization header', async () => { + stubFetch(mints); + + 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..aec92cb2 100644 --- a/test/routes/da-admin.test.js +++ b/test/routes/da-admin.test.js @@ -13,6 +13,8 @@ /* eslint-env mocha */ import assert from 'assert'; import esmock from 'esmock'; +import { fromHtml } from 'hast-util-from-html'; +import { select, selectAll } from 'hast-util-select'; import reqs from '../mocks/req.js'; const { getDaCtx } = await import('../../src/utils/daCtx.js'); @@ -33,7 +35,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 +46,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 +119,19 @@ 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 : ''; - calls = { compose: [], ue: 0, quickEdit: 0 }; + const exists = overrides.site?.exists ?? true; + calls = { + compose: [], ue: 0, ueNonce: undefined, quickEdit: 0, quickEditNonce: undefined, + }; 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, + // template fallback reads the preview host; stub it so this suite's focus + // (UE / quick-edit / composeHtml wiring) is not tangled with it + getAEMHtml: async () => undefined, }, '../../src/render/compose.js': { composeHtml: async (daCtx, aemCtx, bodyHtml) => { @@ -127,18 +140,26 @@ describe('daSourceGet', () => { }, serializeHtml: () => 'composed', }, + '../../src/render/csp.js': { + default: () => 'abc123', + }, '../../src/ue/ue.js': { - applyUEInstrumentation: async () => { calls.ue += 1; }, + applyUEInstrumentation: async (documentTree, daCtx, aemCtx, nonce) => { + calls.ue += 1; + calls.ueNonce = nonce; + }, }, '../../src/utils/quick-edit.js': { - applyQuickEditToDocument: () => { + applyQuickEditToDocument: (documentTree, nonce) => { calls.quickEdit += 1; + calls.quickEditNonce = nonce; return '/scripts/scripts.js'; }, buildQuickEditCookie: (p) => `da-quick-edit=${encodeURIComponent(p)}; Path=/`, }, '../../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; }; @@ -152,10 +173,23 @@ describe('daSourceGet', () => { assert.strictEqual(res.status, 200); assert.strictEqual(calls.ue, 1); + assert.strictEqual(calls.ueNonce, 'abc123'); assert.strictEqual(calls.quickEdit, 0); assert.strictEqual(res.headers.get('Set-Cookie'), null); }); + it('applies UE instrumentation on a stage UE host', async () => { + const daSourceGet = await mockDaSourceGet(); + const req = authedReq('https://main--site--org.stage-ue.da.live/folder/content'); + const daCtx = getDaCtx(req); + + const res = await daSourceGet({ req, env, daCtx }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(calls.ue, 1); + assert.strictEqual(calls.quickEdit, 0); + }); + it('returns the composed page as-is for a preview host', async () => { const daSourceGet = await mockDaSourceGet(); const req = authedReq('https://main--site--org.preview.da.live/folder/content'); @@ -181,6 +215,22 @@ describe('daSourceGet', () => { assert.strictEqual(calls.quickEdit, 0); }); + it('applies UE instrumentation when localhost matches UE_HOST', async () => { + const daSourceGet = await mockDaSourceGet(); + const req = authedReq('http://localhost:4712/org/site/folder/content'); + const daCtx = getDaCtx(req); + + const res = await daSourceGet({ + req, + env: { ...env, UE_HOST: 'localhost:4712' }, + daCtx, + }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(calls.ue, 1); + assert.strictEqual(calls.quickEdit, 0); + }); + it('applies quick-edit injection and sets the cookie when quick-edit is requested', async () => { const daSourceGet = await mockDaSourceGet(); const req = authedReq('https://main--site--org.ue.da.live/folder/content?quick-edit'); @@ -190,6 +240,7 @@ describe('daSourceGet', () => { assert.strictEqual(res.status, 200); assert.strictEqual(calls.quickEdit, 1); + assert.strictEqual(calls.quickEditNonce, 'abc123'); assert.strictEqual(calls.ue, 0); assert.ok(res.headers.get('Set-Cookie')?.includes('da-quick-edit=%2Fscripts%2Fscripts.js')); }); @@ -233,8 +284,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 +296,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 +310,101 @@ 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); + }); +}); + +// the suite above stubs csp.js, so it can only pin which value reached the injectors. this one +// runs the real compose / csp / ue modules: applyCsp mints the nonce and rewrites the head.html +// placeholders, and everything injected afterwards has to carry that same value. +describe('daSourceGet CSP nonce', () => { + const cspHead = '' + + ''; + + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + UE_HOST: 'ue.da.live', + daadmin: { + fetch: async () => new Response('

stored

', { status: 200 }), + }, + }; + + // composing reads the metadata sheet and the UE scaffold reads the component JSON, both off the + // preview host; a 404 is what each of them takes as "nothing to merge" + beforeEach(() => { + globalThis.fetch = async () => new Response('not found', { status: 404 }); + }); + + afterEach(() => { + delete globalThis.fetch; + }); + + const serve = async (url) => { + const { daSourceGet } = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/site.js': { + default: async () => ({ exists: true, head: cspHead, onSourceBus: false }), + }, + }); + const req = authedReq(url); + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + return res.text(); + }; + + const mintedNonce = (tree) => { + const meta = select('head meta[http-equiv="content-security-policy" i]', tree); + return /'nonce-([^']+)'/.exec(meta.properties.content)?.[1]; + }; + + it('stamps the minted nonce on the injected UE scripts', async () => { + const html = await serve('https://main--site--org.ue.da.live/folder/content'); + const tree = fromHtml(html); + const nonce = mintedNonce(tree); + + assert.ok(nonce); + assert.notStrictEqual(nonce, 'aem'); + assert.strictEqual( + select('head script[src="https://universal-editor-service.adobe.io/cors.js"]', tree) + .properties.nonce, + nonce, + ); + const componentScripts = selectAll('head script[src^="/component-"]', tree); + assert.strictEqual(componentScripts.length, 3); + componentScripts.forEach((script) => assert.strictEqual(script.properties.nonce, nonce)); + assert.ok(!html.includes('nonce="aem"')); + }); + + it('stamps the minted nonce on the injected quick-edit import map', async () => { + const html = await serve('https://main--site--org.ue.da.live/folder/content?quick-edit'); + const tree = fromHtml(html); + const nonce = mintedNonce(tree); + + assert.ok(nonce); + assert.notStrictEqual(nonce, 'aem'); + assert.strictEqual(select('head script[type="importmap"]', tree).properties.nonce, nonce); + assert.ok(!html.includes('nonce="aem"')); }); }); describe('source URLs', () => { - // 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 +539,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 +556,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 +568,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..86c97074 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 - 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'); + // `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 templateHtml = 'templateHtml' in overrides + ? overrides.templateHtml + : 'from the template'; + const site = 'site' in overrides ? overrides.site : LEGACY_STORE; + const { + lookupError, templateError, configError, composeError, serializeError, 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,91 @@ 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 preview host serves templates. composeHtml reads metadata.json off the same host, + // and it is stubbed below, so the suite never sees that one + getAEMHtml: async (aemCtx, path) => { + seen.aem.push(path); + if (path === '/head.html') return ''; + if (templateError) throw templateError; + return templateHtml; + }, }, '../../src/render/compose.js': { - composeHtml: async (daCtx, aemCtx, bodyHtml) => ({ bodyHtml }), - serializeHtml: (tree) => `${tree.bodyHtml}`, + // returns a hast root, so quick-edit can walk what was built + composeHtml: async (daCtx, aemCtx, bodyHtml, head) => { + seen.head.push(head); + if (composeError) throw composeError; + return { type: 'root', children: [], bodyHtml }; + }, + serializeHtml: (tree) => { + if (serializeError) throw serializeError; + return `${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', () => { +/** + * Builds the route with compose.js and metadata.js left real, so the /metadata.json read + * composeHtml makes is the one under test. The site is legacy, so its document comes over the + * service binding and `globalThis.fetch` sees nothing but the preview host. + */ +const buildComposing = async (overrides = {}) => { + const { + metadata = () => new Response('{"data":[]}', { status: 200 }), + legacy = () => new Response('
from da-admin
', { status: 200 }), + } = overrides; + const seen = { metadata: [] }; + globalThis.fetch = async (input, init) => { + seen.metadata.push({ url: String(input), init: init ?? {} }); + return metadata(); + }; + const env = { + DA_ADMIN: 'https://admin.da.live', + AEM_API: 'https://api.aem.live', + daadmin: { fetch: async () => legacy() }, + }; + const mod = await esmock('../../src/routes/da-admin.js', { + '../../src/storage/site.js': { + default: async () => ({ exists: true, head: '', onSourceBus: false }), + }, + }); + return { ...mod, env, seen }; +}; + +describe('when the lookup cannot say which store holds the site', () => { afterEach(() => { delete globalThis.fetch; }); + 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 +159,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 +167,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 +191,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 +201,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 +354,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 +365,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 +374,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 +398,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 +411,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 +423,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 +433,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 +452,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 +466,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 +475,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 +496,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 +538,53 @@ 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'); + }); + }); + + // the store answered, so the read is past the fetch; the body arrives after it, and a body + // that never finishes arriving is the same failed read + describe('when the store drops the connection mid-body', () => { + const truncated = () => new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('
')); + controller.error(new TypeError('Network connection lost')); + }, + }), + { status: 200 }, + ); + + it('answers 503 rather than throwing', async () => { + const { daSourceGet, env } = await build({ legacy: truncated }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + }); + + it('names the store in x-error', async () => { + const { daSourceGet, env } = await build({ legacy: truncated }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.match(res.headers.get('x-error'), /^content store failed/); }); }); @@ -398,7 +594,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 +609,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 +620,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 +640,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 +652,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 +665,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 +676,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 +695,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 +704,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 +731,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 +750,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 +770,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 +792,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 +890,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 +901,7 @@ describe('reading from the store that holds the site', () => { }); it('reads a video from the source bus on a source-bus site', async () => { - const { daSourceGet, env, seen } = await build({ onSourceBus: true }); + const { daSourceGet, env, seen } = await build({ site: SOURCE_BUS }); const req = authedReq('https://main--site--org.ue.da.live/folder/clip.mp4'); await daSourceGet({ req, env, daCtx: getDaCtx(req) }); @@ -712,19 +910,506 @@ 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 things that can fail', () => { + // answers the settled 404 ahead of the retryable 503 + it('reports no such site even when the store did not answer', async () => { const { daSourceGet, env } = await build({ - onSourceBus: true, - headHtml: undefined, + site: NO_SITE, bus: () => { throw new TypeError('fetch failed'); }, + legacy: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + }); + + 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); + }); + }); + + describe('where the page head comes from', () => { + [ + ['a UE host', 'https://main--site--org.ue.da.live/folder/content'], + ['a stage UE host', 'https://main--site--org.stage-ue.da.live/folder/content'], + ['a preview host', 'https://main--site--org.preview.da.live/folder/content'], + ['localhost', 'http://localhost:8787/folder/content'], + ].forEach(([hostType, url]) => { + it(`is the config service on ${hostType}`, async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq(url); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.head[0], ''); + assert.ok(!seen.aem.includes('/head.html')); + }); + }); + + // the pipeline scope answers existence and the store together + it('reads the config service once for a page', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.lookups, 1); + }); + + // nothing composes an image, and neither head is read for a non-html read + it('is not read for an asset', async () => { + const { daSourceGet, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/photo.png'); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.lookups, 1); + assert.deepStrictEqual(seen.head, []); + assert.ok(!seen.aem.includes('/head.html')); + }); + + it('is not read on a HEAD', async () => { + const { daSourceHead, env, seen } = await build(); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await daSourceHead({ env, daCtx: getDaCtx(req) }); + + assert.strictEqual(seen.lookups, 1); + assert.deepStrictEqual(seen.head, []); + assert.ok(!seen.aem.includes('/head.html')); + }); + }); + + // #258: head.html does not say whether a site exists, so a missing head.html is not a 404 + describe('when the site serves no head.html', () => { + it('reads the document anyway', async () => { + const { daSourceGet, env, seen } = await build({ headHtml: undefined }); + const 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('names the template failure cause in x-error', async () => { + const { daSourceGet, env } = await build({ + ...missingDoc(['/folder=/scripts/tpl.html']), + templateError: new DOMException('timed out', 'TimeoutError'), + }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.headers.get('x-error'), 'preview host failed: TimeoutError: timed out'); + }); + + it('takes the longest matching prefix', async () => { + const { daSourceGet, env, seen } = await build(missingDoc([ + '/=/scripts/site.html', + '/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(), /
/); + }); +}); + +// composing the page reads /metadata.json off the preview host, and that read is the last one on +// a GET that answered outside the taxonomy: a bodyless 500 where every other upstream says 503 +describe('the read the composed page makes of the preview host', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // a preview host rather than a UE host, so nothing is instrumented onto the composed page + const at = 'https://main--site--org.preview.da.live/folder/content'; + + it('composes the page when the preview host answers', async () => { + const { daSourceGet, env, seen } = await buildComposing(); + const req = authedReq(at); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(seen.metadata[0].url, 'https://main--site--org.aem.page/metadata.json'); + }); + + it('refuses with 503 when the preview host cannot be reached', async () => { + const { daSourceGet, env } = await buildComposing({ + metadata: () => { throw new TypeError('fetch failed'); }, + }); + const req = authedReq(at); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.match(res.headers.get('x-error'), /^preview host failed/); + assert.strictEqual(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); + }); + + // a preview host serving the 404 page as HTML answers 200, and the sheet read throws on it + it('refuses with 503 when metadata.json answers 200 with something other than json', async () => { + const { daSourceGet, env } = await buildComposing({ + metadata: () => new Response('not a sheet', { status: 200 }), + }); + const req = authedReq(at); + + const res = await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 503); + assert.match(res.headers.get('x-error'), /^preview host failed/); + assert.strictEqual(await res.text(), messages.PREVIEW_FAILED_HTML_MESSAGE); + }); + + // a preview host that accepts the connection and never answers would otherwise hold the page + // open for the whole request budget + it('gives the read a deadline without replacing the fetch init', async () => { + const { daSourceGet, env, seen } = await buildComposing(); + const req = authedReq(at); + + await daSourceGet({ req, env, daCtx: getDaCtx(req) }); + + assert.ok(seen.metadata[0].init.signal instanceof AbortSignal); + assert.ok(seen.metadata[0].init.headers instanceof Headers); + }); +}); + +describe('when the worker itself has a bug', () => { + afterEach(() => { + delete globalThis.fetch; + }); + + // serializing the composed tree reaches nothing, so a throw there is the worker's own. Only a + // failed upstream read is retryable, and a 503 would keep the throw out of the log the worker + // boundary writes + it('lets the throw through rather than rendering it as a 503', async () => { + const { daSourceGet, env } = await build({ serializeError: 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/, + ); + }); + + // composing reads the metadata sheet, but the rest of it walks the stored document, and a throw + // from that work is the worker's own however malformed the document was + it('lets a throw from composing through rather than blaming the preview host', async () => { + const { daSourceGet, env } = await build({ composeError: new TypeError('cannot read properties of undefined') }); + const req = authedReq('https://main--site--org.ue.da.live/folder/content'); + + await assert.rejects( + () => daSourceGet({ req, env, daCtx: getDaCtx(req) }), + /cannot read properties of undefined/, + ); }); }); diff --git a/test/routes/source-write.test.js b/test/routes/source-write.test.js index ec9ce54f..2a227dec 100644 --- a/test/routes/source-write.test.js +++ b/test/routes/source-write.test.js @@ -14,10 +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

'; +const FRAMESET = ''; + +// 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) => { @@ -27,13 +36,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 +75,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 +103,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 +111,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 +125,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 +134,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 +146,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 +289,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 +330,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,14 +338,66 @@ 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); + }); + }); + + // parse5 gives a frameset document no body element, and the rewrite has nothing to run on + describe('a document with no body', () => { + it('is refused with 415 rather than throwing', async () => { + const { daSourcePost, env, seen } = await build({}); + const req = uePost(AT, FRAMESET); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 415); assert.strictEqual(seen.bus.length + seen.legacy.length, 0); }); }); + + describe('the order a write refuses in', () => { + // the frameset is what tells the two orderings apart: parsed first it is the 415 above, + // looked up first it is the 404, and only one of them costs a parse and a rewrite + it('answers 404 for a site that does not exist rather than parsing the document', async () => { + const { daSourcePost, env, seen } = await build({ exists: false }); + const req = uePost(AT, FRAMESET); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 404); + assert.strictEqual(await res.text(), SITE_NOT_FOUND_MESSAGE); + assert.strictEqual(seen.bus.length + seen.legacy.length, 0); + }); + + // the extension and the part type are read off the request, so they still refuse a write + // before the site is looked up at all + it('refuses a non-html path ahead of a site that does not exist', async () => { + const { daSourcePost, env, seen } = await build({ exists: false }); + const req = uePost('https://main--site--org.ue.da.live/folder/data.json'); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 415); + assert.strictEqual(seen.probes, 0); + }); + + it('refuses a non-html part ahead of a site that does not exist', async () => { + const { daSourcePost, env, seen } = await build({ exists: false }); + const body = new FormData(); + body.set('data', new File([DOC], 'content.html', { type: 'application/json' })); + const req = new Request(AT, { method: 'POST', body, headers: { Authorization: 'Bearer t' } }); + + const res = await daSourcePost({ req, env, daCtx: getDaCtx(req) }); + + assert.strictEqual(res.status, 415); + assert.strictEqual(seen.probes, 0); + }); + }); }); 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..ff6f286e --- /dev/null +++ b/test/storage/site.test.js @@ -0,0 +1,272 @@ +/* + * 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 capturingErrors = async (run) => { + const errors = []; + const saved = console.error; + console.error = (m) => errors.push(m); + try { + await run(); + } finally { + console.error = saved; + } + return errors; +}; + +const config = (over = {}) => new Response(JSON.stringify({ + head: { html: HEAD }, + contentSource: { type: 'markup', url: 'https://content.da.live/org/site/' }, + ...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); + }); + }); + + // an unset token goes out as the string "undefined" and comes back a refusal, which reads + // exactly like an outage unless the worker says which of the two it is + describe('when the worker carries no config service token', () => { + it('says the worker is misconfigured, and asks anyway', async () => { + stubFetch(config); + + const errors = await capturingErrors( + () => getSiteConfig({ ...env, HLX_CONFIG_SERVICE_TOKEN: undefined }, daCtx()), + ); + + assert.strictEqual(errors.length, 1); + assert.match(errors[0], /HLX_CONFIG_SERVICE_TOKEN/); + assert.match(errors[0], /every site lookup/); + assert.strictEqual(calls.length, 1); + }); + + it('says nothing when the token is there', async () => { + stubFetch(config); + + const errors = await capturingErrors(() => getSiteConfig(env, daCtx())); + + assert.deepStrictEqual(errors, []); + }); + }); + + describe('when the config service refuses', () => { + [401, 403, 429, 500, 503].forEach((status) => { + it(`throws on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects(() => getSiteConfig(env, daCtx()), new RegExp(`${status}`)); + }); + }); + + // a rotated token and an upstream outage are the same status, so the message carries the + // one thing that separates them + [401, 403].forEach((status) => { + it(`names the token as present on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + + await assert.rejects( + () => getSiteConfig(env, daCtx()), + /HLX_CONFIG_SERVICE_TOKEN present/, + ); + }); + + it(`names the token as missing on a ${status}`, async () => { + stubFetch(() => new Response('', { status })); + const noToken = { ...env, HLX_CONFIG_SERVICE_TOKEN: undefined }; + + await capturingErrors(() => assert.rejects( + () => getSiteConfig(noToken, daCtx()), + /HLX_CONFIG_SERVICE_TOKEN missing/, + )); + }); + }); + + it('throws when the answer is not json', async () => { + stubFetch(() => new Response('', { status: 200 })); + + 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/ue/scaffold.test.js b/test/ue/scaffold.test.js index 2c95b91b..005231a3 100644 --- a/test/ue/scaffold.test.js +++ b/test/ue/scaffold.test.js @@ -111,6 +111,21 @@ describe('UE scaffold', () => { ); }); + it('stamps every injected script when a CSP nonce is provided', () => { + const entries = scaffold.getUEHtmlHeadEntries(daCtx, aemCtx, 'abc123'); + const scriptTags = entries.filter((entry) => entry.tagName === 'script'); + + assert.strictEqual(scriptTags.length, 4); + assert.ok(scriptTags.every((tag) => tag.properties.nonce === 'abc123')); + }); + + it('does not stamp injected scripts when no CSP nonce is provided', () => { + const entries = scaffold.getUEHtmlHeadEntries(daCtx, aemCtx); + const scriptTags = entries.filter((entry) => entry.tagName === 'script'); + + assert.ok(scriptTags.every((tag) => tag.properties.nonce === undefined)); + }); + it('generates correct head entries for local environment', () => { daCtx.isLocal = true; daCtx.hostname = 'localhost'; diff --git a/test/ue/ue.test.js b/test/ue/ue.test.js index 210eb4fe..7fc63dee 100644 --- a/test/ue/ue.test.js +++ b/test/ue/ue.test.js @@ -22,9 +22,13 @@ describe('applyUEInstrumentation', () => { it('adds UE head entries and UE body attributes to a composed tree', async () => { let ueConfigArg; let injectedBody; + let scaffoldNonce; const { applyUEInstrumentation } = await esmock('../../src/ue/ue.js', { '../../src/ue/scaffold.js': { - getUEHtmlHeadEntries: () => [h('meta', { name: 'urn:adobe:aue:system:ab', content: 'x' })], + getUEHtmlHeadEntries: (daCtx, aemCtx, nonce) => { + scaffoldNonce = nonce; + return [h('meta', { name: 'urn:adobe:aue:system:ab', content: 'x' })]; + }, getUEConfig: async () => { ueConfigArg = 'config'; return { ok: true }; @@ -36,7 +40,7 @@ describe('applyUEInstrumentation', () => { }); const tree = fromHtml('
content
'); - await applyUEInstrumentation(tree, { org: 'o', site: 's' }, {}); + await applyUEInstrumentation(tree, { org: 'o', site: 's' }, {}, 'abc123'); // UE head entry was pushed into the head node const head = select('head', tree); @@ -46,5 +50,6 @@ describe('applyUEInstrumentation', () => { // UE attributes were applied to the body node, using the fetched config assert.strictEqual(ueConfigArg, 'config'); assert.strictEqual(injectedBody, select('body', tree)); + assert.strictEqual(scaffoldNonce, 'abc123'); }); }); diff --git a/test/utils/aemCtx.test.js b/test/utils/aemCtx.test.js index 8b4432d2..eea36b23 100644 --- a/test/utils/aemCtx.test.js +++ b/test/utils/aemCtx.test.js @@ -19,6 +19,7 @@ import { getDaCtx } from '../../src/utils/daCtx.js'; describe('AEM context', () => { let getAemCtx; let getAEMHtml; + let withAemAuth; let fixUrlsWhenLocalDev; let aemCtx; @@ -26,6 +27,7 @@ describe('AEM context', () => { const mod = await esmock('../../src/utils/aemCtx.js'); getAemCtx = mod.getAemCtx; getAEMHtml = mod.getAEMHtml; + withAemAuth = mod.withAemAuth; fixUrlsWhenLocalDev = mod.fixUrlsWhenLocalDev; }); @@ -66,15 +68,38 @@ describe('AEM context', () => { }); }); + describe('withAemAuth', () => { + it('adds the site token without dropping existing request headers', () => { + const init = withAemAuth( + { siteToken: 'site-token' }, + { method: 'GET', headers: { Accept: 'text/html' } }, + ); + + assert.strictEqual(init.method, 'GET'); + assert.strictEqual(init.headers.get('Accept'), 'text/html'); + assert.strictEqual(init.headers.get('Authorization'), 'site-token'); + }); + + it('leaves authorization unset when there is no site token', () => { + const init = withAemAuth({}, { headers: { Accept: 'text/html' } }); + + assert.strictEqual(init.headers.get('Accept'), 'text/html'); + assert.strictEqual(init.headers.get('Authorization'), null); + }); + }); + describe('getAEMHtml function', () => { const mockAemCtx = { previewUrl: 'https://main--site--org.aem.page', }; + 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 +113,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/quick-edit.test.js b/test/utils/quick-edit.test.js index 74c72a5e..495539e6 100644 --- a/test/utils/quick-edit.test.js +++ b/test/utils/quick-edit.test.js @@ -115,22 +115,30 @@ describe('quick-edit script transform', () => { assert.ok(out.includes('"da-lit"')); }); - it('applies CSP nonce to all script tags', () => { + it('applies the CSP nonce only to an import map it creates', () => { const html = ''; const { html: out } = quickEdit.prepareQuickEditDocument(html, 'abc123'); - assert.ok(out.includes('nonce="abc123"')); + const tree = fromHtml(out); + assert.strictEqual(tree.children[0].children[0].children[0].properties.nonce, 'abc123'); + assert.strictEqual(tree.children[0].children[0].children[1].properties.nonce, undefined); + }); + + it('does not apply the CSP nonce to an existing author import map', () => { + const html = ''; + const { html: out } = quickEdit.prepareQuickEditDocument(html, 'abc123'); + assert.ok(!out.includes('nonce=')); }); }); describe('applyQuickEditToDocument', () => { - it('injects the import map, finds the entry script, and stamps the nonce', () => { + it('injects a nonced import map and finds the entry script', () => { const tree = fromHtml(''); const entryPath = quickEdit.applyQuickEditToDocument(tree, 'abc123'); const out = toHtml(tree, { allowDangerousHtml: true }); assert.strictEqual(entryPath, '/scripts/scripts.js'); - assert.ok(out.includes('