Files
kandev/apps/web/app/settings/plugins/page.test.tsx
T
João Salavisa e145d48f22 feat: add plugin system with grpc runtime and native ui plugins (#1742)
* feat: add plugin system with grpc runtime and native ui plugins

- go plugin backends ship as release tarballs (per-platform binaries, checksums) that
  kandev installs from url/upload or filesystem sideload+sync, then spawns and
  supervises via hashicorp/go-plugin (unix socket / loopback tcp+automtls)
- strict typed grpc contract (kandev.plugin.v1): DeliverEvent/InvokeTool/HandleWebhook
  plus a capability-gated host service (state, secrets, emit); author sdk in
  pkg/pluginsdk; packaging cli in cmd/plugin-pack
- native frontend bundles: window.registerKandevPlugin + registry/host loader with
  dynamic routes, nav items, slots and ws handlers; plugins settings page with
  install dialog, sync button and enable/disable/uninstall
- gated behind the plugins feature flag (prod off, dev/e2e on); covered by unit and
  integration tests plus playwright e2e against a real packaged fixture plugin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): hydrate base app context on plugin routes

Plugin-owned SPA routes classify as RouteUnknown, so the boot payload shipped
only feature flags — no workspaces, workflows, or kanban context. Native plugin
UI (e.g. host.ui.TaskCreateDialog) then had an empty store. Hydrate the same
base context as the Home route for unknown SPA routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): expose TaskCreateDialog on host.ui

Lets a native plugin open kandev's real create-task modal (prefilled via
initialValues) instead of POSTing directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): harden filesystem/URL sinks against CodeQL findings

Add sink-level guards on the plugin trust boundary: securejoin containment on
archive extraction (zip-slip), plugin-id validation before every fs_store path,
http/https scheme validation on install-from-URL, and non-interpolated ws-handler
error logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address review findings (races, resource caps, lifecycle)

- serialize per-plugin startup to close a TOCTOU double-spawn race, and let a
  plugin restart after exhausting attempts
- bound plugin startup with an explicit 30s timeout
- reject backslash paths and cap decompressed bytes by actual size in pkgtar;
  only mark a package Signed when a verifier actually ran and passed
- delete plugin_state on uninstall; validate webhook keys against the manifest
  and cap webhook body size; restart an active plugin when a new version installs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): sanitize plugin-id path sinks for CodeQL

Pin the plugin id to a single path segment with filepath.Base at recordPath/
configPath and pkgtar.Remove — a barrier the taint analysis recognizes.
Behaviour-identical for valid ids (already validated by safePluginID); clears
the go/path-injection alerts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): inline plugin-id validation at path sinks for CodeQL

filepath.Base does not strip '..', so it is not a path-injection barrier. Call
the recognized safePluginID / id-pattern guard inline in Delete/GetConfig/
SetConfig and pkgtar.Remove — the same barrier that already cleared the Get sink.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address code-review findings (backend)

- add capability-gating tests for the Host RPC service (was untested)
- close a stop-during-start orphan race so Disable/Uninstall can't leave a
  supervised subprocess behind
- on failed upgrade, remove only the new version dir (not all versions + data);
  restart the prior version
- validate manifest version and reject ids ending in .config (record-file alias)
- make Deliverer.Refresh non-blocking and cancel in-flight delivery on worker stop
- clamp out-of-range webhook status, skip-and-warn corrupt records in List,
  write records atomically, semver-aware sideload version pick

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address code-review findings (frontend)

- wrap plugin routes + settings routes in a shared PluginErrorBoundary so a
  throwing plugin page shows a fallback instead of white-screening the SPA
- fix disable/re-enable in one session: keep the cached registration and
  re-run initialize instead of relying on a cached ES-module re-import
- prefix root-relative bundle/style URLs with apiBaseUrl (split-origin/Tauri)
- surface partial-install warnings via toast.warning instead of a bare success
- dedupe/remove injected style links on unload; gate PluginNavItems on the flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): serialize per-plugin lifecycle + guard config writes

- add a per-plugin keyed lock in Service so concurrent Enable/Disable/Install/
  Uninstall/UpdateConfig for one id are serialized (no status-machine race)
- take s.mu in SetConfig/GetConfig and write config atomically
- bound each plugin initialize() with a timeout so one hung bundle can't stall
  the rest of the boot load

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): split service_test.go to satisfy file-length lint

Move the install/URL test group into service_install_test.go; no logic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: build and ship the e2e fixture plugin package to E2E shards

The plugin E2E specs' global-setup requires apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz.
Build it in the E2E build job, upload it as an artifact, and download it in both
the regular and container shard jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 12:00:26 +01:00

