UNPKG

cumulocity-cypress

Version:
1,288 lines (1,278 loc) 268 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 setCookieParser = require('set-cookie-parser'); var libCookie = require('cookie'); var yaml = require('yaml'); var util = require('util'); var os = require('node:os'); var express = require('express'); var getRawBody = require('raw-body'); var cookieParser = require('cookie-parser'); var winston = require('winston'); var morgan = require('morgan'); require('@c8y/client'); require('date-fns'); 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 setCookieParser__namespace = /*#__PURE__*/_interopNamespaceDefault(setCookieParser); var libCookie__namespace = /*#__PURE__*/_interopNamespaceDefault(libCookie); var yaml__namespace = /*#__PURE__*/_interopNamespaceDefault(yaml); 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 base url. '${baseUrl}' must be an absolute URL or undefined.`); error.name = "C8yPactError"; throw error; } } /** * Normalizes a URL to ensure it has a protocol and proper trailing slash. * If no protocol is present, HTTPS is added by default. * If the URL has no path component, a trailing slash is appended. * * @param url - The URL string to normalize * @returns The normalized URL with HTTPS protocol and trailing slash if appropriate, or undefined for invalid input */ function normalizeBaseUrl(url) { if (!url || !_$4.isString(url)) { return undefined; } const trimmedUrl = url.trim(); if (!trimmedUrl) { return undefined; } let normalizedUrl; // Check if URL already has a protocol if (/^https?:\/\//i.test(trimmedUrl)) { normalizedUrl = trimmedUrl; } else { // Add https:// if no protocol is present normalizedUrl = `https://${trimmedUrl}`; } try { const urlObj = new URL(normalizedUrl); // remove all components other than protocol, host normalizedUrl = `${urlObj.protocol}//${urlObj.host}`; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error(`Failed to normalize base url ${url}. ${errorMessage}`); } return normalizedUrl; } 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. * * @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 inputStr = _$4.isArray(path) ? null : path; const keys = _$4.isArray(path) ? path.filter((k) => !_$4.isEmpty(k)) : path.split(/[.[\]]/g).filter((k) => !_$4.isEmpty(k)); let current = obj; const resolved = []; for (const key of keys) { if (current === null || current === undefined) return undefined; if (_$4.isArray(current)) { const index = parseInt(key); if (!isNaN(index)) { if (index >= 0 && index < current.length) { resolved.push(key); current = current[index]; } else { return undefined; // index out of bounds } } else if (current.length > 0 && _$4.isString(current[0])) { const matchedIndex = current.findIndex((item) => _$4.isString(item) && item.toLowerCase() === key.toLowerCase()); if (matchedIndex !== -1) { resolved.push(String(matchedIndex)); current = current[matchedIndex]; } else { return undefined; } } else if (current.length > 0 && _$4.isObjectLike(current[0])) { // For arrays of objects, resolve case through the first element so // the caller gets the correctly-cased key without needing an index. const matchingKey = Object.keys(current[0]).find((k) => k.toLowerCase() === key.toLowerCase()); if (matchingKey !== undefined) { resolved.push(matchingKey); current = current[0][matchingKey]; } else { return undefined; } } else { return undefined; } continue; } if (_$4.isObjectLike(current)) { const matchingKey = Object.keys(current).find((k) => k.toLowerCase() === key.toLowerCase()); if (matchingKey !== undefined) { resolved.push(matchingKey); current = current[matchingKey]; } else { return undefined; } } else { return undefined; } } // Fast path: array input or no brackets in input — plain dot-joined output if (!inputStr || !inputStr.includes("[")) return resolved.join("."); // Mirror bracket vs. dot notation from the input when building the output. // Walk the original string in parallel with the resolved keys: wherever the // input had `[key]` we emit `[resolvedKey]`, otherwise `.resolvedKey`. let result = ""; let pos = 0; for (let i = 0; i < resolved.length; i++) { // skip separators (dot after a `]`, or the `]` itself) while (pos < inputStr.length && (inputStr[pos] === "." || inputStr[pos] === "]")) pos++; const useBracket = inputStr[pos] === "["; if (useBracket) pos++; // skip `[` // skip past the key characters in the input while (pos < inputStr.length && inputStr[pos] !== "." && inputStr[pos] !== "[" && inputStr[pos] !== "]") pos++; if (i === 0) result = resolved[i]; else result += useBracket ? `[${resolved[i]}]` : `.${resolved[i]}`; } return result; } /** * 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. * * This function supports deep access to cookie and set-cookie headers, e.g. * `requestHeaders.cookie.authorization`. Cookie headers are parsed and the value * of the specified cookie is returned. If the cookie is not found, undefined is returned. * * @example * get_i(obj, "obj.key.token") * get_i(obj, ["obj", "key", "token"]) * get_i(obj, "obj.key[0].token") * get_i(obj, "obj.key.0.token") * get_i(obj, "requestHeaders.cookie.authorization") * get_i(obj, "requestHeaders.set-cookie.authorization") * * @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; // Handle case where obj itself is an array of strings with a single key lookup const keys = _$4.isArray(keyPath) ? keyPath.filter((k) => !_$4.isEmpty(k)) : keyPath.split(/[.[\]]/g).filter((k) => !_$4.isEmpty(k)); if (keys.length === 1 && _$4.isArray(obj) && obj.length > 0 && _$4.isString(obj[0])) { const matchedString = obj.find((item) => _$4.isString(item) && item.toLowerCase() === keys[0].toLowerCase()); if (matchedString !== undefined) { return matchedString; } } const sensitivePath = toSensitiveObjectKeyPath(obj, keyPath); let direct = undefined; // Try direct access first if we have a valid path if (sensitivePath != null) { direct = _$4.get(obj, sensitivePath); if (direct !== undefined) return direct; } // Handle cookie and set-cookie deep access, e.g. requestHeaders.cookie.authorization if (!keys || keys.length === 0) return undefined; const indexOfKey = (arr, val) => arr.findIndex((k) => k.toLowerCase() === val.toLowerCase()); const cookieIdx = indexOfKey(keys, "cookie"); const setCookieIdx = indexOfKey(keys, "set-cookie"); // Helper to resolve the real path up to a certain index (inclusive) const resolvePathUpTo = (idx) => { const part = keys.slice(0, idx + 1); return toSensitiveObjectKeyPath(obj, part) ?? part.join("."); }; // requestHeaders.cookie.<name> if (cookieIdx >= 0) { const parentPath = resolvePathUpTo(cookieIdx); const cookieHeader = parentPath ? _$4.get(obj, parentPath) : undefined; const cookieName = keys[cookieIdx + 1]; if (cookieHeader == null) return undefined; if (!cookieName) return cookieHeader; // return full header if no name // Parse Cookie header string into key/value if (_$4.isString(cookieHeader)) { const parsed = libCookie__namespace.parse(cookieHeader); const matchKey = Object.keys(parsed).find((k) => k.toLowerCase() === cookieName.toLowerCase()); return matchKey ? parsed[matchKey] : undefined; } return undefined; } // headers.set-cookie.<name> if (setCookieIdx >= 0) { const parentPath = resolvePathUpTo(setCookieIdx); const setCookieHeader = parentPath ? _$4.get(obj, parentPath) : undefined; const cookieName = keys[setCookieIdx + 1]; if (setCookieHeader == null) return undefined; if (!cookieName) return setCookieHeader; // return full header if no name // Parse Set-Cookie header (array or string) const headerInput = _$4.isString(setCookieHeader) ? setCookieParser__namespace.splitCookiesString(setCookieHeader) : setCookieHeader; const cookies = setCookieParser__namespace.parse(headerInput, { decodeValues: false, }); const found = (cookies || []).find((c) => c?.name?.toLowerCase() === cookieName.toLowerCase()); return found?.value; } // Handle arrays of strings with case-insensitive matching // For paths like "headers.authorization" where headers is ["Content-Type", "Authorization"] for (let i = 0; i < keys.length; i++) { const parentPath = resolvePathUpTo(i); const parentValue = parentPath ? _$4.get(obj, parentPath) : undefined; if (_$4.isArray(parentValue) && parentValue.length > 0 && _$4.isString(parentValue[0])) { const searchKey = keys[i + 1]; if (searchKey) { const index = parseInt(searchKey); if (isNaN(index)) { // Non-numeric key, try to find case-insensitive match in string array const matchedString = parentValue.find((item) => _$4.isString(item) && item.toLowerCase() === searchKey.toLowerCase()); // Only return a match when this segment is the final path segment if (matchedString !== undefined && i + 1 === keys.length - 1) { return matchedString; } } } } } return direct; } /** * 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; /** * 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.fileExtension = "json"; this.folder = path__namespace.isAbsolute(folder) ? folder : this.toAbsolutePath(folder); this.enabledExtensions = [`.${this.fileExtension}`, ".yaml", ".yml"]; if (options?.enableJavaScript) { this.enabledExtensions.push(".js", ".cjs"); } this.id = options?.id || "fileadapter"; this.log = debug(`c8y:${this.id}`); } description() { return `C8yPactDefaultFileAdapter: ${this.folder}`; } getFolder() { return this.folder; } loadPacts() { const pactObjects = this.loadPactObjects(); this.log(`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) { this.log(`loadPact() - ${id}`); const pId = pactId(id); if (pId == null) { this.log(`loadPact() - invalid pact id ${id} -> ${pId}`); return null; } if (!this.folder || !fs__namespace.existsSync(this.folder)) { this.log(`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) { this.log(`loadPact() - ${file} loaded successfully`); return loadedPact; } } catch (error) { this.log(`loadPact() - error loading ${file}: ${error}`); } } } this.log(`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) { this.log(`savePact() - invalid pact id ${pact.id} -> ${pId}`); return; } const file = path__namespace.join(this.folder, `${pId}.${this.fileExtension}`); this.log(`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) { this.log(`deletePact() - invalid pact id ${id} -> ${pId}`); return; } const filePath = path__namespace.join(this.folder, `${pId}.${this.fileExtension}`); if (fs__namespace.existsSync(filePath)) { fs__namespace.unlinkSync(filePath); this.log(`deletePact() - deleted ${filePath}`); } else { this.log(`deletePact() - ${filePath} does not exist`); } } readPactFiles() { this.log(`readPactFiles() - ${this.folder}`); if (!this.folder || !fs__namespace.existsSync(this.folder)) { this.log(`readPactFiles() - ${this.folder} does not exist`); return []; } const pacts = this.loadPactObjects(); return pacts.map((pact) => { return safeStringify(pact); }); } /** * @deprecated Use readPactFiles() instead. */ readJsonFiles() { this.log(`readJsonFiles() - ${this.folder}`); if (!this.folder || !fs__namespace.existsSync(this.folder)) { this.log(`readJsonFiles() - ${this.folder} does not exist`); return []; } const jsonFiles = glob__namespace.sync(path__namespace.join(this.folder, `*.${this.fileExtension}`)); this.log(`readJsonFiles() - reading ${jsonFiles.length} ${this.fileExtension} 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)) { this.log(`deleteJsonFiles() - ${this.folder} does not exist`); return; } const jsonFiles = glob__namespace.sync(path__namespace.join(this.folder, `*.${this.fileExtension}`)); this.log(`deleteJsonFiles() - deleting ${jsonFiles.length} ${this.fileExtension} files from ${this.folder}`); jsonFiles.forEach((file) => { fs__namespace.unlinkSync(file); }); } loadPactObjects() { this.log(`loadPactObjects() - ${this.folder}`); if (!this.folder || !fs__namespace.existsSync(this.folder)) { this.log(`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); this.log(`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) { this.log(`loadPactObjects() - error loading ${file}: ${error}`); return null; } }) .filter(Boolean); this.log(`loadPactObjects() - loaded ${pactObjects.length} valid pact objects`); return pactObjects; } loadPactFromFile(filePath) { if (!fs__namespace.existsSync(filePath)) { this.log(`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)) { this.log(`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 === `.${this.fileExtension}`) { // 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) { this.log(`loadPactFromFile() - error loading ${extension} file ${absolutePath}: ${error}`); } } } catch (error) { this.log(`loadPactFromFile() - error parsing file ${filePath}: ${error}`); } return null; } createFolderRecursive(f) { this.log(`createFolderRecursive() - ${f}`); if (!f || !_$2.isString(f)) return undefined; const absolutePath = !path__namespace.isAbsolute(f) ? this.toAbsolutePath(f) : f; if (f !== absolutePath) { this.log(`createFolderRecursive() - resolved ${f} to ${absolutePath}`); } if (fs__namespace.existsSync(f)) return undefined; const result = fs__namespace.mkdirSync(absolutePath, { recursive: true }); if (result) { this.log(`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; } } /** * C8yPactHARFileAdapter converts between C8yPact format and HAR (HTTP Archive) format. * This allows using external HAR tooling with C8yPact recordings. * * This adapter extends C8yPactDefaultFileAdapter to reuse folder management and utility * methods, but only supports .har file extension for reading and writing. * * When saving, pacts are converted to HAR format. When loading, HAR files are converted * back to C8yPact format. Some metadata may be stored in the comment fields to preserve * C8yPact-specific information. */ class C8yPactHARFileAdapter extends C8yPactDefaultFileAdapter { constructor(folder) { // Call parent constructor without JavaScript support super(folder, { enableJavaScript: false, id: "harfileadapter" }); this.id = "harfileadapter"; // Override enabled extensions to only support HAR files this.fileExtension = "har"; this.enabledExtensions = [`.${this.fileExtension}`]; } description() { return `C8yPactHarFileAdapter: ${this.folder}`; } savePact(pact) { this.createFolderRecursive(this.folder); const pId = pactId(pact.id); if (pId == null) { this.log(`savePact() - invalid pact id ${pact.id} -> ${pId}`); return; } const file = path__namespace.join(this.folder, `${pId}.${this.fileExtension}`); this.log(`savePact() - write ${file} (${pact.records?.length || 0} records)`); try { const har = this.pactToHAR(pact); fs__namespace.writeFileSync(file, safeStringify(har, 2), "utf-8"); } catch (error) { console.error(`Failed to save pact as HAR.`, error); } } /** * Override parent's loadPactFromFile to handle HAR format conversion. * This is called by parent's loadPactObjects for each .har file found. */ loadPactFromFile(filePath) { if (!fs__namespace.existsSync(filePath)) { this.log(`loadPactFromFile() - file does not exist: ${filePath}`); return null; } const extension = path__namespace.extname(filePath).toLowerCase(); // Only handle .har files if (extension !== `.${this.fileExtension}`) { this.log(`loadPactFromFile() - file extension ${extension} is not supported: ${filePath}`); return null; } try { const harContent = fs__namespace.readFileSync(filePath, "utf-8"); const har = JSON.parse(harContent); const filename = path__namespace.basename(filePath, `.${this.fileExtension}`); const pact = this.harToPact(har, filename); if (pact) { this.log(`loadPactFromFile() - ${filePath} loaded successfully`); } return pact; } catch (error) { this.log(`loadPactFromFile() - error loading ${filePath}: ${error}`); return null; } } /** * Override parent's loadPactObjects to use simpler glob pattern for .har files. * The parent's brace expansion pattern doesn't work well with single extensions. */ loadPactObjects() { this.log(`loadPactObjects() - ${this.folder}`); if (!this.folder || !fs__namespace.existsSync(this.folder)) { this.log(`loadPactObjects() - ${this.folder} does not exist`); return []; } const harFiles = glob__namespace.sync(path__namespace.join(this.folder, `*.${this.fileExtension}`)); this.log(`loadPactObjects() - reading ${harFiles.length} .${this.fileExtension} files from ${this.folder}`); const pactObjects = harFiles .map((file) => { try { return this.loadPactFromFile(file); } catch (error) { this.log(`loadPactObjects() - error loading ${file}: ${error}`); return null; } }) .filter(Boolean); this.log(`loadPactObjects() - loaded ${pactObjects.length} valid pact objects`); return pactObjects; } /** * Convert a C8yPact object to HAR format */ pactToHAR(pact) { const entries = (pact.records || []).map((record) => { const request = record.request; const response = record.response; // Parse URL to extract query string parameters and ensure absolute URL const requestUrl = request.url || ""; let absoluteUrl = requestUrl; let queryString = []; try { // Parse URL with baseUrl to ensure it's absolute const urlObj = new URL(requestUrl, pact.info?.baseUrl || "http://localhost"); absoluteUrl = urlObj.href; queryString = Array.from(urlObj.searchParams.entries()).map(([name, value]) => ({ name, value })); } catch { // If URL parsing fails, try to make it absolute if it starts with / if (requestUrl.startsWith("/") && pact.info?.baseUrl) { try { const baseUrl = pact.info.baseUrl.replace(/\/$/, ""); absoluteUrl = baseUrl + requestUrl; } catch { // Keep original URL if all fails } } } // Convert headers from object to HAR format const requestHeaders = request.headers ? Object.entries(request.headers).flatMap(([name, value]) => { if (Array.isArray(value)) { return value.map((v) => ({ name, value: String(v) })); } return [{ name, value: String(value) }]; }) : []; const responseHeaders = response.headers ? Object.entries(response.headers).flatMap(([name, value]) => { if (Array.isArray(value)) { return value.map((v) => ({ name, value: String(v) })); } return [{ name, value: String(value) }]; }) : []; // Handle request body let postData; let requestBodySize = 0; if (request.body != null || request.$body != null) { const bodyData = request.$body || request.body; const headers = request.headers; const contentType = headers?.["content-type"] || headers?.["Content-Type"] || "application/json"; const bodyText = typeof bodyData === "string" ? bodyData : safeStringify(bodyData); requestBodySize = bodyText ? bodyText.length : 0; postData = { mimeType: String(contentType), text: bodyText, }; } // Handle response body const responseBody = response.$body || response.body; const respHeaders = response.headers; const responseContentType = respHeaders?.["content-type"] || respHeaders?.["Content-Type"] || "application/json"; const responseText = typeof responseBody === "string" ? responseBody : safeStringify(responseBody); const responseContent = { size: responseText ? responseText.length : 0, mimeType: String(responseContentType), text: responseText, }; // Create the HAR entry with C8yPact metadata in comments const entry = { startedDateTime: new Date().toISOString(), time: response.duration || 0, request: { method: String(request.method || "GET").toUpperCase(), url: absoluteUrl, httpVersion: "HTTP/1.1", cookies: [], headers: requestHeaders, queryString: queryString, postData: postData, headersSize: -1, bodySize: requestBodySize, }, response: { status: response.status || 200, statusText: response.statusText || "", httpVersion: "HTTP/1.1", cookies: [], headers: responseHeaders, content: responseContent, redirectURL: "", headersSize: -1, bodySize: responseContent.size, }, cache: {}, timings: { send: -1, wait: response.duration || 0, receive: -1, }, comment: safeStringify({ c8ypact: { id: record.id, auth: record.auth, options: record.options, createdObject: record.createdObject, }, }), }; return entry; }); const har = { log: { version: "1.2", creator: { name: pact.info?.producer ? typeof pact.info.producer === "string" ? pact.info.producer : pact.info.producer.name : "C8yPact", version: pact.info?.version?.c8ypact || "1.0.0", }, entries: entries, comment: safeStringify({ c8ypact: { id: pact.id, info: { ...pact.info, // Don't duplicate large fields that are in entries }, }, }), }, }; return har; } /** * Convert a HAR format to C8yPact object */ harToPact(har, id) { try { // Extract C8yPact metadata from comment if available let pactMetadata = {}; try { if (har.log.comment) { const parsed = JSON.parse(har.log.comment); pactMetadata = parsed.c8ypact || {}; } } catch { // Ignore comment parsing errors } const baseUrl = pactMetadata.info?.baseUrl; const pactId = pactMetadata.id || id; const records = har.log.entries.map((entry) => { // Extract C8yPact metadata from entry comment if available let recordMetadata = {}; try { if (entry.comment) { const parsed = JSON.parse(entry.comment); recordMetadata = parsed.c8ypact || {}; } } catch { // Ignore comment parsing errors } // Convert HAR headers to object format const requestHeaders = {}; entry.request.headers.forEach((header) => { requestHeaders[header.name] = header.value; }); const responseHeaders = {}; entry.response.headers.forEach((header) => { responseHeaders[header.name] = header.value; }); // Parse request body let requestBody; if (entry.request.postData?.text) { try { // Try to parse as JSON if (entry.request.postData.mimeType.includes("application/json")) { requestBody = JSON.parse(entry.request.postData.text); } else { requestBody = entry.request.postData.text; } } catch { requestBody = entry.request.postData.text; } } // Parse response body let responseBody; if (entry.response.content.text) { try { // Try to parse as JSON if (entry.response.content.mimeType.includes("application/json")) { responseBody = JSON.parse(entry.response.content.text); } else { responseBody = entry.response.content.text; } } catch { responseBody = entry.response.content.text; } } return { id: recordMetadata.id, request: { method: entry.request.method, url: removeBaseUrlFromString(entry.request.url, baseUrl), headers: requestHeaders, body: requestBody, }, response: { status: entry.response.status, statusText: entry.response.statusText, headers: responseHeaders, body: responseBody, duration: entry.time, isOkStatusCode: entry.response.status >= 200 && entry.response.status < 300, }, auth: recordMetadata.auth, options: recordMetadata.options, createdObject: recordMetadata.createdObject, }; }); // Reconstruct pact info from HAR metadata const info = { ...pactMetadata.info, id: pactId, producer: { name: har.log.creator.name, version: har.log.creator.version, }, version: { c8ypact: har.log.creator.version }, baseUrl: pactMetadata.info?.baseUrl || "", }; const pact = { id: pactId, info: info, records: records, }; return pact; } catch (error) { this.log(`harToPact() - error converting HAR to pact: ${error}`); return null; } } } /// <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) || "token" in obj)); } // map from case insensitive auth type to C8yAuthOptionType function getAuthType(auth) { const type = _$4.isString(auth) ? auth.toLowerCase() : auth?.type?.toLowerCase(); if (type === "bearerauth") { return "BearerAuth"; } if (type === "basicauth") { return "BasicAuth"; } if (type === "cookieauth") { return "CookieAuth"; } return undefined; } function toPactAuthObject(obj) { return _$4.pick(obj, C8yPactAuthObjectKeys); } function isPactAuthObject(obj) { return (_$4.isObjectLike(obj) && ("user" in obj || "token" in obj) && ("userAlias" in obj || "type" in obj || "token" in obj) && Object.keys(obj).every((key) => ["token", ...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; } /** * Extracts the authentication options from a JWT token. * @param jwtToken The JWT token to extract the authentication options from. * @returns The extracted authentication options. */ function getAuthOptionsFromJWT(jwtToken) { try { const payload = JSON.parse(atob(jwtToken.split(".")[1])); // Remove all characters not valid in JWT tokens (base64url: A-Z, a-z, 0-9, -, _, .) const cleanedToken = jwtToken?.replace(/[^A-Za-z0-9\-_.]/g, ""); return { token: cleanedToken, xsrfToken: payload.xsrfToken, tenant: payload.ten, user: payload.sub, baseUrl: normalizeBaseUrl(payload.aud ?? payload.iss), }; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to decode JWT token: ${message}`); } } /** * Default implementation of C8yPactRecord. Use C8yDefaultPactRecord.from to create * a C8yPactRecord from a Cypress.Response object or an C8yPactRecord object. */ class C8yDefaultPactRecord { constructor(requestOrParams, response, options, auth, createdObject