first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { apiFetch, fetchManifest } from "../../src/lib/api/client";
describe("apiFetch", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
const originalFetch = globalThis.fetch;
beforeEach(() => {
fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
globalThis.fetch = fetchSpy;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("adds X-EmDash-Request header", async () => {
await apiFetch("/test");
expect(fetchSpy).toHaveBeenCalledOnce();
const [, init] = fetchSpy.mock.calls[0]!;
const headers = new Headers(init.headers);
expect(headers.get("X-EmDash-Request")).toBe("1");
});
it("preserves existing headers", async () => {
await apiFetch("/test", { headers: { "Content-Type": "application/json" } });
const [, init] = fetchSpy.mock.calls[0]!;
const headers = new Headers(init.headers);
expect(headers.get("Content-Type")).toBe("application/json");
expect(headers.get("X-EmDash-Request")).toBe("1");
});
it("passes through other init options", async () => {
await apiFetch("/test", { method: "POST", body: "data" });
const [, init] = fetchSpy.mock.calls[0]!;
expect(init.method).toBe("POST");
expect(init.body).toBe("data");
});
});
describe("fetchManifest", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns parsed manifest on success", async () => {
const manifest = {
version: "1.0",
collections: {},
plugins: {},
authMode: "passkey",
hash: "abc",
};
globalThis.fetch = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ data: manifest }), { status: 200 }));
const result = await fetchManifest();
expect(result.version).toBe("1.0");
expect(result.authMode).toBe("passkey");
});
it("throws on non-ok response", async () => {
globalThis.fetch = vi
.fn()
.mockResolvedValue(new Response("", { status: 500, statusText: "Internal Server Error" }));
await expect(fetchManifest()).rejects.toThrow("Failed to fetch manifest");
});
});

View File

@@ -0,0 +1,77 @@
import { userEvent } from "@vitest/browser/context";
import * as React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "vitest-browser-react";
import { useStableCallback } from "../../src/lib/hooks";
/**
* Test component that attaches a keydown listener using useStableCallback.
* Verifies that re-renders with new callback identities don't cause
* listener churn, and the latest callback is always invoked.
*/
function KeydownListener({ onEscape }: { onEscape: () => void }) {
const stableOnEscape = useStableCallback(onEscape);
React.useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") stableOnEscape();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [stableOnEscape]);
return <div data-testid="listener">Listening</div>;
}
describe("useStableCallback", () => {
it("calls the latest callback after re-render with new identity", async () => {
const first = vi.fn();
const second = vi.fn();
const screen = await render(<KeydownListener onEscape={first} />);
await expect.element(screen.getByTestId("listener")).toBeInTheDocument();
// First callback should work
await userEvent.keyboard("{Escape}");
expect(first).toHaveBeenCalledTimes(1);
// Re-render with a new callback identity
await screen.rerender(<KeydownListener onEscape={second} />);
// Second callback should be called, not the first
await userEvent.keyboard("{Escape}");
expect(second).toHaveBeenCalledTimes(1);
expect(first).toHaveBeenCalledTimes(1); // still just the one from before
});
it("does not add/remove listeners on re-render", async () => {
const addSpy = vi.spyOn(document, "addEventListener");
const removeSpy = vi.spyOn(document, "removeEventListener");
const screen = await render(<KeydownListener onEscape={vi.fn()} />);
await expect.element(screen.getByTestId("listener")).toBeInTheDocument();
const addCountAfterMount = addSpy.mock.calls.filter(([type]) => type === "keydown").length;
const removeCountAfterMount = removeSpy.mock.calls.filter(
([type]) => type === "keydown",
).length;
// Re-render 3 times with different callback identities
for (let i = 0; i < 3; i++) {
await screen.rerender(<KeydownListener onEscape={vi.fn()} />);
}
const addCountAfterRerenders = addSpy.mock.calls.filter(([type]) => type === "keydown").length;
const removeCountAfterRerenders = removeSpy.mock.calls.filter(
([type]) => type === "keydown",
).length;
// No new addEventListener/removeEventListener calls for keydown
expect(addCountAfterRerenders).toBe(addCountAfterMount);
expect(removeCountAfterRerenders).toBe(removeCountAfterMount);
addSpy.mockRestore();
removeSpy.mockRestore();
});
});

