Files
metabase/e2e/support/helpers/e2e-embedding-helpers.js

407 lines
11 KiB
JavaScript

import * as jose from "jose";
import { METABASE_SECRET_KEY } from "e2e/support/cypress_data";
import {
embedModalContent,
embedModalEnableEmbedding,
legacyStaticEmbeddingButton,
} from "e2e/support/helpers/e2e-embedding-iframe-sdk-setup-helpers";
import { modal, popover } from "e2e/support/helpers/e2e-ui-elements-helpers";
import { JWT_SHARED_SECRET } from "e2e/support/helpers/embedding-sdk-helpers/constants";
import { openSharingMenu } from "./e2e-sharing-helpers";
/**
* @typedef {object} QuestionResource
* @property {number} question - ID of a question we are embedding
*
* @typedef {object} DashboardResource
* @property {number} dashboard - ID of a dashboard we are embedding
*
* @typedef {object} EmbedPayload
* @property {(QuestionResource|DashboardResource)} resource
* {@link QuestionResource} or {@link DashboardResource}
* @property {object} params
*
* @typedef {import("metabase/public/lib/types").EmbeddingAdditionalHashOptions} EmbeddingAdditionalHashOptions
*
* @typedef {EmbeddingAdditionalHashOptions['hide_parameters']} HiddenFilters
*
* @typedef {object} PageStyle
* @property {boolean} [bordered]
* @property {boolean} [titled]
* @property {boolean} [downloads] - EE/PRO only feature to disable downloads
*/
/**
* Programmatically generate token and visit the embedded page for a question or a dashboard
*
* @param {EmbedPayload} payload - The {@link EmbedPayload} we pass to this function
* @param {object} [options]
* @param {object} [options.setFilters]
* @param {PageStyle} [options.pageStyle]
* @param {object} [options.additionalHashOptions]
* @param {string} [options.additionalHashOptions.locale]
* @param {string[]} [options.additionalHashOptions.hideFilters]
* @param {(window: Window) => void} [options.onBeforeLoad]
* @param {object} [options.qs]
*
* @example
* visitEmbeddedPage(payload, {
* setFilters: {id: 92, source: "Organic"},
* pageStyle: {titled: true},
* hideFilters: ["id", "source"]
* });
*/
export function visitEmbeddedPage(payload, options) {
getEmbeddedPageUrl(payload, options).then((urlOptions) => {
// Always visit embedded page logged out
cy.signOut();
cy.visit(urlOptions);
});
}
/**
* Programmatically generate token for the embedded page for a question or a dashboard
*
* @param {EmbedPayload} payload - The {@link EmbedPayload} we pass to this function
* @param {object} options
* @param {object} [options.setFilters]
* @param {PageStyle} options.pageStyle
* @param {object} options.additionalHashOptions
* @param {string} [options.additionalHashOptions.locale]
* @param {string[]} [options.additionalHashOptions.hideFilters]
* @param {object} [options.qs]
*
* @example
* getEmbeddedPageUrl(payload, {
* setFilters: {id: 92, source: "Organic"},
* pageStyle: {titled: true},
* hideFilters: ["id", "source"]
* });
*/
export function getEmbeddedPageUrl(
payload,
{
setFilters = {},
additionalHashOptions: { hideFilters = [], locale, font } = {},
pageStyle = {},
onBeforeLoad,
qs,
} = {},
) {
const jwtSignLocation = "e2e/support/external/e2e-jwt-sign.js";
const payloadWithExpiration = {
...payload,
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
};
const stringifiedPayload = JSON.stringify(payloadWithExpiration);
const signTransaction = `node ${jwtSignLocation} '${stringifiedPayload}' ${METABASE_SECRET_KEY}`;
return cy.exec(signTransaction).then(({ stdout: tokenizedQuery }) => {
const embeddableObject = getEmbeddableObject(payload);
const hiddenFilters = getHiddenFilters(hideFilters);
const urlRoot = `/embed/${embeddableObject}/${tokenizedQuery}`;
const urlHash = getHash(
{
...pageStyle,
...(locale && { locale }),
...(font && { font }),
},
hiddenFilters,
);
return {
url: urlRoot,
qs: { ...setFilters, ...qs },
onBeforeLoad: (window) => {
onBeforeLoad?.(window);
if (urlHash) {
window.location.hash = urlHash;
}
},
};
});
/**
* Construct a hidden filters object from the list of filters we want to hide
*
* @param {string[]} filters
* @returns {HiddenFilters}
*/
function getHiddenFilters(filters) {
const params = filters.join(",");
return filters.length > 0 ? { hide_parameters: params } : {};
}
/**
* Get the URL hash from the page style and/or hidden filters parameters
*
* @param {PageStyle & EmbeddingAdditionalHashOptions['locale']} hashOptions
* @param {HiddenFilters} hiddenFilters
*
* @returns string
*/
function getHash(hashOptions, hiddenFilters) {
return new URLSearchParams({ ...hashOptions, ...hiddenFilters }).toString();
}
/**
* Extract the embeddable object type from the payload
*
* @param {EmbedPayload} payload - See {@link EmbedPayload}
* @returns ("question"|"dashboard")
*/
function getEmbeddableObject(payload) {
return Object.keys(payload.resource)[0];
}
}
/**
* Grab an iframe `src` via UI and open it,
* but make sure user is signed out.
*/
export function visitIframe() {
getIframeUrl().then((iframeUrl) => {
cy.signOut();
cy.visit(iframeUrl);
});
}
function getIframeUrl() {
modal().findByText("Preview").click();
return cy.document().then((doc) => {
const iframe = doc.querySelector("iframe");
return iframe.src;
});
}
/**
* Get page iframe body wrapped in `cy` helper
* @param {string} [selector]
*/
export function getIframeBody(selector = "iframe") {
cy.frameLoaded(selector);
cy.wait(1); // unclear why, but the tests are flaky without this
return cy.iframe(selector);
}
export function getEmbedModalSharingPane() {
return cy.findByTestId("sharing-pane-container");
}
/**
* Open Legacy Static Embedding setup modal
* @param {object} params
* @param {("question"|"dashboard")} [params.resource] - resource type
* @param {(string|number)} [params.resourceId] - resource id
* @param {("overview"|"parameters"|"lookAndFeel")} [params.activeTab] - modal tab to open
* @param {("code"|"preview")} [params.previewMode] - preview mode type to activate
* @param {boolean} [params.unpublishBeforeOpen] - either unpublish entity before legacy modal is opened or not
*/
export function openLegacyStaticEmbeddingModal({
resource,
resourceId,
activeTab,
previewMode,
unpublishBeforeOpen = true,
} = {}) {
const apiPath = resource === "question" ? "card" : "dashboard";
cy.request("PUT", `/api/${apiPath}/${resourceId}`, {
enable_embedding: true,
embedding_type: "static-legacy",
});
openSharingMenu("Embed");
embedModalContent().should("exist");
cy.get("body").then(($body) => {
const isEmbeddingDisabled =
$body.find('[data-testid="enable-embedding-card"]').length > 0;
if (isEmbeddingDisabled) {
embedModalEnableEmbedding();
}
embedModalContent().within(() => {
legacyStaticEmbeddingButton().click();
});
modal().within(() => {
cy.findByText("Static embedding").should("be.visible");
// Some tests after a modal is opened expect the entity to be unpublished;
// Other tests want to keep it published.
if (unpublishBeforeOpen) {
unpublishChanges(apiPath);
}
if (activeTab) {
const tabKeyToNameMap = {
overview: "Overview",
parameters: "Parameters",
lookAndFeel: "Look and Feel",
};
cy.findByRole("tab", { name: tabKeyToNameMap[activeTab] }).click();
}
if (previewMode) {
const previewModeToKeyMap = {
code: "Code",
preview: "Preview",
};
cy.findByText(previewModeToKeyMap[previewMode]).click();
}
});
});
}
export function closeStaticEmbeddingModal() {
modal().icon("close").click();
}
/**
* Publish a static dashboard or question
* @param {"card" | "dashboard"} apiPath
* @param [callback]
*/
export function publishChanges(apiPath, callback) {
cy.intercept("PUT", `/api/${apiPath}/*`).as("publishChanges");
cy.button(/^(Publish|Publish changes)$/).click();
// TODO this could be simplified when we send one publish request instead of two
cy.wait(["@publishChanges", "@publishChanges"]).then((xhrs) => {
// Unfortunately, the order of requests is not always the same.
// Therefore, we must first get the one that has the `embedding_params` and then assert on it.
const targetXhr = xhrs.find(({ request }) =>
Object.keys(request.body).includes("embedding_params"),
);
callback?.(targetXhr);
});
}
/**
* Unpublish a static dashboard or question
* @param {"card" | "dashboard"} apiPath
* @param callback
*/
export function unpublishChanges(apiPath, callback) {
cy.intercept("PUT", `/api/${apiPath}/*`, (req) => {
const body = req.body;
if (body?.enable_embedding === false) {
req.alias = "unpublishChanges";
}
});
cy.button("Unpublish").click();
cy.wait("@unpublishChanges").then((xhr) => {
callback?.(xhr);
});
}
export function getParametersContainer() {
return cy.findByLabelText("Configuring parameters");
}
export function setEmbeddingParameter(name, value) {
getParametersContainer().findByLabelText(name).click();
popover().contains(value).click();
}
export function assertEmbeddingParameter(name, value) {
getParametersContainer().findByLabelText(name).should("have.text", value);
}
// @param {("card"|"dashboard")} resourceType - The type of resource we are sharing
export function openNewPublicLinkDropdown(resourceType) {
cy.intercept("POST", `/api/${resourceType}/*/public_link`).as(
"sharingEnabled",
);
openSharingMenu(/public link/i);
cy.wait("@sharingEnabled").then(
({
response: {
body: { uuid },
},
}) => {
cy.wrap(uuid).as("uuid");
},
);
}
export function createPublicQuestionLink(questionId) {
return cy.request("POST", `/api/card/${questionId}/public_link`, {});
}
export function createPublicDashboardLink(dashboardId) {
return cy.request("POST", `/api/dashboard/${dashboardId}/public_link`, {});
}
export function createPublicDocumentLink(documentId) {
return cy.request("POST", `/api/ee/document/${documentId}/public-link`, {});
}
/**
* @param {Object} options
* @param {string} options.url
* @param {import("metabase-types/store").InteractiveEmbeddingOptions} options.qs
* @param {Function} [options.onBeforeLoad]
*/
export const visitFullAppEmbeddingUrl = ({ url, qs, onBeforeLoad }) => {
cy.visit({
url,
qs,
onBeforeLoad(window) {
// cypress runs all tests in an iframe and the app uses this property to avoid embedding mode for all tests
// by removing the property the app would work in embedding mode
window.Cypress = undefined;
onBeforeLoad?.(window);
},
});
};
/**
* @param {Object} options
* @param {number} options.resourceId
* @param {'question' | 'dashboard'} options.resourceType
* @param [options.params]
* @param [options.expirationMinutes]
* @return {Promise<string>}
*/
export const getSignedJwtForResource = async ({
resourceId,
resourceType,
params = {},
expirationMinutes = 10,
}) => {
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
const iat = Math.round(new Date().getTime() / 1000);
const exp = iat + 60 * expirationMinutes;
const payload = {
resource: { [resourceType]: resourceId },
params,
iat,
exp,
};
return new jose.SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
};