UNPKG

cumulocity-cypress

Version:
1,340 lines (1,325 loc) 230 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 _$4 = require('lodash'); var yaml = require('yaml'); 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 buffer = require('buffer'); var httpProxyMiddleware = require('http-proxy-middleware'); var semver = require('semver'); var swaggerUi = require('swagger-ui-express'); var Ajv = require('ajv'); var addFormats = require('ajv-formats'); require('ajv/lib/refs/json-schema-draft-06.json'); var $RefParser = require('@apidevtools/json-schema-ref-parser'); var url = require('url'); 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(_$4); var yaml__namespace = /*#__PURE__*/_interopNamespaceDefault(yaml); var setCookieParser__namespace = /*#__PURE__*/_interopNamespaceDefault(setCookieParser); var libCookie__namespace = /*#__PURE__*/_interopNamespaceDefault(libCookie); var semver__namespace = /*#__PURE__*/_interopNamespaceDefault(semver); function isURL(obj) { return obj instanceof URL; } function removeBaseUrlFromString(url, baseUrl) { if (!url || !baseUrl) { return url; } let normalizedBaseUrl = _$4.clone(baseUrl); while (normalizedBaseUrl.endsWith("/")) { normalizedBaseUrl = normalizedBaseUrl.slice(0, -1); } let result = url.replace(normalizedBaseUrl, ""); if (_$4.isEmpty(result)) { result = "/"; } return result; } function removeBaseUrlFromRequestUrl(record, baseUrl) { if (!record?.request?.url || !baseUrl || !_$4.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 || !_$4.isString(url) || _$4.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; } } 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 = _$4.isArray(path) ? path : path.split(/[.[\]]/g); let current = obj; const actualPath = []; for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (_$4.isEmpty(key)) continue; if (current === null || current === undefined) { return undefined; } if (_$4.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 (_$4.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 _$4.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 || !_$4.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; } /// <reference types="cypress" /> // workaround for lodash import in Cypress nodejs typescript runtime and browser const _$3 = _$4 || ___namespace; const C8yPactModeValues = [ "record", "recording", "apply", "forward", "disabled", "mock", ]; const C8yPactRecordingModeValues = [ "refresh", "append", "new", "replace", ]; const C8yPactObjectKeys = ["records", "info", "id"]; /** * 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) => _$3.words(_$3.deburr(v), /[a-zA-Z0-9_-]+/g).join("_")) .join(suiteSeparator); if (value != null && _$3.isArray(value)) { result = value.map((v) => normalize(v)).join(suiteSeparator); } else if (value != null && _$3.isString(value)) { result = normalize(value); } if (result == null || _$3.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 (!_$3.isString(mode) || _$3.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 (_$3.isObjectLike(obj) && "info" in obj && _$3.isObjectLike(_$3.get(obj, "info")) && "records" in obj && _$3.isArray(_$3.get(obj, "records")) && _$3.every(_$3.get(obj, "records"), isPactRecord) && _$3.isFunction(_$3.get(obj, "nextRecord")) && _$3.isFunction(_$3.get(obj, "nextRecordMatchingRequest")) && _$3.isFunction(_$3.get(obj, "appendRecord")) && _$3.isFunction(_$3.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 (_$3.isObjectLike(obj) && "request" in obj && _$3.isObjectLike(_$3.get(obj, "request")) && "response" in obj && _$3.isObjectLike(_$3.get(obj, "response")) && _$3.isFunction(_$3.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 (_$3.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 !_$3.isUndefined(value); } /** * Converts a Cypress.Response to a C8yPactRequest. */ function toPactRequest(response) { if (!response) return response; const result = _$3.pickBy(_$3.mapKeys(_$3.pick(response, ["url", "method", "requestHeaders", "requestBody"]), (v, k) => { if (_$3.isEqual(k, "requestHeaders")) return "headers"; if (_$3.isEqual(k, "requestBody")) return "body"; return k; }), isDefined); if (_$3.isEmpty(result)) return undefined; return result; } /** * Converts a Cypress.Response to a C8yPactResponse. */ function toPactResponse(response) { if (!response) return response; const result = _$3.pickBy(_$3.pick(response, [ "status", "statusText", "body", "headers", "duration", "isOkStatusCode", "allRequestResponses", "$body", ]), isDefined); if (_$3.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 = _$3.camelCase(name).replace(/^c8Y/i, "c8y"); const camelCasedPlainName = _$3.camelCase(plainName); return (getForName(name) || getForName(camelCasedName) || getForName(plainName) || getForName(camelCasedPlainName)); } function getCreatedObjectId(response) { let newId = response?.body?.id; if (newId) { return 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); newId = pathSegments?.pop(); if (newId != null) { return decodeURIComponent(newId); } } catch { // do nothing } } } return undefined; } const _$2 = _$4 || ___namespace; const log$1 = debug("c8y:fileadapter"); /** * Default implementation of C8yPactFileAdapter which loads and saves C8yPact objects * Provide location of the files using folder option. Default location is * cypress/fixtures/c8ypact folder. * * This adapter supports loading of JSON and YAML pact files (.json, .yaml, .yml). When * saviing pact files, it saves them as JSON files (.json). * * By using C8yPactAdapterOptions you can enable loading of JavaScript pact files (.js, .cjs). * Use with caution, as this can lead to security issues if the files are not trusted. */ class C8yPactDefaultFileAdapter { /** * Creates an instance of C8yPactDefaultFileAdapter. * * @param folder - The folder where pact files are stored. Can be an absolute or relative path. * @param options - Optional configuration for the adapter. * @param options.enableJavaScript - If true, enables loading of JavaScript pact files (.js, .cjs). Defaults to false. */ constructor(folder, options) { this.folder = path__namespace.isAbsolute(folder) ? folder : this.toAbsolutePath(folder); this.enabledExtensions = [".json", ".yaml", ".yml"]; if (options?.enableJavaScript) { this.enabledExtensions.push(".js", ".cjs"); } log$1(`Initialized with enabled extensions: ${this.enabledExtensions.join(", ")}`); } description() { return `C8yPactDefaultFileAdapter: ${this.folder}`; } getFolder() { return this.folder; } loadPacts() { const pactObjects = this.loadPactObjects(); log$1(`loadPacts() - ${pactObjects.length} pact files from ${this.folder}`); return pactObjects.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; } // Try to find the file with different supported extensions const extensions = this.enabledExtensions; let loadedPact = null; for (const ext of extensions) { const file = path__namespace.join(this.folder, `${pId}${ext}`); if (fs__namespace.existsSync(file)) { try { loadedPact = this.loadPactFromFile(file); if (loadedPact) { log$1(`loadPact() - ${file} loaded successfully`); return loadedPact; } } catch (error) { log$1(`loadPact() - error loading ${file}: ${error}`); } } } log$1(`loadPact() - no valid pact file found for id ${pId}`); return null; } pactExists(id) { const pId = pactId(id); return this.enabledExtensions.some((ext) => fs__namespace.existsSync(path__namespace.join(this.folder, `${pId}${ext}`))); } 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`); } } readPactFiles() { log$1(`readPactFiles() - ${this.folder}`); if (!this.folder || !fs__namespace.existsSync(this.folder)) { log$1(`readPactFiles() - ${this.folder} does not exist`); return []; } const pacts = this.loadPactObjects(); return pacts.map((pact) => { return safeStringify(pact); }); } /** * @deprecated Use readPactFiles() instead. */ 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() { log$1(`loadPactObjects() - ${this.folder}`); if (!this.folder || !fs__namespace.existsSync(this.folder)) { log$1(`loadPactObjects() - ${this.folder} does not exist`); return []; } // Find all files with supported extensions const combinedPattern = path__namespace.join(this.folder, `*{${this.enabledExtensions.join(",")}}`); const allFiles = glob__namespace.sync(combinedPattern); log$1(`loadPactObjects() - reading ${allFiles.length} files from ${this.folder}`); // Load and parse each file based on its extension const pactObjects = allFiles .map((file) => { try { return this.loadPactFromFile(file); } catch (error) { log$1(`loadPactObjects() - error loading ${file}: ${error}`); return null; } }) .filter(Boolean); log$1(`loadPactObjects() - loaded ${pactObjects.length} valid pact objects`); return pactObjects; } loadPactFromFile(filePath) { if (!fs__namespace.existsSync(filePath)) { log$1(`loadPactFromFile() - file does not exist: ${filePath}`); return null; } const extension = path__namespace.extname(filePath).toLowerCase(); // Check if the extension is enabled if (!this.enabledExtensions.includes(extension)) { log$1(`loadPactFromFile() - file extension ${extension} is not supported or enabled for loading: ${filePath}`); return null; } const content = fs__namespace.readFileSync(filePath, "utf-8"); try { // Handle different file formats if (extension === ".json") { // Load JSON file return JSON.parse(content); } else if (extension === ".yaml" || extension === ".yml") { // Load YAML file return yaml__namespace.parse(content); } else if (extension === ".js" || extension === ".cjs") { // CommonJS modules (.js, .cjs) can use require const absolutePath = path__namespace.isAbsolute(filePath) ? filePath : path__namespace.resolve(process.cwd(), filePath); try { // Clear cache if needed if (require.cache && require.cache[require.resolve(absolutePath)]) { delete require.cache[require.resolve(absolutePath)]; } // eslint-disable-next-line @typescript-eslint/no-require-imports const pactModule = require(absolutePath); return pactModule.default || pactModule; } catch (error) { log$1(`loadPactFromFile() - error loading ${extension} file ${absolutePath}: ${error}`); } } } catch (error) { log$1(`loadPactFromFile() - error parsing file ${filePath}: ${error}`); } return null; } createFolderRecursive(f) { log$1(`createFolderRecursive() - ${f}`); if (!f || !_$2.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; } } /// <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 _$4.isObjectLike(obj) && "user" in obj && "password" in obj; } function toPactAuthObject(obj) { return _$4.pick(obj, C8yPactAuthObjectKeys); } function isPactAuthObject(obj) { return (_$4.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 = getCreatedObjectId(response); if (newId) { this.createdObject = newId; } } } /** * 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(_$4.get(obj, "request"), _$4.get(obj, "response"), _$4.get(obj, "options") || {}, _$4.get(obj, "auth"), _$4.get(obj, "createdObject"), _$4.get(obj, "modifiedResponse")); } const r = _$4.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 = _$4.get(this.response, "headers.date"); if ((date && _$4.isString(date)) || _$4.isNumber(date) || _$4.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 = _$4.cloneDeep(this.response); _$4.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 = _$4.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 = _$4.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 (_$4.isString(obj)) { pact = JSON.parse(obj); } else if (_$4.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 && !_$4.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 = _$4.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 _$4.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 ? _$4.lowerCase(req.method) === _$4.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 (_$4.isString(setCookie)) { cookieHeader = setCookieParser__namespace.splitCookiesString(setCookie); } else if (_$4.isArrayLike(setCookie)) { cookieHeader = setCookie; } } else { if (_$4.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 (_$4.isEqual(c.name.toLowerCase(), "authorization")) { authorization = c.value; } if (_$4.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 (_$4.isEmpty(authorization)) { authorization = undefined; } } if (!xsrfToken) { xsrfToken = getCookieValue("XSRF-TOKEN") || getCookieValue("xsrf-token"); if (_$4.isEmpty(xsrfToken)) { xsrfToken = undefined; } } return { authorization, xsrfToken }; } // 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() : ""; } 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 basicAuthHeader = `Basic ${buffer.Buffer.from(`${auth.user}:${auth.password}`).toString("base64")}`; const tenantUrl = `${baseUrl}/tenant/currentTenant`; const tenantResponse = await fetch(tenantUrl, { headers: { Authorization: basicAuthHeader, }, }); if (tenantResponse.status !== 200) { const error = new Error(`Getting tenant id failed for ${baseUrl} with status code ${tenantResponse.status}. Use env variable or pass it as part of auth object.`); error.name = "C8yPactError"; throw error; } const tenantData = await tenantResponse.json(); tenant = tenantData.name; } const oauthEndpointUrl = `${baseUrl}/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 oauthResponse = await fetch(oauthEndpointUrl, { method: "POST", body: params.toString(), headers: { "content-type": "application/x-www-form-urlencoded;charset=UTF-8", }, }); if (oauthResponse.status !== 200) { const error = new Error(`Logging in to ${baseUrl} failed for user "${auth.user}" with status code ${oauthResponse.status}.`); error.name = "C8yPactError"; throw error; } const cookies = getAuthCookies(oauthResponse); // Assuming getAuthCookies works with standard Response const { authorization, xsrfToken } = _$4.pick(cookies, [ "authorization", "xsrfToken", ]); auth = { ...auth, ...(authorization && { bearer: authorization }), ...(xsrfToken && { xsrfToken: xsrfToken }), }; return auth; } 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 (_$4.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 = _$4.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 (_$4.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 && !_$4.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 (_$4.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") &&