Files
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

466 lines
14 KiB
TypeScript

/**
* Plugin Integration Tests
*
* Exercises the bridge handler with the same operations that EmDash's
* shipped plugins perform. Uses a real SQLite database with migrations
* to test against the actual schema, not hand-rolled test tables.
*
* This validates that the workerd bridge handler produces the same
* results as the Cloudflare PluginBridge for real plugin workloads.
*
* Tests are modeled after the sandboxed-test plugin's routes:
* - kv/test: set, get, delete a KV entry
* - storage/test: put, get, count in a declared storage collection
* - content/list: list content with read:content capability
* - content lifecycle: create, read, update, soft-delete
*/
import Database from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createBridgeHandler } from "../src/sandbox/bridge-handler.js";
/**
* Create a test database with the minimum schema needed for plugin operations.
* Matches the real migration schema (001_initial + 004_plugins).
*/
function createTestDb() {
const sqlite = new Database(":memory:");
const db = new Kysely<any>({
dialect: new SqliteDialect({ database: sqlite }),
});
return { db, sqlite };
}
async function runMigrations(db: Kysely<any>) {
// Plugin storage (migration 004)
await db.schema
.createTable("_plugin_storage")
.addColumn("plugin_id", "text", (col) => col.notNull())
.addColumn("collection", "text", (col) => col.notNull())
.addColumn("id", "text", (col) => col.notNull())
.addColumn("data", "text", (col) => col.notNull())
.addColumn("created_at", "text", (col) => col.notNull())
.addColumn("updated_at", "text", (col) => col.notNull())
.addPrimaryKeyConstraint("pk_plugin_storage", ["plugin_id", "collection", "id"])
.execute();
// Users (migration 001)
await db.schema
.createTable("users")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("email", "text", (col) => col.notNull())
.addColumn("name", "text")
.addColumn("role", "integer", (col) => col.notNull())
.addColumn("created_at", "text", (col) => col.notNull())
.execute();
// Media (migration 001)
await db.schema
.createTable("media")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("filename", "text", (col) => col.notNull())
.addColumn("mime_type", "text", (col) => col.notNull())
.addColumn("size", "integer")
.addColumn("storage_key", "text", (col) => col.notNull())
.addColumn("status", "text", (col) => col.notNull().defaultTo("pending"))
.addColumn("created_at", "text", (col) => col.notNull())
.execute();
// Content table for posts (created by SchemaRegistry in real code)
await db.schema
.createTable("ec_posts")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("slug", "text")
.addColumn("status", "text", (col) => col.notNull().defaultTo("draft"))
.addColumn("author_id", "text")
.addColumn("created_at", "text", (col) => col.notNull())
.addColumn("updated_at", "text", (col) => col.notNull())
.addColumn("published_at", "text")
.addColumn("deleted_at", "text")
.addColumn("version", "integer", (col) => col.notNull().defaultTo(1))
.addColumn("title", "text")
.addColumn("body", "text")
.execute();
}
describe("Plugin integration: sandboxed-test plugin operations", () => {
let db: Kysely<any>;
let sqlite: Database.Database;
beforeEach(async () => {
const ctx = createTestDb();
db = ctx.db;
sqlite = ctx.sqlite;
await runMigrations(db);
});
afterEach(async () => {
await db.destroy();
sqlite.close();
});
/**
* Create a bridge handler matching the sandboxed-test plugin's capabilities:
* read:content, network:fetch with allowedHosts: ["httpbin.org"]
* storage: { events: { indexes: ["timestamp", "type"] } }
*/
function makePluginHandler() {
return createBridgeHandler({
pluginId: "sandboxed-test",
version: "0.0.1",
capabilities: ["read:content", "network:fetch"],
allowedHosts: ["httpbin.org"],
storageCollections: ["events"],
db,
emailSend: () => null,
});
}
async function call(
handler: ReturnType<typeof makePluginHandler>,
method: string,
body: Record<string, unknown> = {},
) {
const request = new Request(`http://bridge/${method}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const response = await handler(request);
return response.json() as Promise<{ result?: unknown; error?: string }>;
}
// ── Mirrors sandboxed-test plugin's kv/test route ────────────────────
it("KV round-trip: set, get, delete", async () => {
const handler = makePluginHandler();
// Set
await call(handler, "kv/set", {
key: "sandbox-test-key",
value: { tested: true, time: 12345 },
});
// Get
const getResult = await call(handler, "kv/get", { key: "sandbox-test-key" });
expect(getResult.result).toEqual({ tested: true, time: 12345 });
// Delete
const deleteResult = await call(handler, "kv/delete", { key: "sandbox-test-key" });
expect(deleteResult.result).toBe(true);
// Verify deleted
const afterDelete = await call(handler, "kv/get", { key: "sandbox-test-key" });
expect(afterDelete.result).toBeNull();
});
// ── Mirrors sandboxed-test plugin's storage/test route ───────────────
it("Storage round-trip: put, get, count", async () => {
const handler = makePluginHandler();
// Put
await call(handler, "storage/put", {
collection: "events",
id: "event-1",
data: {
timestamp: "2025-01-01T00:00:00Z",
type: "test",
message: "Sandboxed plugin storage test",
},
});
// Get
const getResult = await call(handler, "storage/get", {
collection: "events",
id: "event-1",
});
expect(getResult.result).toEqual({
timestamp: "2025-01-01T00:00:00Z",
type: "test",
message: "Sandboxed plugin storage test",
});
// Count
const countResult = await call(handler, "storage/count", { collection: "events" });
expect(countResult.result).toBe(1);
});
// ── Mirrors sandboxed-test plugin's content/list route ───────────────
it("Content list with read:content capability", async () => {
const handler = makePluginHandler();
// Seed some content
const now = new Date().toISOString();
await db
.insertInto("ec_posts" as any)
.values([
{
id: "post-1",
slug: "hello",
status: "published",
title: "Hello World",
created_at: now,
updated_at: now,
version: 1,
},
{
id: "post-2",
slug: "second",
status: "draft",
title: "Second Post",
created_at: now,
updated_at: now,
version: 1,
},
])
.execute();
const result = await call(handler, "content/list", { collection: "posts", limit: 5 });
expect(result.error).toBeUndefined();
const data = result.result as {
items: Array<{ id: string; type: string; data: Record<string, unknown> }>;
hasMore: boolean;
};
expect(data.items).toHaveLength(2);
expect(data.hasMore).toBe(false);
// Items should be transformed via rowToContentItem
expect(data.items[0]!.type).toBe("posts");
expect(data.items[0]!.data.title).toBeDefined();
});
// ── Content lifecycle: create, read, update, soft-delete ─────────────
describe("content lifecycle (requires read:content + write:content)", () => {
function makeWriteHandler() {
// Bridge enforces capabilities strictly: write:content does NOT
// imply read:content. Plugins that need both must declare both.
return createBridgeHandler({
pluginId: "sandboxed-test",
version: "0.0.1",
capabilities: ["read:content", "write:content"],
allowedHosts: [],
storageCollections: [],
db,
emailSend: () => null,
});
}
it("create, read, update, delete", async () => {
const handler = makeWriteHandler();
// Create
const createResult = await call(handler, "content/create", {
collection: "posts",
data: { title: "New Post", body: "Content here", slug: "new-post", status: "draft" },
});
expect(createResult.error).toBeUndefined();
const created = createResult.result as {
id: string;
type: string;
data: Record<string, unknown>;
};
expect(created.type).toBe("posts");
expect(created.data.title).toBe("New Post");
expect(created.id).toBeTruthy();
// Read
const readResult = await call(handler, "content/get", {
collection: "posts",
id: created.id,
});
expect(readResult.error).toBeUndefined();
const read = readResult.result as { id: string; data: Record<string, unknown> };
expect(read.data.title).toBe("New Post");
// Update
const updateResult = await call(handler, "content/update", {
collection: "posts",
id: created.id,
data: { title: "Updated Post" },
});
expect(updateResult.error).toBeUndefined();
const updated = updateResult.result as { id: string; data: Record<string, unknown> };
expect(updated.data.title).toBe("Updated Post");
// Delete (soft-delete)
const deleteResult = await call(handler, "content/delete", {
collection: "posts",
id: created.id,
});
expect(deleteResult.result).toBe(true);
// Verify soft-deleted: get returns null
const afterDelete = await call(handler, "content/get", {
collection: "posts",
id: created.id,
});
expect(afterDelete.result).toBeNull();
});
});
// ── Capability enforcement matches real plugin config ─────────────────
it("sandboxed-test plugin cannot write content (only has read:content)", async () => {
const handler = makePluginHandler();
const result = await call(handler, "content/create", {
collection: "posts",
data: { title: "Should fail" },
});
expect(result.error).toContain("Missing capability: write:content");
});
it("write-only plugin cannot read content (no implicit upgrade)", async () => {
// Plugins with only write:content cannot call ctx.content.get/list.
// This matches the Cloudflare PluginBridge: capabilities are enforced
// strictly as declared in the manifest. A plugin that needs both
// reads and writes must declare both capabilities.
await db.schema
.createTable("ec_pages")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("slug", "text")
.addColumn("status", "text", (col) => col.notNull().defaultTo("draft"))
.addColumn("author_id", "text")
.addColumn("created_at", "text", (col) => col.notNull())
.addColumn("updated_at", "text", (col) => col.notNull())
.addColumn("deleted_at", "text")
.addColumn("version", "integer", (col) => col.notNull().defaultTo(1))
.addColumn("title", "text")
.execute();
const writeOnlyHandler = createBridgeHandler({
pluginId: "write-only-plugin",
version: "1.0.0",
capabilities: ["write:content"],
allowedHosts: [],
storageCollections: [],
db,
emailSend: () => null,
});
// content/get should fail
const getResult = await call(writeOnlyHandler, "content/get", {
collection: "pages",
id: "any",
});
expect(getResult.error).toContain("Missing capability: read:content");
// content/list should also fail
const listResult = await call(writeOnlyHandler, "content/list", {
collection: "pages",
});
expect(listResult.error).toContain("Missing capability: read:content");
// content/create should still succeed (has write:content)
const createResult = await call(writeOnlyHandler, "content/create", {
collection: "pages",
data: { title: "Allowed" },
});
expect(createResult.error).toBeUndefined();
});
it("write-only media plugin cannot read media", async () => {
// Same enforcement for media: write:media does NOT imply read:media.
const writeOnlyHandler = createBridgeHandler({
pluginId: "write-only-media",
version: "1.0.0",
capabilities: ["write:media"],
allowedHosts: [],
storageCollections: [],
db,
emailSend: () => null,
});
const getResult = await call(writeOnlyHandler, "media/get", { id: "any" });
expect(getResult.error).toContain("Missing capability: read:media");
const listResult = await call(writeOnlyHandler, "media/list", {});
expect(listResult.error).toContain("Missing capability: read:media");
});
it("sandboxed-test plugin cannot send email (not in capabilities)", async () => {
const handler = makePluginHandler();
const result = await call(handler, "email/send", {
message: { to: "a@b.com", subject: "hi", text: "hello" },
});
expect(result.error).toContain("Missing capability: email:send");
});
it("sandboxed-test plugin cannot access undeclared storage collections", async () => {
const handler = makePluginHandler();
const result = await call(handler, "storage/get", {
collection: "secrets",
id: "1",
});
expect(result.error).toContain("Storage collection not declared: secrets");
});
// ── Cross-plugin isolation ────────────────────────────────────────────
it("two plugins cannot see each other's KV data", async () => {
const pluginA = createBridgeHandler({
pluginId: "plugin-a",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storageCollections: [],
db,
emailSend: () => null,
});
const pluginB = createBridgeHandler({
pluginId: "plugin-b",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storageCollections: [],
db,
emailSend: () => null,
});
await call(pluginA, "kv/set", { key: "secret", value: "a-only" });
const fromA = await call(pluginA, "kv/get", { key: "secret" });
expect(fromA.result).toBe("a-only");
const fromB = await call(pluginB, "kv/get", { key: "secret" });
expect(fromB.result).toBeNull();
});
it("two plugins cannot see each other's storage documents", async () => {
const pluginA = createBridgeHandler({
pluginId: "plugin-a",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storageCollections: ["shared-name"],
db,
emailSend: () => null,
});
const pluginB = createBridgeHandler({
pluginId: "plugin-b",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storageCollections: ["shared-name"],
db,
emailSend: () => null,
});
await call(pluginA, "storage/put", {
collection: "shared-name",
id: "doc-1",
data: { owner: "a" },
});
const fromA = await call(pluginA, "storage/get", { collection: "shared-name", id: "doc-1" });
expect((fromA.result as Record<string, unknown>).owner).toBe("a");
const fromB = await call(pluginB, "storage/get", { collection: "shared-name", id: "doc-1" });
expect(fromB.result).toBeNull();
});
});