331 lines
10 KiB
JavaScript
331 lines
10 KiB
JavaScript
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const test = require("node:test");
|
|
const vm = require("node:vm");
|
|
|
|
const rootDir = path.resolve(__dirname, "..", "..");
|
|
const normalize = (value) => JSON.parse(JSON.stringify(value));
|
|
|
|
const loadLocalCookieCompatibility = () => {
|
|
const filePath = path.join(rootDir, "lib", "cookies.js");
|
|
let source = fs.readFileSync(filePath, "utf8");
|
|
|
|
source = source.replace(
|
|
'import * as cookie from "cookie";',
|
|
'const cookie = require("cookie");'
|
|
);
|
|
source = source.replace(
|
|
'import setCookieParser from "set-cookie-parser";',
|
|
'const setCookieParser = require("set-cookie-parser");'
|
|
);
|
|
source = source.replace(/export const\s+/g, "const ");
|
|
source += "\nmodule.exports = { parseCookies, setCookie, destroyCookie };\n";
|
|
|
|
const context = {
|
|
module: { exports: {} },
|
|
exports: {},
|
|
require,
|
|
console,
|
|
document: global.document,
|
|
window: global.window
|
|
};
|
|
|
|
vm.runInNewContext(source, context, { filename: filePath });
|
|
return context.module.exports;
|
|
};
|
|
|
|
const loadCookieImplementation = () => {
|
|
return loadLocalCookieCompatibility();
|
|
};
|
|
|
|
const withBrowserDocument = async (initialCookies, callback) => {
|
|
const previousDocument = global.document;
|
|
const previousWindow = global.window;
|
|
const cookieJar = new Map(Object.entries(initialCookies));
|
|
const writes = [];
|
|
|
|
const documentMock = {};
|
|
Object.defineProperty(documentMock, "cookie", {
|
|
configurable: true,
|
|
get() {
|
|
return Array.from(cookieJar.entries())
|
|
.map(([name, value]) => `${name}=${value}`)
|
|
.join("; ");
|
|
},
|
|
set(serializedCookie) {
|
|
writes.push(serializedCookie);
|
|
const [nameValue, ...attributes] = serializedCookie.split(";");
|
|
const separatorIndex = nameValue.indexOf("=");
|
|
const name = nameValue.slice(0, separatorIndex);
|
|
const value = nameValue.slice(separatorIndex + 1);
|
|
const maxAge = attributes
|
|
.map((attribute) => attribute.trim())
|
|
.find((attribute) =>
|
|
attribute.toLowerCase().startsWith("max-age=")
|
|
);
|
|
|
|
if (maxAge && Number(maxAge.split("=")[1]) < 0) {
|
|
cookieJar.delete(name);
|
|
return;
|
|
}
|
|
|
|
cookieJar.set(name, value);
|
|
}
|
|
});
|
|
|
|
global.window = {};
|
|
global.document = documentMock;
|
|
|
|
try {
|
|
await callback({ cookieJar, writes });
|
|
} finally {
|
|
if (previousWindow === undefined) {
|
|
delete global.window;
|
|
} else {
|
|
global.window = previousWindow;
|
|
}
|
|
|
|
if (previousDocument === undefined) {
|
|
delete global.document;
|
|
} else {
|
|
global.document = previousDocument;
|
|
}
|
|
}
|
|
};
|
|
|
|
const createResponse = (initialHeader) => {
|
|
let setCookieHeader = initialHeader;
|
|
const calls = [];
|
|
|
|
return {
|
|
response: {
|
|
finished: false,
|
|
getHeader(name) {
|
|
assert.strictEqual(name, "Set-Cookie");
|
|
return setCookieHeader;
|
|
},
|
|
setHeader(name, value) {
|
|
calls.push({ name, value });
|
|
setCookieHeader = value;
|
|
}
|
|
},
|
|
calls,
|
|
getSetCookieHeader: () => setCookieHeader
|
|
};
|
|
};
|
|
|
|
test("browser parsing preserves characterized nookies behavior", async () => {
|
|
await withBrowserDocument(
|
|
{
|
|
first: "one",
|
|
encoded: "two%20words",
|
|
equals: "a%3Db",
|
|
spaces: "hello%20world",
|
|
special: "%21%40%23%24%25%5E%26*%28%29",
|
|
malformed: "%E0%A4%A"
|
|
},
|
|
() => {
|
|
const { parseCookies } = loadCookieImplementation();
|
|
|
|
assert.deepStrictEqual(parseCookies(), {
|
|
first: "one",
|
|
encoded: "two words",
|
|
equals: "a=b",
|
|
spaces: "hello world",
|
|
special: "!@#$%^&*()",
|
|
malformed: "%E0%A4%A"
|
|
});
|
|
}
|
|
);
|
|
|
|
await withBrowserDocument({}, () => {
|
|
const { parseCookies } = loadCookieImplementation();
|
|
assert.deepStrictEqual(parseCookies(), {});
|
|
});
|
|
});
|
|
|
|
test("parseCookies returns an empty object without a browser document", () => {
|
|
const { parseCookies } = loadCookieImplementation();
|
|
|
|
assert.deepStrictEqual(normalize(parseCookies()), {});
|
|
assert.deepStrictEqual(normalize(parseCookies(null)), {});
|
|
assert.deepStrictEqual(normalize(parseCookies({})), {});
|
|
});
|
|
|
|
test("browser writes preserve encoding and supported options", async () => {
|
|
await withBrowserDocument({}, ({ writes }) => {
|
|
const { setCookie } = loadCookieImplementation();
|
|
const expires = new Date("2030-01-02T03:04:05.000Z");
|
|
|
|
assert.deepStrictEqual(
|
|
normalize(setCookie(null, "basic", "value")),
|
|
{}
|
|
);
|
|
setCookie(null, "pathCookie", "value", { path: "/" });
|
|
setCookie(null, "expiresCookie", "value", { expires });
|
|
setCookie(null, "maxAgeCookie", "value", { maxAge: 60 });
|
|
setCookie(null, "encodedCookie", "hello world=a&b");
|
|
|
|
assert.deepStrictEqual(writes, [
|
|
"basic=value",
|
|
"pathCookie=value; Path=/",
|
|
"expiresCookie=value; Expires=Wed, 02 Jan 2030 03:04:05 GMT",
|
|
"maxAgeCookie=value; Max-Age=60",
|
|
"encodedCookie=hello%20world%3Da%26b"
|
|
]);
|
|
});
|
|
});
|
|
|
|
test("browser writes reject httpOnly cookies", async () => {
|
|
await withBrowserDocument({}, () => {
|
|
const { setCookie } = loadCookieImplementation();
|
|
|
|
assert.throws(
|
|
() => setCookie(null, "protected", "value", { httpOnly: true }),
|
|
/Can not set a httpOnly cookie in the browser\./
|
|
);
|
|
});
|
|
});
|
|
|
|
test("browser deletion preserves path and uses an empty value with negative maxAge", async () => {
|
|
await withBrowserDocument(
|
|
{
|
|
pedwWatchCase: "case-1",
|
|
pinsUser: "contact-1",
|
|
pedw_locale: "cy"
|
|
},
|
|
({ cookieJar, writes }) => {
|
|
const { destroyCookie } = loadCookieImplementation();
|
|
|
|
for (const name of ["pedwWatchCase", "pinsUser", "pedw_locale"]) {
|
|
assert.deepStrictEqual(
|
|
normalize(destroyCookie(null, name, { path: "/" })),
|
|
{}
|
|
);
|
|
}
|
|
|
|
assert.deepStrictEqual(writes, [
|
|
"pedwWatchCase=; Max-Age=-1; Path=/",
|
|
"pinsUser=; Max-Age=-1; Path=/",
|
|
"pedw_locale=; Max-Age=-1; Path=/"
|
|
]);
|
|
assert.strictEqual(cookieJar.has("pedwWatchCase"), false);
|
|
assert.strictEqual(cookieJar.has("pinsUser"), false);
|
|
assert.strictEqual(cookieJar.has("pedw_locale"), false);
|
|
}
|
|
);
|
|
});
|
|
|
|
test("server parsing uses ctx.req.headers.cookie", () => {
|
|
const { parseCookies } = loadCookieImplementation();
|
|
|
|
assert.deepStrictEqual(
|
|
parseCookies({
|
|
req: {
|
|
headers: {
|
|
cookie: "first=one; encoded=two%20words; equals=a%3Db"
|
|
}
|
|
}
|
|
}),
|
|
{
|
|
first: "one",
|
|
encoded: "two words",
|
|
equals: "a=b"
|
|
}
|
|
);
|
|
});
|
|
|
|
test("server writes a Set-Cookie header and preserves unrelated cookies", () => {
|
|
const { setCookie } = loadCookieImplementation();
|
|
const responseState = createResponse([
|
|
"unrelated=keep; Path=/; SameSite=Lax",
|
|
"other=value; HttpOnly; Path=/; SameSite=Strict"
|
|
]);
|
|
|
|
assert.deepStrictEqual(
|
|
normalize(
|
|
setCookie(
|
|
{ res: responseState.response },
|
|
"target",
|
|
"new value",
|
|
{ path: "/" }
|
|
)
|
|
),
|
|
{}
|
|
);
|
|
|
|
assert.deepStrictEqual(normalize(responseState.getSetCookieHeader()), [
|
|
"unrelated=keep; Path=/; SameSite=Lax",
|
|
"other=value; Path=/; HttpOnly; SameSite=Strict",
|
|
"target=new%20value; Path=/"
|
|
]);
|
|
assert.strictEqual(responseState.calls.length, 1);
|
|
});
|
|
|
|
test("server deduplicates an exactly matching cookie and keeps cookies with changed values or paths", () => {
|
|
const { setCookie } = loadCookieImplementation();
|
|
const responseState = createResponse([
|
|
"target=new; Path=/; SameSite=Lax",
|
|
"target=old; Path=/; SameSite=Lax",
|
|
"target=keep-a; Path=/a; SameSite=Lax",
|
|
"target=keep-b; Path=/b; SameSite=Lax"
|
|
]);
|
|
|
|
setCookie({ res: responseState.response }, "target", "new", {
|
|
path: "/",
|
|
sameSite: "lax"
|
|
});
|
|
|
|
assert.deepStrictEqual(normalize(responseState.getSetCookieHeader()), [
|
|
"target=old; Path=/; SameSite=Lax",
|
|
"target=keep-a; Path=/a; SameSite=Lax",
|
|
"target=keep-b; Path=/b; SameSite=Lax",
|
|
"target=new; Path=/; SameSite=Lax"
|
|
]);
|
|
});
|
|
|
|
test("server does not write after the response has finished", () => {
|
|
const { setCookie } = loadCookieImplementation();
|
|
const responseState = createResponse();
|
|
responseState.response.finished = true;
|
|
const warnings = [];
|
|
const originalWarn = console.warn;
|
|
console.warn = (message) => warnings.push(message);
|
|
|
|
try {
|
|
assert.deepStrictEqual(
|
|
normalize(
|
|
setCookie(
|
|
{ res: responseState.response },
|
|
"target",
|
|
"value"
|
|
)
|
|
),
|
|
{}
|
|
);
|
|
} finally {
|
|
console.warn = originalWarn;
|
|
}
|
|
|
|
assert.deepStrictEqual(responseState.calls, []);
|
|
assert.deepStrictEqual(warnings, [
|
|
'Not setting "target" cookie. Response has finished.',
|
|
"You should set cookie before res.send()"
|
|
]);
|
|
});
|
|
|
|
test("server no-op behavior is preserved for null, empty, and undefined contexts", () => {
|
|
const { setCookie, destroyCookie } = loadCookieImplementation();
|
|
|
|
assert.deepStrictEqual(normalize(setCookie(null, "target", "value")), {});
|
|
assert.deepStrictEqual(normalize(setCookie({}, "target", "value")), {});
|
|
assert.deepStrictEqual(
|
|
normalize(setCookie(undefined, "target", "value")),
|
|
{}
|
|
);
|
|
assert.deepStrictEqual(
|
|
normalize(destroyCookie({}, "pedwWatchCase", { path: "/" })),
|
|
{}
|
|
);
|
|
}); |