mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 01:52:55 +01:00
Reviewed and verified at 592bce69c6.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
161 lines
7.7 KiB
TypeScript
161 lines
7.7 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { Readable } from "node:stream";
|
|
import { createHash } from "node:crypto";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promises as fs } from "node:fs";
|
|
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
|
import { createStorageService } from "../storage/service.js";
|
|
|
|
async function readStreamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of stream) {
|
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
describe("local disk storage provider", () => {
|
|
const tempRoots: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
|
|
tempRoots.length = 0;
|
|
});
|
|
|
|
it("round-trips bytes through storage service", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
|
tempRoots.push(root);
|
|
|
|
const service = createStorageService(createLocalDiskStorageProvider(root));
|
|
const content = Buffer.from("hello image bytes", "utf8");
|
|
const stored = await service.putFile({
|
|
companyId: "company-1",
|
|
namespace: "issues/issue-1",
|
|
originalFilename: "demo.png",
|
|
contentType: "image/png",
|
|
body: content,
|
|
});
|
|
|
|
const fetched = await service.getObject("company-1", stored.objectKey);
|
|
const fetchedBody = await readStreamToBuffer(fetched.stream);
|
|
|
|
expect(fetchedBody.toString("utf8")).toBe("hello image bytes");
|
|
expect(stored.sha256).toHaveLength(64);
|
|
});
|
|
|
|
it("writes streamed files with exact metadata", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
|
tempRoots.push(root);
|
|
const service = createStorageService(createLocalDiskStorageProvider(root));
|
|
const bytes = Buffer.from("streamed response");
|
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
const stored = await service.putFile({ companyId: "company-1", namespace: "runner-api", originalFilename: "response.txt",
|
|
contentType: "text/plain", body: Readable.from([bytes.subarray(0, 4), bytes.subarray(4)]), byteSize: bytes.length, sha256 });
|
|
expect(stored).toMatchObject({ byteSize: bytes.length, sha256 });
|
|
expect(await readStreamToBuffer((await service.getObject("company-1", stored.objectKey)).stream)).toEqual(bytes);
|
|
});
|
|
|
|
it("removes partial local uploads when their source fails", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
|
tempRoots.push(root);
|
|
const provider = createLocalDiskStorageProvider(root);
|
|
const body = Readable.from((async function* () { yield Buffer.from("partial"); throw new Error("source failed"); })());
|
|
await expect(provider.putObject({ objectKey: "company-1/failed", contentType: "text/plain", contentLength: 20, body })).rejects.toThrow("source failed");
|
|
expect(await fs.readdir(path.join(root, "company-1"))).toEqual([]);
|
|
});
|
|
|
|
it("streams only requested byte ranges", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
|
tempRoots.push(root);
|
|
|
|
const service = createStorageService(createLocalDiskStorageProvider(root));
|
|
const stored = await service.putFile({
|
|
companyId: "company-1",
|
|
namespace: "issues/issue-1",
|
|
originalFilename: "demo.mp4",
|
|
contentType: "video/mp4",
|
|
body: Buffer.from("0123456789", "utf8"),
|
|
});
|
|
|
|
const fetched = await service.getObject("company-1", stored.objectKey, { range: { start: 2, end: 5 } });
|
|
const fetchedBody = await readStreamToBuffer(fetched.stream);
|
|
|
|
expect(fetchedBody.toString("utf8")).toBe("2345");
|
|
expect(fetched.contentLength).toBe(4);
|
|
});
|
|
|
|
it("blocks cross-company object access", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
|
tempRoots.push(root);
|
|
|
|
const service = createStorageService(createLocalDiskStorageProvider(root));
|
|
const stored = await service.putFile({
|
|
companyId: "company-a",
|
|
namespace: "issues/issue-1",
|
|
originalFilename: "demo.png",
|
|
contentType: "image/png",
|
|
body: Buffer.from("hello", "utf8"),
|
|
});
|
|
|
|
await expect(service.getObject("company-b", stored.objectKey)).rejects.toMatchObject({ status: 403 });
|
|
});
|
|
|
|
it("delete is idempotent", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
|
tempRoots.push(root);
|
|
|
|
const service = createStorageService(createLocalDiskStorageProvider(root));
|
|
const stored = await service.putFile({
|
|
companyId: "company-1",
|
|
namespace: "issues/issue-1",
|
|
originalFilename: "demo.png",
|
|
contentType: "image/png",
|
|
body: Buffer.from("hello", "utf8"),
|
|
});
|
|
|
|
await service.deleteObject("company-1", stored.objectKey);
|
|
await service.deleteObject("company-1", stored.objectKey);
|
|
await expect(service.getObject("company-1", stored.objectKey)).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
|
|
it.each(["EACCES", "EIO"])("retains storage uncertainty for %s instead of reporting absence", async code => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
|
tempRoots.push(root);
|
|
const service = createStorageService(createLocalDiskStorageProvider(root));
|
|
const stored = await service.putFile({ companyId: "company-1", namespace: "issues/issue-1", originalFilename: "retained.txt",
|
|
contentType: "text/plain", body: Buffer.from("must not disappear") });
|
|
const failure = Object.assign(new Error("controlled filesystem failure"), { code });
|
|
const unlink = vi.spyOn(fs, "unlink").mockRejectedValueOnce(failure);
|
|
try { await expect(service.deleteObject("company-1", stored.objectKey)).rejects.toBe(failure); }
|
|
finally { unlink.mockRestore(); }
|
|
expect(await service.headObject("company-1", stored.objectKey)).toMatchObject({ exists: true });
|
|
const stat = vi.spyOn(fs, "stat").mockRejectedValueOnce(failure);
|
|
try { await expect(service.headObject("company-1", stored.objectKey)).rejects.toBe(failure); }
|
|
finally { stat.mockRestore(); }
|
|
expect(await readStreamToBuffer((await service.getObject("company-1", stored.objectKey)).stream)).toEqual(Buffer.from("must not disappear"));
|
|
});
|
|
|
|
it("treats a non-directory object path as absent", async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-"));
|
|
tempRoots.push(root);
|
|
await fs.writeFile(path.join(root, "company-1"), "not a directory");
|
|
const service = createStorageService(createLocalDiskStorageProvider(root));
|
|
expect(await service.headObject("company-1", "company-1/object")).toEqual({ exists: false });
|
|
await expect(service.deleteObject("company-1", "company-1/object")).resolves.toBeUndefined();
|
|
});
|
|
|
|
it.each(["headObject", "deleteObject"] as const)("cancels %s even if a provider stops responding", async method => {
|
|
const provider = createLocalDiskStorageProvider(os.tmpdir());
|
|
const request = vi.spyOn(provider, method).mockImplementation(async () => new Promise<never>(() => {}));
|
|
const service = createStorageService(provider);
|
|
const controller = new AbortController();
|
|
const pending = service[method]("company-1", "company-1/expired", { signal: controller.signal });
|
|
expect(request).toHaveBeenCalledWith({ objectKey: "company-1/expired", signal: controller.signal });
|
|
controller.abort(new Error("maintenance stopped"));
|
|
await expect(pending).rejects.toThrow("maintenance stopped");
|
|
await expect(service[method]("company-1", "company-1/expired", { signal: controller.signal })).rejects.toThrow("maintenance stopped");
|
|
expect(request).toHaveBeenCalledOnce();
|
|
});
|
|
});
|