import { mkdirSync, mkdtempSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { ComposioEmulator, importMediaLibraryAsset, LocalArtifactStore, MediaLibraryError, searchMediaLibrary, } from "@rakazo/adapters"; import type { MediaLibrarySource } from "@rakazo/contracts"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { createApp } from "../../../apps/api/src/app.ts"; import { sessionCookieHeader } from "./index.js"; type App = { request: (input: string, init?: RequestInit) => Response | Promise }; type AppHandles = Awaited>; 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 describeWithDatabase = hasDb ? describe.sequential : describe.skip; const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); describeWithDatabase("media library (PostgreSQL)", () => { let handles: AppHandles; let app: App; const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-library-data-")); const library = mkdtempSync(path.join(tmpdir(), "rakazo-library-")); const outside = mkdtempSync(path.join(tmpdir(), "rakazo-library-outside-")); beforeAll(async () => { const { createApp } = await import("../../../apps/api/src/app.ts"); handles = await createApp({ databaseUrl: process.env.DATABASE_URL!, dataDir, sandboxProvider: "fake", agentRuntime: "scripted", wakeupDriver: "memory", signupsEnabled: "true", composio: new ComposioEmulator(), }); app = handles.app; mkdirSync(path.join(library, "Italy 2024")); mkdirSync(path.join(library, ".thumbnails")); writeFileSync(path.join(library, "Italy 2024", "beach.png"), PNG); writeFileSync(path.join(library, "Italy 2024", "notes.txt"), "not media"); writeFileSync(path.join(library, "birthday.png"), PNG); writeFileSync(path.join(library, ".thumbnails", "hidden.png"), PNG); writeFileSync(path.join(outside, "secret.png"), PNG); symlinkSync(path.join(outside, "secret.png"), path.join(library, "linked.png")); utimesSync( path.join(library, "Italy 2024", "beach.png"), new Date("2024-07-01"), new Date("2024-07-01"), ); utimesSync(path.join(library, "birthday.png"), new Date("2025-03-01"), new Date("2025-03-01")); }); afterAll(async () => { await handles?.stop(); for (const dir of [dataDir, library, outside]) rmSync(dir, { recursive: true, force: true }); }); async function user(label: string) { const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label); const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); return { cookie, me }; } async function waitForSync(cookie: string, sourceId: string) { const start = Date.now(); while (Date.now() - start < 20_000) { const sources = await rpc(app, cookie, "media/library/list"); const source = sources.find((entry) => entry.id === sourceId); if (source && source.status !== "syncing") return source; await new Promise((resolve) => setTimeout(resolve, 50)); } throw new Error(`library source ${sourceId} never finished syncing`); } it("indexes, searches and imports only the owner's own library files", async () => { const owner = await user("library-owner"); const other = await user("library-other"); await handles.prisma.deploymentSettings.upsert({ where: { id: "default" }, create: { id: "default", ownerUserId: owner.me.userId }, update: { ownerUserId: owner.me.userId }, }); expect((await raw(app, other.cookie, "media/library/list")).status).toBe(403); expect((await raw(app, other.cookie, "media/library/add", { path: library })).status).toBe(403); expect( (await raw(app, owner.cookie, "media/library/add", { path: "relative/folder" })).status, ).toBe(400); expect( (await raw(app, owner.cookie, "media/library/add", { path: path.join(library, "nope") })) .status, ).toBe(400); const added = await rpc(app, owner.cookie, "media/library/add", { path: library, }); expect(added).toMatchObject({ path: library, status: "syncing" }); // Adding the same folder again returns it instead of a duplicate. expect( (await rpc(app, owner.cookie, "media/library/add", { path: library })).id, ).toBe(added.id); const synced = await waitForSync(owner.cookie, added.id); expect(synced).toMatchObject({ status: "idle", error: null, assetCount: 2 }); expect(synced.lastSyncedAt).not.toBeNull(); const all = await searchMediaLibrary(handles.prisma, owner.me.userId, {}); expect(all.total).toBe(2); expect(all.items.map((item) => item.path)).toEqual(["birthday.png", "Italy 2024/beach.png"]); const italy = await searchMediaLibrary(handles.prisma, owner.me.userId, { query: "italy BEACH", }); expect(italy.items.map((item) => item.path)).toEqual(["Italy 2024/beach.png"]); const before = await searchMediaLibrary(handles.prisma, owner.me.userId, { taken_before: "2025-01-01T00:00:00Z", }); expect(before.items.map((item) => item.path)).toEqual(["Italy 2024/beach.png"]); expect((await searchMediaLibrary(handles.prisma, other.me.userId, {})).total).toBe(0); const artifacts = new LocalArtifactStore(dataDir); const assetId = italy.items[0]!.asset_id; const imported = await importMediaLibraryAsset( { prisma: handles.prisma, artifacts }, { spaceId: owner.me.spaceId, userId: owner.me.userId, botId: null, groupId: null, runId: null, }, assetId, ); expect(imported).toMatchObject({ name: "beach.png", mimeType: "image/png", size: PNG.length }); await expect( importMediaLibraryAsset( { prisma: handles.prisma, artifacts }, { spaceId: other.me.spaceId, userId: other.me.userId, botId: null, groupId: null, runId: null, }, assetId, ), ).rejects.toBeInstanceOf(MediaLibraryError); // Another user can neither resync nor remove the owner's folder. expect( (await raw(app, other.cookie, "media/library/sync", { sourceId: added.id })).status, ).toBe(403); // A deleted file leaves the index on the next sync. rmSync(path.join(library, "birthday.png")); await rpc(app, owner.cookie, "media/library/sync", { sourceId: added.id }); expect(await waitForSync(owner.cookie, added.id)).toMatchObject({ assetCount: 1 }); await rpc(app, owner.cookie, "media/library/remove", { sourceId: added.id }); expect(await rpc(app, owner.cookie, "media/library/list")).toEqual([]); expect(await handles.prisma.mediaLibraryAsset.count({ where: { sourceId: added.id } })).toBe(0); expect( (await raw(app, owner.cookie, "media/library/sync", { sourceId: added.id })).status, ).toBe(404); }); }); async function signup(app: App, email: string, name: string) { const res = await app.request("/api/auth/sign-up/email", { method: "POST", headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, body: JSON.stringify({ email, password: "password12", name }), }); if (res.status >= 400) throw new Error(`signup failed ${res.status}: ${await res.text()}`); return sessionCookieHeader(res); } function raw(app: App, cookie: string, proc: string, body: unknown = {}) { return app.request(`/rpc/${proc}`, { method: "POST", headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" }, body: JSON.stringify({ json: body }), }); } async function rpc(app: App, cookie: string, proc: string, body: unknown = {}): Promise { const res = await raw(app, cookie, proc, body); const text = await res.text(); const parsed = JSON.parse(text) as { json?: T; error?: { message?: string } }; if (res.status >= 400 || parsed.error) { throw new Error(`${proc} ${res.status}: ${parsed.error?.message ?? text}`); } return parsed.json as T; }