UNPKG

cumulocity-cypress

Version:
1,297 lines (1,285 loc) 101 kB
'use strict'; var _$2 = 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'); var datefns = require('date-fns'); var setCookieParser = require('set-cookie-parser'); var libCookie = require('cookie'); var fetch = require('cross-fetch'); var buffer = require('buffer'); var httpProxyMiddleware = require('http-proxy-middleware'); var fs = require('fs'); var path = require('path'); var semver = require('semver'); var swaggerUi = require('swagger-ui-express'); var yaml = require('yaml'); var debug = require('debug'); 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 ___namespace = /*#__PURE__*/_interopNamespaceDefault(_$2); var datefns__namespace = /*#__PURE__*/_interopNamespaceDefault(datefns); var setCookieParser__namespace = /*#__PURE__*/_interopNamespaceDefault(setCookieParser); var libCookie__namespace = /*#__PURE__*/_interopNamespaceDefault(libCookie); var fs__namespace = /*#__PURE__*/_interopNamespaceDefault(fs); var path__namespace = /*#__PURE__*/_interopNamespaceDefault(path); var semver__namespace = /*#__PURE__*/_interopNamespaceDefault(semver); function isURL(obj) { return obj instanceof URL; } function removeBaseUrlFromString(url, baseUrl) { if (!url || !baseUrl) { return url; } let normalizedBaseUrl = _$2.clone(baseUrl); while (normalizedBaseUrl.endsWith("/")) { normalizedBaseUrl = normalizedBaseUrl.slice(0, -1); } let result = url.replace(normalizedBaseUrl, ""); if (_$2.isEmpty(result)) { result = "/"; } return result; } function removeBaseUrlFromRequestUrl(record, baseUrl) { if (!record?.request?.url || !baseUrl || !_$2.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 || !_$2.isString(url) || _$2.isEmpty(url)) return false; return /^https?:\/\//i.test(url); } 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 = _$2.isArray(path) ? path : path.split(/[.[\]]/g); let current = obj; const actualPath = []; for (let i = 0; i < keys.length; i++) { const key = keys[i]; if (_$2.isEmpty(key)) continue; if (current === null || current === undefined) { return undefined; } if (_$2.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 (_$2.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 _$2.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 || !_$2.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 _$1 = _$2 || ___namespace; const C8yPactModeValues = [ "record", "recording", "apply", "forward", "disabled", "mock", ]; const C8yPactRecordingModeValues = [ "refresh", "append", "new", "replace", ]; const C8yPactObjectKeys = ["records", "info", "id"]; function isValidPactId(value) { if (value == null || value.length > 1000 || !_$1.isString(value)) return false; const validPactIdRegex = /^[a-zA-Z0-9_-]+(__[a-zA-Z0-9_-]+)*$/; return validPactIdRegex.test(value); } /** * 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) => _$1.words(_$1.deburr(v), /[a-zA-Z0-9_-]+/g).join("_")) .join(suiteSeparator); if (value != null && _$1.isArray(value)) { result = value.map((v) => normalize(v)).join(suiteSeparator); } else if (value != null && _$1.isString(value)) { result = normalize(value); } if (result == null || _$1.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 (!_$1.isString(mode) || _$1.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; } } } /** * Validate the given pact recording mode. Throws an error if the mode is not supported * or undefined. * @param mode The pact recording mode to validate. */ function validatePactRecordingMode(mode) { if (mode != null) { const keys = Object.values(C8yPactRecordingModeValues); if (!_$1.isString(mode) || _$1.isEmpty(mode) || !keys.includes(mode.toLowerCase())) { const error = new Error(`Unsupported recording mode: "${mode}". Supported values are: ${keys.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 (_$1.isObjectLike(obj) && "info" in obj && _$1.isObjectLike(_$1.get(obj, "info")) && "records" in obj && _$1.isArray(_$1.get(obj, "records")) && _$1.every(_$1.get(obj, "records"), isPactRecord) && _$1.isFunction(_$1.get(obj, "nextRecord")) && _$1.isFunction(_$1.get(obj, "nextRecordMatchingRequest")) && _$1.isFunction(_$1.get(obj, "appendRecord")) && _$1.isFunction(_$1.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 (_$1.isObjectLike(obj) && "request" in obj && _$1.isObjectLike(_$1.get(obj, "request")) && "response" in obj && _$1.isObjectLike(_$1.get(obj, "response")) && _$1.isFunction(_$1.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 (_$1.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)); } /** * Checks if the given object is a C8yPactError. A C8yPactError is an error * with the name "C8yPactError". * * @param error The object to check. * @returns True if the object is a C8yPactError, false otherwise. */ function isPactError(error) { return _$1.isError(error) && _$1.get(error, "name") === "C8yPactError"; } function isDefined(value) { return !_$1.isUndefined(value); } /** * Converts a Cypress.Response to a C8yPactRequest. */ function toPactRequest(response) { if (!response) return response; const result = _$1.pickBy(_$1.mapKeys(_$1.pick(response, ["url", "method", "requestHeaders", "requestBody"]), (v, k) => { if (_$1.isEqual(k, "requestHeaders")) return "headers"; if (_$1.isEqual(k, "requestBody")) return "body"; return k; }), isDefined); if (_$1.isEmpty(result)) return undefined; return result; } /** * Converts a Cypress.Response to a C8yPactResponse. */ function toPactResponse(response) { if (!response) return response; const result = _$1.pickBy(_$1.pick(response, [ "status", "statusText", "body", "headers", "duration", "isOkStatusCode", "allRequestResponses", "$body", ]), isDefined); if (_$1.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 = _$1.camelCase(name).replace(/^c8Y/i, "c8y"); const camelCasedPlainName = _$1.camelCase(plainName); return (getForName(name) || getForName(camelCasedName) || getForName(plainName) || getForName(camelCasedPlainName)); } function isOneOfStrings(value, values) { if (!_$1.isString(value) || _$1.isEmpty(value)) return false; return values.includes(value.toLowerCase()); } 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; } /// <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 _$2.isObjectLike(obj) && "user" in obj && "password" in obj; } function toPactAuthObject(obj) { return _$2.pick(obj, C8yPactAuthObjectKeys); } function isPactAuthObject(obj) { return (_$2.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(_$2.get(obj, "request"), _$2.get(obj, "response"), _$2.get(obj, "options") || {}, _$2.get(obj, "auth"), _$2.get(obj, "createdObject"), _$2.get(obj, "modifiedResponse")); } const r = _$2.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 = _$2.get(this.response, "headers.date"); if ((date && _$2.isString(date)) || _$2.isNumber(date) || _$2.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 = _$2.cloneDeep(this.response); _$2.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 = _$2.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 = _$2.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 (_$2.isString(obj)) { pact = JSON.parse(obj); } else if (_$2.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 && !_$2.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 = _$2.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 _$2.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 ? _$2.lowerCase(req.method) === _$2.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; } async function toPactSerializableObject(response, info, options = {}) { if (options.baseUrl == null) { options.baseUrl = info.baseUrl; } const record = toSerializablePactRecord(response, options); const pact = new C8yDefaultPact([record], info, info.id); const keysToSave = ["id", "info", "records"]; try { await Promise.all(pact.records .filter((record_1) => record_1.response.body && !record_1.response.$body && _$2.isObjectLike(record_1.response.body)) .map((record_2) => options?.schemaGenerator ?.generate(record_2.response.body, { name: "body" }) .then((schema) => { record_2.response.$body = schema; return record_2; }))); return { ..._$2.pick(pact, keysToSave) }; } catch (error) { console.error(error); return { ..._$2.pick(pact, keysToSave) }; } } /** * Default implementation of C8yPactMatcher to match C8yPactRecord objects. Pacts * are matched by comparing the properties of the objects using property matchers. * If no property matcher is configured for a property, the property will be matched * by equality. Disable Cypress.c8ypact.config.strictMatching to ignore properties that are * missing in matched objects. In case objects do not match an C8yPactError is thrown. */ class C8yDefaultPactMatcher { constructor(propertyMatchers = { body: new C8yPactBodyMatcher(), requestBody: new C8yPactBodyMatcher(), duration: new C8yNumberMatcher(), date: new C8yIgnoreMatcher(), Authorization: new C8yIgnoreMatcher(), auth: new C8yIgnoreMatcher(), options: new C8yIgnoreMatcher(), createdObject: new C8yIgnoreMatcher(), location: new C8yIgnoreMatcher(), url: new C8yIgnoreMatcher(), "X-XSRF-TOKEN": new C8yIgnoreMatcher(), lastMessage: new C8yISODateStringMatcher(), }) { this.propertyMatchers = {}; this.propertyMatchers = propertyMatchers; } match(obj1, obj2, options) { if (obj1 === obj2) return true; const parents = options?.parents ?? []; const strictMatching = options?.strictMatching ?? false; const matchSchemaAndObject = options?.matchSchemaAndObject ?? C8yDefaultPactMatcher.matchSchemaAndObject; const schemaMatcher = options?.schemaMatcher || C8yDefaultPactMatcher.schemaMatcher; const throwPactError = (message, key) => { const errorMessage = `Pact validation failed! ${message}`; const newErr = new Error(errorMessage); newErr.name = "C8yPactError"; if (options?.loggerProps) { options.loggerProps.error = errorMessage; options.loggerProps.key = key; options.loggerProps.keypath = keyPath(key); options.loggerProps.objects = key && _$2.isPlainObject(obj1) && _$2.isPlainObject(obj2) ? [_$2.pick(obj1, [key]), _$2.pick(obj2, [key])] : [obj1, obj2]; } throw newErr; }; const keyPath = (k) => { return `${[...parents, ...(k ? [k] : [])].join(" > ")}`; }; const isArrayOfPrimitives = (value) => { if (!_$2.isArray(value)) { return false; } const primitiveTypes = ["undefined", "boolean", "number", "string"]; return (value.filter((p) => primitiveTypes.includes(typeof p)).length === value.length); }; if (_$2.isString(obj1) && _$2.isString(obj2) && !_$2.isEqual(obj1, obj2)) { throwPactError(`"${keyPath()}" text did not match.`); } if (!_$2.isObject(obj1) || !_$2.isObject(obj2)) { throwPactError(`Expected 2 objects as input for matching, but got "${typeof obj1}" and ${typeof obj2}".`); } // get keys of objects without schema keys and schema keys separately const objectKeys = Object.keys(obj1).filter((k) => !k.startsWith("$")); const schemaKeys = Object.keys(obj2).filter((k) => k.startsWith("$")); // normalize pact keys and remove keys that have a schema defined // we do not want for example body and $body const pactKeys = matchSchemaAndObject === true ? Object.keys(obj2) : Object.keys(obj2).reduce((acc, key) => { if (!schemaKeys.includes(`$${key}`)) { acc.push(key); } return acc; }, []); if (_$2.isEmpty(objectKeys) && _$2.isEmpty(pactKeys)) { return true; } const removeSchemaPrefix = (key) => key.startsWith("$") ? key.slice(1) : key; // if strictMatching is disabled, only check properties of the pact for object matching // strictMatching for schema matching is considered within the matcher -> schema.additionalProperties const keys = !strictMatching ? pactKeys : objectKeys; for (const key of keys) { // schema is always defined on the pact object - needs special consideration const isSchema = key.startsWith("$") || schemaKeys.includes(`$${key}`); const value = _$2.get(strictMatching || isSchema ? obj1 : obj2, removeSchemaPrefix(key)); let pact = _$2.get(strictMatching || isSchema ? obj2 : obj1, isSchema && !key.startsWith("$") ? `$${key}` : key); if (!(strictMatching ? pactKeys : objectKeys).includes(key) && !isSchema) { throwPactError(`"${keyPath(key)}" not found in ${strictMatching ? "pact" : "response"} object.`); } if (isSchema) { const errorKey = removeSchemaPrefix(key); if (!schemaMatcher) { throwPactError(`No schema matcher registered to validate "${keyPath(errorKey)}".`, errorKey); } try { if (!schemaMatcher.match(value, pact, strictMatching)) { throwPactError(`Schema for "${keyPath(errorKey)}" does not match.`, errorKey); } } catch (error) { throwPactError(`Schema for "${keyPath(errorKey)}" does not match. (${error})`, errorKey); } if (!matchSchemaAndObject) { continue; } pact = _$2.get(strictMatching ? obj2 : obj1, key); } if (this.getPropertyMatcher(key, options?.ignoreCase) != null) { if (!strictMatching && !value) { continue; } try { const result = this.getPropertyMatcher(key, options?.ignoreCase)?.match(value, pact, _$2.extend(options, { parents: [...parents, key] })); if (!result) throw new Error(""); } catch (error) { // calling match recursively requires to pass the root error if (_$2.get(error, "name") === "C8yPactError") { throw error; } else { throwPactError(`Values for "${keyPath(key)}" do not match.${error != null ? " " + error : ""}`, key); } } } else if (isArrayOfPrimitives(value) && isArrayOfPrimitives(pact)) { const v = [value, pact].sort((a1, a2) => a2.length - a1.length); const diff = _$2.difference(v[0], v[1]); if (_$2.isEmpty(diff)) { continue; } else { throwPactError(`Array with key "${keyPath(key)}" has unexpected values "${diff}".`, key); } } else if (_$2.isArray(value) && _$2.isArray(pact)) { if (value.length !== pact.length) { throwPactError(`Array with key "${keyPath(key)}" has different lengths.`, key); } for (let i = 0; i < value.length; i++) { this.match(value[i], pact[i], _$2.extend(options, { parents: [...parents, key, `${i}`] })); } } else if (_$2.isObjectLike(value) && _$2.isObjectLike(pact)) { // if strictMatching is disabled, value1 and value2 have been swapped // swap back to ensure swapping in next iteration works as expected this.match(strictMatching ? value : pact, strictMatching ? pact : value, _$2.extend(options, { parents: [...parents, key] })); } else { if (value != null && pact != null && !_$2.isEqual(value, pact)) { throwPactError(`Values for "${keyPath(key)}" do not match.`, key); } } } return true; } /** * Returns the property matcher for the given property name. * @param key The property name to get the matcher for. * @param ignoreCase Whether to ignore the case of the property name. */ getPropertyMatcher(key, ignoreCase = false) { if (ignoreCase) { return get_i(this.propertyMatchers, key); } return this.propertyMatchers[key]; } /** * Adds a new property matcher for the given property name. */ addPropertyMatcher(propertyName, matcher) { this.propertyMatchers[propertyName] = matcher; } /** * Removes the property matcher for the given property name. */ removePropertyMatcher(propertyName) { delete this.propertyMatchers[propertyName]; } } C8yDefaultPactMatcher.matchSchemaAndObject = false; /** * Extends C8yDefaultPactMatcher with default property matchers for Cumulocity * response bodies. It has rules configured at least for the following properties: * id, statistics, lastUpdated, creationTime, next, self, password, owner, tenantId * and lastPasswordChange. It is registered for the properties body and requestBody. */ class C8yPactBodyMatcher extends C8yDefaultPactMatcher { constructor(propertyMatchers = {}) { super(propertyMatchers); this.addPropertyMatcher("id", new C8ySameTypeMatcher()); this.addPropertyMatcher("statistics", new C8yIgnoreMatcher()); this.addPropertyMatcher("lastUpdated", new C8yISODateStringMatcher()); this.addPropertyMatcher("creationTime", new C8yISODateStringMatcher()); this.addPropertyMatcher("next", new C8yIgnoreMatcher()); this.addPropertyMatcher("self", new C8yIgnoreMatcher()); this.addPropertyMatcher("password", new C8yIgnoreMatcher()); this.addPropertyMatcher("owner", new C8ySameTypeMatcher()); this.addPropertyMatcher("tenantId", new C8yIgnoreMatcher()); this.addPropertyMatcher("lastPasswordChange", new C8yISODateStringMatcher()); } } class C8yIdentifierMatcher { match(obj1, obj2) { [obj1, obj2].forEach((id) => { if (_$2.isString(id) === false || /^\d+$/.test(id) === false) { throw new Error(`Value "${id}" is not a valid identifier.`); } }); return true; } } class C8yNumberMatcher { match(obj1, obj2) { [obj1, obj2].forEach((n) => { if (!_$2.isNumber(n) || _$2.isNaN(n)) { throw new Error(`Value "${obj1}" is not a number.`); } }); return true; } } class C8yStringMatcher { match(obj1, obj2) { [obj1, obj2].forEach((s) => { if (!_$2.isString(s)) { throw new Error(`Value "${s}" is not a string.`); } }); return true; } } class C8yIgnoreMatcher { match() { return true; } } class C8ySameTypeMatcher { match(obj1, obj2) { const result = typeof obj1 === typeof obj2; if (!result) { throw new Error(`Values are not of same type. Expected ${typeof obj1} but got ${typeof obj2}`); } return result; } } class C8yISODateStringMatcher { match(obj1, obj2) { // validate regex as parseISO does not throw an error for invalid dates // and is not strict enough for our use case // https://regex101.com/library/6gJsuQ?filterFlavors=javascript&page=9 const isoRegex = new RegExp(/^((\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-((0[13578]|1[02])-(0[1-9]|[12]\d|3[01])|(0[469]|11)-(0[1-9]|[12]\d|30)|(02)-(0[1-9]|1\d|2[0-8])))T([01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}([+-]([01]\d|2[0-3]):[0-5]\d|Z)$/); [obj1, obj2].forEach((obj) => { if (!_$2.isString(obj)) { throw new Error(`Value "${obj}" is not a string.`); } if (!isoRegex.test(obj)) { throw new Error(`Value "${obj}" is not a valid ISO date string.`); } }); const d1 = datefns__namespace.parseISO(obj1); const d2 = datefns__namespace.parseISO(obj2); return datefns__namespace.isValid(d1) && datefns__namespace.isValid(d2); } } const C8yPactPreprocessorDefaultOptions = { ignore: [ "request.headers.accept-encoding", "response.headers.cache-control", "response.headers.content-length", "response.headers.content-encoding", "response.headers.transfer-encoding", "response.headers.keep-alive", ], obfuscate: [ "request.headers.cookie.authorization", "request.headers.cookie.XSRF-TOKEN", "request.headers.authorization", "request.headers.X-XSRF-TOKEN", "response.headers.set-cookie.authorization", "response.headers.set-cookie.XSRF-TOKEN", "response.body.password", "response.body.users.password", ], obfuscationPattern: "****", ignoreCase: true, }; /** * Default implementation of C8yPactPreprocessor. Preprocessor for C8yPact objects * that can be used to obfuscate or remove sensitive data from the pact objects. * Use C8ypactPreprocessorOptions to configure the preprocessor. * * Removes cookies and set-cookie headers by appending the key to the `cookie` or `set-cookie` * key as for example `headers.cookie.authorization` or `headers.set-cookie.authorization`. */ class C8yDefaultPactPreprocessor { constructor(options) { this.reservedKeys = ["id", "pact", "info", "records"]; this.options = this.resolveOptions(options); } apply(obj, options) { if (!obj || !_$2.isObjectLike(obj)) return; const objs = "records" in obj ? _$2.get(obj, "records") : [obj]; if (!_$2.isArray(objs)) return; const o = this.resolveOptions(options); const ignoreCase = o.ignoreCase; const obfuscationPattern = o.obfuscationPattern; const mapSensitiveKeys = (mapObject, keys) => keys.map((k) => ignoreCase === true ? toSensitiveObjectKeyPath(mapObject, k) ?? k : k); objs.forEach((obj) => { if (o?.pick != null) { const keepPaths = []; if (_$2.isPlainObject(o.pick)) { Object.entries(o.pick ?? {}).forEach(([parentKey, childKeys]) => { if (_$2.isEmpty(childKeys)) keepPaths.push(parentKey); childKeys.forEach((childKey) => { keepPaths.push(`${parentKey}.${childKey}`); }); }); this.filterObjectByKeepPaths(obj, keepPaths, ignoreCase); } else if (_$2.isArray(o.pick)) { this.applyKeepArray(obj, o.pick); } } if (o?.regexReplace != null) { Object.entries(o.regexReplace ?? {}).forEach(([key, value]) => { const patterns = Array.isArray(value) ? value : [value]; const keysToReplace = mapSensitiveKeys(obj, [key]); keysToReplace.forEach((k) => { let v = _$2.get(obj, k); if (v != null) { // Apply each regex pattern in sequence for (const pattern of patterns) { try { const regex = parseRegexReplace(pattern); v = performRegexReplace(v, regex); } catch { // ignore invalid regex } } _$2.set(obj, k, v); } }); }); } const keysToObfuscate = mapSensitiveKeys(obj, o.obfuscate ?? []); this.handleObfuscation(obj, keysToObfuscate, obfuscationPattern); const keysToRemove = mapSensitiveKeys(obj, o.ignore ?? []); this.handleRemoval(obj, keysToRemove); }); } filterObjectByKeepPaths(obj, keepPaths, ignoreCase = false) { const prepKey = (key) => key != null && ignoreCase === true ? key.toLowerCase() : key; const shouldKeep = (keyPath) => { return keepPaths .map((k) => prepKey(k)) .some((keepPath) => prepKey(keyPath) === keepPath || keepPath?.startsWith(`${prepKey(keyPath)}.`)); }; const recursiveFilter = (currentObj, currentPath) => { if (!_$2.isObject(currentObj)) return; Object.keys(currentObj).forEach((key) => { const fullPath = currentPath ? `${currentPath}.${key}` : key; if (!shouldKeep(fullPath)) { _$2.unset(obj, fullPath); } else if (!keepPaths.includes(fullPath)) { recursiveFilter(_$2.get(currentObj, key), fullPath); } }); }; recursiveFilter(obj, ""); } applyKeepArray(obj, keep) { if (keep == null || _$2.isEmpty(keep)) return; if (_$2.isObjectLike(obj)) { const keysToRemove = Object.keys(obj).filter((childKey) => !keep.includes(childKey.toLowerCase())); keysToRemove.forEach((childKey) => { _$2.unset(obj, childKey); }); } } handleObfuscation(obj, keysToObfuscate, obfuscationPattern) { const validKeys = this.filterValidKeys(obj, keysToObfuscate); validKeys.forEach((key) => { this.obfuscateKey(obj, key, obfuscationPattern); }); } handleRemoval(obj, keysToRemove) { const validKeys = this.filterValidKeys(obj, keysToRemove); validKeys.forEach((key) => { this.removeKey(obj, key); }); } removeKey(obj, key) { const keyPath = key.split("."); if (this.hasKey(keyPath, "set-cookie")) { this.removeSetCookie(obj, keyPath); } else if (this.hasKey(keyPath, "cookie")) { this.removeCookie(obj, keyPath); } else { const processKeyPath = (currentObj, remainingKeyParts) => { if (!currentObj || remainingKeyParts.length === 0) return; const [_currentKey, ...restKeys] = remainingKeyParts; const currentKey = toSensitiveObjectKeyPath(currentObj, _currentKey) ?? _currentKey; const target = _$2.get(currentObj, currentKey); if (_$2.isArray(target)) { // If the current key points to an array, process each element target.forEach((item) => processKeyPath(item, restKeys)); } else if (restKeys.length === 0) { _$2.unset(currentObj, currentKey); } else { processKeyPath(target, restKeys); } }; processKeyPath(obj, keyPath); } } removeSetCookie(obj, keyParts) { const { name, keyPath, cookieHeader } = this.getCookieObject(obj, keyParts); if (!cookieHeader) return; if (!name) { _$2.unset(obj, keyPath); return; } const cookies = setCookieParser__namespace.parse(cookieHeader, { decodeValues: false }) ?? []; if (cookies.length) { const filteredCookies = cookies .filter((cookie) => cookie.name.toLowerCase() !== name.toLowerCase()) .map((cookie) => libCookie__namespace.serialize(cookie.name, cookie.value, cookie)); if (filteredCookies.length === 0) { _$2.unset(obj, keyPath); } else { _$2.set(obj, keyPath, filteredCookies);