Skip to content
Open

Next #1192

Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Release channels

This repository ships on two channels:
This repository publishes on two channels:

| Channel | Branch | Example version | JSR resolution |
|---------|--------|-----------------|----------------|
Expand Down
144 changes: 144 additions & 0 deletions blocks/matcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// deno-lint-ignore-file no-explicit-any
import { assert, assertEquals, assertStringIncludes } from "@std/assert";
import { getSetCookies } from "../deps.ts";
import matcherBlock, {
DECO_MATCHER_PREFIX,
type MatcherStickySessionModule,
} from "./matcher.ts";

const buildHttpCtx = (respHeaders: Headers) =>
({
resolveChain: [{ type: "resolvable", value: "test-matcher-id" }],
context: {
state: {
response: { headers: respHeaders },
flags: [] as any[],
global: {},
bag: new WeakMap(),
},
},
request: new Request("https://example.com/"),
resolve: (() => {}) as any,
revision: undefined,
resolverId: "test-resolver",
monitoring: undefined,
}) as any;

const buildMatchCtx = (request: Request) =>
({
device: "desktop",
siteId: 1,
request,
resolve: (() => {}) as any,
invoke: (() => {}) as any,
response: { headers: new Headers() },
bag: new WeakMap(),
}) as any;

Deno.test("sticky matcher flips result and sets cookie WITHOUT Vary: cookie", async () => {
const respHeaders = new Headers();
const httpCtx = buildHttpCtx(respHeaders);

const module: MatcherStickySessionModule = {
default: () => true,
sticky: "session",
};

const result = await resolverFor(module, httpCtx, new Request("https://example.com/"));

assertEquals(result, true);

const setCookies = getSetCookies(respHeaders);
assertEquals(setCookies.length, 1, "expected one Set-Cookie on respHeaders");
assert(
setCookies[0].name.startsWith(DECO_MATCHER_PREFIX),
`expected Set-Cookie name to start with ${DECO_MATCHER_PREFIX}, got ${
setCookies[0].name
}`,
);

const vary = respHeaders.get("vary") ?? "";
assert(
!vary.toLowerCase().includes("cookie"),
`expected Vary header to NOT contain "cookie", got: ${vary}`,
);
});

Deno.test("sticky matcher with matching cookie does NOT set a cookie or Vary", async () => {
const respHeaders = new Headers();
const httpCtx = buildHttpCtx(respHeaders);

const module: MatcherStickySessionModule = {
default: () => true,
sticky: "session",
};

// Build the cookie name the matcher would use, then set it on the request
// with a value that decodes to `true` so result === isMatchFromCookie.
const { Murmurhash3 } = await import("../deps.ts");
const h = new Murmurhash3();
h.hash("test-matcher-id");
const cookieName = `${DECO_MATCHER_PREFIX}${h.result()}`;
// cookieValue.build: btoa(id) + "@" + (result ? 1 : 0)
const cookieVal = `${btoa("test-matcher-id")}@1`;

const request = new Request("https://example.com/", {
headers: { cookie: `${cookieName}=${cookieVal}` },
});

const result = await resolverFor(module, httpCtx, request);
assertEquals(result, true);

assertEquals(
getSetCookies(respHeaders).length,
0,
"expected no Set-Cookie when cookie value already matches result",
);
assertEquals(
respHeaders.get("vary"),
null,
"expected no Vary header when nothing was emitted",
);
});

Deno.test("non-sticky matcher does not touch respHeaders", async () => {
const respHeaders = new Headers();
const httpCtx = buildHttpCtx(respHeaders);

const module = {
default: () => true,
sticky: "none" as const,
};

const result = await resolverFor(
module as any,
httpCtx,
new Request("https://example.com/"),
);
assertEquals(result, true);

assertEquals(getSetCookies(respHeaders).length, 0);
assertEquals(respHeaders.get("vary"), null);
});

// Regression guard: if anyone re-adds Vary: cookie inside the sticky branch,
// this scan will fail. The string check is deliberately broad.
Deno.test("matcher.ts source does not append Vary: cookie", async () => {
const src = await Deno.readTextFile(new URL("./matcher.ts", import.meta.url));
assert(
!/append\(\s*["']vary["']\s*,\s*["']cookie["']\s*\)/i.test(src),
"blocks/matcher.ts must not append Vary: cookie — that disables CDN caching",
);
// Sanity: ensure the cookie-setting code path is still there.
assertStringIncludes(src, "setCookie(respHeaders");
});

async function resolverFor(
module: MatcherStickySessionModule | { default: any; sticky: "none" },
httpCtx: any,
request: Request,
): Promise<boolean> {
const adapt = matcherBlock.adapt as any;
const resolver = adapt(module, "test-matcher-id")({}, httpCtx);
return await resolver(buildMatchCtx(request));
}
1 change: 0 additions & 1 deletion blocks/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,6 @@ const matcherBlock: Block<
sameSite: "Lax",
expires: date,
});
respHeaders.append("vary", "cookie");
}
}

