Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
71
packages/admin/tests/lib/api-client.test.ts
Normal file
71
packages/admin/tests/lib/api-client.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
22
packages/admin/tests/lib/api-token-scopes-contract.test.ts
Normal file
22
packages/admin/tests/lib/api-token-scopes-contract.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Import source (not package `dist`) so the contract tracks edits without a prior `auth` build.
|
||||
import { VALID_SCOPES } from "../../../auth/src/tokens.js";
|
||||
import { API_TOKEN_SCOPE_FORM_SCOPES } from "../../src/components/settings/ApiTokenSettings.js";
|
||||
import { API_TOKEN_SCOPES } from "../../src/lib/api/api-tokens.js";
|
||||
|
||||
function sortedUnique(values: readonly string[]): string[] {
|
||||
return [...new Set(values)].toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
describe("API token scope drift guard", () => {
|
||||
it("admin wire constants match server VALID_SCOPES", () => {
|
||||
expect(sortedUnique(Object.values(API_TOKEN_SCOPES))).toEqual(sortedUnique(VALID_SCOPES));
|
||||
});
|
||||
|
||||
it("create-token UI lists the same scopes as API_TOKEN_SCOPES", () => {
|
||||
expect(sortedUnique(API_TOKEN_SCOPE_FORM_SCOPES)).toEqual(
|
||||
sortedUnique(Object.values(API_TOKEN_SCOPES)),
|
||||
);
|
||||
});
|
||||
});
|
||||
42
packages/admin/tests/lib/datetime-local.test.ts
Normal file
42
packages/admin/tests/lib/datetime-local.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
fromDatetimeLocalInputValue,
|
||||
toDatetimeLocalInputValue,
|
||||
} from "../../src/lib/datetime-local";
|
||||
|
||||
describe("toDatetimeLocalInputValue", () => {
|
||||
it("returns empty for non-string and empty values", () => {
|
||||
expect(toDatetimeLocalInputValue(undefined)).toBe("");
|
||||
expect(toDatetimeLocalInputValue(null)).toBe("");
|
||||
expect(toDatetimeLocalInputValue(0)).toBe("");
|
||||
expect(toDatetimeLocalInputValue("")).toBe("");
|
||||
});
|
||||
|
||||
it("strips seconds/ms/Z from full ISO 8601", () => {
|
||||
expect(toDatetimeLocalInputValue("2026-02-26T09:30:00.000Z")).toBe("2026-02-26T09:30");
|
||||
});
|
||||
|
||||
it("pads date-only values to UTC midnight", () => {
|
||||
expect(toDatetimeLocalInputValue("2026-02-26")).toBe("2026-02-26T00:00");
|
||||
});
|
||||
|
||||
it("preserves a value already in datetime-local shape", () => {
|
||||
expect(toDatetimeLocalInputValue("2026-02-26T09:30")).toBe("2026-02-26T09:30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromDatetimeLocalInputValue", () => {
|
||||
it("returns empty for empty input", () => {
|
||||
expect(fromDatetimeLocalInputValue("")).toBe("");
|
||||
});
|
||||
|
||||
it("appends seconds/ms/Z so the value matches the validator's ISO shape", () => {
|
||||
expect(fromDatetimeLocalInputValue("2026-02-26T09:30")).toBe("2026-02-26T09:30:00.000Z");
|
||||
});
|
||||
|
||||
it("round-trips a stored ISO value without drift", () => {
|
||||
const stored = "2026-02-26T09:30:00.000Z";
|
||||
expect(fromDatetimeLocalInputValue(toDatetimeLocalInputValue(stored))).toBe(stored);
|
||||
});
|
||||
});
|
||||
77
packages/admin/tests/lib/hooks.test.tsx
Normal file
77
packages/admin/tests/lib/hooks.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { useStableCallback } from "../../src/lib/hooks";
|
||||
import { render } from "../utils/render.tsx";
|
||||
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
150
packages/admin/tests/lib/locales.test.ts
Normal file
150
packages/admin/tests/lib/locales.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
getLocaleDir,
|
||||
loadMessages,
|
||||
resolveLocale,
|
||||
SUPPORTED_LOCALES,
|
||||
} from "../../src/locales/index.js";
|
||||
|
||||
for (const { code } of SUPPORTED_LOCALES) {
|
||||
test(`loadMessages resolves catalog for supported locale "${code}"`, async () => {
|
||||
const messages = await loadMessages(code);
|
||||
expect(messages).toBeDefined();
|
||||
expect(typeof messages).toBe("object");
|
||||
expect(Object.keys(messages).length).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("loadMessages falls back to English for unknown locale", async () => {
|
||||
const [fallback, english] = await Promise.all([loadMessages("xx"), loadMessages("en")]);
|
||||
expect(fallback).toEqual(english);
|
||||
});
|
||||
|
||||
// -- getLocaleDir ----------------------------------------------------------
|
||||
|
||||
describe("getLocaleDir", () => {
|
||||
test("returns 'rtl' for Arabic", () => {
|
||||
expect(getLocaleDir("ar")).toBe("rtl");
|
||||
});
|
||||
|
||||
test("returns 'ltr' for English", () => {
|
||||
expect(getLocaleDir("en")).toBe("ltr");
|
||||
});
|
||||
|
||||
test("returns 'ltr' for locales without explicit dir", () => {
|
||||
expect(getLocaleDir("de")).toBe("ltr");
|
||||
expect(getLocaleDir("fr")).toBe("ltr");
|
||||
expect(getLocaleDir("pt-BR")).toBe("ltr");
|
||||
});
|
||||
|
||||
test("returns 'ltr' for unknown locale", () => {
|
||||
expect(getLocaleDir("xx")).toBe("ltr");
|
||||
});
|
||||
});
|
||||
|
||||
// -- resolveLocale ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a Request with the given headers. Browser environments silently
|
||||
* strip the `cookie` header (forbidden header name) from Request/Headers,
|
||||
* so we override `.get()` to inject it for testing purposes.
|
||||
*/
|
||||
function makeRequest(headers: Record<string, string> = {}): Request {
|
||||
const { cookie, ...rest } = headers;
|
||||
const req = new Request("http://localhost/", { headers: rest });
|
||||
if (cookie) {
|
||||
const original = req.headers.get.bind(req.headers);
|
||||
req.headers.get = (name: string) => (name.toLowerCase() === "cookie" ? cookie : original(name));
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("resolveLocale", () => {
|
||||
test("returns DEFAULT_LOCALE when no cookie or accept-language", () => {
|
||||
expect(resolveLocale(makeRequest())).toBe(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
// Cookie precedence
|
||||
test("returns locale from emdash-locale cookie", () => {
|
||||
expect(resolveLocale(makeRequest({ cookie: "emdash-locale=de" }))).toBe("de");
|
||||
});
|
||||
|
||||
test("ignores cookie with unsupported locale", () => {
|
||||
expect(resolveLocale(makeRequest({ cookie: "emdash-locale=xx" }))).toBe(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
test("cookie takes precedence over accept-language", () => {
|
||||
expect(
|
||||
resolveLocale(
|
||||
makeRequest({
|
||||
cookie: "emdash-locale=de",
|
||||
"accept-language": "fr",
|
||||
}),
|
||||
),
|
||||
).toBe("de");
|
||||
});
|
||||
|
||||
// Accept-Language exact match
|
||||
test("matches exact accept-language tag", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "de" }))).toBe("de");
|
||||
});
|
||||
|
||||
test("matches accept-language with region (pt-BR)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "pt-BR" }))).toBe("pt-BR");
|
||||
});
|
||||
|
||||
// Accept-Language case insensitivity (fix for Copilot review #4)
|
||||
test("matches accept-language case-insensitively (pt-br -> pt-BR)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "pt-br" }))).toBe("pt-BR");
|
||||
});
|
||||
|
||||
test("matches accept-language case-insensitively (ZH-CN -> zh-CN)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "ZH-CN" }))).toBe("zh-CN");
|
||||
});
|
||||
|
||||
test("matches accept-language case-insensitively (DE -> de)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "DE" }))).toBe("de");
|
||||
});
|
||||
|
||||
// Accept-Language base language fallback
|
||||
test("falls back to base language (pt-PT -> pt-BR)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "pt-PT" }))).toBe("pt-BR");
|
||||
});
|
||||
|
||||
test("matches exact accept-language tag with region (zh-TW)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "zh-TW" }))).toBe("zh-TW");
|
||||
});
|
||||
|
||||
test("matches Traditional Chinese script tag (zh-Hant -> zh-TW)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "zh-Hant" }))).toBe("zh-TW");
|
||||
});
|
||||
|
||||
test("matches Traditional Chinese script+region tag (zh-Hant-TW -> zh-TW)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "zh-Hant-TW" }))).toBe("zh-TW");
|
||||
});
|
||||
|
||||
test("matches Simplified Chinese script tag (zh-Hans -> zh-CN)", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "zh-Hans" }))).toBe("zh-CN");
|
||||
});
|
||||
// Accept-Language with quality weights
|
||||
test("respects order in accept-language list", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "fr;q=0.9, de;q=1.0" }))).toBe("fr");
|
||||
});
|
||||
|
||||
test("skips unsupported languages in accept-language list", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "xx, yy, de" }))).toBe("de");
|
||||
});
|
||||
|
||||
// Malformed input
|
||||
test("handles empty accept-language gracefully", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "" }))).toBe(DEFAULT_LOCALE);
|
||||
});
|
||||
|
||||
test("handles garbage accept-language gracefully", () => {
|
||||
expect(resolveLocale(makeRequest({ "accept-language": "not-a-real-locale-tag!!!" }))).toBe(
|
||||
DEFAULT_LOCALE,
|
||||
);
|
||||
});
|
||||
});
|
||||
277
packages/admin/tests/lib/marketplace.test.ts
Normal file
277
packages/admin/tests/lib/marketplace.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
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([
|
||||
// Canonical
|
||||
"content:read",
|
||||
"content:write",
|
||||
"media:read",
|
||||
"media:write",
|
||||
"users:read",
|
||||
"network:request",
|
||||
"network:request:unrestricted",
|
||||
// Legacy aliases
|
||||
"read:content",
|
||||
"write:content",
|
||||
"read:media",
|
||||
"write:media",
|
||||
"read:users",
|
||||
"network:fetch",
|
||||
"network:fetch:any",
|
||||
]);
|
||||
});
|
||||
});
|
||||
98
packages/admin/tests/lib/taxonomy-match.test.ts
Normal file
98
packages/admin/tests/lib/taxonomy-match.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
foldForMatch,
|
||||
termExactMatches,
|
||||
termMatches,
|
||||
type MatchableTerm,
|
||||
} from "../../src/lib/taxonomy-match.js";
|
||||
|
||||
/**
|
||||
* Tests for the admin picker matcher. The matcher is the sole gate between
|
||||
* an editor's typed input and the "Create new term" escape hatch, so every
|
||||
* branch has real user impact:
|
||||
*
|
||||
* - accent-fold branch: `"Mexico"` must find `"México"` or editors create
|
||||
* a duplicate term and fragment the taxonomy.
|
||||
* - no-match branch: genuinely new terms must still offer Create.
|
||||
* - exact-match branch: governs whether Create is suppressed, so an
|
||||
* accent-insensitive exact match must count.
|
||||
*/
|
||||
|
||||
const mexico: MatchableTerm = { label: "México" };
|
||||
const hongKong: MatchableTerm = { label: "Hong Kong" };
|
||||
|
||||
describe("foldForMatch", () => {
|
||||
it("folds diacritics and case to the same key", () => {
|
||||
expect(foldForMatch("México")).toBe("mexico");
|
||||
expect(foldForMatch("MEXICO")).toBe("mexico");
|
||||
expect(foldForMatch("méxico")).toBe("mexico");
|
||||
});
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(foldForMatch("")).toBe("");
|
||||
});
|
||||
|
||||
it("leaves non-accented characters unchanged", () => {
|
||||
expect(foldForMatch("USA")).toBe("usa");
|
||||
expect(foldForMatch("Hong Kong")).toBe("hong kong");
|
||||
});
|
||||
|
||||
it("handles precomposed and decomposed forms identically", () => {
|
||||
// Precomposed U+00E9 vs decomposed U+0065 U+0301 both fold to "e".
|
||||
expect(foldForMatch("caf\u00e9")).toBe("cafe");
|
||||
expect(foldForMatch("cafe\u0301")).toBe("cafe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("termMatches", () => {
|
||||
it("matches across the diacritic boundary (regression: Mexico/México)", () => {
|
||||
expect(termMatches(mexico, "Mexico")).toBe(true);
|
||||
expect(termMatches(mexico, "mexico")).toBe(true);
|
||||
expect(termMatches(mexico, "MEX")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an accented term against an accented query too", () => {
|
||||
expect(termMatches(mexico, "México")).toBe(true);
|
||||
expect(termMatches(mexico, "méx")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match genuinely unrelated input (Create button must still appear)", () => {
|
||||
expect(termMatches(mexico, "Japan")).toBe(false);
|
||||
expect(termMatches(hongKong, "Canada")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty input so the dropdown stays closed", () => {
|
||||
expect(termMatches(mexico, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects whitespace-only input even when term labels contain spaces", () => {
|
||||
// Regression guard: the label `"Hong Kong"` contains a space,
|
||||
// so a naive `includes(needle)` without a whitespace guard would
|
||||
// match a needle of `" "` and surface every multi-word term.
|
||||
expect(termMatches(hongKong, " ")).toBe(false);
|
||||
expect(termMatches(hongKong, " ")).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates terms carrying unknown keys without crashing", () => {
|
||||
const term = { label: "Canadá", extra: 42 } as unknown as MatchableTerm;
|
||||
expect(termMatches(term, "Canada")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("termExactMatches", () => {
|
||||
it("treats diacritic-only differences as equal (so Create stays hidden)", () => {
|
||||
expect(termExactMatches(mexico, "Mexico")).toBe(true);
|
||||
expect(termExactMatches(mexico, "México")).toBe(true);
|
||||
});
|
||||
|
||||
it("is stricter than termMatches — substrings do not count as exact", () => {
|
||||
expect(termExactMatches(mexico, "Mex")).toBe(false);
|
||||
expect(termExactMatches(hongKong, "Hong")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty or whitespace-only input", () => {
|
||||
expect(termExactMatches(mexico, "")).toBe(false);
|
||||
expect(termExactMatches(mexico, " ")).toBe(false);
|
||||
});
|
||||
});
|
||||
59
packages/admin/tests/lib/url.test.ts
Normal file
59
packages/admin/tests/lib/url.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
64
packages/admin/tests/lib/utils.test.ts
Normal file
64
packages/admin/tests/lib/utils.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
54
packages/admin/tests/lib/webauthn-environment.test.ts
Normal file
54
packages/admin/tests/lib/webauthn-environment.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
isPasskeyEnvironmentUsable,
|
||||
isPublicKeyCredentialConstructorAvailable,
|
||||
isWebAuthnSecureContext,
|
||||
} from "../../src/lib/webauthn-environment";
|
||||
|
||||
describe("webauthn-environment", () => {
|
||||
const origPk = globalThis.window.PublicKeyCredential;
|
||||
const desc = Object.getOwnPropertyDescriptor(globalThis.window, "isSecureContext");
|
||||
|
||||
afterEach(() => {
|
||||
if (origPk === undefined) {
|
||||
delete (globalThis.window as { PublicKeyCredential?: unknown }).PublicKeyCredential;
|
||||
} else {
|
||||
Object.defineProperty(globalThis.window, "PublicKeyCredential", {
|
||||
value: origPk,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
if (desc) Object.defineProperty(globalThis.window, "isSecureContext", desc);
|
||||
});
|
||||
|
||||
it("is usable only when secure context and PublicKeyCredential constructor exist", () => {
|
||||
Object.defineProperty(globalThis.window, "isSecureContext", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "PublicKeyCredential", {
|
||||
value: function PublicKeyCredential() {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
expect(isWebAuthnSecureContext()).toBe(true);
|
||||
expect(isPublicKeyCredentialConstructorAvailable()).toBe(true);
|
||||
expect(isPasskeyEnvironmentUsable()).toBe(true);
|
||||
});
|
||||
|
||||
it("is not usable in an insecure context even if PublicKeyCredential is defined", () => {
|
||||
Object.defineProperty(globalThis.window, "isSecureContext", {
|
||||
value: false,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis.window, "PublicKeyCredential", {
|
||||
value: function PublicKeyCredential() {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
expect(isWebAuthnSecureContext()).toBe(false);
|
||||
expect(isPasskeyEnvironmentUsable()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user