diff --git a/blocks/canvas/canvas.js b/blocks/canvas/canvas.js index 257cd1711..14a4f6542 100644 --- a/blocks/canvas/canvas.js +++ b/blocks/canvas/canvas.js @@ -15,7 +15,6 @@ import { removeSplitGutter, } from './ew-editor-split/ew-editor-split.js'; import { resolveEditorDocSession } from './ew-editor-doc/utils/load-editor-doc.js'; -import { sourceUrlFromEditorCtx } from './ew-editor-doc/utils/ctx.js'; import { SEL_BLOCK, SEL_ITEM, SEL_TEXT } from './ew-editor-doc/utils/selection.js'; import { getChatPanelContent } from '../shared/chat-panel.js'; import { canvasBus } from './utils/canvas-bus.js'; @@ -108,7 +107,7 @@ async function syncCanvasEditorsToHash({ mountRoot, header, state }) { return; } const ctx = editorCtxFromHashState(state, fullPath); - const session = await resolveEditorDocSession(sourceUrlFromEditorCtx(ctx)); + const session = await resolveEditorDocSession(ctx); if (loadCount !== editorLoadCount) return; if (!session.ok) { removeCanvasEditors(mountRoot); diff --git a/blocks/canvas/ew-editor-doc/ew-editor-doc.js b/blocks/canvas/ew-editor-doc/ew-editor-doc.js index 54da2e2ce..24f1b3e07 100644 --- a/blocks/canvas/ew-editor-doc/ew-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/ew-editor-doc.js @@ -5,7 +5,6 @@ import { updateDocument, updateCursors, getInstrumentedHTML, getEditor } from '. import { getActiveBlockIndex, getBlockPositions } from '../editor-utils/blocks.js'; import { editorDocCanLoad, - sourceUrlFromEditorCtx, controllerPathnameFromEditorCtx, editorDocRenderPhase, } from './utils/ctx.js'; @@ -24,6 +23,7 @@ import { afterNextPaint, ensureProseMountedInShadow } from './utils/shadow-mount import { teardownEditorDocResources } from './utils/teardown.js'; import { hideSelectionToolbar, setSelectionToolbarCtx } from '../editor-utils/selection-toolbar.js'; import { createExtensionsBridgePlugin } from '../editor-utils/extensions-bridge.js'; +import mediaBusImage from './prose-plugins/mediaBusImage.js'; import { MESSAGE_TYPES } from '../utils/quick-edit-messages.js'; import { canvasBus } from '../utils/canvas-bus.js'; @@ -265,13 +265,12 @@ export class EwEditorDoc extends LitElement { return; } - const sourceUrl = sourceUrlFromEditorCtx(this.ctx); - - const session = this.session ?? await resolveEditorDocSession(sourceUrl); + const session = this.session ?? await resolveEditorDocSession(this.ctx); if (!session.ok) { this._error = session.error; return; } + const { sourceUrl } = session; try { const { token, permissions } = session; @@ -282,6 +281,7 @@ export class EwEditorDoc extends LitElement { setEditable: (editable) => this._setEditable(editable), getToken: () => token, extraPlugins: [ + mediaBusImage(this.ctx), createExtensionsBridgePlugin(), createTrackingPlugin( () => { diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js index c21820ed8..7d84c8758 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js @@ -1,9 +1,7 @@ import { Plugin } from 'da-y-wrapper'; -import { getNx } from '../../../../scripts/utils.js'; -import { daFetch } from '../../../shared/utils.js'; +import { getNx2Api } from '../../../../scripts/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; - -const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); +import { dataUrlByteLength, refuseOversizedImage } from '../../utils/image-upload.js'; const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; @@ -14,6 +12,42 @@ function makeHash(string) { ), 0)); } +// the media bus is content addressed, so the src is only known from the response +function removeFpo(view, fpoSrc) { + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' && node.attrs.src === fpoSrc) { + view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize)); + return false; + } + return true; + }); +} + +export async function uploadBase64Image(view, { src, path, fpoSrc, parent }) { + if (await refuseOversizedImage(dataUrlByteLength(src), parent)) { + removeFpo(view, fpoSrc); + return; + } + const resp = await fetch(src); + const blob = await resp.blob(); + const { source } = await getNx2Api(); + const uploadResp = await source.uploadMedia(path, { body: blob }); + if (!uploadResp.ok) { + // eslint-disable-next-line no-console + console.error(`Failed to upload pasted image: ${uploadResp.status} ${uploadResp.statusText}`); + return; + } + const { source: { contentUrl } } = await uploadResp.json(); + + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' && node.attrs.src === fpoSrc) { + view.dispatch(view.state.tr.setNodeMarkup(pos, null, { ...node.attrs, src: contentUrl })); + return false; + } + return true; + }); +} + /** * @param {{ * getSourceUrl: () => string | null, @@ -35,40 +69,16 @@ export default function base64Uploader({ getSourceUrl, getEditorView }) { const details = getSourceUploadContext(getSourceUrl() ?? ''); if (!details) return html; - const imagePaths = []; - const uploadPromises = []; - dataImgs.forEach((img) => { const src = img.getAttribute('src'); let ext = src.replace('data:image/', '').split(';base64')[0]; if (ext === 'jpeg') ext = 'jpg'; const path = `${details.parent}/.${details.name}/wp${makeHash(src)}.${ext}`; - const fpoSrc = `${FPO_IMG_URL}#${DA_CONTENT}${path}`; + const fpoSrc = `${FPO_IMG_URL}#${makeHash(src)}`; img.setAttribute('src', fpoSrc); - imagePaths.push(fpoSrc); - - uploadPromises.push((async () => { - const resp = await fetch(src); - const blob = await resp.blob(); - const body = new FormData(); - body.append('data', blob); - await daFetch(`${DA_ADMIN}/source${path}`, { body, method: 'POST' }); - })()); - }); - Promise.all(uploadPromises).then(() => { const view = getEditorView(); - if (!view) return; - const { tr } = view.state; - - view.state.doc.descendants((node, pos) => { - if (node.type.name === 'image' && imagePaths.includes(node.attrs.src)) { - const newAttrs = { src: node.attrs.src.split('#')[1] }; - tr.setNodeMarkup(pos, null, { ...node.attrs, ...newAttrs }); - } - }); - - view.dispatch(tr); + if (view) uploadBase64Image(view, { src, path, fpoSrc, parent: details.parent }); }); const serializer = new XMLSerializer(); diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js index 49602a954..d63a81734 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js @@ -1,34 +1,44 @@ import { Plugin, TextSelection } from 'da-y-wrapper'; -import { daFetch } from '../../../shared/utils.js'; +import { getNx2Api } from '../../../../scripts/utils.js'; import { getSourceUploadContext } from './sourceUploadContext.js'; +import { refuseOversizedImage } from '../../utils/image-upload.js'; const FPO_IMG_URL = '/blocks/edit/img/fpo.svg'; export const SUPPORTED_IMAGE_FILES = ['image/svg+xml', 'image/png', 'image/jpeg', 'image/gif']; export async function uploadImageFile(view, file, details) { if (!SUPPORTED_IMAGE_FILES.some((type) => type === file.type)) return; + if (await refuseOversizedImage(file.size, details.parent)) return; const { schema } = view.state; const fpo = schema.nodes.image.create({ src: FPO_IMG_URL, style: 'width: 180px' }); view.dispatch(view.state.tr.replaceSelectionWith(fpo).scrollIntoView()); const { $from } = view.state.selection; - const url = `${details.origin}/source${details.parent}/.${details.name}/${file.name}`; + const path = `${details.parent}/.${details.name}/${file.name}`; - const formData = new FormData(); - formData.append('data', file); - const resp = await daFetch(url, { method: 'PUT', body: formData }); + // the media bus is content addressed, so the src is only known from the response + const { source } = await getNx2Api(); + const resp = await source.uploadMedia(path, { body: file }); if (!resp.ok) return; - const json = await resp.json(); + const { source: { contentUrl } } = await resp.json(); - const docImg = document.createElement('img'); - docImg.addEventListener('load', () => { + const replaceFpo = () => { const fpoSelection = TextSelection.create(view.state.doc, $from.pos - 1, $from.pos); const ts = view.state.tr.setSelection(fpoSelection); - const img = schema.nodes.image.create({ src: json.source.contentUrl }); + const img = schema.nodes.image.create({ src: contentUrl }); view.dispatch(ts.replaceSelectionWith(img).scrollIntoView()); - }); - docImg.src = json.source.contentUrl; + }; + + // a media bus src is relative to the published page and cannot load from here + if (contentUrl.startsWith('./media_')) { + replaceFpo(); + return; + } + + const docImg = document.createElement('img'); + docImg.addEventListener('load', replaceFpo); + docImg.src = contentUrl; } /** diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js b/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js new file mode 100644 index 000000000..f83c32092 --- /dev/null +++ b/blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js @@ -0,0 +1,36 @@ +// eslint-disable-next-line import/no-unresolved +import { Plugin, PluginKey } from 'da-y-wrapper'; +import { getPreviewOrigin } from '../../editor-utils/editor-utils.js'; + +const mediaBusImageKey = new PluginKey('canvasMediaBusImage'); + +// a media bus src ("./media_123.png") only resolves where the page is served, so it is rewritten +// to the doc's preview origin for display; node attrs and the saved document keep the relative +// path. getPreviewOrigin matches the origin fetchWysiwygCookie logs into, unlike getLivePreviewUrl. +export function getRenderableSrc(src, ctx) { + if (!src || !src.startsWith('./media_')) return null; + const { org, repo } = ctx ?? {}; + if (!org || !repo) return null; + return `${getPreviewOrigin(org, repo)}/${src.slice(2)}`; +} + +function updateImageSrcs(view, ctx) { + view.dom.querySelectorAll('img[src^="./media_"]').forEach((img) => { + const renderableSrc = getRenderableSrc(img.getAttribute('src'), ctx); + if (renderableSrc) img.src = renderableSrc; + }); +} + +export default function mediaBusImage(ctx) { + return new Plugin({ + key: mediaBusImageKey, + view(view) { + updateImageSrcs(view, ctx); + return { + update(updatedView, prevState) { + if (updatedView.state.doc !== prevState.doc) updateImageSrcs(updatedView, ctx); + }, + }; + }, + }); +} diff --git a/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js index 085a1dc23..e6e0564b1 100644 --- a/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js +++ b/blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js @@ -1,30 +1,31 @@ /* * Copyright 2026 Adobe. All rights reserved. - * Derives upload parent/name from a DA source document URL (same shape as da.live getPathDetails). + * Derives the upload parent and name from a source document URL, in either store's shape. */ -import { getNx } from '../../../../scripts/utils.js'; -const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); +// da-admin: https://admin.da.live/source/{org}/{site}/dir/doc.html +// source bus: https://api.aem.live/{org}/sites/{site}/source/dir/doc.html +function orgSiteAndRest(pathname) { + const segments = pathname.split('/').filter(Boolean); + if (segments[0] === 'source') return segments.slice(1); + if (segments[1] === 'sites' && segments[3] === 'source') { + return [segments[0], segments[2], ...segments.slice(4)]; + } + return null; +} /** - * @param {string} sourceUrl - e.g. https://admin.da.live/source/org/repo/path/doc.html - * @returns {{ origin: string, parent: string, name: string } | null} + * @param {string} sourceUrl a document url on either store + * @returns {{ parent: string, name: string } | null} the parent in the `/org/site/dir` form the + * source api takes, and the document name without its extension */ export function getSourceUploadContext(sourceUrl) { if (!sourceUrl || typeof sourceUrl !== 'string') return null; try { - const u = new URL(sourceUrl); - const mark = '/source/'; - const idx = u.pathname.indexOf(mark); - if (idx === -1) return null; - const rest = u.pathname.slice(idx + mark.length); - const segments = rest.split('/').filter(Boolean); - if (segments.length === 0) return null; - const lastSeg = segments[segments.length - 1]; - const name = lastSeg.replace(/\.html?$/i, ''); - const parentSegments = segments.slice(0, -1); - const parent = parentSegments.length ? `/${parentSegments.join('/')}` : '/'; - return { origin: DA_ADMIN, parent, name }; + const segments = orgSiteAndRest(new URL(sourceUrl).pathname); + if (!segments || segments.length < 3) return null; + const name = segments[segments.length - 1].replace(/\.html?$/i, ''); + return { parent: `/${segments.slice(0, -1).join('/')}`, name }; } catch { return null; } diff --git a/blocks/canvas/ew-editor-doc/prose.js b/blocks/canvas/ew-editor-doc/prose.js index b99e01d07..4a3cdfc63 100644 --- a/blocks/canvas/ew-editor-doc/prose.js +++ b/blocks/canvas/ew-editor-doc/prose.js @@ -43,7 +43,7 @@ import { generateColor, getCollabIdentity } from './utils/collab.js'; import { checkBlockLibraryConfigured } from '../editor-utils/block-slash.js'; import { canvasBus } from '../utils/canvas-bus.js'; -const { DA_ADMIN, DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`); +const { DA_COLLAB, hashChange } = await import(`${getNx()}/utils/utils.js`); function registerErrorHandler(ydoc) { ydoc.on('update', () => { @@ -94,7 +94,8 @@ export default async function initProse({ const ydoc = new Y.Doc(); const server = DA_COLLAB; - const roomName = `${DA_ADMIN}${new URL(path).pathname}`; + // da-collab reads the store off the room name, and `path` is already the store's source url. + const roomName = path; const wsOpts = { protocols: ['yjs'] }; let lastSentToken = null; diff --git a/blocks/canvas/ew-editor-doc/utils/ctx.js b/blocks/canvas/ew-editor-doc/utils/ctx.js index 975da88e4..0fb7d773c 100644 --- a/blocks/canvas/ew-editor-doc/utils/ctx.js +++ b/blocks/canvas/ew-editor-doc/utils/ctx.js @@ -1,16 +1,13 @@ -import { buildSourceUrl } from './source.js'; - -export function sourceUrlFromEditorCtx(ctx) { - return buildSourceUrl(ctx?.path); -} +import { normalizeSourcePath } from './source.js'; export function editorCtxHasOrgRepoPath(ctx) { const { org, repo, path } = ctx ?? {}; return Boolean(org && repo && path); } +// Stays synchronous: the render phase needs an answer without waiting on the store lookup. export function editorDocCanLoad(ctx) { - return editorCtxHasOrgRepoPath(ctx) && Boolean(sourceUrlFromEditorCtx(ctx)); + return editorCtxHasOrgRepoPath(ctx) && Boolean(normalizeSourcePath(ctx.path)); } export function controllerPathnameFromEditorCtx(ctx) { diff --git a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js index 19ade38ca..4226f15a8 100644 --- a/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js +++ b/blocks/canvas/ew-editor-doc/utils/load-editor-doc.js @@ -1,19 +1,37 @@ -import { checkDoc } from './source.js'; +import { buildSourceUrl, checkDoc } from './source.js'; import { initIms } from '../../../shared/utils.js'; -export async function resolveEditorDocSession(sourceUrl) { +export function sessionErrorFromResponse(resp) { + const status = resp?.status; + if (typeof status !== 'number') return { ok: false, error: 'Could not reach the content store' }; + if (resp.ok || status === 404) return null; + if (status === 401) return { ok: false, error: 'Sign in required' }; + if (status === 403) return { ok: false, error: 'Not permitted' }; + const detail = resp.headers?.get?.('x-error'); + const reason = detail ? `: ${detail}` : ''; + return { ok: false, error: `Could not load the document (${status})${reason}` }; +} + +// takes the ctx rather than a url, so the sign-in check runs before the store lookup needs a token +export async function resolveEditorDocSession(ctx) { const ims = await initIms(); const token = ims?.accessToken?.token ?? null; if (ims?.anonymous || !token) { return { ok: false, error: 'Sign in required' }; } - const resp = await checkDoc(sourceUrl); - if (!resp.ok && resp.status !== 404) { - const error = resp.status === 401 ? 'Sign in required' : 'Not permitted'; - return { ok: false, error }; + let sourceUrl; + try { + sourceUrl = await buildSourceUrl(ctx?.path); + } catch { + return { ok: false, error: 'Could not reach the content store' }; } + if (!sourceUrl) return { ok: false, error: 'Could not reach the content store' }; + + const resp = await checkDoc(sourceUrl); + const failure = sessionErrorFromResponse(resp); + if (failure) return failure; const permissions = resp.permissions || ['read']; - return { ok: true, token, permissions }; + return { ok: true, token, permissions, sourceUrl }; } diff --git a/blocks/canvas/ew-editor-doc/utils/source.js b/blocks/canvas/ew-editor-doc/utils/source.js index b22ccec4b..b22d12634 100644 --- a/blocks/canvas/ew-editor-doc/utils/source.js +++ b/blocks/canvas/ew-editor-doc/utils/source.js @@ -1,23 +1,29 @@ -import { getNx } from '../../../../scripts/utils.js'; +import { getNx, getNx2Api } from '../../../../scripts/utils.js'; import { daFetch } from '../../../shared/utils.js'; const { DA_ADMIN } = await import(`${getNx()}/utils/utils.js`); -export function buildSourceUrl(path) { +export function normalizeSourcePath(path) { if (!path || typeof path !== 'string') return null; const trimmed = path.replace(/^\//, '').trim(); - if (!trimmed) return null; - return `${DA_ADMIN}/source/${trimmed}.html`; + return trimmed || null; } -export function parsePermissions(resp) { - const hint = resp.headers.get('x-da-child-actions') ?? resp.headers.get('x-da-actions'); - if (hint) resp.permissions = hint.split('=').pop().split(','); - else resp.permissions = ['read', 'write']; - return resp; +export async function buildSourceUrl(path) { + const trimmed = normalizeSourcePath(path); + if (!trimmed) return null; + const [org, site, ...parts] = trimmed.split('/'); + const { AEM_API, isHlx6 } = await getNx2Api(); + if (org && site && parts.length && await isHlx6(org, site)) { + return `${AEM_API}/${org}/sites/${site}/source/${parts.join('/')}.html`; + } + return `${DA_ADMIN}/source/${trimmed}.html`; } +// da-admin keeps da-live's own fetcher, which reads the token live and retries once on a 401. +// nx2's is the one that allowlists api.aem.live for the bearer. export async function checkDoc(sourceUrl) { - const resp = await daFetch(sourceUrl, { method: 'HEAD' }); - return parsePermissions(resp); + if (sourceUrl.startsWith(DA_ADMIN)) return daFetch(sourceUrl, { method: 'HEAD' }); + const { daFetch: nx2Fetch } = await getNx2Api(); + return nx2Fetch({ url: sourceUrl, opts: { method: 'HEAD' } }); } diff --git a/blocks/canvas/ew-editor-wysiwyg/utils/image.js b/blocks/canvas/ew-editor-wysiwyg/utils/image.js index 770a2408c..2e07649c5 100644 --- a/blocks/canvas/ew-editor-wysiwyg/utils/image.js +++ b/blocks/canvas/ew-editor-wysiwyg/utils/image.js @@ -1,7 +1,6 @@ -import { getNx } from '../../../../scripts/utils.js'; +import { getNx2Api } from '../../../../scripts/utils.js'; import { MESSAGE_TYPES } from '../../utils/quick-edit-messages.js'; - -const { DA_ADMIN, DA_CONTENT } = await import(`${getNx()}/utils/utils.js`); +import { dataUrlByteLength, refuseOversizedImage } from '../../utils/image-upload.js'; function updateImageInDocument(view, originalSrc, newSrc) { if (!view) return false; @@ -62,33 +61,25 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c ctx.suppressRerender = true; try { - // eslint-disable-next-line no-console - console.log('handleImageReplace', fileName, originalSrc); + const sitePath = `/${ctx.owner}/${ctx.repo}`; + if (await refuseOversizedImage(dataUrlByteLength(imageData), sitePath)) { + ctx.port.postMessage({ + type: MESSAGE_TYPES.IMAGE_REPLACE, + payload: { error: 'Image is too large', originalSrc }, + }); + return; + } const blob = dataUrlToBlob(imageData); const pageName = getPageName(ctx.path); const parentPath = ctx.path === '/' ? '' : ctx.path.replace(/\/[^/]+$/, ''); - // Same upload path and URL as da-nx quick-edit-portal/src/images.js - const uploadPath = `${parentPath}/.${pageName}/${fileName}`; - const uploadUrl = `${DA_ADMIN}/source/${ctx.owner}/${ctx.repo}${uploadPath}`; - - const tokenPromise = typeof ctx.getToken === 'function' ? ctx.getToken() : null; - const token = tokenPromise != null && typeof tokenPromise?.then === 'function' - ? await tokenPromise - : tokenPromise; - const headers = {}; - if (token) headers.Authorization = `Bearer ${token}`; + // Same upload path as da-nx quick-edit-portal/src/images.js + const uploadPath = `/${ctx.owner}/${ctx.repo}${parentPath}/.${pageName}/${fileName}`; - const formData = new FormData(); - formData.append('data', blob, fileName); - - const resp = await fetch(uploadUrl, { - method: 'PUT', - body: formData, - headers, - }); + const { source } = await getNx2Api(); + const resp = await source.uploadMedia(uploadPath, { body: blob }); if (!resp.ok) { const error = `Upload failed with status ${resp.status}`; @@ -99,8 +90,8 @@ export async function handleImageReplace({ imageData, fileName, originalSrc }, c return; } - // Same as da-nx: AEM delivery URL for the uploaded image - const newSrc = `${DA_CONTENT}/${ctx.owner}/${ctx.repo}${uploadPath}`; + // the media bus is content addressed, so the src is only known from the response + const { source: { contentUrl: newSrc } } = await resp.json(); updateImageInDocument(ctx.view, originalSrc, newSrc); diff --git a/blocks/canvas/ew-panel-extensions/helpers.js b/blocks/canvas/ew-panel-extensions/helpers.js index b013af80f..8b009b8cf 100644 --- a/blocks/canvas/ew-panel-extensions/helpers.js +++ b/blocks/canvas/ew-panel-extensions/helpers.js @@ -1,7 +1,7 @@ /* eslint-disable import/no-unresolved -- importmap */ import { DOMParser as PMDOMParser, DOMSerializer, Slice, TextSelection } from 'da-y-wrapper'; -import { getNx } from '../../../scripts/utils.js'; -import { aemAdmin, daFetch } from '../../shared/utils.js'; +import { getNx, getNx2Api } from '../../../scripts/utils.js'; +import { daFetch } from '../../shared/utils.js'; import { htmlToProse } from '../../edit/utils/helpers.js'; import { getExtensionsBridge } from '../editor-utils/extensions-bridge.js'; @@ -394,8 +394,10 @@ export async function insertTemplate(view, url) { export async function getPreviewStatus({ org, site, pathname }) { const path = `/${org}/${site}${pathname}`; try { - const json = await aemAdmin(path, 'status', 'GET'); - if (!json) return null; + const { status } = await getNx2Api(); + const resp = await status.get(path); + if (!resp.ok) return null; + const json = await resp.json(); return json.preview?.status === 200; } catch { return null; diff --git a/blocks/canvas/utils/image-upload.js b/blocks/canvas/utils/image-upload.js new file mode 100644 index 000000000..2786957cc --- /dev/null +++ b/blocks/canvas/utils/image-upload.js @@ -0,0 +1,39 @@ +import { getNx2, getNx2Api } from '../../../scripts/utils.js'; + +// The api service runs on Lambda, which caps a request at 6 MiB once base64 has +// inflated the body. Measured 2026-08-18, the AWS edge answers 413 from 4,717,360 +// bytes up, and that 413 carries no CORS header, so the browser reads it as a +// network failure with no status. The size is checked before the request instead. +// da-admin took a 120 MB body in the same probe, so the cap is the source bus's. +export const MAX_IMAGE_BYTES = 4500000; +const MAX_IMAGE_LABEL = '4.5 MB'; + +export function isImageTooLarge(bytes) { + return bytes > MAX_IMAGE_BYTES; +} + +export function dataUrlByteLength(dataUrl) { + const base64 = dataUrl?.split(';base64,')[1]; + if (!base64) return 0; + const padding = (base64.endsWith('==') && 2) || (base64.endsWith('=') && 1) || 0; + return Math.floor(base64.length / 4) * 3 - padding; +} + +export async function showImageTooLarge() { + const { showToast, VARIANT_ERROR } = await import(`${getNx2()}/blocks/shared/toast/toast.js`); + showToast({ + text: `Image upload failed. Image size must be ${MAX_IMAGE_LABEL} or under`, + variant: VARIANT_ERROR, + }); +} + +// `parentPath` is the document's folder, `/org/site/dir`. +export async function refuseOversizedImage(bytes, parentPath) { + if (!isImageTooLarge(bytes)) return false; + const [, org, site] = (parentPath ?? '').split('/'); + if (!org || !site) return false; + const { isHlx6 } = await getNx2Api(); + if (!await isHlx6(org, site)) return false; + await showImageTooLarge(); + return true; +} diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index fc6606c52..c183d7538 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -342,8 +342,12 @@ export async function checkLockdownImages(owner) { export const fetchDaConfigs = (() => { const configCache = {}; - const fetchConfig = async (pathname) => { - const resp = await daFetch(`${DA_ORIGIN}/config${pathname}/`); + // getNx2Api's config.get is isHlx6-aware: it reads from api.aem.live for a + // migrated org/site, and falls back to admin.da.live otherwise. + const fetchConfig = async (org, site) => { + const { config: configApi } = await getNx2Api(); + const resp = await configApi.get({ org, site }); + const pathname = site ? `/${org}/${site}` : `/${org}`; if (!resp.ok) return { error: `Error loading ${pathname}`, status: resp.status }; return resp.json(); }; @@ -352,11 +356,11 @@ export const fetchDaConfigs = (() => { if (!org) return [Promise.resolve(null)]; // Set the org config promise if it does not exist - configCache[`/${org}`] ??= fetchConfig(`/${org}`); + configCache[`/${org}`] ??= fetchConfig(org); if (site) { // Set the site config promise if it does not exist - configCache[`/${org}/${site}`] ??= fetchConfig(`/${org}/${site}`); + configCache[`/${org}/${site}`] ??= fetchConfig(org, site); } // return array of cached configs (org = 0, site = 1) diff --git a/test/fixtures/nx/utils/utils.js b/test/fixtures/nx/utils/utils.js index b771487b9..b084967ed 100644 --- a/test/fixtures/nx/utils/utils.js +++ b/test/fixtures/nx/utils/utils.js @@ -8,6 +8,8 @@ export const loadScript = async () => {}; export const DA_ADMIN = 'https://admin.da.live'; +export const DA_COLLAB = 'wss://collab.da.live'; + let _hashState = {}; const _hashSubscribers = new Set(); export const hashChange = { diff --git a/test/fixtures/nx2/blocks/shared/toast/toast.js b/test/fixtures/nx2/blocks/shared/toast/toast.js new file mode 100644 index 000000000..f24a97ad6 --- /dev/null +++ b/test/fixtures/nx2/blocks/shared/toast/toast.js @@ -0,0 +1,14 @@ +// Test fixture mirroring nx2/blocks/shared/toast/toast.js. +// The array is parked on window because the import-maps plugin can load this +// module twice, once per specifier shape, and both copies have to record here. +window.daTestToasts ??= []; + +export const VARIANT_SUCCESS = 'success'; +export const VARIANT_ERROR = 'error'; +export const VARIANT_WARNING = 'warning'; + +export const toasts = window.daTestToasts; + +export function showToast(opts) { + window.daTestToasts.push(opts); +} diff --git a/test/unit/blocks/browse/da-browse/da-browse.test.js b/test/unit/blocks/browse/da-browse/da-browse.test.js index 9d01eb4f9..b1076cd49 100644 --- a/test/unit/blocks/browse/da-browse/da-browse.test.js +++ b/test/unit/blocks/browse/da-browse/da-browse.test.js @@ -347,10 +347,12 @@ describe('DaBrowse Component', () => { describe('getEditor', () => { function mockConfig(rows) { - window.fetch = async () => ({ - ok: true, - json: async () => ({ data: rows }), - }); + // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that + // with a real Response (so its headers.get() call is safe) and defer everything else. + window.fetch = async (url) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return { ok: true, json: async () => ({ data: rows }) }; + }; } let origFetch; diff --git a/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js b/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js new file mode 100644 index 000000000..000ac9235 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/load-editor-doc-status.test.js @@ -0,0 +1,67 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let sessionErrorFromResponse; + +before(async () => { + ({ sessionErrorFromResponse } = await import('../../../../../blocks/canvas/ew-editor-doc/utils/load-editor-doc.js')); +}); + +const respond = (status, { headers = {}, ok } = {}) => ({ + status, + ok: ok ?? (status >= 200 && status < 300), + headers: new Headers(headers), +}); + +describe('sessionErrorFromResponse', () => { + it('lets a document that loaded through', () => { + expect(sessionErrorFromResponse(respond(200))).to.equal(null); + }); + + it('lets a missing document through, so it can be created', () => { + expect(sessionErrorFromResponse(respond(404))).to.equal(null); + }); + + it('asks for a sign-in on 401', () => { + expect(sessionErrorFromResponse(respond(401))).to.deep.equal({ + ok: false, + error: 'Sign in required', + }); + }); + + it('reports a refusal on 403', () => { + expect(sessionErrorFromResponse(respond(403))).to.deep.equal({ + ok: false, + error: 'Not permitted', + }); + }); + + it('names the status instead of blaming permissions on 503', () => { + expect(sessionErrorFromResponse(respond(503))).to.deep.equal({ + ok: false, + error: 'Could not load the document (503)', + }); + }); + + it('adds the reason the store gave', () => { + const resp = respond(500, { headers: { 'x-error': 'source lookup failed' } }); + expect(sessionErrorFromResponse(resp)).to.deep.equal({ + ok: false, + error: 'Could not load the document (500): source lookup failed', + }); + }); + + it('says so when the store was never reached', () => { + // nx2 daFetch answers {} when it has no token to send, which carries no status. + expect(sessionErrorFromResponse({})).to.deep.equal({ + ok: false, + error: 'Could not reach the content store', + }); + expect(sessionErrorFromResponse(null)).to.deep.equal({ + ok: false, + error: 'Could not reach the content store', + }); + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js new file mode 100644 index 000000000..802570feb --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/base64-upload.test.js @@ -0,0 +1,144 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; +import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let base64Uploader; +let uploadBase64Image; +let MAX_IMAGE_BYTES; +let toasts; + +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +// the paste handler is synchronous and leaves the upload running behind it +async function until(done, tries = 50) { + for (let i = 0; i < tries; i += 1) { + if (done()) return; + // eslint-disable-next-line no-await-in-loop + await nextFrame(); + } +} + +before(async () => { + ({ default: base64Uploader, uploadBase64Image } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/base64Uploader.js')); + ({ MAX_IMAGE_BYTES } = await import('../../../../../../blocks/canvas/utils/image-upload.js')); + ({ toasts } = await import('../../../../../fixtures/nx2/blocks/shared/toast/toast.js')); +}); + +// isHlx6 memoizes its answer per site, so each case needs its own org/site +function stubStore({ upgraded }) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + const href = typeof url === 'string' ? url : url.url; + calls.push({ url: href, opts }); + if (href.includes('/ping/')) { + return new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + } + if (href.startsWith('data:')) return new Response(new Blob([new Uint8Array([1])])); + return new Response(JSON.stringify({ source: { contentUrl: './media_abc.png' } }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('base64Uploader', () => { + let editor; + + const oversized = () => `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`; + + const insertFpo = (fpoSrc) => { + const { schema } = editor.view.state; + const fpo = schema.nodes.image.create({ src: fpoSrc }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(fpo)); + }; + + const imageSrcs = () => { + const srcs = []; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') srcs.push(node.attrs.src); + }); + return srcs; + }; + + beforeEach(async () => { + editor = await createTestEditor(); + await nextFrame(); + toasts.length = 0; + }); + + afterEach(() => { + destroyEditor(editor); + }); + + it('swaps a pasted data url for the fpo and uploads it', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + try { + const plugin = base64Uploader({ + getSourceUrl: () => 'https://api.aem.live/pasteok/sites/pasteok/source/doc.html', + getEditorView: () => editor.view, + }); + const html = plugin.props.transformPastedHTML('

'); + await until(() => calls.some((c) => c.opts?.method === 'POST')); + + expect(html).to.contain('/blocks/edit/img/fpo.svg'); + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1); + expect(toasts).to.have.length(0); + } finally { + restore(); + } + }); + + it('drops the fpo when the pasted image is over the upload limit', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + const fpoSrc = '/blocks/edit/img/fpo.svg#1'; + try { + insertFpo(fpoSrc); + expect(imageSrcs()).to.deep.equal([fpoSrc]); + + await uploadBase64Image(editor.view, { + src: oversized(), + path: '/pastebig/pastebig/.doc/wp1.png', + fpoSrc, + parent: '/pastebig/pastebig', + }); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0); + expect(imageSrcs(), 'the fpo was left in the document').to.deep.equal([]); + expect(toasts).to.have.length(1); + expect(toasts[0].text).to.contain('Image upload failed'); + expect(toasts[0].text).to.contain('4.5 MB or under'); + } finally { + restore(); + } + }); + + it('takes an oversized image on a legacy site, where the limit does not apply', async () => { + const { calls, restore } = stubStore({ upgraded: false }); + const fpoSrc = '/blocks/edit/img/fpo.svg#2'; + try { + insertFpo(fpoSrc); + await uploadBase64Image(editor.view, { + src: oversized(), + path: '/pasteleg/pasteleg/.doc/wp2.png', + fpoSrc, + parent: '/pasteleg/pasteleg', + }); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1); + expect(toasts).to.have.length(0); + } finally { + restore(); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js new file mode 100644 index 000000000..2c28438bf --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/media-bus-image.test.js @@ -0,0 +1,73 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; +import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let getRenderableSrc; +let mediaBusImage; + +const ctx = { org: 'org', repo: 'repo', path: '/org/repo/page' }; +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +before(async () => { + ({ default: mediaBusImage, getRenderableSrc } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/mediaBusImage.js')); +}); + +describe('canvas getRenderableSrc', () => { + // the same origin the canvas fetches its preview cookie from, which is stage off da.live + it('rewrites a relative media src to the origin the canvas logged into', () => { + expect(getRenderableSrc('./media_123.png', ctx)).to.equal( + 'https://main--repo--org.stage-preview.da.live/media_123.png', + ); + }); + + it('answers null for a src that is not on the media bus', () => { + expect(getRenderableSrc('https://example.com/foo.png', ctx)).to.equal(null); + expect(getRenderableSrc('/media_123.png', ctx)).to.equal(null); + expect(getRenderableSrc(null, ctx)).to.equal(null); + }); + + it('answers null when the ctx names no site', () => { + expect(getRenderableSrc('./media_123.png', { org: 'org' })).to.equal(null); + expect(getRenderableSrc('./media_123.png', null)).to.equal(null); + }); +}); + +describe('canvas mediaBusImage plugin', () => { + let editor; + + beforeEach(async () => { + editor = await createTestEditor({ additionalPlugins: [mediaBusImage(ctx)] }); + await nextFrame(); + }); + + afterEach(() => { + destroyEditor(editor); + }); + + it('renders a media bus image from the preview origin, and stores the relative src', async () => { + const { schema } = editor.view.state; + const image = schema.nodes.image.create({ src: './media_123.png' }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image)); + await nextFrame(); + + const img = editor.view.dom.querySelector('img'); + expect(img.src).to.equal('https://main--repo--org.stage-preview.da.live/media_123.png'); + + let storedSrc; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') storedSrc = node.attrs.src; + }); + expect(storedSrc).to.equal('./media_123.png'); + }); + + it('leaves an absolute image src alone', async () => { + const { schema } = editor.view.state; + const image = schema.nodes.image.create({ src: 'https://example.com/pic.png' }); + editor.view.dispatch(editor.view.state.tr.replaceSelectionWith(image)); + await nextFrame(); + + expect(editor.view.dom.querySelector('img').src).to.equal('https://example.com/pic.png'); + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js new file mode 100644 index 000000000..c1e84c07f --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-plugins/upload.test.js @@ -0,0 +1,188 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; +import { createTestEditor, destroyEditor } from '../../../edit/prose/test-helpers.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let getSourceUploadContext; +let uploadImageFile; +let MAX_IMAGE_BYTES; +let toasts; + +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +before(async () => { + ({ getSourceUploadContext } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/sourceUploadContext.js')); + ({ uploadImageFile } = await import('../../../../../../blocks/canvas/ew-editor-doc/prose-plugins/imageDrop.js')); + ({ MAX_IMAGE_BYTES } = await import('../../../../../../blocks/canvas/utils/image-upload.js')); + ({ toasts } = await import('../../../../../fixtures/nx2/blocks/shared/toast/toast.js')); +}); + +// isHlx6 memoizes its answer per site, so each case needs its own org/site +function stubStore({ upgraded, contentUrl = 'https://content.da.live/org/site/.doc/pic.png' }) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + const href = typeof url === 'string' ? url : url.url; + calls.push({ url: href, opts }); + if (href.includes('/ping/')) { + return new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + } + return new Response(JSON.stringify({ source: { contentUrl } }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('getSourceUploadContext', () => { + it('reads a da-admin source url', () => { + const ctx = getSourceUploadContext('https://admin.da.live/source/org/site/dir/doc.html'); + expect(ctx).to.deep.equal({ parent: '/org/site/dir', name: 'doc' }); + }); + + it('reads a source-bus source url', () => { + const ctx = getSourceUploadContext('https://api.aem.live/org/sites/site/source/dir/doc.html'); + expect(ctx).to.deep.equal({ parent: '/org/site/dir', name: 'doc' }); + }); + + it('reads a document at the site root', () => { + expect(getSourceUploadContext('https://admin.da.live/source/org/site/index.html')) + .to.deep.equal({ parent: '/org/site', name: 'index' }); + expect(getSourceUploadContext('https://api.aem.live/org/sites/site/source/index.html')) + .to.deep.equal({ parent: '/org/site', name: 'index' }); + }); + + it('answers null for a url it cannot read', () => { + expect(getSourceUploadContext('')).to.equal(null); + expect(getSourceUploadContext('https://admin.da.live/list/org/site')).to.equal(null); + expect(getSourceUploadContext(null)).to.equal(null); + }); +}); + +describe('uploadImageFile', () => { + let editor; + + beforeEach(async () => { + editor = await createTestEditor(); + await nextFrame(); + }); + + afterEach(() => { + destroyEditor(editor); + }); + + const png = () => new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }); + + it('uploads to the source bus for a migrated site', async () => { + const { calls, restore } = stubStore({ upgraded: true, contentUrl: './media_abc.png' }); + try { + await uploadImageFile(editor.view, png(), { parent: '/upsorg/upssite/dir', name: 'doc' }); + + // nx2 owns the route past the site, so this pins only the store and the path + const upload = calls.find((c) => c.opts?.method === 'POST'); + expect(upload, 'nothing was uploaded').to.exist; + expect(new URL(upload.url).origin).to.equal('https://api.aem.live'); + expect(upload.url).to.contain('/upsorg/sites/upssite/'); + expect(upload.url.endsWith('/dir/.doc/pic.png'), upload.url).to.equal(true); + } finally { + restore(); + } + }); + + it('uploads to da-admin for a legacy site', async () => { + const { calls, restore } = stubStore({ upgraded: false }); + try { + await uploadImageFile(editor.view, png(), { parent: '/legorg/legsite/dir', name: 'doc' }); + + const upload = calls.find((c) => c.opts?.method === 'POST'); + expect(upload, 'nothing was uploaded').to.exist; + expect(new URL(upload.url).origin).to.equal('https://admin.da.live'); + expect(upload.url.endsWith('/legorg/legsite/dir/.doc/pic.png'), upload.url).to.equal(true); + } finally { + restore(); + } + }); + + it('shows a media bus image without waiting for it to load', async () => { + // a relative src cannot load from the canvas origin + const { restore } = stubStore({ upgraded: true, contentUrl: './media_abc.png' }); + try { + await uploadImageFile(editor.view, png(), { parent: '/relorg/relsite', name: 'doc' }); + await nextFrame(); + + let stored; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') stored = node.attrs.src; + }); + expect(stored).to.equal('./media_abc.png'); + } finally { + restore(); + } + }); + + it('refuses a file type the store does not take', async () => { + const { calls, restore } = stubStore({ upgraded: false }); + try { + const pdf = new File([new Uint8Array([1])], 'doc.pdf', { type: 'application/pdf' }); + await uploadImageFile(editor.view, pdf, { parent: '/pdforg/pdfsite', name: 'doc' }); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0); + } finally { + restore(); + } + }); + + it('refuses an image over the upload limit', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + toasts.length = 0; + try { + const big = new File( + [new Uint8Array(MAX_IMAGE_BYTES + 1)], + 'big.png', + { type: 'image/png' }, + ); + await uploadImageFile(editor.view, big, { parent: '/bigorg/bigsite', name: 'doc' }); + await nextFrame(); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0); + let images = 0; + editor.view.state.doc.descendants((node) => { + if (node.type.name === 'image') images += 1; + }); + expect(images, 'the fpo was left in the document').to.equal(0); + expect(toasts).to.have.length(1); + expect(toasts[0].text).to.contain('Image upload failed'); + expect(toasts[0].text).to.contain('4.5 MB or under'); + } finally { + restore(); + } + }); + + it('takes an oversized image on a legacy site, where the limit does not apply', async () => { + const { calls, restore } = stubStore({ upgraded: false }); + toasts.length = 0; + try { + const big = new File( + [new Uint8Array(MAX_IMAGE_BYTES + 1)], + 'big.png', + { type: 'image/png' }, + ); + await uploadImageFile(editor.view, big, { parent: '/bigleg/bigleg', name: 'doc' }); + await nextFrame(); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(1); + expect(toasts).to.have.length(0); + } finally { + restore(); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js b/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js new file mode 100644 index 000000000..4a5d90636 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/prose-room.test.js @@ -0,0 +1,48 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let initProse; + +before(async () => { + ({ default: initProse } = await import('../../../../../blocks/canvas/ew-editor-doc/prose.js')); +}); + +async function connect(sourceUrl) { + const result = await initProse({ + path: sourceUrl, + permissions: ['read', 'write'], + setEditable: () => {}, + getToken: () => 'test-token', + }); + return result; +} + +function teardown({ wsProvider, ydoc, view }) { + wsProvider.disconnect({ data: 'Client navigation' }); + wsProvider.destroy?.(); + view?.destroy?.(); + ydoc.destroy(); +} + +describe('canvas collab room', () => { + it('keeps a legacy document in its da-admin room', async () => { + const result = await connect('https://admin.da.live/source/roomorg/roomsite/page.html'); + try { + expect(result.wsProvider.roomname).to.equal('https://admin.da.live/source/roomorg/roomsite/page.html'); + } finally { + teardown(result); + } + }); + + it('puts a source-bus document in its api.aem.live room', async () => { + // da-collab reads the store off the room name, so the room must name the document's real store + const result = await connect('https://api.aem.live/roomorg/sites/roomsite/source/page.html'); + try { + expect(result.wsProvider.roomname).to.equal('https://api.aem.live/roomorg/sites/roomsite/source/page.html'); + } finally { + teardown(result); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js b/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js new file mode 100644 index 000000000..b6a9dae9d --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/session-order.test.js @@ -0,0 +1,56 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let resolveEditorDocSession; + +before(async () => { + ({ resolveEditorDocSession } = await import('../../../../../blocks/canvas/ew-editor-doc/utils/load-editor-doc.js')); +}); + +function stubFetch(respond) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + const href = typeof url === 'string' ? url : url.url; + calls.push({ url: href, opts }); + return respond(href, opts); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +const ok = () => new Response('', { status: 200 }); + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); + window.localStorage.removeItem('nx-ims'); + delete window.adobeIMS; +}); + +describe('resolveEditorDocSession', () => { + // the nx fixture's loadIms answers nothing, so every session in this file is anonymous + it('refuses an anonymous session before it looks the store up', async () => { + const { calls, restore } = stubFetch(ok); + try { + const session = await resolveEditorDocSession({ org: 'anonorg', repo: 'anonsite', path: '/anonorg/anonsite/page' }); + + expect(session).to.deep.equal({ ok: false, error: 'Sign in required' }); + expect(calls, 'the store was asked before the sign-in check').to.have.length(0); + } finally { + restore(); + } + }); + + it('answers the same for a ctx it cannot use', async () => { + const { calls, restore } = stubFetch(ok); + try { + const session = await resolveEditorDocSession({ org: 'anonorg', repo: 'anonsite', path: '' }); + + expect(session.ok).to.equal(false); + expect(calls).to.have.length(0); + } finally { + restore(); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js new file mode 100644 index 000000000..d2af95ffe --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/ctx.test.js @@ -0,0 +1,28 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let editorDocCanLoad; + +before(async () => { + ({ editorDocCanLoad } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/ctx.js')); +}); + +describe('editorDocCanLoad', () => { + it('is decided without a network call', () => { + // a promise is truthy, so this has to stay synchronous or an empty path reads as loadable + expect(editorDocCanLoad({ org: 'o', repo: 's', path: '/o/s/page' })).to.equal(true); + }); + + it('refuses a ctx with an empty path', () => { + expect(editorDocCanLoad({ org: 'o', repo: 's', path: '' })).to.equal(false); + expect(editorDocCanLoad({ org: 'o', repo: 's', path: ' ' })).to.equal(false); + }); + + it('refuses a ctx missing org or site', () => { + expect(editorDocCanLoad({ repo: 's', path: '/o/s/page' })).to.equal(false); + expect(editorDocCanLoad({ org: 'o', path: '/o/s/page' })).to.equal(false); + expect(editorDocCanLoad(null)).to.equal(false); + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js new file mode 100644 index 000000000..817d55fe9 --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-doc/utils/source.test.js @@ -0,0 +1,131 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let buildSourceUrl; +let checkDoc; + +before(async () => { + ({ buildSourceUrl, checkDoc } = await import('../../../../../../blocks/canvas/ew-editor-doc/utils/source.js')); +}); + +// isHlx6 memoizes its answer per site, so each case needs its own org/site +function stubPing({ upgraded }) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + calls.push({ url: typeof url === 'string' ? url : url.url, opts }); + const headers = upgraded ? { 'x-api-upgrade-available': 'true' } : {}; + return new Response('', { status: 200, headers }); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('canvas buildSourceUrl', () => { + it('keeps a legacy site on da-admin', async () => { + const { restore } = stubPing({ upgraded: false }); + try { + const url = await buildSourceUrl('/legacyorg/legacysite/dir/page'); + expect(url).to.equal('https://admin.da.live/source/legacyorg/legacysite/dir/page.html'); + } finally { + restore(); + } + }); + + it('sends a source-bus site to api.aem.live', async () => { + const { restore } = stubPing({ upgraded: true }); + try { + const url = await buildSourceUrl('/hlxorg/hlxsite/dir/page'); + expect(url).to.equal('https://api.aem.live/hlxorg/sites/hlxsite/source/dir/page.html'); + } finally { + restore(); + } + }); + + it('routes a document at the site root', async () => { + const { restore } = stubPing({ upgraded: true }); + try { + const url = await buildSourceUrl('/rootorg/rootsite/index'); + expect(url).to.equal('https://api.aem.live/rootorg/sites/rootsite/source/index.html'); + } finally { + restore(); + } + }); + + it('answers null for a path it cannot use', async () => { + expect(await buildSourceUrl('')).to.equal(null); + expect(await buildSourceUrl(' ')).to.equal(null); + expect(await buildSourceUrl(null)).to.equal(null); + expect(await buildSourceUrl(42)).to.equal(null); + }); +}); + +describe('canvas checkDoc', () => { + // the source bus is not on da-live's token allowlist, so that read goes through nx2 + it('asks api.aem.live for a source-bus document, with a bearer', async () => { + const { calls, restore } = stubPing({ upgraded: true }); + try { + const url = await buildSourceUrl('/checkorg/checksite/page'); + await checkDoc(url); + const head = calls.find((c) => c.opts?.method === 'HEAD'); + expect(head, 'no HEAD was issued').to.exist; + expect(head.url).to.equal('https://api.aem.live/checkorg/sites/checksite/source/page.html'); + expect(head.opts.headers.Authorization).to.equal('Bearer test-token'); + } finally { + restore(); + } + }); + + // da-admin keeps da-live's own fetcher, which reads the token live and retries once on a 401. + // nx2's takes the snapshot loadIms captured at page load and does neither. + it('asks da-admin for a legacy document, through the fetcher that can refresh a token', async () => { + window.localStorage.setItem('nx-ims', 'true'); + window.adobeIMS = { getAccessToken: () => ({ token: 'live-token' }) }; + const { calls, restore } = stubPing({ upgraded: false }); + try { + const url = await buildSourceUrl('/checkleg/checkleg/page'); + await checkDoc(url); + const head = calls.find((c) => c.opts?.method === 'HEAD'); + expect(head, 'no HEAD was issued').to.exist; + expect(head.url).to.equal('https://admin.da.live/source/checkleg/checkleg/page.html'); + expect(new Headers(head.opts.headers).get('Authorization')).to.equal('Bearer live-token'); + } finally { + restore(); + window.localStorage.removeItem('nx-ims'); + delete window.adobeIMS; + } + }); + + it('reads the action list da-admin sends', async () => { + const saved = window.fetch; + window.fetch = async (url) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return new Response('', { + status: 200, + headers: { 'x-da-actions': '/site/page.html=read' }, + }); + }; + try { + const resp = await checkDoc('https://admin.da.live/source/permorg/permsite/page.html'); + expect(resp.permissions).to.deep.equal(['read']); + } finally { + window.fetch = saved; + } + }); + + it('assumes read and write when the store sends no action list', async () => { + const { restore } = stubPing({ upgraded: true }); + try { + const url = await buildSourceUrl('/faketorg/fakesite/page'); + const resp = await checkDoc(url); + expect(resp.permissions).to.deep.equal(['read', 'write']); + } finally { + restore(); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js new file mode 100644 index 000000000..447aa9edc --- /dev/null +++ b/test/unit/blocks/canvas/ew-editor-wysiwyg/image-upload.test.js @@ -0,0 +1,149 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; +import { createTestEditor, destroyEditor } from '../../edit/prose/test-helpers.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let handleImageReplace; +let MAX_IMAGE_BYTES; +let toasts; + +const nextFrame = () => new Promise((resolve) => { setTimeout(resolve, 0); }); + +before(async () => { + ({ handleImageReplace } = await import('../../../../../blocks/canvas/ew-editor-wysiwyg/utils/image.js')); + ({ MAX_IMAGE_BYTES } = await import('../../../../../blocks/canvas/utils/image-upload.js')); + ({ toasts } = await import('../../../../fixtures/nx2/blocks/shared/toast/toast.js')); +}); + +function stubStore({ upgraded, contentUrl = './media_abc.png' }) { + const saved = window.fetch; + const calls = []; + window.fetch = async (url, opts) => { + const href = typeof url === 'string' ? url : url.url; + calls.push({ url: href, opts }); + if (href.includes('/ping/')) { + return new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + } + return new Response(JSON.stringify({ source: { contentUrl } }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + return { calls, restore: () => { window.fetch = saved; } }; +} + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('handleImageReplace', () => { + let editor; + + const ctxFor = (owner, repo) => { + const posted = []; + return { + posted, + ctx: { + owner, + repo, + path: '/page', + view: editor.view, + port: { postMessage: (message) => posted.push(message) }, + getToken: () => 'test-token', + }, + }; + }; + + const imageData = 'data:image/png;base64,iVBORw0KGgo='; + + beforeEach(async () => { + editor = await createTestEditor(); + await nextFrame(); + }); + + afterEach(() => { + destroyEditor(editor); + }); + + it('uploads to the source bus for a migrated site', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + const { ctx } = ctxFor('wysorg', 'wyssite'); + try { + await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + + // the route past the site is nx2's, so what is pinned is the store and the path + const upload = calls.find((c) => c.opts?.method === 'POST'); + expect(upload, 'nothing was uploaded').to.exist; + expect(new URL(upload.url).origin).to.equal('https://api.aem.live'); + expect(upload.url).to.contain('/wysorg/sites/wyssite/'); + expect(upload.url.endsWith('/.page/pic.png'), upload.url).to.equal(true); + } finally { + restore(); + } + }); + + it('uploads to da-admin for a legacy site', async () => { + const { calls, restore } = stubStore({ upgraded: false, contentUrl: 'https://content.da.live/wyslegacy/wyslegacy/.page/pic.png' }); + const { ctx } = ctxFor('wyslegacy', 'wyslegacy'); + try { + await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + + const upload = calls.find((c) => c.opts?.method === 'POST'); + expect(upload, 'nothing was uploaded').to.exist; + expect(new URL(upload.url).origin).to.equal('https://admin.da.live'); + expect(upload.url.endsWith('/wyslegacy/wyslegacy/.page/pic.png'), upload.url).to.equal(true); + } finally { + restore(); + } + }); + + it('reports the src the store gave back, not one it composed', async () => { + const { restore } = stubStore({ upgraded: true, contentUrl: './media_xyz.png' }); + const { ctx, posted } = ctxFor('wysrep', 'wysrep'); + try { + await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + + expect(posted.at(-1).payload.newSrc).to.equal('./media_xyz.png'); + } finally { + restore(); + } + }); + + it('reports a refused upload', async () => { + const saved = window.fetch; + window.fetch = async (url) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return new Response('', { status: 403 }); + }; + const { ctx, posted } = ctxFor('wysref', 'wysref'); + try { + await handleImageReplace({ imageData, fileName: 'pic.png', originalSrc: '/old.png' }, ctx); + + expect(posted.at(-1).payload.error).to.contain('403'); + } finally { + window.fetch = saved; + } + }); + + it('refuses an image over the upload limit', async () => { + const { calls, restore } = stubStore({ upgraded: true }); + const { ctx, posted } = ctxFor('wysbig', 'wysbig'); + // base64 inflates by 4/3, so this decodes to one byte over the limit + const big = `data:image/png;base64,${'A'.repeat(Math.ceil((MAX_IMAGE_BYTES + 1) / 3) * 4)}`; + toasts.length = 0; + try { + await handleImageReplace({ imageData: big, fileName: 'big.png', originalSrc: '/old.png' }, ctx); + + expect(calls.filter((c) => c.opts?.method === 'POST')).to.have.length(0); + expect(posted.at(-1).payload.error).to.contain('too large'); + expect(toasts).to.have.length(1); + expect(toasts[0].text).to.contain('4.5 MB or under'); + } finally { + restore(); + } + }); +}); diff --git a/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js b/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js index 8ddcfc822..e7a4886df 100644 --- a/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js +++ b/test/unit/blocks/canvas/ew-panel-extensions/aem-assets.test.js @@ -25,12 +25,16 @@ function makeSheet(entries) { function makeFetch(responses) { return async (url) => { + // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); check that + // before the pattern match below, since a ping url can otherwise collide with an + // org-level config pattern (e.g. '/ping/{org}/{site}' contains '/{org}/'). + if (url.includes('/ping/')) return new Response('', { status: 200 }); for (const [pattern, response] of Object.entries(responses).sort( ([a], [b]) => b.length - a.length, )) { if (url.includes(pattern)) return response; } - return { ok: false }; + return new Response('', { status: 404 }); }; } diff --git a/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js b/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js index 355971db9..3cb5a7bde 100644 --- a/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js +++ b/test/unit/blocks/canvas/ew-panel-extensions/helpers.test.js @@ -5,11 +5,13 @@ setNx('/test/fixtures/nx', { hostname: 'example.com' }); let getBlockVariants; let extensionToPanelView; +let getPreviewStatus; before(async () => { const mod = await import('../../../../../blocks/canvas/ew-panel-extensions/helpers.js'); getBlockVariants = mod.getBlockVariants; extensionToPanelView = mod.extensionToPanelView; + getPreviewStatus = mod.getPreviewStatus; }); describe('EW panel helpers transformBlock', () => { @@ -208,3 +210,40 @@ describe('extensionToPanelView', () => { expect(view.openModal).to.be.undefined; }); }); + +// getPreviewStatus now goes through getNx2Api's isHlx6-aware status.get, not the legacy +// admin.hlx.page-only aemAdmin() helper. A real Response keeps isHlx6's ping (which fires +// before the actual status call) safe regardless of route. +describe('getPreviewStatus', () => { + let savedFetch; + + beforeEach(() => { savedFetch = window.fetch; }); + afterEach(() => { + window.fetch = savedFetch; + window.localStorage.removeItem('hlx6-upgrade'); + }); + + it('returns true when preview status is 200', async () => { + window.fetch = () => Promise.resolve(new Response( + JSON.stringify({ preview: { status: 200 } }), + { status: 200 }, + )); + const result = await getPreviewStatus({ org: 'pstatusorg', site: 'pstatussite', pathname: '/p' }); + expect(result).to.be.true; + }); + + it('returns false when preview status is not 200', async () => { + window.fetch = () => Promise.resolve(new Response( + JSON.stringify({ preview: { status: 404 } }), + { status: 200 }, + )); + const result = await getPreviewStatus({ org: 'pstatusorg2', site: 'pstatussite2', pathname: '/p' }); + expect(result).to.be.false; + }); + + it('returns null when the status call fails', async () => { + window.fetch = () => Promise.resolve(new Response('{}', { status: 500 })); + const result = await getPreviewStatus({ org: 'pstatusorg3', site: 'pstatussite3', pathname: '/p' }); + expect(result).to.equal(null); + }); +}); diff --git a/test/unit/blocks/canvas/utils/image-upload.test.js b/test/unit/blocks/canvas/utils/image-upload.test.js new file mode 100644 index 000000000..13949b974 --- /dev/null +++ b/test/unit/blocks/canvas/utils/image-upload.test.js @@ -0,0 +1,112 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +let MAX_IMAGE_BYTES; +let isImageTooLarge; +let dataUrlByteLength; +let showImageTooLarge; +let refuseOversizedImage; +let toasts; + +before(async () => { + ({ + MAX_IMAGE_BYTES, + isImageTooLarge, + dataUrlByteLength, + showImageTooLarge, + refuseOversizedImage, + } = await import('../../../../../blocks/canvas/utils/image-upload.js')); + ({ toasts } = await import('../../../../fixtures/nx2/blocks/shared/toast/toast.js')); +}); + +// isHlx6 memoizes its answer per site, so each case needs its own org/site +function stubPing(upgraded) { + const saved = window.fetch; + window.fetch = async () => new Response('', { + status: 200, + headers: upgraded ? { 'x-api-upgrade-available': 'true' } : {}, + }); + return () => { window.fetch = saved; }; +} + +beforeEach(() => { + toasts.length = 0; +}); + +afterEach(() => { + window.localStorage.removeItem('hlx6-upgrade'); +}); + +describe('image upload limit', () => { + it('caps an upload below the api service request limit', () => { + // the AWS edge answers 413 above 4,717,360 bytes, measured 2026-08-18 + expect(MAX_IMAGE_BYTES).to.be.below(4717360); + }); + + it('takes a file at the limit and refuses the byte above it', () => { + expect(isImageTooLarge(MAX_IMAGE_BYTES)).to.equal(false); + expect(isImageTooLarge(MAX_IMAGE_BYTES + 1)).to.equal(true); + expect(isImageTooLarge(0)).to.equal(false); + }); + + it('reads the decoded length of a data url', () => { + expect(dataUrlByteLength('data:image/png;base64,AAAA')).to.equal(3); + expect(dataUrlByteLength('data:image/png;base64,AAA=')).to.equal(2); + expect(dataUrlByteLength('data:image/png;base64,AA==')).to.equal(1); + expect(dataUrlByteLength('not a data url')).to.equal(0); + }); +}); + +describe('showImageTooLarge', () => { + it('names the failure and the limit', async () => { + await showImageTooLarge(); + expect(toasts).to.have.length(1); + expect(toasts[0].variant).to.equal('error'); + expect(toasts[0].text).to.equal('Image upload failed. Image size must be 4.5 MB or under'); + }); +}); + +describe('refuseOversizedImage', () => { + it('refuses an oversized image on a source bus site', async () => { + const restore = stubPing(true); + try { + expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/refsb/refsb/dir')).to.equal(true); + expect(toasts).to.have.length(1); + } finally { + restore(); + } + }); + + it('takes an oversized image on a legacy site, where the limit does not apply', async () => { + const restore = stubPing(false); + try { + expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/reflg/reflg/dir')).to.equal(false); + expect(toasts).to.have.length(0); + } finally { + restore(); + } + }); + + it('takes an image inside the limit without probing the store', async () => { + let probed = false; + const saved = window.fetch; + window.fetch = async () => { + probed = true; + return new Response('', { status: 200 }); + }; + try { + expect(await refuseOversizedImage(MAX_IMAGE_BYTES, '/refok/refok')).to.equal(false); + expect(probed, 'the store was probed for a file inside the limit').to.equal(false); + } finally { + window.fetch = saved; + } + }); + + it('takes an image it cannot place in a site', async () => { + expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '')).to.equal(false); + expect(await refuseOversizedImage(MAX_IMAGE_BYTES + 1, '/orgonly')).to.equal(false); + expect(toasts).to.have.length(0); + }); +}); diff --git a/test/unit/blocks/edit/da-assets/config.test.js b/test/unit/blocks/edit/da-assets/config.test.js index 53338a6af..a557510ae 100644 --- a/test/unit/blocks/edit/da-assets/config.test.js +++ b/test/unit/blocks/edit/da-assets/config.test.js @@ -15,10 +15,14 @@ function makeSheet(entries) { function makeFetch(responses) { return async (url) => { + // getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); check that + // before the pattern match below, since a ping url can otherwise collide with an + // org-level config pattern (e.g. '/ping/{org}/{site}' contains '/{org}/'). + if (url.includes('/ping/')) return new Response('', { status: 200 }); for (const [pattern, response] of Object.entries(responses)) { if (url.includes(pattern)) return response; } - return { ok: false }; + return new Response('', { status: 404 }); }; } @@ -418,6 +422,15 @@ function makeMultiSheetWithResponsive(crops) { }; } +// getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that with a +// real Response (so its headers.get() call is safe) and defer everything else to `respond`. +function pingSafe(respond) { + return async (url, opts) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return respond(url, opts); + }; +} + describe('getResponsiveImageConfig', () => { it('returns null when owner and repo are both absent', async () => { const result = await getResponsiveImageConfig(null, null); @@ -426,7 +439,7 @@ describe('getResponsiveImageConfig', () => { it('returns false when config has no responsive-images sheet', async () => { const orgFetch = window.fetch; - window.fetch = async () => ({ ok: true, json: async () => ({ data: [] }) }); + window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [] }) })); try { const result = await getResponsiveImageConfig('ri1', 'none'); expect(result).to.be.false; @@ -437,9 +450,9 @@ describe('getResponsiveImageConfig', () => { it('parses crops string into array from responsive-images sheet', async () => { const orgFetch = window.fetch; - window.fetch = async () => makeMultiSheetWithResponsive([ + window.fetch = pingSafe(async () => makeMultiSheetWithResponsive([ { name: 'Full Width', position: 'everywhere', crops: 'desktop, mobile' }, - ]); + ])); try { const result = await getResponsiveImageConfig('ri2', 'crops'); expect(result).to.be.an('array'); @@ -452,9 +465,9 @@ describe('getResponsiveImageConfig', () => { it('handles crops with no spaces around comma', async () => { const orgFetch = window.fetch; - window.fetch = async () => makeMultiSheetWithResponsive([ + window.fetch = pingSafe(async () => makeMultiSheetWithResponsive([ { name: 'Tight', position: 'hero', crops: 'small,medium,large' }, - ]); + ])); try { const result = await getResponsiveImageConfig('ri3', 'tight'); expect(result[0].crops).to.deep.equal(['small', 'medium', 'large']); @@ -465,12 +478,12 @@ describe('getResponsiveImageConfig', () => { it('falls back to org-level config when repo config has no responsive-images', async () => { const orgFetch = window.fetch; - window.fetch = async (url) => { + window.fetch = pingSafe((url) => { if (url.includes('/ri4/fallback/')) return { ok: true, json: async () => ({ data: [] }) }; return makeMultiSheetWithResponsive([ { name: 'Org Wide', position: 'outside-blocks', crops: 'wide' }, ]); - }; + }); try { const result = await getResponsiveImageConfig('ri4', 'fallback'); expect(result[0].name).to.equal('Org Wide'); diff --git a/test/unit/blocks/edit/da-content/helpers/index.test.js b/test/unit/blocks/edit/da-content/helpers/index.test.js index dad5c9cf5..15d01c51e 100644 --- a/test/unit/blocks/edit/da-content/helpers/index.test.js +++ b/test/unit/blocks/edit/da-content/helpers/index.test.js @@ -17,9 +17,18 @@ const MULTI_SHEET = { data: SINGLE_SHEET }; const { default: ueUrlHelper } = await import('../../../../../../blocks/edit/da-content/helpers/index.js'); +// getNx2Api's config.get pings isHlx6 first (HLX_ADMIN/ping/{org}/{site}); answer that with a +// real Response (so its headers.get() call is safe) and defer everything else to `respond`. +function pingSafe(respond) { + return async (url, opts) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return respond(url, opts); + }; +} + describe('UE URLs', () => { it('Supports single sheet configs', async () => { - const mockFetch = async () => ({ ok: true, json: async () => (SINGLE_SHEET) }); + const mockFetch = pingSafe(async () => ({ ok: true, json: async () => (SINGLE_SHEET) })); const orgFetch = window.fetch; try { @@ -32,7 +41,7 @@ describe('UE URLs', () => { }); it('Supports multisheet configs', async () => { - const mockFetch = async () => ({ ok: true, json: async () => (MULTI_SHEET) }); + const mockFetch = pingSafe(async () => ({ ok: true, json: async () => (MULTI_SHEET) })); const orgFetch = window.fetch; try { @@ -45,7 +54,7 @@ describe('UE URLs', () => { }); it('Successfully dies gracefully', async () => { - const mockFetch = async () => ({ ok: false }); + const mockFetch = pingSafe(async () => ({ ok: false })); const orgFetch = window.fetch; try { @@ -60,7 +69,7 @@ describe('UE URLs', () => { it('Returns null when no editor.path or quick-edit config exists', async () => { const orgFetch = window.fetch; try { - window.fetch = async () => ({ ok: true, json: async () => ({ data: [{ key: 'other', value: 'x' }] }) }); + window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [{ key: 'other', value: 'x' }] }) })); const url = await ueUrlHelper('org', 'repo', 'https://main--repo--org.aem.page/page'); expect(url).to.equal(null); } finally { @@ -71,10 +80,10 @@ describe('UE URLs', () => { it('Builds a quick-edit URL when quick-edit config matches the repo', async () => { const orgFetch = window.fetch; try { - window.fetch = async () => ({ + window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [{ key: 'quick-edit', value: 'repo' }] }), - }); + })); const url = await ueUrlHelper('org-qe', 'repo', 'https://main--repo--org.aem.live/page'); expect(url).to.equal('https://main--repo--org.aem.page/page?quick-edit=on'); } finally { @@ -85,10 +94,10 @@ describe('UE URLs', () => { it('Strips trailing /index when building the quick-edit URL', async () => { const orgFetch = window.fetch; try { - window.fetch = async () => ({ + window.fetch = pingSafe(async () => ({ ok: true, json: async () => ({ data: [{ key: 'quick-edit', value: 'repo' }] }), - }); + })); const url = await ueUrlHelper('org-qe-strip', 'repo', 'https://main--repo--org.aem.live/folder/index'); expect(url).to.equal('https://main--repo--org.aem.page/folder/?quick-edit=on'); } finally { diff --git a/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js b/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js index a72d5af4d..725e74765 100644 --- a/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js +++ b/test/unit/blocks/edit/prose/plugins/imageFocalPoint.test.js @@ -54,8 +54,9 @@ describe('imageFocalPoint Plugin', () => { }), }; } - // Fallback for any other requests - return { ok: false }; + // Fallback for any other requests (includes getNx2Api's isHlx6 ping preflight, + // which needs a real Response for its headers.get() call) + return new Response('', { status: 404 }); }; const mod = await import('../../../../../../blocks/edit/prose/plugins/imageFocalPoint.js'); diff --git a/test/unit/blocks/shared/utils.test.js b/test/unit/blocks/shared/utils.test.js index 20365b4b5..f80aa95ab 100644 --- a/test/unit/blocks/shared/utils.test.js +++ b/test/unit/blocks/shared/utils.test.js @@ -684,6 +684,35 @@ describe('fetchDaConfigs', () => { expect(resolved).to.equal(null); expect(fetchCalled).to.be.false; }); + + // fetchDaConfigs routes through getNx2Api's config.get, which pings isHlx6 + // (HLX_ADMIN/ping/{org}/{site}) before resolving the config url. + it('goes through the isHlx6-aware config route, not a hardcoded da-admin fetch', async () => { + const calls = []; + window.fetch = async (url) => { + calls.push(String(url)); + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return new Response(JSON.stringify({ data: [{ key: 'k', value: 'v' }] }), { status: 200 }); + }; + + const [orgConfig, siteConfig] = await Promise.all( + fetchDaConfigs({ org: 'cfgorg', site: 'cfgsite' }), + ); + + expect(calls.some((u) => u.includes('/ping/cfgorg/cfgsite'))).to.equal(true, 'isHlx6 was not consulted'); + expect(orgConfig).to.deep.equal({ data: [{ key: 'k', value: 'v' }] }); + expect(siteConfig).to.deep.equal({ data: [{ key: 'k', value: 'v' }] }); + }); + + it('reports the store status without throwing when the config fetch fails', async () => { + window.fetch = async (url) => { + if (String(url).includes('/ping/')) return new Response('', { status: 200 }); + return new Response('', { status: 500 }); + }; + + const [orgConfig] = await Promise.all(fetchDaConfigs({ org: 'cfgfail' })); + expect(orgConfig).to.deep.equal({ error: 'Error loading /cfgfail', status: 500 }); + }); }); describe('saveDaVersion', () => {