From d91a07e289c3b57ec587f163bc029d31d4cec7eb Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 16 Jul 2024 15:42:00 +0200 Subject: [PATCH 1/8] fix: store response "set-cookie" header --- package.json | 3 +- pnpm-lock.yaml | 31 ++++---- src/core/utils/HttpResponse/decorators.ts | 48 ++++++++---- src/core/utils/cookieStore.ts | 3 + src/core/utils/handleRequest.ts | 6 +- .../request/getRequestCookies.node.test.ts | 29 ------- .../utils/request/getRequestCookies.test.ts | 64 ---------------- src/core/utils/request/getRequestCookies.ts | 76 +++---------------- src/core/utils/request/readResponseCookies.ts | 9 --- .../utils/request/storeResponseCookies.ts | 17 +++++ .../rest-api/cookies-http-only.mocks.ts | 29 +++++++ .../rest-api/cookies-http-only.test.ts | 17 +++++ 12 files changed, 126 insertions(+), 206 deletions(-) create mode 100644 src/core/utils/cookieStore.ts delete mode 100644 src/core/utils/request/getRequestCookies.node.test.ts delete mode 100644 src/core/utils/request/getRequestCookies.test.ts delete mode 100644 src/core/utils/request/readResponseCookies.ts create mode 100644 src/core/utils/request/storeResponseCookies.ts create mode 100644 test/browser/rest-api/cookies-http-only.mocks.ts create mode 100644 test/browser/rest-api/cookies-http-only.test.ts diff --git a/package.json b/package.json index 68dab022d..59b6a2e9a 100644 --- a/package.json +++ b/package.json @@ -136,11 +136,11 @@ "@bundled-es-modules/cookie": "^2.0.0", "@bundled-es-modules/statuses": "^1.0.1", "@inquirer/confirm": "^3.0.0", - "@mswjs/cookies": "^1.1.0", "@mswjs/interceptors": "^0.29.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", + "@types/tough-cookie": "^4.0.5", "chalk": "^4.1.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", @@ -148,6 +148,7 @@ "outvariant": "^1.4.2", "path-to-regexp": "^6.2.0", "strict-event-emitter": "^0.5.1", + "tough-cookie": "^4.1.4", "type-fest": "^4.9.0", "yargs": "^17.7.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2aa6f5fa4..30d1a13e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ dependencies: '@inquirer/confirm': specifier: ^3.0.0 version: 3.1.1 - '@mswjs/cookies': - specifier: ^1.1.0 - version: 1.1.0 '@mswjs/interceptors': specifier: ^0.29.0 version: 0.29.0 @@ -29,6 +26,9 @@ dependencies: '@types/statuses': specifier: ^2.0.4 version: 2.0.5 + '@types/tough-cookie': + specifier: ^4.0.5 + version: 4.0.5 chalk: specifier: ^4.1.2 version: 4.1.2 @@ -50,6 +50,9 @@ dependencies: strict-event-emitter: specifier: ^0.5.1 version: 0.5.1 + tough-cookie: + specifier: ^4.1.4 + version: 4.1.4 type-fest: specifier: ^4.9.0 version: 4.14.0 @@ -1405,11 +1408,6 @@ packages: - utf-8-validate dev: true - /@mswjs/cookies@1.1.0: - resolution: {integrity: sha512-0ZcCVQxifZmhwNBoQIrystCb+2sWBY2Zw8lpfJBPCHGCA/HWqehITeCRVIv4VMy8MPlaHo2w2pTHFV2pFfqKPw==} - engines: {node: '>=18'} - dev: false - /@mswjs/interceptors@0.29.0: resolution: {integrity: sha512-eppU9TxaRS2t5IcR00nuh+36zMHcK09pyhUvWJLO1ae5+U8KL7iatUGKlLUlbxXaq3BvDjlcF0Q8Xhzyosk/xA==} engines: {node: '>=18'} @@ -2107,6 +2105,10 @@ packages: resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==} dev: false + /@types/tough-cookie@4.0.5: + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + dev: false + /@types/uuid@8.3.4: resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} dev: true @@ -5458,7 +5460,7 @@ packages: rrweb-cssom: 0.6.0 saxes: 6.0.0 symbol-tree: 3.2.4 - tough-cookie: 4.1.3 + tough-cookie: 4.1.4 w3c-xmlserializer: 5.0.0 webidl-conversions: 7.0.0 whatwg-encoding: 3.1.1 @@ -6619,7 +6621,6 @@ packages: /psl@1.9.0: resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} - dev: true /pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} @@ -6631,7 +6632,6 @@ packages: /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - dev: true /qs@6.11.0: resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} @@ -6642,7 +6642,6 @@ packages: /querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} - dev: true /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -6784,7 +6783,6 @@ packages: /requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - dev: true /resolve-dir@1.0.1: resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==} @@ -7609,15 +7607,14 @@ packages: engines: {node: '>=0.6'} dev: true - /tough-cookie@4.1.3: - resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} + /tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} dependencies: psl: 1.9.0 punycode: 2.3.1 universalify: 0.2.0 url-parse: 1.5.10 - dev: true /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -7894,7 +7891,6 @@ packages: /universalify@0.2.0: resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} engines: {node: '>= 4.0.0'} - dev: true /universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} @@ -7944,7 +7940,6 @@ packages: dependencies: querystringify: 2.2.0 requires-port: 1.0.0 - dev: true /urlpattern-polyfill@4.0.3: resolution: {integrity: sha512-DOE84vZT2fEcl9gqCUTcnAw5ZY5Id55ikUcziSUntuEFL3pRvavg5kwDmTEUJkeCHInTlV/HexFomgYnzO5kdQ==} diff --git a/src/core/utils/HttpResponse/decorators.ts b/src/core/utils/HttpResponse/decorators.ts index 6af4a72bb..4db62c309 100644 --- a/src/core/utils/HttpResponse/decorators.ts +++ b/src/core/utils/HttpResponse/decorators.ts @@ -1,9 +1,11 @@ import statuses from '@bundled-es-modules/statuses' -import type { HttpResponseInit } from '../../HttpResponse' import { Headers as HeadersPolyfill } from 'headers-polyfill' +import type { HttpResponseInit } from '../../HttpResponse' const { message } = statuses +export const kSetCookie = Symbol('kSetCookie') + export interface HttpResponseDecoratedInit extends HttpResponseInit { status: number statusText: string @@ -38,21 +40,35 @@ export function decorateResponse( }) } - // Cookie forwarding is only relevant in the browser. - if (typeof document !== 'undefined') { - // Write the mocked response cookies to the document. - // Use `headers-polyfill` to get the Set-Cookie header value correctly. - // This is an alternative until TypeScript 5.2 - // and Node.js v20 become the minimum supported version - // and getSetCookie in Headers can be used directly. - const responseCookies = HeadersPolyfill.prototype.getSetCookie.call( - init.headers, - ) - - for (const cookieString of responseCookies) { - // No need to parse the cookie headers because it's defined - // as the valid cookie string to begin with. - document.cookie = cookieString + const responseCookies = init.headers.get('set-cookie') + + if (responseCookies) { + // Record the raw "Set-Cookie" response header provided + // in the HeadersInit. This is later used to store these cookies + // in cookie jar and return the right cookies in the "cookies" + // response resolver argument. + Object.defineProperty(response, kSetCookie, { + value: responseCookies, + enumerable: false, + writable: false, + }) + + // Cookie forwarding is only relevant in the browser. + if (typeof document !== 'undefined') { + // Write the mocked response cookies to the document. + // Use `headers-polyfill` to get the Set-Cookie header value correctly. + // This is an alternative until TypeScript 5.2 + // and Node.js v20 become the minimum supported version + // and getSetCookie in Headers can be used directly. + const responseCookiePairs = HeadersPolyfill.prototype.getSetCookie.call( + init.headers, + ) + + for (const cookieString of responseCookiePairs) { + // No need to parse the cookie headers because it's defined + // as the valid cookie string to begin with. + document.cookie = cookieString + } } } diff --git a/src/core/utils/cookieStore.ts b/src/core/utils/cookieStore.ts new file mode 100644 index 000000000..e215c9b92 --- /dev/null +++ b/src/core/utils/cookieStore.ts @@ -0,0 +1,3 @@ +import { CookieJar } from 'tough-cookie' + +export const cookieStore = new CookieJar() diff --git a/src/core/utils/handleRequest.ts b/src/core/utils/handleRequest.ts index e685a143b..766b22ce6 100644 --- a/src/core/utils/handleRequest.ts +++ b/src/core/utils/handleRequest.ts @@ -5,7 +5,7 @@ import { LifeCycleEventsMap, SharedOptions } from '../sharedOptions' import { RequiredDeep } from '../typeUtils' import { HandlersExecutionResult, executeHandlers } from './executeHandlers' import { onUnhandledRequest } from './request/onUnhandledRequest' -import { readResponseCookies } from './request/readResponseCookies' +import { storeResponseCookies } from './request/storeResponseCookies' export interface HandleRequestOptions { /** @@ -110,8 +110,8 @@ export async function handleRequest( return } - // Store all the received response cookies in the virtual cookie store. - readResponseCookies(request, response) + // Store all the received response cookies in the cookie jar. + storeResponseCookies(request, response) emitter.emit('request:match', { request, requestId }) diff --git a/src/core/utils/request/getRequestCookies.node.test.ts b/src/core/utils/request/getRequestCookies.node.test.ts deleted file mode 100644 index c48fb3bbd..000000000 --- a/src/core/utils/request/getRequestCookies.node.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @vitest-environment node - */ -import { getRequestCookies } from './getRequestCookies' - -const prevLocation = global.location - -beforeAll(() => { - // Node.js applications may polyfill some browser globals (document, location) - // when performing Server-Side Rendering of front-end applications. - global.location = { - href: 'https://mswjs.io', - origin: 'https://mswjs.io', - } as Location -}) - -afterAll(() => { - global.location = prevLocation -}) - -test('returns empty object when in a node environment with polyfilled location object', () => { - const cookies = getRequestCookies( - new Request(new URL('/user', location.href), { - credentials: 'include', - }), - ) - - expect(cookies).toEqual({}) -}) diff --git a/src/core/utils/request/getRequestCookies.test.ts b/src/core/utils/request/getRequestCookies.test.ts deleted file mode 100644 index 1b52fb128..000000000 --- a/src/core/utils/request/getRequestCookies.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { getRequestCookies } from './getRequestCookies' -import { clearCookies } from '../../../../test/support/utils' - -beforeAll(() => { - // Emulate some `document.cookie` value. - document.cookie = 'auth-token=abc-123;' - document.cookie = 'custom-cookie=yes;' - document.cookie = `encoded-cookie=${encodeURIComponent('测试')};` -}) - -afterAll(() => { - clearCookies() -}) - -test('returns all document cookies given "include" credentials', () => { - const cookies = getRequestCookies( - new Request(new URL('/user', location.origin), { - credentials: 'include', - }), - ) - - expect(cookies).toEqual({ - 'auth-token': 'abc-123', - 'custom-cookie': 'yes', - 'encoded-cookie': '测试', - }) -}) - -test('returns all document cookies given "same-origin" credentials and the same request origin', () => { - const cookies = getRequestCookies( - new Request(new URL('/user', location.origin), { - credentials: 'same-origin', - }), - ) - - expect(cookies).toEqual({ - 'auth-token': 'abc-123', - 'custom-cookie': 'yes', - 'encoded-cookie': '测试', - }) -}) - -test('returns an empty object given "same-origin" credentials and a different request origin', () => { - const cookies = getRequestCookies( - new Request(new URL('https://test.mswjs.io/user'), { - credentials: 'same-origin', - }), - ) - - expect(cookies).toEqual({}) -}) - -test('returns an empty object given "omit" credentials', () => { - const cookies = getRequestCookies( - new Request(new URL('/user', location.origin), { - credentials: 'omit', - }), - ) - - expect(cookies).toEqual({}) -}) diff --git a/src/core/utils/request/getRequestCookies.ts b/src/core/utils/request/getRequestCookies.ts index bbc5eb81f..f0b48d3a8 100644 --- a/src/core/utils/request/getRequestCookies.ts +++ b/src/core/utils/request/getRequestCookies.ts @@ -1,75 +1,19 @@ import cookieUtils from '@bundled-es-modules/cookie' -import { store } from '@mswjs/cookies' - -function getAllDocumentCookies() { - return cookieUtils.parse(document.cookie) -} - -/** @todo Rename this to "getDocumentCookies" */ -/** - * Returns relevant document cookies based on the request `credentials` option. - */ -export function getRequestCookies(request: Request): Record { - /** - * @note No cookies persist on the document in Node.js: no document. - */ - if (typeof document === 'undefined' || typeof location === 'undefined') { - return {} - } - - switch (request.credentials) { - case 'same-origin': { - const url = new URL(request.url) - - // Return document cookies only when requested a resource - // from the same origin as the current document. - return location.origin === url.origin ? getAllDocumentCookies() : {} - } - - case 'include': { - // Return all document cookies. - return getAllDocumentCookies() - } - - default: { - return {} - } - } -} +import { cookieStore } from '../cookieStore' export function getAllRequestCookies(request: Request): Record { - const requestCookiesString = request.headers.get('cookie') - const cookiesFromHeaders = requestCookiesString - ? cookieUtils.parse(requestCookiesString) + const requestCookies = request.headers.get('cookie') + const cookiesFromHeaders = requestCookies + ? cookieUtils.parse(requestCookies) : {} - - store.hydrate() - - const cookiesFromStore = Array.from(store.get(request)?.entries()).reduce< - Record - >((cookies, [name, { value }]) => { - return Object.assign(cookies, { [name.trim()]: value }) - }, {}) - - const cookiesFromDocument = getRequestCookies(request) - - const forwardedCookies = { - ...cookiesFromDocument, - ...cookiesFromStore, - } - - // Set the inferred cookies from the cookie store and the document - // on the request's headers. - /** - * @todo Consider making this a separate step so this function - * is pure-er. - */ - for (const [name, value] of Object.entries(forwardedCookies)) { - request.headers.append('cookie', cookieUtils.serialize(name, value)) - } + const cookiesFromStore = Object.fromEntries( + cookieStore + .getCookiesSync(request.url) + .map((cookie) => [cookie.key, cookie.value]), + ) return { - ...forwardedCookies, + ...cookiesFromStore, ...cookiesFromHeaders, } } diff --git a/src/core/utils/request/readResponseCookies.ts b/src/core/utils/request/readResponseCookies.ts deleted file mode 100644 index 0e01b6137..000000000 --- a/src/core/utils/request/readResponseCookies.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { store } from '@mswjs/cookies' - -export function readResponseCookies( - request: Request, - response: Response, -): void { - store.add({ ...request, url: request.url.toString() }, response) - store.persist() -} diff --git a/src/core/utils/request/storeResponseCookies.ts b/src/core/utils/request/storeResponseCookies.ts new file mode 100644 index 000000000..2a26f42dc --- /dev/null +++ b/src/core/utils/request/storeResponseCookies.ts @@ -0,0 +1,17 @@ +import { cookieStore } from '../cookieStore' +import { kSetCookie } from '../HttpResponse/decorators' + +export function storeResponseCookies( + request: Request, + response: Response, +): void { + // Grab the raw "Set-Cookie" response header provided + // in the HeadersInit for this mocked response. + const responseCookies = Reflect.get(response, kSetCookie) as + | string + | undefined + + if (responseCookies) { + cookieStore.setCookie(responseCookies, request.url) + } +} diff --git a/test/browser/rest-api/cookies-http-only.mocks.ts b/test/browser/rest-api/cookies-http-only.mocks.ts new file mode 100644 index 000000000..f9ac6401b --- /dev/null +++ b/test/browser/rest-api/cookies-http-only.mocks.ts @@ -0,0 +1,29 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.post('/login', () => { + return HttpResponse.text(null, { + headers: { + 'Set-Cookie': 'authToken=abc-123; HttpOnly', + }, + }) + }), + http.get('/user', ({ cookies }) => { + if (cookies.authToken == null) { + throw HttpResponse.json( + { + error: 'Not authenticated', + }, + { status: 403 }, + ) + } + + return HttpResponse.json({ + firstName: 'John', + lastName: 'Maverick', + }) + }), +) + +worker.start() diff --git a/test/browser/rest-api/cookies-http-only.test.ts b/test/browser/rest-api/cookies-http-only.test.ts new file mode 100644 index 000000000..3aa5fd9b1 --- /dev/null +++ b/test/browser/rest-api/cookies-http-only.test.ts @@ -0,0 +1,17 @@ +import { test, expect } from '../playwright.extend' + +test('inherits cookies set on a preceeding request', async ({ + loadExample, + fetch, +}) => { + await loadExample(require.resolve('./cookies-http-only.mocks.ts')) + + await fetch('/login', { method: 'POST' }) + const response = await fetch('/user') + + await expect(response.json()).resolves.toEqual({ + firstName: 'John', + lastName: 'Maverick', + }) + expect(response.status()).toBe(200) +}) From e152ee9e2be89e62dada5af6c76a88b6a0b7d9e9 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Tue, 16 Jul 2024 15:46:07 +0200 Subject: [PATCH 2/8] fix: access "CookieJar" as esInterop --- src/core/utils/cookieStore.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/utils/cookieStore.ts b/src/core/utils/cookieStore.ts index e215c9b92..0df6383f0 100644 --- a/src/core/utils/cookieStore.ts +++ b/src/core/utils/cookieStore.ts @@ -1,3 +1,5 @@ -import { CookieJar } from 'tough-cookie' +import * as toughCookie from 'tough-cookie' + +const { CookieJar } = toughCookie export const cookieStore = new CookieJar() From d200f527c8790d6f52f8878b6095b1ce469a6988 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Jul 2024 14:35:28 +0200 Subject: [PATCH 3/8] fix: forward document cookies --- src/core/utils/request/getRequestCookies.ts | 62 ++++++++++++++++--- test/browser/rest-api/cookies-request.test.ts | 32 ++++------ 2 files changed, 64 insertions(+), 30 deletions(-) diff --git a/src/core/utils/request/getRequestCookies.ts b/src/core/utils/request/getRequestCookies.ts index f0b48d3a8..8755bf835 100644 --- a/src/core/utils/request/getRequestCookies.ts +++ b/src/core/utils/request/getRequestCookies.ts @@ -1,19 +1,61 @@ import cookieUtils from '@bundled-es-modules/cookie' import { cookieStore } from '../cookieStore' +function getAllDocumentCookies() { + return cookieUtils.parse(document.cookie) +} + +function getDocumentCookies(request: Request): Record { + if (typeof document === 'undefined' || typeof location === 'undefined') { + return {} + } + + switch (request.credentials) { + case 'same-origin': { + const requestUrl = new URL(request.url) + + // Return document cookies only when requested a resource + // from the same origin as the current document. + return location.origin === requestUrl.origin + ? getAllDocumentCookies() + : {} + } + + case 'include': { + // Return all document cookies. + return getAllDocumentCookies() + } + + default: { + return {} + } + } +} + export function getAllRequestCookies(request: Request): Record { - const requestCookies = request.headers.get('cookie') - const cookiesFromHeaders = requestCookies - ? cookieUtils.parse(requestCookies) - : {} - const cookiesFromStore = Object.fromEntries( - cookieStore - .getCookiesSync(request.url) - .map((cookie) => [cookie.key, cookie.value]), + const cookiesFromDocument = getDocumentCookies(request) + + // Forward the document cookies to the request headers. + for (const name in cookiesFromDocument) { + request.headers.append( + 'cookie', + cookieUtils.serialize(name, cookiesFromDocument[name]), + ) + } + + const cookiesFromStore = cookieStore.getCookiesSync(request.url) + const storedCookiesObject = Object.fromEntries( + cookiesFromStore.map((cookie) => [cookie.key, cookie.value]), ) + // Forward the raw stored cookies to request headers + // so they contain metadata like "expires", "secure", etc. + for (const cookie of cookiesFromStore) { + request.headers.append('cookie', cookie.toString()) + } + return { - ...cookiesFromStore, - ...cookiesFromHeaders, + ...cookiesFromDocument, + ...storedCookiesObject, } } diff --git a/test/browser/rest-api/cookies-request.test.ts b/test/browser/rest-api/cookies-request.test.ts index 196b7ca1b..8b641d39f 100644 --- a/test/browser/rest-api/cookies-request.test.ts +++ b/test/browser/rest-api/cookies-request.test.ts @@ -10,7 +10,7 @@ async function bakeCookies(page: Page) { }) } -test('returns all document cookies in "req.cookies" for "include" credentials', async ({ +test('exposes all document cookies if request "credentials" is "include"', async ({ loadExample, fetch, page, @@ -18,13 +18,11 @@ test('returns all document cookies in "req.cookies" for "include" credentials', await loadExample(EXAMPLE_PATH) await bakeCookies(page) - const res = await fetch('/user', { + const response = await fetch('/user', { credentials: 'include', }) - const body = await res.json() - expect(res.fromServiceWorker()).toBe(true) - expect(body).toEqual({ + await expect(response.json()).resolves.toEqual({ cookies: { 'auth-token': 'abc-123', 'custom-cookie': 'yes', @@ -32,7 +30,7 @@ test('returns all document cookies in "req.cookies" for "include" credentials', }) }) -test('returns all document cookies in "req.cookies" for "same-origin" credentials and request to the same origin', async ({ +test('exposes document cookies if request "credentials" is "same-origin" for same-origin request', async ({ loadExample, fetch, page, @@ -40,13 +38,11 @@ test('returns all document cookies in "req.cookies" for "same-origin" credential await loadExample(EXAMPLE_PATH) await bakeCookies(page) - const res = await fetch('/user', { + const response = await fetch('/user', { credentials: 'same-origin', }) - const body = await res.json() - expect(res.fromServiceWorker()).toBe(true) - expect(body).toEqual({ + await expect(response.json()).resolves.toEqual({ cookies: { 'auth-token': 'abc-123', 'custom-cookie': 'yes', @@ -54,7 +50,7 @@ test('returns all document cookies in "req.cookies" for "same-origin" credential }) }) -test('returns no cookies in "req.cookies" for "same-origin" credentials and request to a different origin', async ({ +test('exposes no cookies if request "credentials" is "same-origin" for a cross-origin request', async ({ loadExample, fetch, page, @@ -62,18 +58,16 @@ test('returns no cookies in "req.cookies" for "same-origin" credentials and requ await loadExample(EXAMPLE_PATH) await bakeCookies(page) - const res = await fetch('https://test.mswjs.io/user', { + const response = await fetch('https://test.mswjs.io/user', { credentials: 'same-origin', }) - const body = await res.json() - expect(res.fromServiceWorker()).toBe(true) - expect(body).toEqual({ + await expect(response.json()).resolves.toEqual({ cookies: {}, }) }) -test('returns no cookies in "req.cookies" for "omit" credentials', async ({ +test('exposes no cookies if request "credentials" is "omit"', async ({ loadExample, fetch, page, @@ -81,13 +75,11 @@ test('returns no cookies in "req.cookies" for "omit" credentials', async ({ await loadExample(EXAMPLE_PATH) await bakeCookies(page) - const res = await fetch('/user', { + const response = await fetch('/user', { credentials: 'omit', }) - const body = await res.json() - expect(res.fromServiceWorker()).toBe(true) - expect(body).toEqual({ + await expect(response.json()).resolves.toEqual({ cookies: {}, }) }) From d661aa6904a7596041761ea2b03204f0909c4c61 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 17 Jul 2024 15:19:08 +0200 Subject: [PATCH 4/8] fix: parse "cookie" request header (node.js) --- src/core/utils/request/getRequestCookies.ts | 11 +++++++++++ .../scenarios/cookies-request.node.test.ts | 8 +++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/core/utils/request/getRequestCookies.ts b/src/core/utils/request/getRequestCookies.ts index 8755bf835..9eab0f803 100644 --- a/src/core/utils/request/getRequestCookies.ts +++ b/src/core/utils/request/getRequestCookies.ts @@ -33,6 +33,16 @@ function getDocumentCookies(request: Request): Record { } export function getAllRequestCookies(request: Request): Record { + /** + * @note While the "cookie" header is a forbidden header field + * in the browser, you can read it in Node.js. We need to respect + * it for mocking in Node.js. + */ + const requestCookieHeader = request.headers.get('cookie') + const cookiesFromHeaders = requestCookieHeader + ? cookieUtils.parse(requestCookieHeader) + : {} + const cookiesFromDocument = getDocumentCookies(request) // Forward the document cookies to the request headers. @@ -57,5 +67,6 @@ export function getAllRequestCookies(request: Request): Record { return { ...cookiesFromDocument, ...storedCookiesObject, + ...cookiesFromHeaders, } } diff --git a/test/node/msw-api/setup-server/scenarios/cookies-request.node.test.ts b/test/node/msw-api/setup-server/scenarios/cookies-request.node.test.ts index 065990fa2..19d6b6ed0 100644 --- a/test/node/msw-api/setup-server/scenarios/cookies-request.node.test.ts +++ b/test/node/msw-api/setup-server/scenarios/cookies-request.node.test.ts @@ -1,9 +1,7 @@ -/** - * @vitest-environment node - */ +// @vitest-environment node import https from 'https' import { http, HttpResponse } from 'msw' -import { setupServer, SetupServerApi } from 'msw/node' +import { setupServer } from 'msw/node' import { httpsAgent, HttpServer } from '@open-draft/test-server/http' import { waitForClientRequest } from '../../../../support/utils' @@ -25,7 +23,7 @@ afterAll(async () => { await httpServer.close() }) -test('has access to request cookies', async () => { +test('exposes request cookies', async () => { const endpointUrl = httpServer.https.url('/user') server.use( From 487ea2258cf6c7d548617483b243a3fc34d96adf Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 19 Jul 2024 15:39:57 +0200 Subject: [PATCH 5/8] test: add response cookies tests --- test/browser/rest-api/cookies.mocks.ts | 33 ------------ test/browser/rest-api/cookies.test.ts | 53 ------------------- .../response/response-cookies.mocks.ts | 30 +++++++++++ .../response/response-cookies.test.ts | 51 ++++++++++++++++++ 4 files changed, 81 insertions(+), 86 deletions(-) delete mode 100644 test/browser/rest-api/cookies.mocks.ts delete mode 100644 test/browser/rest-api/cookies.test.ts create mode 100644 test/browser/rest-api/response/response-cookies.mocks.ts create mode 100644 test/browser/rest-api/response/response-cookies.test.ts diff --git a/test/browser/rest-api/cookies.mocks.ts b/test/browser/rest-api/cookies.mocks.ts deleted file mode 100644 index 7d5aa6746..000000000 --- a/test/browser/rest-api/cookies.mocks.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { http, HttpResponse } from 'msw' -import { setupWorker } from 'msw/browser' - -const worker = setupWorker( - http.get('/user', () => { - return HttpResponse.json( - { - mocked: true, - }, - { - headers: { - 'Set-Cookie': 'myCookie=value; Max-Age=2000', - }, - }, - ) - }), - http.get('/order', () => { - return HttpResponse.json( - { - mocked: true, - }, - { - headers: [ - ['Set-Cookie', 'firstCookie=yes'], - ['Set-Cookie', 'secondCookie=no; Max-Age=1000'], - ['Set-Cookie', 'thirdCookie=1,2,3'], - ], - }, - ) - }), -) - -worker.start() diff --git a/test/browser/rest-api/cookies.test.ts b/test/browser/rest-api/cookies.test.ts deleted file mode 100644 index d571c83e5..000000000 --- a/test/browser/rest-api/cookies.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as cookieUtils from 'cookie' -import { test, expect } from '../playwright.extend' - -const EXAMPLE_PATH = require.resolve('./cookies.mocks.ts') - -test('allows setting cookies on the mocked response', async ({ - loadExample, - fetch, - page, -}) => { - await loadExample(EXAMPLE_PATH) - - const res = await fetch('/user') - const headers = await res.allHeaders() - const body = await res.json() - - expect(res.fromServiceWorker()).toBe(true) - expect(headers).not.toHaveProperty('set-cookie') - expect(body).toEqual({ - mocked: true, - }) - - // Should be able to access the response cookies. - const cookieString = await page.evaluate(() => { - return document.cookie - }) - const allCookies = cookieUtils.parse(cookieString) - expect(allCookies).toEqual({ myCookie: 'value' }) -}) - -test('allows setting multiple response cookies', async ({ - loadExample, - fetch, - page, -}) => { - await loadExample(EXAMPLE_PATH) - - const res = await fetch('/order') - const headers = await res.allHeaders() - - expect(res.fromServiceWorker()).toBe(true) - expect(headers).not.toHaveProperty('set-cookie') - - const cookieString = await page.evaluate(() => { - return document.cookie - }) - const allCookies = cookieUtils.parse(cookieString) - expect(allCookies).toEqual({ - firstCookie: 'yes', - secondCookie: 'no', - thirdCookie: '1,2,3', - }) -}) diff --git a/test/browser/rest-api/response/response-cookies.mocks.ts b/test/browser/rest-api/response/response-cookies.mocks.ts new file mode 100644 index 000000000..fa6147c41 --- /dev/null +++ b/test/browser/rest-api/response/response-cookies.mocks.ts @@ -0,0 +1,30 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/lib/browser' + +const worker = setupWorker( + http.get('/single-cookie', () => { + return new HttpResponse(null, { + headers: { + 'Set-Cookie': 'myCookie=value', + }, + }) + }), + http.get('/multiple-cookies', () => { + return new HttpResponse(null, { + headers: [ + ['Set-Cookie', 'firstCookie=yes'], + ['Set-Cookie', 'secondCookie=no; Max-Age=1000'], + ['Set-Cookie', 'thirdCookie=1,2,3'], + ], + }) + }), + http.get('/cookies-via-headers', () => { + const headers = new Headers({ + 'Set-Cookie': 'myCookie=value', + }) + + return new HttpResponse(null, { headers }) + }), +) + +worker.start() diff --git a/test/browser/rest-api/response/response-cookies.test.ts b/test/browser/rest-api/response/response-cookies.test.ts new file mode 100644 index 000000000..96e7ede9f --- /dev/null +++ b/test/browser/rest-api/response/response-cookies.test.ts @@ -0,0 +1,51 @@ +import { test, expect } from '../../playwright.extend' + +test('supports mocking a single response cookie', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./response-cookies.mocks.ts')) + const response = await fetch('/single-cookie') + const documentCookies = await page.evaluate(() => document.cookie) + + expect(response.status()).toBe(200) + // Must not expose the forbidden "Set-Cookie" header. + expect(await response.allHeaders()).not.toHaveProperty('set-cookie') + // Must set the mocked cookie onto the document. + expect(documentCookies).toBe('myCookie=value') +}) + +test('supports mocking multiple response cookies', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./response-cookies.mocks.ts')) + const response = await fetch('/multiple-cookies') + const documentCookies = await page.evaluate(() => document.cookie) + + expect(response.status()).toBe(200) + expect(await response.allHeaders()).not.toHaveProperty('set-cookie') + /** + * @note The `Max-Age` attribute is not propagated onto the document. + * If that's unexpected, raise an issue. + */ + expect(documentCookies).toBe( + 'firstCookie=yes; secondCookie=no; thirdCookie=1,2,3', + ) +}) + +test('supports mocking cookies via a standalone Headers instance', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./response-cookies.mocks.ts')) + const response = await fetch('/cookies-via-headers') + const documentCookies = await page.evaluate(() => document.cookie) + + expect(response.status()).toBe(200) + expect(await response.allHeaders()).not.toHaveProperty('set-cookie') + expect(documentCookies).toBe('myCookie=value') +}) From 5774b631a53260ca3493b44d813a1ab3d6f59e13 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 19 Jul 2024 16:13:36 +0200 Subject: [PATCH 6/8] test: add request cookies tests --- .../rest-api/cookies-http-only.mocks.ts | 29 --- .../rest-api/cookies-http-only.test.ts | 17 -- .../rest-api/cookies-inheritance.mocks.ts | 27 --- .../rest-api/cookies-inheritance.test.ts | 21 -- .../browser/rest-api/cookies-request.mocks.ts | 12 -- test/browser/rest-api/cookies-request.test.ts | 85 -------- .../rest-api/request/request-cookies.mocks.ts | 17 ++ .../rest-api/request/request-cookies.test.ts | 199 ++++++++++++++++++ 8 files changed, 216 insertions(+), 191 deletions(-) delete mode 100644 test/browser/rest-api/cookies-http-only.mocks.ts delete mode 100644 test/browser/rest-api/cookies-http-only.test.ts delete mode 100644 test/browser/rest-api/cookies-inheritance.mocks.ts delete mode 100644 test/browser/rest-api/cookies-inheritance.test.ts delete mode 100644 test/browser/rest-api/cookies-request.mocks.ts delete mode 100644 test/browser/rest-api/cookies-request.test.ts create mode 100644 test/browser/rest-api/request/request-cookies.mocks.ts create mode 100644 test/browser/rest-api/request/request-cookies.test.ts diff --git a/test/browser/rest-api/cookies-http-only.mocks.ts b/test/browser/rest-api/cookies-http-only.mocks.ts deleted file mode 100644 index f9ac6401b..000000000 --- a/test/browser/rest-api/cookies-http-only.mocks.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { http, HttpResponse } from 'msw' -import { setupWorker } from 'msw/browser' - -const worker = setupWorker( - http.post('/login', () => { - return HttpResponse.text(null, { - headers: { - 'Set-Cookie': 'authToken=abc-123; HttpOnly', - }, - }) - }), - http.get('/user', ({ cookies }) => { - if (cookies.authToken == null) { - throw HttpResponse.json( - { - error: 'Not authenticated', - }, - { status: 403 }, - ) - } - - return HttpResponse.json({ - firstName: 'John', - lastName: 'Maverick', - }) - }), -) - -worker.start() diff --git a/test/browser/rest-api/cookies-http-only.test.ts b/test/browser/rest-api/cookies-http-only.test.ts deleted file mode 100644 index 3aa5fd9b1..000000000 --- a/test/browser/rest-api/cookies-http-only.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { test, expect } from '../playwright.extend' - -test('inherits cookies set on a preceeding request', async ({ - loadExample, - fetch, -}) => { - await loadExample(require.resolve('./cookies-http-only.mocks.ts')) - - await fetch('/login', { method: 'POST' }) - const response = await fetch('/user') - - await expect(response.json()).resolves.toEqual({ - firstName: 'John', - lastName: 'Maverick', - }) - expect(response.status()).toBe(200) -}) diff --git a/test/browser/rest-api/cookies-inheritance.mocks.ts b/test/browser/rest-api/cookies-inheritance.mocks.ts deleted file mode 100644 index 674ba07c1..000000000 --- a/test/browser/rest-api/cookies-inheritance.mocks.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { http, HttpResponse } from 'msw' -import { setupWorker } from 'msw/browser' - -const worker = setupWorker( - http.post('/login', () => { - return HttpResponse.text(null, { - headers: { - 'Set-Cookie': 'authToken=abc-123', - }, - }) - }), - http.get('/user', ({ cookies }) => { - if (cookies.authToken == null) { - return HttpResponse.json( - { error: 'Auth token not found' }, - { status: 403 }, - ) - } - - return HttpResponse.json({ - firstName: 'John', - lastName: 'Maverick', - }) - }), -) - -worker.start() diff --git a/test/browser/rest-api/cookies-inheritance.test.ts b/test/browser/rest-api/cookies-inheritance.test.ts deleted file mode 100644 index 519d0444d..000000000 --- a/test/browser/rest-api/cookies-inheritance.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { test, expect } from '../playwright.extend' - -test('inherits cookies set on a preceeding request', async ({ - loadExample, - fetch, -}) => { - await loadExample(require.resolve('./cookies-inheritance.mocks.ts')) - - const res = await fetch('/login', { method: 'POST' }).then(() => { - return fetch('/user') - }) - - const status = res.status() - const json = await res.json() - - expect(status).toBe(200) - expect(json).toEqual({ - firstName: 'John', - lastName: 'Maverick', - }) -}) diff --git a/test/browser/rest-api/cookies-request.mocks.ts b/test/browser/rest-api/cookies-request.mocks.ts deleted file mode 100644 index 18b89e3e2..000000000 --- a/test/browser/rest-api/cookies-request.mocks.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { http, HttpResponse } from 'msw' -import { setupWorker } from 'msw/browser' - -const worker = setupWorker( - // Use wildcard so that we intercept any "GET /user" requests - // regardless of the origin, and can assert "same-origin" credentials. - http.get('*/user', ({ cookies }) => { - return HttpResponse.json({ cookies }) - }), -) - -worker.start() diff --git a/test/browser/rest-api/cookies-request.test.ts b/test/browser/rest-api/cookies-request.test.ts deleted file mode 100644 index 8b641d39f..000000000 --- a/test/browser/rest-api/cookies-request.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Page } from '@playwright/test' -import { test, expect } from '../playwright.extend' - -const EXAMPLE_PATH = require.resolve('./cookies-request.mocks.ts') - -async function bakeCookies(page: Page) { - await page.evaluate(() => { - document.cookie = 'auth-token=abc-123;' - document.cookie = 'custom-cookie=yes;' - }) -} - -test('exposes all document cookies if request "credentials" is "include"', async ({ - loadExample, - fetch, - page, -}) => { - await loadExample(EXAMPLE_PATH) - await bakeCookies(page) - - const response = await fetch('/user', { - credentials: 'include', - }) - - await expect(response.json()).resolves.toEqual({ - cookies: { - 'auth-token': 'abc-123', - 'custom-cookie': 'yes', - }, - }) -}) - -test('exposes document cookies if request "credentials" is "same-origin" for same-origin request', async ({ - loadExample, - fetch, - page, -}) => { - await loadExample(EXAMPLE_PATH) - await bakeCookies(page) - - const response = await fetch('/user', { - credentials: 'same-origin', - }) - - await expect(response.json()).resolves.toEqual({ - cookies: { - 'auth-token': 'abc-123', - 'custom-cookie': 'yes', - }, - }) -}) - -test('exposes no cookies if request "credentials" is "same-origin" for a cross-origin request', async ({ - loadExample, - fetch, - page, -}) => { - await loadExample(EXAMPLE_PATH) - await bakeCookies(page) - - const response = await fetch('https://test.mswjs.io/user', { - credentials: 'same-origin', - }) - - await expect(response.json()).resolves.toEqual({ - cookies: {}, - }) -}) - -test('exposes no cookies if request "credentials" is "omit"', async ({ - loadExample, - fetch, - page, -}) => { - await loadExample(EXAMPLE_PATH) - await bakeCookies(page) - - const response = await fetch('/user', { - credentials: 'omit', - }) - - await expect(response.json()).resolves.toEqual({ - cookies: {}, - }) -}) diff --git a/test/browser/rest-api/request/request-cookies.mocks.ts b/test/browser/rest-api/request/request-cookies.mocks.ts new file mode 100644 index 000000000..59856827f --- /dev/null +++ b/test/browser/rest-api/request/request-cookies.mocks.ts @@ -0,0 +1,17 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.get('*/cookies', ({ cookies }) => { + return HttpResponse.json(cookies) + }), + http.post('/set-cookies', async ({ request }) => { + return new HttpResponse(null, { + headers: { + 'Set-Cookie': await request.clone().text(), + }, + }) + }), +) + +worker.start() diff --git a/test/browser/rest-api/request/request-cookies.test.ts b/test/browser/rest-api/request/request-cookies.test.ts new file mode 100644 index 000000000..a617ef901 --- /dev/null +++ b/test/browser/rest-api/request/request-cookies.test.ts @@ -0,0 +1,199 @@ +import type { Page } from '@playwright/test' +import { test, expect } from '../../playwright.extend' + +async function bakeCookies(page: Page, cookies: Array) { + await page.evaluate((cookies) => { + cookies.forEach((cookie) => { + document.cookie = cookie + }) + }, cookies) +} + +test('returns empty object if document has no cookies', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + const response = await fetch('/cookies') + const documentCookies = await page.evaluate(() => document.cookie) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({}) + expect(documentCookies).toBe('') +}) + +test('returns empty object for request with "credentials: omit"', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + await bakeCookies(page, ['documentCookie=value']) + const response = await fetch('/cookies', { credentials: 'omit' }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({}) +}) + +test('returns empty object for cross-origin request with "credentials: same-origin"', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + await bakeCookies(page, ['documentCookie=value']) + const response = await fetch('https://example.com/cookies', { + credentials: 'same-origin', + }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({}) +}) + +test('returns cookies for same-origin request with "credentials: same-origin"', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + await bakeCookies(page, ['documentCookie=value']) + const response = await fetch('/cookies', { + credentials: 'same-origin', + }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({ + documentCookie: 'value', + }) +}) + +test('returns cookies for same-origin request with "credentials: include"', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + await bakeCookies(page, ['firstCookie=value', 'secondCookie=anotherValue']) + const response = await fetch('/cookies', { + credentials: 'include', + }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({ + firstCookie: 'value', + secondCookie: 'anotherValue', + }) +}) + +test('returns cookies for cross-origin request with "credentials: include"', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + await bakeCookies(page, ['documentCookie=value']) + const response = await fetch('https://example.com/cookies', { + credentials: 'include', + }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({ + documentCookie: 'value', + }) +}) + +test('inherits mocked cookies', async ({ loadExample, fetch, page }) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + await bakeCookies(page, ['documentCookie=value']) + + // Make a request that sends mocked cookies. + await fetch('/set-cookies', { + method: 'POST', + body: 'mockedCookie=mockedValue', + }) + const response = await fetch('/cookies', { + credentials: 'include', + }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({ + documentCookie: 'value', + mockedCookie: 'mockedValue', + }) +}) + +test('inherits mocked cookies after page reload', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + await bakeCookies(page, ['documentCookie=value']) + + await fetch('/set-cookies', { + method: 'POST', + body: 'mockedCookie=mockedValue', + }) + // Reload the page to ensure that the mocked cookies persist. + await page.reload({ waitUntil: 'networkidle' }) + + const response = await fetch('/cookies', { + credentials: 'include', + }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({ + documentCookie: 'value', + mockedCookie: 'mockedValue', + }) +}) + +test('inherits mocked "HttpOnly" cookies', async ({ + loadExample, + fetch, + page, +}) => { + await loadExample(require.resolve('./request-cookies.mocks.ts')) + await bakeCookies(page, ['documentCookie=value']) + + await fetch('/set-cookies', { + method: 'POST', + body: 'mockedCookie=mockedValue; HttpOnly', + }) + await page.reload({ waitUntil: 'networkidle' }) + + const response = await fetch('/cookies', { + credentials: 'include', + }) + + expect(response.status()).toBe(200) + await expect(response.json()).resolves.toEqual({ + documentCookie: 'value', + mockedCookie: 'mockedValue', + }) +}) + +test('respects cookie "Path" when exposing cookies', async ({ + loadExample, + fetch, + page, +}) => { + const { compilation } = await loadExample( + require.resolve('./request-cookies.mocks.ts'), + ) + await bakeCookies(page, [ + `documentCookie=value; Path=${compilation.previewRoute}`, + ]) + + const nonMatchingResponse = await fetch('/cookies') + // Must not return cookies for the request under a different path. + await expect(nonMatchingResponse.json()).resolves.toEqual({}) + + const matchingResponse = await fetch( + new URL('./cookies', compilation.previewUrl).href, + ) + await expect(matchingResponse.json()).resolves.toEqual({ + documentCookie: 'value', + }) +}) From 7150cbc893579aa7fc85b553acdd4d7ce484f556 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Fri, 19 Jul 2024 18:35:23 +0200 Subject: [PATCH 7/8] fix: implement web storage-based cookie store --- src/core/utils/cookieStore.ts | 212 +++++++++++++++++- .../rest-api/request/request-cookies.test.ts | 26 ++- 2 files changed, 225 insertions(+), 13 deletions(-) diff --git a/src/core/utils/cookieStore.ts b/src/core/utils/cookieStore.ts index 0df6383f0..9b0fd334c 100644 --- a/src/core/utils/cookieStore.ts +++ b/src/core/utils/cookieStore.ts @@ -1,5 +1,211 @@ -import * as toughCookie from 'tough-cookie' +import { invariant } from 'outvariant' +import { isNodeProcess } from 'is-node-process' +import { + Cookie, + CookieJar, + Store, + MemoryCookieStore, + domainMatch, + pathMatch, +} from 'tough-cookie' -const { CookieJar } = toughCookie +/** + * Custom cookie store that uses the Web Storage API. + * @see https://github.com/expo/tough-cookie-web-storage-store + */ +class WebStorageCookieStore extends Store { + private storage: Storage + private storageKey: string -export const cookieStore = new CookieJar() + constructor() { + super() + + invariant( + typeof localStorage !== 'undefined', + 'Failed to create a WebStorageCookieStore: `localStorage` is not available in this environment. This is likely an issue with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues', + ) + + this.synchronous = true + this.storage = localStorage + this.storageKey = '__msw-cookie-store__' + } + + findCookie( + domain: string, + path: string, + key: string, + callback: (error: Error | null, cookie: Cookie | null) => void, + ): void { + try { + const store = this.getStore() + const cookies = this.filterCookiesFromList(store, { domain, path, key }) + callback(null, cookies[0] || null) + } catch (error) { + if (error instanceof Error) { + callback(error, null) + } + } + } + + findCookies( + domain: string, + path: string, + allowSpecialUseDomain: boolean, + callback: (error: Error | null, cookie: Array) => void, + ): void { + if (!domain) { + callback(null, []) + return + } + + try { + const store = this.getStore() + const results = this.filterCookiesFromList(store, { + domain, + path, + }) + callback(null, results) + } catch (error) { + if (error instanceof Error) { + callback(error, []) + } + } + } + + putCookie(cookie: Cookie, callback: (error: Error | null) => void): void { + try { + const store = this.getStore() + store.push(cookie) + this.updateStore(store) + } catch (error) { + if (error instanceof Error) { + callback(error) + } + } + } + + updateCookie( + oldCookie: Cookie, + newCookie: Cookie, + callback: (error: Error | null) => void, + ): void { + this.putCookie(newCookie, callback) + } + + removeCookie( + domain: string, + path: string, + key: string, + callback: (error: Error | null) => void, + ): void { + try { + const store = this.getStore() + const nextStore = this.deleteCookiesFromList(store, { domain, path, key }) + this.updateStore(nextStore) + callback(null) + } catch (error) { + if (error instanceof Error) { + callback(error) + } + } + } + + removeCookies( + domain: string, + path: string, + callback: (error: Error | null) => void, + ): void { + try { + const store = this.getStore() + const nextStore = this.deleteCookiesFromList(store, { domain, path }) + this.updateStore(nextStore) + callback(null) + } catch (error) { + if (error instanceof Error) { + callback(error) + } + } + } + + getAllCookies( + callback: (error: Error | null, cookie: Array) => void, + ): void { + try { + callback(null, this.getStore()) + } catch (error) { + if (error instanceof Error) { + callback(error, []) + } + } + } + + private getStore(): Array { + try { + const json = this.storage.getItem(this.storageKey) + + if (json == null) { + return [] + } + + const rawCookies = JSON.parse(json) as Array> + const cookies: Array = [] + for (const rawCookie of rawCookies) { + const cookie = Cookie.fromJSON(rawCookie) + if (cookie != null) { + cookies.push(cookie) + } + } + return cookies + } catch { + return [] + } + } + + private updateStore(nextStore: Array) { + this.storage.setItem( + this.storageKey, + JSON.stringify(nextStore.map((cookie) => cookie.toJSON())), + ) + } + + private filterCookiesFromList( + cookies: Array, + matches: { domain?: string; path?: string; key?: string }, + ): Array { + const result: Array = [] + + for (const cookie of cookies) { + if (matches.domain && !domainMatch(matches.domain, cookie.domain || '')) { + continue + } + + if (matches.path && !pathMatch(matches.path, cookie.path || '')) { + continue + } + + if (matches.key && cookie.key !== matches.key) { + continue + } + + result.push(cookie) + } + + console.log('filter result:', { cookies, matches, result }) + + return result + } + + private deleteCookiesFromList( + cookies: Array, + matches: { domain?: string; path?: string; key?: string }, + ) { + const matchingCookies = this.filterCookiesFromList(cookies, matches) + return cookies.filter((cookie) => !matchingCookies.includes(cookie)) + } +} + +const store = isNodeProcess() + ? new MemoryCookieStore() + : new WebStorageCookieStore() + +export const cookieStore = new CookieJar(store) diff --git a/test/browser/rest-api/request/request-cookies.test.ts b/test/browser/rest-api/request/request-cookies.test.ts index a617ef901..639dd7028 100644 --- a/test/browser/rest-api/request/request-cookies.test.ts +++ b/test/browser/rest-api/request/request-cookies.test.ts @@ -179,21 +179,27 @@ test('respects cookie "Path" when exposing cookies', async ({ fetch, page, }) => { - const { compilation } = await loadExample( - require.resolve('./request-cookies.mocks.ts'), - ) - await bakeCookies(page, [ - `documentCookie=value; Path=${compilation.previewRoute}`, - ]) + await loadExample(require.resolve('./request-cookies.mocks.ts')) + + /** + * @note I tried including the `document.cookie` with + * a specific `Path` but it behaves differently. It doesn't + * even expose the cookie unless the PAGE path matches the + * cookie path (reproducible in the browser). + */ + + await fetch('/set-cookies', { + method: 'POST', + body: `mockedCookie=mockedValue; Path=/dashboard`, + }) const nonMatchingResponse = await fetch('/cookies') // Must not return cookies for the request under a different path. await expect(nonMatchingResponse.json()).resolves.toEqual({}) - const matchingResponse = await fetch( - new URL('./cookies', compilation.previewUrl).href, - ) + // Must return the mocked cookie for a request with a matching path. + const matchingResponse = await fetch('/dashboard/cookies') await expect(matchingResponse.json()).resolves.toEqual({ - documentCookie: 'value', + mockedCookie: 'mockedValue', }) }) From d448dddfbd60735d740d65aa3aa18516daa929d2 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Mon, 22 Jul 2024 19:41:34 +0200 Subject: [PATCH 8/8] fix: use @bundled-es-modules/tough-cookie --- .gitignore | 1 + package.json | 3 +-- pnpm-lock.yaml | 16 ++++++++----- src/core/utils/cookieStore.ts | 43 ++++++++++++++++++----------------- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index ecf326fe8..be618bdac 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ msw-*.tgz .husky/_ .env **/test-results +/test/modules/node/node-esm-tests # Smoke test temporary files. /package.json.copy diff --git a/package.json b/package.json index 4f07ef167..388493039 100644 --- a/package.json +++ b/package.json @@ -135,12 +135,12 @@ "dependencies": { "@bundled-es-modules/cookie": "^2.0.0", "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", "@inquirer/confirm": "^3.0.0", "@mswjs/interceptors": "^0.29.0", "@open-draft/until": "^2.1.0", "@types/cookie": "^0.6.0", "@types/statuses": "^2.0.4", - "@types/tough-cookie": "^4.0.5", "chalk": "^4.1.2", "graphql": "^16.8.1", "headers-polyfill": "^4.0.2", @@ -148,7 +148,6 @@ "outvariant": "^1.4.2", "path-to-regexp": "^6.2.0", "strict-event-emitter": "^0.5.1", - "tough-cookie": "^4.1.4", "type-fest": "^4.9.0", "yargs": "^17.7.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19275e602..735337fc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: '@bundled-es-modules/statuses': specifier: ^1.0.1 version: 1.0.1 + '@bundled-es-modules/tough-cookie': + specifier: ^0.1.6 + version: 0.1.6 '@inquirer/confirm': specifier: ^3.0.0 version: 3.1.1 @@ -26,9 +29,6 @@ dependencies: '@types/statuses': specifier: ^2.0.4 version: 2.0.5 - '@types/tough-cookie': - specifier: ^4.0.5 - version: 4.0.5 chalk: specifier: ^4.1.2 version: 4.1.2 @@ -50,9 +50,6 @@ dependencies: strict-event-emitter: specifier: ^0.5.1 version: 0.5.1 - tough-cookie: - specifier: ^4.1.4 - version: 4.1.4 type-fest: specifier: ^4.9.0 version: 4.14.0 @@ -435,6 +432,13 @@ packages: statuses: 2.0.1 dev: false + /@bundled-es-modules/tough-cookie@0.1.6: + resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + dependencies: + '@types/tough-cookie': 4.0.5 + tough-cookie: 4.1.4 + dev: false + /@cloudflare/workers-types@4.20240329.0: resolution: {integrity: sha512-AbzgvSQjG8Nci4xxQEcjTTVjiWXgOQnFIbIHtEZXteHiMGDXMWGegjWBo5JHGsZCq+U5V/SD5EnlypQnUQEoig==} dev: true diff --git a/src/core/utils/cookieStore.ts b/src/core/utils/cookieStore.ts index 9b0fd334c..51dcd8e25 100644 --- a/src/core/utils/cookieStore.ts +++ b/src/core/utils/cookieStore.ts @@ -1,13 +1,11 @@ import { invariant } from 'outvariant' import { isNodeProcess } from 'is-node-process' -import { - Cookie, - CookieJar, - Store, - MemoryCookieStore, - domainMatch, - pathMatch, -} from 'tough-cookie' +import toughCookie, { + type Cookie as CookieInstance, +} from '@bundled-es-modules/tough-cookie' + +const { Cookie, CookieJar, Store, MemoryCookieStore, domainMatch, pathMatch } = + toughCookie /** * Custom cookie store that uses the Web Storage API. @@ -34,7 +32,7 @@ class WebStorageCookieStore extends Store { domain: string, path: string, key: string, - callback: (error: Error | null, cookie: Cookie | null) => void, + callback: (error: Error | null, cookie: CookieInstance | null) => void, ): void { try { const store = this.getStore() @@ -51,7 +49,7 @@ class WebStorageCookieStore extends Store { domain: string, path: string, allowSpecialUseDomain: boolean, - callback: (error: Error | null, cookie: Array) => void, + callback: (error: Error | null, cookie: Array) => void, ): void { if (!domain) { callback(null, []) @@ -72,7 +70,10 @@ class WebStorageCookieStore extends Store { } } - putCookie(cookie: Cookie, callback: (error: Error | null) => void): void { + putCookie( + cookie: CookieInstance, + callback: (error: Error | null) => void, + ): void { try { const store = this.getStore() store.push(cookie) @@ -85,8 +86,8 @@ class WebStorageCookieStore extends Store { } updateCookie( - oldCookie: Cookie, - newCookie: Cookie, + oldCookie: CookieInstance, + newCookie: CookieInstance, callback: (error: Error | null) => void, ): void { this.putCookie(newCookie, callback) @@ -128,7 +129,7 @@ class WebStorageCookieStore extends Store { } getAllCookies( - callback: (error: Error | null, cookie: Array) => void, + callback: (error: Error | null, cookie: Array) => void, ): void { try { callback(null, this.getStore()) @@ -139,7 +140,7 @@ class WebStorageCookieStore extends Store { } } - private getStore(): Array { + private getStore(): Array { try { const json = this.storage.getItem(this.storageKey) @@ -148,7 +149,7 @@ class WebStorageCookieStore extends Store { } const rawCookies = JSON.parse(json) as Array> - const cookies: Array = [] + const cookies: Array = [] for (const rawCookie of rawCookies) { const cookie = Cookie.fromJSON(rawCookie) if (cookie != null) { @@ -161,7 +162,7 @@ class WebStorageCookieStore extends Store { } } - private updateStore(nextStore: Array) { + private updateStore(nextStore: Array) { this.storage.setItem( this.storageKey, JSON.stringify(nextStore.map((cookie) => cookie.toJSON())), @@ -169,10 +170,10 @@ class WebStorageCookieStore extends Store { } private filterCookiesFromList( - cookies: Array, + cookies: Array, matches: { domain?: string; path?: string; key?: string }, - ): Array { - const result: Array = [] + ): Array { + const result: Array = [] for (const cookie of cookies) { if (matches.domain && !domainMatch(matches.domain, cookie.domain || '')) { @@ -196,7 +197,7 @@ class WebStorageCookieStore extends Store { } private deleteCookiesFromList( - cookies: Array, + cookies: Array, matches: { domain?: string; path?: string; key?: string }, ) { const matchingCookies = this.filterCookiesFromList(cookies, matches)