730 lines
24 KiB
TypeScript
730 lines
24 KiB
TypeScript
import { USERS, USER_GROUPS } from "e2e/support/cypress_data";
|
||
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
|
||
import {
|
||
addUserToGroup,
|
||
buildDataAppHostApp,
|
||
createDataAppApiKey,
|
||
createSecondDataApp,
|
||
dataAppHostAppRoot,
|
||
dataAppPermissionGroupId,
|
||
declareDataAppQueries,
|
||
removeDataAppQueryDeclaration,
|
||
resetDataAppHostAppSources,
|
||
setDataAppCollectionAccess,
|
||
syncDataAppResources,
|
||
} from "e2e/support/helpers";
|
||
import type { Card, DataApp } from "metabase-types/api";
|
||
|
||
const { H } = cy;
|
||
const { ORDERS_ID } = SAMPLE_DATABASE;
|
||
|
||
/** The `slug` the app's `data_app.yaml` declares. */
|
||
const APP_SLUG = "vite-6-data-app-host-app";
|
||
|
||
/** The fields these specs read off a card the app collection holds. */
|
||
type AppCard = { id: number; name: string; collection_id: number | null };
|
||
|
||
const APP_ROOT = () => dataAppHostAppRoot();
|
||
const LOCKFILE = () => `${APP_ROOT()}/resources_metadata.json`;
|
||
const QUERIES_FILE = () => `${APP_ROOT()}/queries/orders.query.ts`;
|
||
const MANIFEST_FILE = () => `${APP_ROOT()}/data_app.yaml`;
|
||
|
||
const AUTHORED_MANIFEST = `version: 1
|
||
name: Vite 6 Data App
|
||
slug: vite-6-data-app-host-app
|
||
path: ./dist/index.js
|
||
allowed_hosts:
|
||
- https://allowed.data-app.test
|
||
entity_id: qxpaPkU_WRE2ZQu0cmpqD
|
||
serdes/meta:
|
||
- model: DataApp
|
||
id: qxpaPkU_WRE2ZQu0cmpqD
|
||
label: vite-6-data-app-host-app
|
||
`;
|
||
|
||
/**
|
||
* The query half of `sync-resources`, run against the dev host app: a real vite
|
||
* data app with the published SDK installed, so `defineQuery` resolves through
|
||
* the package an author actually consumes rather than a stub.
|
||
*/
|
||
describe("Embedding SDK: data-app sync-resources (queries)", () => {
|
||
const resetHostAppSources = () => {
|
||
resetDataAppHostAppSources();
|
||
cy.writeFile(MANIFEST_FILE(), AUTHORED_MANIFEST);
|
||
};
|
||
|
||
beforeEach(() => {
|
||
H.restore();
|
||
cy.signInAsAdmin();
|
||
H.activateToken("bleeding-edge");
|
||
|
||
// The query and actions specs share a checked-in host app, so start clean.
|
||
resetHostAppSources();
|
||
createDataAppApiKey().as("apiKey");
|
||
});
|
||
|
||
after(() => {
|
||
resetHostAppSources();
|
||
});
|
||
|
||
const sync = () =>
|
||
cy.get<string>("@apiKey").then((apiKey) => {
|
||
syncDataAppResources(apiKey, APP_ROOT()).then(({ ok, error }) => {
|
||
expect(error, "sync-resources failed").to.eq(null);
|
||
expect(ok).to.eq(true);
|
||
});
|
||
});
|
||
|
||
const syncExpectingRefusal = (message: string) =>
|
||
cy.get<string>("@apiKey").then((apiKey) => {
|
||
syncDataAppResources(apiKey, APP_ROOT()).should(({ ok, error }) => {
|
||
expect(ok, "sync-resources should have refused").to.eq(false);
|
||
expect(error).to.contain(message);
|
||
});
|
||
});
|
||
|
||
/** The saved questions Metabase holds in the app's own collection. */
|
||
const savedQuestions = () =>
|
||
cy.request<DataApp>(`/api/apps/${APP_SLUG}`).then(({ body: app }) => {
|
||
expect(app.resource_collection_id, "resource collection").to.be.a(
|
||
"number",
|
||
);
|
||
return cy
|
||
.request<{
|
||
data: AppCard[];
|
||
}>(`/api/collection/${app.resource_collection_id}/items?models=card`)
|
||
.then(({ body }) => body.data);
|
||
});
|
||
|
||
/** Declares one query and syncs it, handing back the card Metabase created. */
|
||
const syncOneQuery = () => {
|
||
declareDataAppQueries(APP_ROOT(), [{ name: "Orders", tableId: ORDERS_ID }]);
|
||
sync();
|
||
return savedQuestions().then(([card]) => cy.wrap(card, { log: false }));
|
||
};
|
||
|
||
it("names the definition whose query Metabase could not resolve", () => {
|
||
declareDataAppQueries(APP_ROOT(), [{ name: "Orders", tableId: 999999 }]);
|
||
|
||
syncExpectingRefusal("Could not resolve queries/orders.query.ts:Orders");
|
||
|
||
savedQuestions().should("have.length", 0);
|
||
});
|
||
|
||
it("creates the saved question, writes its ID back, and re-syncs without changes", () => {
|
||
declareDataAppQueries(APP_ROOT(), [
|
||
{ name: "Orders", tableId: ORDERS_ID, limit: 5 },
|
||
]);
|
||
|
||
sync();
|
||
|
||
savedQuestions().then((cards) => {
|
||
expect(cards).to.have.length(1);
|
||
|
||
cy.readFile(QUERIES_FILE()).should(
|
||
"contain",
|
||
`savedQuestionSourceId: ${cards[0].id}`,
|
||
);
|
||
cy.readFile(LOCKFILE()).then((lockfile) => {
|
||
expect(lockfile.queries).to.have.length(1);
|
||
expect(lockfile.queries[0].savedQuestionSourceId).to.eq(cards[0].id);
|
||
});
|
||
|
||
sync();
|
||
savedQuestions().then((after) => {
|
||
expect(after).to.have.length(1);
|
||
expect(after[0].id).to.eq(cards[0].id);
|
||
});
|
||
});
|
||
});
|
||
|
||
it("deletes the saved question when its declaration is removed", () => {
|
||
syncOneQuery().then(() => {
|
||
removeDataAppQueryDeclaration(APP_ROOT(), "Orders");
|
||
sync();
|
||
|
||
savedQuestions().should("have.length", 0);
|
||
cy.readFile(LOCKFILE()).then((lockfile) => {
|
||
expect(lockfile.queries).to.have.length(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
// Removing a declaration deletes the copy and its lockfile entry, but the ID the
|
||
// sync wrote into the source stays in source control. Bringing the declaration
|
||
// back has to make a fresh copy: there is no card left to adopt.
|
||
it("re-creates the copy when a removed declaration comes back naming the deleted card", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.readFile(QUERIES_FILE()).then((authored: string) => {
|
||
expect(
|
||
authored,
|
||
"the sync wrote the copy's ID into the source",
|
||
).to.contain(`savedQuestionSourceId: ${card.id}`);
|
||
|
||
removeDataAppQueryDeclaration(APP_ROOT(), "Orders");
|
||
sync();
|
||
savedQuestions().should("have.length", 0);
|
||
|
||
// Exactly what restoring the file from source control brings back.
|
||
cy.writeFile(QUERIES_FILE(), authored);
|
||
sync();
|
||
|
||
savedQuestions().then((recreated) => {
|
||
expect(recreated).to.have.length(1);
|
||
expect(
|
||
recreated[0].id,
|
||
"the deleted card is not resurrected",
|
||
).not.to.eq(card.id);
|
||
cy.readFile(QUERIES_FILE())
|
||
.should("contain", `savedQuestionSourceId: ${recreated[0].id}`)
|
||
.should("not.contain", `savedQuestionSourceId: ${card.id}`);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
it("names the trash when a restored declaration points at a trashed copy", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.readFile(QUERIES_FILE()).then((authored: string) => {
|
||
cy.request("PUT", `/api/card/${card.id}`, { archived: true });
|
||
|
||
removeDataAppQueryDeclaration(APP_ROOT(), "Orders");
|
||
sync();
|
||
cy.readFile(LOCKFILE()).then((lockfile) => {
|
||
expect(lockfile.queries, "the entry is dropped").to.have.length(0);
|
||
});
|
||
|
||
cy.writeFile(QUERIES_FILE(), authored);
|
||
syncExpectingRefusal(`card ${card.id}, which is in the trash`);
|
||
});
|
||
});
|
||
});
|
||
|
||
it("restores a hand-edited card's name instead of replacing the card", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.request("PUT", `/api/card/${card.id}`, { name: "Renamed by hand" });
|
||
|
||
sync();
|
||
|
||
savedQuestions().then((cards) => {
|
||
expect(cards, "the card is updated in place").to.have.length(1);
|
||
expect(cards[0].id).to.eq(card.id);
|
||
expect(cards[0].name).to.eq("Orders");
|
||
});
|
||
});
|
||
});
|
||
|
||
it("brings back a saved question that was trashed on its own", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.request("PUT", `/api/card/${card.id}`, { archived: true });
|
||
|
||
sync();
|
||
|
||
cy.request(`/api/card/${card.id}`).then(({ body: restored }) => {
|
||
expect(restored.archived, "the copy is out of the trash").to.eq(false);
|
||
expect(restored.collection_id).to.eq(card.collection_id);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("trashed metric copies", () => {
|
||
beforeEach(() => {
|
||
H.createQuestion({
|
||
name: "Count of orders",
|
||
type: "metric",
|
||
query: { "source-table": ORDERS_ID, aggregation: [["count"]] },
|
||
display: "scalar",
|
||
})
|
||
.its("body.id")
|
||
.as("sourceMetricId");
|
||
|
||
cy.log("define a query that uses metrics");
|
||
cy.get<number>("@sourceMetricId").then((id) => {
|
||
cy.writeFile(
|
||
QUERIES_FILE(),
|
||
[
|
||
'import { defineQuery } from "@metabase/embedding-sdk-react/data-app";',
|
||
|
||
"export const OrdersCount = defineQuery({",
|
||
` source: { type: "table", id: ${ORDERS_ID} },`,
|
||
` aggregations: [{ type: "metric", id: ${id}, sourceTableId: ${ORDERS_ID} }],`,
|
||
"});",
|
||
].join("\n"),
|
||
);
|
||
});
|
||
|
||
sync();
|
||
|
||
cy.readFile(LOCKFILE()).then((lockfile) => {
|
||
expect(lockfile.metrics).to.have.length(1);
|
||
expect(lockfile.queries).to.have.length(1);
|
||
|
||
cy.wrap(lockfile.metrics[0].copiedMetricId).as("copiedMetricId");
|
||
cy.wrap(lockfile.queries[0].savedQuestionSourceId).as("questionId");
|
||
});
|
||
|
||
cy.log("archive the copied metric");
|
||
cy.get<number>("@copiedMetricId").then((id) => {
|
||
cy.request<Card>(`/api/card/${id}`).its("body").as("metricCopy");
|
||
|
||
H.archiveQuestion(id);
|
||
});
|
||
|
||
cy.get<Card>("@metricCopy").then((copy) => {
|
||
cy.log("the copied metric is archived");
|
||
|
||
cy.request<Card>(`/api/card/${copy.id}`).should(({ body }) => {
|
||
expect(body.archived).to.eq(true);
|
||
expect(body.collection_id).not.to.eq(copy.collection_id);
|
||
});
|
||
});
|
||
});
|
||
|
||
it("restores a trashed metric copy after sync", () => {
|
||
sync();
|
||
|
||
cy.get<Card>("@metricCopy").then((copy) => {
|
||
cy.log("the archived metric should no longer be archived after sync");
|
||
|
||
cy.request<Card>(`/api/card/${copy.id}`).should(({ body }) => {
|
||
expect(body.archived).to.eq(false);
|
||
expect(body.collection_id).to.eq(copy.collection_id);
|
||
});
|
||
|
||
cy.log("the copied metric in the lockfile remains the same");
|
||
cy.readFile(LOCKFILE())
|
||
.its("metrics.0.copiedMetricId")
|
||
.should("eq", copy.id);
|
||
});
|
||
|
||
cy.get<number>("@questionId").then((id) => H.visitQuestion(id));
|
||
|
||
H.assertQueryBuilderRowCount(1);
|
||
});
|
||
|
||
it("leaves a trashed metric copy in trash after its source is removed", () => {
|
||
H.removeDataAppQueryDeclaration(APP_ROOT(), "OrdersCount");
|
||
|
||
sync();
|
||
|
||
cy.get<number>("@copiedMetricId").then((id) => {
|
||
cy.log("the copied metric should remain archived after sync");
|
||
cy.request<Card>(`/api/card/${id}`)
|
||
.its("body.archived")
|
||
.should("eq", true);
|
||
});
|
||
|
||
cy.readFile(LOCKFILE()).should((lockfile) => {
|
||
expect(lockfile.metrics).to.deep.eq([]);
|
||
expect(lockfile.queries).to.deep.eq([]);
|
||
});
|
||
|
||
cy.get<number>("@sourceMetricId").then((id) => {
|
||
cy.log("the source metric should not be archived");
|
||
cy.request<Card>(`/api/card/${id}`)
|
||
.its("body.archived")
|
||
.should("eq", false);
|
||
});
|
||
});
|
||
});
|
||
|
||
it("recreates the saved question after it is deleted in Metabase", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.request("DELETE", `/api/card/${card.id}`);
|
||
|
||
sync();
|
||
|
||
savedQuestions().then((cards) => {
|
||
expect(cards).to.have.length(1);
|
||
expect(cards[0].id).not.to.eq(card.id);
|
||
});
|
||
cy.readFile(QUERIES_FILE()).should("not.contain", `Id: ${card.id}`);
|
||
});
|
||
});
|
||
|
||
it("restores savedQuestionSourceId from the lockfile when the source loses it", () => {
|
||
syncOneQuery().then((card) => {
|
||
// Rewriting the declaration drops the injected ID, as a bad merge would.
|
||
declareDataAppQueries(APP_ROOT(), [
|
||
{ name: "Orders", tableId: ORDERS_ID },
|
||
]);
|
||
|
||
sync();
|
||
|
||
savedQuestions().then((cards) => {
|
||
expect(cards, "the card is reused, not replaced").to.have.length(1);
|
||
expect(cards[0].id).to.eq(card.id);
|
||
});
|
||
cy.readFile(QUERIES_FILE()).should(
|
||
"contain",
|
||
`savedQuestionSourceId: ${card.id}`,
|
||
);
|
||
});
|
||
});
|
||
|
||
it("rebuilds a missing lockfile entry from the ID left in the source", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.writeFile(LOCKFILE(), { queries: [], actions: [] });
|
||
|
||
sync();
|
||
|
||
// The inline ID proves ownership, so the entry is rebuilt.
|
||
savedQuestions().then((cards) => {
|
||
expect(cards).to.have.length(1);
|
||
expect(cards[0].id).to.eq(card.id);
|
||
});
|
||
cy.readFile(LOCKFILE()).then((lockfile) => {
|
||
expect(lockfile.queries).to.have.length(1);
|
||
expect(lockfile.queries[0].savedQuestionSourceId).to.eq(card.id);
|
||
});
|
||
});
|
||
});
|
||
|
||
// A card in the trash reports the Trash as its collection, so one moved out
|
||
// and trashed afterwards is indistinguishable from the app's own. Deleting is
|
||
// permanent, so neither is deleted.
|
||
it("leaves a trashed card in the trash when its declaration is removed", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.request("PUT", `/api/card/${card.id}`, { archived: true });
|
||
removeDataAppQueryDeclaration(APP_ROOT(), "Orders");
|
||
|
||
sync();
|
||
|
||
cy.request(`/api/card/${card.id}`)
|
||
.its("body.archived")
|
||
.should("eq", true);
|
||
cy.readFile(LOCKFILE()).then((lockfile) => {
|
||
expect(lockfile.queries, "no longer tracked").to.have.length(0);
|
||
});
|
||
});
|
||
});
|
||
|
||
it("refuses to delete a card that was moved out of the app collection", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.request("POST", "/api/collection", { name: "Elsewhere" }).then(
|
||
({ body: collection }) => {
|
||
cy.request("PUT", `/api/card/${card.id}`, {
|
||
collection_id: collection.id,
|
||
});
|
||
removeDataAppQueryDeclaration(APP_ROOT(), "Orders");
|
||
|
||
syncExpectingRefusal(`Move card ${card.id} back to`);
|
||
cy.request(`/api/card/${card.id}`)
|
||
.its("body.collection_id")
|
||
.should("eq", collection.id);
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
// Trashing the collection archives everything in it, and the app is served
|
||
// from those copies, so a sync that reports success has not actually recovered.
|
||
it("brings the app collection and its copies back out of the trash", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.request(`/api/apps/${APP_SLUG}`).then(({ body: app }) => {
|
||
cy.request("PUT", `/api/collection/${app.resource_collection_id}`, {
|
||
archived: true,
|
||
});
|
||
cy.request(`/api/card/${card.id}`)
|
||
.its("body.archived")
|
||
.should("eq", true);
|
||
|
||
sync();
|
||
|
||
cy.request(`/api/collection/${app.resource_collection_id}`)
|
||
.its("body.archived")
|
||
.should("eq", false);
|
||
cy.request(`/api/card/${card.id}`)
|
||
.its("body.archived")
|
||
.should("eq", false);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("the app's lifecycle", () => {
|
||
it("takes the collection and the group with it when the app is removed", () => {
|
||
syncOneQuery().then((card) => {
|
||
dataAppPermissionGroupId(APP_SLUG).then((groupId) => {
|
||
cy.request(`/api/apps/${APP_SLUG}`).then(({ body: app }) => {
|
||
cy.request("DELETE", `/api/apps/${APP_SLUG}`);
|
||
|
||
// Nothing is left for a former viewer to reach.
|
||
cy.request({
|
||
url: `/api/collection/${app.resource_collection_id}`,
|
||
failOnStatusCode: false,
|
||
})
|
||
.its("status")
|
||
.should("eq", 404);
|
||
cy.request({
|
||
url: `/api/permissions/group/${groupId}`,
|
||
failOnStatusCode: false,
|
||
})
|
||
.its("status")
|
||
.should("eq", 404);
|
||
cy.request({ url: `/api/card/${card.id}`, failOnStatusCode: false })
|
||
.its("status")
|
||
.should("eq", 404);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
// Everything the CLI drives is superuser-gated, starting with the draft it
|
||
// asks for first, so a key that is not an admin's gets nowhere.
|
||
it("refuses to synchronize at all for a key that is not an admin's", () => {
|
||
declareDataAppQueries(APP_ROOT(), [
|
||
{ name: "Orders", tableId: ORDERS_ID },
|
||
]);
|
||
|
||
cy.request("POST", "/api/api-key", {
|
||
name: `data-app-sync-e2e-non-admin-${Date.now()}`,
|
||
group_id: USER_GROUPS.COLLECTION_GROUP,
|
||
}).then(({ body: key }) => {
|
||
syncDataAppResources(key.unmasked_key, APP_ROOT()).should(
|
||
({ ok, error }) => {
|
||
expect(ok, "sync-resources should have refused").to.eq(false);
|
||
expect(error).to.contain("403");
|
||
expect(error, "refused at the first call it makes").to.contain(
|
||
`/api/apps/${APP_SLUG}/draft`,
|
||
);
|
||
},
|
||
);
|
||
|
||
// The draft call is the one that creates the app, so a refusal leaves
|
||
// nothing behind at all — not even the app to hold copies.
|
||
cy.request({ url: `/api/apps/${APP_SLUG}`, failOnStatusCode: false })
|
||
.its("status")
|
||
.should("eq", 404);
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("two apps on one instance", () => {
|
||
const OTHER_SLUG = "sync-resources-second-app";
|
||
|
||
it("keeps each app's copies in its own collection, reachable only by its own group", () => {
|
||
const otherRoot = createSecondDataApp(OTHER_SLUG);
|
||
|
||
syncOneQuery().then((card) => {
|
||
declareDataAppQueries(otherRoot, [
|
||
{ name: "OtherOrders", tableId: ORDERS_ID },
|
||
]);
|
||
cy.get<string>("@apiKey").then((apiKey) => {
|
||
syncDataAppResources(apiKey, otherRoot).then(({ error }) => {
|
||
expect(error, "second app sync failed").to.eq(null);
|
||
});
|
||
});
|
||
|
||
cy.request(`/api/apps/${OTHER_SLUG}`).then(({ body: otherApp }) => {
|
||
dataAppPermissionGroupId(APP_SLUG).then((groupId) => {
|
||
expect(
|
||
otherApp.resource_collection_id,
|
||
"each app gets its own collection",
|
||
).not.to.eq(null);
|
||
expect(otherApp.permission_group_id).not.to.eq(groupId);
|
||
|
||
// Joining one app's group must not reach the other app's copy.
|
||
addUserToGroup(groupId, USERS.normal.email);
|
||
|
||
cy.request(
|
||
`/api/collection/${otherApp.resource_collection_id}/items?models=card`,
|
||
).then(({ body }) => {
|
||
const otherCard = body.data[0];
|
||
expect(otherCard.id, "the apps hold different cards").not.to.eq(
|
||
card.id,
|
||
);
|
||
|
||
cy.signInAsNormalUser();
|
||
cy.request(`/api/card/${card.id}`)
|
||
.its("body.id")
|
||
.should("eq", card.id);
|
||
cy.request({
|
||
url: `/api/card/${otherCard.id}`,
|
||
failOnStatusCode: false,
|
||
})
|
||
.its("status")
|
||
.should("eq", 403);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("the build guard", () => {
|
||
/** `cy.exec` reports the command's output; vite prints this only on success. */
|
||
const expectBuildToSucceed = () =>
|
||
buildDataAppHostApp().should((result) => {
|
||
expect(`${result.stdout}${result.stderr}`).to.contain("built in");
|
||
});
|
||
|
||
// `metabase-resource-sync-check` runs on buildStart, so a stale app is
|
||
// refused before it can be bundled and served.
|
||
it("refuses to build when the source and the lockfile disagree", () => {
|
||
syncOneQuery().then(() => {
|
||
expectBuildToSucceed();
|
||
|
||
// A hand-edited declaration no longer matches its lockfile entry.
|
||
declareDataAppQueries(APP_ROOT(), [
|
||
{ name: "Orders", tableId: ORDERS_ID, limit: 3 },
|
||
]);
|
||
|
||
buildDataAppHostApp().should((result) => {
|
||
const output = `${result.stdout}${result.stderr}`;
|
||
expect(output, "the build is refused").to.contain(
|
||
"is not synchronized",
|
||
);
|
||
expect(output).not.to.contain("built in");
|
||
});
|
||
|
||
// Synchronizing makes it buildable again.
|
||
sync();
|
||
expectBuildToSucceed();
|
||
});
|
||
});
|
||
});
|
||
|
||
describe("permissions", () => {
|
||
/** Puts the normal user in the app's group, as granting app access does. */
|
||
const joinAppGroup = () =>
|
||
dataAppPermissionGroupId(APP_SLUG).then((groupId) => {
|
||
addUserToGroup(groupId, USERS.normal.email);
|
||
return cy.wrap(groupId, { log: false });
|
||
});
|
||
|
||
it("does not let a viewer modify the copy it can read", () => {
|
||
syncOneQuery().then((card) => {
|
||
joinAppGroup();
|
||
|
||
cy.signInAsNormalUser();
|
||
cy.request(`/api/card/${card.id}`).its("body.id").should("eq", card.id);
|
||
|
||
// The grant is collection read, never readwrite.
|
||
cy.request({
|
||
method: "PUT",
|
||
url: `/api/card/${card.id}`,
|
||
body: { name: "Renamed by a viewer" },
|
||
failOnStatusCode: false,
|
||
})
|
||
.its("status")
|
||
.should("eq", 403);
|
||
cy.request({
|
||
method: "DELETE",
|
||
url: `/api/card/${card.id}`,
|
||
failOnStatusCode: false,
|
||
})
|
||
.its("status")
|
||
.should("eq", 403);
|
||
});
|
||
});
|
||
|
||
// The app collection is server-owned, so each sync reasserts exclusive
|
||
// access — including over a grant an admin made deliberately.
|
||
it("takes back access an admin granted another group on the app collection", () => {
|
||
syncOneQuery().then((card) => {
|
||
cy.request(`/api/apps/${APP_SLUG}`).then(({ body: app }) => {
|
||
setDataAppCollectionAccess(app.resource_collection_id, "read");
|
||
|
||
cy.signInAsNormalUser();
|
||
cy.request(`/api/card/${card.id}`)
|
||
.its("body.id")
|
||
.should("eq", card.id);
|
||
|
||
cy.signInAsAdmin();
|
||
sync();
|
||
|
||
cy.signInAsNormalUser();
|
||
cy.request({ url: `/api/card/${card.id}`, failOnStatusCode: false })
|
||
.its("status")
|
||
.should("eq", 403);
|
||
});
|
||
});
|
||
});
|
||
|
||
it("grants the group nothing beyond the app's own collection", () => {
|
||
cy.request("POST", "/api/collection", { name: "Private" }).then(
|
||
({ body: collection }) => {
|
||
// The normal user belongs to groups that can read new collections, so
|
||
// close this one: the claim under test is that joining the app group
|
||
// grants the app's collection and nothing else.
|
||
setDataAppCollectionAccess(collection.id, "none");
|
||
|
||
H.createQuestion({
|
||
name: "Private question",
|
||
query: { "source-table": ORDERS_ID },
|
||
collection_id: collection.id,
|
||
}).then(({ body: unrelated }) => {
|
||
syncOneQuery().then((card) => {
|
||
joinAppGroup();
|
||
|
||
cy.signInAsNormalUser();
|
||
cy.request(`/api/card/${card.id}`)
|
||
.its("body.id")
|
||
.should("eq", card.id);
|
||
cy.request({
|
||
url: `/api/card/${unrelated.id}`,
|
||
failOnStatusCode: false,
|
||
})
|
||
.its("status")
|
||
.should("eq", 403);
|
||
});
|
||
});
|
||
},
|
||
);
|
||
});
|
||
|
||
it("leaves the group with nothing once the declaration is removed", () => {
|
||
syncOneQuery().then((card) => {
|
||
joinAppGroup();
|
||
|
||
cy.signInAsNormalUser();
|
||
cy.request(`/api/card/${card.id}`).its("body.id").should("eq", card.id);
|
||
|
||
cy.signInAsAdmin();
|
||
removeDataAppQueryDeclaration(APP_ROOT(), "Orders");
|
||
sync();
|
||
|
||
// The copy is deleted with its declaration, so there is nothing left to
|
||
// read — the group's collection grant now covers an empty collection.
|
||
cy.signInAsNormalUser();
|
||
cy.request({ url: `/api/card/${card.id}`, failOnStatusCode: false })
|
||
.its("status")
|
||
.should("eq", 404);
|
||
});
|
||
});
|
||
|
||
// The copy exists so an app's viewers can read it: they are given the app's
|
||
// own group, which holds read on the app's collection and nothing else.
|
||
it("lets the app's group read the copy while the rest of the instance cannot", () => {
|
||
syncOneQuery().then((card) => {
|
||
dataAppPermissionGroupId(APP_SLUG).then((groupId) => {
|
||
cy.signInAsNormalUser();
|
||
cy.request({ url: `/api/card/${card.id}`, failOnStatusCode: false })
|
||
.its("status")
|
||
.should("eq", 403);
|
||
|
||
cy.signInAsAdmin();
|
||
addUserToGroup(groupId, USERS.normal.email);
|
||
|
||
cy.signInAsNormalUser();
|
||
cy.request(`/api/card/${card.id}`)
|
||
.its("body.id")
|
||
.should("eq", card.id);
|
||
});
|
||
});
|
||
});
|
||
|
||
// `read-check-data-app` 403s a non-member, and the app view turns that status
|
||
// into its own empty state instead of loading the bundle.
|
||
it("tells a viewer outside the group that the app is not theirs to open", () => {
|
||
syncOneQuery().then(() => {
|
||
cy.signInAsNormalUser();
|
||
cy.visit(`/apps/${APP_SLUG}`);
|
||
|
||
cy.findByText("You don’t have access to this data app").should("exist");
|
||
cy.get("iframe").should("not.exist");
|
||
});
|
||
});
|
||
});
|
||
});
|