Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions __tests__/components/features/mcp-page/install-server-modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import SettingsService from "#/api/settings-service/settings-service.api";
import McpService from "#/api/mcp-service/mcp-service.api";
import { SecretsService } from "#/api/secrets-service";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { InstallServerModal } from "#/components/features/mcp-page/install-server-modal";
Expand Down Expand Up @@ -378,4 +379,197 @@ describe("InstallServerModal", () => {
),
);
});

// ---------------------------------------------------------------------------
// Save-as-secret toggle behaviour
// ---------------------------------------------------------------------------

// Synthetic stdio entry with one password-type envField, one text-type
// envField, and one argField. This gives us complete control over field
// types without depending on the live integration catalog.
const STDIO_ENTRY = {
id: "synthetic-stdio",
kind: "mcp",
name: "Synthetic Stdio Server",
description: "Stdio server used to test the save-as-secret feature.",
iconBg: "#000000",
defaultConnectionOptionId: "stdio",
connectionOptions: [
{
id: "stdio",
provider: "mcp",
transport: {
kind: "stdio",
serverName: "test-server",
command: "npx",
args: ["-y", "test-mcp"],
envFields: [
{
key: "API_KEY",
label: "API Key",
type: "password",
required: true,
placeholder: "Enter API key",
},
{
key: "USERNAME",
label: "Username",
type: "text",
required: false,
placeholder: "Enter username",
},
],
argFields: [
{
key: "EXTRA_ARG",
label: "Extra Arg",
type: "text",
required: false,
placeholder: "optional",
},
],
},
auth: { strategy: "api_key", apiKeyOptional: true },
},
],
} as unknown as MarketplaceEntry;

describe("InstallServerModal — save as secret", () => {
beforeEach(() => {
vi.spyOn(SecretsService, "createSecret").mockResolvedValue();
});

it("pre-checks the toggle for password-type envFields", async () => {
renderWith(<InstallServerModal entry={STDIO_ENTRY} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");

const toggle = screen.getByTestId("mcp-install-save-secret-API_KEY");
expect(toggle.querySelector("input[type='checkbox']")).toBeChecked();
});

it("leaves non-password envFields unchecked by default", async () => {
renderWith(<InstallServerModal entry={STDIO_ENTRY} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");

const toggle = screen.getByTestId("mcp-install-save-secret-USERNAME");
expect(toggle.querySelector("input[type='checkbox']")).not.toBeChecked();
});

it("does not render a toggle for argFields", async () => {
renderWith(<InstallServerModal entry={STDIO_ENTRY} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");

expect(
screen.queryByTestId("mcp-install-save-secret-EXTRA_ARG"),
).not.toBeInTheDocument();
});

it("toggling the checkbox updates its checked state", async () => {
renderWith(<InstallServerModal entry={STDIO_ENTRY} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");

// USERNAME starts unchecked; clicking it should flip to checked.
const toggle = screen.getByTestId("mcp-install-save-secret-USERNAME");
const checkbox = toggle.querySelector(
"input[type='checkbox']",
) as HTMLInputElement;
expect(checkbox).not.toBeChecked();

fireEvent.click(checkbox);

expect(checkbox).toBeChecked();
});

it("setValue preserves savedAsSecret state when a field value changes", async () => {
// Before the ...prev bug-fix in setValue, calling onChange on any field
// would reset savedAsSecret to {}, unchecking all toggles silently.
renderWith(<InstallServerModal entry={STDIO_ENTRY} onClose={vi.fn()} />);
await screen.findByTestId("mcp-install-modal");

// API_KEY starts pre-checked. Typing a new value should leave it checked.
fireEvent.change(screen.getByTestId("mcp-install-field-API_KEY"), {
target: { value: "new-value" },
});

const toggle = screen.getByTestId("mcp-install-save-secret-API_KEY");
expect(toggle.querySelector("input[type='checkbox']")).toBeChecked();
});

it("calls createSecret for checked envFields after a successful install", async () => {
vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true);
const onClose = vi.fn();
renderWith(<InstallServerModal entry={STDIO_ENTRY} onClose={onClose} />);
await screen.findByTestId("mcp-install-modal");
await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled());

// Fill in the required password field (API_KEY is pre-checked as secret).
fireEvent.change(screen.getByTestId("mcp-install-field-API_KEY"), {
target: { value: "my-api-key" },
});
fireEvent.click(screen.getByTestId("mcp-install-submit"));

await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(SecretsService.createSecret).toHaveBeenCalledWith(
"API_KEY",
"my-api-key",
"API Key",
),
);
// USERNAME was unchecked, so no secret call for it.
expect(SecretsService.createSecret).not.toHaveBeenCalledWith(
"USERNAME",
expect.anything(),
expect.anything(),
);
});

it("does not call createSecret when all toggles are unchecked before install", async () => {
vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true);
const onClose = vi.fn();
renderWith(<InstallServerModal entry={STDIO_ENTRY} onClose={onClose} />);
await screen.findByTestId("mcp-install-modal");
await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled());

fireEvent.change(screen.getByTestId("mcp-install-field-API_KEY"), {
target: { value: "my-api-key" },
});

// Uncheck the pre-checked API_KEY toggle before submitting.
const toggle = screen.getByTestId("mcp-install-save-secret-API_KEY");
fireEvent.click(toggle.querySelector("input[type='checkbox']")!);

fireEvent.click(screen.getByTestId("mcp-install-submit"));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));

// Flush the fire-and-forget microtask chain.
await Promise.resolve();
await Promise.resolve();
expect(SecretsService.createSecret).not.toHaveBeenCalled();
});

