import { mkdtempSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { PrismaClient } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { sessionCookieHeader } from "./index.js"; type App = { request: (input: string | Request, init?: RequestInit) => Promise }; process.env.WAKEUP_DRIVER = "memory"; process.env.SANDBOX_PROVIDER = "fake"; process.env.AGENT_RUNTIME = "scripted"; const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); const describeRoutes = hasDb ? describe : describe.skip; const ORIGIN = "http://127.0.0.1:5173"; describeRoutes("artifact HTTP routes", () => { let app: App; let prisma: PrismaClient; let stop: () => Promise; const stamp = Date.now(); const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-artifact-routes-")); const video = Buffer.from("0123456789abcdefghij"); beforeAll(async () => { const { createApp } = await import("../../../apps/api/src/app.ts"); const handles = await createApp({ databaseUrl: process.env.DATABASE_URL!, dataDir, sandboxProvider: "fake", agentRuntime: "scripted", }); app = handles.app; prisma = handles.prisma; stop = handles.stop; }); afterAll(async () => { await stop?.(); }); it("uploads by stream and serves full, ranged, cached and download responses", async () => { const owner = await signup(app, `artifact-route-${stamp}@rakazo.test`, "Route Owner"); const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Streamer")); const uploaded = await upload(app, owner, { botId: bot.id }, "clip.mp4", "video/mp4", video); expect(uploaded.status).toBe(201); const artifact = (await uploaded.json()) as { id: string; size: number; groupId: null }; expect(artifact).toMatchObject({ size: video.length, groupId: null, mimeType: "video/mp4" }); const row = await prisma.artifact.findUniqueOrThrow({ where: { id: artifact.id } }); expect(row).toMatchObject({ botId: bot.id, groupId: null, runId: null, name: "clip.mp4" }); expect(row.hash).toMatch(/^[0-9a-f]{64}$/); const full = await get(app, owner, artifact.id); expect(full.status).toBe(200); expect(full.headers.get("content-type")).toBe("video/mp4"); expect(full.headers.get("accept-ranges")).toBe("bytes"); expect(full.headers.get("content-length")).toBe(String(video.length)); expect(full.headers.get("content-disposition")).toMatch(/^inline; filename="clip\.mp4"/); expect(full.headers.get("x-content-type-options")).toBe("nosniff"); expect(full.headers.get("content-security-policy")).toContain("media-src 'self' blob:"); expect(Buffer.from(await full.arrayBuffer())).toEqual(video); const ranged = await get(app, owner, artifact.id, { range: "bytes=2-5" }); expect(ranged.status).toBe(206); expect(ranged.headers.get("content-range")).toBe(`bytes 2-5/${video.length}`); expect(ranged.headers.get("content-length")).toBe("4"); expect(await ranged.text()).toBe("2345"); const suffix = await get(app, owner, artifact.id, { range: "bytes=-3" }); expect(suffix.status).toBe(206); expect(await suffix.text()).toBe("hij"); for (const range of [`bytes=${video.length}-`, "bytes=0-1,4-5", "bytes=x-y"]) { const rejected = await get(app, owner, artifact.id, { range }); expect(rejected.status, range).toBe(416); expect(rejected.headers.get("content-range")).toBe(`bytes */${video.length}`); } const etag = full.headers.get("etag")!; expect(etag).toBe(`"${row.hash}"`); expect((await get(app, owner, artifact.id, { "if-none-match": etag })).status).toBe(304); const staleIfRange = await get(app, owner, artifact.id, { range: "bytes=0-1", "if-range": '"other"', }); expect(staleIfRange.status).toBe(200); expect(Buffer.from(await staleIfRange.arrayBuffer())).toEqual(video); const head = await app.request(`/api/artifacts/${artifact.id}`, { method: "HEAD", headers: { cookie: owner }, }); expect(head.status).toBe(200); expect(head.headers.get("content-length")).toBe(String(video.length)); const download = await app.request(`/api/artifacts/${artifact.id}?download=1`, { headers: { cookie: owner }, }); expect(download.headers.get("content-disposition")).toMatch(/^attachment; /); await download.body?.cancel(); // Artifacts created through the inline RPC are served the same way; files download. const notes = await rpc<{ id: string }>(app, owner, "artifacts/create", { botId: bot.id, name: "notes.txt", mimeType: "text/plain", contentBase64: Buffer.from("hello notes").toString("base64"), }); const notesResponse = await get(app, owner, notes.id); expect(notesResponse.headers.get("content-disposition")).toMatch(/^attachment; /); expect(await notesResponse.text()).toBe("hello notes"); }); it("denies other users, other spaces, archived threads and signed-out requests", async () => { const owner = await signup(app, `artifact-owner-${stamp}@rakazo.test`, "Artifact Owner"); const intruder = await signup(app, `artifact-intruder-${stamp}@rakazo.test`, "Intruder"); const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Owner Bot")); const bot2 = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Owner Bot 2")); const group = await rpc<{ id: string }>(app, owner, "groups/create", { name: "Owner Group", botIds: [bot.id, bot2.id], }); const botArtifact = (await ( await upload(app, owner, { botId: bot.id }, "a.mp3", "audio/mpeg", video) ).json()) as { id: string }; const groupUpload = await upload( app, owner, { groupId: group.id }, "g.png", "image/png", video, ); expect(groupUpload.status).toBe(201); const groupArtifact = (await groupUpload.json()) as { id: string; groupId: string }; expect(groupArtifact.groupId).toBe(group.id); expect((await get(app, owner, groupArtifact.id)).status).toBe(200); // Another user: reads and uploads into the owner's threads look like missing resources. expect((await get(app, intruder, botArtifact.id)).status).toBe(404); expect((await get(app, intruder, groupArtifact.id)).status).toBe(404); expect( (await upload(app, intruder, { botId: bot.id }, "x.mp4", "video/mp4", video)).status, ).toBe(404); expect( (await upload(app, intruder, { groupId: group.id }, "x.mp4", "video/mp4", video)).status, ).toBe(404); // Signed out. expect((await app.request(`/api/artifacts/${botArtifact.id}`)).status).toBe(401); expect( ( await app.request(`/api/artifacts?botId=${bot.id}&name=x.mp4`, { method: "POST", headers: { "content-type": "video/mp4" }, body: video, }) ).status, ).toBe(401); // An explicit space header must name the artifact's space, like the RPC. const other = await rpc<{ id: string }>(app, owner, "spaces/create", { name: "Other space" }); expect((await get(app, owner, botArtifact.id, { "x-rakazo-space-id": other.id })).status).toBe( 404, ); expect( ( await upload(app, owner, { botId: bot.id }, "x.mp4", "video/mp4", video, { "x-rakazo-space-id": other.id, }) ).status, ).toBe(404); // Archived threads stop serving their artifacts, as artifacts.get does. await rpc(app, owner, "groups/archive", { groupId: group.id }); expect((await get(app, owner, groupArtifact.id)).status).toBe(404); await rpc(app, owner, "bots/archive", { botId: bot.id }); expect((await get(app, owner, botArtifact.id)).status).toBe(404); }); it("keeps a shared-space member out of another member's artifacts", async () => { const owner = await signup(app, `artifact-shared-owner-${stamp}@rakazo.test`, "Shared Owner"); const member = await signup(app, `artifact-shared-member-${stamp}@rakazo.test`, "Member"); const shared = await rpc<{ id: string }>(app, owner, "spaces/create", { name: "Shared" }); const sharedRow = await prisma.space.findUniqueOrThrow({ where: { id: shared.id }, select: { organizationId: true }, }); const memberActor = await rpc<{ userId: string }>(app, member, "me"); await prisma.member.deleteMany({ where: { userId: memberActor.userId } }); await prisma.member.create({ data: { id: `artifact-shared-org-${stamp}`, organizationId: sharedRow.organizationId, userId: memberActor.userId, role: "member", createdAt: new Date(), }, }); await prisma.spaceMember.create({ data: { id: `artifact-shared-member-${stamp}`, spaceId: shared.id, organizationId: sharedRow.organizationId, userId: memberActor.userId, role: "member", createdAt: new Date(), }, }); const ownerBot = await rpc<{ id: string }>( app, owner, "bots/create", botInput("Shared Owner Bot"), shared.id, ); const artifact = (await ( await upload(app, owner, { botId: ownerBot.id }, "s.webm", "video/webm", video, { "x-rakazo-space-id": shared.id, }) ).json()) as { id: string }; expect((await get(app, owner, artifact.id)).status).toBe(200); expect((await get(app, member, artifact.id)).status).toBe(404); expect((await get(app, member, artifact.id, { "x-rakazo-space-id": shared.id })).status).toBe( 404, ); expect( ( await upload(app, member, { botId: ownerBot.id }, "x.mp4", "video/mp4", video, { "x-rakazo-space-id": shared.id, }) ).status, ).toBe(404); }); it("enforces type and size limits on uploads, including chunked bodies", async () => { const owner = await signup(app, `artifact-limits-${stamp}@rakazo.test`, "Limits"); const bot = await rpc<{ id: string }>(app, owner, "bots/create", botInput("Limits Bot")); const before = await prisma.artifact.count({ where: { botId: bot.id } }); expect((await upload(app, owner, { botId: bot.id }, "a.html", "text/html", video)).status).toBe( 415, ); expect((await upload(app, owner, { botId: bot.id }, "", "video/mp4", video)).status).toBe(400); expect( (await upload(app, owner, { botId: bot.id }, "e.mp4", "video/mp4", Buffer.alloc(0))).status, ).toBe(400); const declared = await app.request( new Request(`${ORIGIN}/api/artifacts?botId=${bot.id}&name=big.mp4`, { method: "POST", headers: { cookie: owner, "content-type": "video/mp4", "content-length": String(500 * 1024 * 1024 + 1), }, body: new ReadableStream({ start: (controller) => controller.close() }), duplex: "half", } as RequestInit & { duplex: "half" }), ); expect(declared.status).toBe(413); // No Content-Length: the 25 MiB file cap is enforced while streaming to disk. const mib = new Uint8Array(1024 * 1024); let sent = 0; const chunked = await app.request( new Request(`${ORIGIN}/api/artifacts?botId=${bot.id}&name=big.txt`, { method: "POST", headers: { cookie: owner, "content-type": "text/plain" }, body: new ReadableStream({ pull(controller) { if (sent++ > 26) controller.close(); else controller.enqueue(mib); }, }), duplex: "half", } as RequestInit & { duplex: "half" }), ); expect(chunked.status).toBe(413); expect(await prisma.artifact.count({ where: { botId: bot.id } })).toBe(before); const me = await rpc<{ spaceId: string }>(app, owner, "me"); const spaceDir = path.join(dataDir, "artifacts", me.spaceId); expect(readdirSafe(spaceDir)).toEqual([]); // Video far above the inline cap uploads, streams by range, and is refused as base64. const large = Buffer.alloc(26 * 1024 * 1024, 1); large.write("head", 0); const uploaded = await upload(app, owner, { botId: bot.id }, "long.mp4", "video/mp4", large); expect(uploaded.status).toBe(201); const largeVideo = (await uploaded.json()) as { id: string }; const head = await get(app, owner, largeVideo.id, { range: "bytes=0-3" }); expect(head.status).toBe(206); expect(await head.text()).toBe("head"); await expect( rpc(app, owner, "artifacts/get", { botId: bot.id, artifactId: largeVideo.id }), ).rejects.toThrow(/400: .*too large to load inline/); }); }); function readdirSafe(dir: string) { try { return readdirSync(dir); } catch { return []; } } function botInput(name: string) { return { name, title: name, description: "artifact routes", instructions: "", notifyOnFinish: true, }; } async function upload( app: App, cookie: string, target: { botId: string } | { groupId: string }, name: string, mimeType: string, bytes: Buffer, headers: Record = {}, ) { const query = new URLSearchParams({ ...target, name }); return app.request(`/api/artifacts?${query}`, { method: "POST", headers: { cookie, origin: ORIGIN, "content-type": mimeType, ...headers }, body: bytes, }); } function get(app: App, cookie: string, id: string, headers: Record = {}) { return app.request(`/api/artifacts/${id}`, { headers: { cookie, ...headers } }); } async function signup(app: App, email: string, name: string) { const response = await app.request("/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json", origin: ORIGIN }, body: JSON.stringify({ email, password: "test-password-123", name }), }); expect(response.status).toBeLessThan(400); return sessionCookieHeader(response); } async function rpc( app: App, cookie: string, procedure: string, body: unknown = {}, spaceId?: string, ): Promise { const response = await app.request(`/rpc/${procedure}`, { method: "POST", headers: { "content-type": "application/json", cookie, origin: ORIGIN, ...(spaceId ? { "x-rakazo-space-id": spaceId } : {}), }, body: JSON.stringify({ json: body }), }); const text = await response.text(); const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } }; if (response.status >= 400 || parsed.error) { throw new Error(`${procedure} ${response.status}: ${parsed.error?.message ?? text}`); } return parsed.json as T; }