Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions packages/blocks/src/cms/applySectionConventions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,20 @@
* registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections,
* registerLayoutSections, registerSeoSections, and registerSection.
*/
import {
registerSection,
registerSectionsSync,
} from "./registry";
import {
type CacheableSectionInput,
registerCacheableSections,
registerLayoutSections,
} from "./sectionLoaders";
import { registerSection, registerSectionsSync } from "./registry";
import {
type AsyncRenderingConfig,
getAsyncRenderingConfig,
registerEagerSections,
registerNeverDeferSections,
registerSeoSections,
setAsyncRenderingConfig,
getAsyncRenderingConfig,
} from "./resolve";
import {
type CacheableSectionInput,
registerCacheableSections,
registerLayoutSections,
} from "./sectionLoaders";

export interface SectionMetaEntry {
eager?: boolean;
Expand Down Expand Up @@ -96,8 +93,7 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
// it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and
// even editor-marked ⚡ sections render eagerly. (The default foldThreshold is
// Infinity, so position-based deferral stays off unless a site opts in.)
const existing: Partial<AsyncRenderingConfig> =
getAsyncRenderingConfig() ?? {};
const existing: Partial<AsyncRenderingConfig> = getAsyncRenderingConfig() ?? {};
setAsyncRenderingConfig({
...existing,
alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections],
Expand Down
2 changes: 1 addition & 1 deletion packages/blocks/src/cms/blockSource.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { djb2Hex } from "../sdk/djb2";
import {
BundledBlockSource,
computeRevision,
Expand All @@ -8,7 +9,6 @@ import {
revisionKey,
snapshotKey,
} from "./blockSource";
import { djb2Hex } from "../sdk/djb2";

describe("computeRevision", () => {
it("matches loader.ts computeRevision (djb2Hex of JSON.stringify)", () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/blocks/src/cms/client.browserBundle.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";

const here = dirname(fileURLToPath(import.meta.url));
// esbuild's JS API relies on `new TextEncoder().encode("") instanceof
Expand Down
10 changes: 8 additions & 2 deletions packages/blocks/src/cms/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ export {
registerSectionsSync,
setResolvedComponent,
} from "./registry";
export type { SectionLoaderFn } from "./sectionLoaders";
export { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "./sectionMixins";
export type {
ActionConfig,
AppSchemas,
Expand All @@ -72,3 +70,11 @@ export {
registerMatcherSchema,
registerMatcherSchemas,
} from "./schema";
export type { SectionLoaderFn } from "./sectionLoaders";
export {
compose,
withDevice,
withMobile,
withSearchParam,
withSectionLoader,
} from "./sectionMixins";
258 changes: 258 additions & 0 deletions packages/blocks/src/cms/draftSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { beforeEach, describe, expect, it } from "vitest";

import {
clearDraftCache,
DEFAULT_PREVIEW_API_DOMAINS,
isDraftHostAllowed,
isDraftPreviewEnabled,
parseDraftPointer,
previewApiOriginForHost,
resolveDraftDecofile,
setDraftPreviewHosts,
} from "./draftSource";

const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" };

function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}

beforeEach(() => {
clearDraftCache();
});

describe("parseDraftPointer", () => {
it("parses authority@version, lowercasing the authority", () => {
expect(parseDraftPointer("ABC.Preview-Studio.decocms.com@FF00")).toEqual({
host: "abc.preview-studio.decocms.com",
version: "FF00",
});
});

it("keeps an explicit port on the authority", () => {
expect(parseDraftPointer("abc.localhost:60534@v1")).toEqual({
host: "abc.localhost:60534",
version: "v1",
});
});

it("rejects more than one @", () => {
// A naive split("@") accepts this and silently uses the first two
// segments — the exact hole found while spiking the fetch path.
expect(parseDraftPointer("a.example@b@c")).toBeNull();
});

it("rejects anything that could escape the authority", () => {
// No scheme, no path, no userinfo — the token carries an authority only,
// so `javascript:`/`file:`/full URLs fail structurally at parse time.
expect(parseDraftPointer("https://evil.example@v1")).toBeNull();
expect(parseDraftPointer("evil.example/x@v1")).toBeNull();
expect(parseDraftPointer("a.example:80:80@v1")).toBeNull();
expect(parseDraftPointer("a.example:abc@v1")).toBeNull();
expect(parseDraftPointer(".leading.dot@v1")).toBeNull();
expect(parseDraftPointer("bare-label@v1")).toBeNull();
});

it("validates the version charset — it becomes a cache key", () => {
expect(parseDraftPointer("a.example@")).toBeNull();
expect(parseDraftPointer(`a.example@${"x".repeat(65)}`)).toBeNull();
expect(parseDraftPointer("a.example@v 1")).toBeNull();
expect(parseDraftPointer(null)).toBeNull();
});
});

describe("previewApiOriginForHost", () => {
it("admits authorities under the default deco domains", () => {
expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe(
"https://abc.preview-studio.decocms.com",
);
expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe(
"https://abc.local.studio.decocms.com",
);
expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe("http://abc.localhost:60534");
});

it("rejects hosts outside the domains — the token proposes, config disposes", () => {
expect(previewApiOriginForHost("evil.example", {})).toBeNull();
// Dot-prefixed suffixes guarantee a label boundary: a lookalike domain
// that merely ends with the same characters cannot pass.
expect(previewApiOriginForHost("evil-preview-studio.decocms.com", {})).toBeNull();
// The domain itself (no label in front) is not a draft host.
expect(previewApiOriginForHost("preview-studio.decocms.com", {})).toBeNull();
});

it("allows an explicit port only under localhost-ish domains", () => {
// A public-domain token must not steer the fetch at odd ports.
expect(previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {})).toBeNull();
});

it("honours a configured override instead of the defaults", () => {
const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" };
expect(previewApiOriginForHost("abc.staging.example", env)).toBe("https://abc.staging.example");
expect(previewApiOriginForHost("abc.preview-studio.decocms.com", env)).toBeNull();
});
});

describe("gating", () => {
it("is on iff an allowed host is configured — API domains have defaults", () => {
expect(isDraftPreviewEnabled(ENV_ON)).toBe(true);
expect(isDraftPreviewEnabled({})).toBe(false);
});

it("matches request hosts verbatim, port included, case-insensitively", () => {
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" };
expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true);
expect(isDraftHostAllowed("localhost:3100", env)).toBe(true);
expect(isDraftHostAllowed("fila.com.br", env)).toBe(false);
expect(isDraftHostAllowed("localhost", env)).toBe(false);
expect(isDraftHostAllowed(null, env)).toBe(false);
expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false);
});
});

describe("resolveDraftDecofile", () => {
it("fetches exactly the token's validated origin", async () => {
const calls: string[] = [];
const blocks = await resolveDraftDecofile({
pointer: "abc.preview-studio.decocms.com@v1",
env: ENV_ON,
fetchImpl: (async (url: string) => {
calls.push(String(url));
return jsonResponse({ "pages-home": { title: "draft" } });
}) as unknown as typeof fetch,
});

expect(blocks).toEqual({ "pages-home": { title: "draft" } });
expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]);
});

it("is inert without a host allowlist — no fetch at all", async () => {
let called = false;
const blocks = await resolveDraftDecofile({
pointer: "abc.preview-studio.decocms.com@v1",
env: {},
fetchImpl: (async () => {
called = true;
return jsonResponse({});
}) as unknown as typeof fetch,
});
expect(blocks).toBeNull();
expect(called).toBe(false);
});

it("refuses a parseable token whose origin no domain admits — no fetch", async () => {
let called = false;
const blocks = await resolveDraftDecofile({
pointer: "abc.evil.example@v1",
env: ENV_ON,
fetchImpl: (async () => {
called = true;
return jsonResponse({});
}) as unknown as typeof fetch,
});
expect(blocks).toBeNull();
expect(called).toBe(false);
});

it("caches by version — one fetch per version, not per request", async () => {
let fetches = 0;
const fetchImpl = (async () => {
fetches++;
return jsonResponse({ n: fetches });
}) as unknown as typeof fetch;
const P = "abc.preview-studio.decocms.com";

const a = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
const b = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
expect(fetches).toBe(1);
expect(b).toBe(a);

await resolveDraftDecofile({ pointer: `${P}@v2`, env: ENV_ON, fetchImpl });
expect(fetches).toBe(2);
});

it("bounds the cache so multi-MB decofiles can't accumulate", async () => {
let fetches = 0;
const fetchImpl = (async () => {
fetches++;
return jsonResponse({ n: fetches });
}) as unknown as typeof fetch;
const P = "abc.preview-studio.decocms.com";

for (const v of ["v1", "v2", "v3", "v4"]) {
await resolveDraftDecofile({ pointer: `${P}@${v}`, env: ENV_ON, fetchImpl });
}
expect(fetches).toBe(4);
await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl });
expect(fetches).toBe(5); // v1 evicted (cap 3) — re-fetch, never stale
await resolveDraftDecofile({ pointer: `${P}@v4`, env: ENV_ON, fetchImpl });
expect(fetches).toBe(5); // v4 resident
});

it("degrades to published on unreachable / non-2xx / unparseable", async () => {
const P = "abc.preview-studio.decocms.com";
for (const fetchImpl of [
async () => {
throw new Error("ECONNREFUSED");
},
async () => new Response("nope", { status: 404 }),
async () =>
new Response("<html>not json</html>", {
status: 200,
headers: { "content-type": "text/html" },
}),
]) {
clearDraftCache();
expect(
await resolveDraftDecofile({
pointer: `${P}@v1`,
env: ENV_ON,
fetchImpl: fetchImpl as unknown as typeof fetch,
}),
).toBeNull();
}
});
});

// DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it.
describe("DEFAULT_PREVIEW_API_DOMAINS", () => {
it("ships the deco-operated origins, dot-prefixed", () => {
expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([
".preview-studio.decocms.com",
".local.studio.decocms.com",
".localhost",
]);
});
});

describe("site-block preview hosts", () => {
it("enables the feature from the site block alone — no env needed", () => {
setDraftPreviewHosts(["fila.vtex.app", "LOCALHOST:3100", 42, " "]);
try {
expect(isDraftPreviewEnabled({})).toBe(true);
expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(true);
// Sanitized: lowercased, non-strings and blanks dropped.
expect(isDraftHostAllowed("localhost:3100", {})).toBe(true);
expect(isDraftHostAllowed("evil.example", {})).toBe(false);
} finally {
setDraftPreviewHosts([]);
}
});

it("env REPLACES the block hosts when set — the operational escape hatch", () => {
setDraftPreviewHosts(["fila.vtex.app"]);
try {
const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" };
expect(isDraftHostAllowed("other.example", env)).toBe(true);
// Not merged: env is a kill switch / override, so the block value must
// not survive alongside it.
expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(false);
} finally {
setDraftPreviewHosts([]);
}
});
});
Loading