UNPKG

mentoss

Version:

A utility to mock fetch requests and responses.

598 lines (597 loc) 24.6 kB
/** * @fileoverview The FetchMocker class. * @author Nicholas C. Zakas */ /* globals Headers */ //----------------------------------------------------------------------------- // Imports //----------------------------------------------------------------------------- import { NoRouteMatchedError } from "./util.js"; import { isCorsSimpleRequest, CorsPreflightData, assertCorsResponse, processCorsResponse, validateCorsRequest, CORS_REQUEST_METHOD, CORS_REQUEST_HEADERS, CORS_ORIGIN, createCorsPreflightError, getUnsafeHeaders, createCorsError, } from "./cors.js"; import { createCustomRequest } from "./custom-request.js"; import { isRedirectStatus, isBodylessMethod, isBodyPreservingRedirectStatus, isRequestBodyHeader, } from "./http.js"; //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** @typedef {import("./types.js").RequestPattern} RequestPattern */ /** @typedef {import("./types.js").Credentials} Credentials */ /** @typedef {import("./mock-server.js").MockServer} MockServer */ /** @typedef {import("./mock-server.js").Trace} Trace */ //----------------------------------------------------------------------------- // Helpers //----------------------------------------------------------------------------- /** * Creates a base URL from a URL or string. This is also validates * the URL to ensure it's valid. Empty strings are invalid. * @param {string|URL|undefined} baseUrl The base URL to create. * @returns {URL|undefined} The created base URL. * @throws {TypeError} When the base URL is an empty string. * @throws {TypeError} When the base URL is not a string or URL. */ function createBaseUrl(baseUrl) { if (baseUrl === undefined) { return undefined; } if (baseUrl === "") { throw new TypeError("Base URL cannot be an empty string."); } if (baseUrl instanceof URL) { return baseUrl; } if (typeof baseUrl !== "string") { throw new TypeError("Base URL must be a string or URL object."); } return new URL(baseUrl); } /** * Determines if two URL are of the same origin based on the same origin * policy instituted by browsers. * @param {URL} requestUrl The URL of the request. * @param {URL} baseUrl The base URL to compare against. * @returns {boolean} `true` if the URLs are of the same origin, `false` otherwise. */ function isSameOrigin(requestUrl, baseUrl) { return requestUrl.origin === baseUrl.origin; } /** * Creates an opaque response. * @param {typeof Response} ResponseConstructor The Response constructor to use * @returns {Response} An opaque response */ function createOpaqueResponse(ResponseConstructor) { const response = new ResponseConstructor(null, { status: 200, // doesn't accept a 0 status statusText: "", headers: {}, }); // Define non-configurable properties to match opaque response behavior Object.defineProperties(response, { type: { value: "opaque", configurable: false }, url: { value: "", configurable: false }, ok: { value: false, configurable: false }, redirected: { value: false, configurable: false }, body: { value: null, configurable: false }, bodyUsed: { value: false, configurable: false }, status: { value: 0, configurable: false }, }); return response; } /** * Creates an opaque filtered redirect response. * @param {typeof Response} ResponseConstructor The Response constructor to use * @param {string} url The URL of the response * @returns {Response} An opaque redirect response */ function createOpaqueRedirectResponse(ResponseConstructor, url) { const response = new ResponseConstructor(null, { status: 200, // Node.js doesn't accept 0 status, so use 200 then override it statusText: "", headers: {}, }); // Define non-configurable properties to match opaque redirect response behavior Object.defineProperties(response, { type: { value: "opaqueredirect", configurable: false }, url: { value: url, configurable: false }, ok: { value: false, configurable: false }, redirected: { value: false, configurable: false }, body: { value: null, configurable: false }, bodyUsed: { value: false, configurable: false }, status: { value: 0, configurable: false }, }); return response; } /** * Checks if a redirect needs to adjust the request method and headers. * @param {Request} request The original request * @param {number} status The redirect status code * @returns {boolean} True if the redirect needs to adjust the method */ function redirectNeedsAdjustment(request, status) { // For 303 redirects, change method to GET if it's not already GET or HEAD return ((status === 303 && !isBodylessMethod(request.method)) || // For 301/302 redirects, change method to GET if the original method was POST ((status === 301 || status === 302) && request.method === "POST")); } /** * Checks if a response is tainted (violates CORS) * @param {Response} response The response to check * @returns {boolean} True if the response is tainted */ function isTaintedResponse(response) { return response.type.startsWith("opaque"); } //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** * A class for mocking the `fetch` function. */ export class FetchMocker { /** * The registered servers for `fetch`. * @type {MockServer[]} */ #servers = []; /** * The credentials for `fetch`. * @type {Credentials[]} */ #credentials; /** * The CORS preflight data for each URL. * @type {Map<string, CorsPreflightData>} */ #corsPreflightData = new Map(); /** * The Response constructor to use. * @type {typeof Response} */ #Response; /** * The Request constructor to use. * @type {typeof Request} */ #Request; /** * The base URL to use for relative URLs. * @type {URL|undefined} */ #baseUrl; /** * The created fetch function. * @type {typeof fetch} */ fetch = (input, init) => this.#fetch(input, init); /** * @type {typeof fetch} */ async #fetch(input, init) { // first check to see if the request has been aborted const signal = init?.signal; signal?.throwIfAborted(); // TODO: For some reason this causes Mocha tests to fail with "multiple done" // signal?.addEventListener("abort", () => { // throw new Error("Fetch was aborted."); // }); // adjust any relative URLs const fixedInput = typeof input === "string" && this.#baseUrl ? new URL(input, this.#baseUrl).toString() : input; const request = new this.#Request(fixedInput, init); let useCors = false; let useCorsCredentials = false; let preflightData; let isSimpleRequest = false; // if there's a base URL then we need to check for CORS if (this.#baseUrl) { const requestUrl = new URL(request.url); if (isSameOrigin(requestUrl, this.#baseUrl)) { // if we aren't explicitly blocking credentials then add them if (request.credentials !== "omit") { this.#attachCredentialsToRequest(request); } } else { // check for same-origin mode if (request.mode === "same-origin") { throw new TypeError(`Failed to fetch. Request mode is "same-origin" but the URL's origin is not same as the request origin ${this.#baseUrl.origin}`); } useCors = true; isSimpleRequest = isCorsSimpleRequest(request); const includeCredentials = request.credentials === "include"; validateCorsRequest(request, this.#baseUrl.origin); if (isSimpleRequest) { if (includeCredentials) { useCorsCredentials = true; this.#attachCredentialsToRequest(request); } } else { preflightData = await this.#preflightFetch(request); preflightData.validate(request, this.#baseUrl.origin); if (includeCredentials) { if (!preflightData.allowCredentials) { throw createCorsPreflightError(request.url, this.#baseUrl.origin, "No 'Access-Control-Allow-Credentials' header is present on the requested resource."); } useCorsCredentials = true; this.#attachCredentialsToRequest(request); } } // add the origin header to the request request.headers.append("origin", this.#baseUrl.origin); // if the preflight response is successful, then we can make the actual request } } signal?.throwIfAborted(); const response = await this.#internalFetch(request, init?.body); if (useCors && this.#baseUrl) { // handle no-cors mode for any cross-origin request if (isSimpleRequest && request.mode === "no-cors") { return createOpaqueResponse(this.#Response); } processCorsResponse(response, this.#baseUrl.origin, useCorsCredentials); } signal?.throwIfAborted(); // Process redirects return this.#processRedirect(response, request, [], init?.body); } /** * Map to store original fetch functions for objects * @type {WeakMap<object, Map<string, Function>>} */ #originalFetchers = new WeakMap(); /** * Creates a new instance. * @param {object} options Options for the instance. * @param {MockServer[]} options.servers The servers to use. * @param {string|URL} [options.baseUrl] The base URL to use for relative URLs. * @param {Credentials[]} [options.credentials] The credentials to use. * @param {typeof Response} [options.CustomResponse] The Response constructor to use. * @param {typeof Request} [options.CustomRequest] The Request constructor to use. */ constructor({ servers, baseUrl, credentials = [], CustomRequest = globalThis.Request, CustomResponse = globalThis.Response, }) { this.#servers = servers; this.#credentials = credentials; this.#baseUrl = createBaseUrl(baseUrl); this.#Response = CustomResponse; this.#Request = createCustomRequest(CustomRequest); // must be least one server if (!servers || servers.length === 0) { throw new TypeError("At least one server is required."); } // credentials can only be used if there is a base URL if (credentials.length > 0 && !baseUrl) { throw new TypeError("Credentials can only be used with a base URL."); } } /** * Attaches credentials to a request. * @param {Request} request The request to attach credentials to. * @returns {void} */ #attachCredentialsToRequest(request) { if (this.#credentials.length === 0) { return; } for (const credentials of this.#credentials) { const credentialHeaders = credentials.getHeadersForRequest(request); if (credentialHeaders) { for (const [key, value] of credentialHeaders) { request.headers.append(key, value); } } } } /** * An internal fetch() implementation that runs against the given servers. * @param {Request} request The request to fetch. * @param {string|any|FormData|null} body The body of the request. * @returns {Promise<Response>} The response from the fetch. * @throws {Error} When no route is matched. */ async #internalFetch(request, body = null) { const allTraces = []; /* * Note: Each server gets its own copy of the request so that it * can read the body without interfering with any other servers. */ for (const server of this.#servers) { const requestClone = request.clone(); const { response, traces } = await server.traceReceive(requestClone, this.#Response); if (response) { // Set response.url and type Object.defineProperties(response, { url: { value: request.url }, type: { value: request.mode === "cors" ? "cors" : "default", }, }); return response; } allTraces.push(...traces); } /* * To find possible traces, filter out all of the traces that only * have one message. This is because a single message means that * the URL wasn't matched, so there's no point in reporting that. */ const possibleTraces = allTraces.filter(trace => trace.messages.length > 1); // throw an error saying the route wasn't matched throw new NoRouteMatchedError(request, body, possibleTraces); } /** * Creates a preflight request for a URL. * @param {Request} request The request to create a preflight request for. * @returns {Request} The preflight request. * @throws {Error} When there is no base URL. * @see https://fetch.spec.whatwg.org/#cors-preflight-fetch */ #createPreflightRequest(request) { if (!this.#baseUrl) { throw new Error("Cannot create preflight request without a base URL."); } const nonsimpleHeaders = getUnsafeHeaders(request); /** @type {Record<string,string>} */ const headers = { Accept: "*/*", [CORS_REQUEST_METHOD]: request.method, [CORS_ORIGIN]: this.#baseUrl.origin, }; if (nonsimpleHeaders.length > 0) { headers[CORS_REQUEST_HEADERS] = nonsimpleHeaders.join(","); } return new this.#Request(request.url, { method: "OPTIONS", headers, mode: "cors", referrer: request.referrer, referrerPolicy: request.referrerPolicy, }); } /** * Fetches the preflight data for a URL. Uses the local cache if available, * otherwise fetches the data from the server. * @param {Request} request The request to fetch preflight data for. * @returns {Promise<CorsPreflightData>} The preflight data for the URL. */ async #preflightFetch(request) { if (!this.#baseUrl) { throw new Error("Cannot fetch preflight data without a base URL."); } // first check the cache let preflightData = this.#corsPreflightData.get(request.url); if (preflightData) { return preflightData; } // create the preflight request const preflightRequest = this.#createPreflightRequest(request); const preflightResponse = await this.#internalFetch(preflightRequest); // if the preflight response is successful, then we can make the actual request if (!preflightResponse.ok) { throw createCorsPreflightError(preflightRequest.url, this.#baseUrl.origin, "It does not have HTTP ok status."); } assertCorsResponse(preflightResponse, this.#baseUrl.origin, true); // create the preflight data preflightData = new CorsPreflightData(preflightResponse.headers); // cache the preflight data this.#corsPreflightData.set(preflightRequest.url, preflightData); return preflightData; } /** * Processes a redirect response according to the fetch spec * @param {Response} response The response to check for a redirect * @param {Request} request The original request * @param {URL[]} urlList The list of URLs already visited in this redirect chain * @param {any} requestBody The body of the original request * @returns {Promise<Response>} The final response after any redirects * @see https://fetch.spec.whatwg.org/#http-redirect-fetch */ async #processRedirect(response, request, urlList = [], requestBody = null) { // Add current URL to list urlList.push(new URL(request.url)); const isRedirect = isRedirectStatus(response.status); // Process response based on redirect status and mode if (!isRedirect) { // Not a redirect - set redirected flag if we've had previous redirects if (urlList.length > 1) { Object.defineProperty(response, "redirected", { value: true }); } return response; } // Handle based on redirect mode switch (request.redirect) { case "manual": // Return an opaque redirect response return createOpaqueRedirectResponse(this.#Response, request.url); case "error": // Just throw an error throw new TypeError(`Redirect at ${request.url} was blocked due to redirect mode being 'error'`); case "follow": default: // Continue with redirect handling break; } // Get and validate the redirect location const location = response.headers.get("Location"); if (!location) { throw new TypeError(`Redirect at ${request.url} has no Location header`); } // Construct the new URL let redirectUrl; try { redirectUrl = new URL(location, request.url); } catch { throw new TypeError(`Invalid redirect URL: ${location}`); } // Check for redirect loops if (urlList.some(url => url.href === redirectUrl.href)) { throw new TypeError(`Redirect loop detected for ${redirectUrl.href}`); } // Check redirect limit if (urlList.length >= 20) { throw new TypeError("Too many redirects (maximum is 20)"); } let method = request.method; const headers = new Headers(request.headers); // If this is a redirect that changes the method, adjust accordingly if (redirectNeedsAdjustment(request, response.status)) { method = "GET"; for (const header of headers.keys()) { // Remove headers that should not be sent with GET requests if (isRequestBodyHeader(header)) { headers.delete(header); } } } // Create a new request for the redirect const init = { method, headers, mode: request.mode, credentials: request.credentials, redirect: request.redirect, referrer: request.referrer, referrerPolicy: request.referrerPolicy, signal: request.signal, keepalive: request.keepalive, body: null, }; // Determine if we should preserve the body (307/308 redirects) const preserveBodyStatus = isBodyPreservingRedirectStatus(response.status); if (preserveBodyStatus && requestBody !== null && !isBodylessMethod(method)) { init.body = requestBody; } // Check if this is a cross-origin redirect const currentOrigin = new URL(request.url).origin; const isCrossOrigin = this.#baseUrl && redirectUrl.origin !== currentOrigin; if (isCrossOrigin) { // For non-same-origin redirect, remove authorization header init.headers.delete("authorization"); // For cross-origin redirect with credentials, check for CORS issues if (request.credentials === "include" && !isTaintedResponse(response)) { throw createCorsError(redirectUrl.href, this.#baseUrl?.origin || "", "Cross-origin redirect with credentials is not allowed"); } } // Make the new request const redirectRequest = new this.#Request(redirectUrl.href, init); const redirectResponse = await this.#internalFetch(redirectRequest, init.body); // Process further redirects recursively return this.#processRedirect(redirectResponse, redirectRequest, urlList, init.body); } // #region: Testing Helpers /** * Determines if a request was made. * @param {string|RequestPattern} request The request to match. * @returns {boolean} `true` if the request was made, `false` otherwise. */ called(request) { const requestPattern = typeof request === "string" ? { method: "GET", url: request } : request; return this.#servers.some(server => server.called(requestPattern)); } /** * Determines if all routes were called. * @returns {boolean} `true` if all routes were called, `false` otherwise. */ allRoutesCalled() { return this.#servers.every(server => server.allRoutesCalled()); } /** * Gets the uncalled routes. * @return {string[]} The uncalled routes. */ get uncalledRoutes() { return this.#servers.flatMap(server => server.uncalledRoutes); } /** * Asserts that all routes were called. * @returns {void} * @throws {Error} When not all routes were called. */ assertAllRoutesCalled() { const uncalledRoutes = this.uncalledRoutes; if (uncalledRoutes.length > 0) { throw new Error(`Not all routes were called. Uncalled routes:\n${uncalledRoutes.join("\n")}`); } } // #endregion: Testing Helpers /** * Mocks the fetch property on a given object. * @param {Record<string, any>} object The object to mock fetch on. * @param {string} [property="fetch"] The property name to mock. * @returns {void} * @throws {TypeError} If the object is not an object. */ mockObject(object, property = "fetch") { if (!object || typeof object !== "object") { throw new TypeError("Object must be an object."); } let originalFetchers = this.#originalFetchers.get(object); if (!originalFetchers) { originalFetchers = new Map(); this.#originalFetchers.set(object, originalFetchers); } // store original fetch if not already stored if (!originalFetchers.has(property)) { originalFetchers.set(property, object[property]); } // replace with mocked fetch object[property] = this.fetch; } /** * Unmocks the fetch property on a given object. * @param {Record<string, any>} object The object to unmock fetch on. * @returns {void} * @throws {TypeError} If the object is not an object. */ unmockObject(object) { if (!object || typeof object !== "object") { throw new TypeError("Object must be an object."); } const originalFetchers = this.#originalFetchers.get(object); if (originalFetchers) { // restore all original fetchers for (const [property, originalFetch] of originalFetchers) { object[property] = originalFetch; } this.#originalFetchers.delete(object); } } /** * Mocks the global fetch function. * @returns {void} */ mockGlobal() { this.mockObject(globalThis); } /** * Unmocks the global fetch function. * @returns {void} */ unmockGlobal() { this.unmockObject(globalThis); } /** * Clears the CORS preflight cache. * @returns {void} */ clearPreflightCache() { this.#corsPreflightData.clear(); } /** * Clears all data from the fetch mocker. This include the CORS preflight * cache as well as the routes on the servers. The servers themselves * remain intact. * @returns {void} */ clearAll() { this.#servers.forEach(server => server.clear()); this.#credentials.forEach(credentials => credentials.clear()); this.clearPreflightCache(); } }