evOo-local/packages/adapters/src/artifacts.test.ts
Claude c17445874d
style: use top-level type imports in the media changes
AGENTS.md asks for top-level `import type` instead of inline `type`
specifiers; this applies it to the import statements the media work added
or changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:50:16 +00:00

199 lines
7.5 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdtemp, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { AdapterContext } from "@rakazo/adapter-kit";
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
import { afterEach, describe, expect, it } from "vitest";
import { LocalArtifactStore } from "./artifacts.js";
const dirs: string[] = [];
const context = { spaceId: "space-1", signal: new AbortController().signal } as AdapterContext;
async function tempStore() {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
dirs.push(root);
return { root, store: new LocalArtifactStore(root) };
}
async function readAll(stream: ReadableStream<Uint8Array>) {
return Buffer.from(await new Response(stream).arrayBuffer());
}
async function* chunks(...parts: string[]) {
for (const part of parts) yield new TextEncoder().encode(part);
}
afterEach(async () => {
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("LocalArtifactStore", () => {
it("creates artifact files with owner-only permissions", async () => {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
dirs.push(root);
const store = new LocalArtifactStore(root);
const stored = await store.put(
{ name: "private.txt", mimeType: "text/plain", bytes: new TextEncoder().encode("private") },
context,
);
const info = await stat(path.join(root, "artifacts", "space-1", stored.id));
expect(info.mode & 0o777).toBe(0o600);
});
it("does not follow a replacement symlink when reading an artifact", async () => {
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
dirs.push(root);
const store = new LocalArtifactStore(root);
const stored = await store.put(
{ name: "private.txt", mimeType: "text/plain", bytes: new TextEncoder().encode("private") },
context,
);
const file = path.join(root, "artifacts", "space-1", stored.id);
const target = path.join(root, "outside.txt");
await writeFile(target, "outside");
await rm(file);
await symlink(target, file);
await expect(store.get(stored.id, context)).rejects.toThrow();
});
it("streams uploads to disk with a real hash, size and owner-only mode", async () => {
const { root, store } = await tempStore();
const stored = await store.putStream(
{
name: "clip.mp4",
mimeType: "video/mp4",
stream: new Response("hello media world").body!,
maxBytes: 1024,
},
context,
);
expect(stored.size).toBe(17);
expect(stored.sha256).toBe(createHash("sha256").update("hello media world").digest("hex"));
const info = await stat(path.join(root, "artifacts", "space-1", stored.key));
expect(info.mode & 0o777).toBe(0o600);
expect((await store.stat(stored.key, context)).size).toBe(17);
expect(await store.localPath(stored.key, context)).toBe(
path.join(root, "artifacts", "space-1", stored.key),
);
expect((await store.get(stored.key, context)).byteLength).toBe(17);
});
it("accepts async iterables and rejects bodies over the cap without leaving a file", async () => {
const { root, store } = await tempStore();
const ok = await store.putStream(
{ name: "a.txt", mimeType: "text/plain", stream: chunks("ab", "cd"), maxBytes: 4 },
context,
);
expect(ok.size).toBe(4);
await expect(
store.putStream(
{ name: "b.txt", mimeType: "text/plain", stream: chunks("abc", "de"), maxBytes: 4 },
context,
),
).rejects.toBeInstanceOf(ArtifactTooLargeError);
expect(await readdir(path.join(root, "artifacts", "space-1"))).toEqual([ok.key]);
});
it("stops and cleans up when the run is cancelled mid-upload", async () => {
const { root, store } = await tempStore();
const controller = new AbortController();
async function* aborting() {
yield new TextEncoder().encode("first");
controller.abort();
yield new TextEncoder().encode("second");
}
await expect(
store.putStream(
{ name: "c.txt", mimeType: "text/plain", stream: aborting(), maxBytes: 100 },
{ ...context, signal: controller.signal },
),
).rejects.toThrow();
expect(await readdir(path.join(root, "artifacts", "space-1"))).toEqual([]);
});
it("reads whole artifacts and inclusive byte ranges", async () => {
const { store } = await tempStore();
const stored = await store.putStream(
{ name: "n.txt", mimeType: "text/plain", stream: chunks("0123456789"), maxBytes: 100 },
context,
);
expect((await readAll(await store.createReadStream(stored.key, context))).toString()).toBe(
"0123456789",
);
const middle = await store.createReadStream(stored.key, context, { start: 2, end: 5 });
expect((await readAll(middle)).toString()).toBe("2345");
const last = await store.createReadStream(stored.key, context, { start: 9, end: 9 });
expect((await readAll(last)).toString()).toBe("9");
for (const range of [
{ start: 0, end: 10 },
{ start: 5, end: 4 },
{ start: -1, end: 3 },
{ start: 1.5, end: 3 },
]) {
await expect(store.createReadStream(stored.key, context, range)).rejects.toBeInstanceOf(
RangeError,
);
}
});
it("streams large artifacts in bounded chunks", async () => {
const { store } = await tempStore();
const bytes = Buffer.alloc(700 * 1024, 7);
const stored = await store.putStream(
{
name: "big.bin",
mimeType: "text/plain",
stream: new Response(bytes).body!,
maxBytes: bytes.length,
},
context,
);
const reader = (await store.createReadStream(stored.key, context)).getReader();
let total = 0;
let largest = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
largest = Math.max(largest, value.byteLength);
}
expect(total).toBe(bytes.length);
expect(largest).toBeLessThanOrEqual(256 * 1024);
});
it("rejects keys and spaces that could address another path", async () => {
const { store } = await tempStore();
for (const key of ["../space-2/x", "a/b", ".hidden", "", "x".repeat(200)]) {
await expect(store.stat(key, context)).rejects.toThrow("Invalid artifact key");
await expect(store.createReadStream(key, context)).rejects.toThrow("Invalid artifact key");
await expect(store.remove(key, context)).rejects.toThrow("Invalid artifact key");
}
await expect(
store.putStream(
{ name: "x", mimeType: "text/plain", stream: chunks("x"), maxBytes: 10 },
{ ...context, spaceId: "../escape" },
),
).rejects.toThrow("Invalid artifact space");
});
it("does not follow a replacement symlink when streaming or resolving a local path", async () => {
const { root, store } = await tempStore();
const stored = await store.putStream(
{ name: "p.txt", mimeType: "text/plain", stream: chunks("private"), maxBytes: 100 },
context,
);
const file = path.join(root, "artifacts", "space-1", stored.key);
const target = path.join(root, "outside.txt");
await writeFile(target, "outside");
await rm(file);
await symlink(target, file);
await expect(store.createReadStream(stored.key, context)).rejects.toThrow();
await expect(store.stat(stored.key, context)).rejects.toThrow();
await expect(store.localPath(stored.key, context)).rejects.toThrow();
});
});