b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
573 lines
17 KiB
TypeScript
573 lines
17 KiB
TypeScript
/**
|
|
* Plugin Routes Tests
|
|
*
|
|
* Tests the v2 route system for:
|
|
* - Route registration and invocation
|
|
* - Input validation with Zod schemas
|
|
* - Error handling (PluginRouteError)
|
|
* - Route registry management
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import type { PluginContextFactoryOptions } from "../../../src/plugins/context.js";
|
|
import { EmailPipeline } from "../../../src/plugins/email.js";
|
|
import { HookPipeline } from "../../../src/plugins/hooks.js";
|
|
import {
|
|
PluginRouteHandler,
|
|
PluginRouteRegistry,
|
|
PluginRouteError,
|
|
createRouteRegistry,
|
|
} from "../../../src/plugins/routes.js";
|
|
import type { ResolvedPlugin } from "../../../src/plugins/types.js";
|
|
|
|
/**
|
|
* Create a minimal resolved plugin for testing
|
|
*/
|
|
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
|
return {
|
|
id: overrides.id ?? "test-plugin",
|
|
version: "1.0.0",
|
|
capabilities: [],
|
|
allowedHosts: [],
|
|
storage: {},
|
|
admin: {
|
|
pages: [],
|
|
widgets: [],
|
|
fieldWidgets: {},
|
|
},
|
|
hooks: {},
|
|
routes: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create mock factory options (routes need DB for context)
|
|
*/
|
|
function createMockFactoryOptions(): PluginContextFactoryOptions {
|
|
return {
|
|
db: {} as any, // Mock DB - routes will fail if they try to use DB features
|
|
};
|
|
}
|
|
|
|
describe("PluginRouteError", () => {
|
|
describe("constructor", () => {
|
|
it("creates error with code, message, and status", () => {
|
|
const error = new PluginRouteError("TEST_ERROR", "Test message", 400);
|
|
|
|
expect(error.code).toBe("TEST_ERROR");
|
|
expect(error.message).toBe("Test message");
|
|
expect(error.status).toBe(400);
|
|
expect(error.name).toBe("PluginRouteError");
|
|
});
|
|
|
|
it("defaults status to 400", () => {
|
|
const error = new PluginRouteError("TEST_ERROR", "Test message");
|
|
expect(error.status).toBe(400);
|
|
});
|
|
|
|
it("stores optional details", () => {
|
|
const details = { field: "email", issue: "invalid" };
|
|
const error = new PluginRouteError("VALIDATION_ERROR", "Invalid input", 400, details);
|
|
|
|
expect(error.details).toEqual(details);
|
|
});
|
|
});
|
|
|
|
describe("static factory methods", () => {
|
|
it("badRequest creates 400 error", () => {
|
|
const error = PluginRouteError.badRequest("Bad data", { foo: "bar" });
|
|
|
|
expect(error.code).toBe("BAD_REQUEST");
|
|
expect(error.status).toBe(400);
|
|
expect(error.message).toBe("Bad data");
|
|
expect(error.details).toEqual({ foo: "bar" });
|
|
});
|
|
|
|
it("unauthorized creates 401 error", () => {
|
|
const error = PluginRouteError.unauthorized();
|
|
|
|
expect(error.code).toBe("UNAUTHORIZED");
|
|
expect(error.status).toBe(401);
|
|
expect(error.message).toBe("Unauthorized");
|
|
});
|
|
|
|
it("forbidden creates 403 error", () => {
|
|
const error = PluginRouteError.forbidden("Access denied");
|
|
|
|
expect(error.code).toBe("FORBIDDEN");
|
|
expect(error.status).toBe(403);
|
|
expect(error.message).toBe("Access denied");
|
|
});
|
|
|
|
it("notFound creates 404 error", () => {
|
|
const error = PluginRouteError.notFound("Resource not found");
|
|
|
|
expect(error.code).toBe("NOT_FOUND");
|
|
expect(error.status).toBe(404);
|
|
expect(error.message).toBe("Resource not found");
|
|
});
|
|
|
|
it("conflict creates 409 error", () => {
|
|
const error = PluginRouteError.conflict("Already exists", { id: "123" });
|
|
|
|
expect(error.code).toBe("CONFLICT");
|
|
expect(error.status).toBe(409);
|
|
expect(error.message).toBe("Already exists");
|
|
expect(error.details).toEqual({ id: "123" });
|
|
});
|
|
|
|
it("internal creates 500 error", () => {
|
|
const error = PluginRouteError.internal("Something broke");
|
|
|
|
expect(error.code).toBe("INTERNAL_ERROR");
|
|
expect(error.status).toBe(500);
|
|
expect(error.message).toBe("Something broke");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("PluginRouteHandler", () => {
|
|
describe("getRouteMeta", () => {
|
|
it("returns null for non-existent route", () => {
|
|
const plugin = createTestPlugin();
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
expect(handler.getRouteMeta("non-existent")).toBeNull();
|
|
});
|
|
|
|
it("returns { public: false } for route without public flag", () => {
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
sync: { handler: vi.fn() },
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const meta = handler.getRouteMeta("sync");
|
|
expect(meta).toEqual({ public: false });
|
|
});
|
|
|
|
it("returns { public: true } for route with public: true", () => {
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
submit: { public: true, handler: vi.fn() },
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const meta = handler.getRouteMeta("submit");
|
|
expect(meta).toEqual({ public: true });
|
|
});
|
|
|
|
it("returns { public: false } for route with public: false", () => {
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
admin: { public: false, handler: vi.fn() },
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const meta = handler.getRouteMeta("admin");
|
|
expect(meta).toEqual({ public: false });
|
|
});
|
|
});
|
|
|
|
describe("getRouteNames", () => {
|
|
it("returns empty array for plugin with no routes", () => {
|
|
const plugin = createTestPlugin();
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
expect(handler.getRouteNames()).toEqual([]);
|
|
});
|
|
|
|
it("returns all route names", () => {
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
sync: { handler: vi.fn() },
|
|
webhook: { handler: vi.fn() },
|
|
"batch-process": { handler: vi.fn() },
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const names = handler.getRouteNames();
|
|
expect(names).toContain("sync");
|
|
expect(names).toContain("webhook");
|
|
expect(names).toContain("batch-process");
|
|
expect(names).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
describe("hasRoute", () => {
|
|
it("returns false for non-existent route", () => {
|
|
const plugin = createTestPlugin();
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
expect(handler.hasRoute("non-existent")).toBe(false);
|
|
});
|
|
|
|
it("returns true for existing route", () => {
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
sync: { handler: vi.fn() },
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
expect(handler.hasRoute("sync")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("invoke", () => {
|
|
it("returns 404 for non-existent route", async () => {
|
|
const plugin = createTestPlugin();
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const result = await handler.invoke("non-existent", {
|
|
request: new Request("http://test.com"),
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.status).toBe(404);
|
|
expect(result.error?.code).toBe("ROUTE_NOT_FOUND");
|
|
});
|
|
|
|
it("validates input with Zod schema", async () => {
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
create: {
|
|
input: z.object({
|
|
name: z.string().min(1),
|
|
email: z.string().email(),
|
|
}),
|
|
handler: vi.fn(),
|
|
},
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
// Invalid input
|
|
const result = await handler.invoke("create", {
|
|
request: new Request("http://test.com"),
|
|
body: { name: "", email: "not-an-email" },
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.status).toBe(400);
|
|
expect(result.error?.code).toBe("VALIDATION_ERROR");
|
|
});
|
|
|
|
it("handles PluginRouteError from handler", async () => {
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
fail: {
|
|
handler: async () => {
|
|
throw PluginRouteError.forbidden("No access");
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const result = await handler.invoke("fail", {
|
|
request: new Request("http://test.com"),
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.status).toBe(403);
|
|
expect(result.error?.code).toBe("FORBIDDEN");
|
|
expect(result.error?.message).toBe("No access");
|
|
});
|
|
|
|
it("handles unknown errors from handler", async () => {
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
crash: {
|
|
handler: async () => {
|
|
throw new Error("Unexpected error");
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const result = await handler.invoke("crash", {
|
|
request: new Request("http://test.com"),
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.status).toBe(500);
|
|
expect(result.error?.code).toBe("INTERNAL_ERROR");
|
|
expect(result.error?.message).toBe("An internal error occurred");
|
|
});
|
|
|
|
it("includes ctx.email when email pipeline is configured", async () => {
|
|
const hookPipeline = new HookPipeline([], createMockFactoryOptions());
|
|
hookPipeline.setExclusiveSelection("email:deliver", "provider");
|
|
const emailPipeline = new EmailPipeline(hookPipeline);
|
|
|
|
const plugin = createTestPlugin({
|
|
capabilities: ["email:send"],
|
|
routes: {
|
|
checkEmail: {
|
|
handler: async (ctx) => ({
|
|
hasEmail: !!ctx.email,
|
|
hasSend: typeof ctx.email?.send === "function",
|
|
}),
|
|
},
|
|
},
|
|
});
|
|
|
|
const handler = new PluginRouteHandler(plugin, {
|
|
...createMockFactoryOptions(),
|
|
emailPipeline,
|
|
});
|
|
|
|
const result = await handler.invoke("checkEmail", {
|
|
request: new Request("http://test.com"),
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({ hasEmail: true, hasSend: true });
|
|
});
|
|
|
|
it("surfaces an actionable error when a handler reads the consumed request body (#1293)", async () => {
|
|
// EmDash parses the body once and exposes it as ctx.input; the same
|
|
// Request is then handed to the handler with its stream already spent.
|
|
// A handler that instinctively calls ctx.request.json() should get a
|
|
// message pointing at ctx.input, not the runtime's opaque
|
|
// "body already read" error.
|
|
const bodyMethods = ["json", "text", "arrayBuffer", "formData", "blob"] as const;
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
echo: {
|
|
handler: async (ctx) => {
|
|
const errors: Record<string, string> = {};
|
|
for (const method of bodyMethods) {
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
await (ctx.request as any)[method]();
|
|
errors[method] = "NO_ERROR";
|
|
} catch (e) {
|
|
errors[method] = (e as Error).message;
|
|
}
|
|
}
|
|
return { errors, input: ctx.input };
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const result = await handler.invoke("echo", {
|
|
request: new Request("http://test.com/echo", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ hello: "world" }),
|
|
}),
|
|
body: { hello: "world" },
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
const data = result.data as { errors: Record<string, string>; input: unknown };
|
|
for (const method of bodyMethods) {
|
|
expect(data.errors[method]).toContain("ctx.input");
|
|
expect(data.errors[method]).toContain("[emdash]");
|
|
}
|
|
// The parsed body is still available as ctx.input.
|
|
expect(data.input).toEqual({ hello: "world" });
|
|
});
|
|
|
|
it("passes request url, method, and headers through the body guard (#1293)", async () => {
|
|
// The guard must not interfere with non-body members — notably the
|
|
// `headers.forEach` the sandbox-entry adapter relies on.
|
|
const plugin = createTestPlugin({
|
|
routes: {
|
|
meta: {
|
|
handler: async (ctx) => {
|
|
const collected: Record<string, string> = {};
|
|
ctx.request.headers.forEach((value, name) => {
|
|
collected[name] = value;
|
|
});
|
|
return {
|
|
url: ctx.request.url,
|
|
method: ctx.request.method,
|
|
contentType: ctx.request.headers.get("content-type"),
|
|
forEachSawContentType: collected["content-type"] === "application/json",
|
|
};
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const handler = new PluginRouteHandler(plugin, createMockFactoryOptions());
|
|
|
|
const result = await handler.invoke("meta", {
|
|
request: new Request("http://test.com/meta", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: "{}",
|
|
}),
|
|
body: {},
|
|
});
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.data).toEqual({
|
|
url: "http://test.com/meta",
|
|
method: "POST",
|
|
contentType: "application/json",
|
|
forEachSawContentType: true,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("PluginRouteRegistry", () => {
|
|
describe("register/unregister", () => {
|
|
it("registers a plugin", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
const plugin = createTestPlugin({
|
|
id: "my-plugin",
|
|
routes: { sync: { handler: vi.fn() } },
|
|
});
|
|
|
|
registry.register(plugin);
|
|
|
|
expect(registry.getPluginIds()).toContain("my-plugin");
|
|
});
|
|
|
|
it("unregisters a plugin", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
const plugin = createTestPlugin({ id: "my-plugin" });
|
|
|
|
registry.register(plugin);
|
|
registry.unregister("my-plugin");
|
|
|
|
expect(registry.getPluginIds()).not.toContain("my-plugin");
|
|
});
|
|
});
|
|
|
|
describe("getPluginIds", () => {
|
|
it("returns empty array initially", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
expect(registry.getPluginIds()).toEqual([]);
|
|
});
|
|
|
|
it("returns all registered plugin IDs", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
|
|
registry.register(createTestPlugin({ id: "plugin-a" }));
|
|
registry.register(createTestPlugin({ id: "plugin-b" }));
|
|
registry.register(createTestPlugin({ id: "plugin-c" }));
|
|
|
|
const ids = registry.getPluginIds();
|
|
expect(ids).toContain("plugin-a");
|
|
expect(ids).toContain("plugin-b");
|
|
expect(ids).toContain("plugin-c");
|
|
});
|
|
});
|
|
|
|
describe("getRoutes", () => {
|
|
it("returns empty array for non-existent plugin", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
expect(registry.getRoutes("non-existent")).toEqual([]);
|
|
});
|
|
|
|
it("returns route names for registered plugin", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
const plugin = createTestPlugin({
|
|
id: "my-plugin",
|
|
routes: {
|
|
sync: { handler: vi.fn() },
|
|
import: { handler: vi.fn() },
|
|
},
|
|
});
|
|
|
|
registry.register(plugin);
|
|
const routes = registry.getRoutes("my-plugin");
|
|
|
|
expect(routes).toContain("sync");
|
|
expect(routes).toContain("import");
|
|
});
|
|
});
|
|
|
|
describe("getRouteMeta", () => {
|
|
it("returns null for non-existent plugin", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
expect(registry.getRouteMeta("non-existent", "sync")).toBeNull();
|
|
});
|
|
|
|
it("returns null for non-existent route on registered plugin", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
const plugin = createTestPlugin({
|
|
id: "my-plugin",
|
|
routes: { sync: { handler: vi.fn() } },
|
|
});
|
|
registry.register(plugin);
|
|
|
|
expect(registry.getRouteMeta("my-plugin", "non-existent")).toBeNull();
|
|
});
|
|
|
|
it("returns metadata for existing route", () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
const plugin = createTestPlugin({
|
|
id: "my-plugin",
|
|
routes: {
|
|
sync: { handler: vi.fn() },
|
|
submit: { public: true, handler: vi.fn() },
|
|
},
|
|
});
|
|
registry.register(plugin);
|
|
|
|
expect(registry.getRouteMeta("my-plugin", "sync")).toEqual({ public: false });
|
|
expect(registry.getRouteMeta("my-plugin", "submit")).toEqual({ public: true });
|
|
});
|
|
});
|
|
|
|
describe("invoke", () => {
|
|
it("returns 404 for non-existent plugin", async () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
|
|
const result = await registry.invoke("non-existent", "sync", {
|
|
request: new Request("http://test.com"),
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.status).toBe(404);
|
|
expect(result.error?.code).toBe("PLUGIN_NOT_FOUND");
|
|
});
|
|
|
|
it("delegates to plugin handler", async () => {
|
|
const registry = new PluginRouteRegistry(createMockFactoryOptions());
|
|
const plugin = createTestPlugin({
|
|
id: "my-plugin",
|
|
routes: {
|
|
status: {
|
|
handler: async () => ({ healthy: true }),
|
|
},
|
|
},
|
|
});
|
|
|
|
registry.register(plugin);
|
|
|
|
// This will fail because handler tries to create context with mock DB
|
|
// But we can verify it attempts to invoke
|
|
const result = await registry.invoke("my-plugin", "non-existent", {
|
|
request: new Request("http://test.com"),
|
|
});
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error?.code).toBe("ROUTE_NOT_FOUND");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createRouteRegistry helper", () => {
|
|
it("creates a PluginRouteRegistry instance", () => {
|
|
const registry = createRouteRegistry(createMockFactoryOptions());
|
|
expect(registry).toBeInstanceOf(PluginRouteRegistry);
|
|
});
|
|
});
|