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