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
116 lines
3.4 KiB
TypeScript
116 lines
3.4 KiB
TypeScript
/**
|
|
* Trusted plugin API routes must receive a writable `ctx.media`.
|
|
*
|
|
* Regression: `EmDashRuntime.handlePluginApiRoute` built a `PluginRouteRegistry`
|
|
* without threading `storage`, so a `media:write` plugin invoked via
|
|
* `/_emdash/api/plugin/{id}/{route}` got a read-only (or undefined) `ctx.media`
|
|
* and `ctx.media.upload()` was unusable — the exact trigger in the bug report.
|
|
*/
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
import Database from "better-sqlite3";
|
|
import { SqliteDialect } from "kysely";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { EmDashRuntime } from "../../../src/emdash-runtime.js";
|
|
import type { RuntimeDependencies } from "../../../src/emdash-runtime.js";
|
|
import { definePlugin } from "../../../src/plugins/define-plugin.js";
|
|
import type { Storage } from "../../../src/storage/types.js";
|
|
|
|
/** Minimal in-memory Storage backend that records uploaded keys. */
|
|
function createFakeStorage() {
|
|
const uploads = new Map<string, Uint8Array>();
|
|
const storage: Storage = {
|
|
async upload(options) {
|
|
const body =
|
|
options.body instanceof Uint8Array
|
|
? options.body
|
|
: new Uint8Array(options.body as ArrayBuffer);
|
|
uploads.set(options.key, body);
|
|
return { key: options.key, size: body.byteLength };
|
|
},
|
|
async download() {
|
|
throw new Error("not implemented");
|
|
},
|
|
async delete(key) {
|
|
uploads.delete(key);
|
|
},
|
|
async exists(key) {
|
|
return uploads.has(key);
|
|
},
|
|
async list() {
|
|
return { items: [] };
|
|
},
|
|
async getSignedUploadUrl(options) {
|
|
return {
|
|
url: `https://signed.example.com/${options.key}`,
|
|
method: "PUT",
|
|
headers: {},
|
|
expiresAt: new Date(Date.now() + 3600_000).toISOString(),
|
|
};
|
|
},
|
|
getPublicUrl(key) {
|
|
return `/media/${key}`;
|
|
},
|
|
};
|
|
return { storage, uploads };
|
|
}
|
|
|
|
function createDeps(storage: Storage): RuntimeDependencies {
|
|
const entrypoint = `test-plugin-media-route-${randomUUID()}`;
|
|
return {
|
|
config: {
|
|
database: { entrypoint, config: {}, type: "sqlite" },
|
|
storage: { entrypoint, config: {} },
|
|
},
|
|
plugins: [
|
|
definePlugin({
|
|
id: "media-uploader",
|
|
version: "1.0.0",
|
|
capabilities: ["media:write"],
|
|
routes: {
|
|
upload: {
|
|
handler: async (ctx) => {
|
|
const bytes = new Uint8Array([1, 2, 3, 4]).buffer;
|
|
return ctx.media!.upload!("from-route.png", "image/png", bytes);
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
],
|
|
createDialect: () => new SqliteDialect({ database: new Database(":memory:") }),
|
|
createStorage: () => storage,
|
|
sandboxEnabled: false,
|
|
sandboxedPluginEntries: [],
|
|
createSandboxRunner: null,
|
|
};
|
|
}
|
|
|
|
describe("EmDashRuntime.handlePluginApiRoute — media:write", () => {
|
|
it("provides a writable ctx.media so a trusted plugin route can upload", async () => {
|
|
const { storage, uploads } = createFakeStorage();
|
|
const runtime = await EmDashRuntime.create(createDeps(storage));
|
|
|
|
try {
|
|
const result = await runtime.handlePluginApiRoute(
|
|
"media-uploader",
|
|
"POST",
|
|
"/upload",
|
|
new Request("http://test.local/_emdash/api/plugin/media-uploader/upload", {
|
|
method: "POST",
|
|
}),
|
|
);
|
|
|
|
expect(result.success).toBe(true);
|
|
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- narrowing the route result for the assertion
|
|
const data = result.data as { mediaId: string; storageKey: string };
|
|
expect(data.mediaId).toBeTruthy();
|
|
expect(data.storageKey).toMatch(/\.png$/);
|
|
expect(uploads.has(data.storageKey)).toBe(true);
|
|
} finally {
|
|
await runtime.stopCron();
|
|
}
|
|
});
|
|
});
|