From 3f91a3e095cb59fc67900045343a55ee83363823 Mon Sep 17 00:00:00 2001 From: robbond Date: Wed, 4 Nov 2026 06:54:36 +0000 Subject: [PATCH] fix(security): replace nookies with patched cookie utility --- components/AIPolicyNotice.js | 2 +- components/account/personaldetailsComplete.js | 2 +- components/case/summary.js | 2 +- components/case/watchmodal.js | 2 +- components/cookiemanager/index.js | 2 +- components/header.js | 2 +- components/homepage/login.js | 2 +- .../myportal/awaitingsubmissionfromblob.js | 2 +- components/myportal/topthree.js | 2 +- components/myportal/topthree_reps.js | 2 +- components/myportal/viewall.js | 2 +- components/search/addresssearchresults.js | 2 +- components/search/dnssearchresults.js | 2 +- components/search/searchresults.js | 2 +- lib/auth/sessionClient.js | 2 +- lib/cookies.js | 120 +++++++ package-lock.json | 47 +-- package.json | 3 +- pages/_document.js | 2 +- pages/_error.js | 2 +- pages/account/personaldetails.js | 2 +- pages/auth/signin.js | 2 +- pages/error.js | 2 +- pages/index.js | 2 +- tests/security/cookie-compatibility.test.cjs | 331 ++++++++++++++++++ 25 files changed, 487 insertions(+), 56 deletions(-) create mode 100644 lib/cookies.js create mode 100644 tests/security/cookie-compatibility.test.cjs diff --git a/components/AIPolicyNotice.js b/components/AIPolicyNotice.js index 5df8bbe5..7b59fc49 100644 --- a/components/AIPolicyNotice.js +++ b/components/AIPolicyNotice.js @@ -1,5 +1,5 @@ import useTranslation from "next-translate/useTranslation"; -import { parseCookies, setCookie } from "nookies"; +import { parseCookies, setCookie } from "../lib/cookies"; import { useEffect, useRef, useState } from "react"; const COOKIE_NAME = "pedwAiPolicyNoticeHidden"; diff --git a/components/account/personaldetailsComplete.js b/components/account/personaldetailsComplete.js index cc2ec545..cde6c1cd 100644 --- a/components/account/personaldetailsComplete.js +++ b/components/account/personaldetailsComplete.js @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import React, { useEffect } from "react"; import { connect } from "react-redux"; import { updateAccount } from "../../actions/services/accountService"; -import { setCookie } from "nookies"; +import { setCookie } from "../../lib/cookies"; const PersonalDetailsComplete = (props) => { const { diff --git a/components/case/summary.js b/components/case/summary.js index 14944634..2f308d46 100644 --- a/components/case/summary.js +++ b/components/case/summary.js @@ -32,7 +32,7 @@ import { } from "../../store/watchedCases/action"; import { signIn, useSession } from "next-auth/react"; -import { destroyCookie, parseCookies, setCookie } from "nookies"; +import { destroyCookie, parseCookies, setCookie } from "../../lib/cookies"; import { getFormCollectionByID, getDetailsProxy, hasOwn } from "../utils"; import { getLivePublishedEvent, diff --git a/components/case/watchmodal.js b/components/case/watchmodal.js index e085e930..34a6bc9d 100644 --- a/components/case/watchmodal.js +++ b/components/case/watchmodal.js @@ -2,7 +2,7 @@ import { signOut } from "next-auth/react"; import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; import { useState, useEffect } from "react"; -import { destroyCookie, parseCookies, setCookie } from "nookies"; +import { destroyCookie, parseCookies, setCookie } from "../../lib/cookies"; import { signIn, useSession } from "next-auth/react"; const WatchModal = (props) => { diff --git a/components/cookiemanager/index.js b/components/cookiemanager/index.js index 75fd5d00..862070f5 100644 --- a/components/cookiemanager/index.js +++ b/components/cookiemanager/index.js @@ -1,7 +1,7 @@ import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; -import { parseCookies } from "nookies"; +import { parseCookies } from "../../lib/cookies"; import { useState } from "react"; import { connect } from "react-redux"; import { reduxForm } from "redux-form"; diff --git a/components/header.js b/components/header.js index 2e6568ca..96aae02d 100644 --- a/components/header.js +++ b/components/header.js @@ -2,7 +2,7 @@ import _ from "lodash"; import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; -import { destroyCookie, setCookie } from "nookies"; +import { destroyCookie, setCookie } from "../lib/cookies"; import { connect } from "react-redux"; import Banner from "../components/banner"; import { setLogout } from "../store/accountDetails/action"; diff --git a/components/homepage/login.js b/components/homepage/login.js index 496f37f0..11f7ed44 100644 --- a/components/homepage/login.js +++ b/components/homepage/login.js @@ -2,7 +2,7 @@ import useTranslation from "next-translate/useTranslation"; import { useRouter } from "next/router"; import { useEffect, useState } from "react"; import { connect } from "react-redux"; -import { parseCookies } from "nookies"; +import { parseCookies } from "../../lib/cookies"; import { setLoggedInUserId } from "../../store/accountDetails/action"; import { signOut, useSession } from "next-auth/react"; import CaseComment from "./casecomment"; diff --git a/components/myportal/awaitingsubmissionfromblob.js b/components/myportal/awaitingsubmissionfromblob.js index 8ea4055e..ce22c602 100644 --- a/components/myportal/awaitingsubmissionfromblob.js +++ b/components/myportal/awaitingsubmissionfromblob.js @@ -1,7 +1,7 @@ import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; -import { parseCookies } from "nookies"; +import { parseCookies } from "../../lib/cookies"; import { connect } from "react-redux"; import { deleteAwaitingSubmissionsFromBlob, diff --git a/components/myportal/topthree.js b/components/myportal/topthree.js index bc8fe812..40dcef0d 100644 --- a/components/myportal/topthree.js +++ b/components/myportal/topthree.js @@ -2,7 +2,7 @@ import _ from "lodash"; import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; -import { parseCookies } from "nookies"; +import { parseCookies } from "../../lib/cookies"; import { connect } from "react-redux"; import { deleteAwaitingSubmissions, diff --git a/components/myportal/topthree_reps.js b/components/myportal/topthree_reps.js index 2d11eb9a..8d491a9f 100644 --- a/components/myportal/topthree_reps.js +++ b/components/myportal/topthree_reps.js @@ -1,7 +1,7 @@ import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; -import { parseCookies } from "nookies"; +import { parseCookies } from "../../lib/cookies"; import { connect } from "react-redux"; import { deleteAwaitingSubmissions, diff --git a/components/myportal/viewall.js b/components/myportal/viewall.js index 8792a793..4adde0b5 100644 --- a/components/myportal/viewall.js +++ b/components/myportal/viewall.js @@ -3,7 +3,7 @@ import _ from "lodash"; import useTranslation from "next-translate/useTranslation"; import Link from "next/link"; import { useRouter } from "next/router"; -import { parseCookies } from "nookies"; +import { parseCookies } from "../../lib/cookies"; import { useState } from "react"; import { connect } from "react-redux"; import { diff --git a/components/search/addresssearchresults.js b/components/search/addresssearchresults.js index d38eaa07..1d2c8183 100644 --- a/components/search/addresssearchresults.js +++ b/components/search/addresssearchresults.js @@ -8,7 +8,7 @@ import { connect } from "react-redux"; import transLookup from "../../data/lookuptranslations.json"; import { setCurrentReference } from "../../store/currentView/action"; -import { parseCookies, setCookie } from "nookies"; +import { parseCookies, setCookie } from "../../lib/cookies"; import { useEffect } from "react"; import { setLoggedInUserId } from "../../store/accountDetails/action"; import { diff --git a/components/search/dnssearchresults.js b/components/search/dnssearchresults.js index c67f21d5..81d05251 100644 --- a/components/search/dnssearchresults.js +++ b/components/search/dnssearchresults.js @@ -15,7 +15,7 @@ import { } from "../../actions/services/searchService"; import GoogleMapReact from "google-map-react"; -import { parseCookies } from "nookies"; +import { parseCookies } from "../../lib/cookies"; import OSPoint from "ospoint"; import { createWatchedCases, diff --git a/components/search/searchresults.js b/components/search/searchresults.js index 316c75a1..2207e862 100644 --- a/components/search/searchresults.js +++ b/components/search/searchresults.js @@ -14,7 +14,7 @@ import { setCurrentReference } from "../../store/currentView/action"; -import { parseCookies, setCookie } from "nookies"; +import { parseCookies, setCookie } from "../../lib/cookies"; import { useEffect } from "react"; import { setLoggedInUserId } from "../../store/accountDetails/action"; import { diff --git a/lib/auth/sessionClient.js b/lib/auth/sessionClient.js index 3783b75a..35b135d8 100644 --- a/lib/auth/sessionClient.js +++ b/lib/auth/sessionClient.js @@ -1,4 +1,4 @@ -import { destroyCookie } from "nookies"; +import { destroyCookie } from "../cookies"; import { signOut } from "next-auth/react"; const AUTH_COOKIE_CANDIDATES = [ diff --git a/lib/cookies.js b/lib/cookies.js new file mode 100644 index 00000000..cf70a6f6 --- /dev/null +++ b/lib/cookies.js @@ -0,0 +1,120 @@ +import * as cookie from "cookie"; +import setCookieParser from "set-cookie-parser"; + +const isBrowser = () => typeof window !== "undefined"; + +const createCookie = (name, value, options) => { + let sameSite = options.sameSite; + + if (sameSite === true) { + sameSite = "strict"; + } + + if (sameSite === undefined || sameSite === false) { + sameSite = "lax"; + } + + const cookieToSet = { ...options, sameSite }; + delete cookieToSet.encode; + + return { name, value, ...cookieToSet }; +}; + +const hasSameProperties = (firstCookie, secondCookie) => { + const firstProperties = Object.getOwnPropertyNames(firstCookie); + const secondProperties = Object.getOwnPropertyNames(secondCookie); + + if (firstProperties.length !== secondProperties.length) { + return false; + } + + return firstProperties.every( + (propertyName) => + firstCookie[propertyName] === secondCookie[propertyName] + ); +}; + +const areCookiesEqual = (firstCookie, secondCookie) => { + let sameSiteMatches = firstCookie.sameSite === secondCookie.sameSite; + + if ( + typeof firstCookie.sameSite === "string" && + typeof secondCookie.sameSite === "string" + ) { + sameSiteMatches = + firstCookie.sameSite.toLowerCase() === + secondCookie.sameSite.toLowerCase(); + } + + return ( + hasSameProperties( + { ...firstCookie, sameSite: undefined }, + { ...secondCookie, sameSite: undefined } + ) && sameSiteMatches + ); +}; + +export const parseCookies = (ctx, options) => { + if (ctx?.req?.headers?.cookie) { + return cookie.parse(ctx.req.headers.cookie, options); + } + + if (isBrowser()) { + return cookie.parse(document.cookie, options); + } + + return {}; +}; + +export const setCookie = (ctx, name, value, options = {}) => { + if (ctx?.res?.getHeader && ctx.res.setHeader) { + if (ctx?.res?.finished) { + console.warn(`Not setting "${name}" cookie. Response has finished.`); + console.warn("You should set cookie before res.send()"); + return {}; + } + + let cookies = ctx.res.getHeader("Set-Cookie") || []; + + if (typeof cookies === "string") { + cookies = [cookies]; + } + + if (typeof cookies === "number") { + cookies = []; + } + + const parsedCookies = setCookieParser.parse(cookies, { + decodeValues: false + }); + const newCookie = createCookie(name, value, options); + const cookiesToSet = []; + + parsedCookies.forEach((parsedCookie) => { + if (!areCookiesEqual(parsedCookie, newCookie)) { + cookiesToSet.push( + cookie.serialize(parsedCookie.name, parsedCookie.value, { + encode: (cookieValue) => cookieValue, + ...parsedCookie + }) + ); + } + }); + + cookiesToSet.push(cookie.serialize(name, value, options)); + ctx.res.setHeader("Set-Cookie", cookiesToSet); + } + + if (isBrowser()) { + if (options?.httpOnly) { + throw new Error("Can not set a httpOnly cookie in the browser."); + } + + document.cookie = cookie.serialize(name, value, options); + } + + return {}; +}; + +export const destroyCookie = (ctx, name, options) => + setCookie(ctx, name, "", { ...(options || {}), maxAge: -1 }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 080300ec..608ee456 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@xmldom/xmldom": "^0.8.10", "applicationinsights": "^2.9.1", "axios": "^1.21.0", + "cookie": "0.7.2", "cookie-cutter": "^0.2.0", "crypto-js": "^4.2.0", "date-fns": "^2.30.0", @@ -46,7 +47,6 @@ "next-translate": "^2.6.2", "next-translate-plugin": "^2.6.2", "nodemailer": "7.0.13", - "nookies": "^2.5.2", "notifications-node-client": "^7.0.6", "openapi-types": "^12.1.3", "ospoint": "^0.2.1", @@ -73,6 +73,7 @@ "redux-thunk": "^2.4.2", "sass": "1.69", "sass-loader": "13.3.2", + "set-cookie-parser": "2.7.0", "tslib": "^2.7.0", "typescript": "^5.3.2", "uuid": "9.0.0", @@ -3716,6 +3717,15 @@ "emitter-listener": "^1.1.1" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-cutter": { "version": "0.2.0", "license": "MIT" @@ -4919,14 +4929,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -6940,14 +6942,6 @@ } } }, - "node_modules/next-auth/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/next-auth/node_modules/preact": { "version": "10.24.0", "license": "MIT", @@ -7101,23 +7095,6 @@ "node": ">=6.0.0" } }, - "node_modules/nookies": { - "version": "2.5.2", - "license": "MIT", - "dependencies": { - "cookie": "^0.4.1", - "set-cookie-parser": "^2.4.6" - } - }, - "node_modules/nookies/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "license": "MIT", @@ -8629,6 +8606,8 @@ }, "node_modules/set-cookie-parser": { "version": "2.7.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.0.tgz", + "integrity": "sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ==", "license": "MIT" }, "node_modules/set-function-length": { diff --git a/package.json b/package.json index 9e0f536d..0154e093 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@xmldom/xmldom": "^0.8.10", "applicationinsights": "^2.9.1", "axios": "^1.21.0", + "cookie": "0.7.2", "cookie-cutter": "^0.2.0", "crypto-js": "^4.2.0", "date-fns": "^2.30.0", @@ -68,7 +69,6 @@ "next-translate": "^2.6.2", "next-translate-plugin": "^2.6.2", "nodemailer": "7.0.13", - "nookies": "^2.5.2", "notifications-node-client": "^7.0.6", "openapi-types": "^12.1.3", "ospoint": "^0.2.1", @@ -95,6 +95,7 @@ "redux-thunk": "^2.4.2", "sass": "1.69", "sass-loader": "13.3.2", + "set-cookie-parser": "2.7.0", "tslib": "^2.7.0", "typescript": "^5.3.2", "uuid": "9.0.0", diff --git a/pages/_document.js b/pages/_document.js index 0be14036..bd5d4601 100644 --- a/pages/_document.js +++ b/pages/_document.js @@ -2,7 +2,7 @@ import Document, { Html, Head, Main, NextScript } from "next/document"; import SkipLink from "../components/skiplink"; import { Tracking, TrackingNoScript } from "../components/tracking"; //import { nanoid } from "nanoid"; -import { setCookie } from "nookies"; +import { setCookie } from "../lib/cookies"; import Script from "next/script"; class MyDocument extends Document { diff --git a/pages/_error.js b/pages/_error.js index 37d84e95..fc5416c8 100644 --- a/pages/_error.js +++ b/pages/_error.js @@ -4,7 +4,7 @@ import Link from "next/link"; import CookieBanner from "../components/cookieBanner"; import Footer from "../components/footer"; import Header from "../components/header"; -import { parseCookies, setCookie, destroyCookie } from "nookies"; +import { parseCookies, setCookie, destroyCookie } from "../lib/cookies"; import { useSession, signIn, signOut } from "next-auth/react"; function Error({ statusCode }, props) { diff --git a/pages/account/personaldetails.js b/pages/account/personaldetails.js index f185dee0..706be197 100644 --- a/pages/account/personaldetails.js +++ b/pages/account/personaldetails.js @@ -20,7 +20,7 @@ import { reduxForm, formValueSelector } from "redux-form"; import PersonalDetails from "../../components/account/personaldetails"; import { setAccountDetails } from "../../store/accountDetails/action"; import { getPersonalAccount } from "../../actions/services/accountService"; -import { setCookie } from "nookies"; +import { setCookie } from "../../lib/cookies"; import { getSession, useSession } from "next-auth/react"; import NoSessionWarning from "../../components/nosession"; diff --git a/pages/auth/signin.js b/pages/auth/signin.js index 1015c343..c03c9cb9 100644 --- a/pages/auth/signin.js +++ b/pages/auth/signin.js @@ -7,7 +7,7 @@ import CookieBanner from "../../components/cookieBanner"; import Footer from "../../components/footer"; import Header from "../../components/header"; import ServiceBanner from "../../components/servicebanner"; -import { setCookie } from "nookies"; +import { setCookie } from "../../lib/cookies"; const SignIn = (props) => { let { t, lang } = useTranslation(); diff --git a/pages/error.js b/pages/error.js index c1de6a39..be5a1f19 100644 --- a/pages/error.js +++ b/pages/error.js @@ -2,7 +2,7 @@ import { signOut, useSession } from "next-auth/react"; import useTranslation from "next-translate/useTranslation"; import Head from "next/head"; import Router from "next/router"; -import { destroyCookie } from "nookies"; +import { destroyCookie } from "../lib/cookies"; import CookieBanner from "../components/cookieBanner"; import Footer from "../components/footer"; import Header from "../components/header"; diff --git a/pages/index.js b/pages/index.js index 16f1b749..d8026ffb 100644 --- a/pages/index.js +++ b/pages/index.js @@ -3,7 +3,7 @@ import { getSession } from "next-auth/react"; import useTranslation from "next-translate/useTranslation"; import Head from "next/head"; import { useRouter } from "next/router"; -import { setCookie } from "nookies"; +import { setCookie } from "../lib/cookies"; import { connect } from "react-redux"; import { hashString } from "../actions/core/hash"; import { getIP } from "../actions/core/logger"; diff --git a/tests/security/cookie-compatibility.test.cjs b/tests/security/cookie-compatibility.test.cjs new file mode 100644 index 00000000..d330320c --- /dev/null +++ b/tests/security/cookie-compatibility.test.cjs @@ -0,0 +1,331 @@ +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: "/" })), + {} + ); +}); \ No newline at end of file