diff --git a/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx index f95b4bb9ae..8dd501ab76 100644 --- a/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx +++ b/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx @@ -126,6 +126,8 @@ afterEach(() => { }); describe("PluginManager registry browsing", () => { + // GAP: The API supports ?category= filtering but the UI does not expose a category selector. + // A future task should add category filter UI and tests. it("renders registry entries with metadata", async () => { await renderRegistry(); @@ -165,6 +167,32 @@ describe("PluginManager registry browsing", () => { expect(fetchPlugins).toHaveBeenCalledTimes(2); }); + it("surfaces install rejection without unmounting registry results", async () => { + vi.mocked(installPlugin).mockRejectedValueOnce(new Error("install rejected")); + await renderRegistry(); + + const installable = screen.getByText("Installable Registry").closest(".plugin-registry-item") as HTMLElement; + const installButton = within(installable).getByRole("button", { name: "Install" }); + fireEvent.click(installButton); + expect(within(installable).getByRole("button", { name: "Installing..." })).toBeDisabled(); + + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(addToast).toHaveBeenCalledWith(expect.stringContaining("install rejected"), "error"); + expect(screen.getByRole("region", { name: "Browse Registry" })).toBeInTheDocument(); + expect(screen.getByText("Installable Registry")).toBeInTheDocument(); + expect(within(installable).getByRole("button", { name: "Install" })).toBeEnabled(); + + const searchInput = screen.getByPlaceholderText("Search registry plugins"); + fireEvent.change(searchInput, { target: { value: "still interactive" } }); + expect(searchInput).toHaveValue("still interactive"); + }); + it("opens detail management for installed entries", async () => { await renderRegistry(); @@ -227,6 +255,42 @@ describe("PluginManager registry browsing", () => { expect(fetchPluginRegistry).toHaveBeenCalledTimes(2); }); + it("renders very long registry metadata inside the registry item container", async () => { + const longName = `Very Long Registry Plugin ${"Name".repeat(140)}`; + const longDescription = `Description ${"with lengthy details ".repeat(40)}`; + await renderRegistry([ + { + id: "registry-long-metadata", + name: longName, + description: longDescription, + version: "9.9.9", + author: "Fusion Labs", + category: "integration", + path: "./plugins/registry-long-metadata", + installed: false, + canInstall: true, + }, + ]); + + const item = screen.getByText(longName).closest(".plugin-registry-item") as HTMLElement; + expect(item).toBeInTheDocument(); + expect(item).toHaveClass("plugin-registry-item"); + expect(within(item).getByText(longName)).toBeInTheDocument(); + expect(item).toHaveTextContent(longDescription.trim()); + }); + + it("renders registry browsing controls at narrow viewport widths", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 360 }); + window.dispatchEvent(new Event("resize")); + + await renderRegistry(); + + const section = screen.getByRole("region", { name: "Browse Registry" }); + expect(within(section).getByPlaceholderText("Search registry plugins")).toBeInTheDocument(); + expect(within(section).getByLabelText("Registry plugin results")).toBeInTheDocument(); + expect(within(section).getByText("Installable Registry")).toBeInTheDocument(); + }); + it("shows empty state when no registry entries match", async () => { await renderRegistry([]); diff --git a/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts b/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts index c63ceede73..f6a68afa3a 100644 --- a/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts +++ b/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts @@ -130,6 +130,44 @@ describe("GET /api/plugins/registry", () => { await expect(buildRegistryPluginEntries({}, pluginStore)).resolves.toEqual([]); await expect(buildRegistryPluginEntries({ plugins: [] }, pluginStore)).resolves.toEqual([]); + await expect(buildRegistryPluginEntries({ plugins: "not-an-array" }, pluginStore)).resolves.toEqual([]); + }); + + it("filters invalid manifest entry shapes", async () => { + const pluginStore = createMockPluginStore(); + + await expect( + buildRegistryPluginEntries({ plugins: [null, undefined, "string-entry", 42] }, pluginStore), + ).resolves.toEqual([]); + await expect( + buildRegistryPluginEntries({ plugins: [{ id: 123, name: null }] }, pluginStore), + ).resolves.toEqual([]); + }); + + describe("input sanitization", () => { + it.each([ + ["HTML/script injection", ""], + ["SQL-injection-like text", "' OR 1=1 --"], + ["extremely long text", "x".repeat(10_001)], + ["regex-special characters", "[.*+]"], + ])("treats %s in q as plain search text", async (_label, query) => { + const pluginStore = createMockPluginStore(); + const res = await performGet(buildApp(pluginStore), `/api/plugins/registry?q=${encodeURIComponent(query)}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ plugins: [] }); + }); + + it.each(["nonexistent", "../../etc", "runtime