UNPKG

cumulocity-cypress

Version:
1,358 lines (1,342 loc) 211 kB
'use strict'; var path = require('path'); var fs = require('fs'); var debug = require('debug'); var odiffBin = require('odiff-bin'); var WebSocket = require('ws'); var chokidar = require('chokidar'); var fetch = require('cross-fetch'); var glob = require('glob'); var _$3 = require('lodash'); var util = require('util'); var express = require('express'); var getRawBody = require('raw-body'); var cookieParser = require('cookie-parser'); var winston = require('winston'); var morgan = require('morgan'); require('date-fns'); var setCookieParser = require('set-cookie-parser'); var libCookie = require('cookie'); var client = require('@c8y/client'); var httpProxyMiddleware = require('http-proxy-middleware'); var semver = require('semver'); var swaggerUi = require('swagger-ui-express'); var yaml = require('yaml'); var Ajv = require('ajv'); var addFormats = require('ajv-formats'); require('ajv/lib/refs/json-schema-draft-06.json'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path); var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs); var glob__namespace = /*#__PURE__*/_interopNamespaceDefault(glob); var ___namespace = /*#__PURE__*/_interopNamespaceDefault(_$3); var setCookieParser__namespace = /*#__PURE__*/_interopNamespaceDefault(setCookieParser); var libCookie__namespace = /*#__PURE__*/_interopNamespaceDefault(libCookie); var semver__namespace = /*#__PURE__*/_interopNamespaceDefault(semver); var yaml__namespace = /*#__PURE__*/_interopNamespaceDefault(yaml); /// <reference types="cypress" /> // workaround for lodash import in Cypress nodejs typescript runtime and browser const _$2 = _$3 || ___namespace; const C8yPactModeValues = [ "record", "recording", "apply", "forward", "disabled", "mock", ]; const C8yPactRecordingModeValues = [ "refresh", "append", "new", "replace", ]; /** * Creates an C8yPactID for a given string or array of strings. * @param value The string or array of strings to convert to a pact id. * @returns The pact id. */ function pactId(value) { let result = ""; const suiteSeparator = "__"; const normalize = (value) => value .split(suiteSeparator) .map((v) => _$2.words(_$2.deburr(v), /[a-zA-Z0-9_-]+/g).join("_")) .join(suiteSeparator); if (value != null && _$2.isArray(value)) { result = value.map((v) => normalize(v)).join(suiteSeparator); } else if (value != null && _$2.isString(value)) { result = normalize(value); } if (result == null || _$2.isEmpty(result)) { return !value ? value : undefined; } return result; } /** * Validate the given pact mode. Throws an error if the mode is not supported * or undefined. * @param mode The pact mode to validate. */ function validatePactMode(mode) { if (mode != null) { const values = Object.values(C8yPactModeValues); if (!_$2.isString(mode) || _$2.isEmpty(mode) || !values.includes(mode.toLowerCase())) { const error = new Error(`Unsupported pact mode: "${mode}". Supported values are: ${values.join(", ")} or undefined.`); error.name = "C8yPactError"; throw error; } } } /** * Checks if the given object is a C8yPact. This also includes checking * all records to be valid C8yPactRecord instances. * * @param obj The object to check. * @returns True if the object is a C8yPact, false otherwise. */ function isPact(obj) { return (_$2.isObjectLike(obj) && "info" in obj && _$2.isObjectLike(_$2.get(obj, "info")) && "records" in obj && _$2.isArray(_$2.get(obj, "records")) && _$2.every(_$2.get(obj, "records"), isPactRecord) && _$2.isFunction(_$2.get(obj, "nextRecord")) && _$2.isFunction(_$2.get(obj, "nextRecordMatchingRequest")) && _$2.isFunction(_$2.get(obj, "appendRecord")) && _$2.isFunction(_$2.get(obj, "replaceRecord"))); } /** * Checks if the given object is a C8yPactRecord. * * @param obj The object to check. * @returns True if the object is a C8yPactRecord, false otherwise. */ function isPactRecord(obj) { return (_$2.isObjectLike(obj) && "request" in obj && _$2.isObjectLike(_$2.get(obj, "request")) && "response" in obj && _$2.isObjectLike(_$2.get(obj, "response")) && _$2.isFunction(_$2.get(obj, "toCypressResponse"))); } /** * Checks if the given object is a Cypress.Response. * * @param obj The object to check. * @returns True if the object is a Cypress.Response, false otherwise. */ function isCypressResponse(obj) { return (_$2.isObjectLike(obj) && "body" in obj && "status" in obj && "headers" in obj && "requestHeaders" in obj && "duration" in obj && "url" in obj && "isOkStatusCode" in obj && // not a window.Response or Client.FetchResponse !("ok" in obj || "arrayBuffer" in obj)); } function isDefined(value) { return !_$2.isUndefined(value); } /** * Converts a Cypress.Response to a C8yPactRequest. */ function toPactRequest(response) { if (!response) return response; const result = _$2.pickBy(_$2.mapKeys(_$2.pick(response, ["url", "method", "requestHeaders", "requestBody"]), (v, k) => { if (_$2.isEqual(k, "requestHeaders")) return "headers"; if (_$2.isEqual(k, "requestBody")) return "body"; return k; }), isDefined); if (_$2.isEmpty(result)) return undefined; return result; } /** * Converts a Cypress.Response to a C8yPactResponse. */ function toPactResponse(response) { if (!response) return response; const result = _$2.pickBy(_$2.pick(response, [ "status", "statusText", "body", "headers", "duration", "isOkStatusCode", "allRequestResponses", "$body", ]), isDefined); if (_$2.isEmpty(result)) return undefined; return result; } /** * Returns the value of the environment variable with the given name. The function * tries to find the value in the global `process.env` or `Cypress.env()`. If `env` * is provided, the function uses the given object as environment. * * The function tries to find the value in the following order: * - `name` * - `camelCase(name)` * - `CYPRESS_name` * - `name.replace(/^C8Y_/i, "")` * - `CYPRESS_camelCase(name)` * - `CYPRESS_camelCase(name.replace(/^C8Y_/i, ""))` * * @param name The name of the environment variable. * @param env The environment object to use. Default is `process.env` or `Cypress.env()` * * @returns The value of the environment variable or `undefined` if not found. */ function getEnvVar(name, env) { if (!name) return undefined; const e = env || (typeof window !== "undefined" && window.Cypress ? Cypress.env() : process.env); function getFromEnv(key) { return e[key]; } function getForName(name) { return getFromEnv(name) || getFromEnv(`CYPRESS_${name}`); } const plainName = name.replace(/^C8Y_/i, ""); const camelCasedName = _$2.camelCase(name).replace(/^c8Y/i, "c8y"); const camelCasedPlainName = _$2.camelCase(plainName); return (getForName(name) || getForName(camelCasedName) || getForName(plainName) || getForName(camelCasedPlainName)); } function safeStringify(obj, indent = 2) { let cache = []; const retVal = JSON.stringify(obj, (key, value) => typeof value === "object" && value !== null ? cache.includes(value) ? undefined : cache.push(value) && value : value, indent); cache = []; return retVal; } /** * Gets the case-sensitive path for a given case-insensitive path. The path is * assumed to be a dot-separated string. If the path is an array, it is assumed * to be a list of keys. * * The function will go over all keys and return the actual case-sensitive path * up to the first mismatch. * * @param obj The object to query * @param path The case-insensitive path to find * @returns The actual case-sensitive path if found, undefined otherwise */ function toSensitiveObjectKeyPath(obj, path) { if (!obj) return undefined; const keys = _$3.isArray(path) ? path : path.split(/[.[\]]/g); let current = obj; const actualPath = []; for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (_$3.isEmpty(key)) continue; if (current === null || current === undefined) { return undefined; } if (_$3.isArray(current)) { const index = parseInt(key); if (!isNaN(index)) { if (index >= 0 && index < current.length) { current = current[index]; actualPath.push(key); continue; } } actualPath.push(...keys.slice(i)); return actualPath.join("."); } // Handle object case with case-insensitive matching if (_$3.isObjectLike(current)) { const matchingKey = Object.keys(current).find((k) => k.toLowerCase() === key.toLowerCase()); if (matchingKey !== undefined) { current = current[matchingKey]; actualPath.push(matchingKey); } else { actualPath.push(...keys.slice(i)); break; } } else { actualPath.push(...keys.slice(i)); break; } } return actualPath.join("."); } /** * Gets the value of a case-insensitive key path from an object. The path is * assumed to be a dot-separated string. If the path is an array, it is assumed * to be a list of keys. * * @example * geti(obj, "obj.key.token") * geti(obj, ["obj", "key", "token"]) * geti(obj, "obj.key[0].token") * geti(obj, "obj.key.0.token") * * @param obj The object to query * @param keyPath The case-insensitive key path to find * @returns The value of the key path if found, undefined otherwise */ function get_i(obj, keyPath) { if (obj == null || keyPath == null) return undefined; const sensitivePath = toSensitiveObjectKeyPath(obj, keyPath); if (sensitivePath == null) return undefined; return _$3.get(obj, sensitivePath); } /** * Converts a string value to a boolean. Supported values are "true", "false", "1", and "0". * @param input The input string to convert to a boolean * @param defaultValue The default value to return if the input is not a valid boolean string * @returns The boolean value of the input string or the default value if the input is not a valid boolean string */ function to_boolean(input, defaultValue) { if (input == null || !_$3.isString(input)) return defaultValue; const booleanString = input.toString().toLowerCase(); if (booleanString == "true" || booleanString === "1") return true; if (booleanString == "false" || booleanString === "0") return false; return defaultValue; } const _$1 = _$3 || ___namespace; const log$1 = debug("c8y:fileadapter"); /** * Default implementation of C8yPactFileAdapter which loads and saves pact objects from/to * json files using C8yPact objects. */ class C8yPactDefaultFileAdapter { constructor(folder) { this.folder = path__namespace.isAbsolute(folder) ? folder : this.toAbsolutePath(folder); } description() { return `C8yPactDefaultFileAdapter: ${this.folder}`; } getFolder() { return this.folder; } loadPacts() { const jsonFiles = this.loadPactObjects(); log$1(`loadPacts() - ${jsonFiles.length} pact files from ${this.folder}`); return jsonFiles.reduce((acc, obj) => { if (!obj?.info?.id) return acc; acc[obj.info.id] = obj; return acc; }, {}); } loadPact(id) { log$1(`loadPact() - ${id}`); const pId = pactId(id); if (pId == null) { log$1(`loadPact() - invalid pact id ${id} -> ${pId}`); return null; } if (!this.folder || !fs__namespace.existsSync(this.folder)) { log$1(`loadPact() - folder ${this.folder} does not exist`); return null; } const file = path__namespace.join(this.folder, `${pId}.json`); if (fs__namespace.existsSync(file)) { const pact = fs__namespace.readFileSync(file, "utf-8"); log$1(`loadPact() - ${file} loaded`); const json = JSON.parse(pact); log$1(`loadPact() - parsed as json`); return json || null; } else { log$1(`loadPact() - ${file} does not exist`); } return null; } pactExists(id) { return fs__namespace.existsSync(path__namespace.join(this.folder, `${pactId(id)}.json`)); } savePact(pact) { this.createFolderRecursive(this.folder); const pId = pactId(pact.id); if (pId == null) { log$1(`savePact() - invalid pact id ${pact.id} -> ${pId}`); return; } const file = path__namespace.join(this.folder, `${pId}.json`); log$1(`savePact() - write ${file} (${pact.records?.length || 0} records)`); try { fs__namespace.writeFileSync(file, safeStringify({ id: pact.id, info: pact.info, records: pact.records, }, 2), "utf-8"); } catch (error) { console.error(`Failed to save pact.`, error); } } deletePact(id) { const pId = pactId(id); if (pId == null) { log$1(`deletePact() - invalid pact id ${id} -> ${pId}`); return; } const filePath = path__namespace.join(this.folder, `${pId}.json`); if (fs__namespace.existsSync(filePath)) { fs__namespace.unlinkSync(filePath); log$1(`deletePact() - deleted ${filePath}`); } else { log$1(`deletePact() - ${filePath} does not exist`); } } readJsonFiles() { log$1(`readJsonFiles() - ${this.folder}`); if (!this.folder || !fs__namespace.existsSync(this.folder)) { log$1(`readJsonFiles() - ${this.folder} does not exist`); return []; } const jsonFiles = glob__namespace.sync(path__namespace.join(this.folder, "*.json")); log$1(`readJsonFiles() - reading ${jsonFiles.length} json files from ${this.folder}`); const pacts = jsonFiles.map((file) => { return fs__namespace.readFileSync(file, "utf-8"); }); return pacts; } deleteJsonFiles() { if (!this.folder || !fs__namespace.existsSync(this.folder)) { log$1(`deleteJsonFiles() - ${this.folder} does not exist`); return; } const jsonFiles = glob__namespace.sync(path__namespace.join(this.folder, "*.json")); log$1(`deleteJsonFiles() - deleting ${jsonFiles.length} json files from ${this.folder}`); jsonFiles.forEach((file) => { fs__namespace.unlinkSync(file); }); } loadPactObjects() { const pacts = this.readJsonFiles(); return pacts.map((pact) => JSON.parse(pact)); } createFolderRecursive(f) { log$1(`createFolderRecursive() - ${f}`); if (!f || !_$1.isString(f)) return undefined; const absolutePath = !path__namespace.isAbsolute(f) ? this.toAbsolutePath(f) : f; if (f !== absolutePath) { log$1(`createFolderRecursive() - resolved ${f} to ${absolutePath}`); } if (fs__namespace.existsSync(f)) return undefined; const result = fs__namespace.mkdirSync(absolutePath, { recursive: true }); if (result) { log$1(`createFolderRecursive() - created ${absolutePath}`); } return result; } toAbsolutePath(f) { return path__namespace.isAbsolute(f) ? f : path__namespace.resolve(process.cwd(), f); } isNodeError(error, type) { return error instanceof type; } } function isURL(obj) { return obj instanceof URL; } function removeBaseUrlFromString(url, baseUrl) { if (!url || !baseUrl) { return url; } let normalizedBaseUrl = _$3.clone(baseUrl); while (normalizedBaseUrl.endsWith("/")) { normalizedBaseUrl = normalizedBaseUrl.slice(0, -1); } let result = url.replace(normalizedBaseUrl, ""); if (_$3.isEmpty(result)) { result = "/"; } return result; } function removeBaseUrlFromRequestUrl(record, baseUrl) { if (!record?.request?.url || !baseUrl || !_$3.isString(baseUrl)) { return; } record.request.url = removeBaseUrlFromString(record.request.url, baseUrl); } /** * Checks if the given URL is an absolute URL. * @param url The URL to check. * @returns True if the URL is an absolute URL, false otherwise. */ function isAbsoluteURL(url) { if (!url || !_$3.isString(url) || _$3.isEmpty(url)) return false; return /^https?:\/\//i.test(url); } /** * Validates the base URL and throws an error if the base URL is not an absolute URL. This * is required as commands expect an absolute URL as baseUrl. Will not fail for undefined values. * `Cypress.config().baseUrl` is validated by Cypress itself and throw an error. * * @param baseUrl The url to validate. */ function validateBaseUrl(baseUrl) { if (baseUrl != null && !isAbsoluteURL(baseUrl)) { const error = new Error(`Invalid value for C8Y_BASEURL. C8Y_BASEURL must be an absolute URL or undefined.`); error.name = "C8yPactError"; throw error; } } /// <reference types="cypress" /> const C8yPactAuthObjectKeys = [ "userAlias", "user", "type", ]; /** * Checks if the given object is a C8yAuthOptions. * * @param obj The object to check. * @param options Options to check for additional properties. * @returns True if the object is a C8yAuthOptions, false otherwise. */ function isAuthOptions(obj) { return _$3.isObjectLike(obj) && "user" in obj && "password" in obj; } function toPactAuthObject(obj) { return _$3.pick(obj, C8yPactAuthObjectKeys); } function isPactAuthObject(obj) { return (_$3.isObjectLike(obj) && "user" in obj && ("userAlias" in obj || "type" in obj) && Object.keys(obj).every((key) => C8yPactAuthObjectKeys.includes(key))); } function normalizeAuthHeaders(headers) { // required to fix inconsistencies between c8yclient and interceptions // using lowercase and uppercase. fix here. const xsrfTokenHeader = Object.keys(headers || {}).find((key) => key.toLowerCase() === "x-xsrf-token"); const authorizationHeader = Object.keys(headers || {}).find((key) => key.toLowerCase() === "authorization"); if (xsrfTokenHeader && xsrfTokenHeader !== "X-XSRF-TOKEN") { headers["X-XSRF-TOKEN"] = headers[xsrfTokenHeader]; delete headers[xsrfTokenHeader]; } if (authorizationHeader && authorizationHeader !== "Authorization") { headers["Authorization"] = headers[authorizationHeader]; delete headers[authorizationHeader]; } return headers; } /** * Default implementation of C8yPactRecord. Use C8yDefaultPactRecord.from to create * a C8yPactRecord from a Cypress.Response object or an C8yPactRecord object. */ class C8yDefaultPactRecord { constructor(request, response, options, auth, createdObject, modifiedResponse) { this.request = request; this.response = response; if (options) this.options = options; if (auth) this.auth = auth; if (createdObject) this.createdObject = createdObject; if (modifiedResponse) this.modifiedResponse = modifiedResponse; if (request?.method?.toLowerCase() === "post") { const newId = response.body?.id; if (newId) { this.createdObject = newId; } else { const location = get_i(response, "headers.location"); if (isAbsoluteURL(location)) { try { const url = new URL(location); const pathSegments = url.pathname.split("/").filter(Boolean); this.createdObject = pathSegments.pop(); } catch { // do nothing } } } } } /** * Creates a C8yPactRecord from a Cypress.Response or an C8yPactRecord object. * @param obj The Cypress.Response<any> or C8yPactRecord object. * @param client The C8yClient for options and auth information. */ static from(obj, auth, client) { // if (obj == null) return obj; if ("request" in obj && "response" in obj) { return new C8yDefaultPactRecord(_$3.get(obj, "request"), _$3.get(obj, "response"), _$3.get(obj, "options") || {}, _$3.get(obj, "auth"), _$3.get(obj, "createdObject"), _$3.get(obj, "modifiedResponse")); } const r = _$3.cloneDeep(obj); return new C8yDefaultPactRecord(toPactRequest(r) || {}, toPactResponse(r) || {}, client?._options, isAuthOptions(auth) || isPactAuthObject(auth) ? toPactAuthObject(auth) : client?._auth ? toPactAuthObject(client?._auth) : undefined); } /** * Returns the date of the response. */ date() { const date = _$3.get(this.response, "headers.date"); if ((date && _$3.isString(date)) || _$3.isNumber(date) || _$3.isDate(date)) { const result = new Date(date); if (!isNaN(result.getTime())) { return result; } } return null; } /** * Converts the C8yPactRecord to a Cypress.Response object. */ toCypressResponse() { const result = _$3.cloneDeep(this.response); _$3.extend(result, { ...(result.status && { isOkStatusCode: result.status > 199 && result.status < 300, }), ...(this.request.headers && { requestHeaders: Object.fromEntries(Object.entries(this.request.headers || [])), }), ...(this.request.url && { url: this.request.url }), ...(result.allRequestResponses && { allRequestResponses: [] }), ...(this.request.body && { requestBody: this.request.body }), method: this.request.method || this.response.method || "GET", }); return result; } hasRequestHeader(key) { return Object.keys(this.request.headers ?? {}) .map((k) => k.toLowerCase()) .includes(key?.toLowerCase()); } authType() { const type = this.auth?.type; if (type === "BasicAuth" || type === "CookieAuth") { return type; } if (this.hasRequestHeader("x-xsrf-token")) { return "CookieAuth"; } if (this.hasRequestHeader("authorization")) { return "BasicAuth"; } return undefined; } } function createPactRecord(response, client, options = {}) { let auth = undefined; const envUser = options.loggedInUser; const envAlias = options.loggedInUserAlias; const envType = options.authType; const envAuth = { ...(envUser && { user: envUser }), ...(envAlias && { userAlias: envAlias }), ...(envAlias && { type: envType ?? "CookieAuth" }), }; if (client?._auth) { // do not pick the password. passwords must not be stored in the pact. auth = _$3.defaultsDeep(client._auth, envAuth); if (client._auth.constructor != null) { if (!auth) { auth = { type: client._auth.constructor.name }; } else { auth.type = client._auth.constructor.name; } } } if (!auth && (envUser || envAlias)) { auth = envAuth; } // only store properties that need to be exposed. do not store password. auth = auth ? toPactAuthObject(auth) : auth; return C8yDefaultPactRecord.from(response, auth, client); } /** * Default implementation of C8yPact. Use C8yDefaultPact.from to create a C8yPact from * a Cypress.Response object, a serialized pact as string or an object implementing the * C8yPact interface. Note, objects implementing the C8yPact interface may not provide * all required functions and properties. */ class C8yDefaultPact { constructor(records, info, id) { this.recordIndex = 0; this.iteratorIndex = 0; this.requestIndexMap = {}; this.records = records; this.info = info; this.id = id; } /** * Creates a C8yPact from a Cypress.Response object, a serialized pact as string * or an object containing the pact records and info object. Throws an error if * the input can not be converted to a C8yPact. * @param obj The Cypress.Response, string or object to create a pact from. * @param info The C8yPactInfo object containing additional information for the pact. * @param client The optional C8yClient for options and auth information. */ static from(...args) { const obj = args[0]; if (!obj) { throw new Error("Can not create pact from null or undefined."); } if (isCypressResponse(obj)) { const info = args && args.length > 1 ? args[1] : undefined; if (!info) { throw new Error(`Can not create pact from response without C8yPactInfo.`); } const client = args[2]; const r = _$3.cloneDeep(obj); const pactRecord = new C8yDefaultPactRecord(toPactRequest(r) || {}, toPactResponse(r) || {}, client?._options, client?._auth ? toPactAuthObject(client?._auth) : undefined); removeBaseUrlFromRequestUrl(pactRecord, info.baseUrl); return new C8yDefaultPact([pactRecord], info, info.id); } else { let pact; if (_$3.isString(obj)) { pact = JSON.parse(obj); } else if (_$3.isObjectLike(obj)) { pact = obj; } else { throw new Error(`Can not create pact from ${typeof obj}.`); } // required to map the record object to a C8yPactRecord here as this can // not be done in the plugin pact.records = pact.records?.map((record) => { return new C8yDefaultPactRecord(record.request, record.response, record.options || {}, record.auth, record.createdObject); }); const result = new C8yDefaultPact(pact.records, pact.info, pact.id); if (!isPact(result)) { throw new Error(`Invalid pact object. Can not create pact from ${typeof obj}.`); } return result; } } clearRecords() { this.records = []; this.requestIndexMap = {}; this.recordIndex = 0; this.iteratorIndex = 0; } appendRecord(record, skipIfExists = false) { if (skipIfExists) { if (!record.request.url) return false; const matches = this.getRecordsMatchingRequest(record.request); if (matches && !_$3.isEmpty(matches)) return false; } this.records.push(record); return true; } replaceRecord(record) { const key = this.indexMapKey(record.request, this.info.baseUrl); if (!key) return false; const matches = this.getRecordsMatchingRequest(record.request); if (!matches) { this.appendRecord(record); } else { const currentIndex = Math.max(0, this.getIndexForKey(key)); const match = matches[currentIndex]; if (!match) { this.appendRecord(record); } else { const index = this.records.indexOf(match); if (index >= 0) { this.records[index] = record; this.setIndexForKey(key, currentIndex + 1); } else { return false; } } } return true; } /** * Returns the next pact record or null if no more records are available. */ nextRecord() { if (this.recordIndex >= this.records.length) { return null; } return this.records[this.recordIndex++]; } nextRecordMatchingRequest(request, baseUrl) { if (!request?.url) return null; const key = this.indexMapKey(request, baseUrl); if (!key) return null; const matches = this.getRecordsMatchingRequest(request); if (!matches) return null; const currentIndex = Math.max(0, this.getIndexForKey(key)); const result = matches[Math.min(currentIndex, matches.length - 1)]; this.requestIndexMap[key] = currentIndex + 1; return result; } getIndexForKey(key) { return this.requestIndexMap[key] || -1; } setIndexForKey(key, index) { this.requestIndexMap[key] = index; } indexMapKey(request, baseUrl) { if (!request.url) return undefined; const url = this.normalizeUrl(request.url, undefined, baseUrl); const method = _$3.lowerCase(request.method || "get"); return `${method}:${url}`; } normalizeUrl(url, parametersToRemove, baseUrl) { const urlObj = isURL(url) ? url : new URL(decodeURIComponent(url), this.info.baseUrl); const p = parametersToRemove || this.info.requestMatching?.ignoreUrlParameters || []; p.forEach((name) => { urlObj.searchParams.delete(name); }); if (!baseUrl) { return decodeURIComponent(urlObj.pathname + urlObj.search + urlObj.hash); } return decodeURIComponent(urlObj.toString()?.replace(this.info.baseUrl, "")?.replace(baseUrl, "")); } matchUrls(url1, url2, baseUrl) { if (!url1 || !url2) return false; const ignoreParameters = this.info.requestMatching?.ignoreUrlParameters || []; const n1 = this.normalizeUrl(url1, ignoreParameters, baseUrl); const n2 = this.normalizeUrl(url2, ignoreParameters, baseUrl); return _$3.isEqual(n1, n2); } // debugging and test purposes only getRequesIndex(key) { return this.requestIndexMap[key] || 0; } /** * Returns the pact record for the given request or null if no record is found. * Currently only url and method are used for matching. * @param req The request to use for matching. */ getRecordsMatchingRequest(req, baseUrl) { const records = this.records.filter((record) => { return (record.request?.url && req.url && this.matchUrls(record.request.url, req.url, baseUrl) && (req.method != null ? _$3.lowerCase(req.method) === _$3.lowerCase(record.request.method) : true)); }); return records.length ? records : null; } /** * Returns an iterator for the pact records to iterate records using `for (const record of pact) {...}`. */ [Symbol.iterator]() { return { next: () => { if (this.iteratorIndex < this.records.length) { return { value: this.records[this.iteratorIndex++], done: false }; } else { return { value: null, done: true }; } }, }; } } function toSerializablePactRecord(response, options = {}) { const recordOptions = { loggedInUser: options?.loggedInUser, loggedInUserAlias: options?.loggedInUserAlias, authType: options?.authType, }; const record = createPactRecord(response, options?.client, recordOptions); removeBaseUrlFromRequestUrl(record, options.baseUrl); if (options?.modifiedResponse && isCypressResponse(options?.modifiedResponse)) { const modifiedPactRecord = createPactRecord(options.modifiedResponse, options?.client, recordOptions); record.modifiedResponse = modifiedPactRecord.response; } options?.preprocessor?.apply(record); return record; } function getAuthCookies(response) { let setCookie = response.headers.getSetCookie; let cookieHeader; if (typeof response.headers.getSetCookie === "function") { cookieHeader = response.headers.getSetCookie(); } else { if (typeof response.headers.get === "function") { setCookie = response.headers.get("set-cookie"); if (_$3.isString(setCookie)) { cookieHeader = setCookieParser__namespace.splitCookiesString(setCookie); } else if (_$3.isArrayLike(setCookie)) { cookieHeader = setCookie; } } else { if (_$3.isPlainObject(response.headers)) { cookieHeader = get_i(response.headers, "set-cookie"); } } } if (!cookieHeader) return undefined; let authorization = undefined; let xsrfToken = undefined; setCookieParser__namespace.parse(cookieHeader || []).forEach((c) => { if (_$3.isEqual(c.name.toLowerCase(), "authorization")) { authorization = c.value; } if (_$3.isEqual(c.name.toLowerCase(), "xsrf-token")) { xsrfToken = c.value; } }); // This method is intended for use on server environments (for example Node.js). // Browsers block frontend JavaScript code from accessing the Set-Cookie header, // as required by the Fetch spec, which defines Set-Cookie as a forbidden // response-header name that must be filtered out from any response exposed to frontend code. // https://developer.mozilla.org/en-US/docs/Web/API/Headers/getSetCookie if (!authorization) { authorization = getCookieValue("authorization") || getCookieValue("Authorization"); if (_$3.isEmpty(authorization)) { authorization = undefined; } } if (!xsrfToken) { xsrfToken = getCookieValue("XSRF-TOKEN") || getCookieValue("xsrf-token"); if (_$3.isEmpty(xsrfToken)) { xsrfToken = undefined; } } return { authorization, xsrfToken }; } async function oauthLogin(auth, baseUrl) { if (!auth || !auth.user || !auth.password) { const error = new Error("Authentication required. oauthLogin requires user and password for authentication."); error.name = "C8yPactError"; throw error; } if (!baseUrl) { const error = new Error("Base URL required. oauthLogin requires absolute url for login."); error.name = "C8yPactError"; throw error; } let tenant = auth.tenant; if (!tenant) { const fetchClient = new client.FetchClient(baseUrl); const credentials = new client.BasicAuth(auth); fetchClient.setAuth(credentials); const res = await fetchClient.fetch("/tenant/currentTenant"); credentials.logout(); if (res.status !== 200) { const error = new Error(`Getting tenant id failed for ${baseUrl} with status code ${res.status}. Use env variable or pass it as part of auth object.`); error.name = "C8yPactError"; throw error; } const { name } = await res.json(); tenant = name; } const url = `/tenant/oauth?tenant_id=${tenant}`; const params = new URLSearchParams({ grant_type: "PASSWORD", username: auth.user || "", password: auth.password || "", ...(auth.tfa && { tfa_code: auth.tfa }), }); const fetchClient = new client.FetchClient(baseUrl); const res = await fetchClient.fetch(url, { method: "POST", body: params.toString(), headers: { "content-type": "application/x-www-form-urlencoded;charset=UTF-8", }, }); if (res.status !== 200) { const error = new Error(`Logging in to ${baseUrl} failed for user "${auth.user}" with status code ${res.status}.`); error.name = "C8yPactError"; throw error; } const cookies = getAuthCookies(res); const { authorization, xsrfToken } = _$3.pick(cookies, [ "authorization", "xsrfToken", ]); auth = { ...auth, ...(authorization && { bearer: authorization }), ...(xsrfToken && { xsrfToken: xsrfToken }), }; return auth; } // from c8y/client FetchClient function getCookieValue(name) { if (typeof document === "undefined") return undefined; const value = document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)"); return value ? value.pop() : ""; } const C8yPactHttpControllerLogLevel = [ "info", "debug", "warn", "error", ]; function createMiddleware(c8yctrl, options = {}) { const ignoredPaths = options.ignoredPaths || ["/c8yctrl"]; return wrapPathIgnoreHandler(httpProxyMiddleware.createProxyMiddleware({ target: options.baseUrl || c8yctrl.baseUrl, changeOrigin: true, cookieDomainRewrite: "", selfHandleResponse: true, logger: options.logger || c8yctrl.logger, followRedirects: false, on: { proxyReq: createRequestHandler(c8yctrl, options.auth), proxyRes: httpProxyMiddleware.responseInterceptor(createResponseInterceptor(c8yctrl, options.errorHandler)), }, }), 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 */ 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(); // }); } }; } 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 (_$3.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 = _$3.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; let reqBody = req.rawBody || req.body; try { if (_$3.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__namespace.parse(setCookieHeader, { decodeValues: false, }); if (cookies.length) { res.setHeader("set-cookie", cookies.map(function (cookie) { delete cookie.domain; delete cookie.secure; return libCookie__namespace.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 && !_$3.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 (_$3.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) { didSave = await c8yctrl.savePact(toCypressResponse(req, res, { resBody, reqBody }), pact); } addC8yCtrlHeader(res, "x-c8yctrl-type", didSave ? "saved" : "discard"); addC8yCtrlHeader(res, "x-c8yctrl-count", `${pact.records.length}`); return responseBuffer; }; } function createRequestHandler(c8yctrl, auth) { return (proxyReq, req, res) => { const rawBody = req.rawBody; if (rawBody && typeof rawBody === "string") { proxyReq.setHeader("transfer-encoding", "chunked"); proxyReq.removeHeader("content-length"); proxyReq.removeHeader("Content-Length"); proxyReq.write(rawBody); } else if (req.body) { const bodyString = JSON.stringify(req.body); proxyReq.removeHeader("transfer-encoding"); proxyReq.removeHeader("Transfer-Encoding"); proxyReq.setHeader("content-length", Buffer.byteLength(bodyString)); proxyReq.write(bodyString); } if (c8yctrl.currentPact?.id) { req.c8yctrlId = c8yctrl.currentPact?.id; } addC8yCtrlHeader(res, "x-c8yctrl-mode", c8yctrl.recordingMode); // add authorization header if ((c8yctrl.isRecordingEnabled() === true || c8yctrl.mode === "forward") && auth && !proxyReq.getHeader("authorization") && !proxyReq.getHeader("Authorization")) { const { bearer, xsrfToken, user, password } = auth; if (bearer) { proxyReq.setHeader("Authorization", `Bearer ${bearer}`); } if (!bearer && user && password) { proxyReq.setHeader("Authorization", `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`); } if (xsrfToken) { proxyReq.setHeader("X-XSRF-TOKEN", xsrfToken); } } if (_$3.isFunction(c8yctrl.options.on.proxyRequest)) { const r = c8yctrl.options.on.proxyRequest(c8yctrl, proxyReq, req); if (r) { const responseBody = _$3.isString(r?.body) ? r?.body : c8yctrl.stringify(r?.body); res.setHeader("content-length", Buffer.byteLength(responseBody)); r.headers = _$3.defaults(r?.headers, _$3.pick(r?.headers, ["content-type", "set-cookie"])); res.writeHead(r?.status || 200, r?.headers); res.end(responseBody); } } }; } 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 (!_$3.get(response.headers, ctrlHeader)) { response.headers = response?.headers || {}; response.headers[ctrlHeader] = value; } } } function toC8yPactResponse(res, body) { return { headers: res?.getHeaders(), status: res?.statusCode, statusText: res?.statusMessage, body, }; } 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: 0, 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; } const _ = _$3 || ___namespace; /** * Checks if the given version satisfies the requirements provided as an array of semver ranges. * If no required ranges are provided or range is empty, `true` is returned. * @param version - The version to check as a string or SemVer object. * @param requires - The required versions as semver ranges or `null` to allow version without specifying a range. * @returns `true` if the version satisfies the requirements, `false` otherwise. */ function isVersionSatisfyingRequirements(version, requires) { if (!requires || !_.isArrayLike(requires) || _.isEmpty(requires)) return true; if (requires.length === 1 && _.first(requires) == null) return true; let result = true; if (version != null) { const requiredRanges = getRangesSatisfyingVersion(version, requires); result = !_.isEmpty(requiredRanges); } else { // null is a special placeholder to mark the test to be executed if NO system version // is configured. Used for example for mocked tests with cy.intercept. result = requires?.includes(null); } return result; } /** * Returns the required semver ranges that are satisfied by the given version. * @param version - The version to check as a string or SemVer object. * @param requires - The required versions as semver ranges or `null` to allow version without specifying a range. * @returns The ranges that are satisfied by the version. */ function getRangesSatisfyingVersion(version, requires) { if (version == null || requires == null || _.isEmpty(requires)) { return []; } return filterNonNull(requires) .filter((v) => semver__namespace.satisfies(version, v)) .filter((v) => v != null); } function filterNonNull(items) { return items.filter((item) => item !== null); } function getPackageVersion() { try { let currentDir = __dirname; let packageJsonPath; let maxLevels = 3; while (maxLevels > 0) { packageJsonPath = path__namespace.resolve(currentDir, "package.json"); if (fs__namespace.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs__namespace.readFileSync(packageJsonPath, "utf8")); return packageJson.version; } currentDir = path__namespace.dirname(currentDir); maxLevels--; } } catch { console.error("Failed to get version from package.json. package.json not found."); } return "unknown"; } const log = debug("c8y:ctrl:http"); class C8yPactHttpController { constructor(options) { this._recordingMode = "append"; this._mode = "apply"; this._isStrictMocking = true; this.staticApps = {}; // mock handler - returns recorded response. // register before proxy handler this.mockRequestHandler = (req, res, next) => { if (!thi