cumulocity-cypress
Version:
Cypress commands for Cumulocity IoT
248 lines (247 loc) • 10.4 kB
JavaScript
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;
}