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

672 lines
22 KiB
TypeScript

/**
* Bridge Handler Conformance Tests
*
* Tests the shared bridge handler that both the production (workerd)
* and dev (miniflare) runners use. This is the conformance test suite
* that ensures identical behavior across all sandbox runners.
*
* These tests exercise capability enforcement, KV isolation, and
* error handling at the bridge level.
*/
import Database from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createBridgeHandler } from "../src/sandbox/bridge-handler.js";
// Set up an in-memory SQLite database with the minimum tables needed
function createTestDb() {
const sqlite = new Database(":memory:");
const db = new Kysely<any>({
dialect: new SqliteDialect({ database: sqlite }),
});
return { db, sqlite };
}
async function setupTables(db: Kysely<any>) {
// Plugin storage table (used for both KV and document storage)
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 table (matches 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();
// Insert a test user
await db
.insertInto("users" as any)
.values({
id: "user-1",
email: "test@example.com",
name: "Test User",
role: 50,
created_at: new Date().toISOString(),
})
.execute();
}
describe("Bridge Handler Conformance", () => {
let db: Kysely<any>;
let sqlite: Database.Database;
beforeEach(async () => {
const ctx = createTestDb();
db = ctx.db;
sqlite = ctx.sqlite;
await setupTables(db);
});
afterEach(async () => {
await db.destroy();
sqlite.close();
});
function makeHandler(opts: {
capabilities?: string[];
allowedHosts?: string[];
storageCollections?: string[];
}) {
return createBridgeHandler({
pluginId: "test-plugin",
version: "1.0.0",
capabilities: opts.capabilities ?? [],
allowedHosts: opts.allowedHosts ?? [],
storageCollections: opts.storageCollections ?? [],
db,
emailSend: () => null,
});
}
async function call(
handler: ReturnType<typeof makeHandler>,
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 }>;
}
// ── KV Operations ────────────────────────────────────────────────────
describe("KV operations", () => {
it("set and get a value", async () => {
const handler = makeHandler({});
await call(handler, "kv/set", { key: "test", value: "hello" });
const result = await call(handler, "kv/get", { key: "test" });
expect(result.result).toBe("hello");
});
it("get returns null for non-existent key", async () => {
const handler = makeHandler({});
const result = await call(handler, "kv/get", { key: "missing" });
expect(result.result).toBeNull();
});
it("delete removes a key", async () => {
const handler = makeHandler({});
await call(handler, "kv/set", { key: "to-delete", value: "bye" });
await call(handler, "kv/delete", { key: "to-delete" });
const result = await call(handler, "kv/get", { key: "to-delete" });
expect(result.result).toBeNull();
});
it("list returns keys with prefix", async () => {
const handler = makeHandler({});
await call(handler, "kv/set", { key: "settings:theme", value: "dark" });
await call(handler, "kv/set", { key: "settings:lang", value: "en" });
await call(handler, "kv/set", { key: "state:count", value: 42 });
const result = await call(handler, "kv/list", { prefix: "settings:" });
const items = result.result as Array<{ key: string; value: unknown }>;
expect(items).toHaveLength(2);
expect(items.map((i) => i.key).toSorted()).toEqual(["settings:lang", "settings:theme"]);
});
it("KV is scoped per plugin (isolation)", async () => {
const handlerA = createBridgeHandler({
pluginId: "plugin-a",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storageCollections: [],
db,
emailSend: () => null,
});
const handlerB = createBridgeHandler({
pluginId: "plugin-b",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storageCollections: [],
db,
emailSend: () => null,
});
// Plugin A sets a value
await call(handlerA, "kv/set", { key: "secret", value: "a-data" });
// Plugin B cannot see it
const resultB = await call(handlerB, "kv/get", { key: "secret" });
expect(resultB.result).toBeNull();
// Plugin A can see it
const resultA = await call(handlerA, "kv/get", { key: "secret" });
expect(resultA.result).toBe("a-data");
});
});
// ── Capability Enforcement ────────────────────────────────────────────
describe("capability enforcement", () => {
it("rejects content read without read:content capability", async () => {
const handler = makeHandler({ capabilities: [] });
const result = await call(handler, "content/get", {
collection: "posts",
id: "123",
});
expect(result.error).toContain("Missing capability: read:content");
});
it("allows content read with read:content", async () => {
// Create a content table first
await db.schema
.createTable("ec_posts")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("deleted_at", "text")
.addColumn("title", "text")
.execute();
const handler = makeHandler({ capabilities: ["read:content"] });
const result = await call(handler, "content/get", {
collection: "posts",
id: "123",
});
// No error, returns null (post doesn't exist)
expect(result.error).toBeUndefined();
expect(result.result).toBeNull();
});
it("write:content does NOT imply read:content (matches Cloudflare bridge)", async () => {
// The bridge enforces capabilities strictly: a plugin that declares
// only write:content cannot call ctx.content.get/list. This matches
// the Cloudflare PluginBridge behavior. The plugin must declare
// read:content explicitly to read.
await db.schema
.createTable("ec_posts")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("deleted_at", "text")
.addColumn("title", "text")
.execute();
const handler = makeHandler({ capabilities: ["write:content"] });
const result = await call(handler, "content/get", {
collection: "posts",
id: "123",
});
expect(result.error).toContain("Missing capability: read:content");
});
it("rejects taxonomy read without taxonomies:read capability", async () => {
// content:read does not grant taxonomy access — it's a separate
// capability (and a new one, so the canonical name is checked).
const handler = makeHandler({ capabilities: ["read:content"] });
const result = await call(handler, "taxonomy/list", {});
expect(result.error).toContain("Missing capability: taxonomies:read");
});
it("allows taxonomy read with taxonomies:read", async () => {
await db.schema
.createTable("_emdash_taxonomy_defs")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("name", "text", (col) => col.notNull())
.addColumn("label", "text", (col) => col.notNull())
.addColumn("label_singular", "text")
.addColumn("hierarchical", "integer", (col) => col.notNull().defaultTo(0))
.addColumn("collections", "text")
.addColumn("locale", "text", (col) => col.notNull().defaultTo("en"))
.addColumn("translation_group", "text")
.execute();
await db
.insertInto("_emdash_taxonomy_defs" as any)
.values({
id: "def-genre",
name: "genre",
label: "Genres",
label_singular: "Genre",
hierarchical: 1,
collections: '["posts"]',
locale: "en",
translation_group: "def-genre",
})
.execute();
const handler = makeHandler({ capabilities: ["taxonomies:read"] });
const result = await call(handler, "taxonomy/list", {});
expect(result.error).toBeUndefined();
const defs = result.result as Array<{ name: string; hierarchical: boolean }>;
expect(defs).toHaveLength(1);
expect(defs[0]).toMatchObject({ name: "genre", hierarchical: true, collections: ["posts"] });
});
it("resolves taxonomy terms and entry terms through the pivot join", async () => {
await db.schema
.createTable("taxonomies")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("name", "text", (col) => col.notNull())
.addColumn("slug", "text", (col) => col.notNull())
.addColumn("label", "text", (col) => col.notNull())
.addColumn("parent_id", "text")
.addColumn("data", "text")
.addColumn("locale", "text", (col) => col.notNull().defaultTo("en"))
.addColumn("translation_group", "text")
.execute();
await db.schema
.createTable("content_taxonomies")
.addColumn("collection", "text", (col) => col.notNull())
.addColumn("entry_id", "text", (col) => col.notNull())
.addColumn("taxonomy_id", "text", (col) => col.notNull())
.execute();
await db
.insertInto("taxonomies" as any)
.values([
{
id: "term-en",
name: "genre",
slug: "scifi",
label: "Sci-Fi",
data: '{"description":"Space"}',
locale: "en",
translation_group: "tg-scifi",
},
{
id: "term-de",
name: "genre",
slug: "scifi",
label: "Science-Fiction",
locale: "de",
translation_group: "tg-scifi",
},
{
id: "term-other",
name: "genre",
slug: "fantasy",
label: "Fantasy",
locale: "en",
translation_group: "tg-fantasy",
},
])
.execute();
// The pivot stores the term's translation_group, not a row id.
await db
.insertInto("content_taxonomies" as any)
.values({ collection: "posts", entry_id: "post-1", taxonomy_id: "tg-scifi" })
.execute();
const denied = makeHandler({ capabilities: ["read:content"] });
expect((await call(denied, "taxonomy/terms", { taxonomy: "genre" })).error).toContain(
"Missing capability: taxonomies:read",
);
expect(
(await call(denied, "taxonomy/entryTerms", { collection: "posts", entryId: "post-1" }))
.error,
).toContain("Missing capability: taxonomies:read");
const handler = makeHandler({ capabilities: ["taxonomies:read"] });
// terms: locale filter + data JSON parsing + translation group
const terms = await call(handler, "taxonomy/terms", { taxonomy: "genre", locale: "en" });
expect(terms.error).toBeUndefined();
const termRows = terms.result as Array<Record<string, unknown>>;
expect(termRows.map((t) => t.slug)).toEqual(["fantasy", "scifi"]);
expect(termRows[1]).toMatchObject({
id: "term-en",
data: { description: "Space" },
translationGroup: "tg-scifi",
});
// entryTerms: pivot join on translation_group resolves both locales
const entryTerms = await call(handler, "taxonomy/entryTerms", {
collection: "posts",
entryId: "post-1",
});
expect(entryTerms.error).toBeUndefined();
const entryRows = entryTerms.result as Array<Record<string, unknown>>;
expect(entryRows.map((t) => t.id)).toEqual(["term-de", "term-en"]);
// entryTerms: locale narrows to one row per assignment
const localized = await call(handler, "taxonomy/entryTerms", {
collection: "posts",
entryId: "post-1",
locale: "de",
});
expect((localized.result as unknown[]).length).toBe(1);
});
it("rejects user read without read:users capability", async () => {
const handler = makeHandler({ capabilities: [] });
const result = await call(handler, "users/get", { id: "user-1" });
expect(result.error).toContain("Missing capability: read:users");
});
it("allows user read with read:users", async () => {
const handler = makeHandler({ capabilities: ["read:users"] });
const result = await call(handler, "users/get", { id: "user-1" });
expect(result.error).toBeUndefined();
const user = result.result as { id: string; email: string };
expect(user.id).toBe("user-1");
expect(user.email).toBe("test@example.com");
});
it("rejects network fetch without network:fetch capability", async () => {
const handler = makeHandler({ capabilities: [] });
const result = await call(handler, "http/fetch", {
url: "https://example.com",
});
expect(result.error).toContain("Missing capability: network:fetch");
});
it("rejects email send without email:send capability", async () => {
const handler = makeHandler({ capabilities: [] });
const result = await call(handler, "email/send", {
message: { to: "a@b.com", subject: "hi", text: "hello" },
});
expect(result.error).toContain("Missing capability: email:send");
});
});
// ── Storage (document store) ──────────────────────────────────────────
describe("plugin storage", () => {
it("rejects access to undeclared storage collection", async () => {
const handler = makeHandler({ storageCollections: ["logs"] });
const result = await call(handler, "storage/get", {
collection: "secrets",
id: "1",
});
expect(result.error).toContain("Storage collection not declared: secrets");
});
it("allows access to declared storage collection", async () => {
const handler = makeHandler({ storageCollections: ["logs"] });
const result = await call(handler, "storage/get", {
collection: "logs",
id: "1",
});
expect(result.error).toBeUndefined();
expect(result.result).toBeNull();
});
it("put and get storage document", async () => {
const handler = makeHandler({ storageCollections: ["logs"] });
await call(handler, "storage/put", {
collection: "logs",
id: "log-1",
data: { message: "hello", level: "info" },
});
const result = await call(handler, "storage/get", {
collection: "logs",
id: "log-1",
});
expect(result.result).toEqual({ message: "hello", level: "info" });
});
it("storage is scoped per plugin", async () => {
const handlerA = createBridgeHandler({
pluginId: "plugin-a",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storageCollections: ["data"],
db,
emailSend: () => null,
});
const handlerB = createBridgeHandler({
pluginId: "plugin-b",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storageCollections: ["data"],
db,
emailSend: () => null,
});
await call(handlerA, "storage/put", {
collection: "data",
id: "item-1",
data: { owner: "a" },
});
// Plugin B cannot see plugin A's data
const resultB = await call(handlerB, "storage/get", {
collection: "data",
id: "item-1",
});
expect(resultB.result).toBeNull();
});
});
// ── Error Handling ────────────────────────────────────────────────────
describe("error handling", () => {
it("returns error for unknown bridge method", async () => {
const handler = makeHandler({});
const result = await call(handler, "unknown/method");
expect(result.error).toContain("Unknown bridge method: unknown/method");
});
it("returns error for missing required parameters", async () => {
const handler = makeHandler({ capabilities: ["read:content"] });
const result = await call(handler, "content/get", {});
expect(result.error).toContain("Missing required string parameter");
});
});
// ── Limit clamping ────────────────────────────────────────────────────
describe("list endpoints clamp negative limit", () => {
it("content/list clamps negative limit to 1", async () => {
await db.schema
.createTable("ec_posts")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("deleted_at", "text")
.addColumn("title", "text")
.execute();
for (const id of ["post-1", "post-2", "post-3"]) {
await db
.insertInto("ec_posts" as any)
.values({ id, deleted_at: null, title: `Title ${id}` })
.execute();
}
const handler = makeHandler({ capabilities: ["read:content"] });
const result = await call(handler, "content/list", {
collection: "posts",
limit: -5,
});
expect(result.error).toBeUndefined();
const list = result.result as { items: unknown[] };
expect(list.items.length).toBeGreaterThanOrEqual(1);
expect(list.items.length).toBeLessThanOrEqual(1);
});
it("media/list clamps negative limit to 1", async () => {
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("ready"))
.addColumn("created_at", "text", (col) => col.notNull())
.execute();
for (const id of ["m-1", "m-2", "m-3"]) {
await db
.insertInto("media" as any)
.values({
id,
filename: `${id}.png`,
mime_type: "image/png",
size: 100,
storage_key: `keys/${id}`,
status: "ready",
created_at: new Date().toISOString(),
})
.execute();
}
const handler = makeHandler({ capabilities: ["read:media"] });
const result = await call(handler, "media/list", { limit: -5 });
expect(result.error).toBeUndefined();
const list = result.result as { items: unknown[] };
expect(list.items.length).toBeGreaterThanOrEqual(1);
expect(list.items.length).toBeLessThanOrEqual(1);
});
it("storage/query clamps negative limit to 1", async () => {
const handler = makeHandler({ storageCollections: ["logs"] });
for (const id of ["log-1", "log-2", "log-3"]) {
await call(handler, "storage/put", {
collection: "logs",
id,
data: { message: id },
});
}
const result = await call(handler, "storage/query", {
collection: "logs",
where: {},
limit: -5,
});
expect(result.error).toBeUndefined();
const list = result.result as { items: unknown[] };
expect(list.items.length).toBeGreaterThanOrEqual(1);
expect(list.items.length).toBeLessThanOrEqual(1);
});
it("storage/query without limit returns all rows (undefined passthrough)", async () => {
const handler = makeHandler({ storageCollections: ["logs"] });
for (const id of ["log-1", "log-2", "log-3"]) {
await call(handler, "storage/put", {
collection: "logs",
id,
data: { message: id },
});
}
const result = await call(handler, "storage/query", {
collection: "logs",
where: {},
});
expect(result.error).toBeUndefined();
const list = result.result as { items: unknown[] };
expect(list.items.length).toBe(3);
});
});
// ── Logging ───────────────────────────────────────────────────────────
describe("logging", () => {
it("log call succeeds without capabilities", async () => {
const handler = makeHandler({});
const result = await call(handler, "log", {
level: "info",
msg: "test message",
});
expect(result.error).toBeUndefined();
expect(result.result).toBeNull();
});
});
// ── Batch transactionality ────────────────────────────────────────────
describe("batch operations are transactional", () => {
beforeEach(async () => {
await db.schema
.createTable("ec_atomic_posts")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("slug", "text", (col) => col.unique())
.addColumn("status", "text", (col) => col.defaultTo("draft"))
.addColumn("title", "text")
.addColumn("created_at", "text")
.addColumn("updated_at", "text")
.addColumn("deleted_at", "text")
.addColumn("version", "integer", (col) => col.defaultTo(1))
.addColumn("author_id", "text")
.execute();
});
it("contentCreateMany rolls back when a mid-batch insert fails", async () => {
const handler = makeHandler({ capabilities: ["write:content"] });
// Pre-insert a row that will collide with item index 2's slug.
await call(handler, "content/create", {
collection: "atomic_posts",
data: { slug: "conflict", title: "existing" },
});
const before = await db
.selectFrom("ec_atomic_posts" as any)
.selectAll()
.execute();
expect(before).toHaveLength(1);
const result = await call(handler, "content/createMany", {
collection: "atomic_posts",
items: [
{ slug: "a", title: "ok 1" },
{ slug: "b", title: "ok 2" },
{ slug: "conflict", title: "should fail" },
{ slug: "d", title: "would be ok" },
],
});
expect(result.error).toBeDefined();
// After the failed batch, only the pre-existing row should remain.
const after = await db
.selectFrom("ec_atomic_posts" as any)
.selectAll()
.execute();
expect(after).toHaveLength(1);
expect((after[0] as any).slug).toBe("conflict");
});
it("contentCreateMany commits all when no item fails", async () => {
const handler = makeHandler({ capabilities: ["write:content"] });
const result = await call(handler, "content/createMany", {
collection: "atomic_posts",
items: [
{ slug: "x1", title: "1" },
{ slug: "x2", title: "2" },
{ slug: "x3", title: "3" },
],
});
expect(result.result).toBeDefined();
const rows = await db
.selectFrom("ec_atomic_posts" as any)
.selectAll()
.execute();
expect(rows).toHaveLength(3);
});
});
});