Files
metabase/e2e/test/scenarios/metabot/ai-controls.cy.spec.ts
Denis Berezin 24a52962de UXW-3922 Add syntax highlight for AI system prompts (#79675)
* UXW-3922 Add syntax highlight for AI system prompts

* Review fixes

* Fix CI

* Update Loki Snapshots

* Update Loki Snapshots

* Fix e2e

---------

Co-authored-by: Metabase Automation <github-automation@metabase.com>
2026-10-02 22:18:58 +05:00

737 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ALL_USERS_GROUP_ID,
NORMAL_USER_ID,
} from "e2e/support/cypress_sample_instance_data";
const { H } = cy;
describe("AI Controls > Metabot access and customization", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.updateSetting("metabot-enabled?", true);
llmMockServerSetup();
});
afterEach(() => {
llmMockServerTeardown();
});
describe("Feature Access page", () => {
it("should display the AI feature access table with groups and save a permission change", () => {
cy.intercept("GET", "/api/ee/ai-controls/permissions").as(
"getPermissions",
);
cy.intercept("PUT", "/api/ee/ai-controls/permissions").as(
"updatePermissions",
);
cy.visit("/admin/metabot/usage-controls/ai-feature-access");
cy.wait("@getPermissions");
cy.findByTestId("ai-feature-access-table").should("be.visible");
// Verify column headers
cy.findByTestId("ai-feature-access-table").within(() => {
cy.findByText("AI features").should("be.visible");
cy.findByText("Chat and NLQ").should("be.visible");
cy.findByText("SQL generation").should("be.visible");
cy.findByText("Other tools").should("be.visible");
});
// Admins row checkboxes should be always checked and disabled
cy.findByRole("row", { name: /Administrators permissions/ }).within(
() => {
cy.findByRole("switch").should("be.checked").and("be.disabled");
},
);
cy.findByRole("row", { name: /All Users permissions/ }).within(() => {
// Toggle the metabot AI features switch off (default is checked)
cy.findByRole("switch").should("be.checked").click({ force: true });
cy.wait("@updatePermissions")
.its("response.statusCode")
.should("eq", 200);
// Check that the switch is off and all checkboxes are unchecked
cy.findByRole("switch").should("not.be.checked");
cy.findAllByRole("checkbox").each(($checkbox) => {
cy.wrap($checkbox).should("not.be.checked");
});
// Toggle the metabot AI features switch on again
cy.findByRole("switch").click({ force: true });
cy.wait("@updatePermissions")
.its("response.statusCode")
.should("eq", 200);
// When metabot permission is toggled on, all tools should be enabled by default
cy.findAllByRole("checkbox").each(($checkbox) => {
cy.wrap($checkbox).should("be.checked");
});
});
});
});
describe("Group access controls", () => {
it("should not show the Metabot chat icon for users in a group without Metabot access", () => {
// First, get the current permissions so we can update only the All Users group
cy.request("GET", "/api/ee/ai-controls/permissions").then((response) => {
const currentPermissions: Array<{
group_id: number;
perm_type: string;
perm_value: string;
}> = response.body.permissions;
// Set All Users group's metabot permission to "no"
const updatedPermissions = currentPermissions.map((p) => {
if (
p.group_id === ALL_USERS_GROUP_ID &&
p.perm_type === "permission/metabot"
) {
return { ...p, perm_value: "no" };
}
return p;
});
cy.request("PUT", "/api/ee/ai-controls/permissions", {
permissions: updatedPermissions,
});
});
// Sign in as a normal user (who is only in the All Users group)
cy.signInAsNormalUser();
cy.visit("/");
// Wait for the navigation bar to be present
cy.findByLabelText("Navigation bar").should("be.visible");
// The Metabot chat icon should not be present
H.appBar().find('[aria-label*="Chat with"]').should("not.exist");
});
});
describe("Customization", () => {
it("should save a custom Metabot name", () => {
cy.intercept("PUT", "/api/setting/metabot-name").as("saveName");
cy.visit("/admin/metabot/customization");
cy.findByRole("heading", { name: "Customization", level: 1 }).should(
"be.visible",
);
cy.findByLabelText("AI agent's name")
.should("be.visible")
.clear()
.type("HAL 9000");
// Wait for debounced save
cy.wait("@saveName").its("response.statusCode").should("eq", 204);
// Reload and verify persistence
cy.reload();
cy.findByLabelText("AI agent's name").should("have.value", "HAL 9000");
});
it("should upload a custom Metabot icon", () => {
cy.intercept("PUT", "/api/setting/metabot-icon").as("saveIcon");
cy.visit("/admin/metabot/customization");
H.main().findByText("AI agent's icon").should("be.visible");
cy.findByRole("button", { name: "Upload a custom icon" }).should(
"be.visible",
);
// Upload a tiny PNG via the hidden file input
cy.get('input[type="file"]').selectFile(
{
contents: Cypress.Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
fileName: "metabot-icon.png",
mimeType: "image/png",
},
{ force: true },
);
cy.wait("@saveIcon").its("response.statusCode").should("eq", 204);
// The "Remove custom icon" button should be visible
cy.findByLabelText("Remove custom icon").should("be.visible");
});
it("should hide Metabot illustrations when the toggle is switched off", () => {
H.updateEnterpriseSettings({
"metabot-show-illustrations": true,
});
H.updateSetting("metabot-enabled?", true);
cy.intercept("PUT", "/api/setting/metabot-show-illustrations").as(
"saveIllustrations",
);
cy.visit("/admin/metabot/customization");
H.main().findByText("Metabot illustrations").should("be.visible");
// The switch should be ON
cy.findByRole("switch", { name: "Show Metabot illustrations" }).should(
"have.attr",
"data-checked",
"true",
);
// Toggle illustrations off
cy.findByRole("switch", {
name: "Show Metabot illustrations",
}).click({ force: true });
cy.wait("@saveIllustrations")
.its("response.statusCode")
.should("eq", 204);
// Navigate to the home page and open Metabot chat to verify illustrations are hidden
cy.visit("/");
H.openMetabotViaSearchButton();
cy.findByTestId("metabot-empty-chat-info").should("be.visible");
// The SVG illustration should NOT be rendered when showIllustrations=false
cy.findByTestId("metabot-empty-chat-info")
.find("svg")
.should("not.exist");
// But the hint text should still be visible, with the no-illustration copy
cy.findByTestId("metabot-empty-chat-info")
.findByText(/Explore your metrics and models with AI/)
.should("be.visible");
});
it("should show the custom Metabot name in the app bar button tooltip", () => {
H.updateSetting("metabot-name", "Aria");
cy.visit("/");
cy.findByLabelText("Navigation bar").should("be.visible");
// The app bar button tooltip/aria-label should reflect the custom name
H.appBar()
.findByRole("button", { name: /Chat with Aria/ })
.should("be.visible");
});
it("should show a custom Metabot icon in the app bar when metabot-icon is set", () => {
H.updateEnterpriseSettings({ "metabot-icon": TINY_PNG_DATA_URI });
cy.visit("/");
cy.findByLabelText("Navigation bar").should("be.visible");
// The custom icon img should be rendered (alt = metabot name)
H.appBar()
.findByRole("button", { name: /Chat with/ })
.within(() => {
// When a custom icon is set, MetabotIcon renders an <img> with alt = metabotName
cy.get("img").should("be.visible");
});
});
});
describe("System Prompts pages", () => {
it("should save a custom Metabot chat system prompt", () => {
cy.intercept("PUT", "/api/setting/metabot-chat-system-prompt").as(
"savePrompt",
);
cy.visit("/admin/metabot/system-prompts/metabot-chat");
cy.findByRole("heading", {
name: "AI chat prompt instructions",
level: 1,
}).should("be.visible");
cy.findByRole("textbox", { name: /AI chat prompt instructions/ })
.should("be.visible")
.click()
.type("Be concise and helpful.")
.blur();
cy.wait("@savePrompt").its("response.statusCode").should("eq", 204);
// Reload and verify persistence
cy.reload();
cy.findByRole("textbox", {
name: /AI chat prompt instructions/,
}).should("have.text", "Be concise and helpful.");
});
it("should save a custom SQL generation system prompt", () => {
cy.intercept("PUT", "/api/setting/metabot-sql-system-prompt").as(
"saveSqlPrompt",
);
cy.visit("/admin/metabot/system-prompts/sql-generation");
cy.findByRole("heading", {
name: "SQL generation prompt instructions",
level: 1,
}).should("be.visible");
cy.findByRole("textbox", {
name: /SQL generation prompt instructions/,
})
.should("be.visible")
.click()
.type("Always use uppercase SQL keywords.")
.blur();
cy.wait("@saveSqlPrompt").its("response.statusCode").should("eq", 204);
cy.reload();
cy.findByRole("textbox", {
name: /SQL generation prompt instructions/,
}).should("have.text", "Always use uppercase SQL keywords.");
});
});
});
describe("AI controls > AI usage limits", () => {
const AI_USAGE_LIMITS_URL = "/admin/metabot/usage-controls/ai-usage-limits";
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
llmMockServerSetup();
});
afterEach(() => {
llmMockServerTeardown();
});
describe("AI Limits settings can be saved properly", () => {
beforeEach(() => {
cy.intercept("GET", "/api/ee/ai-controls/usage/instance").as(
"getInstanceLimit",
);
cy.intercept("PUT", "/api/ee/ai-controls/usage/instance").as(
"updateInstanceLimit",
);
cy.intercept("GET", "/api/ee/ai-controls/usage/group").as(
"getGroupLimits",
);
cy.intercept("PUT", "/api/setting/metabot-limit-unit").as(
"saveLimitUnit",
);
cy.intercept("PUT", "/api/setting/metabot-limit-reset-rate").as(
"saveLimitPeriod",
);
cy.intercept("PUT", "/api/setting/metabot-quota-reached-message").as(
"saveQuotaMessage",
);
});
it("should save limit type, reset period, instance limit, and quota-reached message when changed", () => {
cy.visit(AI_USAGE_LIMITS_URL);
cy.wait("@getInstanceLimit");
// Change limit type to messages
cy.findByRole("radio", { name: "By message count" }).click({
force: true,
});
cy.wait("@saveLimitUnit").then(({ request }) => {
expect(request.body).to.deep.equal({ value: "messages" });
});
// Change reset period to weekly
cy.findByRole("radio", { name: "Weekly" }).click({ force: true });
cy.wait("@saveLimitPeriod").then(({ request }) => {
expect(request.body).to.deep.equal({ value: "weekly" });
});
// Type an instance limit value
cy.findByLabelText("Total weekly instance message limit").type("500");
cy.wait("@updateInstanceLimit").then(({ request }) => {
expect(request.body).to.deep.equal({ max_usage: 500 });
});
// Type a quota-reached message
cy.findByLabelText("Quota-reached message")
.clear()
.type("You have hit the AI usage limit.");
cy.wait("@saveQuotaMessage").then(({ request }) => {
expect(request.body).to.deep.equal({
value: "You have hit the AI usage limit.",
});
});
});
it("should save instance limit as null when the field is cleared", () => {
// Pre-set a limit so there's something to clear
cy.request("PUT", "/api/ee/ai-controls/usage/instance", {
max_usage: 100,
});
cy.visit(AI_USAGE_LIMITS_URL);
cy.wait("@getInstanceLimit");
cy.findByRole("textbox", {
name: "Total monthly instance token limit",
}).clear();
cy.wait("@updateInstanceLimit").then(({ request }) => {
expect(request.body).to.deep.equal({ max_usage: null });
});
});
});
describe("When instance limit is set to 0", () => {
beforeEach(() => {
// Enable Metabot with a configured LLM key
H.updateSetting("metabot-enabled?", true);
// Set messages as the limit type (easier to trigger with 0)
// (These settings are added in PR #71699, so we call the API directly)
cy.request("PUT", "/api/setting/metabot-limit-unit", {
value: "messages",
});
// Set a custom quota-reached message
cy.request("PUT", "/api/setting/metabot-quota-reached-message", {
value: DEFAULT_QUOTA_MESSAGE,
});
// Set instance limit to 0 — any usage will immediately exceed it
cy.request("PUT", "/api/ee/ai-controls/usage/instance", {
max_usage: 0,
});
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
cy.intercept("GET", "/api/automagic-dashboards/database/*/candidates").as(
"xrayCandidates",
);
llmMockServerSetup();
});
afterEach(() => {
llmMockServerTeardown();
});
it("should show the quota-reached message when the user sends a message to Metabot", () => {
cy.visit("/");
cy.wait("@xrayCandidates");
H.openMetabotViaSearchButton();
H.sendMetabotMessage("hello");
// The backend returns the quota-reached message when limit is exceeded
H.lastChatMessage().should("have.text", DEFAULT_QUOTA_MESSAGE);
});
});
describe("Group limits handling", () => {
let groupAId: number;
let groupBId: number;
beforeEach(() => {
cy.request("PUT", "/api/setting/metabot-limit-unit", {
value: "messages",
});
cy.request("PUT", "/api/setting/metabot-quota-reached-message", {
value: DEFAULT_QUOTA_MESSAGE,
});
// Set instance limit high so it doesn't interfere
cy.request("PUT", "/api/ee/ai-controls/usage/instance", {
max_usage: 1000,
});
// limit-for-user returns NULL (unlimited) when any of the user's groups
// has no metabot_group_limit row, and returns the MAX across all groups.
// Set a low limit (1) on every pre-existing group the normal user
// belongs to so the effective limit equals Group B's 100.
// Note: the membership API is keyed by user_id, with each entry
// containing a group_id property.
cy.request("GET", "/api/permissions/membership").then(({ body }) => {
// Unjustified type cast. FIXME
const memberships = body[String(NORMAL_USER_ID)] as
| Array<{ group_id: number }>
| undefined;
if (memberships) {
for (const m of memberships) {
cy.request("PUT", `/api/ee/ai-controls/usage/group/${m.group_id}`, {
max_usage: 1,
});
}
}
});
// Create group A (low limit: 5) and group B (high limit: 100);
// add the normal user to both
cy.request("POST", "/api/permissions/group", {
name: "AI Limit Group A (low)",
}).then(({ body }) => {
groupAId = body.id;
cy.request("PUT", `/api/ee/ai-controls/usage/group/${groupAId}`, {
max_usage: 5,
});
cy.request("POST", "/api/permissions/membership", {
group_id: groupAId,
user_id: NORMAL_USER_ID,
});
});
cy.request("POST", "/api/permissions/group", {
name: "AI Limit Group B (high)",
}).then(({ body }) => {
groupBId = body.id;
cy.request("PUT", `/api/ee/ai-controls/usage/group/${groupBId}`, {
max_usage: 100,
});
cy.request("POST", "/api/permissions/membership", {
group_id: groupBId,
user_id: NORMAL_USER_ID,
});
});
cy.intercept("GET", "/api/automagic-dashboards/database/*/candidates").as(
"xrayCandidates",
);
});
afterEach(() => {
// Sign back in as admin for cleanup (tests may end signed in as normal user)
cy.signInAsAdmin();
// Clean up seeded usage rows and groups
cy.request("DELETE", "/api/testing/metabot/seed-ai-usage", {
user_id: NORMAL_USER_ID,
});
cy.request("PUT", "/api/ee/ai-controls/usage/instance", {
max_usage: null,
});
cy.then(() => {
if (groupAId) {
cy.request("DELETE", `/api/permissions/group/${groupAId}`);
}
if (groupBId) {
cy.request("DELETE", `/api/permissions/group/${groupBId}`);
}
});
});
it("should display both groups with their configured limits and note that users get the highest limit", () => {
cy.visit(AI_USAGE_LIMITS_URL);
// Both groups should appear in the group limits table with correct values
cy.findByLabelText(
"Max messages per user for AI Limit Group A (low)",
).should("have.value", "5");
cy.findByLabelText(
"Max messages per user for AI Limit Group B (high)",
).should("have.value", "100");
// The section description should explain that users get the highest limit
cy.findByTestId("group-limits-tab").within(() => {
cy.findByText(
/If a user belongs to more than one group, they'll be given the highest limit among all the groups/i,
)
.scrollIntoView()
.should("be.visible");
});
});
it("should respect the effective user limit (max across their groups)", () => {
// Normal user is in group A (limit 5) and group B (limit 100).
// Effective limit = max(1, 1, 1, 5, 100) = 100.
// (pre-existing groups are set to 1 in beforeEach)
// Seed 10 usage rows — below the effective limit of 100
cy.request("POST", "/api/testing/metabot/seed-ai-usage", {
user_id: NORMAL_USER_ID,
count: 10,
});
cy.signInAsNormalUser();
cy.visit("/");
cy.wait("@xrayCandidates");
H.openMetabotViaSearchButton();
H.sendMetabotMessage("hello");
// With usage (10) < effective limit (100) the backend passes the quota
// check, calls the mock LLM, and streams its response to the frontend.
H.lastChatMessage().should("contain.text", MOCK_LLM_RESPONSE);
// Seed additional rows so total usage (101) exceeds effective group limit (100)
cy.signInAsAdmin();
cy.request("POST", "/api/testing/metabot/seed-ai-usage", {
user_id: NORMAL_USER_ID,
count: 91,
});
cy.signInAsNormalUser();
cy.visit("/");
cy.wait("@xrayCandidates");
H.openMetabotViaSearchButton();
H.sendMetabotMessage("hello");
// With usage (101) > effective group limit (100) the backend short-circuits
// before calling the LLM and returns the quota-reached message.
H.lastChatMessage().should("have.text", DEFAULT_QUOTA_MESSAGE);
});
});
});
describe("AI Controls > Tenant usage limits", () => {
let tenantId: number;
let tenantUserId: number;
const TENANT_USER_EMAIL = "tenant.user@metabase-test.com";
const TENANT_USER_PASSWORD = "12341234";
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("pro-self-hosted");
H.updateSetting("metabot-enabled?", true);
cy.request("PUT", "/api/setting/metabot-quota-reached-message", {
value: DEFAULT_QUOTA_MESSAGE,
});
// Enable multi-tenancy
H.updateSetting("use-tenants", true);
// Create a tenant
cy.request("POST", "/api/ee/tenant", {
name: "Test Corp",
slug: "test-corp",
}).then(({ body }) => {
tenantId = body.id;
// Create a user belonging to that tenant
cy.request("POST", "/api/user", {
first_name: "Tenant",
last_name: "User",
email: TENANT_USER_EMAIL,
password: TENANT_USER_PASSWORD,
tenant_id: tenantId,
}).then(({ body: user }) => {
tenantUserId = user.id;
});
});
cy.intercept("POST", "/api/metabot/agent-streaming").as("agentReq");
cy.intercept("GET", "/api/automagic-dashboards/database/*/candidates").as(
"xrayCandidates",
);
cy.intercept("PUT", "/api/ee/ai-controls/usage/tenant/*").as(
"updateTenantLimit",
);
llmMockServerSetup();
});
afterEach(() => {
llmMockServerTeardown();
});
it("should allow updating tenant limits when tenants are enabled", () => {
cy.visit("/admin/metabot/usage-controls/ai-usage-limits");
cy.findByRole("tab", { name: "Specific tenants" }).click();
cy.findByTestId("tenant-limits-tab").should("be.visible");
cy.findByTestId("tenant-limits-tab")
.findByText("Test Corp")
.should("be.visible");
cy.findByLabelText("Max total monthly tokens for Test Corp").type("10");
cy.wait("@updateTenantLimit").its("response.statusCode").should("eq", 200);
});
it("should show the quota-reached message when the tenant limit is set to 0 and the user sends a message", () => {
// Set tenant limit to 0 — any usage will immediately exceed it
cy.request("PUT", `/api/ee/ai-controls/usage/tenant/${tenantId}`, {
max_usage: 0,
});
// Clear the backend limit-check cache so the new limit is seen immediately,
cy.request("DELETE", "/api/testing/metabot/seed-ai-usage", {
user_id: tenantUserId,
});
// Sign in as the tenant user
cy.request("POST", "/api/session", {
username: TENANT_USER_EMAIL,
password: TENANT_USER_PASSWORD,
});
cy.visit("/");
cy.wait("@xrayCandidates");
H.openMetabotViaSearchButton();
H.sendMetabotMessage("hello");
// The backend returns the quota-reached message when the tenant limit is exceeded
H.lastChatMessage().should("contain.text", DEFAULT_QUOTA_MESSAGE);
});
it("should not show the quota-reached message when no tenant limit is set", () => {
// No tenant limit is set — the chat input should be available without a quota message
cy.request("PUT", `/api/ee/ai-controls/usage/tenant/${tenantId}`, {
max_usage: null,
});
// Clear the backend limit-check cache so the new null limit is seen immediately,
cy.request("DELETE", "/api/testing/metabot/seed-ai-usage", {
user_id: tenantUserId,
});
// Sign in as the tenant user
cy.request("POST", "/api/session", {
username: TENANT_USER_EMAIL,
password: TENANT_USER_PASSWORD,
});
cy.visit("/");
cy.wait("@xrayCandidates");
H.openMetabotViaSearchButton();
H.sendMetabotMessage("hello");
H.lastChatMessage().should("contain.text", MOCK_LLM_RESPONSE);
H.lastChatMessage().should("not.contain.text", DEFAULT_QUOTA_MESSAGE);
});
});
// A tiny valid PNG (1×1 transparent pixel) as a data URI, used for icon upload tests
const TINY_PNG_DATA_URI =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
const MOCK_LLM_PORT = 6123;
const MOCK_LLM_RESPONSE = "Hello from mock LLM!";
const DEFAULT_QUOTA_MESSAGE =
"You have reached your AI usage limit for this period. Please try again later, Batman.";
function llmMockServerSetup() {
// Start a mock server that impersonates the Anthropic Messages API
// so requests flow through the full backend (including quota checks)
// without needing a real LLM key.
cy.task("startMockLlmServer", {
port: MOCK_LLM_PORT,
responseText: MOCK_LLM_RESPONSE,
});
H.setupAnthropicLlmProvider({
baseUrl: `http://localhost:${MOCK_LLM_PORT}`,
});
}
function llmMockServerTeardown() {
cy.task("stopMockLlmServer");
}