cumulocity-cypress
Version:
Cypress commands for Cumulocity IoT
699 lines (698 loc) • 30.2 kB
JavaScript
import _ from "lodash";
import { inspect } from "util";
import os from "node:os";
import express from "express";
import getRawBody from "raw-body";
import cookieParser from "cookie-parser";
import winston from "winston";
import morgan from "morgan";
import { C8yDefaultPact, pactId, C8yPactRecordingModeValues, C8yPactModeValues, isPact, isCypressResponse, toSerializablePactRecord, } from "../c8ypact";
import { C8yPactHttpControllerLogLevel, } from "./httpcontroller-options";
import { addC8yCtrlHeader, createMiddleware, wrapPathIgnoreHandler, } from "./middleware";
import { oauthLogin } from "../oauthlogin";
import fs from "fs";
import path from "path";
import { isVersionSatisfyingRequirements } from "../versioning";
import { safeStringify, to_boolean } from "../util";
import { getPackageVersion } from "../util-node";
import swaggerUi from "swagger-ui-express";
import yaml from "yaml";
import debug from "debug";
const log = debug("c8y:ctrl:http");
export 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 (!this.isMockingEnabled()) {
return next();
}
let response = undefined;
const record = this.currentPact?.nextRecordMatchingRequest(req, this.baseUrl);
if (_.isFunction(this.options.on.mockRequest)) {
response = this.options.on.mockRequest(this, req, record);
if (!response && record) {
addC8yCtrlHeader(res, "x-c8yctrl-type", "skipped");
return next();
}
}
else {
response = record?.response;
}
if (response != null) {
addC8yCtrlHeader(response, "x-c8yctrl-mode", this.recordingMode);
}
if (!record && !response) {
if (this._isStrictMocking) {
if (_.isFunction(this.options.on.mockNotFound)) {
const r = this.options.on.mockNotFound(this, req);
if (r != null) {
response = r;
}
}
if (response == null && this.options.mockNotFoundResponse) {
const r = this.options.mockNotFoundResponse;
response = _.isFunction(r) ? r(req) : r;
}
else if (response == null) {
response = {
status: 404,
statusText: "Not Found",
body: `<html>\n<head><title>404 Recording Not Found</title></head>` +
`\n<body bgcolor="white">\n<center><h1>404 Recording Not Found</h1>` +
`</center>\n<hr><center>cumulocity-cypress-ctrl/${this.constructor.name}</center>` +
`\n</body>\n</html>\n`,
headers: {
"content-type": "text/html",
},
};
}
addC8yCtrlHeader(response, "x-c8yctrl-type", "notfound");
}
}
if (!response) {
this.logger.error(`No response for ${req.method} ${req.url}`);
return next();
}
const responseBody = _.isString(response?.body)
? response?.body
: this.stringify(response?.body);
if (res.hasHeader("transfer-encoding")) {
res.removeHeader("transfer-encoding");
res.removeHeader("Transfer-Encoding");
}
res.setHeader("content-length", Buffer.byteLength(responseBody));
response.headers = _.defaults(response?.headers, _.pick(response?.headers, ["content-type", "set-cookie"]));
res.writeHead(response?.status || 200, _.omit(response?.headers || {}, "content-length", "date", "connection"));
res.end(responseBody);
};
this.stringifyReplacer = (key, value) => {
if (!_.isString(value))
return value;
const replaceProperties = ["self", "next", "initRequest"];
if (replaceProperties.includes(key) && value.startsWith("http")) {
// replace url host with localhost
const newHost = `http://${this.hostname ?? "localhost"}:${this.port}`;
value = value.replace(/https?:\/\/[^/]+/, newHost);
}
return value;
};
this.options = options;
this.adapter = options.adapter;
this.port = options.port || 3000;
this.hostname = options.hostname;
this._isStrictMocking = options.strictMocking || true;
this.resourcePath = options.resourcePath || "/c8yctrl";
this._baseUrl = options.baseUrl;
this._staticRoot = options.staticRoot;
this.currentPact = undefined;
this.tenant = options.tenant;
this.mode = options.mode || "apply";
this.recordingMode = options.recordingMode || "append";
const loggerOptions = {
format: winston.format.simple(),
transports: [new winston.transports.Console()],
};
this.logger = this.options.logger || winston.createLogger(loggerOptions);
this.logger.level = options.logLevel || "info";
const loggerStream = {
write: (message) => {
this.logger.info(message.trim());
},
};
if (this.adapter) {
this.logger.info(`Adapter: ${this.adapter.description()}`);
}
this.app = express();
if (this.options.requestLogger) {
let rls = this.options.requestLogger;
if (_.isFunction(rls)) {
rls = rls(this.logger);
}
if (!_.isArrayLike(rls)) {
rls = [rls];
}
log("RequestLogger", rls);
rls.forEach((h) => this.app.use(h));
}
else {
this.app.use(morgan((options.logFormat || "short"), { stream: loggerStream }));
}
// register cookie parser
this.app.use(cookieParser());
this.authOptions = options.auth;
}
/**
* Base URL of the target server to proxy requests to.
* @example "https://mytenant.eu-latest.cumulocity.com"
*/
get baseUrl() {
return this._baseUrl;
}
/**
* Root folder for static files to serve. The controller will serve static files from this folder.
* @example "/path/to/static/root"
*/
get staticRoot() {
return this._staticRoot;
}
get recordingMode() {
return this._recordingMode;
}
set recordingMode(mode) {
if (!_.isString(mode) || !C8yPactRecordingModeValues.includes(mode)) {
this.logger.warn(`Invalid recording mode: "${mode}". Ignoring and continuing with recording mode "${this.recordingMode}".`);
return;
}
this._recordingMode = mode;
}
get mode() {
return this._mode;
}
set mode(mode) {
if (!_.isString(mode) || !C8yPactModeValues.includes(mode)) {
this.logger.warn(`Invalid mode: "${mode}". Ignoring and continuing with mode "${this.mode}".`);
return;
}
this._mode = mode;
}
isRecordingEnabled() {
return ((this.mode === "record" || this.mode === "recording") &&
this.adapter != null &&
this.baseUrl != null);
}
isMockingEnabled() {
return this.mode === "apply" || this.mode === "mock";
}
/**
* Starts the server. When started, the server listens on the configured port and hostname. If required,
* the server will try to login to the target server using the provided credentials. If authOptions have
* a token, the server will use this token for authentication. To enforce BasicAuth, set the type
* property of the authOptions to "BasicAuth".
*/
async start() {
if (this.server) {
await this.stop();
}
if (this.authOptions && this.baseUrl) {
const { user, password, token, xsrfToken, type } = this.authOptions;
if (!_.isEqual(type, "BasicAuth") && !token && user && password) {
this.logger.info("Auth: BasicAuth");
try {
if (token == null || xsrfToken == null) {
const a = await oauthLogin(this.authOptions, this.baseUrl);
this.logger.info(`oauthLogin -> ${this.baseUrl} (${a.user})`);
_.extend(this.authOptions, _.pick(a, ["token", "xsrfToken"]));
}
}
catch (error) {
this.logger.error(`Login failed ${this.baseUrl} (${user})\n${inspect(error, {
depth: null,
})}`);
}
}
else if (token != null) {
this.logger.info("Auth: BearerAuth");
}
}
if (!this.authOptions) {
this.logger.debug(`No auth options provided. Not logging in.`);
}
await this.registerStaticRootRequestHandler();
this.registerOpenAPIRequestHandler();
const ignoredPaths = [this.resourcePath];
if (!this.mockHandler) {
this.mockHandler = this.app.use(wrapPathIgnoreHandler(this.mockRequestHandler, ignoredPaths));
}
this.app.use(wrapPathIgnoreHandler((req, res, next) => {
const that = this;
getRawBody(req, {
length: req.headers["content-length"],
}, function (err, chunk) {
if (err != null) {
if (err.type === "stream.not.readable") {
that.logger.warn(`stream.not.readable: Stream already consumed for ${req.method} ${req.url}`);
if (req.body) {
req.rawBody = _.isObjectLike(req.body)
? safeStringify(req.body)
: req.body;
}
}
else {
that.logger.warn(`Failed to parse request body: ${err}`);
}
}
else {
const rawBody = Buffer.concat([chunk]).toString("utf8");
if (rawBody != null) {
req.rawBody = rawBody;
}
}
next();
});
}, ignoredPaths));
// Express 5 compatibility: explicitly add body parsing middleware
this.app.use(express.json());
this.app.use(express.urlencoded({ extended: true }));
if (this.baseUrl) {
this.logger.info(`BaseURL: ${this.baseUrl}`);
// register proxy handler first requires to make the proxy ignore certain paths
// this is needed as bodyParser will break post requests in the proxy handler but
// is needed before any other handlers dealing with request bodies
const errorHandler = _.isString(this.options.errorLogger)
? morgan(this.options.errorLogger, morganErrorOptions(this.logger))
: this.options.errorLogger;
if (!this.proxyHandler) {
this.proxyHandler = this.app.use(createMiddleware(this, {
...this.options,
errorHandler,
}));
}
}
this.registerC8yctrlInterface();
// Express 5 compatible app. listen with error handling
return new Promise((resolve, reject) => {
const listenArgs = [this.port];
if (this.hostname != null) {
listenArgs.push(this.hostname);
}
listenArgs.push((error) => {
if (error) {
this.logger.error("Server failed to start:", error);
reject(error);
}
else {
const addr = this.server?.address();
if (addr && typeof addr === "object") {
const host = this.hostname ?? addr.address;
if (addr.address === "::" || addr.address === "0.0.0.0") {
const displayHost = addr.address; // Keep the actual bind address
const interfaces = os.networkInterfaces();
const externalIPv4 = Object.values(interfaces)
.flat()
.filter((iface) => iface && iface.family === "IPv4");
this.logger.info(`Listening on all interfaces`);
this.logger.info(` ${displayHost}:${addr.port}`);
for (const addrv4 of externalIPv4) {
if (!addrv4?.address || addrv4.address === displayHost)
continue;
this.logger.info(` http://${addrv4.address}:${addr.port}`);
}
}
else {
this.logger.info(`Listening on http://${host}:${addr.port}`);
}
}
resolve();
}
});
this.server = this.app.listen(...listenArgs);
});
}
/**
* Stops the server.
*/
async stop() {
await this.server?.close();
this.logger.info("Stopped server");
}
registerOpenAPIRequestHandler() {
try {
const openapiPath = path.join(path.dirname(__filename), "openapi.yaml");
log(`loading openapi from ${openapiPath}`);
const fileContent = fs.readFileSync(openapiPath, "utf-8");
const document = yaml.parse(fileContent);
this.app.use(`${this.resourcePath}/openapi/`, swaggerUi.serve, swaggerUi.setup(document));
this.logger.info(`OpenAPI: ${this.resourcePath}/openapi/`);
}
catch (error) {
log(`loading openapi failed: ${error.message}`);
this.logger.warn(`Failed to load OpenAPI document: ${error.message}`);
this.logger.debug(inspect(error, { depth: null }));
}
}
async registerStaticRootRequestHandler() {
if (!this.staticRoot)
return;
// register static root
this.logger.info(`Static Root: ${this.staticRoot}`);
const appsDir = path.join(this.staticRoot, "apps");
const subfolders = await fs.promises.readdir(appsDir, {
withFileTypes: true,
});
for (const folder of subfolders) {
if (!folder.isDirectory())
continue;
const cumulocityJsonPath = path.join(appsDir, folder.name, "cumulocity.json");
try {
const data = await fs.promises.readFile(cumulocityJsonPath, "utf-8");
const c = JSON.parse(data);
const version = c.version;
const contextPath = c.contextPath;
const relativePath = `/apps/${contextPath}`;
const semverRange = this.options.appsVersions?.[contextPath];
if (semverRange != null) {
if (!isVersionSatisfyingRequirements(version, [semverRange])) {
this.logger.debug(` ${relativePath} (${version}) does not satisfy version requirements ${semverRange}`);
continue;
}
}
if (this.staticApps[contextPath] != null) {
this.logger.debug(` ${contextPath} already registered. Skipping ${cumulocityJsonPath}`);
continue;
}
this.staticApps[contextPath] = version;
const info = version + (semverRange ? ": " + semverRange : "");
this.logger.info(` ${relativePath} (${info}) -> ${this.staticRoot}/apps/${folder.name}`);
const appFolder = path.join(this.staticRoot, "apps", folder.name);
log(`${relativePath} -> ${appFolder}`);
this.app.use(relativePath, express.static(appFolder));
}
catch (error) {
this.logger.error(`error reading or parsing ${cumulocityJsonPath}`);
this.logger.error(inspect(error, { depth: null }));
}
}
}
registerC8yctrlInterface() {
// head endpoint can be used to check if the server is running, e.g. by start-server-and-test package
this.app.head(this.resourcePath, (req, res) => {
res.sendStatus(200);
});
this.app.get(`${this.resourcePath}/status`, (req, res) => {
res.setHeader("content-type", "application/json");
res.send(safeStringify(this.getStatus()));
});
this.app.get(`${this.resourcePath}/current`, (req, res) => {
if (!this.currentPact) {
// return 404 instead of 204 to indicate that no pact is set
res.status(404).send("No current pact set");
return;
}
res.setHeader("content-type", "application/json");
res.send(this.stringifyPact(this.currentPact));
});
this.app.post(`${this.resourcePath}/current`, async (req, res) => {
// Express 5 compatibility: create a copy of query parameters
const bodyParams = req.body || {};
const parameters = { ...bodyParams, ...req.query };
const { mode, clear, recordingMode, strictMocking } = parameters;
const id = pactId(parameters.id) || pactId(parameters.title);
this.mode = mode;
this.recordingMode = recordingMode;
this._isStrictMocking = to_boolean(strictMocking, this._isStrictMocking);
if (!id || !_.isString(id)) {
res.status(404).send("Missing or invalid pact id");
return;
}
const refreshPact = this.recordingMode === "refresh" &&
this.isRecordingEnabled() === true &&
this.currentPact != null;
const clearPact = _.isString(clear) &&
(_.isEmpty(clear) || to_boolean(clear, false) === true);
this.logger.debug(`mode: ${this.mode}, recordingMode: ${this.recordingMode}, strictMocking: ${this._isStrictMocking}, refresh: ${refreshPact}, clear: ${clearPact}`);
let current = this.adapter?.loadPact(id);
if (!current && this.isRecordingEnabled()) {
const info = {
baseUrl: this.baseUrl || "",
tenant: this.tenant || "",
recordingMode: this.recordingMode,
requestMatching: this.options.requestMatching,
preprocessor: this.options.preprocessor?.options,
strictMocking: this._isStrictMocking,
..._.pick(req.body, [
"id",
"producer",
"consumer",
"version",
"title",
"tags",
"description",
]),
};
current = new C8yDefaultPact([], info, id);
this.currentPact = current;
res.status(201);
}
if (!current) {
res
.status(404)
.send(`Not found. Could not find pact with id ${_.escape(id)}. Enable recording to create a new pact.`);
return;
}
else {
this.currentPact = C8yDefaultPact.from(current);
}
if (refreshPact === true || clearPact === true) {
this.currentPact.clearRecords();
let shouldSave = true;
if (_.isFunction(this.options.on.savePact)) {
shouldSave = this.options.on.savePact(this, this.currentPact);
if (!shouldSave) {
this.logger.warn("Pact not saved. Disabled by on.savePact() even though refresh or clear was requested.");
}
}
if (shouldSave === true) {
await this.savePact(this.currentPact);
this.logger.debug(`Cleared pact (refresh: ${refreshPact} and clear: ${clearPact})`);
}
}
res.setHeader("content-type", "application/json");
res.send(this.stringifyPact(current)
// {
// ...this.currentPact,
// records: (this.currentPact?.records?.length || 0) as any,
// })
);
});
this.app.delete(`${this.resourcePath}/current`, (req, res) => {
this.currentPact = undefined;
res.sendStatus(204);
});
this.app.post(`${this.resourcePath}/current/clear`, async (req, res) => {
if (!this.currentPact) {
// return 204 instead of 404 to indicate that no pact is set
res.status(404).send("No current pact set");
return;
}
this.currentPact.clearRecords();
res.setHeader("content-type", "application/json");
res.send(this.stringifyPact(this.currentPact));
});
this.app.get(`${this.resourcePath}/current/request`, (req, res) => {
if (!this.currentPact) {
res.status(404).send("No current pact set");
return;
}
const { keys } = { ...req.query };
const queryKeys = Object.keys(req.query);
const result = this.getObjectWithKeys(this.currentPact.records.map((r) => r.request), keys ?? queryKeys);
res.setHeader("content-type", "application/json");
res.status(200).send(JSON.stringify(result, null, 2));
});
this.app.get(`${this.resourcePath}/current/response`, (req, res) => {
if (!this.currentPact) {
res.status(404).send("No current pact set");
return;
}
const { keys } = { ...req.query };
const queryKeys = Object.keys(req.query);
const result = this.getObjectWithKeys(this.currentPact.records.map((r) => {
return { ...r.response, url: r.request.url };
}), keys ?? queryKeys);
res.setHeader("content-type", "application/json");
res.status(200).send(JSON.stringify(result, null, 2));
});
// log endpoint
const logLevels = Object.values(C8yPactHttpControllerLogLevel);
this.app.get(`${this.resourcePath}/log`, (req, res) => {
res.setHeader("content-type", "application/json");
res.status(200).send(JSON.stringify({ level: this.logger.level }));
});
this.app.post(`${this.resourcePath}/log`, (req, res) => {
const bodyParams = req.body || {};
const parameters = { ...bodyParams, ...req.query };
const { message, level } = parameters;
if (level != null &&
(!_.isString(level) || !logLevels.includes(level.toLowerCase()))) {
res
.status(400)
.send(`Invalid log level. Use one of: ${logLevels.join(", ")}`);
return;
}
if (_.isString(message)) {
this.logger.log(level || "info", message);
}
res.sendStatus(204);
});
this.app.put(`${this.resourcePath}/log`, (req, res) => {
const bodyParams = req.body || {};
const parameters = { ...bodyParams, ...req.query };
const { level } = parameters;
if (_.isString(level) && logLevels.includes(level.toLowerCase())) {
this.logger.level = level.toLowerCase();
}
else {
res
.status(400)
.send(`Invalid log level. Use one of: ${logLevels.join(", ")}`);
return;
}
res.sendStatus(204);
});
}
getStatus() {
const status = {
status: "ok",
uptime: process.uptime(),
version: getPackageVersion(),
adapter: this.adapter?.description() || null,
baseUrl: this.baseUrl || null,
tenant: this.tenant || null,
current: {
id: this.currentPact?.id || null,
},
static: {
root: this.staticRoot || null,
required: this.options.appsVersions || null,
apps: this.staticApps || null,
},
mode: this.mode,
supportedModes: C8yPactModeValues,
recording: {
recordingMode: this.recordingMode,
supportedRecordingModes: C8yPactRecordingModeValues,
isRecordingEnabled: this.isRecordingEnabled(),
},
mocking: {
isMockingEnabled: this.isMockingEnabled(),
strictMocking: this._isStrictMocking,
},
logger: {
level: this.logger.level,
},
};
return status;
}
getStringifyReplacer() {
const configReplacer = this.options.stringifyReplacer;
return configReplacer && _.isFunction(configReplacer)
? configReplacer
: this.stringifyReplacer;
}
stringify(obj) {
if (!obj)
return "";
return JSON.stringify(obj, this.getStringifyReplacer(), 2);
}
stringifyPact(pact) {
const p = _.pick(pact, ["id", "info", "records"]);
return this.stringify(p);
}
producerForPact(pact) {
return _.isString(pact.info.producer)
? { name: pact.info.producer }
: pact.info.producer;
}
pactsForProducer(pacts, producer, version) {
if (!pacts)
return [];
return Object.keys(pacts)
.filter((key) => {
const p = pacts[key];
const n = _.isString(producer) ? producer : producer.name;
const v = _.isString(producer) ? version : producer.version;
const pactProducer = this.producerForPact(p);
if (!_.isUndefined(v) && !_.isEqual(v, pactProducer?.version))
return false;
if (!_.isEqual(n, pactProducer?.name))
return false;
return true;
})
.map((key) => pacts[key]);
}
async savePact(response, pactForId) {
let pact = undefined;
if (pactForId == null &&
(("records" in response && "info" in response && "id" in response) ||
isPact(response))) {
pact = new C8yDefaultPact(response.records, response.info, response.id);
}
if (pactForId != null) {
pact =
pactForId instanceof C8yDefaultPact
? pactForId
: C8yDefaultPact.from(pactForId);
}
if (pact == null && pactForId == null) {
pact = this.currentPact;
}
if (pact == null) {
this.logger.warn(`savePact(): Could not save pact. No pact provided to save the response.`);
return false;
}
if (this.adapter == null) {
this.logger.warn(`savePact(): Failed to save pact ${pact.id}. No adapter configured.`);
return false;
}
let result = false;
if (isCypressResponse(response)) {
const record = toSerializablePactRecord(response, {
preprocessor: this.options.preprocessor,
...(this.baseUrl && { baseUrl: this.baseUrl }),
});
if (this.recordingMode === "append" ||
this.recordingMode === "new" ||
// refresh is the same as append as for refresh we remove the pact in each tests beforeEach
this.recordingMode === "refresh") {
result =
result || pact.appendRecord(record, this.recordingMode === "new");
}
else if (this.recordingMode === "replace") {
result = result || pact.replaceRecord(record);
}
}
try {
// records might be empty for a new pact without having received a request
if (_.isEmpty(pact.records))
return false;
if (result === true) {
this.adapter?.savePact(pact);
}
}
catch (error) {
this.logger.error(`Failed to save pact ${error}`);
this.logger.error(inspect(error, { depth: null }));
result = false;
}
return result;
}
getObjectWithKeys(objs, keys) {
return objs.map((r) => {
const x = _.pick(r, keys);
if (keys.includes("size")) {
x.size = r.body ? this.stringify(r.body).length : 0;
}
return x;
});
}
}
export function morganErrorOptions(logger = undefined) {
const options = {
skip: (req, res) => {
return (res.statusCode < 400 || req.url.startsWith("/notification/realtime"));
},
};
if (logger != null) {
options.stream = {
write: (message) => {
logger.error(message.trim());
},
};
}
return options;
}