UNPKG

cumulocity-cypress

Version:
407 lines (406 loc) 14.7 kB
import _ from "lodash"; import { getAuthOptionsFromBasicAuthHeader } from "./auth"; import { BasicAuth, FetchClient, } from "@c8y/client"; import { isCypressResponse, isPactRecord, } from "./c8ypact/c8ypact"; import * as setCookieParser from "set-cookie-parser"; import { get_i } from "./util"; export async function wrapFetchRequest(url, fetchOptions, logOptions) { // client.tenant.current() does add content-type header for some reason. probably mistaken accept header. // as this is not required, remove it to avoid special handling in pact matching against recordings // not created by c8y/client. if (_.endsWith(toUrlString(url), "/tenant/currentTenant")) { // @ts-expect-error fetchOptions.headers = _.omit(fetchOptions.headers, ["content-type"]); } else { // add json content type if body is present and content-type is not set const method = fetchOptions?.method || "GET"; if (fetchOptions?.body && method !== "GET" && method != "HEAD") { fetchOptions.headers = { "content-type": "application/json", ...fetchOptions.headers, }; } } const startTime = Date.now(); const fetchFn = _.get(globalThis, "fetchStub") || globalThis.fetch; const fetchPromise = fetchFn(url, fetchOptions); const duration = Date.now() - startTime; const options = { url, fetchOptions, logOptions, duration, }; return fetchPromise .then(async (response) => { const res = await wrapFetchResponse(response, options); if (_.isFunction(logOptions?.logger?.end)) logOptions?.logger?.end(); return Promise.resolve(res); }) .catch(async (response) => { const res = await wrapFetchResponse(response, options); if (_.isFunction(logOptions?.logger?.end)) logOptions?.logger?.end(); return Promise.reject(res); }); } export async function wrapFetchResponse(response, options = {}) { // only wrap valid responses or new Response() will fail later if (response.status == null || response.status < 200 || response.status > 599) { return response; } const responseObj = await (async () => { return toCypressResponse(response, options.duration, options.fetchOptions, options.url); })(); if (!responseObj) return response; let rawBody = undefined; if (response.data) { responseObj.body = response.data; rawBody = _.isObject(responseObj.body) ? JSON.stringify(responseObj.body) : responseObj.body; } else if (response.body) { try { rawBody = await response.text(); responseObj.body = JSON.parse(rawBody); } catch { responseObj.body = rawBody; } } // empty body ("") is not allowed, make sure to use undefined instead if (_.isEmpty(rawBody)) { rawBody = undefined; } const fetchOptions = options?.fetchOptions ?? {}; const logOptions = options?.logOptions; try { responseObj.requestBody = fetchOptions && _.isString(fetchOptions?.body) ? JSON.parse(fetchOptions.body) : fetchOptions?.body; } catch { responseObj.requestBody = fetchOptions?.body; } // res.ok = response.ok, responseObj.method = fetchOptions?.method || response.method || "GET"; if (logOptions?.consoleProps) { _.extend(logOptions.consoleProps, updateConsoleProps(responseObj, fetchOptions, logOptions)); } // create a new window.Response for Client. this is required as the body // stream can not be read more than once. as we just read it, recreate the response // and resolve json() and text() promises using the values we read from the stream. const result = new Response(rawBody, { status: response.status, statusText: response.statusText, headers: response.headers, }); // pass the responseObj as part of the window.Response object. this way we can access // in the Clients response and do not need to reprocess result.responseObj = responseObj; result.requestBody = responseObj.requestBody; result.method = responseObj.method; result.data = responseObj.body; // result.json = () => Promise.resolve(responseObj.body); // result.text = () => Promise.resolve(rawBody || ""); return result; } function updateConsoleProps(responseObj, fetchOptions, logOptions, url) { const props = {}; const cookieAuth = (responseObj.requestHeaders && responseObj.requestHeaders["X-XSRF-TOKEN"]) || undefined; const basicAuth = (responseObj.requestHeaders && responseObj.requestHeaders["Authorization"]) || undefined; // props["Options"] = options; if (cookieAuth) { const loggedInUser = logOptions?.loggedInUser || ""; props["CookieAuth"] = `XSRF-TOKEN ${cookieAuth} (${loggedInUser})`; } if (basicAuth) { const auth = getAuthOptionsFromBasicAuthHeader(basicAuth); if (auth?.user) { props["BasicAuth"] = `${basicAuth} (${auth.user})`; } } props["Options"] = fetchOptions; props["Request"] = { responseBody: responseObj.body, responseStatus: responseObj.status, requestHeaders: responseObj.requestHeaders, requestBody: fetchOptions?.body || "", responseHeaders: responseObj.headers || [], requestURL: responseObj.url || url, }; props["Yielded"] = responseObj; return props; } /** * Converts the given URL to a string. * @param url The URL or RequestInfo to convert. * @returns The URL as a string. */ export function toUrlString(url) { if (_.isString(url)) { return url; } else if (url instanceof URL) { return url.toString(); } else if (url instanceof Request) { return url.url; } else { throw new Error(`Type for URL not supported. Expected URL, string or Request, but found $'{typeof url}}'.`); } } /** * Converts the given object to a Cypress.Response. * @param obj The object to convert. * @param duration The duration of the request. * @param fetchOptions The fetch options used for the request. * @param url The URL of the request. * @param schema The schema of the response. */ export function toCypressResponse(obj, duration = 0, fetchOptions = {}, url, schema) { if (!obj) return undefined; if (typeof isPactRecord === "function" && isPactRecord(obj)) { return obj.toCypressResponse(); } let fetchResponse; if (isIResult(obj)) { fetchResponse = obj.res; } else if (isWindowFetchResponse(obj)) { fetchResponse = obj; } else { fetchResponse = obj; } if ("responseObj" in fetchResponse) { return _.get(fetchResponse, "responseObj"); } return { status: fetchResponse.status, isOkStatusCode: fetchResponse.ok || (fetchResponse.status > 199 && fetchResponse.status < 300), statusText: fetchResponse.statusText, headers: Object.fromEntries(fetchResponse.headers || []), requestHeaders: fetchOptions.headers, duration: duration, ...(url && { url: toUrlString(url) }), allRequestResponses: [], body: fetchResponse.data, requestBody: fetchResponse.requestBody, method: fetchResponse.method || "GET", ...(schema && { $body: schema }), }; } /** * Converts a Cypress.Response or C8yPactRecord to a window.Response. If * the given object is not a Cypress.Response or C8yPactRecord, undefined * is returned. * @param obj The object to check. */ export function toWindowFetchResponse(obj) { if (isPactRecord(obj)) { const body = _.isObjectLike(obj.response.body) ? JSON.stringify(obj.response.body) : obj.response.body; return new window.Response(body, { status: obj.response.status, statusText: obj.response.statusText, url: obj.request.url, ...(obj.response.headers && { headers: toResponseHeaders(obj.response.headers), }), ...(obj.request.url && { url: obj.request.url }), }); } if (isCypressResponse(obj)) { return new window.Response(obj.body, { status: obj.status, statusText: obj.statusText, headers: toResponseHeaders(obj.headers), ...(obj.url && { url: obj.url }), }); } return undefined; } /** * Converts the given headers to a window.Headers object. * @param headers The headers object to convert. */ export function toResponseHeaders(headers) { // type HeadersInit = [string, string][] | Record<string, string> | Headers; const arr = []; for (const [key, value] of Object.entries(headers)) { if (Array.isArray(value)) { value.forEach((v) => arr.push([key, v])); } else { arr.push([key, value]); } } return new Headers(arr); } /** * Checks if the given object is a window.Response. * @param obj The object to check. */ export function isWindowFetchResponse(obj) { return (obj != null && _.isObjectLike(obj) && "status" in obj && "statusText" in obj && "headers" in obj && "body" in obj && "url" in obj && _.isFunction(_.get(obj, "json")) && _.isFunction(_.get(obj, "arrayBuffer"))); } /** * Checks if the given object is an IResult. * @param obj The object to check. */ export function isIResult(obj) { return (obj != null && _.isObjectLike(obj) && "data" in obj && "res" in obj && isWindowFetchResponse(obj.res)); } /** * Checks if the given object is a CypressError. * @param error The object to check. * @returns True if the object is a CypressError, false otherwise. */ export function isCypressError(error) { return _.isError(error) && _.get(error, "name") === "CypressError"; } export 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 (_.isString(setCookie)) { cookieHeader = setCookieParser.splitCookiesString(setCookie); } else if (_.isArrayLike(setCookie)) { cookieHeader = setCookie; } } else { if (_.isPlainObject(response.headers)) { cookieHeader = get_i(response.headers, "set-cookie"); } } } if (!cookieHeader) return undefined; let authorization = undefined; let xsrfToken = undefined; setCookieParser.parse(cookieHeader || []).forEach((c) => { if (_.isEqual(c.name.toLowerCase(), "authorization")) { authorization = c.value; } if (_.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 (_.isEmpty(authorization)) { authorization = undefined; } } if (!xsrfToken) { xsrfToken = getCookieValue("XSRF-TOKEN") || getCookieValue("xsrf-token"); if (_.isEmpty(xsrfToken)) { xsrfToken = undefined; } } return { authorization, xsrfToken }; } export 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 FetchClient(baseUrl); const credentials = new 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 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 } = _.pick(cookies, [ "authorization", "xsrfToken", ]); auth = { ...auth, ...(authorization && { bearer: authorization }), ...(xsrfToken && { xsrfToken: xsrfToken }), }; return auth; } // from c8y/client FetchClient export function getCookieValue(name) { if (typeof document === "undefined") return undefined; const value = document.cookie.match("(^|;)\\s*" + name + "\\s*=\\s*([^;]+)"); return value ? value.pop() : ""; }