View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
searchMarketplace,
fetchMarketplacePlugin,
installMarketplacePlugin,
updateMarketplacePlugin,
uninstallMarketplacePlugin,
checkPluginUpdates,
describeCapability,
CAPABILITY_LABELS,
} from "../../src/lib/api/marketplace";
describe("marketplace API client", () => {
let fetchSpy: ReturnType<typeof vi.fn>;
const originalFetch = globalThis.fetch;
beforeEach(() => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
// -----------------------------------------------------------------------
// searchMarketplace
// -----------------------------------------------------------------------
describe("searchMarketplace", () => {
it("calls correct URL with no params", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ data: { items: [], nextCursor: undefined } }), {
status: 200,
}),
);
await searchMarketplace();
const [url] = fetchSpy.mock.calls[0]!;
expect(url).toBe("/_emdash/api/admin/plugins/marketplace");
});
it("appends query params when provided", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ data: { items: [] } }), { status: 200 }),
);
await searchMarketplace({ q: "seo", sort: "installs", limit: 10, cursor: "abc" });
const [url] = fetchSpy.mock.calls[0]!;
expect(url).toContain("q=seo");
expect(url).toContain("sort=installs");
expect(url).toContain("limit=10");
expect(url).toContain("cursor=abc");
});
it("omits undefined params", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ data: { items: [] } }), { status: 200 }),
);
await searchMarketplace({ q: "test" });
const [url] = fetchSpy.mock.calls[0]!;
expect(url).toContain("q=test");
expect(url).not.toContain("sort=");
expect(url).not.toContain("cursor=");
expect(url).not.toContain("limit=");
});
it("includes CSRF header", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ data: { items: [] } }), { status: 200 }),
);
await searchMarketplace();
const [, init] = fetchSpy.mock.calls[0]!;
const headers = new Headers(init.headers);
expect(headers.get("X-EmDash-Request")).toBe("1");
});
it("throws on non-ok response", async () => {
fetchSpy.mockResolvedValue(
new Response("", { status: 503, statusText: "Service Unavailable" }),
);
await expect(searchMarketplace()).rejects.toThrow("Marketplace search failed");
});
});
// -----------------------------------------------------------------------
// fetchMarketplacePlugin
// -----------------------------------------------------------------------
describe("fetchMarketplacePlugin", () => {
it("calls correct URL with encoded ID", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "my-plugin", name: "My Plugin" } }), {
status: 200,
}),
);
await fetchMarketplacePlugin("my-plugin");
const [url] = fetchSpy.mock.calls[0]!;
expect(url).toBe("/_emdash/api/admin/plugins/marketplace/my-plugin");
});
it("encodes special characters in ID", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "a/b" } }), { status: 200 }),
);
await fetchMarketplacePlugin("a/b");
const [url] = fetchSpy.mock.calls[0]!;
expect(url).toBe("/_emdash/api/admin/plugins/marketplace/a%2Fb");
});
it("throws specific message on 404", async () => {
fetchSpy.mockResolvedValue(new Response("", { status: 404 }));
await expect(fetchMarketplacePlugin("nonexistent")).rejects.toThrow(
'Plugin "nonexistent" not found in marketplace',
);
});
it("throws generic message on other errors", async () => {
fetchSpy.mockResolvedValue(
new Response("", { status: 500, statusText: "Internal Server Error" }),
);
await expect(fetchMarketplacePlugin("broken")).rejects.toThrow("Failed to fetch plugin");
});
});
// -----------------------------------------------------------------------
// installMarketplacePlugin
// -----------------------------------------------------------------------
describe("installMarketplacePlugin", () => {
it("POSTs to correct URL", async () => {
fetchSpy.mockResolvedValue(new Response("{}", { status: 200 }));
await installMarketplacePlugin("my-plugin", { version: "1.0.0" });
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe("/_emdash/api/admin/plugins/marketplace/my-plugin/install");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ version: "1.0.0" });
});
it("throws error message from response body", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ error: { message: "Version conflict" } }), { status: 409 }),
);
await expect(installMarketplacePlugin("my-plugin")).rejects.toThrow("Version conflict");
});
it("falls back to statusText when body has no message", async () => {
fetchSpy.mockResolvedValue(
new Response("not json", { status: 500, statusText: "Server Error" }),
);
await expect(installMarketplacePlugin("my-plugin")).rejects.toThrow(
"Failed to install plugin: Server Error",
);
});
});
// -----------------------------------------------------------------------
// updateMarketplacePlugin
// -----------------------------------------------------------------------
describe("updateMarketplacePlugin", () => {
it("POSTs to plugin update endpoint (not marketplace proxy)", async () => {
fetchSpy.mockResolvedValue(new Response("{}", { status: 200 }));
await updateMarketplacePlugin("my-plugin", { confirmCapabilities: true });
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe("/_emdash/api/admin/plugins/my-plugin/update");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ confirmCapabilities: true });
});
it("throws error message from response body", async () => {
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ error: { message: "Capability mismatch" } }), {
status: 400,
}),
);
await expect(updateMarketplacePlugin("x")).rejects.toThrow("Capability mismatch");
});
});
// -----------------------------------------------------------------------
// uninstallMarketplacePlugin
// -----------------------------------------------------------------------
describe("uninstallMarketplacePlugin", () => {
it("POSTs to the uninstall endpoint", async () => {
fetchSpy.mockResolvedValue(new Response("{}", { status: 200 }));
await uninstallMarketplacePlugin("my-plugin", { deleteData: true });
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe("/_emdash/api/admin/plugins/my-plugin/uninstall");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body)).toEqual({ deleteData: true });
});
it("defaults to empty opts", async () => {
fetchSpy.mockResolvedValue(new Response("{}", { status: 200 }));
await uninstallMarketplacePlugin("my-plugin");
const [, init] = fetchSpy.mock.calls[0]!;
expect(JSON.parse(init.body)).toEqual({});
});
});
// -----------------------------------------------------------------------
// checkPluginUpdates
// -----------------------------------------------------------------------
describe("checkPluginUpdates", () => {
it("GETs the updates endpoint and returns items", async () => {
const updates = [
{ pluginId: "a", installed: "1.0.0", latest: "2.0.0", hasCapabilityChanges: true },
];
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ data: { items: updates } }), { status: 200 }),
);
const result = await checkPluginUpdates();
expect(result).toEqual(updates);
const [url] = fetchSpy.mock.calls[0]!;
expect(url).toBe("/_emdash/api/admin/plugins/updates");
});
it("throws on non-ok response", async () => {
fetchSpy.mockResolvedValue(new Response("", { status: 500, statusText: "Server Error" }));
await expect(checkPluginUpdates()).rejects.toThrow("Failed to check for updates");
});
});
});
// ---------------------------------------------------------------------------
// describeCapability helper
// ---------------------------------------------------------------------------
describe("describeCapability", () => {
it("returns known capability label", () => {
expect(describeCapability("read:content")).toBe("Read your content");
expect(describeCapability("write:media")).toBe("Upload and manage media");
});
it("returns raw capability string for unknown capabilities", () => {
expect(describeCapability("custom:something")).toBe("custom:something");
});
it("appends allowed hosts for network:fetch", () => {
const result = describeCapability("network:fetch", ["api.example.com", "cdn.example.com"]);
expect(result).toBe("Make network requests to: api.example.com, cdn.example.com");
});
it("ignores empty allowed hosts for network:fetch", () => {
expect(describeCapability("network:fetch", [])).toBe("Make network requests");
expect(describeCapability("network:fetch")).toBe("Make network requests");
});
it("ignores allowed hosts for non-fetch capabilities", () => {
expect(describeCapability("read:content", ["example.com"])).toBe("Read your content");
});
});
describe("CAPABILITY_LABELS", () => {
it("has entries for all known capabilities", () => {
expect(Object.keys(CAPABILITY_LABELS)).toEqual([
"read:content",
"write:content",
"read:media",
"write:media",
"network:fetch",
"network:fetch:any",
]);
});
});

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { sanitizeRedirectUrl } from "../../src/lib/url";
describe("sanitizeRedirectUrl", () => {
it("allows simple relative paths", () => {
expect(sanitizeRedirectUrl("/_emdash/admin")).toBe("/_emdash/admin");
});
it("allows deep relative paths", () => {
expect(sanitizeRedirectUrl("/_emdash/admin/content/posts")).toBe(
"/_emdash/admin/content/posts",
);
});
it("allows root path", () => {
expect(sanitizeRedirectUrl("/")).toBe("/");
});
it("allows paths with query strings", () => {
expect(sanitizeRedirectUrl("/_emdash/admin?tab=settings")).toBe(
"/_emdash/admin?tab=settings",
);
});
it("allows paths with hash fragments", () => {
expect(sanitizeRedirectUrl("/_emdash/admin#section")).toBe("/_emdash/admin#section");
});
it("rejects absolute http URLs (open redirect)", () => {
expect(sanitizeRedirectUrl("https://evil.com/phishing")).toBe("/_emdash/admin");
});
it("rejects absolute http URLs without TLS", () => {
expect(sanitizeRedirectUrl("http://evil.com")).toBe("/_emdash/admin");
});
it("rejects protocol-relative URLs (//evil.com)", () => {
expect(sanitizeRedirectUrl("//evil.com/phishing")).toBe("/_emdash/admin");
});
it("rejects javascript: scheme (DOM XSS)", () => {
expect(sanitizeRedirectUrl("javascript:alert(document.cookie)")).toBe("/_emdash/admin");
});
it("rejects data: scheme", () => {
expect(sanitizeRedirectUrl("data:text/html,<script>alert(1)</script>")).toBe(
"/_emdash/admin",
);
});
it("rejects backslash trick (/\\evil.com)", () => {
expect(sanitizeRedirectUrl("/\\evil.com")).toBe("/_emdash/admin");
});
it("rejects empty string", () => {
expect(sanitizeRedirectUrl("")).toBe("/_emdash/admin");
});
it("rejects bare domain", () => {
expect(sanitizeRedirectUrl("evil.com")).toBe("/_emdash/admin");
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { cn, slugify } from "../../src/lib/utils";
describe("slugify", () => {
it("converts basic text to slug", () => {
expect(slugify("Hello World")).toBe("hello-world");
});
it("handles unicode and diacritics", () => {
expect(slugify("café résumé")).toBe("cafe-resume");
});
it("strips special characters", () => {
expect(slugify("hello! @world# $")).toBe("hello-world");
});
it("collapses multiple hyphens", () => {
expect(slugify("hello---world")).toBe("hello-world");
});
it("trims leading/trailing hyphens", () => {
expect(slugify("-hello-world-")).toBe("hello-world");
});
it("handles underscores as separators", () => {
expect(slugify("hello_world")).toBe("hello-world");
});
it("returns empty string for empty input", () => {
expect(slugify("")).toBe("");
});
it("handles all special characters", () => {
expect(slugify("!@#$%")).toBe("");
});
it("handles mixed case", () => {
expect(slugify("HeLLo WoRLD")).toBe("hello-world");
});
it("handles multiple spaces", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});
describe("cn", () => {
it("merges class names", () => {
expect(cn("foo", "bar")).toBe("foo bar");
});
it("handles conditional classes", () => {
const condition = false;
expect(cn("foo", condition && "bar", "baz")).toBe("foo baz");
});
it("merges conflicting tailwind classes", () => {
expect(cn("p-4", "p-2")).toBe("p-2");
});
it("handles undefined and null", () => {
expect(cn("foo", undefined, null, "bar")).toBe("foo bar");
});
});