Node's default 5-minute requestTimeout cut off a 500 MiB video upload on any uplink slower than about 14 Mbit/s, so the video cap was unusable over ordinary connections. - The API server's requestTimeout is 30 minutes, enough for the largest upload at about 2.5 Mbit/s. headersTimeout keeps its 60-second default. - Each streamed upload aborts when the client sends no bytes for 60 seconds: the route answers 408, the partial file is deleted and no row is written, so a slow-drip client cannot hold a connection for the full request timeout. Time spent writing to disk does not count. - The store and the upload guard no longer wait for a body to confirm its cancellation, which a stalled or hostile client may never do. - A guard test checks that every file the executor attaches records the thread's group, which the render_plot chart fallback once dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
|
import { createServer } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { ArtifactStore } from "@rakazo/adapter-kit";
|
|
import { LocalArtifactStore } from "@rakazo/adapters";
|
|
import type { PrismaClient } from "@rakazo/db";
|
|
import { Hono } from "hono";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
API_SERVER_OPTIONS,
|
|
contentDisposition,
|
|
mountArtifactHttpRoutes,
|
|
parseByteRange,
|
|
stallGuardedChunks,
|
|
UploadStalledError,
|
|
} from "./artifact-http.js";
|
|
|
|
describe("parseByteRange", () => {
|
|
it("ignores an absent header or another unit", () => {
|
|
expect(parseByteRange(undefined, 100)).toBeNull();
|
|
expect(parseByteRange("", 100)).toBeNull();
|
|
expect(parseByteRange("items=0-1", 100)).toBeNull();
|
|
});
|
|
|
|
it("parses closed, open and suffix ranges and clamps the end to the size", () => {
|
|
expect(parseByteRange("bytes=0-9", 100)).toEqual({ start: 0, end: 9 });
|
|
expect(parseByteRange("bytes=90-", 100)).toEqual({ start: 90, end: 99 });
|
|
expect(parseByteRange("bytes=-10", 100)).toEqual({ start: 90, end: 99 });
|
|
expect(parseByteRange("bytes=-500", 100)).toEqual({ start: 0, end: 99 });
|
|
expect(parseByteRange("bytes=50-1000", 100)).toEqual({ start: 50, end: 99 });
|
|
expect(parseByteRange("Bytes= 0-0 ", 100)).toEqual({ start: 0, end: 0 });
|
|
});
|
|
|
|
it("rejects multiple, inverted, out-of-file and malformed ranges", () => {
|
|
for (const header of [
|
|
"bytes=0-1,5-6",
|
|
"bytes=5-4",
|
|
"bytes=100-",
|
|
"bytes=100-200",
|
|
"bytes=-0",
|
|
"bytes=-",
|
|
"bytes=a-b",
|
|
"bytes=1.5-2",
|
|
"bytes= -1-2",
|
|
"bytes=0x10-20",
|
|
`bytes=${"9".repeat(16)}-`,
|
|
]) {
|
|
expect(parseByteRange(header, 100), header).toBe("unsatisfiable");
|
|
}
|
|
expect(parseByteRange("bytes=0-0", 0)).toBe("unsatisfiable");
|
|
});
|
|
});
|
|
|
|
describe("contentDisposition", () => {
|
|
it("keeps plain names and marks media inline", () => {
|
|
expect(contentDisposition("clip.mp4", true)).toBe(
|
|
`inline; filename="clip.mp4"; filename*=UTF-8''clip.mp4`,
|
|
);
|
|
});
|
|
|
|
it("neutralizes quotes, separators, control characters and header injection", () => {
|
|
const header = contentDisposition('../evil"\r\nSet-Cookie: x=1;.txt', false);
|
|
expect(header.startsWith("attachment; ")).toBe(true);
|
|
expect(header).not.toMatch(/[\r\n]/);
|
|
expect(header).toContain(`filename=".._evil___Set-Cookie: x=1_.txt"`);
|
|
expect(header).not.toContain("/");
|
|
});
|
|
|
|
it("encodes non-ASCII names with an ASCII fallback", () => {
|
|
expect(contentDisposition("Grüße (1).png", true)).toBe(
|
|
`inline; filename="Gr__e (1).png"; filename*=UTF-8''Gr%C3%BC%C3%9Fe%20%281%29.png`,
|
|
);
|
|
expect(contentDisposition(" ", false)).toBe(
|
|
`attachment; filename="download"; filename*=UTF-8''download`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("artifact HTTP routes", () => {
|
|
function app(sessionUserId: () => Promise<string | null>) {
|
|
const prisma = {
|
|
artifact: { findUnique: vi.fn(), create: vi.fn() },
|
|
spaceMember: { findFirst: vi.fn() },
|
|
};
|
|
const artifacts = { putStream: vi.fn(), stat: vi.fn(), createReadStream: vi.fn() };
|
|
const hono = new Hono();
|
|
mountArtifactHttpRoutes(hono, {
|
|
prisma: prisma as unknown as PrismaClient,
|
|
artifacts: artifacts as unknown as ArtifactStore,
|
|
sessionUserId,
|
|
trustedOrigin: (origin) => origin === "http://127.0.0.1:5173",
|
|
});
|
|
return { hono, prisma, artifacts };
|
|
}
|
|
|
|
it("rejects signed-out reads and uploads before touching storage", async () => {
|
|
const { hono, prisma, artifacts } = app(async () => null);
|
|
const read = await hono.request("/api/artifacts/art-1");
|
|
const upload = await hono.request("/api/artifacts?botId=bot-1&name=a.mp4", {
|
|
method: "POST",
|
|
headers: { "content-type": "video/mp4" },
|
|
body: "data",
|
|
});
|
|
expect(read.status).toBe(401);
|
|
expect(upload.status).toBe(401);
|
|
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
|
|
expect(artifacts.putStream).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses uploads from untrusted origins before reading the session", async () => {
|
|
const sessionUserId = vi.fn(async () => "user-1");
|
|
const { hono, artifacts } = app(sessionUserId);
|
|
const response = await hono.request("/api/artifacts?botId=bot-1&name=a.txt", {
|
|
method: "POST",
|
|
headers: { "content-type": "text/plain", origin: "https://evil.example" },
|
|
body: "data",
|
|
});
|
|
expect(response.status).toBe(403);
|
|
expect(sessionUserId).not.toHaveBeenCalled();
|
|
expect(artifacts.putStream).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("answers ids that are not artifact ids with 404 without a lookup", async () => {
|
|
const { hono, prisma } = app(async () => "user-1");
|
|
const response = await hono.request(`/api/artifacts/${encodeURIComponent("../etc/passwd")}`);
|
|
expect(response.status).toBe(404);
|
|
expect(prisma.artifact.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects declared oversized and disallowed uploads without reading the body", async () => {
|
|
const { hono, prisma, artifacts } = app(async () => "user-1");
|
|
prisma.spaceMember.findFirst.mockResolvedValue({
|
|
userId: "user-1",
|
|
spaceId: "space-1",
|
|
member: { user: { email: "user@example.test" } },
|
|
});
|
|
(prisma as unknown as { deploymentSettings: unknown }).deploymentSettings = {
|
|
findUnique: vi.fn(async () => null),
|
|
};
|
|
const cancel = vi.fn(() => new Promise<void>(() => undefined));
|
|
const oversized = await hono.request(
|
|
new Request("http://localhost/api/artifacts?botId=bot-1&name=a.mp4", {
|
|
method: "POST",
|
|
headers: { "content-type": "video/mp4", "content-length": String(501 * 1024 * 1024) },
|
|
body: new ReadableStream({ cancel }),
|
|
duplex: "half",
|
|
} as RequestInit & { duplex: "half" }),
|
|
);
|
|
expect(oversized.status).toBe(413);
|
|
expect(cancel).toHaveBeenCalled();
|
|
|
|
const html = await hono.request("/api/artifacts?botId=bot-1&name=a.html", {
|
|
method: "POST",
|
|
headers: { "content-type": "text/html" },
|
|
body: "<script></script>",
|
|
});
|
|
expect(html.status).toBe(415);
|
|
|
|
const noTarget = await hono.request("/api/artifacts?name=a.mp4", {
|
|
method: "POST",
|
|
headers: { "content-type": "video/mp4" },
|
|
body: "data",
|
|
});
|
|
expect(noTarget.status).toBe(400);
|
|
expect(artifacts.putStream).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("upload timeouts", () => {
|
|
const dirs: string[] = [];
|
|
afterEach(async () => {
|
|
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
it("gives the server time for large uploads but keeps the header timeout", () => {
|
|
const server = createServer(API_SERVER_OPTIONS);
|
|
expect(server.requestTimeout).toBe(30 * 60 * 1000);
|
|
expect(server.headersTimeout).toBe(createServer().headersTimeout);
|
|
});
|
|
|
|
/** A body that sends `chunks` at `gapMs` intervals, then stays open forever if `hang`. */
|
|
function drip(chunks: string[], gapMs: number, hang: boolean) {
|
|
const cancel = vi.fn(() => new Promise<void>(() => undefined));
|
|
let sent = 0;
|
|
const body = new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
if (sent === chunks.length) {
|
|
if (!hang) controller.close();
|
|
else await new Promise(() => undefined);
|
|
return;
|
|
}
|
|
if (sent > 0) await new Promise((resolve) => setTimeout(resolve, gapMs));
|
|
controller.enqueue(new TextEncoder().encode(chunks[sent++]));
|
|
},
|
|
cancel,
|
|
});
|
|
return { body, cancel };
|
|
}
|
|
|
|
it("throws once the body stops sending and cancels it without waiting", async () => {
|
|
const { body, cancel } = drip(["ab", "cd"], 5, true);
|
|
const seen: string[] = [];
|
|
await expect(
|
|
(async () => {
|
|
for await (const chunk of stallGuardedChunks(body, 50)) {
|
|
seen.push(new TextDecoder().decode(chunk));
|
|
}
|
|
})(),
|
|
).rejects.toBeInstanceOf(UploadStalledError);
|
|
expect(seen).toEqual(["ab", "cd"]);
|
|
expect(cancel).toHaveBeenCalled();
|
|
});
|
|
|
|
async function postDrip(stallMs: number, body: ReadableStream<Uint8Array>) {
|
|
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-upload-"));
|
|
dirs.push(root);
|
|
const create = vi.fn(async ({ data }: { data: Record<string, unknown> }) => ({
|
|
id: "art-1",
|
|
createdAt: new Date(),
|
|
...data,
|
|
}));
|
|
const prisma = {
|
|
artifact: { create },
|
|
spaceMember: {
|
|
findFirst: vi.fn(async () => ({
|
|
userId: "user-1",
|
|
spaceId: "space-1",
|
|
member: { user: { email: "user@example.test" } },
|
|
})),
|
|
},
|
|
deploymentSettings: { findUnique: vi.fn(async () => null) },
|
|
bot: { findFirst: vi.fn(async () => ({ id: "bot-1" })) },
|
|
};
|
|
const hono = new Hono();
|
|
mountArtifactHttpRoutes(hono, {
|
|
prisma: prisma as unknown as PrismaClient,
|
|
artifacts: new LocalArtifactStore(root),
|
|
sessionUserId: async () => "user-1",
|
|
trustedOrigin: () => true,
|
|
uploadStallTimeoutMs: stallMs,
|
|
});
|
|
const response = await hono.request(
|
|
new Request("http://localhost/api/artifacts?botId=bot-1&name=clip.mp4", {
|
|
method: "POST",
|
|
headers: { "content-type": "video/mp4" },
|
|
body,
|
|
duplex: "half",
|
|
} as RequestInit & { duplex: "half" }),
|
|
);
|
|
// The store creates the space directory before it reads the body, so it must exist here.
|
|
const stored = await readdir(path.join(root, "artifacts", "space-1"));
|
|
return { response, create, stored };
|
|
}
|
|
|
|
it("aborts a stalled upload with 408 and deletes the partial file", async () => {
|
|
const { body, cancel } = drip(["partial video bytes"], 0, true);
|
|
const { response, create, stored } = await postDrip(50, body);
|
|
expect(response.status).toBe(408);
|
|
expect(await response.json()).toEqual({ error: "Upload stalled." });
|
|
expect(cancel).toHaveBeenCalled();
|
|
expect(create).not.toHaveBeenCalled();
|
|
expect(stored).toEqual([]);
|
|
});
|
|
|
|
it("keeps a slow but steady upload going past the stall timeout", async () => {
|
|
const { body } = drip(["a", "b", "c", "d", "e", "f"], 30, false);
|
|
const { response, create, stored } = await postDrip(100, body);
|
|
expect(response.status).toBe(201);
|
|
expect(create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ size: 6, mimeType: "video/mp4", botId: "bot-1" }),
|
|
});
|
|
expect(stored).toHaveLength(1);
|
|
});
|
|
});
|