392 lines
14 KiB
TypeScript

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "sonner";
import type { PluginRecord, SyncResult } from "@/lib/types/plugins";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() } }));
const {
enablePluginSpy,
disablePluginSpy,
uninstallPluginSpy,
installPluginFromUrlSpy,
installPluginUploadSpy,
listPluginsSpy,
syncPluginsSpy,
} = vi.hoisted(() => ({
enablePluginSpy: vi.fn(),
disablePluginSpy: vi.fn(),
uninstallPluginSpy: vi.fn(),
installPluginFromUrlSpy: vi.fn(),
installPluginUploadSpy: vi.fn(),
listPluginsSpy: vi.fn(),
syncPluginsSpy: vi.fn(),
}));
vi.mock("@/lib/api/domains/plugins-api", () => ({
listPlugins: (...args: unknown[]) => {
listPluginsSpy(...args);
return Promise.resolve([]);
},
enablePlugin: (...args: [string]) => {
enablePluginSpy(...args);
return Promise.resolve({ enabled: true });
},
disablePlugin: (...args: [string]) => {
disablePluginSpy(...args);
return Promise.resolve({ disabled: true });
},
uninstallPlugin: (...args: [string]) => {
uninstallPluginSpy(...args);
return Promise.resolve({ deleted: true });
},
installPluginFromUrl: (...args: [string]) => installPluginFromUrlSpy(...args),
installPluginUpload: (...args: [File]) => installPluginUploadSpy(...args),
syncPlugins: (...args: unknown[]) => syncPluginsSpy(...args),
}));
const { loadPluginsSpy, unloadPluginSpy } = vi.hoisted(() => ({
loadPluginsSpy: vi.fn(),
unloadPluginSpy: vi.fn(),
}));
vi.mock("@/lib/plugins/host", () => ({
loadPlugins: (...args: unknown[]) => {
loadPluginsSpy(...args);
return Promise.resolve();
},
unloadPlugin: (...args: unknown[]) => unloadPluginSpy(...args),
}));
vi.mock("@/lib/plugins/host-api", () => ({
buildHostApi: (pluginId: string) => ({ pluginId }),
}));
vi.mock("@/components/theme/app-theme", () => ({
useTheme: () => ({ resolvedTheme: "light" }),
}));
let featureEnabled = true;
vi.mock("@/hooks/domains/features/use-feature", () => ({
useFeature: () => featureEnabled,
}));
let storeState: Record<string, unknown> = {};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) => selector(storeState),
useAppStoreApi: () => ({
getState: () => storeState,
setState: vi.fn(),
subscribe: vi.fn(() => () => {}),
}),
}));
import PluginsSettingsPage from "./page";
const PLUGIN_ID = "acme-tools";
const NEW_PLUGIN_ID = "new-plugin";
const SYNC_BUTTON_TESTID = "plugins-sync-button";
const INSTALL_TRIGGER_TESTID = "install-plugin-trigger";
const NEW_PLUGIN_URL = "https://example.test/new-plugin-1.0.0.tar.gz";
function activePlugin(overrides: Partial<PluginRecord> = {}): PluginRecord {
return {
id: PLUGIN_ID,
api_version: 1,
version: "1.0.0",
display_name: "Acme Tools",
description: "desc",
author: "acme",
categories: ["productivity"],
capabilities: {},
status: "active",
install_path: "/home/user/.kandev/plugins/acme-tools/1.0.0",
signed: true,
installed_at: "2026-01-01T00:00:00Z",
restart_count: 0,
ui: { bundle: "/ui/bundle.js", styles: ["/ui/style.css"] },
...overrides,
};
}
function installedPlugin(overrides: Partial<PluginRecord> = {}): PluginRecord {
return activePlugin({
id: NEW_PLUGIN_ID,
display_name: "New Plugin",
install_path: `/home/user/.kandev/plugins/${NEW_PLUGIN_ID}/1.0.0`,
...overrides,
});
}
function setStoreState(plugins: PluginRecord[]) {
storeState = {
plugins: { items: plugins, loading: false, loaded: true, error: null },
setPlugins: vi.fn(),
setPluginsLoading: vi.fn(),
setPluginsError: vi.fn(),
upsertPlugin: vi.fn(),
removePlugin: vi.fn(),
};
}
function emptySyncResult(overrides: Partial<SyncResult> = {}): SyncResult {
return { added: [], installed: [], missing: [], errors: [], ...overrides };
}
beforeEach(() => {
installPluginFromUrlSpy.mockResolvedValue({ plugin: installedPlugin() });
installPluginUploadSpy.mockResolvedValue({ plugin: installedPlugin() });
syncPluginsSpy.mockResolvedValue(emptySyncResult());
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
featureEnabled = true;
storeState = {};
});
describe("PluginsSettingsPage", () => {
it("renders nothing when the plugins feature flag is off", () => {
featureEnabled = false;
setStoreState([]);
const { container } = render(<PluginsSettingsPage />);
expect(container.firstChild).toBeNull();
});
it("shows an empty state when there are no plugins", () => {
setStoreState([]);
render(<PluginsSettingsPage />);
expect(screen.getByText(/no plugins/i)).toBeTruthy();
});
it("lists plugins with their status, version, and an unsigned badge when unverified", () => {
setStoreState([
activePlugin(),
activePlugin({ id: "unsigned-one", display_name: "Unsigned Plugin", signed: false }),
]);
render(<PluginsSettingsPage />);
expect(screen.getByText("Acme Tools")).toBeTruthy();
expect(screen.getByText(/acme-tools.*v1\.0\.0/)).toBeTruthy();
expect(screen.getAllByText(/active/i).length).toBeGreaterThan(0);
expect(screen.getAllByTestId("plugin-unsigned-badge")).toHaveLength(1);
});
it("disables an active plugin: calls the API, unloads the bundle, and updates the store", async () => {
setStoreState([activePlugin()]);
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByRole("button", { name: /disable/i }));
await vi.waitFor(() => expect(disablePluginSpy).toHaveBeenCalledWith(PLUGIN_ID));
expect(unloadPluginSpy).toHaveBeenCalledWith(PLUGIN_ID);
const upsertPlugin = storeState.upsertPlugin as ReturnType<typeof vi.fn>;
expect(upsertPlugin).toHaveBeenCalledWith(
expect.objectContaining({ id: PLUGIN_ID, status: "disabled" }),
);
});
it("enables a disabled plugin: calls the API, updates the store, and reloads the bundle", async () => {
setStoreState([activePlugin({ status: "disabled" })]);
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByRole("button", { name: /enable/i }));
await vi.waitFor(() => expect(enablePluginSpy).toHaveBeenCalledWith(PLUGIN_ID));
const upsertPlugin = storeState.upsertPlugin as ReturnType<typeof vi.fn>;
expect(upsertPlugin).toHaveBeenCalledWith(
expect.objectContaining({ id: PLUGIN_ID, status: "active" }),
);
await vi.waitFor(() =>
expect(loadPluginsSpy).toHaveBeenCalledWith(
[expect.objectContaining({ id: PLUGIN_ID, bundleUrl: `/api/plugins/${PLUGIN_ID}/bundle` })],
expect.any(Function),
),
);
});
it("uninstalls a plugin after confirmation: calls the API, unloads the bundle, and removes it from the store", async () => {
setStoreState([activePlugin()]);
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByRole("button", { name: /uninstall/i }));
fireEvent.click(screen.getByRole("button", { name: /confirm uninstall/i }));
await vi.waitFor(() => expect(uninstallPluginSpy).toHaveBeenCalledWith(PLUGIN_ID));
expect(unloadPluginSpy).toHaveBeenCalledWith(PLUGIN_ID);
const removePlugin = storeState.removePlugin as ReturnType<typeof vi.fn>;
expect(removePlugin).toHaveBeenCalledWith(PLUGIN_ID);
});
it("does not uninstall when the confirmation dialog is cancelled", () => {
setStoreState([activePlugin()]);
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByRole("button", { name: /uninstall/i }));
fireEvent.click(screen.getByRole("button", { name: /^cancel$/i }));
expect(uninstallPluginSpy).not.toHaveBeenCalled();
});
});
describe("PluginsSettingsPage install dialog", () => {
it("installs a plugin from a URL: calls the API, loads the bundle, and updates the store", async () => {
setStoreState([]);
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID));
fireEvent.change(screen.getByTestId("install-plugin-url-input"), {
target: { value: NEW_PLUGIN_URL },
});
fireEvent.click(screen.getByTestId("install-plugin-url-submit"));
await vi.waitFor(() => expect(installPluginFromUrlSpy).toHaveBeenCalledWith(NEW_PLUGIN_URL));
const upsertPlugin = storeState.upsertPlugin as ReturnType<typeof vi.fn>;
expect(upsertPlugin).toHaveBeenCalledWith(expect.objectContaining({ id: NEW_PLUGIN_ID }));
await vi.waitFor(() =>
expect(loadPluginsSpy).toHaveBeenCalledWith(
[
expect.objectContaining({
id: NEW_PLUGIN_ID,
bundleUrl: `/api/plugins/${NEW_PLUGIN_ID}/bundle`,
}),
],
expect.any(Function),
),
);
});
it("installs a plugin by uploading a file: calls the API and updates the store", async () => {
setStoreState([]);
const file = new File([new Uint8Array([1, 2, 3])], "new-plugin-1.0.0.tar.gz", {
type: "application/gzip",
});
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID));
// Radix TabsTrigger switches tabs on mousedown, not click (see
// @radix-ui/react-tabs) — fireEvent.click alone never fires it.
fireEvent.mouseDown(screen.getByTestId("install-plugin-tab-upload"));
fireEvent.change(screen.getByTestId("install-plugin-file-input"), {
target: { files: [file] },
});
fireEvent.click(screen.getByTestId("install-plugin-upload-submit"));
await vi.waitFor(() => expect(installPluginUploadSpy).toHaveBeenCalledWith(file));
const upsertPlugin = storeState.upsertPlugin as ReturnType<typeof vi.fn>;
expect(upsertPlugin).toHaveBeenCalledWith(expect.objectContaining({ id: NEW_PLUGIN_ID }));
});
it("shows a warning toast (not a bare success toast) when the package installed but failed to start", async () => {
installPluginFromUrlSpy.mockResolvedValueOnce({
plugin: installedPlugin({ status: "error" }),
warning: "plugin installed but failed to start: handshake timed out",
});
setStoreState([]);
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID));
fireEvent.change(screen.getByTestId("install-plugin-url-input"), {
target: { value: NEW_PLUGIN_URL },
});
fireEvent.click(screen.getByTestId("install-plugin-url-submit"));
await vi.waitFor(() =>
expect(toast.warning).toHaveBeenCalledWith(
"plugin installed but failed to start: handshake timed out",
),
);
expect(toast.success).not.toHaveBeenCalled();
const upsertPlugin = storeState.upsertPlugin as ReturnType<typeof vi.fn>;
expect(upsertPlugin).toHaveBeenCalledWith(
expect.objectContaining({ id: NEW_PLUGIN_ID, status: "error" }),
);
});
it("shows the backend's error message inline when install fails", async () => {
installPluginFromUrlSpy.mockRejectedValueOnce(
new Error("bad checksum for server/plugin-linux-amd64"),
);
setStoreState([]);
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByTestId(INSTALL_TRIGGER_TESTID));
fireEvent.change(screen.getByTestId("install-plugin-url-input"), {
target: { value: "https://example.test/bad.tar.gz" },
});
fireEvent.click(screen.getByTestId("install-plugin-url-submit"));
await vi.waitFor(() =>
expect(screen.getByTestId("install-plugin-error").textContent).toMatch(/bad checksum/),
);
});
});
describe("PluginsSettingsPage sync button", () => {
it("syncs plugins: calls the API, refreshes the list, and toasts a summary", async () => {
setStoreState([]);
syncPluginsSpy.mockResolvedValueOnce(emptySyncResult({ added: ["sideloaded-plugin"] }));
const callsBeforeClick = listPluginsSpy.mock.calls.length;
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByTestId(SYNC_BUTTON_TESTID));
await vi.waitFor(() => expect(syncPluginsSpy).toHaveBeenCalled());
await vi.waitFor(() =>
expect(listPluginsSpy.mock.calls.length).toBeGreaterThan(callsBeforeClick),
);
const setPlugins = storeState.setPlugins as ReturnType<typeof vi.fn>;
await vi.waitFor(() => expect(setPlugins).toHaveBeenCalledWith([]));
await vi.waitFor(() => expect(toast.success).toHaveBeenCalledWith("Sync: 1 sideloaded"));
});
it("toasts 'Everything up to date' when the sync finds nothing to do", async () => {
setStoreState([]);
syncPluginsSpy.mockResolvedValueOnce(emptySyncResult());
render(<PluginsSettingsPage />);
fireEvent.click(screen.getByTestId(SYNC_BUTTON_TESTID));
await vi.waitFor(() => expect(toast.success).toHaveBeenCalledWith("Everything up to date"));
});
it("shows sync errors inline", async () => {
setStoreState([]);
syncPluginsSpy.mockResolvedValueOnce(
emptySyncResult({
errors: [{ path: "/plugins/junk.tar.gz", reason: "invalid gzip stream" }],
}),
);
render(<PluginsSettingsPage />);
expect(screen.queryByTestId("plugins-sync-errors")).toBeNull();
fireEvent.click(screen.getByTestId(SYNC_BUTTON_TESTID));
await vi.waitFor(() =>
expect(screen.getByTestId("plugins-sync-errors").textContent).toMatch(/invalid gzip stream/),
);
});
it("toasts an error and does not refresh the list when the sync call fails", async () => {
setStoreState([]);
syncPluginsSpy.mockRejectedValueOnce(new Error("backend unreachable"));
render(<PluginsSettingsPage />);
// Let the mount-triggered usePlugins() load settle before capturing the
// baseline call count, so it only reflects the click below.
await vi.waitFor(() => expect(listPluginsSpy).toHaveBeenCalled());
const callsBeforeClick = listPluginsSpy.mock.calls.length;
fireEvent.click(screen.getByTestId(SYNC_BUTTON_TESTID));
await vi.waitFor(() => expect(toast.error).toHaveBeenCalledWith("backend unreachable"));
expect(listPluginsSpy.mock.calls.length).toBe(callsBeforeClick);
});
});