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