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:
485
packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts
Normal file
485
packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* adaptSandboxEntry() Tests
|
||||
*
|
||||
* Tests the in-process adapter that converts standard-format plugins
|
||||
* ({ hooks, routes }) into ResolvedPlugin instances compatible with HookPipeline.
|
||||
*
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js";
|
||||
import { adaptSandboxEntry } from "../../../src/plugins/adapt-sandbox-entry.js";
|
||||
import type { StandardPluginDefinition, StandardHookHandler } from "../../../src/plugins/types.js";
|
||||
|
||||
/** Create a properly typed mock hook handler */
|
||||
function mockHandler(): StandardHookHandler {
|
||||
return vi.fn(async () => {}) as unknown as StandardHookHandler;
|
||||
}
|
||||
|
||||
function createDescriptor(overrides?: Partial<PluginDescriptor>): PluginDescriptor {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@test/plugin",
|
||||
format: "standard",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("adaptSandboxEntry", () => {
|
||||
describe("basic adaptation", () => {
|
||||
it("produces a ResolvedPlugin with correct id and version", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {},
|
||||
routes: {},
|
||||
};
|
||||
const descriptor = createDescriptor({ id: "my-plugin", version: "2.1.0" });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.id).toBe("my-plugin");
|
||||
expect(result.version).toBe("2.1.0");
|
||||
});
|
||||
|
||||
it("adapts an empty definition", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.hooks).toEqual({});
|
||||
expect(result.routes).toEqual({});
|
||||
expect(result.capabilities).toEqual([]);
|
||||
expect(result.allowedHosts).toEqual([]);
|
||||
expect(result.storage).toEqual({});
|
||||
});
|
||||
|
||||
it("carries capabilities from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
capabilities: ["content:read", "network:request"],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.capabilities).toEqual(["content:read", "network:request"]);
|
||||
});
|
||||
|
||||
it("carries allowedHosts from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
allowedHosts: ["api.example.com", "*.cdn.com"],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.allowedHosts).toEqual(["api.example.com", "*.cdn.com"]);
|
||||
});
|
||||
|
||||
it("carries storage config from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
storage: {
|
||||
events: { indexes: ["timestamp", "type"] },
|
||||
logs: { indexes: ["level"] },
|
||||
},
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.storage).toEqual({
|
||||
events: { indexes: ["timestamp", "type"] },
|
||||
logs: { indexes: ["level"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("carries admin pages from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
adminPages: [{ path: "/settings", label: "Settings", icon: "gear" }],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.admin.pages).toEqual([{ path: "/settings", label: "Settings", icon: "gear" }]);
|
||||
});
|
||||
|
||||
it("carries admin widgets from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.admin.widgets).toEqual([{ id: "status", title: "Status", size: "half" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook adaptation", () => {
|
||||
it("resolves a bare function hook with defaults", () => {
|
||||
const handler = vi.fn();
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:afterSave": handler,
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const hook = result.hooks["content:afterSave"];
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook!.handler).toBe(handler);
|
||||
expect(hook!.priority).toBe(100);
|
||||
expect(hook!.timeout).toBe(5000);
|
||||
expect(hook!.dependencies).toEqual([]);
|
||||
expect(hook!.errorPolicy).toBe("abort");
|
||||
expect(hook!.exclusive).toBe(false);
|
||||
expect(hook!.pluginId).toBe("test-plugin");
|
||||
});
|
||||
|
||||
it("resolves a config object hook with custom settings", () => {
|
||||
const handler = vi.fn();
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:beforeSave": {
|
||||
handler,
|
||||
priority: 1,
|
||||
timeout: 10000,
|
||||
dependencies: ["other-plugin"],
|
||||
errorPolicy: "continue",
|
||||
exclusive: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const hook = result.hooks["content:beforeSave"];
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook!.handler).toBe(handler);
|
||||
expect(hook!.priority).toBe(1);
|
||||
expect(hook!.timeout).toBe(10000);
|
||||
expect(hook!.dependencies).toEqual(["other-plugin"]);
|
||||
expect(hook!.errorPolicy).toBe("continue");
|
||||
});
|
||||
|
||||
it("resolves multiple hooks", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:beforeSave": mockHandler(),
|
||||
"content:afterSave": { handler: mockHandler(), priority: 200 },
|
||||
"content:afterDelete": mockHandler(),
|
||||
"media:afterUpload": mockHandler(),
|
||||
"plugin:install": mockHandler(),
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.hooks["content:beforeSave"]).toBeDefined();
|
||||
expect(result.hooks["content:afterSave"]).toBeDefined();
|
||||
expect(result.hooks["content:afterDelete"]).toBeDefined();
|
||||
expect(result.hooks["media:afterUpload"]).toBeDefined();
|
||||
expect(result.hooks["plugin:install"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets pluginId on all hooks from descriptor", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:beforeSave": mockHandler(),
|
||||
"content:afterSave": { handler: mockHandler() },
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor({ id: "my-plugin" });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.hooks["content:beforeSave"]!.pluginId).toBe("my-plugin");
|
||||
expect(result.hooks["content:afterSave"]!.pluginId).toBe("my-plugin");
|
||||
});
|
||||
|
||||
it("resolves exclusive hooks", () => {
|
||||
const handler = vi.fn();
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"email:deliver": {
|
||||
handler,
|
||||
exclusive: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.hooks["email:deliver"]!.exclusive).toBe(true);
|
||||
});
|
||||
|
||||
it("throws on unknown hook names", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"unknown:hook": mockHandler(),
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
expect(() => adaptSandboxEntry(def, descriptor)).toThrow("unknown hook");
|
||||
});
|
||||
|
||||
it("applies default config for partial config objects", () => {
|
||||
const handler = vi.fn();
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler,
|
||||
priority: 200,
|
||||
// timeout, dependencies, errorPolicy, exclusive use defaults
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const hook = result.hooks["content:afterSave"];
|
||||
expect(hook!.priority).toBe(200);
|
||||
expect(hook!.timeout).toBe(5000);
|
||||
expect(hook!.dependencies).toEqual([]);
|
||||
expect(hook!.errorPolicy).toBe("abort");
|
||||
expect(hook!.exclusive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route adaptation", () => {
|
||||
it("wraps standard two-arg route handler into single-arg RouteContext handler", async () => {
|
||||
const standardHandler = vi.fn().mockResolvedValue({ ok: true });
|
||||
|
||||
const def: StandardPluginDefinition = {
|
||||
routes: {
|
||||
status: {
|
||||
handler: standardHandler,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.routes.status).toBeDefined();
|
||||
|
||||
// Simulate calling the adapted handler with a RouteContext-like object
|
||||
const mockCtx = {
|
||||
input: { foo: "bar" },
|
||||
request: new Request("http://localhost/test"),
|
||||
requestMeta: { ip: null, userAgent: null, referer: null, geo: null },
|
||||
plugin: { id: "test-plugin", version: "1.0.0" },
|
||||
kv: {} as any,
|
||||
storage: {} as any,
|
||||
log: {} as any,
|
||||
site: { name: "", url: "", locale: "en" },
|
||||
url: (p: string) => p,
|
||||
};
|
||||
|
||||
await result.routes.status.handler(mockCtx as any);
|
||||
|
||||
// Verify the standard handler was called with (routeCtx, pluginCtx)
|
||||
expect(standardHandler).toHaveBeenCalledTimes(1);
|
||||
const [routeCtx, pluginCtx] = standardHandler.mock.calls[0];
|
||||
expect(routeCtx.input).toEqual({ foo: "bar" });
|
||||
expect(routeCtx.request).toBeDefined();
|
||||
expect(routeCtx.requestMeta).toBeDefined();
|
||||
// pluginCtx should be the stripped PluginContext (without route-specific fields)
|
||||
expect(pluginCtx.plugin.id).toBe("test-plugin");
|
||||
expect(pluginCtx.kv).toBeDefined();
|
||||
expect(pluginCtx.log).toBeDefined();
|
||||
// Route-specific fields should NOT leak into pluginCtx
|
||||
expect(pluginCtx).not.toHaveProperty("input");
|
||||
expect(pluginCtx).not.toHaveProperty("request");
|
||||
expect(pluginCtx).not.toHaveProperty("requestMeta");
|
||||
});
|
||||
|
||||
it("preserves public flag on routes", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
routes: {
|
||||
webhook: {
|
||||
handler: vi.fn(),
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.routes.webhook.public).toBe(true);
|
||||
});
|
||||
|
||||
it("adapts multiple routes", () => {
|
||||
const def: StandardPluginDefinition = {
|
||||
routes: {
|
||||
status: { handler: vi.fn() },
|
||||
sync: { handler: vi.fn() },
|
||||
"admin/settings": { handler: vi.fn() },
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(Object.keys(result.routes)).toEqual(["status", "sync", "admin/settings"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability normalization", () => {
|
||||
it("normalizes content:write to include content:read", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({ capabilities: ["content:write"] });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.capabilities).toContain("content:write");
|
||||
expect(result.capabilities).toContain("content:read");
|
||||
});
|
||||
|
||||
it("normalizes media:write to include media:read", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({ capabilities: ["media:write"] });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.capabilities).toContain("media:write");
|
||||
expect(result.capabilities).toContain("media:read");
|
||||
});
|
||||
|
||||
it("normalizes network:request:unrestricted to include network:request", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({ capabilities: ["network:request:unrestricted"] });
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
expect(result.capabilities).toContain("network:request:unrestricted");
|
||||
expect(result.capabilities).toContain("network:request");
|
||||
});
|
||||
|
||||
it("does not duplicate implied capabilities", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
capabilities: ["content:read", "content:write"],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const readCount = result.capabilities.filter((c) => c === "content:read").length;
|
||||
expect(readCount).toBe(1);
|
||||
});
|
||||
|
||||
it("throws on invalid capability", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
capabilities: ["invalid:capability"],
|
||||
});
|
||||
|
||||
expect(() => adaptSandboxEntry(def, descriptor)).toThrow("Invalid capability");
|
||||
});
|
||||
|
||||
// ── Deprecation alias layer ────────────────────────────────
|
||||
// Sandboxed plugins arrive via descriptors generated by older
|
||||
// builds (or older bundle versions). The adapter must accept
|
||||
// deprecated names and silently rewrite to canonical names so
|
||||
// the runtime only sees the new shape.
|
||||
|
||||
it("rewrites all deprecated capability names to current names", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
capabilities: [
|
||||
"read:content",
|
||||
"write:content",
|
||||
"read:media",
|
||||
"write:media",
|
||||
"read:users",
|
||||
"network:fetch",
|
||||
"network:fetch:any",
|
||||
"email:provide",
|
||||
"email:intercept",
|
||||
"page:inject",
|
||||
],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
// Canonical names present
|
||||
expect(result.capabilities).toContain("content:read");
|
||||
expect(result.capabilities).toContain("content:write");
|
||||
expect(result.capabilities).toContain("media:read");
|
||||
expect(result.capabilities).toContain("media:write");
|
||||
expect(result.capabilities).toContain("users:read");
|
||||
expect(result.capabilities).toContain("network:request");
|
||||
expect(result.capabilities).toContain("network:request:unrestricted");
|
||||
expect(result.capabilities).toContain("hooks.email-transport:register");
|
||||
expect(result.capabilities).toContain("hooks.email-events:register");
|
||||
expect(result.capabilities).toContain("hooks.page-fragments:register");
|
||||
|
||||
// Deprecated names absent
|
||||
for (const old of [
|
||||
"read:content",
|
||||
"write:content",
|
||||
"read:media",
|
||||
"write:media",
|
||||
"read:users",
|
||||
"network:fetch",
|
||||
"network:fetch:any",
|
||||
"email:provide",
|
||||
"email:intercept",
|
||||
"page:inject",
|
||||
]) {
|
||||
expect(result.capabilities).not.toContain(old);
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates when both deprecated and current names are present", () => {
|
||||
const def: StandardPluginDefinition = {};
|
||||
const descriptor = createDescriptor({
|
||||
capabilities: ["read:content", "content:read"],
|
||||
});
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
const readCount = result.capabilities.filter((c) => c === "content:read").length;
|
||||
expect(readCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration with HookPipeline", () => {
|
||||
it("produces hooks compatible with HookPipeline registration", () => {
|
||||
// HookPipeline stores hooks as ResolvedHook<unknown> internally.
|
||||
// The adapted hooks must have the expected shape.
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
const def: StandardPluginDefinition = {
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler,
|
||||
priority: 50,
|
||||
},
|
||||
},
|
||||
};
|
||||
const descriptor = createDescriptor();
|
||||
|
||||
const result = adaptSandboxEntry(def, descriptor);
|
||||
|
||||
// Verify the hook shape matches what HookPipeline expects
|
||||
const hook = result.hooks["content:afterSave"]!;
|
||||
expect(typeof hook.handler).toBe("function");
|
||||
expect(typeof hook.priority).toBe("number");
|
||||
expect(typeof hook.timeout).toBe("number");
|
||||
expect(Array.isArray(hook.dependencies)).toBe(true);
|
||||
expect(typeof hook.errorPolicy).toBe("string");
|
||||
expect(typeof hook.exclusive).toBe("boolean");
|
||||
expect(typeof hook.pluginId).toBe("string");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Capability Normalization Tests
|
||||
*
|
||||
* Tests the deprecation alias layer for plugin capability names. The runtime
|
||||
* never sees deprecated names — `normalizeCapability()` rewrites them at
|
||||
* every external boundary (definePlugin, adaptSandboxEntry, marketplace
|
||||
* diff). These tests pin the rename map and the normalization helpers so
|
||||
* that the alias layer keeps working until the deprecated names are
|
||||
* removed in the next minor.
|
||||
*
|
||||
* @see Issue: "Plugin capability names are inconsistent"
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
CAPABILITY_RENAMES,
|
||||
isDeprecatedCapability,
|
||||
normalizeCapabilities,
|
||||
normalizeCapability,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import type { DeprecatedPluginCapability } from "../../../src/plugins/types.js";
|
||||
|
||||
describe("CAPABILITY_RENAMES", () => {
|
||||
it("maps every deprecated name to its current replacement", () => {
|
||||
// Pin the rename table — if the issue's table changes, this test
|
||||
// catches the drift. Anyone adding a deprecation should update
|
||||
// this case explicitly.
|
||||
expect(CAPABILITY_RENAMES).toEqual({
|
||||
"network:fetch": "network:request",
|
||||
"network:fetch:any": "network:request:unrestricted",
|
||||
"read:content": "content:read",
|
||||
"write:content": "content:write",
|
||||
"read:media": "media:read",
|
||||
"write:media": "media:write",
|
||||
"read:users": "users:read",
|
||||
"email:provide": "hooks.email-transport:register",
|
||||
"email:intercept": "hooks.email-events:register",
|
||||
"page:inject": "hooks.page-fragments:register",
|
||||
});
|
||||
});
|
||||
|
||||
it("is frozen — cannot be mutated at runtime", () => {
|
||||
// `Object.freeze` makes the rename table tamper-proof.
|
||||
expect(Object.isFrozen(CAPABILITY_RENAMES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDeprecatedCapability", () => {
|
||||
it("returns true for every deprecated name in the rename table", () => {
|
||||
for (const cap of Object.keys(CAPABILITY_RENAMES) as DeprecatedPluginCapability[]) {
|
||||
expect(isDeprecatedCapability(cap)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for current capability names", () => {
|
||||
const current = [
|
||||
"content:read",
|
||||
"content:write",
|
||||
"media:read",
|
||||
"media:write",
|
||||
"users:read",
|
||||
"network:request",
|
||||
"network:request:unrestricted",
|
||||
"email:send",
|
||||
"hooks.email-transport:register",
|
||||
"hooks.email-events:register",
|
||||
"hooks.page-fragments:register",
|
||||
];
|
||||
for (const cap of current) {
|
||||
expect(isDeprecatedCapability(cap)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false for unknown strings", () => {
|
||||
expect(isDeprecatedCapability("not:a:capability")).toBe(false);
|
||||
expect(isDeprecatedCapability("")).toBe(false);
|
||||
expect(isDeprecatedCapability("content")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match Object.prototype keys", () => {
|
||||
// Regression: an `in` check against CAPABILITY_RENAMES would
|
||||
// also match inherited properties. Using `Object.prototype.hasOwnProperty`
|
||||
// (or `Object.hasOwn`) keeps the check scoped to own properties.
|
||||
// Without the guard, `normalizeCapability("toString")` would return
|
||||
// the prototype function reference, breaking the contract that
|
||||
// unknown strings are returned as-is.
|
||||
expect(isDeprecatedCapability("toString")).toBe(false);
|
||||
expect(isDeprecatedCapability("constructor")).toBe(false);
|
||||
expect(isDeprecatedCapability("hasOwnProperty")).toBe(false);
|
||||
expect(isDeprecatedCapability("__proto__")).toBe(false);
|
||||
expect(isDeprecatedCapability("valueOf")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCapability", () => {
|
||||
it("rewrites deprecated names to current names", () => {
|
||||
expect(normalizeCapability("read:content")).toBe("content:read");
|
||||
expect(normalizeCapability("write:content")).toBe("content:write");
|
||||
expect(normalizeCapability("read:media")).toBe("media:read");
|
||||
expect(normalizeCapability("write:media")).toBe("media:write");
|
||||
expect(normalizeCapability("read:users")).toBe("users:read");
|
||||
expect(normalizeCapability("network:fetch")).toBe("network:request");
|
||||
expect(normalizeCapability("network:fetch:any")).toBe("network:request:unrestricted");
|
||||
expect(normalizeCapability("email:provide")).toBe("hooks.email-transport:register");
|
||||
expect(normalizeCapability("email:intercept")).toBe("hooks.email-events:register");
|
||||
expect(normalizeCapability("page:inject")).toBe("hooks.page-fragments:register");
|
||||
});
|
||||
|
||||
it("leaves current names unchanged", () => {
|
||||
expect(normalizeCapability("content:read")).toBe("content:read");
|
||||
expect(normalizeCapability("network:request")).toBe("network:request");
|
||||
expect(normalizeCapability("hooks.email-transport:register")).toBe(
|
||||
"hooks.email-transport:register",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes through unknown strings unchanged", () => {
|
||||
// Downstream validators throw on unknown capabilities; the
|
||||
// normalizer's job is purely to translate the alias map.
|
||||
expect(normalizeCapability("invalid:capability")).toBe("invalid:capability");
|
||||
expect(normalizeCapability("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns Object.prototype keys as-is (does not return prototype values)", () => {
|
||||
// Regression: with an `in` check, `normalizeCapability("toString")`
|
||||
// would have returned `Object.prototype.toString` (a function).
|
||||
// The own-property guard ensures we always return a string.
|
||||
expect(normalizeCapability("toString")).toBe("toString");
|
||||
expect(normalizeCapability("constructor")).toBe("constructor");
|
||||
expect(normalizeCapability("__proto__")).toBe("__proto__");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCapabilities", () => {
|
||||
it("rewrites every deprecated name in an array", () => {
|
||||
const input = ["read:content", "write:content", "network:fetch"];
|
||||
const result = normalizeCapabilities(input);
|
||||
|
||||
expect(result).toEqual(["content:read", "content:write", "network:request"]);
|
||||
});
|
||||
|
||||
it("preserves order of first occurrence", () => {
|
||||
const result = normalizeCapabilities(["network:request", "read:content", "write:media"]);
|
||||
|
||||
expect(result).toEqual(["network:request", "content:read", "media:write"]);
|
||||
});
|
||||
|
||||
it("deduplicates by canonical name when both old and new are present", () => {
|
||||
// A plugin migrating from old to new might transiently declare
|
||||
// both — the normalizer must not produce duplicates.
|
||||
const result = normalizeCapabilities(["read:content", "content:read"]);
|
||||
|
||||
expect(result).toEqual(["content:read"]);
|
||||
});
|
||||
|
||||
it("deduplicates two deprecated names that map to the same current name", () => {
|
||||
// Defensive: if someone declares the same alias twice, the result
|
||||
// must still contain it only once.
|
||||
const result = normalizeCapabilities(["read:content", "read:content"]);
|
||||
|
||||
expect(result).toEqual(["content:read"]);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(normalizeCapabilities([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not mutate the input array", () => {
|
||||
const input = ["read:content", "write:content"];
|
||||
const snapshot = [...input];
|
||||
normalizeCapabilities(input);
|
||||
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("is idempotent — normalizing twice gives the same result", () => {
|
||||
const input = ["read:content", "write:media", "page:inject"];
|
||||
const once = normalizeCapabilities(input);
|
||||
const twice = normalizeCapabilities(once);
|
||||
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
});
|
||||
518
packages/core/tests/unit/plugins/define-plugin.test.ts
Normal file
518
packages/core/tests/unit/plugins/define-plugin.test.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* definePlugin() Tests
|
||||
*
|
||||
* Tests the plugin definition helper for:
|
||||
* - ID validation (simple and scoped formats)
|
||||
* - Version validation (semver)
|
||||
* - Capability validation and normalization
|
||||
* - Hook resolution (function vs config object)
|
||||
* - Default value handling
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { definePlugin } from "../../../src/plugins/define-plugin.js";
|
||||
|
||||
// Error message patterns for test assertions
|
||||
const INVALID_PLUGIN_ID_PATTERN = /Invalid plugin id/;
|
||||
const INVALID_PLUGIN_VERSION_PATTERN = /Invalid plugin version/;
|
||||
const INVALID_CAPABILITY_PATTERN = /Invalid capability/;
|
||||
|
||||
describe("definePlugin", () => {
|
||||
describe("ID validation", () => {
|
||||
it("accepts valid simple ID", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.id).toBe("my-plugin");
|
||||
});
|
||||
|
||||
it("accepts valid simple ID with numbers", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "plugin-v2",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.id).toBe("plugin-v2");
|
||||
});
|
||||
|
||||
it("accepts valid scoped ID", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "@emdash-cms/seo-plugin",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.id).toBe("@emdash-cms/seo-plugin");
|
||||
});
|
||||
|
||||
it("accepts scoped ID with numbers", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "@my-org/plugin-v2",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.id).toBe("@my-org/plugin-v2");
|
||||
});
|
||||
|
||||
it("rejects ID with uppercase letters", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "MyPlugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects ID with underscores", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "my_plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects ID with spaces", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "my plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects empty ID", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects invalid scoped ID (missing name)", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "@my-org/",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects invalid scoped ID (missing scope)", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "@/my-plugin",
|
||||
version: "1.0.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_ID_PATTERN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("version validation", () => {
|
||||
it("accepts valid semver", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("accepts semver with prerelease", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0-beta.1",
|
||||
});
|
||||
|
||||
expect(plugin.version).toBe("1.0.0-beta.1");
|
||||
});
|
||||
|
||||
it("accepts semver with build metadata", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0+build.123",
|
||||
});
|
||||
|
||||
expect(plugin.version).toBe("1.0.0+build.123");
|
||||
});
|
||||
|
||||
it("rejects invalid version format", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "test",
|
||||
version: "1.0",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_VERSION_PATTERN);
|
||||
});
|
||||
|
||||
it("rejects non-numeric version", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "test",
|
||||
version: "latest",
|
||||
}),
|
||||
).toThrow(INVALID_PLUGIN_VERSION_PATTERN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability validation", () => {
|
||||
it("accepts valid capabilities", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["content:read", "content:write", "network:request"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("content:read");
|
||||
expect(plugin.capabilities).toContain("content:write");
|
||||
expect(plugin.capabilities).toContain("network:request");
|
||||
});
|
||||
|
||||
it("accepts media:read and media:write", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["media:read", "media:write"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("media:read");
|
||||
expect(plugin.capabilities).toContain("media:write");
|
||||
});
|
||||
|
||||
it("rejects invalid capability", () => {
|
||||
expect(() =>
|
||||
definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["invalid:capability" as any],
|
||||
}),
|
||||
).toThrow(INVALID_CAPABILITY_PATTERN);
|
||||
});
|
||||
|
||||
it("normalizes content:write to include content:read", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["content:write"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("content:write");
|
||||
expect(plugin.capabilities).toContain("content:read");
|
||||
});
|
||||
|
||||
it("normalizes media:write to include media:read", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["media:write"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("media:write");
|
||||
expect(plugin.capabilities).toContain("media:read");
|
||||
});
|
||||
|
||||
it("normalizes network:request:unrestricted to include network:request", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["network:request:unrestricted"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("network:request:unrestricted");
|
||||
expect(plugin.capabilities).toContain("network:request");
|
||||
});
|
||||
|
||||
it("does not duplicate read when already present", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["content:read", "content:write"],
|
||||
});
|
||||
|
||||
const readCount = plugin.capabilities.filter((c) => c === "content:read").length;
|
||||
expect(readCount).toBe(1);
|
||||
});
|
||||
|
||||
it("defaults to empty capabilities", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toEqual([]);
|
||||
});
|
||||
|
||||
// ── Deprecation alias layer ────────────────────────────────
|
||||
// During the deprecation window we accept the old names and
|
||||
// silently rewrite them to the new names. The runtime should
|
||||
// only ever see canonical (new) names.
|
||||
|
||||
it("accepts and normalizes deprecated capability names", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: [
|
||||
"read:content",
|
||||
"write:content",
|
||||
"read:media",
|
||||
"write:media",
|
||||
"read:users",
|
||||
"network:fetch",
|
||||
"network:fetch:any",
|
||||
"email:provide",
|
||||
"email:intercept",
|
||||
"page:inject",
|
||||
],
|
||||
});
|
||||
|
||||
// Normalized to current names
|
||||
expect(plugin.capabilities).toContain("content:read");
|
||||
expect(plugin.capabilities).toContain("content:write");
|
||||
expect(plugin.capabilities).toContain("media:read");
|
||||
expect(plugin.capabilities).toContain("media:write");
|
||||
expect(plugin.capabilities).toContain("users:read");
|
||||
expect(plugin.capabilities).toContain("network:request");
|
||||
expect(plugin.capabilities).toContain("network:request:unrestricted");
|
||||
expect(plugin.capabilities).toContain("hooks.email-transport:register");
|
||||
expect(plugin.capabilities).toContain("hooks.email-events:register");
|
||||
expect(plugin.capabilities).toContain("hooks.page-fragments:register");
|
||||
|
||||
// And the deprecated names do NOT appear in the resolved capabilities
|
||||
expect(plugin.capabilities).not.toContain("read:content");
|
||||
expect(plugin.capabilities).not.toContain("write:content");
|
||||
expect(plugin.capabilities).not.toContain("network:fetch");
|
||||
expect(plugin.capabilities).not.toContain("network:fetch:any");
|
||||
expect(plugin.capabilities).not.toContain("email:provide");
|
||||
expect(plugin.capabilities).not.toContain("email:intercept");
|
||||
expect(plugin.capabilities).not.toContain("page:inject");
|
||||
});
|
||||
|
||||
it("deduplicates when both deprecated and current names are passed", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
// Same capability, both spellings
|
||||
capabilities: ["read:content", "content:read"],
|
||||
});
|
||||
|
||||
const readCount = plugin.capabilities.filter((c) => c === "content:read").length;
|
||||
expect(readCount).toBe(1);
|
||||
});
|
||||
|
||||
it("normalizes deprecated names before applying implications", () => {
|
||||
// `write:content` (deprecated) should still imply `content:read`
|
||||
// after rewrite, not `read:content`.
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: ["write:content"],
|
||||
});
|
||||
|
||||
expect(plugin.capabilities).toContain("content:write");
|
||||
expect(plugin.capabilities).toContain("content:read");
|
||||
expect(plugin.capabilities).not.toContain("write:content");
|
||||
expect(plugin.capabilities).not.toContain("read:content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook resolution", () => {
|
||||
it("resolves function shorthand to full config", () => {
|
||||
const handler = vi.fn();
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": handler,
|
||||
},
|
||||
});
|
||||
|
||||
const hook = plugin.hooks["content:beforeSave"];
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook!.handler).toBe(handler);
|
||||
expect(hook!.priority).toBe(100);
|
||||
expect(hook!.timeout).toBe(5000);
|
||||
expect(hook!.dependencies).toEqual([]);
|
||||
expect(hook!.errorPolicy).toBe("abort");
|
||||
expect(hook!.pluginId).toBe("test");
|
||||
});
|
||||
|
||||
it("resolves full config object", () => {
|
||||
const handler = vi.fn();
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": {
|
||||
handler,
|
||||
priority: 50,
|
||||
timeout: 10000,
|
||||
dependencies: ["other-plugin"],
|
||||
errorPolicy: "continue",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hook = plugin.hooks["content:beforeSave"];
|
||||
expect(hook).toBeDefined();
|
||||
expect(hook!.handler).toBe(handler);
|
||||
expect(hook!.priority).toBe(50);
|
||||
expect(hook!.timeout).toBe(10000);
|
||||
expect(hook!.dependencies).toEqual(["other-plugin"]);
|
||||
expect(hook!.errorPolicy).toBe("continue");
|
||||
});
|
||||
|
||||
it("applies defaults to partial config", () => {
|
||||
const handler = vi.fn();
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler,
|
||||
priority: 200,
|
||||
// timeout, dependencies, errorPolicy use defaults
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hook = plugin.hooks["content:afterSave"];
|
||||
expect(hook!.priority).toBe(200);
|
||||
expect(hook!.timeout).toBe(5000);
|
||||
expect(hook!.dependencies).toEqual([]);
|
||||
expect(hook!.errorPolicy).toBe("abort");
|
||||
});
|
||||
|
||||
it("resolves multiple hooks", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": vi.fn(),
|
||||
"content:afterSave": vi.fn(),
|
||||
"plugin:install": vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.hooks["content:beforeSave"]).toBeDefined();
|
||||
expect(plugin.hooks["content:afterSave"]).toBeDefined();
|
||||
expect(plugin.hooks["plugin:install"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("sets pluginId on all resolved hooks", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": vi.fn(),
|
||||
"media:afterUpload": { handler: vi.fn(), priority: 50 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.hooks["content:beforeSave"]!.pluginId).toBe("my-plugin");
|
||||
expect(plugin.hooks["media:afterUpload"]!.pluginId).toBe("my-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default values", () => {
|
||||
it("defaults allowedHosts to empty array", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.allowedHosts).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults storage to empty object", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.storage).toEqual({});
|
||||
});
|
||||
|
||||
it("defaults hooks to empty object", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.hooks).toEqual({});
|
||||
});
|
||||
|
||||
it("defaults routes to empty object", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
expect(plugin.routes).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves provided allowedHosts", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
allowedHosts: ["api.example.com", "*.cdn.com"],
|
||||
});
|
||||
|
||||
expect(plugin.allowedHosts).toEqual(["api.example.com", "*.cdn.com"]);
|
||||
});
|
||||
|
||||
it("preserves provided storage config", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
storage: {
|
||||
items: { indexes: ["type", "status"] },
|
||||
cache: { indexes: ["key"] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.storage).toEqual({
|
||||
items: { indexes: ["type", "status"] },
|
||||
cache: { indexes: ["key"] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("routes passthrough", () => {
|
||||
it("preserves route definitions", () => {
|
||||
const handler = vi.fn();
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
routes: {
|
||||
sync: { handler },
|
||||
webhook: { handler, input: {} as any },
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.routes.sync).toBeDefined();
|
||||
expect(plugin.routes.sync.handler).toBe(handler);
|
||||
expect(plugin.routes.webhook).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin passthrough", () => {
|
||||
it("preserves admin config", () => {
|
||||
const plugin = definePlugin({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
admin: {
|
||||
entry: "@test/plugin/admin",
|
||||
pages: [{ id: "settings", title: "Settings" }],
|
||||
widgets: [{ id: "stats", title: "Stats", area: "dashboard" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(plugin.admin.entry).toBe("@test/plugin/admin");
|
||||
expect(plugin.admin.pages).toHaveLength(1);
|
||||
expect(plugin.admin.widgets).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
1312
packages/core/tests/unit/plugins/email-pipeline.test.ts
Normal file
1312
packages/core/tests/unit/plugins/email-pipeline.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
821
packages/core/tests/unit/plugins/exclusive-hooks.test.ts
Normal file
821
packages/core/tests/unit/plugins/exclusive-hooks.test.ts
Normal file
@@ -0,0 +1,821 @@
|
||||
/**
|
||||
* Exclusive Hooks Tests
|
||||
*
|
||||
* Tests the exclusive hook system:
|
||||
* - HookPipeline: registration/tracking, selection, invokeExclusiveHook
|
||||
* - PluginManager.resolveExclusiveHooks(): single provider auto-select,
|
||||
* multi-provider no auto-select, stale selection clearing, preferred hints,
|
||||
* admin override beats preferred
|
||||
* - Lifecycle: activate → auto-select, deactivate → clears stale selection
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { extractManifest } from "../../../src/cli/commands/bundle-utils.js";
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database as DbSchema } from "../../../src/database/types.js";
|
||||
import { HookPipeline, resolveExclusiveHooks } from "../../../src/plugins/hooks.js";
|
||||
import { PluginManager } from "../../../src/plugins/manager.js";
|
||||
import { normalizeManifestHook } from "../../../src/plugins/manifest-schema.js";
|
||||
import type {
|
||||
ResolvedPlugin,
|
||||
ResolvedHook,
|
||||
PluginDefinition,
|
||||
ContentBeforeSaveHandler,
|
||||
ContentAfterSaveHandler,
|
||||
} from "../../../src/plugins/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — ResolvedPlugin (for HookPipeline tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: ["content:write", "content:read"],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: {
|
||||
pages: [],
|
||||
widgets: [],
|
||||
},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestHook<T>(
|
||||
pluginId: string,
|
||||
handler: T,
|
||||
overrides: Partial<ResolvedHook<T>> = {},
|
||||
): ResolvedHook<T> {
|
||||
return {
|
||||
pluginId,
|
||||
handler,
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "continue",
|
||||
exclusive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — PluginDefinition (for PluginManager tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestDefinition(overrides: Partial<PluginDefinition> = {}): PluginDefinition {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: ["content:write", "content:read"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HookPipeline — exclusive behaviour
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("HookPipeline — exclusive hooks", () => {
|
||||
it("tracks exclusive hook names during registration", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "email-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("email-provider", vi.fn(), {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
expect(pipeline.isExclusiveHook("content:beforeSave")).toBe(true);
|
||||
expect(pipeline.isExclusiveHook("content:afterSave")).toBe(false);
|
||||
expect(pipeline.getRegisteredExclusiveHooks()).toContain("content:beforeSave");
|
||||
});
|
||||
|
||||
it("does not track non-exclusive hooks as exclusive", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "normal-plugin",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("normal-plugin", vi.fn(), {
|
||||
exclusive: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
expect(pipeline.isExclusiveHook("content:beforeSave")).toBe(false);
|
||||
expect(pipeline.getRegisteredExclusiveHooks()).not.toContain("content:beforeSave");
|
||||
});
|
||||
|
||||
it("returns providers for an exclusive hook", () => {
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "provider-a",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "provider-b",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-b", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin1, plugin2]);
|
||||
|
||||
const providers = pipeline.getExclusiveHookProviders("content:beforeSave");
|
||||
expect(providers).toHaveLength(2);
|
||||
expect(providers.map((p) => p.pluginId)).toEqual(
|
||||
expect.arrayContaining(["provider-a", "provider-b"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("set/get/clear exclusive selection", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "email-ses",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("email-ses", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBeUndefined();
|
||||
|
||||
pipeline.setExclusiveSelection("content:beforeSave", "email-ses");
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("email-ses");
|
||||
|
||||
pipeline.clearExclusiveSelection("content:beforeSave");
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invokeExclusiveHook returns null when no selection", async () => {
|
||||
const handler = vi.fn().mockResolvedValue("result");
|
||||
const plugin = createTestPlugin({
|
||||
id: "provider-a",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-a", handler, { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
const result = await pipeline.invokeExclusiveHook("content:beforeSave", { some: "event" });
|
||||
expect(result).toBeNull();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invokeExclusiveHook dispatches only to selected provider", async () => {
|
||||
const handlerA = vi.fn().mockResolvedValue("result-a");
|
||||
const handlerB = vi.fn().mockResolvedValue("result-b");
|
||||
|
||||
const pluginA = createTestPlugin({
|
||||
id: "provider-a",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("provider-a", handlerA, { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pluginB = createTestPlugin({
|
||||
id: "provider-b",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("provider-b", handlerB, { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
// Context factory needs a db for PluginContextFactory
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([pluginA, pluginB], { db });
|
||||
|
||||
pipeline.setExclusiveSelection("content:afterSave", "provider-b");
|
||||
|
||||
const result = await pipeline.invokeExclusiveHook("content:afterSave", { some: "event" });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.pluginId).toBe("provider-b");
|
||||
expect(result!.result).toBe("result-b");
|
||||
|
||||
expect(handlerB).toHaveBeenCalledTimes(1);
|
||||
expect(handlerA).not.toHaveBeenCalled();
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("invokeExclusiveHook isolates errors — returns error result instead of throwing", async () => {
|
||||
const handler = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("provider crashed")) as unknown as ContentAfterSaveHandler;
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "broken-provider",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("broken-provider", handler, {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
pipeline.setExclusiveSelection("content:afterSave", "broken-provider");
|
||||
|
||||
// Should NOT throw — error is isolated
|
||||
const result = await pipeline.invokeExclusiveHook("content:afterSave", {});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.pluginId).toBe("broken-provider");
|
||||
expect(result!.error).toBeInstanceOf(Error);
|
||||
expect(result!.error!.message).toBe("provider crashed");
|
||||
expect(result!.result).toBeUndefined();
|
||||
expect(result!.duration).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("invokeExclusiveHook respects timeout", async () => {
|
||||
const handler = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 10_000);
|
||||
}),
|
||||
) as unknown as ContentAfterSaveHandler;
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "slow-provider",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("slow-provider", handler, {
|
||||
exclusive: true,
|
||||
timeout: 50,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
pipeline.setExclusiveSelection("content:afterSave", "slow-provider");
|
||||
|
||||
const result = await pipeline.invokeExclusiveHook("content:afterSave", {});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.error).toBeInstanceOf(Error);
|
||||
expect(result!.error!.message.toLowerCase()).toContain("timeout");
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("exclusive hooks with a selection are skipped in regular pipeline", async () => {
|
||||
const exclusiveHandler = vi.fn().mockResolvedValue(undefined);
|
||||
const normalHandler = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const exclusivePlugin = createTestPlugin({
|
||||
id: "exclusive-plugin",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("exclusive-plugin", exclusiveHandler, {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const normalPlugin = createTestPlugin({
|
||||
id: "normal-plugin",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("normal-plugin", normalHandler, {
|
||||
exclusive: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([exclusivePlugin, normalPlugin], { db });
|
||||
|
||||
// Set a selection — this means the exclusive hook should NOT run in the regular pipeline
|
||||
pipeline.setExclusiveSelection("content:afterSave", "exclusive-plugin");
|
||||
|
||||
await pipeline.runContentAfterSave({ title: "test" }, "posts", true);
|
||||
|
||||
// Normal hook should run
|
||||
expect(normalHandler).toHaveBeenCalledTimes(1);
|
||||
// Exclusive hook should NOT have run in the regular pipeline
|
||||
expect(exclusiveHandler).not.toHaveBeenCalled();
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("exclusive hooks without a selection DO run in regular pipeline", async () => {
|
||||
const exclusiveHandler = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "unselected-provider",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("unselected-provider", exclusiveHandler, {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const sqlite = new Database(":memory:");
|
||||
const db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
|
||||
// No selection set — exclusive hooks should still run in regular pipeline
|
||||
await pipeline.runContentAfterSave({ title: "test" }, "posts", true);
|
||||
|
||||
expect(exclusiveHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HookPipeline — non-exclusive provider enumeration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("HookPipeline — getHookProviders", () => {
|
||||
it("returns non-exclusive providers registered for a hook", () => {
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "middleware-a",
|
||||
capabilities: ["hooks.email-events:register"],
|
||||
hooks: {
|
||||
"email:beforeSend": createTestHook("middleware-a", vi.fn()),
|
||||
},
|
||||
});
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "middleware-b",
|
||||
capabilities: ["hooks.email-events:register"],
|
||||
hooks: {
|
||||
"email:beforeSend": createTestHook("middleware-b", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin1, plugin2]);
|
||||
|
||||
const providers = pipeline.getHookProviders("email:beforeSend");
|
||||
expect(providers.map((p) => p.pluginId)).toEqual(
|
||||
expect.arrayContaining(["middleware-a", "middleware-b"]),
|
||||
);
|
||||
expect(providers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("partitions with getExclusiveHookProviders — excludes exclusive registrations", () => {
|
||||
const exclusivePlugin = createTestPlugin({
|
||||
id: "exclusive-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("exclusive-provider", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const nonExclusivePlugin = createTestPlugin({
|
||||
id: "non-exclusive-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("non-exclusive-provider", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([exclusivePlugin, nonExclusivePlugin]);
|
||||
|
||||
expect(pipeline.getHookProviders("content:beforeSave").map((p) => p.pluginId)).toEqual([
|
||||
"non-exclusive-provider",
|
||||
]);
|
||||
expect(pipeline.getExclusiveHookProviders("content:beforeSave").map((p) => p.pluginId)).toEqual(
|
||||
["exclusive-provider"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array for an unregistered hook", () => {
|
||||
const pipeline = new HookPipeline([]);
|
||||
expect(pipeline.getHookProviders("email:beforeSend")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeManifestHook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("normalizeManifestHook", () => {
|
||||
it("converts a plain string to an object", () => {
|
||||
const result = normalizeManifestHook("content:beforeSave");
|
||||
expect(result).toEqual({ name: "content:beforeSave" });
|
||||
});
|
||||
|
||||
it("passes through an object unchanged", () => {
|
||||
const entry = { name: "content:beforeSave", exclusive: true, priority: 50 };
|
||||
const result = normalizeManifestHook(entry);
|
||||
expect(result).toEqual(entry);
|
||||
});
|
||||
|
||||
it("handles object with only name", () => {
|
||||
const result = normalizeManifestHook({ name: "media:afterUpload" });
|
||||
expect(result).toEqual({ name: "media:afterUpload" });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractManifest — exclusive hook metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("extractManifest — exclusive hooks", () => {
|
||||
it("emits plain hook names for non-exclusive hooks with default settings", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "simple-plugin",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("simple-plugin", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.hooks).toEqual(["content:beforeSave"]);
|
||||
});
|
||||
|
||||
it("emits structured entries for exclusive hooks", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "email-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("email-provider", vi.fn(), {
|
||||
exclusive: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.hooks).toEqual([{ name: "content:beforeSave", exclusive: true }]);
|
||||
});
|
||||
|
||||
it("emits structured entries for hooks with custom priority or timeout", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "custom-plugin",
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("custom-plugin", vi.fn(), {
|
||||
priority: 50,
|
||||
timeout: 10000,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.hooks).toEqual([{ name: "content:afterSave", priority: 50, timeout: 10000 }]);
|
||||
});
|
||||
|
||||
it("handles mixed exclusive and non-exclusive hooks", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "mixed-plugin",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("mixed-plugin", vi.fn(), { exclusive: true }),
|
||||
"content:afterSave": createTestHook("mixed-plugin", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const manifest = extractManifest(plugin);
|
||||
expect(manifest.hooks).toHaveLength(2);
|
||||
|
||||
// One should be structured (exclusive), one should be a plain string
|
||||
const structured = manifest.hooks.filter((h) => typeof h === "object");
|
||||
const plain = manifest.hooks.filter((h) => typeof h === "string");
|
||||
expect(structured).toHaveLength(1);
|
||||
expect(plain).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveExclusiveHooks (shared function)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveExclusiveHooks — shared function", () => {
|
||||
it("auto-selects single active provider", async () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "only-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("only-provider", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
await resolveExclusiveHooks({
|
||||
pipeline,
|
||||
isActive: () => true,
|
||||
getOption: async (key) => store.get(key) ?? null,
|
||||
setOption: async (key, value) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
deleteOption: async (key) => {
|
||||
store.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("only-provider");
|
||||
});
|
||||
|
||||
it("filters out inactive providers", async () => {
|
||||
const pluginA = createTestPlugin({
|
||||
id: "active-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("active-provider", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pluginB = createTestPlugin({
|
||||
id: "inactive-provider",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("inactive-provider", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pipeline = new HookPipeline([pluginA, pluginB]);
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
await resolveExclusiveHooks({
|
||||
pipeline,
|
||||
isActive: (id) => id === "active-provider",
|
||||
getOption: async (key) => store.get(key) ?? null,
|
||||
setOption: async (key, value) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
deleteOption: async (key) => {
|
||||
store.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
// Only active-provider is active, so it should be auto-selected
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("active-provider");
|
||||
});
|
||||
|
||||
it("clears stale selection when selected provider is inactive", async () => {
|
||||
const pluginA = createTestPlugin({
|
||||
id: "provider-a",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-a", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pluginB = createTestPlugin({
|
||||
id: "provider-b",
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("provider-b", vi.fn(), { exclusive: true }),
|
||||
},
|
||||
});
|
||||
const pipeline = new HookPipeline([pluginA, pluginB]);
|
||||
|
||||
// Simulate existing selection for provider-a which is now inactive
|
||||
const store = new Map<string, string>([
|
||||
["emdash:exclusive_hook:content:beforeSave", "provider-a"],
|
||||
]);
|
||||
|
||||
await resolveExclusiveHooks({
|
||||
pipeline,
|
||||
isActive: (id) => id === "provider-b", // provider-a is inactive
|
||||
getOption: async (key) => store.get(key) ?? null,
|
||||
setOption: async (key, value) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
deleteOption: async (key) => {
|
||||
store.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
// provider-a was stale, cleared. provider-b is the only active one → auto-selected
|
||||
expect(pipeline.getExclusiveSelection("content:beforeSave")).toBe("provider-b");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PluginManager — resolveExclusiveHooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PluginManager — resolveExclusiveHooks", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: Database.Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
sqliteDb = new Database(":memory:");
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqliteDb }),
|
||||
});
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
it("auto-selects when only one provider for an exclusive hook", async () => {
|
||||
const handler = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "email-ses",
|
||||
hooks: {
|
||||
"content:beforeSave": { handler, exclusive: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await manager.activate("email-ses");
|
||||
|
||||
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
||||
expect(selection).toBe("email-ses");
|
||||
});
|
||||
|
||||
it("keeps auto-selected provider when a second provider activates", async () => {
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
// provider-a is the only one — gets auto-selected
|
||||
await manager.activate("provider-a");
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
||||
|
||||
// provider-b activates — existing valid selection is preserved
|
||||
await manager.activate("provider-b");
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
||||
});
|
||||
|
||||
it("leaves unselected when multiple providers activate simultaneously", async () => {
|
||||
// If no one was auto-selected before the second provider, there's no
|
||||
// selection to keep. Test this by registering both before activating.
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
// Activate provider-a (auto-selects as sole provider)
|
||||
await manager.activate("provider-a");
|
||||
// Clear the auto-selection to simulate "no prior selection"
|
||||
await manager.setExclusiveHookSelection("content:beforeSave", null);
|
||||
|
||||
// Now activate provider-b — both active, no existing selection
|
||||
await manager.activate("provider-b");
|
||||
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
||||
expect(selection).toBeNull();
|
||||
});
|
||||
|
||||
it("clears stale selection when selected plugin is deactivated", async () => {
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.activate("provider-a");
|
||||
await manager.activate("provider-b");
|
||||
|
||||
// Manually set a selection
|
||||
await manager.setExclusiveHookSelection("content:beforeSave", "provider-a");
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
||||
|
||||
// Deactivate the selected plugin
|
||||
await manager.deactivate("provider-a");
|
||||
|
||||
// After deactivation, provider-b is the only one left → auto-selects
|
||||
const selection = await manager.getExclusiveHookSelection("content:beforeSave");
|
||||
expect(selection).toBe("provider-b");
|
||||
});
|
||||
|
||||
it("uses preferred hints when no selection exists", async () => {
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.activate("provider-a");
|
||||
await manager.activate("provider-b");
|
||||
|
||||
// Clear any auto-selection from the first activate
|
||||
await manager.setExclusiveHookSelection("content:beforeSave", null);
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBeNull();
|
||||
|
||||
// Resolve with preferred hint
|
||||
const hints = new Map([["provider-b", ["content:beforeSave"]]]);
|
||||
await manager.resolveExclusiveHooks(hints);
|
||||
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-b");
|
||||
});
|
||||
|
||||
it("admin override (DB selection) beats preferred hints", async () => {
|
||||
const handlerA = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
const handlerB = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler: handlerA, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-b",
|
||||
hooks: { "content:beforeSave": { handler: handlerB, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.activate("provider-a");
|
||||
await manager.activate("provider-b");
|
||||
|
||||
// Admin explicitly sets provider-a
|
||||
await manager.setExclusiveHookSelection("content:beforeSave", "provider-a");
|
||||
|
||||
// Resolve with preferred hint for provider-b — admin choice should win
|
||||
const hints = new Map([["provider-b", ["content:beforeSave"]]]);
|
||||
await manager.resolveExclusiveHooks(hints);
|
||||
|
||||
expect(await manager.getExclusiveHookSelection("content:beforeSave")).toBe("provider-a");
|
||||
});
|
||||
|
||||
it("getExclusiveHooksInfo returns complete info", async () => {
|
||||
const handler = vi.fn() as unknown as ContentBeforeSaveHandler;
|
||||
|
||||
const manager = new PluginManager({ db });
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "provider-a",
|
||||
hooks: { "content:beforeSave": { handler, exclusive: true } },
|
||||
}),
|
||||
);
|
||||
await manager.activate("provider-a");
|
||||
|
||||
const info = await manager.getExclusiveHooksInfo();
|
||||
expect(info).toHaveLength(1);
|
||||
expect(info[0]!.hookName).toBe("content:beforeSave");
|
||||
expect(info[0]!.providers).toHaveLength(1);
|
||||
expect(info[0]!.providers[0]!.pluginId).toBe("provider-a");
|
||||
expect(info[0]!.selectedPluginId).toBe("provider-a");
|
||||
});
|
||||
});
|
||||
187
packages/core/tests/unit/plugins/field-widgets.test.ts
Normal file
187
packages/core/tests/unit/plugins/field-widgets.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Tests for the field widget plugin pipeline.
|
||||
*
|
||||
* Covers:
|
||||
* - Manifest schema validation for fieldWidgets
|
||||
* - definePlugin() with fieldWidgets
|
||||
* - FieldWidgetConfig type correctness
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { pluginManifestSchema } from "../../../src/plugins/manifest-schema.js";
|
||||
|
||||
/** Minimal valid manifest */
|
||||
function makeManifest(admin: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pluginManifestSchema — fieldWidgets", () => {
|
||||
it("should accept manifest without fieldWidgets", () => {
|
||||
const result = pluginManifestSchema.safeParse(makeManifest());
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept manifest with empty fieldWidgets array", () => {
|
||||
const result = pluginManifestSchema.safeParse(makeManifest({ fieldWidgets: [] }));
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept a valid field widget definition", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "picker",
|
||||
label: "Color Picker",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept multiple field widget definitions", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "picker",
|
||||
label: "Color Picker",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
{
|
||||
name: "pricing",
|
||||
label: "Pricing Editor",
|
||||
fieldTypes: ["json"],
|
||||
elements: [{ type: "toggle", action_id: "enabled", label: "Enable" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept field widget with Block Kit elements", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "pricing",
|
||||
label: "Pricing",
|
||||
fieldTypes: ["json"],
|
||||
elements: [
|
||||
{ type: "toggle", action_id: "enabled", label: "Enable" },
|
||||
{ type: "text_input", action_id: "price", label: "Price" },
|
||||
{
|
||||
type: "select",
|
||||
action_id: "mode",
|
||||
label: "Mode",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept field widget with multiple field types", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "hex",
|
||||
label: "Hex Input",
|
||||
fieldTypes: ["string", "json"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject field widget with empty name", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "",
|
||||
label: "Test",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject field widget with empty label", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "test",
|
||||
label: "",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject field widget without name", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
label: "Test",
|
||||
fieldTypes: ["string"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject field widget without fieldTypes", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "test",
|
||||
label: "Test",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept field widget with empty fieldTypes array", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
fieldWidgets: [
|
||||
{
|
||||
name: "test",
|
||||
label: "Test",
|
||||
fieldTypes: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
719
packages/core/tests/unit/plugins/hooks.test.ts
Normal file
719
packages/core/tests/unit/plugins/hooks.test.ts
Normal file
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* HookPipeline Tests
|
||||
*
|
||||
* Tests the v2 hook pipeline for:
|
||||
* - Hook registration and sorting
|
||||
* - Hook execution with timeout
|
||||
* - Content hooks (beforeSave, afterSave, beforeDelete, afterDelete)
|
||||
* - Lifecycle hooks (install, activate, deactivate, uninstall)
|
||||
* - Error handling and error policies
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import { HookPipeline, createHookPipeline } from "../../../src/plugins/hooks.js";
|
||||
import type { ResolvedPlugin, ResolvedHook } 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: [],
|
||||
},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resolved hook with defaults
|
||||
*/
|
||||
function createTestHook<T>(
|
||||
pluginId: string,
|
||||
handler: T,
|
||||
overrides: Partial<ResolvedHook<T>> = {},
|
||||
): ResolvedHook<T> {
|
||||
return {
|
||||
pluginId,
|
||||
handler,
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "continue",
|
||||
exclusive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HookPipeline", () => {
|
||||
describe("construction and registration", () => {
|
||||
it("creates empty pipeline with no plugins", () => {
|
||||
const pipeline = new HookPipeline([]);
|
||||
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(false);
|
||||
expect(pipeline.getHookCount("content:beforeSave")).toBe(0);
|
||||
});
|
||||
|
||||
it("registers hooks from plugins", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["content:write", "content:read"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("test", vi.fn()),
|
||||
"content:afterSave": createTestHook("test", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:afterSave")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:beforeDelete")).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks registered hook names", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["content:write", "media:read"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("test", vi.fn()),
|
||||
"media:afterUpload": createTestHook("test", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
const registered = pipeline.getRegisteredHooks();
|
||||
|
||||
expect(registered).toContain("content:beforeSave");
|
||||
expect(registered).toContain("media:afterUpload");
|
||||
expect(registered).not.toContain("content:afterSave");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook sorting", () => {
|
||||
it("sorts hooks by priority (lower first)", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
const handler3 = vi.fn();
|
||||
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "plugin-1",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-1", handler1, {
|
||||
priority: 200,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "plugin-2",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-2", handler2, {
|
||||
priority: 50,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin3 = createTestPlugin({
|
||||
id: "plugin-3",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-3", handler3, {
|
||||
priority: 100,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Create pipeline and manually verify order through execution
|
||||
const pipeline = new HookPipeline([plugin1, plugin2, plugin3]);
|
||||
|
||||
expect(pipeline.getHookCount("content:beforeSave")).toBe(3);
|
||||
});
|
||||
|
||||
it("respects dependencies when sorting", () => {
|
||||
const handler1 = vi.fn();
|
||||
const handler2 = vi.fn();
|
||||
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "plugin-1",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-1", handler1, {
|
||||
priority: 50, // Lower priority but...
|
||||
dependencies: ["plugin-2"], // depends on plugin-2
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "plugin-2",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-2", handler2, {
|
||||
priority: 100, // Higher priority
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin1, plugin2]);
|
||||
|
||||
// plugin-2 should run before plugin-1 despite priority
|
||||
// because plugin-1 depends on plugin-2
|
||||
expect(pipeline.getHookCount("content:beforeSave")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("content:beforeSave", () => {
|
||||
it("runs hooks and returns modified content", async () => {
|
||||
const handler = vi.fn(async (event) => ({
|
||||
...event.content,
|
||||
modified: true,
|
||||
}));
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
// Need context factory for actual execution
|
||||
// Without it, getContext will throw
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
|
||||
// For unit test without DB, we can verify the hook count
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(true);
|
||||
});
|
||||
|
||||
it("chains content through multiple hooks", async () => {
|
||||
const handler1 = vi.fn(async (event) => ({
|
||||
...event.content,
|
||||
step1: true,
|
||||
}));
|
||||
|
||||
const handler2 = vi.fn(async (event) => ({
|
||||
...event.content,
|
||||
step2: true,
|
||||
}));
|
||||
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "plugin-1",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-1", handler1, {
|
||||
priority: 1,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "plugin-2",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-2", handler2, {
|
||||
priority: 2,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin1, plugin2]);
|
||||
expect(pipeline.getHookCount("content:beforeSave")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("content:beforeDelete", () => {
|
||||
it("registers beforeDelete hooks", () => {
|
||||
const handler = vi.fn(async () => true);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["content:read"],
|
||||
hooks: {
|
||||
"content:beforeDelete": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeDelete")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lifecycle hooks", () => {
|
||||
it("registers plugin:install hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
hooks: {
|
||||
"plugin:install": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:install")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers plugin:activate hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
hooks: {
|
||||
"plugin:activate": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:activate")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers plugin:deactivate hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
hooks: {
|
||||
"plugin:deactivate": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:deactivate")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers plugin:uninstall hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
hooks: {
|
||||
"plugin:uninstall": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:uninstall")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("media hooks", () => {
|
||||
it("registers media:beforeUpload hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["media:write"],
|
||||
hooks: {
|
||||
"media:beforeUpload": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:beforeUpload")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers media:afterUpload hook", () => {
|
||||
const handler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test",
|
||||
capabilities: ["media:read"],
|
||||
hooks: {
|
||||
"media:afterUpload": createTestHook("test", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:afterUpload")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHookPipeline helper", () => {
|
||||
it("creates a HookPipeline instance", () => {
|
||||
const plugins = [createTestPlugin({ id: "test" })];
|
||||
const pipeline = createHookPipeline(plugins);
|
||||
|
||||
expect(pipeline).toBeInstanceOf(HookPipeline);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Capability enforcement for non-email hooks
|
||||
// =========================================================================
|
||||
|
||||
describe("capability enforcement — content hooks", () => {
|
||||
it("skips content:beforeSave without content:write capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips content:beforeSave with only content:read (requires content:write)", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "read-only",
|
||||
capabilities: ["content:read"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("read-only", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers content:beforeSave with content:write capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips content:afterSave without content:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterSave")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers content:afterSave with content:read capability (read-only notification)", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["content:read"],
|
||||
hooks: {
|
||||
"content:afterSave": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterSave")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips content:beforeDelete without content:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:beforeDelete": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeDelete")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips content:afterDelete without content:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:afterDelete": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterDelete")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers all content hooks with content:write + content:read", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "writer",
|
||||
capabilities: ["content:write", "content:read"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("writer", vi.fn()),
|
||||
"content:afterSave": createTestHook("writer", vi.fn()),
|
||||
"content:beforeDelete": createTestHook("writer", vi.fn()),
|
||||
"content:afterDelete": createTestHook("writer", vi.fn()),
|
||||
"content:afterPublish": createTestHook("writer", vi.fn()),
|
||||
"content:afterUnpublish": createTestHook("writer", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:beforeSave")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:afterSave")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:beforeDelete")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:afterDelete")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:afterPublish")).toBe(true);
|
||||
expect(pipeline.hasHooks("content:afterUnpublish")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips content:afterPublish without content:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:afterPublish": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterPublish")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers content:afterPublish with content:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["content:read"],
|
||||
hooks: {
|
||||
"content:afterPublish": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterPublish")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips content:afterUnpublish without content:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"content:afterUnpublish": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterUnpublish")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers content:afterUnpublish with content:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["content:read"],
|
||||
hooks: {
|
||||
"content:afterUnpublish": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("content:afterUnpublish")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability enforcement — media hooks", () => {
|
||||
it("skips media:beforeUpload without media:write capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"media:beforeUpload": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:beforeUpload")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers media:beforeUpload with media:write capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["media:write"],
|
||||
hooks: {
|
||||
"media:beforeUpload": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:beforeUpload")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips media:afterUpload without media:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"media:afterUpload": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:afterUpload")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers media:afterUpload with media:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["media:read"],
|
||||
hooks: {
|
||||
"media:afterUpload": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("media:afterUpload")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability enforcement — comment hooks", () => {
|
||||
it("skips comment:beforeCreate without users:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"comment:beforeCreate": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:beforeCreate")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers comment:beforeCreate with users:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["users:read"],
|
||||
hooks: {
|
||||
"comment:beforeCreate": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:beforeCreate")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips comment:moderate without users:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"comment:moderate": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:moderate")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips comment:afterCreate without users:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"comment:afterCreate": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:afterCreate")).toBe(false);
|
||||
});
|
||||
|
||||
it("skips comment:afterModerate without users:read capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"comment:afterModerate": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("comment:afterModerate")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability enforcement — page:fragments", () => {
|
||||
it("skips page:fragments without hooks.page-fragments:register capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"page:fragments": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("page:fragments")).toBe(false);
|
||||
});
|
||||
|
||||
it("registers page:fragments with hooks.page-fragments:register capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "has-cap",
|
||||
capabilities: ["hooks.page-fragments:register"],
|
||||
hooks: {
|
||||
"page:fragments": createTestHook("has-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("page:fragments")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capability enforcement — hooks without requirements", () => {
|
||||
it("registers lifecycle hooks without any capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"plugin:install": createTestHook("no-cap", vi.fn()),
|
||||
"plugin:activate": createTestHook("no-cap", vi.fn()),
|
||||
"plugin:deactivate": createTestHook("no-cap", vi.fn()),
|
||||
"plugin:uninstall": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("plugin:install")).toBe(true);
|
||||
expect(pipeline.hasHooks("plugin:activate")).toBe(true);
|
||||
expect(pipeline.hasHooks("plugin:deactivate")).toBe(true);
|
||||
expect(pipeline.hasHooks("plugin:uninstall")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers cron hook without any capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
cron: createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("cron")).toBe(true);
|
||||
});
|
||||
|
||||
it("registers page:metadata without any capability", () => {
|
||||
const plugin = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("no-cap", vi.fn()),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin]);
|
||||
expect(pipeline.hasHooks("page:metadata")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Tests that plugin HTTP functions strip credential headers on cross-origin redirects.
|
||||
*
|
||||
* Both createHttpAccess and createUnrestrictedHttpAccess manually follow redirects.
|
||||
* When a redirect crosses origins, Authorization/Cookie/Proxy-Authorization headers
|
||||
* must be stripped to prevent credential leakage to untrusted hosts.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { setDefaultDnsResolver } from "../../../src/import/ssrf.js";
|
||||
import { createHttpAccess, createUnrestrictedHttpAccess } from "../../../src/plugins/context.js";
|
||||
|
||||
// Intercept globalThis.fetch so we can simulate redirect chains
|
||||
const mockFetch = vi.fn<typeof globalThis.fetch>();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
// Bypass DoH so the fetch mock only sees the calls these tests model.
|
||||
// Returns a fixed public IP so resolveAndValidateExternalUrl passes.
|
||||
const STUB_RESOLVER = async () => ["93.184.216.34"];
|
||||
let previousResolver: ReturnType<typeof setDefaultDnsResolver> | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
previousResolver = setDefaultDnsResolver(STUB_RESOLVER);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
setDefaultDnsResolver(previousResolver ?? null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
/** Build a minimal redirect response */
|
||||
function redirectResponse(location: string, status = 302): Response {
|
||||
return new Response(null, {
|
||||
status,
|
||||
headers: { Location: location },
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a 200 response */
|
||||
function okResponse(body = "ok"): Response {
|
||||
return new Response(body, { status: 200 });
|
||||
}
|
||||
|
||||
/** Extract the headers passed to the Nth fetch call */
|
||||
function headersOfCall(callIndex: number): Headers {
|
||||
const init = mockFetch.mock.calls[callIndex]?.[1] as RequestInit | undefined;
|
||||
return new Headers(init?.headers);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// createHttpAccess – host-restricted
|
||||
// =============================================================================
|
||||
|
||||
describe("createHttpAccess host allowlist matching", () => {
|
||||
const pluginId = "test-plugin";
|
||||
|
||||
it('allows any hostname when allowedHosts contains standalone "*"', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, ["*"]);
|
||||
await expect(http.fetch("https://api.example.com/v1")).resolves.toBeInstanceOf(Response);
|
||||
await expect(http.fetch("https://random.host.io/path")).resolves.toBeInstanceOf(Response);
|
||||
});
|
||||
|
||||
it('allows requests when "*" is mixed with explicit hosts', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, ["*", "api.example.com"]);
|
||||
await expect(http.fetch("https://another.example.net/ok")).resolves.toBeInstanceOf(Response);
|
||||
});
|
||||
|
||||
it('still supports "*.domain" wildcard matching', async () => {
|
||||
mockFetch.mockResolvedValue(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, ["*.example.com"]);
|
||||
await expect(http.fetch("https://api.example.com/v1")).resolves.toBeInstanceOf(Response);
|
||||
await expect(http.fetch("https://evil.com")).rejects.toThrow(
|
||||
'is not allowed to fetch from host "evil.com"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHttpAccess credential stripping", () => {
|
||||
const pluginId = "test-plugin";
|
||||
const allowedHosts = ["a.example.com", "b.example.com"];
|
||||
|
||||
it("preserves credentials on same-origin redirect", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://a.example.com/page2"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, allowedHosts);
|
||||
await http.fetch("https://a.example.com/page1", {
|
||||
headers: { Authorization: "Bearer secret", Cookie: "session=abc" },
|
||||
});
|
||||
|
||||
// Second call should still have credentials (same origin)
|
||||
const h = headersOfCall(1);
|
||||
expect(h.get("authorization")).toBe("Bearer secret");
|
||||
expect(h.get("cookie")).toBe("session=abc");
|
||||
});
|
||||
|
||||
it("strips credentials on cross-origin redirect", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://b.example.com/landing"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, allowedHosts);
|
||||
await http.fetch("https://a.example.com/start", {
|
||||
headers: {
|
||||
Authorization: "Bearer secret",
|
||||
Cookie: "session=abc",
|
||||
"Proxy-Authorization": "Basic creds",
|
||||
"X-Custom": "keep-me",
|
||||
},
|
||||
});
|
||||
|
||||
const h = headersOfCall(1);
|
||||
expect(h.get("authorization")).toBeNull();
|
||||
expect(h.get("cookie")).toBeNull();
|
||||
expect(h.get("proxy-authorization")).toBeNull();
|
||||
// Non-credential headers survive
|
||||
expect(h.get("x-custom")).toBe("keep-me");
|
||||
});
|
||||
|
||||
it("strips credentials only once even with multiple same-origin hops after cross-origin", async () => {
|
||||
// a.example.com -> b.example.com -> b.example.com/final
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://b.example.com/step1"))
|
||||
.mockResolvedValueOnce(redirectResponse("https://b.example.com/step2"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createHttpAccess(pluginId, allowedHosts);
|
||||
await http.fetch("https://a.example.com/start", {
|
||||
headers: { Authorization: "Bearer secret" },
|
||||
});
|
||||
|
||||
// Call 0: original (has auth)
|
||||
expect(headersOfCall(0).get("authorization")).toBe("Bearer secret");
|
||||
// Call 1: after cross-origin hop (stripped)
|
||||
expect(headersOfCall(1).get("authorization")).toBeNull();
|
||||
// Call 2: same-origin hop on b (still stripped -- not re-added)
|
||||
expect(headersOfCall(2).get("authorization")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// createUnrestrictedHttpAccess – SSRF-protected but no host list
|
||||
// =============================================================================
|
||||
|
||||
describe("createUnrestrictedHttpAccess credential stripping", () => {
|
||||
const pluginId = "unrestricted-plugin";
|
||||
|
||||
it("preserves credentials on same-origin redirect", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://api.example.com/v2"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createUnrestrictedHttpAccess(pluginId);
|
||||
await http.fetch("https://api.example.com/v1", {
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
expect(headersOfCall(1).get("authorization")).toBe("Bearer token");
|
||||
});
|
||||
|
||||
it("strips credentials on cross-origin redirect", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://evil.example.com/steal"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createUnrestrictedHttpAccess(pluginId);
|
||||
await http.fetch("https://api.example.com/start", {
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
Cookie: "session=xyz",
|
||||
"Proxy-Authorization": "Basic pw",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const h = headersOfCall(1);
|
||||
expect(h.get("authorization")).toBeNull();
|
||||
expect(h.get("cookie")).toBeNull();
|
||||
expect(h.get("proxy-authorization")).toBeNull();
|
||||
expect(h.get("accept")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("handles redirect with no init gracefully", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(redirectResponse("https://other.example.com/"))
|
||||
.mockResolvedValueOnce(okResponse());
|
||||
|
||||
const http = createUnrestrictedHttpAccess(pluginId);
|
||||
// No init at all -- should not throw
|
||||
await http.fetch("https://api.example.com/bare");
|
||||
|
||||
expect(headersOfCall(1).get("authorization")).toBeNull();
|
||||
});
|
||||
});
|
||||
426
packages/core/tests/unit/plugins/manager.test.ts
Normal file
426
packages/core/tests/unit/plugins/manager.test.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* PluginManager Tests
|
||||
*
|
||||
* Tests the central plugin orchestrator for:
|
||||
* - Plugin registration
|
||||
* - Lifecycle management (install, activate, deactivate, uninstall)
|
||||
* - Query methods
|
||||
* - Hook and route delegation
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database as DbSchema } from "../../../src/database/types.js";
|
||||
import { PluginManager, createPluginManager } from "../../../src/plugins/manager.js";
|
||||
import type { PluginDefinition } from "../../../src/plugins/types.js";
|
||||
|
||||
// Test error message regex patterns
|
||||
const ALREADY_REGISTERED_REGEX = /already registered/;
|
||||
const DEACTIVATE_FIRST_REGEX = /Deactivate it first/;
|
||||
const NOT_FOUND_REGEX = /not found/;
|
||||
const ALREADY_INSTALLED_REGEX = /already installed/;
|
||||
|
||||
/**
|
||||
* Create a minimal plugin definition for testing
|
||||
*/
|
||||
function createTestDefinition(overrides: Partial<PluginDefinition> = {}): PluginDefinition {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PluginManager", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: Database.Database;
|
||||
let manager: PluginManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create in-memory SQLite database
|
||||
sqliteDb = new Database(":memory:");
|
||||
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({
|
||||
database: sqliteDb,
|
||||
}),
|
||||
});
|
||||
|
||||
// Run migrations
|
||||
await runMigrations(db);
|
||||
|
||||
manager = new PluginManager({ db });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
describe("register", () => {
|
||||
it("registers a plugin definition", () => {
|
||||
const resolved = manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
expect(resolved.id).toBe("my-plugin");
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns the resolved plugin", () => {
|
||||
const resolved = manager.register(
|
||||
createTestDefinition({
|
||||
id: "test",
|
||||
capabilities: ["content:write"],
|
||||
}),
|
||||
);
|
||||
|
||||
// content:write should add content:read
|
||||
expect(resolved.capabilities).toContain("content:write");
|
||||
expect(resolved.capabilities).toContain("content:read");
|
||||
});
|
||||
|
||||
it("throws on duplicate registration", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
expect(() => manager.register(createTestDefinition({ id: "my-plugin" }))).toThrow(
|
||||
ALREADY_REGISTERED_REGEX,
|
||||
);
|
||||
});
|
||||
|
||||
it("sets initial state to registered", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("registered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerAll", () => {
|
||||
it("registers multiple plugins", () => {
|
||||
manager.registerAll([
|
||||
createTestDefinition({ id: "plugin-a" }),
|
||||
createTestDefinition({ id: "plugin-b" }),
|
||||
createTestDefinition({ id: "plugin-c" }),
|
||||
]);
|
||||
|
||||
expect(manager.hasPlugin("plugin-a")).toBe(true);
|
||||
expect(manager.hasPlugin("plugin-b")).toBe(true);
|
||||
expect(manager.hasPlugin("plugin-c")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregister", () => {
|
||||
it("returns false for non-existent plugin", () => {
|
||||
const result = manager.unregister("non-existent");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("unregisters a registered plugin", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
const result = manager.unregister("my-plugin");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when trying to unregister active plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
expect(() => manager.unregister("my-plugin")).toThrow(DEACTIVATE_FIRST_REGEX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("install", () => {
|
||||
it("throws for non-existent plugin", async () => {
|
||||
await expect(manager.install("non-existent")).rejects.toThrow(NOT_FOUND_REGEX);
|
||||
});
|
||||
|
||||
it("installs a registered plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
await manager.install("my-plugin");
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("installed");
|
||||
});
|
||||
|
||||
it("throws if plugin is already installed", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.install("my-plugin");
|
||||
|
||||
await expect(manager.install("my-plugin")).rejects.toThrow(ALREADY_INSTALLED_REGEX);
|
||||
});
|
||||
|
||||
it("runs plugin:install hook", async () => {
|
||||
const installHandler = vi.fn();
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "my-plugin",
|
||||
hooks: {
|
||||
"plugin:install": installHandler,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await manager.install("my-plugin");
|
||||
|
||||
// Hook should be registered but not called without context factory
|
||||
// In real usage, the hook would be called
|
||||
expect(manager.getPluginState("my-plugin")).toBe("installed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activate", () => {
|
||||
it("throws for non-existent plugin", async () => {
|
||||
await expect(manager.activate("non-existent")).rejects.toThrow(NOT_FOUND_REGEX);
|
||||
});
|
||||
|
||||
it("auto-installs if not installed", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("active");
|
||||
});
|
||||
|
||||
it("activates an installed plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.install("my-plugin");
|
||||
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("active");
|
||||
});
|
||||
|
||||
it("returns empty array if already active", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
const results = await manager.activate("my-plugin");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivate", () => {
|
||||
it("throws for non-existent plugin", async () => {
|
||||
await expect(manager.deactivate("non-existent")).rejects.toThrow(NOT_FOUND_REGEX);
|
||||
});
|
||||
|
||||
it("returns empty array if not active", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
|
||||
const results = await manager.deactivate("my-plugin");
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it("deactivates an active plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
await manager.deactivate("my-plugin");
|
||||
|
||||
expect(manager.getPluginState("my-plugin")).toBe("inactive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("uninstall", () => {
|
||||
it("throws for non-existent plugin", async () => {
|
||||
await expect(manager.uninstall("non-existent")).rejects.toThrow(NOT_FOUND_REGEX);
|
||||
});
|
||||
|
||||
it("deactivates before uninstalling if active", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
await manager.uninstall("my-plugin");
|
||||
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("removes plugin from manager", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.install("my-plugin");
|
||||
|
||||
await manager.uninstall("my-plugin");
|
||||
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlugin", () => {
|
||||
it("returns undefined for non-existent plugin", () => {
|
||||
expect(manager.getPlugin("non-existent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the resolved plugin", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin", version: "2.0.0" }));
|
||||
|
||||
const plugin = manager.getPlugin("my-plugin");
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin!.id).toBe("my-plugin");
|
||||
expect(plugin!.version).toBe("2.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginState", () => {
|
||||
it("returns undefined for non-existent plugin", () => {
|
||||
expect(manager.getPluginState("non-existent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns current state", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
expect(manager.getPluginState("my-plugin")).toBe("registered");
|
||||
|
||||
await manager.install("my-plugin");
|
||||
expect(manager.getPluginState("my-plugin")).toBe("installed");
|
||||
|
||||
await manager.activate("my-plugin");
|
||||
expect(manager.getPluginState("my-plugin")).toBe("active");
|
||||
|
||||
await manager.deactivate("my-plugin");
|
||||
expect(manager.getPluginState("my-plugin")).toBe("inactive");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllPlugins", () => {
|
||||
it("returns empty array initially", () => {
|
||||
expect(manager.getAllPlugins()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all plugins with state", async () => {
|
||||
manager.register(createTestDefinition({ id: "plugin-a" }));
|
||||
manager.register(createTestDefinition({ id: "plugin-b" }));
|
||||
await manager.activate("plugin-b");
|
||||
|
||||
const all = manager.getAllPlugins();
|
||||
|
||||
expect(all).toHaveLength(2);
|
||||
|
||||
const pluginA = all.find((p) => p.plugin.id === "plugin-a");
|
||||
const pluginB = all.find((p) => p.plugin.id === "plugin-b");
|
||||
|
||||
expect(pluginA!.state).toBe("registered");
|
||||
expect(pluginB!.state).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActivePlugins", () => {
|
||||
it("returns empty array when no active plugins", () => {
|
||||
manager.register(createTestDefinition({ id: "plugin-a" }));
|
||||
|
||||
expect(manager.getActivePlugins()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only active plugins", async () => {
|
||||
manager.register(createTestDefinition({ id: "plugin-a" }));
|
||||
manager.register(createTestDefinition({ id: "plugin-b" }));
|
||||
manager.register(createTestDefinition({ id: "plugin-c" }));
|
||||
|
||||
await manager.activate("plugin-a");
|
||||
await manager.activate("plugin-c");
|
||||
|
||||
const active = manager.getActivePlugins();
|
||||
|
||||
expect(active).toHaveLength(2);
|
||||
expect(active.map((p) => p.id).toSorted()).toEqual(["plugin-a", "plugin-c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPlugin", () => {
|
||||
it("returns false for non-existent plugin", () => {
|
||||
expect(manager.hasPlugin("non-existent")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for registered plugin", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
expect(manager.hasPlugin("my-plugin")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isActive", () => {
|
||||
it("returns false for non-existent plugin", () => {
|
||||
expect(manager.isActive("non-existent")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for registered but not active plugin", () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
expect(manager.isActive("my-plugin")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for active plugin", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
expect(manager.isActive("my-plugin")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false after deactivation", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
await manager.deactivate("my-plugin");
|
||||
|
||||
expect(manager.isActive("my-plugin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginRoutes", () => {
|
||||
it("returns routes for active plugin", async () => {
|
||||
manager.register(
|
||||
createTestDefinition({
|
||||
id: "my-plugin",
|
||||
routes: {
|
||||
sync: { handler: vi.fn() },
|
||||
import: { handler: vi.fn() },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
const routes = manager.getPluginRoutes("my-plugin");
|
||||
|
||||
expect(routes).toContain("sync");
|
||||
expect(routes).toContain("import");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reinitialize", () => {
|
||||
it("can be called to force reinitialization", async () => {
|
||||
manager.register(createTestDefinition({ id: "my-plugin" }));
|
||||
await manager.activate("my-plugin");
|
||||
|
||||
// Should not throw
|
||||
manager.reinitialize();
|
||||
|
||||
expect(manager.isActive("my-plugin")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPluginManager helper", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: Database.Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
sqliteDb = new Database(":memory:");
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqliteDb }),
|
||||
});
|
||||
await runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
it("creates a PluginManager instance", () => {
|
||||
const manager = createPluginManager({ db });
|
||||
expect(manager).toBeInstanceOf(PluginManager);
|
||||
});
|
||||
});
|
||||
361
packages/core/tests/unit/plugins/manifest-schema.test.ts
Normal file
361
packages/core/tests/unit/plugins/manifest-schema.test.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
pluginManifestSchema,
|
||||
normalizeManifestRoute,
|
||||
} from "../../../src/plugins/manifest-schema.js";
|
||||
|
||||
/** Minimal valid manifest for testing — only storage fields vary */
|
||||
function makeManifest(storage: Record<string, { indexes: Array<string | string[]> }>) {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage,
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("pluginManifestSchema — route entries", () => {
|
||||
it("should accept plain string routes", () => {
|
||||
const result = pluginManifestSchema.safeParse(makeManifest({}));
|
||||
// Baseline with empty routes is valid
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const withRoutes = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["webhook", "callback"],
|
||||
});
|
||||
expect(withRoutes.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept structured route objects", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ name: "webhook", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept a mix of strings and objects", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["callback", { name: "webhook", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject route objects with empty name", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ name: "", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject route objects with missing name", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ public: true }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should accept route objects without public (defaults to private)", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ name: "internal" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept route names with slashes and hyphens", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["auth/callback", "web-hook", { name: "api/v2/data", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject route names with path traversal", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["../../admin/settings"],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject route names starting with special characters", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: ["/leading-slash"],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject route object names with path traversal", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
routes: [{ name: "../escape", public: true }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeManifestRoute", () => {
|
||||
it("should convert a plain string to { name } object", () => {
|
||||
expect(normalizeManifestRoute("webhook")).toEqual({ name: "webhook" });
|
||||
});
|
||||
|
||||
it("should pass through a structured object unchanged", () => {
|
||||
expect(normalizeManifestRoute({ name: "webhook", public: true })).toEqual({
|
||||
name: "webhook",
|
||||
public: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass through an object without public", () => {
|
||||
expect(normalizeManifestRoute({ name: "internal" })).toEqual({ name: "internal" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("pluginManifestSchema — storage index field names", () => {
|
||||
it("should accept valid simple index field names", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["status", "createdAt", "count"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept valid composite index field names", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: [["status", "createdAt"]] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject index field names containing SQL injection payloads", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["'); DROP TABLE users--"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject index field names with dots (JSON path traversal)", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["nested.field"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject index field names with hyphens", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["my-field"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject index field names starting with a number", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: ["1field"] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject empty index field names", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: [""] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject malicious field names in composite indexes", () => {
|
||||
const result = pluginManifestSchema.safeParse(
|
||||
makeManifest({
|
||||
items: { indexes: [["status", "'); DROP TABLE--"]] },
|
||||
}),
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
describe("pluginManifestSchema - admin.settingsSchema url/email field types", () => {
|
||||
it("should accept url setting field with label and description", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
website: {
|
||||
type: "url",
|
||||
label: "Website URL",
|
||||
description: "The plugin website",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept url setting field with default and placeholder", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
website: {
|
||||
type: "url",
|
||||
label: "Website URL",
|
||||
description: "The plugin website",
|
||||
default: "https://example.com",
|
||||
placeholder: "https://your-site.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const parsed = result.data;
|
||||
expect(parsed.admin?.settingsSchema?.website).toEqual({
|
||||
type: "url",
|
||||
label: "Website URL",
|
||||
description: "The plugin website",
|
||||
default: "https://example.com",
|
||||
placeholder: "https://your-site.com",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept email setting field with label and description", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
supportEmail: {
|
||||
type: "email",
|
||||
label: "Support Email",
|
||||
description: "Email for support",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept email setting field with default and placeholder", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
supportEmail: {
|
||||
type: "email",
|
||||
label: "Support Email",
|
||||
description: "Email for support",
|
||||
default: "support@example.com",
|
||||
placeholder: "your@email.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const parsed = result.data;
|
||||
expect(parsed.admin?.settingsSchema?.supportEmail).toEqual({
|
||||
type: "email",
|
||||
label: "Support Email",
|
||||
description: "Email for support",
|
||||
default: "support@example.com",
|
||||
placeholder: "your@email.com",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept both url and email in the same settingsSchema", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
website: {
|
||||
type: "url",
|
||||
label: "Website",
|
||||
default: "https://example.com",
|
||||
placeholder: "https://",
|
||||
},
|
||||
contactEmail: {
|
||||
type: "email",
|
||||
label: "Contact Email",
|
||||
default: "contact@example.com",
|
||||
placeholder: "email@domain.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
const parsed = result.data;
|
||||
expect(parsed.admin?.settingsSchema?.website.type).toBe("url");
|
||||
expect(parsed.admin?.settingsSchema?.contactEmail.type).toBe("email");
|
||||
expect(parsed.admin?.settingsSchema?.website.default).toBe("https://example.com");
|
||||
expect(parsed.admin?.settingsSchema?.contactEmail.default).toBe("contact@example.com");
|
||||
}
|
||||
});
|
||||
|
||||
it("should accept url field without optional fields", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
docs: {
|
||||
type: "url",
|
||||
label: "Documentation",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept email field without optional fields", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
notifications: {
|
||||
type: "email",
|
||||
label: "Notification Email",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept number field without optional fields", () => {
|
||||
const result = pluginManifestSchema.safeParse({
|
||||
...makeManifest({}),
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
port: {
|
||||
type: "number",
|
||||
label: "Server Port",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
528
packages/core/tests/unit/plugins/marketplace-client.test.ts
Normal file
528
packages/core/tests/unit/plugins/marketplace-client.test.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* MarketplaceClient + tar parser tests
|
||||
*
|
||||
* Tests:
|
||||
* - createMarketplaceClient factory
|
||||
* - MarketplaceClient.search/getPlugin/getVersions
|
||||
* - Bundle download and extraction (tar + gzip)
|
||||
* - Error handling (unavailable, HTTP errors)
|
||||
* - reportInstall (fire-and-forget)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
createMarketplaceClient,
|
||||
MarketplaceError,
|
||||
MarketplaceUnavailableError,
|
||||
type MarketplaceClient,
|
||||
type MarketplacePluginDetail,
|
||||
type MarketplaceSearchResult,
|
||||
} from "../../../src/plugins/marketplace.js";
|
||||
|
||||
const HEX_64_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const HEX_16_PATTERN = /^[a-f0-9]{16}$/;
|
||||
|
||||
// ── Helpers ───────────<E29480><E29480><EFBFBD>────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a minimal tar archive from a map of filename → content.
|
||||
* Returns an uncompressed tar buffer.
|
||||
*/
|
||||
function createTar(files: Record<string, string>): Uint8Array {
|
||||
const blocks: Uint8Array[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const contentBytes = encoder.encode(content);
|
||||
const size = contentBytes.length;
|
||||
|
||||
// Create 512-byte header
|
||||
const header = new Uint8Array(512);
|
||||
// Name (bytes 0-99)
|
||||
const nameBytes = encoder.encode(name);
|
||||
header.set(nameBytes.subarray(0, 100), 0);
|
||||
|
||||
// File mode (bytes 100-107): "0000644\0"
|
||||
header.set(encoder.encode("0000644\0"), 100);
|
||||
|
||||
// UID (bytes 108-115): "0000000\0"
|
||||
header.set(encoder.encode("0000000\0"), 108);
|
||||
|
||||
// GID (bytes 116-123): "0000000\0"
|
||||
header.set(encoder.encode("0000000\0"), 116);
|
||||
|
||||
// Size in octal (bytes 124-135)
|
||||
const sizeOctal = size.toString(8).padStart(11, "0") + "\0";
|
||||
header.set(encoder.encode(sizeOctal), 124);
|
||||
|
||||
// Mtime (bytes 136-147): "00000000000\0"
|
||||
header.set(encoder.encode("00000000000\0"), 136);
|
||||
|
||||
// Type flag (byte 156): '0' for regular file
|
||||
header[156] = 0x30;
|
||||
|
||||
// Checksum (bytes 148-155): compute after setting spaces
|
||||
// Initially fill with spaces
|
||||
header.set(encoder.encode(" "), 148);
|
||||
|
||||
// Compute checksum (sum of all unsigned bytes in header)
|
||||
let checksum = 0;
|
||||
for (let i = 0; i < 512; i++) {
|
||||
checksum += header[i]!;
|
||||
}
|
||||
const checksumOctal = checksum.toString(8).padStart(6, "0") + "\0 ";
|
||||
header.set(encoder.encode(checksumOctal), 148);
|
||||
|
||||
blocks.push(header);
|
||||
|
||||
// File data (padded to 512-byte boundary)
|
||||
const paddedSize = Math.ceil(size / 512) * 512;
|
||||
const dataBlock = new Uint8Array(paddedSize);
|
||||
dataBlock.set(contentBytes, 0);
|
||||
blocks.push(dataBlock);
|
||||
}
|
||||
|
||||
// Two 512-byte zero blocks = end of archive
|
||||
blocks.push(new Uint8Array(1024));
|
||||
|
||||
// Concatenate all blocks
|
||||
const totalSize = blocks.reduce((sum, b) => sum + b.length, 0);
|
||||
const tar = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const block of blocks) {
|
||||
tar.set(block, offset);
|
||||
offset += block.length;
|
||||
}
|
||||
|
||||
return tar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gzip compress data using CompressionStream
|
||||
*/
|
||||
async function gzip(data: Uint8Array): Promise<Uint8Array> {
|
||||
const cs = new CompressionStream("gzip");
|
||||
const writer = cs.writable.getWriter();
|
||||
const reader = cs.readable.getReader();
|
||||
|
||||
const writePromise = writer.write(data).then(() => writer.close());
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalLength = 0;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalLength += value.length;
|
||||
}
|
||||
await writePromise;
|
||||
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const BASE_URL = "https://marketplace.example.com";
|
||||
|
||||
function mockPlugin(): MarketplacePluginDetail {
|
||||
return {
|
||||
id: "test-seo",
|
||||
name: "Test SEO",
|
||||
description: "SEO plugin",
|
||||
author: { name: "Test Author", verified: true, avatarUrl: null },
|
||||
capabilities: ["hooks"],
|
||||
keywords: ["seo"],
|
||||
installCount: 42,
|
||||
hasIcon: false,
|
||||
iconUrl: `${BASE_URL}/api/v1/plugins/test-seo/icon`,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-02-01T00:00:00Z",
|
||||
repositoryUrl: "https://github.com/test/test-seo",
|
||||
homepageUrl: null,
|
||||
license: "MIT",
|
||||
latestVersion: {
|
||||
version: "1.0.0",
|
||||
minEmDashVersion: null,
|
||||
bundleSize: 1234,
|
||||
checksum: "abc123",
|
||||
changelog: "Initial release",
|
||||
readme: "# Test SEO",
|
||||
hasIcon: false,
|
||||
screenshotCount: 0,
|
||||
screenshotUrls: [],
|
||||
capabilities: ["hooks"],
|
||||
auditVerdict: "pass",
|
||||
imageAuditVerdict: "pass",
|
||||
publishedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("MarketplaceClient", () => {
|
||||
let client: MarketplaceClient;
|
||||
let fetchSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = createMarketplaceClient(BASE_URL);
|
||||
fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("search", () => {
|
||||
it("fetches plugins from marketplace", async () => {
|
||||
const searchResult: MarketplaceSearchResult = {
|
||||
items: [
|
||||
{
|
||||
id: "test-seo",
|
||||
name: "Test SEO",
|
||||
description: "SEO plugin",
|
||||
author: { name: "Test", verified: true, avatarUrl: null },
|
||||
capabilities: ["hooks"],
|
||||
keywords: ["seo"],
|
||||
installCount: 10,
|
||||
hasIcon: false,
|
||||
iconUrl: `${BASE_URL}/api/v1/plugins/test-seo/icon`,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-02-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(searchResult), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await client.search("seo");
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.id).toBe("test-seo");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/api/v1/plugins?q=seo`,
|
||||
expect.objectContaining({ headers: { Accept: "application/json" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes category and limit as query params", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||
|
||||
await client.search(undefined, { category: "analytics", limit: 10 });
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toContain("category=analytics");
|
||||
expect(url).toContain("limit=10");
|
||||
});
|
||||
|
||||
it("throws MarketplaceUnavailableError on network failure", async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
await expect(client.search("test")).rejects.toThrow(MarketplaceUnavailableError);
|
||||
});
|
||||
|
||||
it("throws MarketplaceError on HTTP error", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ error: "Rate limited" }), { status: 429 }),
|
||||
);
|
||||
|
||||
await expect(client.search("test")).rejects.toThrow(MarketplaceError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPlugin", () => {
|
||||
it("fetches plugin detail", async () => {
|
||||
const plugin = mockPlugin();
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(plugin), { status: 200 }));
|
||||
|
||||
const result = await client.getPlugin("test-seo");
|
||||
expect(result.id).toBe("test-seo");
|
||||
expect(result.latestVersion?.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("encodes plugin ID in URL", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(mockPlugin()), { status: 200 }));
|
||||
|
||||
await client.getPlugin("@scope/plugin");
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toContain("%40scope%2Fplugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getVersions", () => {
|
||||
it("fetches version list", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
minEmDashVersion: null,
|
||||
bundleSize: 1234,
|
||||
checksum: "abc",
|
||||
changelog: "First",
|
||||
capabilities: ["hooks"],
|
||||
auditVerdict: "pass",
|
||||
imageAuditVerdict: "pass",
|
||||
publishedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const versions = await client.getVersions("test-seo");
|
||||
expect(versions).toHaveLength(1);
|
||||
expect(versions[0]!.version).toBe("1.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadBundle", () => {
|
||||
it("downloads, decompresses, and extracts a bundle tarball", async () => {
|
||||
const manifest = {
|
||||
id: "test-seo",
|
||||
version: "1.0.0",
|
||||
capabilities: ["content:read"],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
};
|
||||
|
||||
const tarData = createTar({
|
||||
"manifest.json": JSON.stringify(manifest),
|
||||
"backend.js": 'export default function() { return "hello"; }',
|
||||
});
|
||||
const gzipped = await gzip(tarData);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(gzipped, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/gzip" },
|
||||
}),
|
||||
);
|
||||
|
||||
const bundle = await client.downloadBundle("test-seo", "1.0.0");
|
||||
|
||||
expect(bundle.manifest.id).toBe("test-seo");
|
||||
expect(bundle.manifest.version).toBe("1.0.0");
|
||||
expect(bundle.backendCode).toContain("hello");
|
||||
expect(bundle.checksum).toMatch(HEX_64_PATTERN);
|
||||
});
|
||||
|
||||
it("extracts optional admin.js", async () => {
|
||||
const manifest = {
|
||||
id: "test-seo",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
};
|
||||
|
||||
const tarData = createTar({
|
||||
"manifest.json": JSON.stringify(manifest),
|
||||
"backend.js": "export default {};",
|
||||
"admin.js": "export const Admin = {};",
|
||||
});
|
||||
const gzipped = await gzip(tarData);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(new Response(gzipped, { status: 200 }));
|
||||
|
||||
const bundle = await client.downloadBundle("test-seo", "1.0.0");
|
||||
expect(bundle.adminCode).toContain("Admin");
|
||||
});
|
||||
|
||||
it("throws on missing manifest.json", async () => {
|
||||
const tarData = createTar({
|
||||
"backend.js": "export default {};",
|
||||
});
|
||||
const gzipped = await gzip(tarData);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(new Response(gzipped, { status: 200 }));
|
||||
|
||||
await expect(client.downloadBundle("test-seo", "1.0.0")).rejects.toThrow(
|
||||
"missing manifest.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on missing backend.js", async () => {
|
||||
const tarData = createTar({
|
||||
"manifest.json": JSON.stringify({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
}),
|
||||
});
|
||||
const gzipped = await gzip(tarData);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(new Response(gzipped, { status: 200 }));
|
||||
|
||||
await expect(client.downloadBundle("test-seo", "1.0.0")).rejects.toThrow(
|
||||
"missing backend.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on malformed manifest.json", async () => {
|
||||
const tarData = createTar({
|
||||
"manifest.json": "not-json{{{",
|
||||
"backend.js": "export default {};",
|
||||
});
|
||||
const gzipped = await gzip(tarData);
|
||||
|
||||
fetchSpy.mockResolvedValueOnce(new Response(gzipped, { status: 200 }));
|
||||
|
||||
await expect(client.downloadBundle("test-seo", "1.0.0")).rejects.toThrow(
|
||||
"malformed manifest.json",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws MarketplaceUnavailableError on network failure", async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error("Connection refused"));
|
||||
|
||||
await expect(client.downloadBundle("test-seo", "1.0.0")).rejects.toThrow(
|
||||
MarketplaceUnavailableError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on HTTP error from bundle download", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response("Not Found", { status: 404 }));
|
||||
|
||||
await expect(client.downloadBundle("test-seo", "1.0.0")).rejects.toThrow(MarketplaceError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportInstall", () => {
|
||||
it("sends install stat without throwing", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response("OK", { status: 200 }));
|
||||
|
||||
// Should not throw even if we await it
|
||||
await client.reportInstall("test-seo", "1.0.0");
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${BASE_URL}/api/v1/plugins/test-seo/installs`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw on network failure", async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
// Should not throw
|
||||
await client.reportInstall("test-seo", "1.0.0");
|
||||
});
|
||||
|
||||
it("sends a stable site hash across multiple calls", async () => {
|
||||
const clientWithOrigin = createMarketplaceClient(BASE_URL, "https://myblog.example.com");
|
||||
|
||||
fetchSpy.mockResolvedValue(new Response("OK", { status: 200 }));
|
||||
|
||||
await clientWithOrigin.reportInstall("test-seo", "1.0.0");
|
||||
await clientWithOrigin.reportInstall("test-seo", "1.0.0");
|
||||
|
||||
const calls = fetchSpy.mock.calls;
|
||||
expect(calls.length).toBe(2);
|
||||
|
||||
const body1 = JSON.parse(calls[0]![1]!.body as string);
|
||||
const body2 = JSON.parse(calls[1]![1]!.body as string);
|
||||
|
||||
// Same origin produces the same hash every time
|
||||
expect(body1.siteHash).toBe(body2.siteHash);
|
||||
expect(body1.siteHash).toMatch(HEX_16_PATTERN);
|
||||
});
|
||||
|
||||
it("produces different hashes for different site origins", async () => {
|
||||
const client1 = createMarketplaceClient(BASE_URL, "https://site-a.example.com");
|
||||
const client2 = createMarketplaceClient(BASE_URL, "https://site-b.example.com");
|
||||
|
||||
fetchSpy.mockResolvedValue(new Response("OK", { status: 200 }));
|
||||
|
||||
await client1.reportInstall("test-seo", "1.0.0");
|
||||
await client2.reportInstall("test-seo", "1.0.0");
|
||||
|
||||
const body1 = JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string);
|
||||
const body2 = JSON.parse(fetchSpy.mock.calls[1]![1]!.body as string);
|
||||
|
||||
expect(body1.siteHash).not.toBe(body2.siteHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trailing slash handling", () => {
|
||||
it("strips trailing slashes from base URL", async () => {
|
||||
const clientWithSlash = createMarketplaceClient("https://example.com/");
|
||||
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify({ items: [] }), { status: 200 }));
|
||||
|
||||
await clientWithSlash.search("test");
|
||||
|
||||
const [url] = fetchSpy.mock.calls[0]!;
|
||||
expect(url).toContain("https://example.com/api/v1/plugins");
|
||||
expect(url).not.toContain("//api");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tar parser", () => {
|
||||
it("handles files with ./ prefix in paths", async () => {
|
||||
// Create tar with ./ prefixed paths (common from tar tools)
|
||||
const manifest = {
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
};
|
||||
const files: Record<string, string> = {};
|
||||
files["./manifest.json"] = JSON.stringify(manifest);
|
||||
files["./backend.js"] = "export default {};";
|
||||
|
||||
const tarData = createTar(files);
|
||||
const gzipped = await gzip(tarData);
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValueOnce(new Response(gzipped, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const client = createMarketplaceClient("https://example.com");
|
||||
const bundle = await client.downloadBundle("test", "1.0.0");
|
||||
|
||||
expect(bundle.manifest.id).toBe("test");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("handles empty tar archive gracefully", async () => {
|
||||
// Just two zero blocks (empty archive)
|
||||
const emptyTar = new Uint8Array(1024);
|
||||
const gzipped = await gzip(emptyTar);
|
||||
|
||||
const fetchSpy = vi.fn().mockResolvedValueOnce(new Response(gzipped, { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
const client = createMarketplaceClient("https://example.com");
|
||||
await expect(client.downloadBundle("test", "1.0.0")).rejects.toThrow("missing manifest.json");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
108
packages/core/tests/unit/plugins/marketplace-state.test.ts
Normal file
108
packages/core/tests/unit/plugins/marketplace-state.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Marketplace plugin state tests
|
||||
*
|
||||
* Tests the PluginStateRepository marketplace extensions:
|
||||
* - source/marketplaceVersion fields in upsert
|
||||
* - getMarketplacePlugins filter
|
||||
* - Migration 022 columns
|
||||
*/
|
||||
|
||||
import BetterSqlite3 from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database as DbSchema } from "../../../src/database/types.js";
|
||||
import { PluginStateRepository } from "../../../src/plugins/state.js";
|
||||
|
||||
describe("PluginStateRepository – marketplace extensions", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: BetterSqlite3.Database;
|
||||
let repo: PluginStateRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
sqliteDb = new BetterSqlite3(":memory:");
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({ database: sqliteDb }),
|
||||
});
|
||||
await runMigrations(db);
|
||||
repo = new PluginStateRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
describe("upsert with marketplace source", () => {
|
||||
it("defaults source to 'config' when not specified", async () => {
|
||||
const state = await repo.upsert("test-plugin", "1.0.0", "active");
|
||||
expect(state.source).toBe("config");
|
||||
expect(state.marketplaceVersion).toBeNull();
|
||||
});
|
||||
|
||||
it("stores source='marketplace' and marketplaceVersion", async () => {
|
||||
const state = await repo.upsert("mp-plugin", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
expect(state.source).toBe("marketplace");
|
||||
expect(state.marketplaceVersion).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("updates marketplaceVersion on subsequent upsert", async () => {
|
||||
await repo.upsert("mp-plugin", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
const updated = await repo.upsert("mp-plugin", "2.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "2.0.0",
|
||||
});
|
||||
|
||||
expect(updated.version).toBe("2.0.0");
|
||||
expect(updated.marketplaceVersion).toBe("2.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMarketplacePlugins", () => {
|
||||
it("returns empty array when no marketplace plugins", async () => {
|
||||
await repo.upsert("config-plugin", "1.0.0", "active");
|
||||
const result = await repo.getMarketplacePlugins();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns only marketplace-sourced plugins", async () => {
|
||||
await repo.upsert("config-plugin", "1.0.0", "active");
|
||||
await repo.upsert("mp-plugin-a", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
await repo.upsert("mp-plugin-b", "2.0.0", "inactive", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "2.0.0",
|
||||
});
|
||||
|
||||
const result = await repo.getMarketplacePlugins();
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((p) => p.pluginId).toSorted()).toEqual(["mp-plugin-a", "mp-plugin-b"]);
|
||||
expect(result.every((p) => p.source === "marketplace")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete marketplace plugin", () => {
|
||||
it("deletes marketplace plugin state", async () => {
|
||||
await repo.upsert("mp-plugin", "1.0.0", "active", {
|
||||
source: "marketplace",
|
||||
marketplaceVersion: "1.0.0",
|
||||
});
|
||||
|
||||
const deleted = await repo.delete("mp-plugin");
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const state = await repo.get("mp-plugin");
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
156
packages/core/tests/unit/plugins/page-context.test.ts
Normal file
156
packages/core/tests/unit/plugins/page-context.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Page Context Tests
|
||||
*
|
||||
* Tests the public page context builder for:
|
||||
* - Astro-like input handling
|
||||
* - URL string and object input
|
||||
* - Default pageType resolution
|
||||
* - Null normalization for optional fields
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { createPublicPageContext } from "../../../src/page/context.js";
|
||||
|
||||
describe("createPublicPageContext", () => {
|
||||
it("accepts Astro-like input and extracts url/path/locale", () => {
|
||||
const result = createPublicPageContext({
|
||||
Astro: {
|
||||
url: new URL("https://example.com/blog/hello"),
|
||||
currentLocale: "en",
|
||||
},
|
||||
kind: "content",
|
||||
title: "Hello",
|
||||
pageTitle: "Hello",
|
||||
});
|
||||
|
||||
expect(result.url).toBe("https://example.com/blog/hello");
|
||||
expect(result.path).toBe("/blog/hello");
|
||||
expect(result.locale).toBe("en");
|
||||
expect(result.title).toBe("Hello");
|
||||
expect(result.pageTitle).toBe("Hello");
|
||||
});
|
||||
|
||||
it("accepts URL string input", () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/about",
|
||||
kind: "custom",
|
||||
locale: "fr",
|
||||
});
|
||||
|
||||
expect(result.url).toBe("https://example.com/about");
|
||||
expect(result.path).toBe("/about");
|
||||
expect(result.locale).toBe("fr");
|
||||
});
|
||||
|
||||
it("accepts URL object input", () => {
|
||||
const urlObj = new URL("https://example.com/products?page=2");
|
||||
|
||||
const result = createPublicPageContext({
|
||||
url: urlObj,
|
||||
kind: "custom",
|
||||
});
|
||||
|
||||
expect(result.url).toBe("https://example.com/products?page=2");
|
||||
expect(result.path).toBe("/products");
|
||||
});
|
||||
|
||||
it('defaults pageType to "article" for content kind', () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/post/1",
|
||||
kind: "content",
|
||||
});
|
||||
|
||||
expect(result.pageType).toBe("article");
|
||||
});
|
||||
|
||||
it('defaults pageType to "website" for custom kind', () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/",
|
||||
kind: "custom",
|
||||
});
|
||||
|
||||
expect(result.pageType).toBe("website");
|
||||
});
|
||||
|
||||
it("normalizes undefined locale to null", () => {
|
||||
const result = createPublicPageContext({
|
||||
Astro: {
|
||||
url: new URL("https://example.com/"),
|
||||
// currentLocale not set
|
||||
},
|
||||
kind: "custom",
|
||||
});
|
||||
|
||||
expect(result.locale).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes undefined pageTitle to null", () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/about",
|
||||
kind: "custom",
|
||||
title: "About | My Site",
|
||||
});
|
||||
|
||||
expect(result.pageTitle).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes content slug undefined to null", () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/post/1",
|
||||
kind: "content",
|
||||
content: { collection: "posts", id: "abc123" },
|
||||
});
|
||||
|
||||
expect(result.content).toBeDefined();
|
||||
expect(result.content!.slug).toBeNull();
|
||||
expect(result.content!.collection).toBe("posts");
|
||||
expect(result.content!.id).toBe("abc123");
|
||||
});
|
||||
|
||||
it("sets content to undefined for custom kind", () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/about",
|
||||
kind: "custom",
|
||||
});
|
||||
|
||||
expect(result.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes breadcrumbs through verbatim when provided", () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/blog/hello",
|
||||
kind: "content",
|
||||
breadcrumbs: [
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: "Blog", url: "/blog/" },
|
||||
{ name: "Hello", url: "/blog/hello" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.breadcrumbs).toEqual([
|
||||
{ name: "Home", url: "/" },
|
||||
{ name: "Blog", url: "/blog/" },
|
||||
{ name: "Hello", url: "/blog/hello" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves breadcrumbs undefined when not provided", () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/about",
|
||||
kind: "custom",
|
||||
});
|
||||
|
||||
expect(result.breadcrumbs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves explicit empty breadcrumbs array (opt-out signal)", () => {
|
||||
const result = createPublicPageContext({
|
||||
url: "https://example.com/",
|
||||
kind: "custom",
|
||||
breadcrumbs: [],
|
||||
});
|
||||
|
||||
expect(result.breadcrumbs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests for the sandbox boundary enforcement of page contribution hooks.
|
||||
*
|
||||
* page:metadata is sandbox-safe.
|
||||
* page:fragments is trusted-only but valid in manifests (enforcement happens
|
||||
* at runtime via capability checks and at bundle time via CLI warnings).
|
||||
*
|
||||
* The enforcement happens at multiple layers:
|
||||
* 1. Manifest schema: HOOK_NAMES includes both page:metadata and page:fragments
|
||||
* 2. Capability enforcement: page:fragments requires page:inject capability
|
||||
* 3. Bundle CLI: warns when page:fragments is declared in a sandbox-targeted plugin
|
||||
* 4. Fragment collector: never invokes sandboxed plugins for page:fragments
|
||||
*/
|
||||
|
||||
describe("page contribution sandbox boundary", () => {
|
||||
describe("manifest schema validation", () => {
|
||||
it("should accept page:metadata in manifests", async () => {
|
||||
const { pluginManifestSchema } = await import("../../../src/plugins/manifest-schema.js");
|
||||
|
||||
const manifest = {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [{ name: "page:metadata" }],
|
||||
routes: [],
|
||||
admin: { pages: [], widgets: [] },
|
||||
};
|
||||
|
||||
const result = pluginManifestSchema.safeParse(manifest);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept page:fragments in manifests (enforcement is at runtime)", async () => {
|
||||
const { pluginManifestSchema } = await import("../../../src/plugins/manifest-schema.js");
|
||||
|
||||
const manifest = {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [{ name: "page:fragments" }],
|
||||
routes: [],
|
||||
admin: { pages: [], widgets: [] },
|
||||
};
|
||||
|
||||
// Manifest validation accepts page:fragments — trusted-only enforcement
|
||||
// happens via capability checks (requires page:inject) and the bundle CLI
|
||||
// warns when this hook is used in a sandbox-targeted plugin.
|
||||
const result = pluginManifestSchema.safeParse(manifest);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fragment collector defense-in-depth", () => {
|
||||
it("resolveFragments only processes contributions it receives", async () => {
|
||||
// The fragment collector in page/fragments.ts is a pure function that
|
||||
// processes whatever contributions are passed to it. The defense-in-depth
|
||||
// is that the runtime never passes sandboxed plugin contributions to it.
|
||||
// This test verifies the pure function works correctly.
|
||||
const { resolveFragments } = await import("../../../src/page/fragments.js");
|
||||
|
||||
const result = resolveFragments([], "head");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
222
packages/core/tests/unit/plugins/page-fragments.test.ts
Normal file
222
packages/core/tests/unit/plugins/page-fragments.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Page Fragments Tests
|
||||
*
|
||||
* Tests the fragment collector for:
|
||||
* - Filtering contributions by placement
|
||||
* - Deduplication by key and src
|
||||
* - HTML rendering of script and raw HTML fragments
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { resolveFragments, renderFragments } from "../../../src/page/fragments.js";
|
||||
import type { PageFragmentContribution } from "../../../src/plugins/types.js";
|
||||
|
||||
describe("resolveFragments", () => {
|
||||
it("filters by placement", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{ kind: "html", placement: "head", html: "<link>" },
|
||||
{ kind: "html", placement: "body:end", html: "<div>footer</div>" },
|
||||
{ kind: "html", placement: "head", html: "<style></style>" },
|
||||
];
|
||||
|
||||
const result = resolveFragments(contributions, "head");
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]!.kind).toBe("html");
|
||||
expect((result[0] as { html: string }).html).toBe("<link>");
|
||||
expect((result[1] as { html: string }).html).toBe("<style></style>");
|
||||
});
|
||||
|
||||
it("dedupes by key + placement", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{ kind: "html", placement: "head", html: "<link first>", key: "my-styles" },
|
||||
{ kind: "html", placement: "head", html: "<link second>", key: "my-styles" },
|
||||
];
|
||||
|
||||
const result = resolveFragments(contributions, "head");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as { html: string }).html).toBe("<link first>");
|
||||
});
|
||||
|
||||
it("dedupes external scripts by src", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "body:end",
|
||||
src: "https://cdn.example.com/lib.js",
|
||||
async: true,
|
||||
},
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "body:end",
|
||||
src: "https://cdn.example.com/lib.js",
|
||||
defer: true,
|
||||
},
|
||||
];
|
||||
|
||||
const result = resolveFragments(contributions, "body:end");
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0] as { async?: boolean }).async).toBe(true);
|
||||
});
|
||||
|
||||
it("allows different placements of same key", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{ kind: "html", placement: "head", html: "<meta>", key: "seo" },
|
||||
{ kind: "html", placement: "body:end", html: "<noscript>", key: "seo" },
|
||||
];
|
||||
|
||||
const headResult = resolveFragments(contributions, "head");
|
||||
const bodyResult = resolveFragments(contributions, "body:end");
|
||||
|
||||
expect(headResult).toHaveLength(1);
|
||||
expect(bodyResult).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves order", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{ kind: "html", placement: "head", html: "<first>" },
|
||||
{ kind: "html", placement: "head", html: "<second>" },
|
||||
{ kind: "html", placement: "head", html: "<third>" },
|
||||
];
|
||||
|
||||
const result = resolveFragments(contributions, "head");
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect((result[0] as { html: string }).html).toBe("<first>");
|
||||
expect((result[1] as { html: string }).html).toBe("<second>");
|
||||
expect((result[2] as { html: string }).html).toBe("<third>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFragments", () => {
|
||||
it("renders external script with async/defer", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "head",
|
||||
src: "https://cdn.example.com/analytics.js",
|
||||
async: true,
|
||||
defer: true,
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderFragments(contributions, "head");
|
||||
|
||||
expect(html).toBe('<script src="https://cdn.example.com/analytics.js" async defer></script>');
|
||||
});
|
||||
|
||||
it("renders external script with attributes", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "head",
|
||||
src: "https://cdn.example.com/widget.js",
|
||||
attributes: { "data-site-id": "abc123", crossorigin: "anonymous" },
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderFragments(contributions, "head");
|
||||
|
||||
expect(html).toContain('src="https://cdn.example.com/widget.js"');
|
||||
expect(html).toContain('data-site-id="abc123"');
|
||||
expect(html).toContain('crossorigin="anonymous"');
|
||||
expect(html).toContain("</script>");
|
||||
});
|
||||
|
||||
it("renders inline script", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{
|
||||
kind: "inline-script",
|
||||
placement: "body:end",
|
||||
code: "console.log('hello');",
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderFragments(contributions, "body:end");
|
||||
|
||||
expect(html).toBe("<script>console.log('hello');</script>");
|
||||
});
|
||||
|
||||
it("escapes </script> in inline script code", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{
|
||||
kind: "inline-script",
|
||||
placement: "head",
|
||||
code: 'var x = "</script><script>alert(1)</script>";',
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderFragments(contributions, "head");
|
||||
|
||||
// The </ sequence should be escaped to <\/ to prevent tag breakout
|
||||
expect(html).not.toContain("</script><script>");
|
||||
expect(html).toContain("<\\/script>");
|
||||
});
|
||||
|
||||
it("renders raw HTML", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{
|
||||
kind: "html",
|
||||
placement: "body:start",
|
||||
html: '<div id="overlay"></div>',
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderFragments(contributions, "body:start");
|
||||
|
||||
expect(html).toBe('<div id="overlay"></div>');
|
||||
});
|
||||
|
||||
it("escapes attribute names and values", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "head",
|
||||
src: "https://example.com/x.js",
|
||||
attributes: { 'data-"key': 'val<ue&"more' },
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderFragments(contributions, "head");
|
||||
|
||||
expect(html).toContain("data-"key");
|
||||
expect(html).toContain("val<ue&"more");
|
||||
expect(html).not.toContain('data-"key');
|
||||
});
|
||||
|
||||
it("strips event handler attributes", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "head",
|
||||
src: "https://example.com/x.js",
|
||||
attributes: {
|
||||
onload: "alert(1)",
|
||||
onerror: "alert(2)",
|
||||
"data-id": "safe",
|
||||
crossorigin: "anonymous",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderFragments(contributions, "head");
|
||||
|
||||
expect(html).not.toContain("onload");
|
||||
expect(html).not.toContain("onerror");
|
||||
expect(html).toContain('data-id="safe"');
|
||||
expect(html).toContain('crossorigin="anonymous"');
|
||||
});
|
||||
|
||||
it("returns empty string for no matching placement", () => {
|
||||
const contributions: PageFragmentContribution[] = [
|
||||
{ kind: "html", placement: "head", html: "<link>" },
|
||||
];
|
||||
|
||||
const html = renderFragments(contributions, "body:end");
|
||||
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
318
packages/core/tests/unit/plugins/page-hooks-execution.test.ts
Normal file
318
packages/core/tests/unit/plugins/page-hooks-execution.test.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Page Hooks Execution Tests
|
||||
*
|
||||
* Tests that page:metadata and page:fragments hooks fire correctly through
|
||||
* the HookPipeline, returning plugin contributions that EmDashHead,
|
||||
* EmDashBodyStart, and EmDashBodyEnd render into HTML.
|
||||
*
|
||||
* Bug context: The middleware's anonymous fast-path skipped runtime init,
|
||||
* so collectPageMetadata/collectPageFragments were never available to
|
||||
* anonymous visitors. These tests verify the hook pipeline actually runs
|
||||
* plugin handlers and collects their contributions — the path that was
|
||||
* broken before the fix.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { HookPipeline } from "../../../src/plugins/hooks.js";
|
||||
import type {
|
||||
ResolvedPlugin,
|
||||
ResolvedHook,
|
||||
PageMetadataHandler,
|
||||
PageFragmentHandler,
|
||||
PublicPageContext,
|
||||
} from "../../../src/plugins/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
hooks: {},
|
||||
routes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestHook<T>(
|
||||
pluginId: string,
|
||||
handler: T,
|
||||
overrides: Partial<ResolvedHook<T>> = {},
|
||||
): ResolvedHook<T> {
|
||||
return {
|
||||
pluginId,
|
||||
handler,
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "continue",
|
||||
exclusive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPageContext(overrides: Partial<PublicPageContext> = {}): PublicPageContext {
|
||||
return {
|
||||
url: "https://example.com/blog/hello",
|
||||
path: "/blog/hello",
|
||||
locale: null,
|
||||
kind: "content",
|
||||
pageType: "post",
|
||||
title: "Hello World",
|
||||
description: null,
|
||||
canonical: null,
|
||||
image: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB setup (required for PluginContextFactory)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let db: Kysely<any>;
|
||||
let sqlite: InstanceType<typeof Database>;
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
db = new Kysely({ dialect: new SqliteDialect({ database: sqlite }) });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("page:metadata hook execution", () => {
|
||||
it("runs page:metadata handler and collects contributions", async () => {
|
||||
const metaHandler: PageMetadataHandler = vi.fn(async () => ({
|
||||
kind: "meta" as const,
|
||||
name: "x-page-hook-test",
|
||||
content: "present",
|
||||
}));
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test-meta",
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("test-meta", metaHandler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
const page = createPageContext();
|
||||
|
||||
const results = await pipeline.runPageMetadata({ page });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.pluginId).toBe("test-meta");
|
||||
expect(results[0]!.contributions).toEqual([
|
||||
{ kind: "meta", name: "x-page-hook-test", content: "present" },
|
||||
]);
|
||||
expect(metaHandler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("collects contributions from multiple plugins", async () => {
|
||||
const handler1: PageMetadataHandler = vi.fn(async () => ({
|
||||
kind: "meta" as const,
|
||||
name: "plugin-1",
|
||||
content: "first",
|
||||
}));
|
||||
|
||||
const handler2: PageMetadataHandler = vi.fn(async () => [
|
||||
{ kind: "meta" as const, name: "plugin-2a", content: "second-a" },
|
||||
{ kind: "link" as const, rel: "alternate" as const, href: "/fr/blog/hello", hreflang: "fr" },
|
||||
]);
|
||||
|
||||
const plugin1 = createTestPlugin({
|
||||
id: "plugin-1",
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("plugin-1", handler1, { priority: 1 }),
|
||||
},
|
||||
});
|
||||
|
||||
const plugin2 = createTestPlugin({
|
||||
id: "plugin-2",
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("plugin-2", handler2, { priority: 2 }),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin1, plugin2], { db });
|
||||
const page = createPageContext();
|
||||
|
||||
const results = await pipeline.runPageMetadata({ page });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]!.pluginId).toBe("plugin-1");
|
||||
expect(results[1]!.pluginId).toBe("plugin-2");
|
||||
expect(results[1]!.contributions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("passes page context to the handler", async () => {
|
||||
const metaHandler: PageMetadataHandler = vi.fn(async () => null);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "ctx-test",
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("ctx-test", metaHandler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
const page = createPageContext({ title: "Test Page", path: "/test" });
|
||||
|
||||
await pipeline.runPageMetadata({ page });
|
||||
|
||||
expect(metaHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page: expect.objectContaining({ title: "Test Page", path: "/test" }),
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles null return from handler (no contributions)", async () => {
|
||||
const metaHandler: PageMetadataHandler = vi.fn(async () => null);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "null-return",
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("null-return", metaHandler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
const page = createPageContext();
|
||||
|
||||
const results = await pipeline.runPageMetadata({ page });
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("isolates errors from individual plugin handlers", async () => {
|
||||
const badHandler: PageMetadataHandler = vi.fn(async () => {
|
||||
throw new Error("Plugin crashed");
|
||||
});
|
||||
|
||||
const goodHandler: PageMetadataHandler = vi.fn(async () => ({
|
||||
kind: "meta" as const,
|
||||
name: "still-works",
|
||||
content: "yes",
|
||||
}));
|
||||
|
||||
const badPlugin = createTestPlugin({
|
||||
id: "bad-plugin",
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("bad-plugin", badHandler, { priority: 1 }),
|
||||
},
|
||||
});
|
||||
|
||||
const goodPlugin = createTestPlugin({
|
||||
id: "good-plugin",
|
||||
hooks: {
|
||||
"page:metadata": createTestHook("good-plugin", goodHandler, { priority: 2 }),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([badPlugin, goodPlugin], { db });
|
||||
const page = createPageContext();
|
||||
|
||||
// Should not throw — errors are logged, not propagated
|
||||
const results = await pipeline.runPageMetadata({ page });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.pluginId).toBe("good-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("page:fragments hook execution", () => {
|
||||
it("runs page:fragments handler and collects contributions", async () => {
|
||||
const fragmentHandler: PageFragmentHandler = vi.fn(async () => ({
|
||||
kind: "html" as const,
|
||||
placement: "head" as const,
|
||||
html: '<link rel="webmention" href="https://example.com/webmention">',
|
||||
}));
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "test-fragment",
|
||||
capabilities: ["hooks.page-fragments:register"],
|
||||
hooks: {
|
||||
"page:fragments": createTestHook("test-fragment", fragmentHandler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
const page = createPageContext();
|
||||
|
||||
const results = await pipeline.runPageFragments({ page });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.pluginId).toBe("test-fragment");
|
||||
expect(results[0]!.contributions).toEqual([
|
||||
{
|
||||
kind: "html",
|
||||
placement: "head",
|
||||
html: '<link rel="webmention" href="https://example.com/webmention">',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires hooks.page-fragments:register capability for page:fragments", () => {
|
||||
const handler: PageFragmentHandler = vi.fn(async () => null);
|
||||
|
||||
const pluginWithoutCap = createTestPlugin({
|
||||
id: "no-cap",
|
||||
capabilities: [],
|
||||
hooks: {
|
||||
"page:fragments": createTestHook("no-cap", handler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([pluginWithoutCap], { db });
|
||||
|
||||
expect(pipeline.hasHooks("page:fragments")).toBe(false);
|
||||
});
|
||||
|
||||
it("collects external script contributions", async () => {
|
||||
const fragmentHandler: PageFragmentHandler = vi.fn(async () => ({
|
||||
kind: "external-script" as const,
|
||||
placement: "body:end" as const,
|
||||
src: "https://cdn.example.com/analytics.js",
|
||||
async: true,
|
||||
}));
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "analytics",
|
||||
capabilities: ["hooks.page-fragments:register"],
|
||||
hooks: {
|
||||
"page:fragments": createTestHook("analytics", fragmentHandler),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = new HookPipeline([plugin], { db });
|
||||
const page = createPageContext();
|
||||
|
||||
const results = await pipeline.runPageFragments({ page });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.contributions[0]).toEqual({
|
||||
kind: "external-script",
|
||||
placement: "body:end",
|
||||
src: "https://cdn.example.com/analytics.js",
|
||||
async: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
314
packages/core/tests/unit/plugins/page-metadata.test.ts
Normal file
314
packages/core/tests/unit/plugins/page-metadata.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Page Metadata Tests
|
||||
*
|
||||
* Tests the metadata collector for:
|
||||
* - Resolving contributions into deduplicated metadata
|
||||
* - HTML rendering with proper escaping
|
||||
* - Safe JSON-LD serialization
|
||||
* - HTML attribute escaping
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
resolvePageMetadata,
|
||||
renderPageMetadata,
|
||||
safeJsonLdSerialize,
|
||||
escapeHtmlAttr,
|
||||
} from "../../../src/page/metadata.js";
|
||||
import type { PageMetadataContribution } from "../../../src/plugins/types.js";
|
||||
|
||||
describe("resolvePageMetadata", () => {
|
||||
it("resolves meta tags correctly", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "meta", name: "description", content: "A test page" },
|
||||
{ kind: "meta", name: "robots", content: "index, follow" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.meta).toEqual([
|
||||
{ name: "description", content: "A test page" },
|
||||
{ name: "robots", content: "index, follow" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves property tags correctly", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "property", property: "og:title", content: "My Page" },
|
||||
{ kind: "property", property: "og:type", content: "article" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.properties).toEqual([
|
||||
{ property: "og:title", content: "My Page" },
|
||||
{ property: "og:type", content: "article" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves canonical link", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "link", rel: "canonical", href: "https://example.com/page" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.links).toEqual([{ rel: "canonical", href: "https://example.com/page" }]);
|
||||
});
|
||||
|
||||
it("resolves alternate links with hreflang", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "link", rel: "alternate", href: "https://example.com/en/page", hreflang: "en" },
|
||||
{ kind: "link", rel: "alternate", href: "https://example.com/fr/page", hreflang: "fr" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.links).toEqual([
|
||||
{ rel: "alternate", href: "https://example.com/en/page", hreflang: "en" },
|
||||
{ rel: "alternate", href: "https://example.com/fr/page", hreflang: "fr" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves nlweb link for agent discovery", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "link", rel: "nlweb", href: "https://example.com/nlweb" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.links).toEqual([{ rel: "nlweb", href: "https://example.com/nlweb" }]);
|
||||
});
|
||||
|
||||
it("resolves JSON-LD", () => {
|
||||
const graph = { "@type": "Article", name: "Test" };
|
||||
const contributions: PageMetadataContribution[] = [{ kind: "jsonld", id: "article", graph }];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.jsonld).toHaveLength(1);
|
||||
expect(result.jsonld[0]!.id).toBe("article");
|
||||
expect(JSON.parse(result.jsonld[0]!.json)).toEqual(graph);
|
||||
});
|
||||
|
||||
it("first-wins dedupe for meta by name", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "meta", name: "description", content: "First" },
|
||||
{ kind: "meta", name: "description", content: "Second" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.meta).toHaveLength(1);
|
||||
expect(result.meta[0]!.content).toBe("First");
|
||||
});
|
||||
|
||||
it("first-wins dedupe for meta by explicit key", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "meta", name: "description", content: "First", key: "seo-desc" },
|
||||
{ kind: "meta", name: "og-description", content: "Second", key: "seo-desc" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.meta).toHaveLength(1);
|
||||
expect(result.meta[0]!.content).toBe("First");
|
||||
});
|
||||
|
||||
it("first-wins dedupe for property", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "property", property: "og:title", content: "First" },
|
||||
{ kind: "property", property: "og:title", content: "Second" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.properties).toHaveLength(1);
|
||||
expect(result.properties[0]!.content).toBe("First");
|
||||
});
|
||||
|
||||
it("canonical is singleton (second canonical ignored)", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "link", rel: "canonical", href: "https://example.com/first" },
|
||||
{ kind: "link", rel: "canonical", href: "https://example.com/second" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]!.href).toBe("https://example.com/first");
|
||||
});
|
||||
|
||||
it("alternate links deduped by hreflang", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "link", rel: "alternate", href: "https://example.com/en/v1", hreflang: "en" },
|
||||
{ kind: "link", rel: "alternate", href: "https://example.com/en/v2", hreflang: "en" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.links).toHaveLength(1);
|
||||
expect(result.links[0]!.href).toBe("https://example.com/en/v1");
|
||||
});
|
||||
|
||||
it("JSON-LD deduped by id", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "jsonld", id: "article", graph: { "@type": "Article", name: "First" } },
|
||||
{ kind: "jsonld", id: "article", graph: { "@type": "Article", name: "Second" } },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.jsonld).toHaveLength(1);
|
||||
expect(JSON.parse(result.jsonld[0]!.json)).toEqual({
|
||||
"@type": "Article",
|
||||
name: "First",
|
||||
});
|
||||
});
|
||||
|
||||
it("JSON-LD without id is always appended", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "jsonld", graph: { "@type": "Article", name: "First" } },
|
||||
{ kind: "jsonld", graph: { "@type": "BreadcrumbList", name: "Second" } },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.jsonld).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects non-HTTP link href (javascript:, data:, blob:)", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "link", rel: "canonical", href: "javascript:alert(1)" },
|
||||
{ kind: "link", rel: "alternate", href: "data:text/html,<h1>hi</h1>", hreflang: "en" },
|
||||
{ kind: "link", rel: "alternate", href: "blob:https://example.com/abc", hreflang: "fr" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.links).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts valid HTTP and HTTPS hrefs", () => {
|
||||
const contributions: PageMetadataContribution[] = [
|
||||
{ kind: "link", rel: "canonical", href: "https://example.com/page" },
|
||||
{ kind: "link", rel: "alternate", href: "http://example.com/en", hreflang: "en" },
|
||||
];
|
||||
|
||||
const result = resolvePageMetadata(contributions);
|
||||
|
||||
expect(result.links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPageMetadata", () => {
|
||||
it("renders meta tags with escaped attributes", () => {
|
||||
const html = renderPageMetadata({
|
||||
meta: [{ name: 'desc"ription', content: "A <test> & page" }],
|
||||
properties: [],
|
||||
links: [],
|
||||
jsonld: [],
|
||||
});
|
||||
|
||||
expect(html).toBe('<meta name="desc"ription" content="A <test> & page">');
|
||||
});
|
||||
|
||||
it("renders property tags", () => {
|
||||
const html = renderPageMetadata({
|
||||
meta: [],
|
||||
properties: [{ property: "og:title", content: "My Page" }],
|
||||
links: [],
|
||||
jsonld: [],
|
||||
});
|
||||
|
||||
expect(html).toBe('<meta property="og:title" content="My Page">');
|
||||
});
|
||||
|
||||
it("renders link tags with hreflang", () => {
|
||||
const html = renderPageMetadata({
|
||||
meta: [],
|
||||
properties: [],
|
||||
links: [{ rel: "alternate", href: "https://example.com/fr", hreflang: "fr" }],
|
||||
jsonld: [],
|
||||
});
|
||||
|
||||
expect(html).toBe('<link rel="alternate" href="https://example.com/fr" hreflang="fr">');
|
||||
});
|
||||
|
||||
it("renders JSON-LD script tags", () => {
|
||||
const json = JSON.stringify({ "@type": "Article" });
|
||||
const html = renderPageMetadata({
|
||||
meta: [],
|
||||
properties: [],
|
||||
links: [],
|
||||
jsonld: [{ id: "article", json }],
|
||||
});
|
||||
|
||||
expect(html).toBe(`<script type="application/ld+json">${json}</script>`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeJsonLdSerialize", () => {
|
||||
it("escapes </script> in nested values", () => {
|
||||
const result = safeJsonLdSerialize({ text: "</script><script>alert(1)</script>" });
|
||||
|
||||
expect(result).not.toContain("</script>");
|
||||
expect(result).toContain("\\u003c");
|
||||
expect(result).toContain("\\u003e");
|
||||
});
|
||||
|
||||
it("escapes <!-- sequences", () => {
|
||||
const result = safeJsonLdSerialize({ text: "<!-- comment -->" });
|
||||
|
||||
expect(result).not.toContain("<!--");
|
||||
expect(result).toContain("\\u003c");
|
||||
});
|
||||
|
||||
it("escapes U+2028 line separator", () => {
|
||||
const result = safeJsonLdSerialize({ text: "before\u2028after" });
|
||||
|
||||
expect(result).not.toContain("\u2028");
|
||||
expect(result).toContain("\\u2028");
|
||||
});
|
||||
|
||||
it("escapes U+2029 paragraph separator", () => {
|
||||
const result = safeJsonLdSerialize({ text: "before\u2029after" });
|
||||
|
||||
expect(result).not.toContain("\u2029");
|
||||
expect(result).toContain("\\u2029");
|
||||
});
|
||||
|
||||
it("handles normal objects correctly", () => {
|
||||
const obj = { "@type": "Article", name: "Hello World", count: 42 };
|
||||
const result = safeJsonLdSerialize(obj);
|
||||
|
||||
// The result should be parseable back to the same object
|
||||
// (angle brackets are escaped but that's fine for JSON-LD consumers)
|
||||
expect(result).toContain('"@type"');
|
||||
expect(result).toContain('"Hello World"');
|
||||
expect(result).toContain("42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeHtmlAttr", () => {
|
||||
it("escapes double quotes", () => {
|
||||
expect(escapeHtmlAttr('say "hello"')).toBe("say "hello"");
|
||||
});
|
||||
|
||||
it("escapes angle brackets", () => {
|
||||
expect(escapeHtmlAttr("<script>")).toBe("<script>");
|
||||
});
|
||||
|
||||
it("escapes ampersands", () => {
|
||||
expect(escapeHtmlAttr("foo & bar")).toBe("foo & bar");
|
||||
});
|
||||
|
||||
it("escapes single quotes", () => {
|
||||
expect(escapeHtmlAttr("it's here")).toBe("it's here");
|
||||
});
|
||||
|
||||
it("passes through safe strings unchanged", () => {
|
||||
expect(escapeHtmlAttr("hello world")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
85
packages/core/tests/unit/plugins/page-seo.test.ts
Normal file
85
packages/core/tests/unit/plugins/page-seo.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildBlogPostingJsonLd } from "../../../src/page/jsonld.js";
|
||||
import { generateBaseSeoContributions } from "../../../src/page/seo-contributions.js";
|
||||
import type { PublicPageContext } from "../../../src/plugins/types.js";
|
||||
|
||||
function createPage(overrides: Partial<PublicPageContext> = {}): PublicPageContext {
|
||||
return {
|
||||
url: "https://example.com/posts/hello",
|
||||
path: "/posts/hello",
|
||||
locale: null,
|
||||
kind: "content",
|
||||
pageType: "article",
|
||||
title: "Hello World | My Site",
|
||||
description: "Test description",
|
||||
canonical: "https://example.com/posts/hello",
|
||||
image: "https://example.com/og.png",
|
||||
siteName: "My Site",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("page SEO metadata", () => {
|
||||
it("uses pageTitle for og:title and twitter:title", () => {
|
||||
const page = createPage({ pageTitle: "Hello World" });
|
||||
|
||||
const contributions = generateBaseSeoContributions(page);
|
||||
|
||||
expect(contributions).toContainEqual({
|
||||
kind: "property",
|
||||
property: "og:title",
|
||||
content: "Hello World",
|
||||
});
|
||||
expect(contributions).toContainEqual({
|
||||
kind: "meta",
|
||||
name: "twitter:title",
|
||||
content: "Hello World",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit seo.ogTitle over pageTitle", () => {
|
||||
const page = createPage({
|
||||
seo: { ogTitle: "Custom OG Title" },
|
||||
pageTitle: "Hello World",
|
||||
});
|
||||
|
||||
const contributions = generateBaseSeoContributions(page);
|
||||
|
||||
expect(contributions).toContainEqual({
|
||||
kind: "property",
|
||||
property: "og:title",
|
||||
content: "Custom OG Title",
|
||||
});
|
||||
expect(contributions).toContainEqual({
|
||||
kind: "meta",
|
||||
name: "twitter:title",
|
||||
content: "Custom OG Title",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to title when pageTitle is absent", () => {
|
||||
const page = createPage();
|
||||
|
||||
const contributions = generateBaseSeoContributions(page);
|
||||
|
||||
expect(contributions).toContainEqual({
|
||||
kind: "property",
|
||||
property: "og:title",
|
||||
content: "Hello World | My Site",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses pageTitle for article JSON-LD headline", () => {
|
||||
const page = createPage({
|
||||
articleMeta: { publishedTime: "2026-04-03T12:00:00.000Z" },
|
||||
pageTitle: "Hello World",
|
||||
});
|
||||
|
||||
const graph = buildBlogPostingJsonLd(page);
|
||||
|
||||
expect(graph).toMatchObject({
|
||||
headline: "Hello World",
|
||||
});
|
||||
});
|
||||
});
|
||||
332
packages/core/tests/unit/plugins/pipeline-rebuild.test.ts
Normal file
332
packages/core/tests/unit/plugins/pipeline-rebuild.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Pipeline Rebuild Tests
|
||||
*
|
||||
* Verifies that rebuilding the HookPipeline after plugin enable/disable
|
||||
* correctly includes/excludes hooks from the affected plugins.
|
||||
*
|
||||
* This tests the fix for #105: disabled plugins' hooks kept firing because
|
||||
* the pipeline was constructed once at startup and never rebuilt.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { createHookPipeline, resolveExclusiveHooks } from "../../../src/plugins/hooks.js";
|
||||
import type { ResolvedPlugin, ResolvedHook, ContentHookEvent } from "../../../src/plugins/types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
|
||||
return {
|
||||
id: overrides.id ?? "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: {
|
||||
pages: [],
|
||||
widgets: [],
|
||||
},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestHook<T>(
|
||||
pluginId: string,
|
||||
handler: T,
|
||||
overrides: Partial<ResolvedHook<T>> = {},
|
||||
): ResolvedHook<T> {
|
||||
return {
|
||||
pluginId,
|
||||
handler,
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: [],
|
||||
errorPolicy: "continue",
|
||||
exclusive: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("HookPipeline rebuild on plugin disable/enable (#105)", () => {
|
||||
let sqlite: InstanceType<typeof Database>;
|
||||
let db: Kysely<Record<string, unknown>>;
|
||||
|
||||
beforeEach(() => {
|
||||
sqlite = new Database(":memory:");
|
||||
db = new Kysely<Record<string, unknown>>({
|
||||
dialect: new SqliteDialect({ database: sqlite }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
it("hooks from disabled plugin do not fire after pipeline rebuild", async () => {
|
||||
const handlerA = vi.fn(async (event: ContentHookEvent) => ({
|
||||
...event.content,
|
||||
pluginA: true,
|
||||
}));
|
||||
const handlerB = vi.fn(async (event: ContentHookEvent) => ({
|
||||
...event.content,
|
||||
pluginB: true,
|
||||
}));
|
||||
|
||||
const pluginA = createTestPlugin({
|
||||
id: "plugin-a",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-a", handlerA),
|
||||
},
|
||||
});
|
||||
|
||||
const pluginB = createTestPlugin({
|
||||
id: "plugin-b",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-b", handlerB),
|
||||
},
|
||||
});
|
||||
|
||||
const allPlugins = [pluginA, pluginB];
|
||||
|
||||
// Initial pipeline with both plugins enabled
|
||||
const pipeline1 = createHookPipeline(allPlugins, { db });
|
||||
expect(pipeline1.hasHooks("content:beforeSave")).toBe(true);
|
||||
expect(pipeline1.getHookCount("content:beforeSave")).toBe(2);
|
||||
|
||||
// Run hooks — both should fire
|
||||
const result1 = await pipeline1.runContentBeforeSave({ title: "test" }, "posts", true);
|
||||
expect(handlerA).toHaveBeenCalledTimes(1);
|
||||
expect(handlerB).toHaveBeenCalledTimes(1);
|
||||
expect(result1.content).toEqual({ title: "test", pluginA: true, pluginB: true });
|
||||
|
||||
handlerA.mockClear();
|
||||
handlerB.mockClear();
|
||||
|
||||
// Simulate disabling plugin-b: rebuild pipeline with only plugin-a
|
||||
const enabledPlugins = allPlugins.filter((p) => p.id !== "plugin-b");
|
||||
const pipeline2 = createHookPipeline(enabledPlugins, { db });
|
||||
expect(pipeline2.hasHooks("content:beforeSave")).toBe(true);
|
||||
expect(pipeline2.getHookCount("content:beforeSave")).toBe(1);
|
||||
|
||||
// Run hooks — only plugin-a should fire
|
||||
const result2 = await pipeline2.runContentBeforeSave({ title: "test" }, "posts", true);
|
||||
expect(handlerA).toHaveBeenCalledTimes(1);
|
||||
expect(handlerB).not.toHaveBeenCalled();
|
||||
expect(result2.content).toEqual({ title: "test", pluginA: true });
|
||||
});
|
||||
|
||||
it("hooks from re-enabled plugin fire after pipeline rebuild", async () => {
|
||||
const handlerA = vi.fn(async (event: ContentHookEvent) => ({
|
||||
...event.content,
|
||||
pluginA: true,
|
||||
}));
|
||||
const handlerB = vi.fn(async (event: ContentHookEvent) => ({
|
||||
...event.content,
|
||||
pluginB: true,
|
||||
}));
|
||||
|
||||
const pluginA = createTestPlugin({
|
||||
id: "plugin-a",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-a", handlerA),
|
||||
},
|
||||
});
|
||||
|
||||
const pluginB = createTestPlugin({
|
||||
id: "plugin-b",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("plugin-b", handlerB),
|
||||
},
|
||||
});
|
||||
|
||||
const allPlugins = [pluginA, pluginB];
|
||||
|
||||
// Start with only plugin-a (plugin-b is disabled)
|
||||
const pipeline1 = createHookPipeline([pluginA], { db });
|
||||
const result1 = await pipeline1.runContentBeforeSave({ title: "test" }, "posts", true);
|
||||
expect(handlerA).toHaveBeenCalledTimes(1);
|
||||
expect(handlerB).not.toHaveBeenCalled();
|
||||
expect(result1.content).toEqual({ title: "test", pluginA: true });
|
||||
|
||||
handlerA.mockClear();
|
||||
|
||||
// Re-enable plugin-b: rebuild pipeline with both
|
||||
const pipeline2 = createHookPipeline(allPlugins, { db });
|
||||
const result2 = await pipeline2.runContentBeforeSave({ title: "test" }, "posts", true);
|
||||
expect(handlerA).toHaveBeenCalledTimes(1);
|
||||
expect(handlerB).toHaveBeenCalledTimes(1);
|
||||
expect(result2.content).toEqual({ title: "test", pluginA: true, pluginB: true });
|
||||
});
|
||||
|
||||
it("exclusive hook selections are re-resolved after rebuild", async () => {
|
||||
const handlerA = vi.fn().mockResolvedValue(undefined);
|
||||
const handlerB = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const pluginA = createTestPlugin({
|
||||
id: "provider-a",
|
||||
capabilities: ["hooks.email-transport:register"],
|
||||
hooks: {
|
||||
"email:deliver": createTestHook("provider-a", handlerA, { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
const pluginB = createTestPlugin({
|
||||
id: "provider-b",
|
||||
capabilities: ["hooks.email-transport:register"],
|
||||
hooks: {
|
||||
"email:deliver": createTestHook("provider-b", handlerB, { exclusive: true }),
|
||||
},
|
||||
});
|
||||
|
||||
// Both enabled — two providers, no auto-select
|
||||
const pipeline1 = createHookPipeline([pluginA, pluginB], { db });
|
||||
expect(pipeline1.getExclusiveHookProviders("email:deliver")).toHaveLength(2);
|
||||
|
||||
// Manually select provider-b (simulating admin selection)
|
||||
pipeline1.setExclusiveSelection("email:deliver", "provider-b");
|
||||
expect(pipeline1.getExclusiveSelection("email:deliver")).toBe("provider-b");
|
||||
|
||||
// Disable provider-b: rebuild with only provider-a
|
||||
const pipeline2 = createHookPipeline([pluginA], { db });
|
||||
expect(pipeline2.getExclusiveHookProviders("email:deliver")).toHaveLength(1);
|
||||
|
||||
// Run exclusive hook resolution — should auto-select the sole provider
|
||||
const options = new Map<string, string>();
|
||||
await resolveExclusiveHooks({
|
||||
pipeline: pipeline2,
|
||||
isActive: () => true,
|
||||
getOption: async (key) => options.get(key) ?? null,
|
||||
setOption: async (key, value) => {
|
||||
options.set(key, value);
|
||||
},
|
||||
deleteOption: async (key) => {
|
||||
options.delete(key);
|
||||
},
|
||||
});
|
||||
|
||||
expect(pipeline2.getExclusiveSelection("email:deliver")).toBe("provider-a");
|
||||
});
|
||||
|
||||
it("disabling all plugins with a hook removes that hook entirely", async () => {
|
||||
const handler = vi.fn(async () => undefined);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "only-plugin",
|
||||
capabilities: ["content:write"],
|
||||
hooks: {
|
||||
"content:beforeSave": createTestHook("only-plugin", handler),
|
||||
},
|
||||
});
|
||||
|
||||
// Pipeline with the plugin
|
||||
const pipeline1 = createHookPipeline([plugin], { db });
|
||||
expect(pipeline1.hasHooks("content:beforeSave")).toBe(true);
|
||||
|
||||
// Disable it: rebuild with empty list
|
||||
const pipeline2 = createHookPipeline([], { db });
|
||||
expect(pipeline2.hasHooks("content:beforeSave")).toBe(false);
|
||||
expect(pipeline2.getHookCount("content:beforeSave")).toBe(0);
|
||||
});
|
||||
|
||||
it("lifecycle hooks for disabled plugin are excluded from pipeline", async () => {
|
||||
const installHandler = vi.fn();
|
||||
const activateHandler = vi.fn();
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "lifecycle-plugin",
|
||||
hooks: {
|
||||
"plugin:install": createTestHook("lifecycle-plugin", installHandler),
|
||||
"plugin:activate": createTestHook("lifecycle-plugin", activateHandler),
|
||||
},
|
||||
});
|
||||
|
||||
// Pipeline with plugin
|
||||
const pipeline1 = createHookPipeline([plugin], { db });
|
||||
expect(pipeline1.hasHooks("plugin:install")).toBe(true);
|
||||
expect(pipeline1.hasHooks("plugin:activate")).toBe(true);
|
||||
|
||||
// Pipeline without plugin (disabled)
|
||||
const pipeline2 = createHookPipeline([], { db });
|
||||
expect(pipeline2.hasHooks("plugin:install")).toBe(false);
|
||||
expect(pipeline2.hasHooks("plugin:activate")).toBe(false);
|
||||
});
|
||||
|
||||
it("plugin:activate fires when runPluginActivate is called after pipeline rebuild", async () => {
|
||||
const activateHandler = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "my-plugin",
|
||||
hooks: {
|
||||
"plugin:activate": createTestHook("my-plugin", activateHandler),
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate enabling: rebuild pipeline with plugin included, then invoke activate
|
||||
const pipeline = createHookPipeline([plugin], { db });
|
||||
await pipeline.runPluginActivate("my-plugin");
|
||||
|
||||
expect(activateHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("plugin:deactivate fires when runPluginDeactivate is called before pipeline rebuild", async () => {
|
||||
const deactivateHandler = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const plugin = createTestPlugin({
|
||||
id: "my-plugin",
|
||||
hooks: {
|
||||
"plugin:deactivate": createTestHook("my-plugin", deactivateHandler),
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate disabling: invoke deactivate on the current pipeline, then rebuild without the plugin
|
||||
const pipeline = createHookPipeline([plugin], { db });
|
||||
await pipeline.runPluginDeactivate("my-plugin");
|
||||
|
||||
expect(deactivateHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rebuild without the plugin — hook should no longer be registered
|
||||
const disabledPipeline = createHookPipeline([], { db });
|
||||
expect(disabledPipeline.hasHooks("plugin:deactivate")).toBe(false);
|
||||
});
|
||||
|
||||
it("plugin:activate only fires for the targeted plugin, not others", async () => {
|
||||
const activateA = vi.fn().mockResolvedValue(undefined);
|
||||
const activateB = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
const pluginA = createTestPlugin({
|
||||
id: "plugin-a",
|
||||
hooks: {
|
||||
"plugin:activate": createTestHook("plugin-a", activateA),
|
||||
},
|
||||
});
|
||||
const pluginB = createTestPlugin({
|
||||
id: "plugin-b",
|
||||
hooks: {
|
||||
"plugin:activate": createTestHook("plugin-b", activateB),
|
||||
},
|
||||
});
|
||||
|
||||
const pipeline = createHookPipeline([pluginA, pluginB], { db });
|
||||
|
||||
// Enabling only plugin-a should not fire plugin-b's activate
|
||||
await pipeline.runPluginActivate("plugin-a");
|
||||
|
||||
expect(activateA).toHaveBeenCalledTimes(1);
|
||||
expect(activateB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
443
packages/core/tests/unit/plugins/plugin-storage.test.ts
Normal file
443
packages/core/tests/unit/plugins/plugin-storage.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { PluginStorageRepository } from "../../../src/database/repositories/plugin-storage.js";
|
||||
import type { Database } from "../../../src/database/types.js";
|
||||
import { IdentifierError } from "../../../src/database/validate.js";
|
||||
import { StorageQueryError } from "../../../src/plugins/storage-query.js";
|
||||
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
interface TestDocument {
|
||||
title: string;
|
||||
status: string;
|
||||
count: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
describe("PluginStorageRepository", () => {
|
||||
let db: Kysely<Database>;
|
||||
let repo: PluginStorageRepository<TestDocument>;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await setupTestDatabase();
|
||||
repo = new PluginStorageRepository<TestDocument>(db, "test-plugin", "items", [
|
||||
"status",
|
||||
"count",
|
||||
["status", "createdAt"],
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await teardownTestDatabase(db);
|
||||
});
|
||||
|
||||
describe("get()", () => {
|
||||
it("should return null for non-existent document", async () => {
|
||||
const result = await repo.get("non-existent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return document after put", async () => {
|
||||
const doc: TestDocument = {
|
||||
title: "Test",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
};
|
||||
|
||||
await repo.put("doc1", doc);
|
||||
const result = await repo.get("doc1");
|
||||
|
||||
expect(result).toEqual(doc);
|
||||
});
|
||||
});
|
||||
|
||||
describe("put()", () => {
|
||||
it("should store new document", async () => {
|
||||
const doc: TestDocument = {
|
||||
title: "Test",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
};
|
||||
|
||||
await repo.put("doc1", doc);
|
||||
|
||||
const result = await repo.get("doc1");
|
||||
expect(result).toEqual(doc);
|
||||
});
|
||||
|
||||
it("should update existing document", async () => {
|
||||
const doc: TestDocument = {
|
||||
title: "Test",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
};
|
||||
|
||||
await repo.put("doc1", doc);
|
||||
|
||||
const updatedDoc = { ...doc, status: "inactive", count: 10 };
|
||||
await repo.put("doc1", updatedDoc);
|
||||
|
||||
const result = await repo.get("doc1");
|
||||
expect(result).toEqual(updatedDoc);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete()", () => {
|
||||
it("should return false for non-existent document", async () => {
|
||||
const result = await repo.delete("non-existent");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should delete existing document and return true", async () => {
|
||||
await repo.put("doc1", {
|
||||
title: "Test",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
});
|
||||
|
||||
const result = await repo.delete("doc1");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const doc = await repo.get("doc1");
|
||||
expect(doc).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exists()", () => {
|
||||
it("should return false for non-existent document", async () => {
|
||||
const result = await repo.exists("non-existent");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for existing document", async () => {
|
||||
await repo.put("doc1", {
|
||||
title: "Test",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
});
|
||||
|
||||
const result = await repo.exists("doc1");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMany()", () => {
|
||||
it("should return empty map for empty ids", async () => {
|
||||
const result = await repo.getMany([]);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should return only existing documents", async () => {
|
||||
await repo.put("doc1", {
|
||||
title: "Test 1",
|
||||
status: "active",
|
||||
count: 1,
|
||||
createdAt: "2024-01-01",
|
||||
});
|
||||
await repo.put("doc2", {
|
||||
title: "Test 2",
|
||||
status: "active",
|
||||
count: 2,
|
||||
createdAt: "2024-01-02",
|
||||
});
|
||||
|
||||
const result = await repo.getMany(["doc1", "doc2", "doc3"]);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get("doc1")?.title).toBe("Test 1");
|
||||
expect(result.get("doc2")?.title).toBe("Test 2");
|
||||
expect(result.has("doc3")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("putMany()", () => {
|
||||
it("should handle empty array", async () => {
|
||||
await repo.putMany([]);
|
||||
// Should not throw
|
||||
});
|
||||
|
||||
it("should store multiple documents atomically", async () => {
|
||||
await repo.putMany([
|
||||
{
|
||||
id: "doc1",
|
||||
data: {
|
||||
title: "Test 1",
|
||||
status: "active",
|
||||
count: 1,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "doc2",
|
||||
data: {
|
||||
title: "Test 2",
|
||||
status: "inactive",
|
||||
count: 2,
|
||||
createdAt: "2024-01-02",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await repo.exists("doc1")).toBe(true);
|
||||
expect(await repo.exists("doc2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMany()", () => {
|
||||
it("should return 0 for empty ids", async () => {
|
||||
const count = await repo.deleteMany([]);
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("should delete multiple documents and return count", async () => {
|
||||
await repo.putMany([
|
||||
{
|
||||
id: "doc1",
|
||||
data: {
|
||||
title: "Test 1",
|
||||
status: "active",
|
||||
count: 1,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "doc2",
|
||||
data: {
|
||||
title: "Test 2",
|
||||
status: "active",
|
||||
count: 2,
|
||||
createdAt: "2024-01-02",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "doc3",
|
||||
data: {
|
||||
title: "Test 3",
|
||||
status: "active",
|
||||
count: 3,
|
||||
createdAt: "2024-01-03",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const count = await repo.deleteMany(["doc1", "doc2"]);
|
||||
|
||||
expect(count).toBe(2);
|
||||
expect(await repo.exists("doc1")).toBe(false);
|
||||
expect(await repo.exists("doc2")).toBe(false);
|
||||
expect(await repo.exists("doc3")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("query()", () => {
|
||||
beforeEach(async () => {
|
||||
// Setup test data
|
||||
await repo.putMany([
|
||||
{
|
||||
id: "doc1",
|
||||
data: {
|
||||
title: "Alpha",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "doc2",
|
||||
data: {
|
||||
title: "Beta",
|
||||
status: "active",
|
||||
count: 10,
|
||||
createdAt: "2024-01-02",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "doc3",
|
||||
data: {
|
||||
title: "Gamma",
|
||||
status: "inactive",
|
||||
count: 15,
|
||||
createdAt: "2024-01-03",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return all documents when no filter", async () => {
|
||||
const result = await repo.query();
|
||||
expect(result.items).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should filter by equality", async () => {
|
||||
const result = await repo.query({
|
||||
where: { status: "active" },
|
||||
});
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items.every((i) => i.data.status === "active")).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by range (gte)", async () => {
|
||||
const result = await repo.query({
|
||||
where: { count: { gte: 10 } },
|
||||
});
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items.every((i) => i.data.count >= 10)).toBe(true);
|
||||
});
|
||||
|
||||
it("should filter by range (lt)", async () => {
|
||||
const result = await repo.query({
|
||||
where: { count: { lt: 15 } },
|
||||
});
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items.every((i) => i.data.count < 15)).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw when querying non-indexed field", async () => {
|
||||
await expect(
|
||||
repo.query({
|
||||
where: { title: "Alpha" },
|
||||
}),
|
||||
).rejects.toThrow(StorageQueryError);
|
||||
});
|
||||
|
||||
it("should reject malicious orderBy field names (SQL injection defense)", async () => {
|
||||
// Create a repo that declares a malicious index name to bypass the
|
||||
// "field must be indexed" check and hit the jsonExtract validation
|
||||
const evilRepo = new PluginStorageRepository<TestDocument>(db, "test-plugin", "items", [
|
||||
"'); DROP TABLE _plugin_storage--",
|
||||
]);
|
||||
|
||||
await expect(
|
||||
evilRepo.query({
|
||||
orderBy: { "'); DROP TABLE _plugin_storage--": "asc" },
|
||||
}),
|
||||
).rejects.toThrow(IdentifierError);
|
||||
});
|
||||
|
||||
it("should respect limit", async () => {
|
||||
const result = await repo.query({ limit: 2 });
|
||||
expect(result.items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should provide cursor for pagination", async () => {
|
||||
const result = await repo.query({ limit: 2 });
|
||||
expect(result.cursor).toBeDefined();
|
||||
});
|
||||
|
||||
it("should not provide cursor when no more results", async () => {
|
||||
const result = await repo.query({ limit: 10 });
|
||||
expect(result.cursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should paginate using cursor", async () => {
|
||||
const page1 = await repo.query({ limit: 2 });
|
||||
expect(page1.items).toHaveLength(2);
|
||||
|
||||
const page2 = await repo.query({ limit: 2, cursor: page1.cursor });
|
||||
expect(page2.items).toHaveLength(1);
|
||||
expect(page2.cursor).toBeUndefined();
|
||||
|
||||
// Ensure no duplicates
|
||||
const allIds = [...page1.items, ...page2.items].map((i) => i.id);
|
||||
expect(new Set(allIds).size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("count()", () => {
|
||||
beforeEach(async () => {
|
||||
await repo.putMany([
|
||||
{
|
||||
id: "doc1",
|
||||
data: {
|
||||
title: "Alpha",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "doc2",
|
||||
data: {
|
||||
title: "Beta",
|
||||
status: "active",
|
||||
count: 10,
|
||||
createdAt: "2024-01-02",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "doc3",
|
||||
data: {
|
||||
title: "Gamma",
|
||||
status: "inactive",
|
||||
count: 15,
|
||||
createdAt: "2024-01-03",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should count all documents when no filter", async () => {
|
||||
const count = await repo.count();
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it("should count with filter", async () => {
|
||||
const count = await repo.count({ status: "active" });
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
it("should return 0 for no matches", async () => {
|
||||
const count = await repo.count({ count: { gt: 100 } });
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("should throw when counting on non-indexed field", async () => {
|
||||
await expect(repo.count({ title: "Alpha" })).rejects.toThrow(StorageQueryError);
|
||||
});
|
||||
});
|
||||
|
||||
// Note: v2 API removed async iterator list() in favor of paginated query()
|
||||
// Use query() with cursor for iteration
|
||||
|
||||
describe("plugin isolation", () => {
|
||||
it("should not see documents from other plugins", async () => {
|
||||
const otherRepo = new PluginStorageRepository<TestDocument>(db, "other-plugin", "items", [
|
||||
"status",
|
||||
]);
|
||||
|
||||
await repo.put("doc1", {
|
||||
title: "Test",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
});
|
||||
|
||||
const result = await otherRepo.get("doc1");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should not see documents from other collections", async () => {
|
||||
const otherRepo = new PluginStorageRepository<TestDocument>(
|
||||
db,
|
||||
"test-plugin",
|
||||
"other-collection",
|
||||
["status"],
|
||||
);
|
||||
|
||||
await repo.put("doc1", {
|
||||
title: "Test",
|
||||
status: "active",
|
||||
count: 5,
|
||||
createdAt: "2024-01-01",
|
||||
});
|
||||
|
||||
const result = await otherRepo.get("doc1");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
625
packages/core/tests/unit/plugins/request-meta.test.ts
Normal file
625
packages/core/tests/unit/plugins/request-meta.test.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
/**
|
||||
* Request Metadata Extraction Tests
|
||||
*
|
||||
* Tests for extractRequestMeta():
|
||||
* - IP resolution: CF-Connecting-IP (only with cf object), X-Forwarded-For fallback, null
|
||||
* - IP validation: rejects non-IP values (XSS payloads, garbage)
|
||||
* - Geo extraction from Cloudflare `cf` object on request
|
||||
* - User agent and referer header reads (trimmed)
|
||||
* - IPv6 support
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { _resetTrustedProxyHeadersCache } from "../../../src/auth/trusted-proxy.js";
|
||||
import {
|
||||
extractRequestMeta,
|
||||
sanitizeHeadersForSandbox,
|
||||
} from "../../../src/plugins/request-meta.js";
|
||||
|
||||
// Keep the env-derived trusted-header cache from leaking between tests
|
||||
// (and between this file and others in the same vitest worker).
|
||||
const ORIGINAL_TRUSTED_ENV = process.env.EMDASH_TRUSTED_PROXY_HEADERS;
|
||||
beforeEach(() => {
|
||||
delete process.env.EMDASH_TRUSTED_PROXY_HEADERS;
|
||||
_resetTrustedProxyHeadersCache();
|
||||
});
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_TRUSTED_ENV === undefined) {
|
||||
delete process.env.EMDASH_TRUSTED_PROXY_HEADERS;
|
||||
} else {
|
||||
process.env.EMDASH_TRUSTED_PROXY_HEADERS = ORIGINAL_TRUSTED_ENV;
|
||||
}
|
||||
_resetTrustedProxyHeadersCache();
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper to create a Request with optional headers and cf properties.
|
||||
*/
|
||||
function createRequest(
|
||||
opts: {
|
||||
headers?: Record<string, string>;
|
||||
cf?: { country?: string; region?: string; city?: string };
|
||||
} = {},
|
||||
): Request {
|
||||
const req = new Request("http://localhost/test", {
|
||||
headers: opts.headers,
|
||||
});
|
||||
|
||||
// Attach cf object if provided (simulates Cloudflare Workers runtime)
|
||||
if (opts.cf) {
|
||||
(req as unknown as { cf: typeof opts.cf }).cf = opts.cf;
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
describe("extractRequestMeta", () => {
|
||||
describe("IP resolution", () => {
|
||||
it("trusts CF-Connecting-IP when cf object is present", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"cf-connecting-ip": "1.2.3.4",
|
||||
"x-forwarded-for": "5.6.7.8, 9.10.11.12",
|
||||
},
|
||||
cf: { country: "US" },
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("1.2.3.4");
|
||||
});
|
||||
|
||||
it("ignores CF-Connecting-IP and XFF when no cf object (spoofed headers)", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"cf-connecting-ip": "1.2.3.4",
|
||||
"x-forwarded-for": "5.6.7.8, 9.10.11.12",
|
||||
},
|
||||
// No cf object — not on Cloudflare, XFF is untrusted
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
// Neither CF-Connecting-IP nor XFF should be trusted without cf object
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when CF-Connecting-IP is spoofed and no XFF", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"cf-connecting-ip": "1.2.3.4",
|
||||
},
|
||||
// No cf object
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to X-Forwarded-For when behind Cloudflare (cf object present)", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"x-forwarded-for": "5.6.7.8, 9.10.11.12",
|
||||
},
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("ignores X-Forwarded-For without cf object (standalone deployment)", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"x-forwarded-for": "5.6.7.8, 9.10.11.12",
|
||||
},
|
||||
// No cf object — standalone deployment, XFF is spoofable
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("handles single IP in X-Forwarded-For with cf object", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"x-forwarded-for": "5.6.7.8",
|
||||
},
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("trims whitespace from X-Forwarded-For entries", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"x-forwarded-for": " 5.6.7.8 , 9.10.11.12",
|
||||
},
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("trims whitespace from CF-Connecting-IP", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"cf-connecting-ip": " 1.2.3.4 ",
|
||||
},
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("1.2.3.4");
|
||||
});
|
||||
|
||||
it("returns null when no IP headers present", () => {
|
||||
const req = createRequest();
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty CF-Connecting-IP with no X-Forwarded-For", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"cf-connecting-ip": "",
|
||||
},
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to X-Forwarded-For when CF-Connecting-IP is empty", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"cf-connecting-ip": "",
|
||||
"x-forwarded-for": "5.6.7.8",
|
||||
},
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("5.6.7.8");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv6 support", () => {
|
||||
it("handles IPv6 loopback in X-Forwarded-For with cf object", () => {
|
||||
const req = createRequest({
|
||||
headers: { "x-forwarded-for": "::1" },
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("::1");
|
||||
});
|
||||
|
||||
it("handles full IPv6 address in X-Forwarded-For with cf object", () => {
|
||||
const req = createRequest({
|
||||
headers: { "x-forwarded-for": "2001:db8::1, 10.0.0.1" },
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("2001:db8::1");
|
||||
});
|
||||
|
||||
it("handles IPv6 in CF-Connecting-IP with cf object", () => {
|
||||
const req = createRequest({
|
||||
headers: { "cf-connecting-ip": "2001:db8:85a3::8a2e:370:7334" },
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBe("2001:db8:85a3::8a2e:370:7334");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IP validation", () => {
|
||||
it("rejects XSS payload in X-Forwarded-For", () => {
|
||||
const req = createRequest({
|
||||
headers: { "x-forwarded-for": "<script>alert(1)</script>" },
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects non-IP text in X-Forwarded-For", () => {
|
||||
const req = createRequest({
|
||||
headers: { "x-forwarded-for": "not-an-ip, 1.2.3.4" },
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
// First entry is "not-an-ip" which fails validation
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects XSS payload in CF-Connecting-IP", () => {
|
||||
const req = createRequest({
|
||||
headers: { "cf-connecting-ip": "<img onerror=alert(1)>" },
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects empty-looking IP values with only whitespace", () => {
|
||||
const req = createRequest({
|
||||
headers: { "x-forwarded-for": " " },
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("geo extraction", () => {
|
||||
it("extracts geo from cf object", () => {
|
||||
const req = createRequest({
|
||||
cf: { country: "US", region: "CA", city: "San Francisco" },
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.geo).toEqual({
|
||||
country: "US",
|
||||
region: "CA",
|
||||
city: "San Francisco",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null geo when no cf object", () => {
|
||||
const req = createRequest();
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.geo).toBeNull();
|
||||
});
|
||||
|
||||
it("handles partial geo data", () => {
|
||||
const req = createRequest({
|
||||
cf: { country: "GB" },
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.geo).toEqual({
|
||||
country: "GB",
|
||||
region: null,
|
||||
city: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null geo when cf object has no geo fields", () => {
|
||||
const req = createRequest({
|
||||
cf: {},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.geo).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("user agent", () => {
|
||||
it("extracts user agent from header", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"user-agent": "Mozilla/5.0 (Test)",
|
||||
},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.userAgent).toBe("Mozilla/5.0 (Test)");
|
||||
});
|
||||
|
||||
it("returns null when no user agent header", () => {
|
||||
const req = createRequest();
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.userAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty user agent header", () => {
|
||||
const req = createRequest({
|
||||
headers: { "user-agent": "" },
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.userAgent).toBeNull();
|
||||
});
|
||||
|
||||
it("trims whitespace from user agent", () => {
|
||||
const req = createRequest({
|
||||
headers: { "user-agent": " TestBot/1.0 " },
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.userAgent).toBe("TestBot/1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("referer", () => {
|
||||
it("extracts referer from header", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
referer: "https://example.com/page",
|
||||
},
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.referer).toBe("https://example.com/page");
|
||||
});
|
||||
|
||||
it("returns null when no referer header", () => {
|
||||
const req = createRequest();
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.referer).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty referer header", () => {
|
||||
const req = createRequest({
|
||||
headers: { referer: "" },
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.referer).toBeNull();
|
||||
});
|
||||
|
||||
it("trims whitespace from referer", () => {
|
||||
const req = createRequest({
|
||||
headers: { referer: " https://example.com " },
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta.referer).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeHeadersForSandbox", () => {
|
||||
it("strips cookie header", () => {
|
||||
const headers = new Headers({ cookie: "session=abc123", "content-type": "text/html" });
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(result).not.toHaveProperty("cookie");
|
||||
expect(result["content-type"]).toBe("text/html");
|
||||
});
|
||||
|
||||
it("strips set-cookie header", () => {
|
||||
const headers = new Headers({ "set-cookie": "token=xyz", accept: "application/json" });
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(result).not.toHaveProperty("set-cookie");
|
||||
expect(result.accept).toBe("application/json");
|
||||
});
|
||||
|
||||
it("strips authorization header", () => {
|
||||
const headers = new Headers({ authorization: "Bearer secret-token", host: "example.com" });
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(result).not.toHaveProperty("authorization");
|
||||
expect(result.host).toBe("example.com");
|
||||
});
|
||||
|
||||
it("strips proxy-authorization header", () => {
|
||||
const headers = new Headers({ "proxy-authorization": "Basic abc", host: "example.com" });
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(result).not.toHaveProperty("proxy-authorization");
|
||||
});
|
||||
|
||||
it("strips Cloudflare Access headers", () => {
|
||||
const headers = new Headers({
|
||||
"cf-access-jwt-assertion": "jwt-token",
|
||||
"cf-access-client-id": "client-id",
|
||||
"cf-access-client-secret": "client-secret",
|
||||
"cf-ray": "abc123",
|
||||
});
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(result).not.toHaveProperty("cf-access-jwt-assertion");
|
||||
expect(result).not.toHaveProperty("cf-access-client-id");
|
||||
expect(result).not.toHaveProperty("cf-access-client-secret");
|
||||
expect(result["cf-ray"]).toBe("abc123");
|
||||
});
|
||||
|
||||
it("strips x-emdash-request CSRF header", () => {
|
||||
const headers = new Headers({ "x-emdash-request": "1", "x-custom": "safe" });
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(result).not.toHaveProperty("x-emdash-request");
|
||||
expect(result["x-custom"]).toBe("safe");
|
||||
});
|
||||
|
||||
it("passes through safe headers unchanged", () => {
|
||||
const headers = new Headers({
|
||||
"content-type": "application/json",
|
||||
accept: "text/html",
|
||||
"user-agent": "TestBot/1.0",
|
||||
"x-forwarded-for": "1.2.3.4",
|
||||
"cf-connecting-ip": "5.6.7.8",
|
||||
});
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(result["content-type"]).toBe("application/json");
|
||||
expect(result.accept).toBe("text/html");
|
||||
expect(result["user-agent"]).toBe("TestBot/1.0");
|
||||
expect(result["x-forwarded-for"]).toBe("1.2.3.4");
|
||||
expect(result["cf-connecting-ip"]).toBe("5.6.7.8");
|
||||
});
|
||||
|
||||
it("returns empty object for headers that are all sensitive", () => {
|
||||
const headers = new Headers({
|
||||
cookie: "session=abc",
|
||||
authorization: "Bearer token",
|
||||
});
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns empty object for empty headers", () => {
|
||||
const headers = new Headers();
|
||||
const result = sanitizeHeadersForSandbox(headers);
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full extraction", () => {
|
||||
it("extracts all metadata from a fully-populated request", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"cf-connecting-ip": "203.0.113.50",
|
||||
"user-agent": "TestBot/1.0",
|
||||
referer: "https://example.com",
|
||||
},
|
||||
cf: { country: "DE", region: "BE", city: "Berlin" },
|
||||
});
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta).toEqual({
|
||||
ip: "203.0.113.50",
|
||||
userAgent: "TestBot/1.0",
|
||||
referer: "https://example.com",
|
||||
geo: {
|
||||
country: "DE",
|
||||
region: "BE",
|
||||
city: "Berlin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns all nulls for a bare request", () => {
|
||||
const req = createRequest();
|
||||
|
||||
const meta = extractRequestMeta(req);
|
||||
expect(meta).toEqual({
|
||||
ip: null,
|
||||
userAgent: null,
|
||||
referer: null,
|
||||
geo: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// Trusted proxy headers — operator-declared headers for self-hosted
|
||||
// deployments behind a reverse proxy they control.
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
describe("trusted proxy headers", () => {
|
||||
it("reads the IP from a trusted header on a non-CF deployment", () => {
|
||||
const req = createRequest({
|
||||
headers: { "x-real-ip": "203.0.113.50" },
|
||||
});
|
||||
const meta = extractRequestMeta(req, { trustedProxyHeaders: ["x-real-ip"] });
|
||||
expect(meta.ip).toBe("203.0.113.50");
|
||||
});
|
||||
|
||||
it("tries trusted headers in order", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"x-real-ip": "203.0.113.50",
|
||||
"fly-client-ip": "198.51.100.7",
|
||||
},
|
||||
});
|
||||
const meta = extractRequestMeta(req, {
|
||||
trustedProxyHeaders: ["fly-client-ip", "x-real-ip"],
|
||||
});
|
||||
expect(meta.ip).toBe("198.51.100.7");
|
||||
});
|
||||
|
||||
it("falls through to the next trusted header when the first is missing", () => {
|
||||
const req = createRequest({
|
||||
headers: { "x-real-ip": "203.0.113.50" },
|
||||
});
|
||||
const meta = extractRequestMeta(req, {
|
||||
trustedProxyHeaders: ["fly-client-ip", "x-real-ip"],
|
||||
});
|
||||
expect(meta.ip).toBe("203.0.113.50");
|
||||
});
|
||||
|
||||
it("treats trusted XFF-style headers as comma-separated and takes the first entry", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"x-forwarded-for": "203.0.113.50, 10.0.0.1, 10.0.0.2",
|
||||
},
|
||||
});
|
||||
const meta = extractRequestMeta(req, {
|
||||
trustedProxyHeaders: ["x-forwarded-for"],
|
||||
});
|
||||
expect(meta.ip).toBe("203.0.113.50");
|
||||
});
|
||||
|
||||
it("rejects non-IP-shaped values in trusted headers", () => {
|
||||
const req = createRequest({
|
||||
headers: { "x-real-ip": "<script>alert(1)</script>" },
|
||||
});
|
||||
const meta = extractRequestMeta(req, { trustedProxyHeaders: ["x-real-ip"] });
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("matches header names case-insensitively", () => {
|
||||
const req = createRequest({
|
||||
headers: { "X-Real-IP": "203.0.113.50" },
|
||||
});
|
||||
const meta = extractRequestMeta(req, { trustedProxyHeaders: ["x-real-ip"] });
|
||||
expect(meta.ip).toBe("203.0.113.50");
|
||||
});
|
||||
|
||||
it("does not read from headers that are not on the trusted list", () => {
|
||||
// "x-client-ip" is not declared trusted — must not be used.
|
||||
const req = createRequest({
|
||||
headers: { "x-client-ip": "203.0.113.50" },
|
||||
});
|
||||
const meta = extractRequestMeta(req, { trustedProxyHeaders: ["x-real-ip"] });
|
||||
expect(meta.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("CF-Connecting-IP wins over trusted headers on Cloudflare", () => {
|
||||
// On CF, cf-connecting-ip is cryptographically trustworthy (CF edge
|
||||
// overwrites any client-supplied value). Operator-declared trusted
|
||||
// headers only apply as a fallback, not an override, so a wrong
|
||||
// entry in the config can't regress CF deployments.
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"cf-connecting-ip": "1.1.1.1",
|
||||
"x-real-ip": "203.0.113.50",
|
||||
},
|
||||
cf: {},
|
||||
});
|
||||
const meta = extractRequestMeta(req, { trustedProxyHeaders: ["x-real-ip"] });
|
||||
expect(meta.ip).toBe("1.1.1.1");
|
||||
});
|
||||
|
||||
it("trusted headers are used as fallback when CF headers are absent", () => {
|
||||
const req = createRequest({
|
||||
headers: {
|
||||
"x-real-ip": "203.0.113.50",
|
||||
},
|
||||
cf: {},
|
||||
});
|
||||
const meta = extractRequestMeta(req, { trustedProxyHeaders: ["x-real-ip"] });
|
||||
expect(meta.ip).toBe("203.0.113.50");
|
||||
});
|
||||
|
||||
it("falls back to the CF path when no trusted header matches", () => {
|
||||
const req = createRequest({
|
||||
headers: { "cf-connecting-ip": "1.1.1.1" },
|
||||
cf: {},
|
||||
});
|
||||
const meta = extractRequestMeta(req, { trustedProxyHeaders: ["x-real-ip"] });
|
||||
expect(meta.ip).toBe("1.1.1.1");
|
||||
});
|
||||
|
||||
it("validates a pre-resolved string[] of trusted headers", () => {
|
||||
// Passing the array form (used by the plugin runtime with a list
|
||||
// pre-resolved from the config) must not trust an invalid header
|
||||
// name — headers.get() would throw a TypeError.
|
||||
const req = createRequest({
|
||||
headers: { "x-real-ip": "203.0.113.50" },
|
||||
});
|
||||
const meta = extractRequestMeta(req, ["bad name", " x-real-ip ", "bad:colon"]);
|
||||
expect(meta.ip).toBe("203.0.113.50");
|
||||
});
|
||||
});
|
||||
});
|
||||
484
packages/core/tests/unit/plugins/routes.test.ts
Normal file
484
packages/core/tests/unit/plugins/routes.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
251
packages/core/tests/unit/plugins/standard-format.test.ts
Normal file
251
packages/core/tests/unit/plugins/standard-format.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Standard Plugin Format Tests
|
||||
*
|
||||
* Tests the definePlugin() standard format overload, isStandardPluginDefinition(),
|
||||
* and the generatePluginsModule() standard format handling.
|
||||
*
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js";
|
||||
import { generatePluginsModule } from "../../../src/astro/integration/virtual-modules.js";
|
||||
import { definePlugin } from "../../../src/plugins/define-plugin.js";
|
||||
import { isStandardPluginDefinition } from "../../../src/plugins/types.js";
|
||||
|
||||
describe("definePlugin() standard format overload", () => {
|
||||
it("returns the same object (identity function)", () => {
|
||||
const def = {
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler: async () => {},
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
status: {
|
||||
handler: async () => ({ ok: true }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = definePlugin(def);
|
||||
|
||||
// Standard format: definePlugin is an identity function
|
||||
expect(result).toBe(def);
|
||||
});
|
||||
|
||||
it("accepts hooks-only definition", () => {
|
||||
const def = {
|
||||
hooks: {
|
||||
"content:beforeSave": async () => {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = definePlugin(def);
|
||||
|
||||
expect(result).toBe(def);
|
||||
expect(result.hooks).toBeDefined();
|
||||
});
|
||||
|
||||
it("accepts routes-only definition", () => {
|
||||
const def = {
|
||||
routes: {
|
||||
ping: {
|
||||
handler: async () => ({ pong: true }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = definePlugin(def);
|
||||
|
||||
expect(result).toBe(def);
|
||||
expect(result.routes).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws on empty definition (no hooks or routes)", () => {
|
||||
// An empty object has no id/version, so it's treated as standard format,
|
||||
// but standard format requires at least hooks or routes
|
||||
expect(() => definePlugin({})).toThrow(
|
||||
"Standard plugin format requires at least `hooks` or `routes`",
|
||||
);
|
||||
});
|
||||
|
||||
it("still works with native format (id + version)", () => {
|
||||
const handler = vi.fn();
|
||||
const result = definePlugin({
|
||||
id: "native-plugin",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": handler,
|
||||
},
|
||||
});
|
||||
|
||||
// Native format: returns a ResolvedPlugin
|
||||
expect(result.id).toBe("native-plugin");
|
||||
expect(result.version).toBe("1.0.0");
|
||||
expect(result.hooks["content:beforeSave"]).toBeDefined();
|
||||
expect(result.hooks["content:beforeSave"]!.pluginId).toBe("native-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStandardPluginDefinition()", () => {
|
||||
it("returns true for { hooks: {} }", () => {
|
||||
expect(isStandardPluginDefinition({ hooks: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for { routes: {} }", () => {
|
||||
expect(isStandardPluginDefinition({ routes: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for { hooks: {}, routes: {} }", () => {
|
||||
expect(isStandardPluginDefinition({ hooks: {}, routes: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isStandardPluginDefinition(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined", () => {
|
||||
expect(isStandardPluginDefinition(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a string", () => {
|
||||
expect(isStandardPluginDefinition("hello")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a native plugin definition (has id + version)", () => {
|
||||
expect(
|
||||
isStandardPluginDefinition({
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
hooks: {},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty object (no hooks or routes)", () => {
|
||||
// Empty object has neither hooks/routes NOR id/version
|
||||
// So hasPluginShape is false
|
||||
expect(isStandardPluginDefinition({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePluginsModule() standard format", () => {
|
||||
it("generates adapter import for standard-format plugins", () => {
|
||||
const descriptors: PluginDescriptor[] = [
|
||||
{
|
||||
id: "my-standard-plugin",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@my/standard-plugin",
|
||||
format: "standard",
|
||||
},
|
||||
];
|
||||
|
||||
const code = generatePluginsModule(descriptors);
|
||||
|
||||
expect(code).toContain("adaptSandboxEntry");
|
||||
expect(code).toContain('from "emdash/plugins/adapt-sandbox-entry"');
|
||||
expect(code).toContain('import pluginDef0 from "@my/standard-plugin"');
|
||||
expect(code).toContain("adaptSandboxEntry(pluginDef0");
|
||||
});
|
||||
|
||||
it("generates createPlugin import for native-format plugins", () => {
|
||||
const descriptors: PluginDescriptor[] = [
|
||||
{
|
||||
id: "my-native-plugin",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@my/native-plugin",
|
||||
options: { debug: true },
|
||||
},
|
||||
];
|
||||
|
||||
const code = generatePluginsModule(descriptors);
|
||||
|
||||
expect(code).not.toContain("adaptSandboxEntry");
|
||||
expect(code).toContain('import { createPlugin as createPlugin0 } from "@my/native-plugin"');
|
||||
expect(code).toContain('createPlugin0({"debug":true})');
|
||||
});
|
||||
|
||||
it("handles mixed standard and native plugins", () => {
|
||||
const descriptors: PluginDescriptor[] = [
|
||||
{
|
||||
id: "native-plugin",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@my/native-plugin",
|
||||
options: {},
|
||||
},
|
||||
{
|
||||
id: "standard-plugin",
|
||||
version: "2.0.0",
|
||||
entrypoint: "@my/standard-plugin",
|
||||
format: "standard",
|
||||
capabilities: ["content:read"],
|
||||
},
|
||||
];
|
||||
|
||||
const code = generatePluginsModule(descriptors);
|
||||
|
||||
// Should have the adapter import (at least one standard plugin)
|
||||
expect(code).toContain("adaptSandboxEntry");
|
||||
|
||||
// Native plugin uses createPlugin
|
||||
expect(code).toContain('import { createPlugin as createPlugin0 } from "@my/native-plugin"');
|
||||
expect(code).toContain("createPlugin0(");
|
||||
|
||||
// Standard plugin uses default import + adapter
|
||||
expect(code).toContain('import pluginDef1 from "@my/standard-plugin"');
|
||||
expect(code).toContain("adaptSandboxEntry(pluginDef1");
|
||||
});
|
||||
|
||||
it("does not import adapter when all plugins are native", () => {
|
||||
const descriptors: PluginDescriptor[] = [
|
||||
{
|
||||
id: "native-1",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@my/native-1",
|
||||
options: {},
|
||||
},
|
||||
{
|
||||
id: "native-2",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@my/native-2",
|
||||
options: {},
|
||||
format: "native",
|
||||
},
|
||||
];
|
||||
|
||||
const code = generatePluginsModule(descriptors);
|
||||
|
||||
expect(code).not.toContain("adaptSandboxEntry");
|
||||
});
|
||||
|
||||
it("returns empty plugins array for no descriptors", () => {
|
||||
const code = generatePluginsModule([]);
|
||||
|
||||
expect(code).toBe("export const plugins = [];");
|
||||
});
|
||||
|
||||
it("serializes descriptor metadata for standard plugins", () => {
|
||||
const descriptors: PluginDescriptor[] = [
|
||||
{
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@my/plugin",
|
||||
format: "standard",
|
||||
capabilities: ["content:read", "network:request"],
|
||||
allowedHosts: ["api.example.com"],
|
||||
storage: { events: { indexes: ["timestamp"] } },
|
||||
adminPages: [{ path: "/settings", label: "Settings" }],
|
||||
},
|
||||
];
|
||||
|
||||
const code = generatePluginsModule(descriptors);
|
||||
|
||||
// The descriptor metadata should be serialized into the adapter call
|
||||
expect(code).toContain('"id":"my-plugin"');
|
||||
expect(code).toContain('"version":"1.0.0"');
|
||||
expect(code).toContain('"capabilities":["content:read","network:request"]');
|
||||
expect(code).toContain('"allowedHosts":["api.example.com"]');
|
||||
expect(code).toContain('"storage":{"events":{"indexes":["timestamp"]}}');
|
||||
});
|
||||
});
|
||||
276
packages/core/tests/unit/plugins/state.test.ts
Normal file
276
packages/core/tests/unit/plugins/state.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* PluginStateRepository Tests
|
||||
*
|
||||
* Tests the database-backed plugin state storage for:
|
||||
* - CRUD operations (get, getAll, upsert, delete)
|
||||
* - Enable/disable convenience methods
|
||||
* - Timestamp tracking
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { Kysely, SqliteDialect } from "kysely";
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import { runMigrations } from "../../../src/database/migrations/runner.js";
|
||||
import type { Database as DbSchema } from "../../../src/database/types.js";
|
||||
import { PluginStateRepository } from "../../../src/plugins/state.js";
|
||||
|
||||
describe("PluginStateRepository", () => {
|
||||
let db: Kysely<DbSchema>;
|
||||
let sqliteDb: Database.Database;
|
||||
let repo: PluginStateRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create in-memory SQLite database
|
||||
sqliteDb = new Database(":memory:");
|
||||
|
||||
db = new Kysely<DbSchema>({
|
||||
dialect: new SqliteDialect({
|
||||
database: sqliteDb,
|
||||
}),
|
||||
});
|
||||
|
||||
// Run migrations to create tables
|
||||
await runMigrations(db);
|
||||
|
||||
repo = new PluginStateRepository(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.destroy();
|
||||
sqliteDb.close();
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("returns null for non-existent plugin", async () => {
|
||||
const state = await repo.get("non-existent");
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
|
||||
it("returns state for existing plugin", async () => {
|
||||
// Insert directly
|
||||
await db
|
||||
.insertInto("_plugin_state")
|
||||
.values({
|
||||
plugin_id: "test-plugin",
|
||||
status: "active",
|
||||
version: "1.0.0",
|
||||
installed_at: new Date().toISOString(),
|
||||
activated_at: new Date().toISOString(),
|
||||
deactivated_at: null,
|
||||
data: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const state = await repo.get("test-plugin");
|
||||
|
||||
expect(state).not.toBeNull();
|
||||
expect(state!.pluginId).toBe("test-plugin");
|
||||
expect(state!.status).toBe("active");
|
||||
expect(state!.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("parses dates correctly", async () => {
|
||||
const now = new Date();
|
||||
await db
|
||||
.insertInto("_plugin_state")
|
||||
.values({
|
||||
plugin_id: "test-plugin",
|
||||
status: "inactive",
|
||||
version: "2.0.0",
|
||||
installed_at: now.toISOString(),
|
||||
activated_at: now.toISOString(),
|
||||
deactivated_at: now.toISOString(),
|
||||
data: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const state = await repo.get("test-plugin");
|
||||
|
||||
expect(state!.installedAt).toBeInstanceOf(Date);
|
||||
expect(state!.activatedAt).toBeInstanceOf(Date);
|
||||
expect(state!.deactivatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("handles null dates", async () => {
|
||||
await db
|
||||
.insertInto("_plugin_state")
|
||||
.values({
|
||||
plugin_id: "test-plugin",
|
||||
status: "inactive",
|
||||
version: "1.0.0",
|
||||
installed_at: new Date().toISOString(),
|
||||
activated_at: null,
|
||||
deactivated_at: null,
|
||||
data: null,
|
||||
})
|
||||
.execute();
|
||||
|
||||
const state = await repo.get("test-plugin");
|
||||
|
||||
expect(state!.activatedAt).toBeNull();
|
||||
expect(state!.deactivatedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAll", () => {
|
||||
it("returns empty array when no plugins", async () => {
|
||||
const states = await repo.getAll();
|
||||
expect(states).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all plugin states", async () => {
|
||||
await db
|
||||
.insertInto("_plugin_state")
|
||||
.values([
|
||||
{
|
||||
plugin_id: "plugin-a",
|
||||
status: "active",
|
||||
version: "1.0.0",
|
||||
installed_at: new Date().toISOString(),
|
||||
activated_at: new Date().toISOString(),
|
||||
deactivated_at: null,
|
||||
data: null,
|
||||
},
|
||||
{
|
||||
plugin_id: "plugin-b",
|
||||
status: "inactive",
|
||||
version: "2.0.0",
|
||||
installed_at: new Date().toISOString(),
|
||||
activated_at: null,
|
||||
deactivated_at: null,
|
||||
data: null,
|
||||
},
|
||||
])
|
||||
.execute();
|
||||
|
||||
const states = await repo.getAll();
|
||||
|
||||
expect(states).toHaveLength(2);
|
||||
expect(states.map((s) => s.pluginId).toSorted()).toEqual(["plugin-a", "plugin-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsert", () => {
|
||||
it("creates new state when plugin does not exist", async () => {
|
||||
const state = await repo.upsert("new-plugin", "1.0.0", "active");
|
||||
|
||||
expect(state.pluginId).toBe("new-plugin");
|
||||
expect(state.version).toBe("1.0.0");
|
||||
expect(state.status).toBe("active");
|
||||
expect(state.installedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("updates existing state", async () => {
|
||||
// Create initial state
|
||||
await repo.upsert("test-plugin", "1.0.0", "active");
|
||||
|
||||
// Update it
|
||||
const state = await repo.upsert("test-plugin", "1.1.0", "inactive");
|
||||
|
||||
expect(state.pluginId).toBe("test-plugin");
|
||||
expect(state.version).toBe("1.1.0");
|
||||
expect(state.status).toBe("inactive");
|
||||
});
|
||||
|
||||
it("sets activated_at when activating", async () => {
|
||||
// Create as inactive
|
||||
await repo.upsert("test-plugin", "1.0.0", "inactive");
|
||||
|
||||
// Activate
|
||||
const state = await repo.upsert("test-plugin", "1.0.0", "active");
|
||||
|
||||
expect(state.activatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("sets deactivated_at when deactivating", async () => {
|
||||
// Create as active
|
||||
await repo.upsert("test-plugin", "1.0.0", "active");
|
||||
|
||||
// Deactivate
|
||||
const state = await repo.upsert("test-plugin", "1.0.0", "inactive");
|
||||
|
||||
expect(state.deactivatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("does not change activated_at if already active", async () => {
|
||||
// Create as active
|
||||
const initial = await repo.upsert("test-plugin", "1.0.0", "active");
|
||||
const initialActivatedAt = initial.activatedAt!.getTime();
|
||||
|
||||
// Wait a bit then update version (still active)
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const updated = await repo.upsert("test-plugin", "1.1.0", "active");
|
||||
|
||||
// activated_at should be the same
|
||||
expect(updated.activatedAt!.getTime()).toBe(initialActivatedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enable", () => {
|
||||
it("creates active state for new plugin", async () => {
|
||||
const state = await repo.enable("new-plugin", "1.0.0");
|
||||
|
||||
expect(state.status).toBe("active");
|
||||
expect(state.activatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("activates inactive plugin", async () => {
|
||||
await repo.upsert("test-plugin", "1.0.0", "inactive");
|
||||
|
||||
const state = await repo.enable("test-plugin", "1.0.0");
|
||||
|
||||
expect(state.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("disable", () => {
|
||||
it("creates inactive state for new plugin", async () => {
|
||||
const state = await repo.disable("new-plugin", "1.0.0");
|
||||
|
||||
expect(state.status).toBe("inactive");
|
||||
expect(state.activatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("deactivates active plugin", async () => {
|
||||
await repo.upsert("test-plugin", "1.0.0", "active");
|
||||
|
||||
const state = await repo.disable("test-plugin", "1.0.0");
|
||||
|
||||
expect(state.status).toBe("inactive");
|
||||
expect(state.deactivatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("returns false for non-existent plugin", async () => {
|
||||
const deleted = await repo.delete("non-existent");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes existing plugin and returns true", async () => {
|
||||
await repo.upsert("test-plugin", "1.0.0", "active");
|
||||
|
||||
const deleted = await repo.delete("test-plugin");
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
// Verify it's gone
|
||||
const state = await repo.get("test-plugin");
|
||||
expect(state).toBeNull();
|
||||
});
|
||||
|
||||
it("only deletes specified plugin", async () => {
|
||||
await repo.upsert("plugin-a", "1.0.0", "active");
|
||||
await repo.upsert("plugin-b", "1.0.0", "active");
|
||||
|
||||
await repo.delete("plugin-a");
|
||||
|
||||
const stateA = await repo.get("plugin-a");
|
||||
const stateB = await repo.get("plugin-b");
|
||||
|
||||
expect(stateA).toBeNull();
|
||||
expect(stateB).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
packages/core/tests/unit/plugins/storage-indexes.test.ts
Normal file
106
packages/core/tests/unit/plugins/storage-indexes.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { IdentifierError } from "../../../src/database/validate.js";
|
||||
import {
|
||||
generateIndexName,
|
||||
generateCreateIndexSql,
|
||||
generateDropIndexSql,
|
||||
normalizeIndexes,
|
||||
} from "../../../src/plugins/storage-indexes.js";
|
||||
import { createTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("storage-indexes", () => {
|
||||
const db = createTestDatabase();
|
||||
describe("generateIndexName", () => {
|
||||
it("should generate deterministic index name for single field", () => {
|
||||
const name = generateIndexName("my-plugin", "items", ["status"]);
|
||||
expect(name).toBe("idx_plugin_my-plugin_items_status");
|
||||
});
|
||||
|
||||
it("should generate deterministic index name for multiple fields", () => {
|
||||
const name = generateIndexName("my-plugin", "items", ["status", "createdAt"]);
|
||||
expect(name).toBe("idx_plugin_my-plugin_items_status_createdAt");
|
||||
});
|
||||
|
||||
it("should truncate long names to 128 characters", () => {
|
||||
const longFieldNames = Array.from({ length: 20 }, (_, i) => `veryLongFieldName${i}`);
|
||||
const name = generateIndexName("my-plugin", "items", longFieldNames);
|
||||
expect(name.length).toBeLessThanOrEqual(128);
|
||||
});
|
||||
|
||||
it("should be consistent across calls", () => {
|
||||
const name1 = generateIndexName("plugin", "coll", ["a", "b"]);
|
||||
const name2 = generateIndexName("plugin", "coll", ["a", "b"]);
|
||||
expect(name1).toBe(name2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateCreateIndexSql", () => {
|
||||
it("should return a RawBuilder with CREATE INDEX", () => {
|
||||
const result = generateCreateIndexSql(db, "my-plugin", "items", ["status"]);
|
||||
// It should be a RawBuilder (has toOperationNode method)
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof (result as any).toOperationNode).toBe("function");
|
||||
});
|
||||
|
||||
it("should reject invalid field names", () => {
|
||||
expect(() =>
|
||||
generateCreateIndexSql(db, "my-plugin", "items", ["status; DROP TABLE users--"]),
|
||||
).toThrow(IdentifierError);
|
||||
});
|
||||
|
||||
it("should reject invalid collection names", () => {
|
||||
expect(() =>
|
||||
generateCreateIndexSql(db, "my-plugin", "items'; DROP TABLE--", ["status"]),
|
||||
).toThrow(IdentifierError);
|
||||
});
|
||||
|
||||
it("should reject invalid plugin IDs", () => {
|
||||
expect(() => generateCreateIndexSql(db, "'; DROP TABLE--", "items", ["status"])).toThrow(
|
||||
IdentifierError,
|
||||
);
|
||||
});
|
||||
|
||||
it("should accept valid identifiers with hyphens in plugin ID", () => {
|
||||
// Should not throw
|
||||
const result = generateCreateIndexSql(db, "my-plugin", "items", ["status"]);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it("should accept composite field indexes", () => {
|
||||
// Should not throw
|
||||
const result = generateCreateIndexSql(db, "my-plugin", "items", ["status", "created_at"]);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateDropIndexSql", () => {
|
||||
it("should return a RawBuilder", () => {
|
||||
const result = generateDropIndexSql("idx_plugin_my-plugin_items_status");
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof (result as any).toOperationNode).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeIndexes", () => {
|
||||
it("should convert single fields to arrays", () => {
|
||||
const normalized = normalizeIndexes(["status", "category"]);
|
||||
expect(normalized).toEqual([["status"], ["category"]]);
|
||||
});
|
||||
|
||||
it("should keep arrays as-is", () => {
|
||||
const normalized = normalizeIndexes([["status", "createdAt"]]);
|
||||
expect(normalized).toEqual([["status", "createdAt"]]);
|
||||
});
|
||||
|
||||
it("should handle mixed input", () => {
|
||||
const normalized = normalizeIndexes(["status", ["category", "priority"], "name"]);
|
||||
expect(normalized).toEqual([["status"], ["category", "priority"], ["name"]]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", () => {
|
||||
const normalized = normalizeIndexes([]);
|
||||
expect(normalized).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
332
packages/core/tests/unit/plugins/storage-query.test.ts
Normal file
332
packages/core/tests/unit/plugins/storage-query.test.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { IdentifierError } from "../../../src/database/validate.js";
|
||||
import {
|
||||
isRangeFilter,
|
||||
isInFilter,
|
||||
isStartsWithFilter,
|
||||
getIndexedFields,
|
||||
validateWhereClause,
|
||||
validateOrderByClause,
|
||||
jsonExtract,
|
||||
buildCondition,
|
||||
buildWhereClause,
|
||||
buildOrderByClause,
|
||||
StorageQueryError,
|
||||
} from "../../../src/plugins/storage-query.js";
|
||||
import { createTestDatabase } from "../../utils/test-db.js";
|
||||
|
||||
describe("storage-query", () => {
|
||||
const db = createTestDatabase();
|
||||
describe("type guards", () => {
|
||||
describe("isRangeFilter", () => {
|
||||
it("should return true for range filters with gt", () => {
|
||||
expect(isRangeFilter({ gt: 10 })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for range filters with gte", () => {
|
||||
expect(isRangeFilter({ gte: 10 })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for range filters with lt", () => {
|
||||
expect(isRangeFilter({ lt: 10 })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for range filters with lte", () => {
|
||||
expect(isRangeFilter({ lte: 10 })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for combined range filters", () => {
|
||||
expect(isRangeFilter({ gt: 5, lt: 10 })).toBe(true);
|
||||
expect(isRangeFilter({ gte: 5, lte: 10 })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for plain values", () => {
|
||||
expect(isRangeFilter("foo")).toBe(false);
|
||||
expect(isRangeFilter(42)).toBe(false);
|
||||
expect(isRangeFilter(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for other filter types", () => {
|
||||
expect(isRangeFilter({ in: [1, 2, 3] })).toBe(false);
|
||||
expect(isRangeFilter({ startsWith: "foo" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInFilter", () => {
|
||||
it("should return true for in filters", () => {
|
||||
expect(isInFilter({ in: [1, 2, 3] })).toBe(true);
|
||||
expect(isInFilter({ in: ["a", "b", "c"] })).toBe(true);
|
||||
expect(isInFilter({ in: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-array in values", () => {
|
||||
expect(isInFilter({ in: "foo" } as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for other filter types", () => {
|
||||
expect(isInFilter({ gt: 10 })).toBe(false);
|
||||
expect(isInFilter({ startsWith: "foo" })).toBe(false);
|
||||
expect(isInFilter("foo")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isStartsWithFilter", () => {
|
||||
it("should return true for startsWith filters", () => {
|
||||
expect(isStartsWithFilter({ startsWith: "foo" })).toBe(true);
|
||||
expect(isStartsWithFilter({ startsWith: "" })).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-string startsWith values", () => {
|
||||
expect(isStartsWithFilter({ startsWith: 123 } as any)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for other filter types", () => {
|
||||
expect(isStartsWithFilter({ gt: 10 })).toBe(false);
|
||||
expect(isStartsWithFilter({ in: ["a", "b"] })).toBe(false);
|
||||
expect(isStartsWithFilter("foo")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIndexedFields", () => {
|
||||
it("should extract fields from simple indexes", () => {
|
||||
const indexes = ["status", "category"];
|
||||
const fields = getIndexedFields(indexes);
|
||||
|
||||
expect(fields).toEqual(new Set(["status", "category"]));
|
||||
});
|
||||
|
||||
it("should extract fields from composite indexes", () => {
|
||||
const indexes = [["status", "createdAt"], "category"];
|
||||
const fields = getIndexedFields(indexes);
|
||||
|
||||
expect(fields).toEqual(new Set(["status", "createdAt", "category"]));
|
||||
});
|
||||
|
||||
it("should handle empty indexes", () => {
|
||||
const fields = getIndexedFields([]);
|
||||
expect(fields).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("should deduplicate fields", () => {
|
||||
const indexes = ["status", ["status", "createdAt"]];
|
||||
const fields = getIndexedFields(indexes);
|
||||
|
||||
expect(fields).toEqual(new Set(["status", "createdAt"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateWhereClause", () => {
|
||||
const indexedFields = new Set(["status", "category", "createdAt"]);
|
||||
const pluginId = "test-plugin";
|
||||
const collection = "items";
|
||||
|
||||
it("should pass for indexed fields", () => {
|
||||
expect(() =>
|
||||
validateWhereClause(
|
||||
{ status: "active", category: "blog" },
|
||||
indexedFields,
|
||||
pluginId,
|
||||
collection,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw for non-indexed fields", () => {
|
||||
expect(() =>
|
||||
validateWhereClause({ title: "foo" }, indexedFields, pluginId, collection),
|
||||
).toThrow(StorageQueryError);
|
||||
});
|
||||
|
||||
it("should include helpful suggestion in error", () => {
|
||||
try {
|
||||
validateWhereClause({ title: "foo" }, indexedFields, pluginId, collection);
|
||||
expect.fail("Should have thrown");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(StorageQueryError);
|
||||
const error = e as StorageQueryError;
|
||||
expect(error.field).toBe("title");
|
||||
expect(error.suggestion).toContain("title");
|
||||
expect(error.suggestion).toContain(pluginId);
|
||||
}
|
||||
});
|
||||
|
||||
it("should pass for empty where clause", () => {
|
||||
expect(() => validateWhereClause({}, indexedFields, pluginId, collection)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateOrderByClause", () => {
|
||||
const indexedFields = new Set(["status", "createdAt"]);
|
||||
const pluginId = "test-plugin";
|
||||
const collection = "items";
|
||||
|
||||
it("should pass for indexed fields", () => {
|
||||
expect(() =>
|
||||
validateOrderByClause({ createdAt: "desc" }, indexedFields, pluginId, collection),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("should throw for non-indexed fields", () => {
|
||||
expect(() =>
|
||||
validateOrderByClause({ title: "asc" }, indexedFields, pluginId, collection),
|
||||
).toThrow(StorageQueryError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonExtract", () => {
|
||||
it("should generate correct SQLite JSON extraction syntax", () => {
|
||||
expect(jsonExtract(db, "status")).toBe("json_extract(data, '$.status')");
|
||||
expect(jsonExtract(db, "created_at")).toBe("json_extract(data, '$.created_at')");
|
||||
});
|
||||
|
||||
it("should accept camelCase field names (used in plugin JSON data)", () => {
|
||||
expect(jsonExtract(db, "createdAt")).toBe("json_extract(data, '$.createdAt')");
|
||||
expect(jsonExtract(db, "myField")).toBe("json_extract(data, '$.myField')");
|
||||
expect(jsonExtract(db, "UPPERCASE")).toBe("json_extract(data, '$.UPPERCASE')");
|
||||
});
|
||||
|
||||
it("should reject invalid field names to prevent SQL injection", () => {
|
||||
expect(() => jsonExtract(db, "'); DROP TABLE users--")).toThrow(IdentifierError);
|
||||
expect(() => jsonExtract(db, "field.with.dots")).toThrow(IdentifierError);
|
||||
expect(() => jsonExtract(db, "field-with-hyphens")).toThrow(IdentifierError);
|
||||
expect(() => jsonExtract(db, "")).toThrow(IdentifierError);
|
||||
expect(() => jsonExtract(db, "1startsWithNumber")).toThrow(IdentifierError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCondition", () => {
|
||||
it("should handle null values", () => {
|
||||
const result = buildCondition(db, "status", null);
|
||||
expect(result.sql).toBe("json_extract(data, '$.status') IS NULL");
|
||||
expect(result.params).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle string values", () => {
|
||||
const result = buildCondition(db, "status", "active");
|
||||
expect(result.sql).toBe("json_extract(data, '$.status') = ?");
|
||||
expect(result.params).toEqual(["active"]);
|
||||
});
|
||||
|
||||
it("should handle number values", () => {
|
||||
const result = buildCondition(db, "count", 42);
|
||||
expect(result.sql).toBe("json_extract(data, '$.count') = ?");
|
||||
expect(result.params).toEqual([42]);
|
||||
});
|
||||
|
||||
it("should handle boolean values", () => {
|
||||
const result = buildCondition(db, "active", true);
|
||||
expect(result.sql).toBe("json_extract(data, '$.active') = ?");
|
||||
expect(result.params).toEqual([true]);
|
||||
});
|
||||
|
||||
it("should handle IN filters", () => {
|
||||
const result = buildCondition(db, "status", { in: ["a", "b", "c"] });
|
||||
expect(result.sql).toBe("json_extract(data, '$.status') IN (?, ?, ?)");
|
||||
expect(result.params).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("should handle startsWith filters", () => {
|
||||
const result = buildCondition(db, "name", { startsWith: "foo" });
|
||||
expect(result.sql).toBe("json_extract(data, '$.name') LIKE ?");
|
||||
expect(result.params).toEqual(["foo%"]);
|
||||
});
|
||||
|
||||
it("should handle range filters with gt", () => {
|
||||
const result = buildCondition(db, "age", { gt: 18 });
|
||||
expect(result.sql).toBe("json_extract(data, '$.age') > ?");
|
||||
expect(result.params).toEqual([18]);
|
||||
});
|
||||
|
||||
it("should handle range filters with gte", () => {
|
||||
const result = buildCondition(db, "age", { gte: 18 });
|
||||
expect(result.sql).toBe("json_extract(data, '$.age') >= ?");
|
||||
expect(result.params).toEqual([18]);
|
||||
});
|
||||
|
||||
it("should handle range filters with lt", () => {
|
||||
const result = buildCondition(db, "age", { lt: 65 });
|
||||
expect(result.sql).toBe("json_extract(data, '$.age') < ?");
|
||||
expect(result.params).toEqual([65]);
|
||||
});
|
||||
|
||||
it("should handle range filters with lte", () => {
|
||||
const result = buildCondition(db, "age", { lte: 65 });
|
||||
expect(result.sql).toBe("json_extract(data, '$.age') <= ?");
|
||||
expect(result.params).toEqual([65]);
|
||||
});
|
||||
|
||||
it("should handle combined range filters", () => {
|
||||
const result = buildCondition(db, "age", { gte: 18, lt: 65 });
|
||||
expect(result.sql).toBe(
|
||||
"json_extract(data, '$.age') >= ? AND json_extract(data, '$.age') < ?",
|
||||
);
|
||||
expect(result.params).toEqual([18, 65]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWhereClause", () => {
|
||||
it("should return empty result for empty where", () => {
|
||||
const result = buildWhereClause(db, {});
|
||||
expect(result.sql).toBe("");
|
||||
expect(result.params).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle single condition", () => {
|
||||
const result = buildWhereClause(db, { status: "active" });
|
||||
expect(result.sql).toBe("json_extract(data, '$.status') = ?");
|
||||
expect(result.params).toEqual(["active"]);
|
||||
});
|
||||
|
||||
it("should combine multiple conditions with AND", () => {
|
||||
const result = buildWhereClause(db, {
|
||||
status: "active",
|
||||
category: "blog",
|
||||
});
|
||||
expect(result.sql).toBe(
|
||||
"json_extract(data, '$.status') = ? AND json_extract(data, '$.category') = ?",
|
||||
);
|
||||
expect(result.params).toEqual(["active", "blog"]);
|
||||
});
|
||||
|
||||
it("should handle mixed filter types", () => {
|
||||
const result = buildWhereClause(db, {
|
||||
status: { in: ["active", "pending"] },
|
||||
name: { startsWith: "test" },
|
||||
count: { gte: 5 },
|
||||
});
|
||||
expect(result.sql).toContain("IN (?, ?)");
|
||||
expect(result.sql).toContain("LIKE ?");
|
||||
expect(result.sql).toContain(">= ?");
|
||||
expect(result.params).toEqual(["active", "pending", "test%", 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildOrderByClause", () => {
|
||||
it("should return empty string for empty orderBy", () => {
|
||||
const result = buildOrderByClause(db, {});
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should handle single field ascending", () => {
|
||||
const result = buildOrderByClause(db, { createdAt: "asc" });
|
||||
expect(result).toBe("ORDER BY json_extract(data, '$.createdAt') ASC");
|
||||
});
|
||||
|
||||
it("should handle single field descending", () => {
|
||||
const result = buildOrderByClause(db, { createdAt: "desc" });
|
||||
expect(result).toBe("ORDER BY json_extract(data, '$.createdAt') DESC");
|
||||
});
|
||||
|
||||
it("should handle multiple fields", () => {
|
||||
const result = buildOrderByClause(db, {
|
||||
category: "asc",
|
||||
createdAt: "desc",
|
||||
});
|
||||
expect(result).toBe(
|
||||
"ORDER BY json_extract(data, '$.category') ASC, json_extract(data, '$.createdAt') DESC",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user