/** * PluginSettings: auto-generated settings form from a plugin's * settingsSchema (#341). Covers field rendering per type, the * write-only secret contract, and the save payload. */ import { Toasty } from "@cloudflare/kumo"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import type { PluginInfo, PluginSettingsResponse } from "../../src/lib/api/plugins"; import { render } from "../utils/render.tsx"; vi.mock("@tanstack/react-router", async () => { const actual = await vi.importActual("@tanstack/react-router"); return { ...actual, Link: ({ children, to, ...props }: any) => ( {children} ), useNavigate: () => vi.fn(), }; }); const mockFetchPlugin = vi.fn<() => Promise>(); const mockFetchPluginSettings = vi.fn<() => Promise>(); const mockUpdatePluginSettings = vi.fn<(pluginId: string, values: Record) => Promise>(); vi.mock("../../src/lib/api/plugins", async () => { const actual = await vi.importActual("../../src/lib/api/plugins"); return { ...actual, fetchPlugin: (...args: unknown[]) => mockFetchPlugin(...(args as [])), fetchPluginSettings: (...args: unknown[]) => mockFetchPluginSettings(...(args as [])), updatePluginSettings: (...args: unknown[]) => mockUpdatePluginSettings(...(args as [string, Record])), }; }); const { PluginSettings } = await import("../../src/components/PluginSettings"); const SETTINGS: PluginSettingsResponse = { schema: { siteKey: { type: "string", label: "Turnstile Site Key", description: "Public key" }, retries: { type: "number", label: "Retries", min: 0, max: 10 }, enabled: { type: "boolean", label: "Enable checks" }, mode: { type: "select", label: "Mode", options: [ { value: "fast", label: "Fast" }, { value: "safe", label: "Safe" }, ], default: "safe", }, secretKey: { type: "secret", label: "Turnstile Secret Key" }, }, values: { siteKey: "0xAAA", retries: 3, enabled: true, mode: "safe" }, secretsSet: { secretKey: true }, }; function Wrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return ( {children} ); } describe("PluginSettings", () => { beforeEach(() => { vi.clearAllMocks(); mockFetchPlugin.mockResolvedValue({ id: "emdash-forms", name: "Forms", version: "1.0.0", enabled: true, status: "active", capabilities: [], hasAdminPages: true, hasDashboardWidgets: false, hasHooks: false, hasSettings: true, }); mockFetchPluginSettings.mockResolvedValue(SETTINGS); mockUpdatePluginSettings.mockResolvedValue(SETTINGS); }); it("renders a field for every schema entry with current values", async () => { const screen = await render( , ); const siteKey = screen.getByLabelText("Turnstile Site Key"); await expect.element(siteKey).toBeInTheDocument(); expect((siteKey.element() as HTMLInputElement).value).toBe("0xAAA"); const retries = screen.getByLabelText("Retries"); await expect.element(retries).toBeInTheDocument(); expect((retries.element() as HTMLInputElement).value).toBe("3"); await expect.element(screen.getByRole("switch", { name: "Enable checks" })).toBeInTheDocument(); await expect.element(screen.getByText("Mode")).toBeInTheDocument(); await expect.element(screen.getByLabelText("Turnstile Secret Key")).toBeInTheDocument(); }); it("masks stored secrets: empty input with a 'currently set' hint", async () => { const screen = await render( , ); const secret = screen.getByLabelText("Turnstile Secret Key"); await expect.element(secret).toBeInTheDocument(); const input = secret.element() as HTMLInputElement; expect(input.value).toBe(""); expect(input.type).toBe("password"); expect(input.placeholder).toContain("Currently set"); await expect.element(screen.getByText("Clear stored value")).toBeInTheDocument(); }); it("saves edited values but omits untouched secrets", async () => { const screen = await render( , ); const siteKey = screen.getByLabelText("Turnstile Site Key"); await expect.element(siteKey).toBeInTheDocument(); await siteKey.fill("0xBBB"); await screen.getByRole("button", { name: "Save Settings" }).first().click(); await vi.waitFor(() => { expect(mockUpdatePluginSettings).toHaveBeenCalledTimes(1); }); const [pluginId, values] = mockUpdatePluginSettings.mock.calls[0]!; expect(pluginId).toBe("emdash-forms"); expect(values).toEqual({ siteKey: "0xBBB", retries: 3, enabled: true, mode: "safe", }); // Untouched secret is not sent — the stored value stays as-is. expect("secretKey" in values).toBe(false); }); it("sends null (not empty string) for a cleared field so it reverts to the default", async () => { const screen = await render( , ); const siteKey = screen.getByLabelText("Turnstile Site Key"); await expect.element(siteKey).toBeInTheDocument(); await siteKey.fill(""); await screen.getByRole("button", { name: "Save Settings" }).first().click(); await vi.waitFor(() => { expect(mockUpdatePluginSettings).toHaveBeenCalledTimes(1); }); const [, values] = mockUpdatePluginSettings.mock.calls[0]!; // Storing "" would shadow the schema default forever; null deletes // the stored value server-side so the default applies again. expect(values.siteKey).toBeNull(); }); it("sends null for a cleared secret", async () => { const screen = await render( , ); await expect.element(screen.getByLabelText("Turnstile Secret Key")).toBeInTheDocument(); await screen.getByText("Clear stored value").click(); await screen.getByRole("button", { name: "Save Settings" }).first().click(); await vi.waitFor(() => { expect(mockUpdatePluginSettings).toHaveBeenCalledTimes(1); }); const [, values] = mockUpdatePluginSettings.mock.calls[0]!; expect(values.secretKey).toBeNull(); }); it("sends a typed secret value", async () => { const screen = await render( , ); const secret = screen.getByLabelText("Turnstile Secret Key"); await expect.element(secret).toBeInTheDocument(); await secret.fill("new-secret"); await screen.getByRole("button", { name: "Save Settings" }).first().click(); await vi.waitFor(() => { expect(mockUpdatePluginSettings).toHaveBeenCalledTimes(1); }); const [, values] = mockUpdatePluginSettings.mock.calls[0]!; expect(values.secretKey).toBe("new-secret"); }); it("shows an empty state for a plugin without settings", async () => { mockFetchPluginSettings.mockResolvedValue({ schema: {}, values: {}, secretsSet: {} }); const screen = await render( , ); await expect .element(screen.getByText("This plugin has no configurable settings.")) .toBeInTheDocument(); }); });