Files
edgeever/tests/storage-adapter.test.ts

229 lines
9.3 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { createCloudflareStorageAdapter } from "../apps/api/src/cloudflare-storage-adapter";
import { createSelfHostedStorageAdapter } from "../apps/api/src/self-hosted-storage-adapter";
import { SELF_HOSTED_DATABASE_DIALECT } from "../apps/api/src/self-hosted-storage-adapter";
import { createS3CompatibleStorageAdapter } from "../apps/api/src/s3-compatible-storage-adapter";
describe("storage adapter", () => {
test("wraps Cloudflare bindings without changing their identity", () => {
const db = { prepare: () => undefined, batch: () => undefined };
const resources = { get: async () => null, put: async () => undefined, delete: async () => undefined };
const adapter = createCloudflareStorageAdapter({ DB: db, RESOURCES: resources } as never);
expect(adapter.db).toBe(db);
expect(adapter.resources).toBe(resources);
expect(adapter.diagnostics).toEqual({
database: "d1",
resources: "r2",
migrationTable: "d1_migrations",
});
});
test("keeps the self-hosted database dialect explicit", () => {
expect(SELF_HOSTED_DATABASE_DIALECT).toBe("sqlite");
});
test("implements the portable statement result contract without D1 types", async () => {
const executions: Array<{ sql: string; bindings: unknown[] }> = [];
const sqlite = {
query: (sql: string) => ({
all: (...bindings: unknown[]) => [{ sql, value: bindings[0] }],
get: (...bindings: unknown[]) => ({ sql, value: bindings[0] }),
run: (...bindings: unknown[]) => {
executions.push({ sql, bindings });
return { changes: 1, lastInsertRowid: 7 };
},
}),
transaction: (callback: () => void) => () => callback(),
};
const database = createSelfHostedStorageAdapter(sqlite, ".edgeever-unused-resources").db;
const statement = database.prepare("SELECT ? AS value").bind("edgeever");
await expect(statement.all<{ sql: string; value: string }>()).resolves.toMatchObject({
success: true,
results: [{ sql: "SELECT ? AS value", value: "edgeever" }],
});
await expect(statement.first<string>("value")).resolves.toBe("edgeever");
await expect(database.prepare("UPDATE notes SET value = ?").bind("updated").run()).resolves.toMatchObject({
success: true,
results: [],
meta: { changes: 1, lastInsertRowid: 7 },
});
await expect(database.batch([
database.prepare("DELETE FROM notes WHERE id = ?").bind("one"),
database.prepare("DELETE FROM notes WHERE id = ?").bind("two"),
])).resolves.toHaveLength(2);
expect(executions.map(({ bindings }) => bindings)).toEqual([
["updated"],
["one"],
["two"],
]);
});
test("stores attachments in a persistent filesystem directory", async () => {
const directory = await mkdtemp(`${tmpdir()}/edgeever-storage-`);
const sqlite = {
query: () => ({ all: () => [], get: () => null, run: () => undefined }),
transaction: (callback: () => void) => () => callback(),
};
try {
const adapter = createSelfHostedStorageAdapter(sqlite, directory);
expect(adapter.diagnostics).toEqual({
database: "sqlite",
resources: "filesystem",
migrationTable: "_edgeever_migrations",
});
await adapter.resources.put("workspace/memo/image.bin", new Uint8Array([1, 2, 3]));
expect(await readFile(`${directory}/workspace/memo/image.bin`)).toEqual(new Uint8Array([1, 2, 3]));
expect(await adapter.resources.get("workspace/memo/image.bin")).not.toBeNull();
await adapter.resources.delete("workspace/memo/image.bin");
expect(await adapter.resources.get("workspace/memo/image.bin")).toBeNull();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("streams full and ranged filesystem downloads in bounded chunks", async () => {
const directory = await mkdtemp(`${tmpdir()}/edgeever-storage-`);
const sqlite = {
query: () => ({ all: () => [], get: () => null, run: () => undefined }),
transaction: (callback: () => void) => () => callback(),
};
const payload = new Uint8Array(2 * 1024 * 1024);
payload.fill(7);
try {
const store = createSelfHostedStorageAdapter(sqlite, directory).resources;
await store.put("workspace/memo/large.bin", payload);
const object = await store.get("workspace/memo/large.bin");
const reader = object!.body.getReader();
const first = await reader.read();
expect(first.done).toBe(false);
expect(first.value!.byteLength).toBeLessThan(payload.byteLength);
await reader.cancel();
const ranged = await store.get("workspace/memo/large.bin", {
range: { offset: 1_000_000, length: 100_000 },
});
expect(ranged?.range).toEqual({ offset: 1_000_000, length: 100_000 });
expect((await new Response(ranged!.body).arrayBuffer()).byteLength).toBe(100_000);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("assembles filesystem multipart uploads without exposing staging parts", async () => {
const directory = await mkdtemp(`${tmpdir()}/edgeever-storage-`);
const sqlite = {
query: () => ({ all: () => [], get: () => null, run: () => undefined }),
transaction: (callback: () => void) => () => callback(),
};
try {
const store = createSelfHostedStorageAdapter(sqlite, directory).resources;
const upload = await store.createMultipartUpload("workspace/memo/archive.bin");
const second = await upload.uploadPart(2, new Uint8Array([4, 5]));
const first = await upload.uploadPart(1, new Uint8Array([1, 2, 3]));
await upload.complete([first, second]);
expect(await readFile(`${directory}/workspace/memo/archive.bin`)).toEqual(
new Uint8Array([1, 2, 3, 4, 5]),
);
expect(await store.get(`.uploads/${upload.uploadId}/1.part`)).toBeNull();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("rejects attachment path traversal", async () => {
const directory = await mkdtemp(`${tmpdir()}/edgeever-storage-`);
const sqlite = {
query: () => ({ all: () => [], get: () => null, run: () => undefined }),
transaction: (callback: () => void) => () => callback(),
};
try {
const adapter = createSelfHostedStorageAdapter(sqlite, directory);
await expect(adapter.resources.put("../outside", new Uint8Array([1]))).rejects.toThrow(
"Invalid resource object key",
);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
test("maps the common object operations to an S3-compatible client", async () => {
const commands: string[] = [];
const client = {
send: async (command: { constructor: { name: string }; input: { Key?: string } }) => {
commands.push(`${command.constructor.name}:${command.input.Key ?? ""}`);
if (command.constructor.name === "GetObjectCommand") {
return { Body: new Blob(["edgeever"]), ContentLength: 8, ContentType: "text/plain" };
}
return {};
},
};
const sqlite = {
query: () => ({ all: () => [], get: () => null, run: () => undefined }),
transaction: (callback: () => void) => () => callback(),
};
const adapter = createS3CompatibleStorageAdapter(
sqlite,
{ bucket: "edgeever", endpoint: "http://minio:9000" },
client as never,
);
expect(adapter.diagnostics).toEqual({
database: "sqlite",
resources: "s3",
migrationTable: "_edgeever_migrations",
});
await adapter.resources.put("memo/image.txt", new Uint8Array([1]), {
httpMetadata: { contentType: "text/plain" },
});
expect(await adapter.resources.get("memo/image.txt")).not.toBeNull();
await adapter.resources.delete(["memo/image.txt", "memo/other.txt"]);
expect(commands).toEqual([
"PutObjectCommand:memo/image.txt",
"GetObjectCommand:memo/image.txt",
"DeleteObjectsCommand:",
]);
});
test("maps multipart lifecycle operations to an S3-compatible client", async () => {
const commands: string[] = [];
const client = {
send: async (command: { constructor: { name: string }; input: { PartNumber?: number } }) => {
commands.push(`${command.constructor.name}:${command.input.PartNumber ?? ""}`);
if (command.constructor.name === "CreateMultipartUploadCommand") return { UploadId: "provider-upload" };
if (command.constructor.name === "UploadPartCommand") return { ETag: `etag-${command.input.PartNumber}` };
return {};
},
};
const sqlite = {
query: () => ({ all: () => [], get: () => null, run: () => undefined }),
transaction: (callback: () => void) => () => callback(),
};
const store = createS3CompatibleStorageAdapter(
sqlite,
{ bucket: "edgeever", endpoint: "http://minio:9000" },
client as never,
).resources;
const upload = await store.createMultipartUpload("memo/archive.bin");
const part = await upload.uploadPart(1, new Uint8Array([1, 2, 3]));
await upload.complete([part]);
expect(part).toEqual({ partNumber: 1, etag: "etag-1" });
expect(commands).toEqual([
"CreateMultipartUploadCommand:",
"UploadPartCommand:1",
"CompleteMultipartUploadCommand:",
]);
});
});