514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
import { USER_GROUPS, WRITABLE_DB_ID } from "e2e/support/cypress_data";
|
|
import * as Urls from "metabase/urls/data-apps";
|
|
import type {
|
|
Collection,
|
|
CollectionId,
|
|
CollectionPermission,
|
|
CollectionPermissionsGraph,
|
|
DataApp,
|
|
WritebackAction,
|
|
} from "metabase-types/api";
|
|
|
|
import { createTestNativeQuery } from "./api";
|
|
import type { DataAppTestEnv } from "./data-app-test-env";
|
|
import { getIframeBody } from "./e2e-embedding-helpers";
|
|
import { LOCAL_GIT_PATH } from "./e2e-remote-sync-helpers";
|
|
|
|
export const DATA_APP_NAME = "kitchen-sink";
|
|
export const DATA_APP_DISPLAY_NAME = "Kitchen Sink";
|
|
|
|
export const visitDataAppRoute = (route: string) => {
|
|
installDataAppScopeGuard();
|
|
return cy.visit(`/apps/${DATA_APP_NAME}/${route}`);
|
|
};
|
|
|
|
export const fakeDataApp = (overrides: Partial<DataApp> = {}): DataApp => ({
|
|
id: 1,
|
|
entity_id: "e2eFakeDataAppEntityI",
|
|
name: DATA_APP_NAME,
|
|
display_name: DATA_APP_DISPLAY_NAME,
|
|
description: null,
|
|
version: 1,
|
|
outdated: false,
|
|
bundle_path: "dist/index.js",
|
|
enabled: true,
|
|
draft: false,
|
|
resource_collection_id: null,
|
|
permission_group_id: null,
|
|
table_ids: [],
|
|
allowed_hosts: [],
|
|
bundle_hash: "e2e-bundle-hash",
|
|
created_at: "2024-01-01T00:00:00Z",
|
|
updated_at: "2024-01-01T00:00:00Z",
|
|
...overrides,
|
|
});
|
|
|
|
type MockDataAppOptions<TestEnv> = {
|
|
/** Display name (iframe title + admin list); defaults to the fixture dir name. */
|
|
displayName?: string;
|
|
/** `allowed_hosts` served in the bundle response header. */
|
|
allowedHosts?: string[];
|
|
/**
|
|
* Config a fixture reads at runtime, rather than hard-coding ids that track the
|
|
* Cypress snapshot.
|
|
*/
|
|
testEnv?: TestEnv;
|
|
/** Delays the bundle response, so a loading assertion has a window to catch. */
|
|
bundleDelayMs?: number;
|
|
};
|
|
|
|
export const mockDataApp = <TestEnv = DataAppTestEnv>(
|
|
appName: string,
|
|
options: MockDataAppOptions<TestEnv> = {},
|
|
) => {
|
|
const slug = appName;
|
|
const displayName = options.displayName ?? appName;
|
|
const allowedHosts = options.allowedHosts ?? [];
|
|
|
|
// Prelude runs in the sandbox realm before the bundle's factory, so the app
|
|
// can read the injected config as a global (see MockDataAppOptions.testEnv).
|
|
const prelude =
|
|
options.testEnv !== undefined
|
|
? `globalThis.__METABASE_DATA_APP_TEST_ENV__ = ${JSON.stringify(options.testEnv)};\n`
|
|
: "";
|
|
|
|
return cy.task<string>("buildDataApp", { appName }).then((bundleCode) => {
|
|
const app = fakeDataApp({
|
|
name: slug,
|
|
display_name: displayName,
|
|
allowed_hosts: allowedHosts,
|
|
});
|
|
|
|
cy.intercept("GET", "/api/apps/repo-status", {
|
|
configured: true,
|
|
});
|
|
cy.intercept("GET", "/api/apps", [app]);
|
|
cy.intercept({ method: "GET", pathname: `/api/apps/${slug}` }, app);
|
|
cy.intercept(
|
|
{ method: "GET", pathname: `/api/apps/${slug}/bundle` },
|
|
{
|
|
statusCode: 200,
|
|
headers: {
|
|
// Match the REAL bundle endpoint (`data_apps/api.clj`), which serves
|
|
// `application/javascript` + `nosniff`. The runtime fetch-and-evals the
|
|
// bundle, so the type is irrelevant there — but a stricter mock (e.g.
|
|
// `text/plain`) would diverge from production, so keep it aligned.
|
|
"content-type": "application/javascript",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"X-Metabase-Data-App-Allowed-Hosts": JSON.stringify(allowedHosts),
|
|
},
|
|
body: prelude + bundleCode,
|
|
...(options.bundleDelayMs ? { delay: options.bundleDelayMs } : {}),
|
|
},
|
|
);
|
|
|
|
return cy.wrap({ slug, displayName }, { log: false });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* The two rejections endpoint scope enforcement can emit. `scope_not_permitted` comes from
|
|
* `ensure-scopes-checked` — the endpoint declares no `:scope` at all, so a narrowed request
|
|
* may not reach it. `unsupported_scope` comes from `enforce-scope` — the endpoint is scoped,
|
|
* but not for the scope the request carries. Either one inside a data app means a route the
|
|
* SDK really uses was never tagged `data-apps:base`.
|
|
*/
|
|
const SCOPE_ERRORS = ["scope_not_permitted", "unsupported_scope"];
|
|
|
|
let scopeDenials: string[] = [];
|
|
let scopeGuardInstalled = false;
|
|
|
|
/**
|
|
* Records every scope rejection the backend returns while a data app is open. Every request a
|
|
* sandboxed app makes is marked `X-Metabase-Client: data-app` and confined to `data-apps:base`,
|
|
* so a rejection here is a route missing its `{:scope api-scope/data-app}` tag, not a
|
|
* permission problem. `assertNoDataAppScopeDenials` (a root `afterEach`) fails the test on any.
|
|
*/
|
|
function installDataAppScopeGuard() {
|
|
if (scopeGuardInstalled) {
|
|
return;
|
|
}
|
|
scopeGuardInstalled = true;
|
|
|
|
cy.intercept("/api/**", (req) => {
|
|
// `after:response` rather than a `req.continue` callback: the latter buffers the body,
|
|
// which would sit in front of the streamed `/api/dataset` responses.
|
|
req.on("after:response", (res) => {
|
|
// `res.body` is whatever the endpoint returned; the scope middleware answers with a
|
|
// JSON `{error, message}` body.
|
|
const error = (res.body as { error?: string } | undefined)?.error;
|
|
if (
|
|
res.statusCode === 403 &&
|
|
error !== undefined &&
|
|
SCOPE_ERRORS.includes(error)
|
|
) {
|
|
scopeDenials.push(
|
|
`${req.method} ${new URL(req.url).pathname} -> ${error}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Root `beforeEach` in `e2e/support/cypress.js`: intercepts reset between tests, so must this record. */
|
|
export function resetDataAppScopeGuard() {
|
|
scopeDenials = [];
|
|
scopeGuardInstalled = false;
|
|
}
|
|
|
|
/** Root `afterEach` in `e2e/support/cypress.js`. Only a test that opened a data app pays for it. */
|
|
export function assertNoDataAppScopeDenials() {
|
|
if (!scopeGuardInstalled) {
|
|
return;
|
|
}
|
|
cy.then(() => {
|
|
expect(
|
|
scopeDenials,
|
|
`data-app scope rejections:\n${scopeDenials.join("\n")}`,
|
|
).to.be.empty;
|
|
});
|
|
}
|
|
|
|
export function openDataApp(slug: string) {
|
|
installDataAppScopeGuard();
|
|
return cy.visit(Urls.dataApp(slug));
|
|
}
|
|
|
|
export function dataAppIframe(displayName: string) {
|
|
return getIframeBody(`iframe[title="${displayName}"]`);
|
|
}
|
|
|
|
export function setDataAppCollectionAccess(
|
|
collectionId: CollectionId,
|
|
access: CollectionPermission,
|
|
) {
|
|
return cy
|
|
.request<CollectionPermissionsGraph>("GET", "/api/collection/graph")
|
|
.then(({ body: graph }) => {
|
|
const groups = Object.fromEntries(
|
|
Object.entries(graph.groups).map(([groupId, collections]) => [
|
|
groupId,
|
|
Number(groupId) === USER_GROUPS.ADMIN_GROUP
|
|
? collections
|
|
: { ...collections, [collectionId]: access },
|
|
]),
|
|
);
|
|
|
|
cy.request("PUT", "/api/collection/graph", { ...graph, groups });
|
|
});
|
|
}
|
|
|
|
/** Creates a collection the non-admin groups hold `access` to. */
|
|
export function createDataAppCollection({
|
|
name,
|
|
access,
|
|
}: {
|
|
name: string;
|
|
access: CollectionPermission;
|
|
}) {
|
|
return cy
|
|
.request<Collection>("POST", "/api/collection", { name })
|
|
.then(({ body: collection }) => {
|
|
setDataAppCollectionAccess(collection.id, access);
|
|
return cy.wrap(collection, { log: false });
|
|
});
|
|
}
|
|
|
|
/** Creates a query action without a model that inserts a team into `scoreboard_actions`. */
|
|
export function createDataAppScoreboardAction({
|
|
name = "Add team",
|
|
collectionId = null,
|
|
}: { name?: string; collectionId?: CollectionId | null } = {}) {
|
|
return createTestNativeQuery({
|
|
database: WRITABLE_DB_ID,
|
|
query:
|
|
"INSERT INTO scoreboard_actions (team_name, score) VALUES ({{team_name}}, {{score}})",
|
|
templateTags: {
|
|
team_name: { type: "text", "display-name": "Team name", required: true },
|
|
score: { type: "number", "display-name": "Score", required: true },
|
|
},
|
|
}).then((datasetQuery) =>
|
|
cy
|
|
.request<WritebackAction>("POST", "/api/action", {
|
|
name,
|
|
type: "query",
|
|
database_id: WRITABLE_DB_ID,
|
|
collection_id: collectionId,
|
|
dataset_query: datasetQuery,
|
|
parameters: [
|
|
{
|
|
id: "team_name",
|
|
slug: "team_name",
|
|
name: "Team name",
|
|
type: "string/=",
|
|
target: ["variable", ["template-tag", "team_name"]],
|
|
required: true,
|
|
},
|
|
{
|
|
id: "score",
|
|
slug: "score",
|
|
name: "Score",
|
|
type: "number/=",
|
|
target: ["variable", ["template-tag", "score"]],
|
|
required: true,
|
|
},
|
|
],
|
|
})
|
|
.then(({ body: action }) => cy.wrap(action, { log: false })),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The dev host app is a real vite data app with the published SDK installed, so
|
|
* synchronizing it exercises the package an author actually consumes rather than
|
|
* the stub the scratch fixture provides.
|
|
*/
|
|
export const dataAppHostAppRoot = () =>
|
|
`${Cypress.config("projectRoot")}/${DATA_APP_DEV_HOST_APP_DIR}`;
|
|
|
|
const actionDeclarationsFile = (appRoot: string) =>
|
|
`${appRoot}/actions/orders.action.ts`;
|
|
|
|
/**
|
|
* Clears everything synchronization generates in the host app. Both sync specs
|
|
* drive the same checked-in directory, so each has to start from a clean tree —
|
|
* a stray `actions/` would otherwise be discovered by the query spec's sync.
|
|
*/
|
|
export function resetDataAppHostAppSources() {
|
|
const appRoot = dataAppHostAppRoot();
|
|
|
|
return cy.task("removeDataAppPaths", {
|
|
paths: [
|
|
`${appRoot}/queries`,
|
|
`${appRoot}/actions`,
|
|
`${appRoot}/resources_metadata.json`,
|
|
],
|
|
});
|
|
}
|
|
|
|
export function declareDataAppActions(
|
|
appRoot: string,
|
|
sourceActionIds: number[],
|
|
) {
|
|
return cy.task("writeDataAppFiles", {
|
|
files: {
|
|
[actionDeclarationsFile(appRoot)]: [
|
|
'import { defineAction } from "@metabase/embedding-sdk-react/data-app";',
|
|
...sourceActionIds.map(
|
|
(id) =>
|
|
`export const Action${id} = defineAction({ action: { id: ${id}, parameters: [] } });`,
|
|
),
|
|
].join("\n"),
|
|
},
|
|
});
|
|
}
|
|
|
|
const queryDeclarationsFile = (appRoot: string) =>
|
|
`${appRoot}/queries/orders.query.ts`;
|
|
|
|
/** Declares one `defineQuery` per entry, as an app author would. */
|
|
export function declareDataAppQueries(
|
|
appRoot: string,
|
|
declarations: Array<{ name: string; tableId: number; limit?: number }>,
|
|
) {
|
|
return cy.task("writeDataAppFiles", {
|
|
files: {
|
|
[queryDeclarationsFile(appRoot)]: [
|
|
'import { defineQuery } from "@metabase/embedding-sdk-react/data-app";',
|
|
...declarations.map(({ name, tableId, limit }) => {
|
|
const clauses = limit === undefined ? "" : `, limit: ${limit}`;
|
|
return `export const ${name} = defineQuery({ source: { type: "table", id: ${tableId} }${clauses} });`;
|
|
}),
|
|
].join("\n"),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deletes one declaration in place, so the rest keep the generated IDs
|
|
* synchronization injected — which is what an author removing one does.
|
|
* Splits on the declaration keyword rather than on lines, because an injected
|
|
* ID lands on its own line and makes a declaration span several.
|
|
*/
|
|
function removeDeclaration(filePath: string, exportName: string) {
|
|
return cy.task("removeDataAppDeclaration", { filePath, exportName });
|
|
}
|
|
|
|
export function removeDataAppQueryDeclaration(appRoot: string, name: string) {
|
|
return removeDeclaration(queryDeclarationsFile(appRoot), name);
|
|
}
|
|
|
|
export function removeDataAppActionDeclaration(
|
|
appRoot: string,
|
|
sourceActionId: number,
|
|
) {
|
|
return removeDeclaration(
|
|
actionDeclarationsFile(appRoot),
|
|
`Action${sourceActionId}`,
|
|
);
|
|
}
|
|
|
|
/** Runs the real `sync-resources` CLI against the instance under test. */
|
|
export function syncDataAppResources(apiKey: string, appRoot: string) {
|
|
return cy.task<{ ok: boolean; error: string | null }>("syncDataApp", {
|
|
appRoot,
|
|
metabaseUrl: Cypress.config("baseUrl"),
|
|
apiKey,
|
|
});
|
|
}
|
|
|
|
export const copySyncedDataAppsFixture = () =>
|
|
cy.task("copyDirectory", {
|
|
source: `${Cypress.config("projectRoot")}/e2e/support/assets/example_synced_data_apps`,
|
|
destination: LOCAL_GIT_PATH,
|
|
});
|
|
|
|
/** The test repo is not an npm project, so `defineQuery` needs a stub to resolve. */
|
|
export function declareSyncedDataAppQuery(slug: string, tableId: number) {
|
|
const appRoot = `${LOCAL_GIT_PATH}/data_apps/${slug}`;
|
|
const packageRoot = `${appRoot}/node_modules/@metabase/embedding-sdk-react`;
|
|
|
|
return cy.task("writeDataAppFiles", {
|
|
files: {
|
|
[`${packageRoot}/package.json`]: JSON.stringify({
|
|
name: "@metabase/embedding-sdk-react",
|
|
exports: { "./data-app": "./data-app.js" },
|
|
}),
|
|
[`${packageRoot}/data-app.js`]: "exports.defineQuery = (q) => q;",
|
|
[`${appRoot}/queries/orders.query.ts`]: [
|
|
'import { defineQuery } from "@metabase/embedding-sdk-react/data-app";',
|
|
`export const Orders = defineQuery({ source: { type: "table", id: ${tableId} } });`,
|
|
"",
|
|
].join("\n"),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function createDataAppApiKey() {
|
|
return cy
|
|
.request<{ unmasked_key: string }>("POST", "/api/api-key", {
|
|
name: `data-app-sync-e2e-${Date.now()}`,
|
|
group_id: USER_GROUPS.ADMIN_GROUP,
|
|
})
|
|
.then(({ body }) => body.unmasked_key);
|
|
}
|
|
|
|
/**
|
|
* A second app beside the host app, for cases that need two of them. It reuses
|
|
* the host app's `node_modules`, so `defineQuery` still resolves through the
|
|
* published SDK, and its manifest declares `slug` as its slug.
|
|
*/
|
|
export function createSecondDataApp(slug: string) {
|
|
cy.task("scaffoldDataApp", { appName: slug, sdkFrom: dataAppHostAppRoot() });
|
|
|
|
return `${Cypress.config("projectRoot")}/e2e/tmp/${slug}`;
|
|
}
|
|
|
|
/**
|
|
* Runs the host app's own production build. The SDK's `metabase-resource-sync-check`
|
|
* plugin runs on `buildStart`, so this is what refuses to bundle a stale app.
|
|
*/
|
|
export function buildDataAppHostApp() {
|
|
return cy.exec(`cd "${dataAppHostAppRoot()}" && npm run build`, {
|
|
failOnNonZeroExit: false,
|
|
timeout: 180_000,
|
|
});
|
|
}
|
|
|
|
/** The app's own permission group — the one its viewers are given. */
|
|
export function dataAppPermissionGroupId(slug: string) {
|
|
return cy.request<DataApp>(`/api/apps/${slug}`).then(({ body }) => {
|
|
const groupId = body.permission_group_id;
|
|
|
|
if (typeof groupId !== "number") {
|
|
throw new Error(`Data app ${slug} has no permission group.`);
|
|
}
|
|
|
|
return cy.wrap(groupId, { log: false });
|
|
});
|
|
}
|
|
|
|
/** Puts a user in the app's own permission group, as granting app access does. */
|
|
const DATA_APP_DEV_HOST_APP_DIR =
|
|
"e2e/embedding-sdk-host-apps/vite-6-data-app-host-app";
|
|
|
|
const DATA_APP_DEV_ENV_PATH = `${DATA_APP_DEV_HOST_APP_DIR}/.env.local`;
|
|
|
|
export const DATA_APP_DEV_MANIFEST_PATH = `${DATA_APP_DEV_HOST_APP_DIR}/data_app.yaml`;
|
|
|
|
export const DATA_APP_DEV_APP_SRC_PATH = `${DATA_APP_DEV_HOST_APP_DIR}/src/App.tsx`;
|
|
|
|
const DATA_APP_DEV_CONTENT_TIMEOUT_MS = 40000;
|
|
|
|
export function visitDataAppDevApp(clientHost: string) {
|
|
cy.visit(clientHost);
|
|
cy.findByTestId("dev-app-content", {
|
|
timeout: DATA_APP_DEV_CONTENT_TIMEOUT_MS,
|
|
}).should("exist");
|
|
}
|
|
|
|
export function setUpDataAppDevServer(clientHost: string) {
|
|
const mbUrl = Cypress.config("baseUrl");
|
|
if (!mbUrl) {
|
|
throw new Error("baseUrl must be set for the data-app dev-server suite");
|
|
}
|
|
|
|
cy.task("removeDataAppPaths", { paths: [DATA_APP_DEV_ENV_PATH] });
|
|
waitForDataAppDevServerEnv(clientHost, mbUrl, { expectPresent: false });
|
|
|
|
cy.request("POST", "/api/api-key", {
|
|
name: `data-app-dev-e2e-${Date.now()}`,
|
|
group_id: USER_GROUPS.ADMIN_GROUP,
|
|
}).then(({ body }) => {
|
|
cy.task("writeDataAppFiles", {
|
|
files: {
|
|
[DATA_APP_DEV_ENV_PATH]: `DATA_APP_MB_URL=${mbUrl}\nDATA_APP_MB_API_KEY=${body.unmasked_key}\n`,
|
|
},
|
|
});
|
|
});
|
|
|
|
waitForDataAppDevServerEnv(clientHost, mbUrl, { expectPresent: true });
|
|
}
|
|
|
|
export function tearDownDataAppDevServer() {
|
|
return cy.task("removeDataAppPaths", { paths: [DATA_APP_DEV_ENV_PATH] });
|
|
}
|
|
|
|
// `DATA_APP_MB_URL` shows up in (or drops out of) the served CSP once Vite has
|
|
// restarted onto the changed env — poll for the expected state before visiting.
|
|
function waitForDataAppDevServerEnv(
|
|
clientHost: string,
|
|
mbUrl: string,
|
|
{ expectPresent }: { expectPresent: boolean },
|
|
attempt = 0,
|
|
) {
|
|
const MAX_ATTEMPTS = 40;
|
|
const origin = new URL(mbUrl).origin;
|
|
|
|
cy.request({
|
|
url: `${clientHost}/`,
|
|
headers: { Accept: "text/html" },
|
|
failOnStatusCode: false,
|
|
}).then((res) => {
|
|
const csp = String(res.headers["content-security-policy"] ?? "");
|
|
|
|
if (csp.includes(origin) === expectPresent) {
|
|
return;
|
|
}
|
|
|
|
if (attempt >= MAX_ATTEMPTS) {
|
|
throw new Error(
|
|
`Dev server never restarted onto DATA_APP_MB_URL ${expectPresent ? "present" : "absent"} (${origin}); last CSP: "${csp}"`,
|
|
);
|
|
}
|
|
|
|
cy.wait(1000);
|
|
waitForDataAppDevServerEnv(
|
|
clientHost,
|
|
mbUrl,
|
|
{ expectPresent },
|
|
attempt + 1,
|
|
);
|
|
});
|
|
}
|