UNPKG

cumulocity-cypress

Version:
248 lines (247 loc) 10.4 kB
import _ from "lodash"; import * as setCookieParser from "set-cookie-parser"; import * as libCookie from "cookie"; import { createProxyMiddleware, responseInterceptor, } from "http-proxy-middleware"; import { normalizeAuthHeaders } from "../auth"; import { C8yDefaultPact } from "../c8ypact"; export function createMiddleware(c8yctrl, options = {}) { const ignoredPaths = options.ignoredPaths || ["/c8yctrl"]; const middlewareOptions = { target: options.baseUrl || c8yctrl.baseUrl, changeOrigin: true, cookieDomainRewrite: "", selfHandleResponse: true, logger: options.logger || c8yctrl.logger, followRedirects: false, ...(options?.proxyOptions || {}), on: { // @ts-expect-error - Type mismatch in http-proxy-middleware types proxyReq: createRequestHandler(c8yctrl, options.auth), // @ts-expect-error - Type mismatch in http-proxy-middleware types proxyRes: responseInterceptor(createResponseInterceptor(c8yctrl, options.errorHandler)), }, }; return wrapPathIgnoreHandler(createProxyMiddleware(middlewareOptions), ignoredPaths); } /** * Wraps a RequestHandler to ignore certain paths. For paths matching items in the * `ignoredPaths` parameter, the handler will call `next()` immediately and not call * the wrapped handler. For matching `startsWith` is used. * @param handler The RequestHandler to wrap * @param ignoredPaths The paths to ignore using exact match * @returns The RequestHandler wrapper */ export function wrapPathIgnoreHandler(handler, ignoredPaths) { return (req, res, next) => { if (ignoredPaths.filter((p) => req.path.startsWith(p)).length > 0) { next(); } else { handler(req, res, next); // disabled calling the handler in Promise. // new Promise((resolve, reject) => { // handler(req, res, (err) => (err ? reject(err) : resolve(null))); // }) // .then(() => { // next(); // }) // .catch(() => { // next(); // }); } }; } export function createResponseInterceptor(c8yctrl, errorHandler) { return async (responseBuffer, proxyRes, req, res) => { let resBody = responseBuffer.toString("utf8"); if (res.statusCode >= 400 && errorHandler != null) { res.body = resBody; errorHandler(req, res, () => { }); } const c8yctrlId = req.c8yctrlId; addC8yCtrlHeader(res, "x-c8yctrl-mode", c8yctrl.recordingMode); const onProxyResponse = c8yctrl.options.on.proxyResponse; if (_.isFunction(onProxyResponse)) { const pactResponse = toC8yPactResponse(res, resBody); const shouldContinue = onProxyResponse(c8yctrl, req, pactResponse); // pass objects from response returned by onProxyResponse to res res.statusCode = pactResponse.status != null ? pactResponse.status : res.statusCode; for (const [key, value] of Object.entries(pactResponse.headers || {})) { res.setHeader(key, value); } if (pactResponse.body) { resBody = _.isString(pactResponse.body) ? pactResponse.body : c8yctrl.stringify(pactResponse.body); } if (!shouldContinue) { addC8yCtrlHeader(res, "x-c8yctrl-type", "skip"); return resBody; } } // Rewrite the Location header if present const locationHeader = res.getHeader("location"); if (locationHeader) { const newLocation = locationHeader .toString() .replace(/^https?:\/\/[^/]+/, `${req.protocol}://${req.get("host")}`); res.setHeader("location", newLocation); } if (c8yctrl.isRecordingEnabled() === false) return responseBuffer; // Express 5 compatibility: handle undefined req.body let reqBody = req.rawBody || req.body; if (reqBody === undefined) { reqBody = {}; } try { if (_.isString(reqBody)) { reqBody = JSON.parse(reqBody); } } catch { // no-op : use body as string } try { resBody = JSON.parse(resBody); } catch { // no-op : use body as string } const setCookieHeader = res.getHeader("set-cookie"); const cookies = setCookieParser.parse(setCookieHeader, { decodeValues: false, }); if (cookies.length) { res.setHeader("set-cookie", cookies.map(function (cookie) { delete cookie.domain; delete cookie.secure; return libCookie.serialize(cookie.name, cookie.value, cookie); })); } // we might receive responses for requests triggered for a previous pact // ensure recording to the correct pact and log some warning. let pact = c8yctrl.currentPact; if (c8yctrlId && !_.isEqual(c8yctrl.currentPact?.id, c8yctrlId)) { const p = c8yctrl.adapter?.loadPact(c8yctrlId); pact = p ? C8yDefaultPact.from(p) : undefined; c8yctrl.logger.warn(`Request for ${c8yctrlId} received for pact with different id.`); } if (pact == null) return responseBuffer; if (_.isFunction(c8yctrl.options.on.savePact)) { const shouldSave = c8yctrl.options.on.savePact(c8yctrl, pact); if (!shouldSave) { addC8yCtrlHeader(res, "x-c8yctrl-type", "skipped"); return responseBuffer; } } let didSave = false; if (pact != null) { // Calculate request duration const startTime = req.c8yctrlStartTime; const duration = startTime ? Date.now() - startTime : -1; didSave = await c8yctrl.savePact(toCypressResponse(req, res, { resBody, reqBody, duration }), pact); } addC8yCtrlHeader(res, "x-c8yctrl-type", didSave ? "saved" : "discard"); addC8yCtrlHeader(res, "x-c8yctrl-count", `${pact.records.length}`); return responseBuffer; }; } export function createRequestHandler(c8yctrl, auth) { return (proxyReq, req, res) => { // 1) Set headers FIRST (before any proxyReq.write) addC8yCtrlHeader(res, "x-c8yctrl-mode", c8yctrl.recordingMode); if ((c8yctrl.isRecordingEnabled() === true || c8yctrl.mode === "forward") && auth && !proxyReq.getHeader("authorization") && !proxyReq.getHeader("Authorization")) { const { token, xsrfToken, user, password } = auth; if (token) { proxyReq.setHeader("Authorization", `Bearer ${token}`); } else if (user && password) { proxyReq.setHeader("Authorization", `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`); if (xsrfToken) { proxyReq.setHeader("X-XSRF-TOKEN", String(xsrfToken).trim()); } } } if (c8yctrl.currentPact?.id) { req.c8yctrlId = c8yctrl.currentPact?.id; } // Record request start time for duration tracking req.c8yctrlStartTime = Date.now(); // 2) Then write the body (but only one source) const rawBody = req.rawBody; if (typeof rawBody === "string") { // raw body provided by previous middleware – set correct headers, then write proxyReq.removeHeader("content-length"); proxyReq.removeHeader("Content-Length"); proxyReq.setHeader("transfer-encoding", "chunked"); proxyReq.write(rawBody); } else if (req.body !== undefined && req.body !== null) { // body already parsed – stringify and write once const bodyString = JSON.stringify(req.body); proxyReq.removeHeader("transfer-encoding"); proxyReq.removeHeader("Transfer-Encoding"); proxyReq.setHeader("content-length", Buffer.byteLength(bodyString)); proxyReq.write(bodyString); } // Optional short-circuit response if (_.isFunction(c8yctrl.options.on.proxyRequest)) { const r = c8yctrl.options.on.proxyRequest(c8yctrl, proxyReq, req); if (r) { const responseBody = _.isString(r?.body) ? r?.body : c8yctrl.stringify(r?.body); res.setHeader("content-length", Buffer.byteLength(responseBody)); r.headers = _.defaults(r?.headers, _.pick(r?.headers, ["content-type", "set-cookie"])); res.writeHead(r?.status || 200, r?.headers); res.end(responseBody); return; // ensure we don’t touch headers after response is sent } } }; } export function addC8yCtrlHeader(response, ctrlHeader, value) { if (response != null && "hasHeader" in response && "setHeader" in response) { if (!response.hasHeader(ctrlHeader)) { response.setHeader(ctrlHeader, value); } } else if (response && "headers" in response) { if (!_.get(response.headers, ctrlHeader)) { response.headers = response?.headers || {}; response.headers[ctrlHeader] = value; } } } export function toC8yPactResponse(res, body) { return { headers: res?.getHeaders(), status: res?.statusCode, statusText: res?.statusMessage, body, }; } export function toCypressResponse(req, res, options) { const statusCode = res?.statusCode || 200; const result = { body: options?.resBody, url: req?.url, headers: res?.getHeaders(), status: res?.statusCode, duration: options?.duration ?? -1, requestHeaders: req?.headers, requestBody: options?.reqBody, statusText: res?.statusMessage, method: req?.method || "GET", isOkStatusCode: statusCode >= 200 && statusCode < 300, allRequestResponses: [], }; result.headers = normalizeAuthHeaders(result.headers); return result; }