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) { 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(() => 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: "", }); 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(() => undefined)); let sent = 0; const body = new ReadableStream({ 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) { const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifact-upload-")); dirs.push(root); const create = vi.fn(async ({ data }: { data: Record }) => ({ 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); }); });