From 8f137a67228515c159d8b72d7c000c8a8995f044 Mon Sep 17 00:00:00 2001 From: Cacie Prins Date: Fri, 22 May 2026 12:24:37 -0400 Subject: [PATCH] refactor(proxy): network capture and cookie state adapters Stage 6 (6a+6b): capture/cookie driven ports and middleware delegation. - Add ProxyNetworkCaptureAdapter and ProxyCookieStateAdapter in proxy/lib/adapters - Expand ForNetworkCapture / ForCookieState and wire through NetworkPolicyCore - Delegate RecordRequest, RecordResponse, and cookie middleware through the core Co-authored-by: Cursor --- packages/driver/package.json | 1 + .../adapters/driver-command-log.ts | 22 ++ .../src/cy/net-stubbing/adapters/index.ts | 3 + .../cy/net-stubbing/events/before-request.ts | 7 +- .../net-stubbing/driver-command-log.spec.ts | 23 ++ packages/network-policy/lib/core/index.ts | 2 + .../lib/core/network-policy-core.ts | 62 +++++ .../lib/core/request-logging.ts | 23 ++ .../network-policy/lib/ports/driven-ports.ts | 21 +- .../test/unit/network-policy-core.spec.ts | 28 +++ .../test/unit/request-logging.spec.ts | 24 ++ .../adapters/attach-cross-origin-cookies.ts | 79 +++++++ .../adapters/copy-cookies-from-response.ts | 119 ++++++++++ packages/proxy/lib/adapters/index.ts | 14 ++ .../proxy/lib/adapters/network-capture.ts | 71 ++++++ .../proxy/lib/adapters/proxy-command-log.ts | 14 ++ .../proxy/lib/adapters/proxy-cookie-state.ts | 15 ++ .../lib/adapters/proxy-network-capture.ts | 14 ++ packages/proxy/lib/adapters/send-to-driver.ts | 28 +++ packages/proxy/lib/http/request-middleware.ts | 112 +-------- .../proxy/lib/http/response-middleware.ts | 220 +----------------- .../proxy/lib/http/util/protocol-capture.ts | 15 ++ .../test/integration/net-stubbing.spec.ts | 6 + .../unit/adapters/proxy-command-log.spec.ts | 30 +++ .../unit/adapters/proxy-cookie-state.spec.ts | 45 ++++ .../adapters/proxy-network-capture.spec.ts | 37 +++ packages/proxy/test/unit/http/helpers.ts | 6 + packages/server/lib/network-runtime.ts | 6 + .../server/test/unit/network-runtime_spec.ts | 3 + 29 files changed, 724 insertions(+), 326 deletions(-) create mode 100644 packages/driver/src/cy/net-stubbing/adapters/driver-command-log.ts create mode 100644 packages/driver/src/cy/net-stubbing/adapters/index.ts create mode 100644 packages/driver/test/unit/net-stubbing/driver-command-log.spec.ts create mode 100644 packages/network-policy/lib/core/request-logging.ts create mode 100644 packages/network-policy/test/unit/request-logging.spec.ts create mode 100644 packages/proxy/lib/adapters/attach-cross-origin-cookies.ts create mode 100644 packages/proxy/lib/adapters/copy-cookies-from-response.ts create mode 100644 packages/proxy/lib/adapters/network-capture.ts create mode 100644 packages/proxy/lib/adapters/proxy-command-log.ts create mode 100644 packages/proxy/lib/adapters/proxy-cookie-state.ts create mode 100644 packages/proxy/lib/adapters/proxy-network-capture.ts create mode 100644 packages/proxy/lib/adapters/send-to-driver.ts create mode 100644 packages/proxy/lib/http/util/protocol-capture.ts create mode 100644 packages/proxy/test/unit/adapters/proxy-command-log.spec.ts create mode 100644 packages/proxy/test/unit/adapters/proxy-cookie-state.spec.ts create mode 100644 packages/proxy/test/unit/adapters/proxy-network-capture.spec.ts diff --git a/packages/driver/package.json b/packages/driver/package.json index c80c10cc8a..63c42c92eb 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -26,6 +26,7 @@ "@packages/config": "0.0.0-development", "@packages/errors": "0.0.0-development", "@packages/net-stubbing": "0.0.0-development", + "@packages/network-policy": "0.0.0-development", "@packages/network-tools": "0.0.0-development", "@packages/proxy": "0.0.0-development", "@packages/rewriter": "0.0.0-development", diff --git a/packages/driver/src/cy/net-stubbing/adapters/driver-command-log.ts b/packages/driver/src/cy/net-stubbing/adapters/driver-command-log.ts new file mode 100644 index 0000000000..df5a11d53d --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/adapters/driver-command-log.ts @@ -0,0 +1,22 @@ +import type { + CommandLogInterceptionInput, + CommandLogInterceptionResult, + ForCommandLog, +} from '@packages/network-policy' + +export type DriverCommandLogAdapterOptions = { + logInterception: (interception: unknown, route: unknown) => CommandLogInterceptionResult +} + +/** Driver-side {@link ForCommandLog} adapter — delegates to {@link Cypress.ProxyLogging}. */ +export class DriverCommandLogAdapter implements ForCommandLog { + constructor (private readonly options: DriverCommandLogAdapterOptions) {} + + notifyIncomingRequest (_ctx: unknown): void { + // Server-side only — driver receives incoming requests via socket events. + } + + logInterception (input: CommandLogInterceptionInput): CommandLogInterceptionResult { + return this.options.logInterception(input.interception, input.route) + } +} diff --git a/packages/driver/src/cy/net-stubbing/adapters/index.ts b/packages/driver/src/cy/net-stubbing/adapters/index.ts new file mode 100644 index 0000000000..ad9b5b7afa --- /dev/null +++ b/packages/driver/src/cy/net-stubbing/adapters/index.ts @@ -0,0 +1,3 @@ +export { DriverCommandLogAdapter } from './driver-command-log' + +export type { DriverCommandLogAdapterOptions } from './driver-command-log' diff --git a/packages/driver/src/cy/net-stubbing/events/before-request.ts b/packages/driver/src/cy/net-stubbing/events/before-request.ts index 51e0325c5d..83ed0bc259 100644 --- a/packages/driver/src/cy/net-stubbing/events/before-request.ts +++ b/packages/driver/src/cy/net-stubbing/events/before-request.ts @@ -17,6 +17,7 @@ import type { HandlerFn, HandlerResult } from '.' import Bluebird from 'bluebird' import type { NetEvent } from '@packages/net-stubbing/lib/types' import Debug from 'debug' +import { DriverCommandLogAdapter } from '../adapters' const debug = Debug('cypress:driver:net-stubbing:events:before-request') @@ -306,7 +307,11 @@ export const onBeforeRequest: HandlerFn = (Cypre resolve = _resolve }) - request.setLogFlag = Cypress.ProxyLogging.logInterception(request, route)?.setFlag || (() => {}) + const commandLog = new DriverCommandLogAdapter({ + logInterception: (interception, route) => Cypress.ProxyLogging.logInterception(interception as Interception, route), + }) + + request.setLogFlag = commandLog.logInterception({ interception: request, route })?.setFlag || (() => {}) // TODO: this misnomer is a holdover from XHR, should be numRequests route.log.set('numResponses', (route.log.get('numResponses') || 0) + 1) diff --git a/packages/driver/test/unit/net-stubbing/driver-command-log.spec.ts b/packages/driver/test/unit/net-stubbing/driver-command-log.spec.ts new file mode 100644 index 0000000000..29ed1e0cbb --- /dev/null +++ b/packages/driver/test/unit/net-stubbing/driver-command-log.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from 'vitest' +import { DriverCommandLogAdapter } from '../../../src/cy/net-stubbing/adapters/driver-command-log' + +describe('DriverCommandLogAdapter', () => { + it('delegates logInterception to supplied proxy logging hook', () => { + const setFlag = vi.fn() + const logInterception = vi.fn().mockReturnValue({ setFlag }) + const adapter = new DriverCommandLogAdapter({ logInterception }) + const interception = { requestId: 'req-1' } + const route = { routeId: 'route-1' } + + const result = adapter.logInterception({ interception, route }) + + expect(logInterception).toHaveBeenCalledWith(interception, route) + expect(result?.setFlag).toBe(setFlag) + }) + + it('notifyIncomingRequest is a no-op on the driver', () => { + const adapter = new DriverCommandLogAdapter({ logInterception: vi.fn() }) + + expect(() => adapter.notifyIncomingRequest({})).not.toThrow() + }) +}) diff --git a/packages/network-policy/lib/core/index.ts b/packages/network-policy/lib/core/index.ts index 05c41642f0..23101885fd 100644 --- a/packages/network-policy/lib/core/index.ts +++ b/packages/network-policy/lib/core/index.ts @@ -8,4 +8,6 @@ export * from './merge-handler-result' export * from './document-preparation' +export * from './request-logging' + export * from './network-policy-core' diff --git a/packages/network-policy/lib/core/network-policy-core.ts b/packages/network-policy/lib/core/network-policy-core.ts index 032a7ec6ce..678c5d256f 100644 --- a/packages/network-policy/lib/core/network-policy-core.ts +++ b/packages/network-policy/lib/core/network-policy-core.ts @@ -122,6 +122,56 @@ export class NetworkPolicyCore { return port.removeSecurity(ctx) } + notifyIncomingRequest (ctx: unknown): void { + const port = this.options.commandLog + + if (!port) { + throw new Error('NetworkPolicyCore.commandLog is not configured') + } + + return port.notifyIncomingRequest(ctx) + } + + async attachCrossOriginCookies (ctx: unknown): Promise { + const port = this.options.cookieState + + if (!port) { + throw new Error('NetworkPolicyCore.cookieState is not configured') + } + + return port.attachCrossOriginCookies(ctx) + } + + async copyCookiesFromResponse (ctx: unknown): Promise { + const port = this.options.cookieState + + if (!port) { + throw new Error('NetworkPolicyCore.cookieState is not configured') + } + + return port.copyCookiesFromResponse(ctx) + } + + async notifyResponseStreamReceived (ctx: unknown): Promise { + const port = this.options.networkCapture + + if (!port) { + throw new Error('NetworkPolicyCore.networkCapture is not configured') + } + + return port.notifyResponseStreamReceived(ctx) + } + + notifyResponseEndedWithEmptyBody (ctx: unknown, options: { isCached: boolean }): void { + const port = this.options.networkCapture + + if (!port) { + throw new Error('NetworkPolicyCore.networkCapture is not configured') + } + + return port.notifyResponseEndedWithEmptyBody(ctx, options) + } + get requestInterception (): ForRequestInterception | undefined { return this.options.requestInterception } @@ -133,4 +183,16 @@ export class NetworkPolicyCore { get documentPreparation (): ForDocumentPreparation | undefined { return this.options.documentPreparation } + + get networkCapture (): ForNetworkCapture | undefined { + return this.options.networkCapture + } + + get cookieState (): ForCookieState | undefined { + return this.options.cookieState + } + + get commandLog (): ForCommandLog | undefined { + return this.options.commandLog + } } diff --git a/packages/network-policy/lib/core/request-logging.ts b/packages/network-policy/lib/core/request-logging.ts new file mode 100644 index 0000000000..12462a51bb --- /dev/null +++ b/packages/network-policy/lib/core/request-logging.ts @@ -0,0 +1,23 @@ +export type ShouldLogRequestFacts = { + matchingRoutes?: Array<{ staticResponse?: { log?: boolean } }> + resourceType?: string +} + +/** + * Pure intercept/request logging decision — extracted from proxy `SendToDriver` middleware. + */ +export function shouldLogRequest (facts: ShouldLogRequestFacts): boolean { + if (facts.matchingRoutes?.length) { + const lastMatchingRoute = facts.matchingRoutes[0] + + if (!lastMatchingRoute.staticResponse) { + return true + } + + if (lastMatchingRoute.staticResponse.log !== undefined) { + return Boolean(lastMatchingRoute.staticResponse.log) + } + } + + return facts.resourceType === 'fetch' || facts.resourceType === 'xhr' +} diff --git a/packages/network-policy/lib/ports/driven-ports.ts b/packages/network-policy/lib/ports/driven-ports.ts index f6be9f88ee..68091c0a21 100644 --- a/packages/network-policy/lib/ports/driven-ports.ts +++ b/packages/network-policy/lib/ports/driven-ports.ts @@ -33,21 +33,36 @@ export interface ForDocumentPreparation { * Driven port: Test Replay / protocol capture at the proxy boundary. */ export interface ForNetworkCapture { - // Expanded in Stage 6a. + notifyResponseStreamReceived (ctx: unknown): Promise + + notifyResponseEndedWithEmptyBody (ctx: unknown, options: { isCached: boolean }): void } /** * Driven port: cookie jar read/write for proxied requests. */ export interface ForCookieState { - // Expanded in Stage 6a. + attachCrossOriginCookies (ctx: unknown): Promise + + copyCookiesFromResponse (ctx: unknown): Promise } +export type CommandLogInterceptionInput = { + interception: unknown + route: unknown +} + +export type CommandLogInterceptionResult = { + setFlag?: (flag: string) => void +} | undefined + /** * Driven port: command log entries for intercept provenance. */ export interface ForCommandLog { - // Expanded in Stage 6a. + notifyIncomingRequest (ctx: unknown): void + + logInterception (input: CommandLogInterceptionInput): CommandLogInterceptionResult } /** diff --git a/packages/network-policy/test/unit/network-policy-core.spec.ts b/packages/network-policy/test/unit/network-policy-core.spec.ts index 8976fe6075..17800589a9 100644 --- a/packages/network-policy/test/unit/network-policy-core.spec.ts +++ b/packages/network-policy/test/unit/network-policy-core.spec.ts @@ -210,4 +210,32 @@ describe('NetworkPolicyCore', () => { await expect(core.injectHtml({})).rejects.toThrow(/documentPreparation/) await expect(core.removeSecurity({})).rejects.toThrow(/documentPreparation/) }) + + it('delegates capture, cookie, and command log ports', async () => { + const notifyIncomingRequest = vi.fn() + const attachCrossOriginCookies = vi.fn().mockResolvedValue(undefined) + const copyCookiesFromResponse = vi.fn().mockResolvedValue(undefined) + const notifyResponseStreamReceived = vi.fn().mockResolvedValue(undefined) + const notifyResponseEndedWithEmptyBody = vi.fn() + + const core = new NetworkPolicyCore({ + commandLog: { notifyIncomingRequest, logInterception: vi.fn() }, + cookieState: { attachCrossOriginCookies, copyCookiesFromResponse }, + networkCapture: { notifyResponseStreamReceived, notifyResponseEndedWithEmptyBody }, + }) + + const ctx = { req: {} } + + core.notifyIncomingRequest(ctx) + await core.attachCrossOriginCookies(ctx) + await core.copyCookiesFromResponse(ctx) + await core.notifyResponseStreamReceived(ctx) + core.notifyResponseEndedWithEmptyBody(ctx, { isCached: false }) + + expect(notifyIncomingRequest).toHaveBeenCalledWith(ctx) + expect(attachCrossOriginCookies).toHaveBeenCalledWith(ctx) + expect(copyCookiesFromResponse).toHaveBeenCalledWith(ctx) + expect(notifyResponseStreamReceived).toHaveBeenCalledWith(ctx) + expect(notifyResponseEndedWithEmptyBody).toHaveBeenCalledWith(ctx, { isCached: false }) + }) }) diff --git a/packages/network-policy/test/unit/request-logging.spec.ts b/packages/network-policy/test/unit/request-logging.spec.ts new file mode 100644 index 0000000000..e85ceb3f95 --- /dev/null +++ b/packages/network-policy/test/unit/request-logging.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { shouldLogRequest } from '../../lib/core/request-logging' + +describe('core/request-logging', () => { + it('logs intercept routes without static responses by default', () => { + expect(shouldLogRequest({ + matchingRoutes: [{ staticResponse: undefined }], + resourceType: 'image', + })).toBe(true) + }) + + it('respects staticResponse.log when set', () => { + expect(shouldLogRequest({ + matchingRoutes: [{ staticResponse: { log: false } }], + resourceType: 'xhr', + })).toBe(false) + }) + + it('logs xhr and fetch when no intercept routes match', () => { + expect(shouldLogRequest({ resourceType: 'xhr' })).toBe(true) + expect(shouldLogRequest({ resourceType: 'fetch' })).toBe(true) + expect(shouldLogRequest({ resourceType: 'image' })).toBe(false) + }) +}) diff --git a/packages/proxy/lib/adapters/attach-cross-origin-cookies.ts b/packages/proxy/lib/adapters/attach-cross-origin-cookies.ts new file mode 100644 index 0000000000..cc1930e601 --- /dev/null +++ b/packages/proxy/lib/adapters/attach-cross-origin-cookies.ts @@ -0,0 +1,79 @@ +import { telemetry } from '@packages/telemetry' +import { getSameSiteContext, shouldAttachAndSetCookies, addCookieJarCookiesToRequest } from '../http/util/cookies' +import { doesTopNeedToBeSimulated } from '../http/util/top-simulation' +import { isVerboseTelemetry as isVerbose } from '../http' +import * as errors from '@packages/errors' +import type { RequestInterceptionMiddlewareCtx } from './types' + +/** + * Attach cross-origin cookies from the server-side cookie jar to proxied requests. + */ +export async function attachCrossOriginCookies (mw: RequestInterceptionMiddlewareCtx): Promise { + const span = telemetry.startSpan({ name: 'maybe:attach:cross:origin:cookies', parentSpan: mw.reqMiddlewareSpan, isVerbose }) + + const doesTopNeedSimulation = doesTopNeedToBeSimulated(mw) + + span?.setAttributes({ + doesTopNeedToBeSimulated: doesTopNeedSimulation, + resourceType: mw.req.resourceType, + }) + + if (!doesTopNeedSimulation) { + span?.end() + + return mw.next() + } + + if (mw.req.isSyncRequest) { + errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED', mw.req.proxiedUrl) + } + + const currentAUTUrl = mw.getAUTUrl() + const shouldCookiesBeAttachedToRequest = shouldAttachAndSetCookies(mw.req.proxiedUrl, currentAUTUrl, mw.req.resourceType, mw.req.credentialsLevel, mw.req.isAUTFrame) + + span?.setAttributes({ + currentAUTUrl, + shouldCookiesBeAttachedToRequest, + }) + + mw.debug(`should cookies be attached to request?: ${shouldCookiesBeAttachedToRequest}`) + if (!shouldCookiesBeAttachedToRequest) { + span?.end() + + return mw.next() + } + + const sameSiteContext = getSameSiteContext( + currentAUTUrl, + mw.req.proxiedUrl, + mw.req.isAUTFrame, + ) + + span?.setAttributes({ + sameSiteContext, + currentAUTUrl, + isAUTFrame: mw.req.isAUTFrame, + }) + + const applicableCookiesInCookieJar = mw.getCookieJar().getCookies(mw.req.proxiedUrl, sameSiteContext) + const cookiesOnRequest = (mw.req.headers['cookie'] || '').split('; ') + + const existingCookiesInJar = applicableCookiesInCookieJar.join('; ') + const addedCookiesFromHeader = cookiesOnRequest.join('; ') + + mw.debug('existing cookies on request from cookie jar: %s', existingCookiesInJar) + mw.debug('add cookies to request from header: %s', addedCookiesFromHeader) + + mw.req.headers['cookie'] = addCookieJarCookiesToRequest(applicableCookiesInCookieJar, cookiesOnRequest) || undefined + + span?.setAttributes({ + existingCookiesInJar, + addedCookiesFromHeader, + cookieHeader: mw.req.headers['cookie'], + }) + + mw.debug('cookies being sent with request: %s', mw.req.headers['cookie']) + + span?.end() + mw.next() +} diff --git a/packages/proxy/lib/adapters/copy-cookies-from-response.ts b/packages/proxy/lib/adapters/copy-cookies-from-response.ts new file mode 100644 index 0000000000..1e78f7beeb --- /dev/null +++ b/packages/proxy/lib/adapters/copy-cookies-from-response.ts @@ -0,0 +1,119 @@ +import { URL } from 'url' +import { telemetry } from '@packages/telemetry' +import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' +import { CookiesHelper } from '../http/util/cookies' +import { doesTopNeedToBeSimulated } from '../http/util/top-simulation' +import { isVerboseTelemetry as isVerbose } from '../http' +import * as errors from '@packages/errors' +import type { ResponseInterceptionMiddlewareCtx } from './types' + +function setSimulatedCookies (mw: ResponseInterceptionMiddlewareCtx) { + if (mw.res.wantsInjection !== 'fullCrossOrigin') return + + const defaultDomain = (new URL(mw.req.proxiedUrl)).hostname + const allCookiesForRequest = mw.getCookieJar() + .getCookies(mw.req.proxiedUrl) + .map((cookie) => toughCookieToAutomationCookie(cookie, defaultDomain)) + + mw.simulatedCookies = allCookiesForRequest +} + +/** + * Capture Set-Cookie headers into the server-side cookie jar and browser automation. + */ +export async function copyCookiesFromResponse (mw: ResponseInterceptionMiddlewareCtx): Promise { + const span = telemetry.startSpan({ name: 'maybe:copy:cookies:from:incoming:res', parentSpan: mw.resMiddlewareSpan, isVerbose }) + + const cookies: string | string[] | undefined = mw.incomingRes.headers['set-cookie'] + + const areCookiesPresent = !cookies || !cookies.length + + span?.setAttributes({ + areCookiesPresent, + }) + + if (areCookiesPresent) { + setSimulatedCookies(mw) + + span?.end() + + return mw.next() + } + + const doesTopNeedSimulating = doesTopNeedToBeSimulated(mw) + + span?.setAttributes({ + doesTopNeedSimulating, + }) + + const appendCookie = (cookie: string) => { + const headerName = 'Set-Cookie' + + try { + mw.res.append(headerName, cookie) + } catch (err) { + mw.debug(`failed to append header ${headerName}, continuing %o`, { err, cookie }) + } + } + + if (!doesTopNeedSimulating) { + ([] as string[]).concat(cookies).forEach((cookie) => { + appendCookie(cookie) + }) + + span?.end() + + return mw.next() + } + + const cookiesHelper = new CookiesHelper({ + cookieJar: mw.getCookieJar(), + currentAUTUrl: mw.getAUTUrl(), + debug: mw.debug, + request: { + url: mw.req.proxiedUrl, + isAUTFrame: mw.req.isAUTFrame, + doesTopNeedSimulating, + resourceType: mw.req.resourceType, + credentialLevel: mw.req.credentialsLevel, + }, + }) + + await cookiesHelper.capturePreviousCookies() + + ;([] as string[]).concat(cookies).forEach((cookie) => { + cookiesHelper.setCookie(cookie) + + appendCookie(cookie) + }) + + setSimulatedCookies(mw) + + const addedCookies = await cookiesHelper.getAddedCookies() + const wereSimCookiesAdded = addedCookies.length + + span?.setAttributes({ + wereSimCookiesAdded, + }) + + if (!wereSimCookiesAdded) { + span?.end() + + return mw.next() + } + + if (mw.req.isSyncRequest) { + errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET', mw.req.proxiedUrl) + + span?.end() + + return mw.next() + } + + mw.serverBus.once('cross:origin:cookies:received', () => { + span?.end() + mw.next() + }) + + mw.serverBus.emit('cross:origin:cookies', addedCookies) +} diff --git a/packages/proxy/lib/adapters/index.ts b/packages/proxy/lib/adapters/index.ts index e68298e0ef..cc4e76c93a 100644 --- a/packages/proxy/lib/adapters/index.ts +++ b/packages/proxy/lib/adapters/index.ts @@ -4,6 +4,12 @@ export { ProxyResponseInterceptionAdapter } from './proxy-response-interception' export { ProxyDocumentPreparationAdapter } from './proxy-document-preparation' +export { ProxyNetworkCaptureAdapter } from './proxy-network-capture' + +export { ProxyCookieStateAdapter } from './proxy-cookie-state' + +export { ProxyCommandLogAdapter } from './proxy-command-log' + export { correlateBrowserPreRequest } from './correlate-browser-pre-request' export { sendRequestOutgoing } from './send-request-outgoing' @@ -14,4 +20,12 @@ export { injectHtml } from './inject-html' export { removeSecurity } from './remove-security' +export { sendToDriver } from './send-to-driver' + +export { attachCrossOriginCookies } from './attach-cross-origin-cookies' + +export { copyCookiesFromResponse } from './copy-cookies-from-response' + +export { notifyResponseEndedWithEmptyBody, notifyResponseStreamReceived } from './network-capture' + export type { RequestInterceptionMiddlewareCtx, ResponseInterceptionMiddlewareCtx } from './types' diff --git a/packages/proxy/lib/adapters/network-capture.ts b/packages/proxy/lib/adapters/network-capture.ts new file mode 100644 index 0000000000..7a7a3ea2db --- /dev/null +++ b/packages/proxy/lib/adapters/network-capture.ts @@ -0,0 +1,71 @@ +import type { ResponseStreamOptions } from '@packages/types' +import { telemetry } from '@packages/telemetry' +import { isVerboseTelemetry as isVerbose } from '../http' +import { getOriginalRequestId } from '../http/util/protocol-capture' +import type { ResponseInterceptionMiddlewareCtx } from './types' + +/** + * Notify the protocol manager that a response stream is available for capture. + */ +export async function notifyResponseStreamReceived (mw: ResponseInterceptionMiddlewareCtx): Promise { + if (!mw.protocolManager || !mw.req.browserPreRequest?.requestId) { + return + } + + const preRequest = mw.req.browserPreRequest + const requestId = getOriginalRequestId(preRequest.requestId) + + const span = telemetry.startSpan({ name: 'gzip:body:protocol-notification', parentSpan: mw.resMiddlewareSpan, isVerbose }) + + const streamOptions: ResponseStreamOptions = { + requestId, + responseHeaders: mw.incomingRes.headers, + isAlreadyGunzipped: mw.isGunzipped, + isAlreadyBrotliDecompressed: mw.isBrotliDecompressed, + responseStream: mw.incomingResStream, + res: mw.res, + timings: { + cdpRequestWillBeSentTimestamp: preRequest.cdpRequestWillBeSentTimestamp, + cdpRequestWillBeSentReceivedTimestamp: preRequest.cdpRequestWillBeSentReceivedTimestamp, + proxyRequestReceivedTimestamp: preRequest.proxyRequestReceivedTimestamp, + cdpLagDuration: preRequest.cdpLagDuration, + proxyRequestCorrelationDuration: preRequest.proxyRequestCorrelationDuration, + }, + } + + const resultingStream = mw.protocolManager.responseStreamReceived(streamOptions) + + if (resultingStream) { + mw.incomingResStream = resultingStream.on('error', mw.onError).once('close', () => { + span?.end() + }) + } else { + span?.end() + } +} + +/** + * Notify the protocol manager that a response ended with an empty body. + */ +export function notifyResponseEndedWithEmptyBody ( + mw: ResponseInterceptionMiddlewareCtx, + options: { isCached: boolean }, +): void { + if (!mw.protocolManager || !mw.req.browserPreRequest?.requestId) { + return + } + + const requestId = getOriginalRequestId(mw.req.browserPreRequest.requestId) + + mw.protocolManager.responseEndedWithEmptyBody({ + requestId, + isCached: options.isCached, + timings: { + cdpRequestWillBeSentTimestamp: mw.req.browserPreRequest.cdpRequestWillBeSentTimestamp, + cdpRequestWillBeSentReceivedTimestamp: mw.req.browserPreRequest.cdpRequestWillBeSentReceivedTimestamp, + proxyRequestReceivedTimestamp: mw.req.browserPreRequest.proxyRequestReceivedTimestamp, + cdpLagDuration: mw.req.browserPreRequest.cdpLagDuration, + proxyRequestCorrelationDuration: mw.req.browserPreRequest.proxyRequestCorrelationDuration, + }, + }) +} diff --git a/packages/proxy/lib/adapters/proxy-command-log.ts b/packages/proxy/lib/adapters/proxy-command-log.ts new file mode 100644 index 0000000000..db8b3a327d --- /dev/null +++ b/packages/proxy/lib/adapters/proxy-command-log.ts @@ -0,0 +1,14 @@ +import type { CommandLogInterceptionInput, CommandLogInterceptionResult, ForCommandLog } from '@packages/network-policy' +import { sendToDriver } from './send-to-driver' +import type { RequestInterceptionMiddlewareCtx } from './types' + +/** Server-side {@link ForCommandLog} adapter — emits incoming request events to the driver. */ +export class ProxyCommandLogAdapter implements ForCommandLog { + notifyIncomingRequest (ctx: unknown): void { + sendToDriver(ctx as RequestInterceptionMiddlewareCtx) + } + + logInterception (_input: CommandLogInterceptionInput): CommandLogInterceptionResult { + return undefined + } +} diff --git a/packages/proxy/lib/adapters/proxy-cookie-state.ts b/packages/proxy/lib/adapters/proxy-cookie-state.ts new file mode 100644 index 0000000000..2a4db4f528 --- /dev/null +++ b/packages/proxy/lib/adapters/proxy-cookie-state.ts @@ -0,0 +1,15 @@ +import type { ForCookieState } from '@packages/network-policy' +import { attachCrossOriginCookies } from './attach-cross-origin-cookies' +import { copyCookiesFromResponse } from './copy-cookies-from-response' +import type { RequestInterceptionMiddlewareCtx, ResponseInterceptionMiddlewareCtx } from './types' + +/** {@link ForCookieState} adapter — delegates to legacy proxy cookie jar middleware. */ +export class ProxyCookieStateAdapter implements ForCookieState { + attachCrossOriginCookies (ctx: unknown): Promise { + return attachCrossOriginCookies(ctx as RequestInterceptionMiddlewareCtx) + } + + copyCookiesFromResponse (ctx: unknown): Promise { + return copyCookiesFromResponse(ctx as ResponseInterceptionMiddlewareCtx) + } +} diff --git a/packages/proxy/lib/adapters/proxy-network-capture.ts b/packages/proxy/lib/adapters/proxy-network-capture.ts new file mode 100644 index 0000000000..72372aada3 --- /dev/null +++ b/packages/proxy/lib/adapters/proxy-network-capture.ts @@ -0,0 +1,14 @@ +import type { ForNetworkCapture } from '@packages/network-policy' +import { notifyResponseEndedWithEmptyBody, notifyResponseStreamReceived } from './network-capture' +import type { ResponseInterceptionMiddlewareCtx } from './types' + +/** {@link ForNetworkCapture} adapter — delegates to legacy proxy protocol capture hooks. */ +export class ProxyNetworkCaptureAdapter implements ForNetworkCapture { + notifyResponseStreamReceived (ctx: unknown): Promise { + return notifyResponseStreamReceived(ctx as ResponseInterceptionMiddlewareCtx) + } + + notifyResponseEndedWithEmptyBody (ctx: unknown, options: { isCached: boolean }): void { + return notifyResponseEndedWithEmptyBody(ctx as ResponseInterceptionMiddlewareCtx, options) + } +} diff --git a/packages/proxy/lib/adapters/send-to-driver.ts b/packages/proxy/lib/adapters/send-to-driver.ts new file mode 100644 index 0000000000..f6f823278f --- /dev/null +++ b/packages/proxy/lib/adapters/send-to-driver.ts @@ -0,0 +1,28 @@ +import { shouldLogRequest } from '@packages/network-policy' +import { telemetry } from '@packages/telemetry' +import { isVerboseTelemetry as isVerbose } from '../http' +import type { RequestInterceptionMiddlewareCtx } from './types' + +/** + * Emit incoming request events to the driver for proxy command logging. + */ +export function sendToDriver (mw: RequestInterceptionMiddlewareCtx): void { + const span = telemetry.startSpan({ name: 'send:to:driver', parentSpan: mw.reqMiddlewareSpan, isVerbose }) + + const shouldLogReq = shouldLogRequest({ + matchingRoutes: mw.req.matchingRoutes, + resourceType: mw.req.resourceType, + }) + + if (shouldLogReq && mw.req.browserPreRequest) { + mw.socket.toDriver('request:event', 'incoming:request', mw.req.browserPreRequest) + } + + span?.setAttributes({ + shouldLogReq, + hasBrowserPreRequest: !!mw.req.browserPreRequest, + }) + + span?.end() + mw.next() +} diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index c5be40892a..602006d048 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -3,15 +3,10 @@ import { blocked } from '@packages/network' import { InterceptRequest, SetMatchingRoutes } from '@packages/net-stubbing' import { telemetry } from '@packages/telemetry' import { isVerboseTelemetry as isVerbose } from '.' -import { - addCookieJarCookiesToRequest, getSameSiteContext, shouldAttachAndSetCookies, -} from './util/cookies' import { doesTopNeedToBeSimulated } from './util/top-simulation' import { resourceTypeAndCredentialManager } from '../resourceTypeAndCredentialManager' import type { HttpMiddleware } from './' -import type { CypressIncomingRequest } from '../types' import { getSupportedAcceptEncoding, urlMatchesOriginProtectionSpace } from '@packages/network-tools' -import * as errors from '@packages/errors' // do not use a debug namespace in this file - use the per-request `this.debug` instead // available as cypress-verbose:proxy:http @@ -139,113 +134,12 @@ const FormatCookiesIfApplicable: RequestMiddleware = function () { return this.next() } -const MaybeAttachCrossOriginCookies: RequestMiddleware = function () { - const span = telemetry.startSpan({ name: 'maybe:attach:cross:origin:cookies', parentSpan: this.reqMiddlewareSpan, isVerbose }) - - const doesTopNeedSimulation = doesTopNeedToBeSimulated(this) - - span?.setAttributes({ - doesTopNeedToBeSimulated: doesTopNeedSimulation, - resourceType: this.req.resourceType, - }) - - if (!doesTopNeedSimulation) { - span?.end() - - return this.next() - } - - if (this.req.isSyncRequest) { - errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_APPLIED', this.req.proxiedUrl) - } - - // Top needs to be simulated since the AUT is in a cross origin state. Get the "requested with" and credentials and see what cookies need to be attached - const currentAUTUrl = this.getAUTUrl() - const shouldCookiesBeAttachedToRequest = shouldAttachAndSetCookies(this.req.proxiedUrl, currentAUTUrl, this.req.resourceType, this.req.credentialsLevel, this.req.isAUTFrame) - - span?.setAttributes({ - currentAUTUrl, - shouldCookiesBeAttachedToRequest, - }) - - this.debug(`should cookies be attached to request?: ${shouldCookiesBeAttachedToRequest}`) - if (!shouldCookiesBeAttachedToRequest) { - span?.end() - - return this.next() - } - - const sameSiteContext = getSameSiteContext( - currentAUTUrl, - this.req.proxiedUrl, - this.req.isAUTFrame, - ) - - span?.setAttributes({ - sameSiteContext, - currentAUTUrl, - isAUTFrame: this.req.isAUTFrame, - }) - - const applicableCookiesInCookieJar = this.getCookieJar().getCookies(this.req.proxiedUrl, sameSiteContext) - const cookiesOnRequest = (this.req.headers['cookie'] || '').split('; ') - - const existingCookiesInJar = applicableCookiesInCookieJar.join('; ') - const addedCookiesFromHeader = cookiesOnRequest.join('; ') - - this.debug('existing cookies on request from cookie jar: %s', existingCookiesInJar) - this.debug('add cookies to request from header: %s', addedCookiesFromHeader) - - // if the cookie header is empty (i.e. ''), set it to undefined for expected behavior - this.req.headers['cookie'] = addCookieJarCookiesToRequest(applicableCookiesInCookieJar, cookiesOnRequest) || undefined - - span?.setAttributes({ - existingCookiesInJar, - addedCookiesFromHeader, - cookieHeader: this.req.headers['cookie'], - }) - - this.debug('cookies being sent with request: %s', this.req.headers['cookie']) - - span?.end() - this.next() -} - -function shouldLog (req: CypressIncomingRequest) { - // 1. Any matching `cy.intercept()` should cause `req` to be logged by default, unless `log: false` is passed explicitly. - if (req.matchingRoutes?.length) { - const lastMatchingRoute = req.matchingRoutes[0] - - if (!lastMatchingRoute.staticResponse) { - // No StaticResponse is set, therefore the request must be logged. - return true - } - - if (lastMatchingRoute.staticResponse.log !== undefined) { - return Boolean(lastMatchingRoute.staticResponse.log) - } - } - - // 2. Otherwise, only log if it is an XHR or fetch. - return req.resourceType === 'fetch' || req.resourceType === 'xhr' +const MaybeAttachCrossOriginCookies: RequestMiddleware = async function () { + return this.networkPolicyCore.attachCrossOriginCookies(this) } const SendToDriver: RequestMiddleware = function () { - const span = telemetry.startSpan({ name: 'send:to:driver', parentSpan: this.reqMiddlewareSpan, isVerbose }) - - const shouldLogReq = shouldLog(this.req) - - if (shouldLogReq && this.req.browserPreRequest) { - this.socket.toDriver('request:event', 'incoming:request', this.req.browserPreRequest) - } - - span?.setAttributes({ - shouldLogReq, - hasBrowserPreRequest: !!this.req.browserPreRequest, - }) - - span?.end() - this.next() + this.networkPolicyCore.notifyIncomingRequest(this) } const MaybeEndRequestWithBufferedResponse: RequestMiddleware = function () { diff --git a/packages/proxy/lib/http/response-middleware.ts b/packages/proxy/lib/http/response-middleware.ts index 79eaf75f5d..44baa2ebdd 100644 --- a/packages/proxy/lib/http/response-middleware.ts +++ b/packages/proxy/lib/http/response-middleware.ts @@ -4,17 +4,12 @@ import { URL } from 'url' import zlib from 'zlib' import { InterceptResponse } from '@packages/net-stubbing' import { concatStream, httpUtils } from '@packages/network' -import { toughCookieToAutomationCookie } from '@packages/server/lib/util/cookies' import { telemetry } from '@packages/telemetry' import { hasServiceWorkerHeader, isVerboseTelemetry as isVerbose } from '.' -import { CookiesHelper } from './util/cookies' -import { doesTopNeedToBeSimulated } from './util/top-simulation' -import * as errors from '@packages/errors' import type { CookieOptions } from 'express' -import type { ResponseStreamOptions } from '@packages/types' import type { CypressOutgoingResponse } from '../types' -import type { HttpMiddleware, HttpMiddlewareThis } from '.' +import type { HttpMiddleware } from '.' import type { IncomingMessage } from 'http' import { cspHeaderNames, generateCspDirectives, parseCspHeaders, problematicCspDirectives, unsupportedCSPDirectives } from './util/csp-header' @@ -150,18 +145,6 @@ const stringifyFeaturePolicy = (policy: any): string => { return pairs.map((directive) => directive.join(' ')).join('; ') } -const requestIdRegEx = /^(.*)-retry-([\d]+)$/ -const getOriginalRequestId = (requestId: string) => { - let originalRequestId = requestId - const match = requestIdRegEx.exec(requestId) - - if (match) { - [, originalRequestId] = match - } - - return originalRequestId -} - const LogResponse: ResponseMiddleware = function () { this.debug('received response %o', { browserPreRequest: _.pick(this.req.browserPreRequest, 'requestId'), @@ -469,142 +452,8 @@ const MaybePreventCaching: ResponseMiddleware = function () { this.next() } -const setSimulatedCookies = (ctx: HttpMiddlewareThis) => { - if (ctx.res.wantsInjection !== 'fullCrossOrigin') return - - const defaultDomain = (new URL(ctx.req.proxiedUrl)).hostname - const allCookiesForRequest = ctx.getCookieJar() - .getCookies(ctx.req.proxiedUrl) - .map((cookie) => toughCookieToAutomationCookie(cookie, defaultDomain)) - - ctx.simulatedCookies = allCookiesForRequest -} - const MaybeCopyCookiesFromIncomingRes: ResponseMiddleware = async function () { - const span = telemetry.startSpan({ name: 'maybe:copy:cookies:from:incoming:res', parentSpan: this.resMiddlewareSpan, isVerbose }) - - const cookies: string | string[] | undefined = this.incomingRes.headers['set-cookie'] - - const areCookiesPresent = !cookies || !cookies.length - - span?.setAttributes({ - areCookiesPresent, - }) - - if (areCookiesPresent) { - setSimulatedCookies(this) - - span?.end() - - return this.next() - } - - // Simulated Top Cookie Handling - // --------------------------- - // - We capture cookies sent by responses and add them to our own server-side - // tough-cookie cookie jar. All request cookies are captured, since any - // future request could be cross-origin in the context of top, even if the response that sets them - // is not. - // - If we sent the cookie header, it may fail to be set by the browser - // (in most cases). However, we cannot determine all the cases in which Set-Cookie - // will currently fail. We try to address this in our tough cookie jar - // by only setting cookies that would otherwise work in the browser if the AUT url was top - // - We also set the cookies through automation so they are available in the - // browser via document.cookie and via Cypress cookie APIs - // (e.g. cy.getCookie). This is only done when the AUT url and top do not match responses, - // since AUT and Top being same origin will be successfully set in the browser - // automatically as expected. - // - In the request middleware, we retrieve the cookies for a given URL - // and attach them to the request, like the browser normally would. - // tough-cookie handles retrieving the correct cookies based on domain, - // path, etc. It also removes cookies from the cookie jar if they've expired. - const doesTopNeedSimulating = doesTopNeedToBeSimulated(this) - - span?.setAttributes({ - doesTopNeedSimulating, - }) - - const appendCookie = (cookie: string) => { - // always call 'Set-Cookie' in the browser as cross origin or same site requests - // can effectively set cookies in the browser if given correct credential permissions - const headerName = 'Set-Cookie' - - try { - this.res.append(headerName, cookie) - } catch (err) { - this.debug(`failed to append header ${headerName}, continuing %o`, { err, cookie }) - } - } - - if (!doesTopNeedSimulating) { - ([] as string[]).concat(cookies).forEach((cookie) => { - appendCookie(cookie) - }) - - span?.end() - - return this.next() - } - - const cookiesHelper = new CookiesHelper({ - cookieJar: this.getCookieJar(), - currentAUTUrl: this.getAUTUrl(), - debug: this.debug, - request: { - url: this.req.proxiedUrl, - isAUTFrame: this.req.isAUTFrame, - doesTopNeedSimulating, - resourceType: this.req.resourceType, - credentialLevel: this.req.credentialsLevel, - }, - }) - - await cookiesHelper.capturePreviousCookies() - - ;([] as string[]).concat(cookies).forEach((cookie) => { - cookiesHelper.setCookie(cookie) - - appendCookie(cookie) - }) - - setSimulatedCookies(this) - - const addedCookies = await cookiesHelper.getAddedCookies() - const wereSimCookiesAdded = addedCookies.length - - span?.setAttributes({ - wereSimCookiesAdded, - }) - - if (!wereSimCookiesAdded) { - span?.end() - - return this.next() - } - - // if the request is sync, we cannot wait on the cross:origin:cookies:received - // event since the sync request is blocking. This means that the cross-origin cookies - // may not have been applied. - if (this.req.isSyncRequest) { - errors.warning('SYNCHRONOUS_XHR_REQUEST_COOKIES_NOT_SET', this.req.proxiedUrl) - - span?.end() - - return this.next() - } - - // we want to set the cookies via automation so they exist in the browser - // itself. however, firefox will hang if we try to use the extension - // to set cookies on a url that's in-flight, so we send the cookies down to - // the driver, let the response go, and set the cookies via automation - // from the driver once the page has loaded but before we run any further - // commands - this.serverBus.once('cross:origin:cookies:received', () => { - span?.end() - this.next() - }) - - this.serverBus.emit('cross:origin:cookies', addedCookies) + return this.networkPolicyCore.copyCookiesFromResponse(this) } const REDIRECT_STATUS_CODES: any[] = [301, 302, 303, 307, 308] @@ -661,40 +510,16 @@ const ClearCyInitialCookie: ResponseMiddleware = function () { } const MaybeEndWithEmptyBody: ResponseMiddleware = function () { - const notifyProtocolManagerOfEmptyBody = (isCached: boolean) => { - if (this.protocolManager && this.req.browserPreRequest?.requestId) { - const requestId = getOriginalRequestId(this.req.browserPreRequest.requestId) - - this.protocolManager.responseEndedWithEmptyBody({ - requestId, - isCached, - timings: { - cdpRequestWillBeSentTimestamp: this.req.browserPreRequest.cdpRequestWillBeSentTimestamp, - cdpRequestWillBeSentReceivedTimestamp: this.req.browserPreRequest.cdpRequestWillBeSentReceivedTimestamp, - proxyRequestReceivedTimestamp: this.req.browserPreRequest.proxyRequestReceivedTimestamp, - cdpLagDuration: this.req.browserPreRequest.cdpLagDuration, - proxyRequestCorrelationDuration: this.req.browserPreRequest.proxyRequestCorrelationDuration, - }, - }) - } - } - if (httpUtils.responseMustHaveEmptyBody(this.req, this.incomingRes)) { - notifyProtocolManagerOfEmptyBody(this.incomingRes.statusCode === 304) + this.networkPolicyCore.notifyResponseEndedWithEmptyBody(this, { + isCached: this.incomingRes.statusCode === 304, + }) this.res.end() return this.end() } - // When the origin response declared `Content-Length: 0`, short-circuit with an - // explicit Content-Length: 0 instead of streaming an empty body — otherwise - // OmitProblematicHeaders has stripped Content-Length and Node's HTTP layer - // adds `Transfer-Encoding: chunked`, which breaks clients that assume a - // response for chunked encoding. See cypress-io/cypress#16469. - // Skip when downstream middleware will rewrite the body or when a cy.intercept - // route matched (the interceptor may have replaced the body without updating - // the upstream Content-Length header). const wasIntercepted = !!this.netStubbingState?.requests?.[this.req.requestId] if ( @@ -703,7 +528,7 @@ const MaybeEndWithEmptyBody: ResponseMiddleware = function () { && !this.res.wantsInjection && !this.res.wantsSecurityRemoved ) { - notifyProtocolManagerOfEmptyBody(false) + this.networkPolicyCore.notifyResponseEndedWithEmptyBody(this, { isCached: false }) this.res.setHeader('Content-Length', '0') this.res.end() @@ -755,38 +580,7 @@ const MaybeInjectServiceWorker: ResponseMiddleware = function () { } const CompressBody: ResponseMiddleware = async function () { - if (this.protocolManager && this.req.browserPreRequest?.requestId) { - const preRequest = this.req.browserPreRequest - const requestId = getOriginalRequestId(preRequest.requestId) - - const span = telemetry.startSpan({ name: 'gzip:body:protocol-notification', parentSpan: this.resMiddlewareSpan, isVerbose }) - - const streamOptions: ResponseStreamOptions = { - requestId, - responseHeaders: this.incomingRes.headers, - isAlreadyGunzipped: this.isGunzipped, - isAlreadyBrotliDecompressed: this.isBrotliDecompressed, - responseStream: this.incomingResStream, - res: this.res, - timings: { - cdpRequestWillBeSentTimestamp: preRequest.cdpRequestWillBeSentTimestamp, - cdpRequestWillBeSentReceivedTimestamp: preRequest.cdpRequestWillBeSentReceivedTimestamp, - proxyRequestReceivedTimestamp: preRequest.proxyRequestReceivedTimestamp, - cdpLagDuration: preRequest.cdpLagDuration, - proxyRequestCorrelationDuration: preRequest.proxyRequestCorrelationDuration, - }, - } - - const resultingStream = this.protocolManager.responseStreamReceived(streamOptions) - - if (resultingStream) { - this.incomingResStream = resultingStream.on('error', this.onError).once('close', () => { - span?.end() - }) - } else { - span?.end() - } - } + await this.networkPolicyCore.notifyResponseStreamReceived(this) // Re-compress in the same order as the original content-encoding (innermost first). const order = this.contentEncodingOrder ?? [] diff --git a/packages/proxy/lib/http/util/protocol-capture.ts b/packages/proxy/lib/http/util/protocol-capture.ts new file mode 100644 index 0000000000..9804ce1263 --- /dev/null +++ b/packages/proxy/lib/http/util/protocol-capture.ts @@ -0,0 +1,15 @@ +const requestIdRegEx = /^(.*)-retry-([\d]+)$/ + +/** + * Strip synthetic retry suffixes from CDP request IDs before protocol capture. + */ +export function getOriginalRequestId (requestId: string) { + let originalRequestId = requestId + const match = requestIdRegEx.exec(requestId) + + if (match) { + [, originalRequestId] = match + } + + return originalRequestId +} diff --git a/packages/proxy/test/integration/net-stubbing.spec.ts b/packages/proxy/test/integration/net-stubbing.spec.ts index efcb58856e..039ed69be2 100644 --- a/packages/proxy/test/integration/net-stubbing.spec.ts +++ b/packages/proxy/test/integration/net-stubbing.spec.ts @@ -13,7 +13,10 @@ import { DocumentDomainInjection, RemoteStates } from '@packages/network-tools' import { EventEmitter } from 'events' import { NetworkPolicyCore } from '@packages/network-policy' import { + ProxyCommandLogAdapter, + ProxyCookieStateAdapter, ProxyDocumentPreparationAdapter, + ProxyNetworkCaptureAdapter, ProxyRequestInterceptionAdapter, ProxyResponseInterceptionAdapter, } from '../../lib/adapters' @@ -62,6 +65,9 @@ describe('network stubbing', () => { requestInterception: new ProxyRequestInterceptionAdapter(), responseInterception: new ProxyResponseInterceptionAdapter(), documentPreparation: new ProxyDocumentPreparationAdapter(), + networkCapture: new ProxyNetworkCaptureAdapter(), + cookieState: new ProxyCookieStateAdapter(), + commandLog: new ProxyCommandLogAdapter(), }), config, middleware: defaultMiddleware, diff --git a/packages/proxy/test/unit/adapters/proxy-command-log.spec.ts b/packages/proxy/test/unit/adapters/proxy-command-log.spec.ts new file mode 100644 index 0000000000..0d44bb7b8f --- /dev/null +++ b/packages/proxy/test/unit/adapters/proxy-command-log.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ProxyCommandLogAdapter } from '../../../lib/adapters/proxy-command-log' +import { sendToDriver } from '../../../lib/adapters/send-to-driver' + +vi.mock('../../../lib/adapters/send-to-driver', () => { + return { + sendToDriver: vi.fn(), + } +}) + +describe('ProxyCommandLogAdapter', () => { + beforeEach(() => { + vi.mocked(sendToDriver).mockReset() + }) + + it('delegates notifyIncomingRequest to sendToDriver helper', () => { + const adapter = new ProxyCommandLogAdapter() + const ctx = { req: { browserPreRequest: { requestId: '1' } } } + + adapter.notifyIncomingRequest(ctx) + + expect(sendToDriver).toHaveBeenCalledWith(ctx) + }) + + it('returns undefined from logInterception on the server', () => { + const adapter = new ProxyCommandLogAdapter() + + expect(adapter.logInterception({ interception: {}, route: {} })).toBeUndefined() + }) +}) diff --git a/packages/proxy/test/unit/adapters/proxy-cookie-state.spec.ts b/packages/proxy/test/unit/adapters/proxy-cookie-state.spec.ts new file mode 100644 index 0000000000..68ebdadbd9 --- /dev/null +++ b/packages/proxy/test/unit/adapters/proxy-cookie-state.spec.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ProxyCookieStateAdapter } from '../../../lib/adapters/proxy-cookie-state' +import { attachCrossOriginCookies } from '../../../lib/adapters/attach-cross-origin-cookies' +import { copyCookiesFromResponse } from '../../../lib/adapters/copy-cookies-from-response' + +vi.mock('../../../lib/adapters/attach-cross-origin-cookies', () => { + return { + attachCrossOriginCookies: vi.fn(), + } +}) + +vi.mock('../../../lib/adapters/copy-cookies-from-response', () => { + return { + copyCookiesFromResponse: vi.fn(), + } +}) + +describe('ProxyCookieStateAdapter', () => { + beforeEach(() => { + vi.mocked(attachCrossOriginCookies).mockReset() + vi.mocked(copyCookiesFromResponse).mockReset() + }) + + it('delegates attachCrossOriginCookies to helper', async () => { + const adapter = new ProxyCookieStateAdapter() + const ctx = { req: {} } + + vi.mocked(attachCrossOriginCookies).mockResolvedValue(undefined) + + await adapter.attachCrossOriginCookies(ctx) + + expect(attachCrossOriginCookies).toHaveBeenCalledWith(ctx) + }) + + it('delegates copyCookiesFromResponse to helper', async () => { + const adapter = new ProxyCookieStateAdapter() + const ctx = { req: {} } + + vi.mocked(copyCookiesFromResponse).mockResolvedValue(undefined) + + await adapter.copyCookiesFromResponse(ctx) + + expect(copyCookiesFromResponse).toHaveBeenCalledWith(ctx) + }) +}) diff --git a/packages/proxy/test/unit/adapters/proxy-network-capture.spec.ts b/packages/proxy/test/unit/adapters/proxy-network-capture.spec.ts new file mode 100644 index 0000000000..5249c96f79 --- /dev/null +++ b/packages/proxy/test/unit/adapters/proxy-network-capture.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ProxyNetworkCaptureAdapter } from '../../../lib/adapters/proxy-network-capture' +import { notifyResponseEndedWithEmptyBody, notifyResponseStreamReceived } from '../../../lib/adapters/network-capture' + +vi.mock('../../../lib/adapters/network-capture', () => { + return { + notifyResponseStreamReceived: vi.fn(), + notifyResponseEndedWithEmptyBody: vi.fn(), + } +}) + +describe('ProxyNetworkCaptureAdapter', () => { + beforeEach(() => { + vi.mocked(notifyResponseStreamReceived).mockReset() + vi.mocked(notifyResponseEndedWithEmptyBody).mockReset() + }) + + it('delegates notifyResponseStreamReceived to helper', async () => { + const adapter = new ProxyNetworkCaptureAdapter() + const ctx = { req: { requestId: '1' } } + + vi.mocked(notifyResponseStreamReceived).mockResolvedValue(undefined) + + await adapter.notifyResponseStreamReceived(ctx) + + expect(notifyResponseStreamReceived).toHaveBeenCalledWith(ctx) + }) + + it('delegates notifyResponseEndedWithEmptyBody to helper', () => { + const adapter = new ProxyNetworkCaptureAdapter() + const ctx = { req: { requestId: '1' } } + + adapter.notifyResponseEndedWithEmptyBody(ctx, { isCached: true }) + + expect(notifyResponseEndedWithEmptyBody).toHaveBeenCalledWith(ctx, { isCached: true }) + }) +}) diff --git a/packages/proxy/test/unit/http/helpers.ts b/packages/proxy/test/unit/http/helpers.ts index dae85b3296..857879c929 100644 --- a/packages/proxy/test/unit/http/helpers.ts +++ b/packages/proxy/test/unit/http/helpers.ts @@ -1,7 +1,10 @@ import { HttpMiddleware, HttpStages, _runStage } from '../../../lib/http' import { NetworkPolicyCore } from '@packages/network-policy' import { + ProxyCommandLogAdapter, + ProxyCookieStateAdapter, ProxyDocumentPreparationAdapter, + ProxyNetworkCaptureAdapter, ProxyRequestInterceptionAdapter, ProxyResponseInterceptionAdapter, } from '../../../lib/adapters' @@ -11,6 +14,9 @@ export function createTestNetworkPolicyCore () { requestInterception: new ProxyRequestInterceptionAdapter(), responseInterception: new ProxyResponseInterceptionAdapter(), documentPreparation: new ProxyDocumentPreparationAdapter(), + networkCapture: new ProxyNetworkCaptureAdapter(), + cookieState: new ProxyCookieStateAdapter(), + commandLog: new ProxyCommandLogAdapter(), }) } diff --git a/packages/server/lib/network-runtime.ts b/packages/server/lib/network-runtime.ts index 1dc9b8041f..fefcfdd225 100644 --- a/packages/server/lib/network-runtime.ts +++ b/packages/server/lib/network-runtime.ts @@ -1,7 +1,10 @@ import type EventEmitter from 'events' import { NetworkProxy, BrowserPreRequest } from '@packages/proxy' import { + ProxyCommandLogAdapter, + ProxyCookieStateAdapter, ProxyDocumentPreparationAdapter, + ProxyNetworkCaptureAdapter, ProxyRequestInterceptionAdapter, ProxyResponseInterceptionAdapter, } from '@packages/proxy/lib/adapters' @@ -47,6 +50,9 @@ export function createProxyRuntime (deps: CreateProxyRuntimeDeps): ProxyNetworkR requestInterception: new ProxyRequestInterceptionAdapter(), responseInterception: new ProxyResponseInterceptionAdapter(), documentPreparation: new ProxyDocumentPreparationAdapter(), + networkCapture: new ProxyNetworkCaptureAdapter(), + cookieState: new ProxyCookieStateAdapter(), + commandLog: new ProxyCommandLogAdapter(), }) registerDefaultNetworkPolicies(networkPolicyRegistration, deps.config) diff --git a/packages/server/test/unit/network-runtime_spec.ts b/packages/server/test/unit/network-runtime_spec.ts index b5f0d6566b..3eef49654d 100644 --- a/packages/server/test/unit/network-runtime_spec.ts +++ b/packages/server/test/unit/network-runtime_spec.ts @@ -51,6 +51,9 @@ describe('lib/network-runtime', () => { expect(runtime.networkPolicyCore.requestInterception).to.exist expect(runtime.networkPolicyCore.responseInterception).to.exist expect(runtime.networkPolicyCore.documentPreparation).to.exist + expect(runtime.networkPolicyCore.networkCapture).to.exist + expect(runtime.networkPolicyCore.cookieState).to.exist + expect(runtime.networkPolicyCore.commandLog).to.exist }) it('registers configurator CSP and document rewrite policies at startup', () => {