70bf21e064
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
258 lines
7.7 KiB
TypeScript
258 lines
7.7 KiB
TypeScript
import assert from "node:assert";
|
|
import { Blob } from "node:buffer";
|
|
import { existsSync } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { Miniflare } from "miniflare";
|
|
import { afterAll, beforeAll, test } from "vitest";
|
|
import { useTmp } from "../test-shared";
|
|
import type { InclusiveRange, Response } from "miniflare";
|
|
import type { ReadableStream } from "node:stream/web";
|
|
|
|
class BlobStoreStub {
|
|
constructor(private readonly mf: Miniflare) {}
|
|
|
|
get(
|
|
id: string,
|
|
ranges?: InclusiveRange | InclusiveRange[],
|
|
opts?: { contentType: string }
|
|
): Promise<Response> {
|
|
return this.mf.dispatchFetch("http://placeholder", {
|
|
method: "POST", // ish
|
|
body: JSON.stringify([id, ranges, opts]),
|
|
});
|
|
}
|
|
|
|
async put(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
const res = await this.mf.dispatchFetch("http://placeholder", {
|
|
method: "PUT",
|
|
body: stream,
|
|
duplex: "half",
|
|
});
|
|
return res.text();
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
await this.mf.dispatchFetch("http://placeholder", {
|
|
method: "DELETE",
|
|
body: id,
|
|
});
|
|
}
|
|
}
|
|
|
|
const NAMESPACE = " strange%/namespace \\";
|
|
|
|
interface Context {
|
|
tmp: string;
|
|
mf: Miniflare;
|
|
store: BlobStoreStub;
|
|
}
|
|
|
|
const ctx: Context = {} as Context;
|
|
|
|
// Can't use `miniflareTest` here as we want to import from "miniflare:shared".
|
|
// Can't use dynamic import as `esbuild` will still try to bundle it/apply
|
|
// format conversion to CJS.
|
|
// Can't use proxy client here as the blob store isn't accessible as a binding.
|
|
// Could do with wrapped bindings support though once implemented :eyes: .
|
|
beforeAll(async () => {
|
|
const tmp = await useTmp();
|
|
ctx.tmp = tmp;
|
|
ctx.mf = new Miniflare({
|
|
bindings: { NAMESPACE },
|
|
serviceBindings: { BLOBS: { disk: { path: tmp, writable: true } } },
|
|
compatibilityDate: "2023-08-01",
|
|
compatibilityFlags: ["nodejs_compat"],
|
|
modules: [
|
|
{
|
|
type: "ESModule",
|
|
path: "blob.js",
|
|
contents: `
|
|
import { BlobStore } from "miniflare:shared";
|
|
export default {
|
|
async fetch(request, env, ctx) {
|
|
const store = new BlobStore(env.BLOBS, env.NAMESPACE, false);
|
|
if (request.method === "POST") {
|
|
let args = await request.json();
|
|
args = args.map((arg) => arg === null ? undefined : arg);
|
|
const result = await store.get(...args);
|
|
if (result instanceof ReadableStream) {
|
|
return new Response(result, { duplex: "half" });
|
|
} else if (result !== null) {
|
|
return new Response(result.body, {
|
|
headers: { "Content-Type": result.multipartContentType },
|
|
duplex: "half",
|
|
});
|
|
} else {
|
|
return new Response(null, { status: 404 });
|
|
}
|
|
} else if (request.method === "PUT") {
|
|
return new Response(await store.put(request.body))
|
|
} else if (request.method === "DELETE") {
|
|
await store.delete(await request.text());
|
|
return new Response(null, { status: 204 });
|
|
} else {
|
|
return new Response(null, { status: 405 });
|
|
}
|
|
}
|
|
}`,
|
|
},
|
|
],
|
|
});
|
|
ctx.store = new BlobStoreStub(ctx.mf);
|
|
});
|
|
afterAll(() => ctx.mf.dispose());
|
|
|
|
test("BlobStore: put/get", async ({ expect }) => {
|
|
const { tmp, store } = ctx;
|
|
|
|
// Check put writes file to correct location
|
|
const id = await store.put(new Blob(["0123456789"]).stream());
|
|
const namespacePath = path.join(tmp, "_strange%_namespace _");
|
|
const blobsPath = path.join(namespacePath, "blobs");
|
|
expect(await fs.readFile(path.join(blobsPath, id), "utf8")).toBe(
|
|
"0123456789"
|
|
);
|
|
|
|
// Check full range
|
|
let res = await store.get(id);
|
|
expect(res.ok).toBe(true);
|
|
expect(await res.text()).toBe("0123456789");
|
|
|
|
// Check single range
|
|
res = await store.get(id, { start: 3, end: 7 });
|
|
expect(res.ok).toBe(true);
|
|
expect(await res.text()).toBe("34567");
|
|
|
|
// Check multiple ranges with no content type
|
|
res = await store.get(id, [
|
|
{ start: 5, end: 7 },
|
|
{ start: 8, end: 8 },
|
|
]);
|
|
expect(res.ok).toBe(true);
|
|
let contentTypeHeader = res.headers.get("Content-Type");
|
|
assert(contentTypeHeader !== null);
|
|
let [contentType, boundary] = contentTypeHeader.split("=");
|
|
expect(contentType).toBe("multipart/byteranges; boundary");
|
|
let actualText = await res.text();
|
|
let expectedText = [
|
|
`--${boundary}`,
|
|
"Content-Range: bytes 5-7/10",
|
|
"",
|
|
"567",
|
|
`--${boundary}`,
|
|
"Content-Range: bytes 8-8/10",
|
|
"",
|
|
"8",
|
|
`--${boundary}--`,
|
|
].join("\r\n");
|
|
expect(actualText).toBe(expectedText);
|
|
|
|
// Check multiple ranges with content type
|
|
res = await store.get(
|
|
id,
|
|
[
|
|
{ start: 1, end: 3 },
|
|
{ start: 5, end: 6 },
|
|
{ start: 0, end: 0 }, // (out of order)
|
|
{ start: 9, end: 9 },
|
|
],
|
|
{ contentType: "text/plain" }
|
|
);
|
|
expect(res.ok).toBe(true);
|
|
contentTypeHeader = res.headers.get("Content-Type");
|
|
assert(contentTypeHeader !== null);
|
|
[contentType, boundary] = contentTypeHeader.split("=");
|
|
expect(contentType).toBe("multipart/byteranges; boundary");
|
|
actualText = await res.text();
|
|
expectedText = [
|
|
`--${boundary}`,
|
|
"Content-Type: text/plain",
|
|
"Content-Range: bytes 1-3/10",
|
|
"",
|
|
"123",
|
|
`--${boundary}`,
|
|
"Content-Type: text/plain",
|
|
"Content-Range: bytes 5-6/10",
|
|
"",
|
|
"56",
|
|
`--${boundary}`,
|
|
"Content-Type: text/plain",
|
|
"Content-Range: bytes 0-0/10",
|
|
"",
|
|
"0",
|
|
`--${boundary}`,
|
|
"Content-Type: text/plain",
|
|
"Content-Range: bytes 9-9/10",
|
|
"",
|
|
"9",
|
|
`--${boundary}--`,
|
|
].join("\r\n");
|
|
expect(actualText).toBe(expectedText);
|
|
|
|
// Check multiple ranges with no ranges
|
|
res = await store.get(id, []);
|
|
expect(res.ok).toBe(true);
|
|
contentTypeHeader = res.headers.get("Content-Type");
|
|
assert(contentTypeHeader !== null);
|
|
[contentType, boundary] = contentTypeHeader.split("=");
|
|
expect(contentType).toBe("multipart/byteranges; boundary");
|
|
actualText = await res.text();
|
|
expectedText = `--${boundary}--`;
|
|
expect(actualText).toBe(expectedText);
|
|
|
|
// Check getting invalid ID returns null
|
|
res = await store.get("bad");
|
|
expect(res.status).toBe(404);
|
|
await res.arrayBuffer(); // (drain)
|
|
|
|
// Check getting invalid ID with multiple ranges returns null
|
|
res = await store.get("bad", [
|
|
{ start: 1, end: 2 },
|
|
{ start: 3, end: 4 },
|
|
]);
|
|
expect(res.status).toBe(404);
|
|
await res.arrayBuffer(); // (drain)
|
|
|
|
// Check getting ID outside root returns null
|
|
await fs.writeFile(path.join(namespacePath, "secrets.txt"), "password123");
|
|
res = await store.get("../secrets.txt");
|
|
expect(res.status).toBe(404);
|
|
await res.arrayBuffer(); // (drain)
|
|
});
|
|
|
|
test("BlobStore: delete", async ({ expect }) => {
|
|
const { tmp, store } = ctx;
|
|
|
|
// Check delete removes blob
|
|
let id = await store.put(new Blob(["value"]).stream());
|
|
let res = await store.get(id);
|
|
expect(res.ok).toBe(true);
|
|
await res.arrayBuffer(); // (drain)
|
|
await store.delete(id);
|
|
res = await store.get(id);
|
|
expect(res.status).toBe(404);
|
|
await res.arrayBuffer(); // (drain)
|
|
|
|
// Check delete whilst getting still returns value
|
|
id = await store.put(new Blob(["value"]).stream());
|
|
// (intentionally not consuming body immediately here)
|
|
const originalAssertConsumed = process.env.MINIFLARE_ASSERT_BODIES_CONSUMED;
|
|
process.env.MINIFLARE_ASSERT_BODIES_CONSUMED = undefined;
|
|
res = await store.get(id);
|
|
process.env.MINIFLARE_ASSERT_BODIES_CONSUMED = originalAssertConsumed;
|
|
expect(res.ok).toBe(true);
|
|
await store.delete(id);
|
|
expect(await res.text()).toBe("value");
|
|
|
|
// Check deleting invalid ID does nothing
|
|
await store.delete("whoops");
|
|
|
|
// Check deleting ID outside root does nothing
|
|
const importantPath = path.join(tmp, "unicorn.txt");
|
|
await fs.writeFile(importantPath, "❤️🦄");
|
|
await store.delete("../../unicorn.txt");
|
|
await store.delete("dir/../../../unicorn.txt");
|
|
expect(existsSync(importantPath)).toBe(true);
|
|
});
|