Files
kandev/apps/web/hooks/domains/plugins/use-plugin-updates.test.ts
T
João Salavisa bc2f310e73 feat(plugins): add plugin marketplace with curated registry and updates (#1798)
* feat(plugins): add plugin marketplace with curated registry and updates

Discover and install plugins from inside kandev instead of only by URL/upload.

- Backend marketplace subpackage: SQLite-backed sources, catalog fetch/merge
  (dedup, install_state, degraded-source handling), and the /api/plugins/
  marketplace[/sources] API. Install reuses the existing install-by-URL pipeline.
- Frontend: Settings > Plugins gains a Browse tab (search/category/star sort),
  source management, one-click install, and an Update button on the Installed
  tab when the catalog has a newer version. Plugins may ship an icon (manifest
  `icon` -> catalog icon_url) with a letter-tile fallback.
- Registry infra: plugin-registry/ (curated plugins.yaml + schema.json) and two
  GitHub Actions (zero-dependency Node build-index.mjs -> index.json on Pages,
  daily star refresh). Ships empty at launch; the empty catalog is valid.
- Spec: docs/specs/plugins/marketplace.md. No download telemetry; stars only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): address marketplace PR review feedback

- CodeQL: stub fetch routers keyed off a path segment, not a hostname substring.
- Nullable stars end to end: a failed star lookup stays unknown (not 0) and
  sorts last, instead of decoding null -> 0 and corrupting ranking.
- Fetch marketplace sources concurrently and dedupe identical in-flight
  downloads (singleflight), so a slow source can't serialize the Browse tab.
- Register /marketplace routes before the /:id wildcard (gin tree ordering).
- Duplicate source URL returns 409 ErrDuplicateSource, not a raw SQL error.
- Browse: filter category client-side so the category dropdown never collapses.
- marketplaceInstall swallows after toasting (no unhandled promise rejection).
- Source badge keys off source_id, not a name substring.
- build-index: read block-sequence manifest categories, not just inline.
- Registry index workflow validates on pull_request; Pages deploy gated to
  push/schedule/dispatch. Spec: align package_sha256 (advisory in v1) + sort enum.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): guard catalog repo_url against javascript: href injection

repo_url comes from an untrusted index.json, so a malicious/compromised source
could set a javascript: URL that runs in the operator's session when the Repo
link is clicked (rel/target don't block it). Only render the anchor for http(s)
schemes. Adds regression tests for the scheme guard and null-star rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): require https for remote sources; test oversized download

- Reject remote plain-http marketplace sources (MITM catalog-poisoning risk);
  loopback http stays allowed for local dev/e2e fixtures.
- Add the missing oversized-response assertion to TestDownloadRejectsNon200AndOversized
  so the maxIndexBytes memory bound has coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): detach source download from singleflight leader's context

Two concurrent Browse opens after a cache Refresh share one singleflight
download; if the leader's request context is canceled (it navigates away), the
waiters must not inherit that cancellation. Use context.WithoutCancel so values
are preserved but the leader can't cancel everyone; the client's 20s timeout
still bounds the download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 17:10:53 +01:00

53 lines
1.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
const getMarketplaceCatalog = vi.fn();
vi.mock("@/lib/api/domains/marketplace-api", () => ({
getMarketplaceCatalog: (...args: unknown[]) => getMarketplaceCatalog(...args),
}));
import { usePluginUpdates } from "./use-plugin-updates";
function entry(id: string, install_state: string, version = "2.0.0") {
return { id, install_state, version, package_url: `https://ex/${id}.tar.gz` };
}
beforeEach(() => {
getMarketplaceCatalog.mockReset();
});
afterEach(() => cleanup());
describe("usePluginUpdates", () => {
it("keeps only update_available entries, keyed by id", async () => {
getMarketplaceCatalog.mockResolvedValue({
plugins: [entry("a", "update_available"), entry("b", "installed"), entry("c", "available")],
sources: [],
});
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(result.current.updates.size).toBe(1));
expect(result.current.updates.has("a")).toBe(true);
expect(result.current.updates.get("a")?.version).toBe("2.0.0");
expect(result.current.updates.has("b")).toBe(false);
});
it("reload re-fetches the catalog", async () => {
getMarketplaceCatalog.mockResolvedValue({ plugins: [], sources: [] });
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(1));
act(() => result.current.reload());
await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalledTimes(2));
});
it("swallows a catalog fetch error (no updates, no throw)", async () => {
getMarketplaceCatalog.mockRejectedValue(new Error("offline"));
const { result } = renderHook(() => usePluginUpdates());
await waitFor(() => expect(getMarketplaceCatalog).toHaveBeenCalled());
expect(result.current.updates.size).toBe(0);
});
});