it("closes the modal even when the background secret save fails", async () => {
vi.spyOn(SettingsService, "saveSettings").mockResolvedValue(true);
vi.spyOn(SecretsService, "createSecret").mockRejectedValue(
new Error("forbidden"),
);
const onClose = vi.fn();
renderWith(<InstallServerModal entry={STDIO_ENTRY} onClose={onClose} />);
await screen.findByTestId("mcp-install-modal");
await waitFor(() => expect(SettingsService.getSettings).toHaveBeenCalled());

fireEvent.change(screen.getByTestId("mcp-install-field-API_KEY"), {
target: { value: "my-api-key" },
});
fireEvent.click(screen.getByTestId("mcp-install-submit"));

// The modal must close regardless of the secret-save outcome.
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
// Secret save errors use toasts, not the modal inline error element.
expect(
screen.queryByTestId("mcp-install-modal-error"),
).not.toBeInTheDocument();
});
});

});
113 changes: 113 additions & 0 deletions __tests__/components/features/mcp-page/save-as-secret-toggle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { ReactNode } from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { SaveAsSecretToggle } from "#/components/features/mcp-page/save-as-secret-toggle";

// HeroUI's Tooltip (used inside StyledTooltip) only mounts its content on a
// real hover event, which jsdom doesn't fire. Stub it so the content renders
// eagerly and is queryable in tests.
vi.mock("#/components/shared/buttons/styled-tooltip", () => ({
StyledTooltip: ({
content,
children,
}: {
content: ReactNode;
children: ReactNode;
}) => (
<>
{children}
<span data-testid="styled-tooltip-content">{content}</span>
</>
),
}));

describe("SaveAsSecretToggle", () => {
// ── rendering ──────────────────────────────────────────────────────────────

it("attaches data-testid to the label using fieldKey", () => {
render(
<SaveAsSecretToggle fieldKey="MY_KEY" checked={false} onToggle={vi.fn()} />,
);
expect(
screen.getByTestId("mcp-install-save-secret-MY_KEY"),
).toBeInTheDocument();
});

it("renders the field key inside a <code> element", () => {
render(
<SaveAsSecretToggle fieldKey="MY_KEY" checked={false} onToggle={vi.fn()} />,
);
const codeEl = screen.getByText("MY_KEY");
expect(codeEl.tagName.toLowerCase()).toBe("code");
});

it("renders the checkbox unchecked when checked=false", () => {
render(
<SaveAsSecretToggle fieldKey="KEY" checked={false} onToggle={vi.fn()} />,
);
expect(screen.getByRole("checkbox")).not.toBeChecked();
});

it("renders the checkbox checked when checked=true", () => {
render(
<SaveAsSecretToggle fieldKey="KEY" checked={true} onToggle={vi.fn()} />,
);
expect(screen.getByRole("checkbox")).toBeChecked();
});

// ── accessibility ──────────────────────────────────────────────────────────

it("the info button has an aria-label describing its purpose", () => {
render(
<SaveAsSecretToggle fieldKey="KEY" checked={false} onToggle={vi.fn()} />,
);
// t(key) => key in tests, so aria-label equals the raw i18n key.
const infoBtn = screen.getByRole("button");
expect(infoBtn).toHaveAttribute(
"aria-label",
"MCP$SAVE_AS_SECRET_TOOLTIP",
);
});

it("the visual track is hidden from the accessibility tree (aria-hidden)", () => {
const { container } = render(
<SaveAsSecretToggle fieldKey="KEY" checked={false} onToggle={vi.fn()} />,
);
// The decorative <span> that forms the visual slider track.
const track = container.querySelector("span[aria-hidden='true']");
expect(track).toBeInTheDocument();
});

// ── interaction ────────────────────────────────────────────────────────────

it("calls onToggle(true) when the unchecked checkbox is clicked", () => {
const onToggle = vi.fn();
render(
<SaveAsSecretToggle fieldKey="KEY" checked={false} onToggle={onToggle} />,
);
fireEvent.click(screen.getByRole("checkbox"));
expect(onToggle).toHaveBeenCalledWith(true);
});

it("calls onToggle(false) when the checked checkbox is clicked", () => {
const onToggle = vi.fn();
render(
<SaveAsSecretToggle fieldKey="KEY" checked={true} onToggle={onToggle} />,
);
fireEvent.click(screen.getByRole("checkbox"));
expect(onToggle).toHaveBeenCalledWith(false);
});

// ── tooltip ────────────────────────────────────────────────────────────────

it("passes tooltip text to StyledTooltip as its content prop", () => {
render(
<SaveAsSecretToggle fieldKey="KEY" checked={false} onToggle={vi.fn()} />,
);
// The mock renders StyledTooltip's content prop into a <span>.
// t(key) => key, so the rendered text is the raw i18n key.
expect(screen.getByTestId("styled-tooltip-content")).toHaveTextContent(
"MCP$SAVE_AS_SECRET_TOOLTIP",
);
});
});
Loading
Loading