Expand Down
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@deco/deco",
"version": "1.197.0",
"version": "1.200.1-next.1",
"lock": false,
"nodeModulesDir": "auto",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion dev/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@deco/dev",
"version": "1.197.0",
"version": "1.200.1-next.1",
"exports": {
"./tailwind": "./tailwind.ts"
},
Expand Down
128 changes: 128 additions & 0 deletions runtime/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { assert, assertEquals } from "@std/assert";
import { setCookie } from "../utils/cookies.ts";
import { DECO_MATCHER_PREFIX } from "../blocks/matcher.ts";
import { applyPageCacheDecision, DECO_SEGMENT } from "./middleware.ts";

const matcherCookie = `${DECO_MATCHER_PREFIX}1234567890_0.5`;

const pageInput = {
flags: [],
isPageCacheAllowed: true,
shouldCacheFromVary: true,
};

Deno.test("no matcher, no Set-Cookie → public cache-control", () => {
const headers = new Headers({ "Content-Type": "text/html" });
applyPageCacheDecision(headers, pageInput);
const cc = headers.get("Cache-Control") ?? "";
assert(cc.startsWith("public,"), `expected public Cache-Control, got: ${cc}`);
assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null);
});

Deno.test("matcher Set-Cookie only → public cache-control + hint header", () => {
const headers = new Headers({ "Content-Type": "text/html" });
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });
setCookie(headers, { name: DECO_SEGMENT, value: "%7B%7D", path: "/" });

applyPageCacheDecision(headers, pageInput);

const cc = headers.get("Cache-Control") ?? "";
assert(cc.startsWith("public,"), `expected public Cache-Control, got: ${cc}`);

const hint = headers.get("Deco-Cache-Vary-Cookies") ?? "";
assert(
hint.includes(matcherCookie),
`expected hint to include matcher cookie name, got: ${hint}`,
);
assert(
hint.includes(DECO_SEGMENT),
`expected hint to include deco_segment, got: ${hint}`,
);
});

Deno.test("foreign Set-Cookie → no-store (safety preserved)", () => {
const headers = new Headers({ "Content-Type": "text/html" });
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });
setCookie(headers, { name: "cart_count", value: "3", path: "/" });

applyPageCacheDecision(headers, pageInput);

assertEquals(
headers.get("Cache-Control"),
"no-store, no-cache, must-revalidate",
);
assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null);
});

Deno.test("vary.shouldCache=false (personalizing loader) → no-store", () => {
const headers = new Headers({ "Content-Type": "text/html" });
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });

applyPageCacheDecision(headers, {
...pageInput,
shouldCacheFromVary: false,
});

assertEquals(
headers.get("Cache-Control"),
"no-store, no-cache, must-revalidate",
);
assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null);
});

Deno.test("flag with cacheable:false → no-store", () => {
const headers = new Headers({ "Content-Type": "text/html" });

applyPageCacheDecision(headers, {
flags: [{ cacheable: false }],
isPageCacheAllowed: true,
shouldCacheFromVary: true,
});

assertEquals(
headers.get("Cache-Control"),
"no-store, no-cache, must-revalidate",
);
});

Deno.test("isPageCacheAllowed=false → headers untouched", () => {
const headers = new Headers({ "Content-Type": "text/html" });
setCookie(headers, { name: matcherCookie, value: "abc@1", path: "/" });

applyPageCacheDecision(headers, {
...pageInput,
isPageCacheAllowed: false,
});

assertEquals(headers.get("Cache-Control"), null);
assertEquals(headers.get("Deco-Cache-Vary-Cookies"), null);
});

Deno.test("respects pre-existing Cache-Control header", () => {
const headers = new Headers({
"Content-Type": "text/html",
"Cache-Control": "public, max-age=600",
});

applyPageCacheDecision(headers, pageInput);

assertEquals(headers.get("Cache-Control"), "public, max-age=600");
});

Deno.test(
"cacheDisqualified overrides a pre-existing Cache-Control header",
() => {
const headers = new Headers({
"Content-Type": "text/html",
"Cache-Control": "public, max-age=600",
});
setCookie(headers, { name: "session_id", value: "xyz", path: "/" });

applyPageCacheDecision(headers, pageInput);

assertEquals(
headers.get("Cache-Control"),
"no-store, no-cache, must-revalidate",
);
},
);
Loading
Loading