import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import express from "express"; import { mkdtemp, rm } from "node:fs/promises"; import { request as httpRequest, type Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildChatRouter } from "../../src/routes/chat.js"; import { ChatSessionBridge } from "../../src/chat/sessionBridge.js"; import { SessionMetadataStore } from "../../src/sessions/metadataStore.js"; import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js"; import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js"; import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js"; const originalFetch = globalThis.fetch; describe("chat session HTTP isolation", () => { let metadataDir: string; let server: Server; beforeEach(async () => { metadataDir = await mkdtemp(join(tmpdir(), "tjwater-route-sessions-")); }); afterEach(async () => { globalThis.fetch = originalFetch; if (server) { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); } await rm(metadataDir, { force: true, recursive: true }); }); it("hides task sessions from ordinary history and scopes every list to the verified user and project", async () => { globalThis.fetch = (async (_input, init) => { const headers = new Headers(init?.headers); const authorization = headers.get("authorization"); const projectId = headers.get("x-project-id"); const userId = authorization === "Bearer token-a" ? "user-a" : "user-b"; return Response.json({ user_id: userId, project_id: projectId, network: "supply", }); }) as typeof fetch; const workspaces = new SessionWorkspaceStore(join(metadataDir, "workspaces")); const sessionMetadataStore = new SessionMetadataStore(metadataDir, workspaces); const sessionUiStateStore = new SessionUiStateStore(join(metadataDir, "old-ui"), workspaces); await sessionMetadataStore.initialize(); const ownerContext = { actorKey: toActorKey("product:user-a"), projectId: "10000000-0000-4000-8000-000000000001", projectKey: toProjectKey("product:10000000-0000-4000-8000-000000000001"), userId: "user-a", }; await sessionMetadataStore.ensure({ ...ownerContext, sessionId: "ordinary-session", }); await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, { sessionId: "ordinary-session", messages: [{ type: "text", text: "owner-only answer" }], }); const personalContext = { actorKey: ownerContext.actorKey, projectKey: `personal-${ownerContext.actorKey}`, userId: "user-a" }; await sessionMetadataStore.ensure({ ...personalContext, sessionId: "personal-session" }); await sessionUiStateStore.write({ ...personalContext, sessionId: "personal-session" }, { sessionId: "personal-session", messages: [{ type: "text", text: "personal answer" }], }); await sessionMetadataStore.ensure({ ...ownerContext, sessionId: "execution-session", purpose: "task_execution", taskId: "50000000-0000-4000-8000-000000000001", requestId: "60000000-0000-4000-8000-000000000001", }); const app = express(); const removed: string[] = []; let failCleanup = true; const runtime = { removeSession: async (sessionId: string) => { if (failCleanup) throw new Error("native service unavailable"); removed.push(sessionId); }, } as never; app.use(express.json()); app.use("/api/v1", buildChatRouter({ frontendActionCoordinator: { cancelSession: () => undefined } as never, learningOrchestrator: { cancelSession: async () => undefined } as never, memoryStore: {} as never, runtime, sessionBridge: new ChatSessionBridge(runtime), sessionMetadataStore, sessionTranscriptStore: {} as never, sessionUiStateStore, })); server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("expected HTTP server address"); const ownedOrdinary = await getJson(address.port, "/api/v1/sessions", "token-a", ownerContext.projectId); const ownedExecution = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-a", ownerContext.projectId); const otherUser = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-b", ownerContext.projectId); const otherProject = await getJson(address.port, "/api/v1/sessions?purpose=task_execution", "token-a", "10000000-0000-4000-8000-000000000002"); expect(ownedOrdinary.body.sessions.map((item: { id: string }) => item.id)).toEqual(["ordinary-session"]); expect(ownedExecution.body.sessions.map((item: { id: string }) => item.id)).toEqual(["execution-session"]); expect(otherUser.body.sessions).toEqual([]); expect(otherProject.body.sessions).toEqual([]); expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", ownerContext.projectId)).body.messages).toEqual([{ type: "text", text: "owner-only answer" }]); expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-b", ownerContext.projectId)).status).toBe(404); expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a", "10000000-0000-4000-8000-000000000002")).status).toBe(404); expect((await getJson(address.port, "/api/v1/session/ordinary-session", "token-a")).status).toBe(404); const personalHistory = await getJson(address.port, "/api/v1/sessions", "token-a"); expect(personalHistory.body.sessions.map((session: { id: string }) => session.id)).toEqual(["personal-session"]); expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a")).body.messages).toEqual([{ type: "text", text: "personal answer" }]); expect((await getJson(address.port, "/api/v1/session/personal-session", "token-b")).status).toBe(404); expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a", ownerContext.projectId)).status).toBe(404); expect((await getJson(address.port, "/api/v1/session", "token-a", undefined, { parent_session_id: "ordinary-session" })).status).toBe(404); expect((await getJson(address.port, "/api/v1/fork", "token-b", ownerContext.projectId, { session_id: "ordinary-session", keep_message_count: 2 })).status).toBe(404); expect((await getJson(address.port, "/api/v1/session", "token-b", ownerContext.projectId, { session_id: "ordinary-session" })).status).toBe(404); expect((await getJson(address.port, "/api/v1/session", "token-b", ownerContext.projectId, { session_id: "unknown-session" })).status).toBe(404); const path = "/api/v1/session/ordinary-session"; expect((await getJson(address.port, path, "token-b", ownerContext.projectId, undefined, "DELETE")).status).toBe(204); expect((await getJson(address.port, path, "token-a", undefined, undefined, "DELETE")).status).toBe(204); expect((await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" }))?.messages).toHaveLength(1); expect((await getJson(address.port, path, "token-a", ownerContext.projectId, undefined, "DELETE")).status).toBe(503); expect((await getJson(address.port, path, "token-a", ownerContext.projectId)).status).toBe(404); expect((await getJson(address.port, "/api/v1/sessions", "token-a", ownerContext.projectId)).body.sessions).toEqual([]); failCleanup = false; expect((await getJson(address.port, path, "token-a", ownerContext.projectId, undefined, "DELETE")).status).toBe(204); expect(removed).toEqual(["ordinary-session"]); expect(await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" })).toBeNull(); expect((await getJson(address.port, "/api/v1/session/personal-session", "token-a")).status).toBe(200); }); }); const getJson = ( port: number, path: string, token: string, projectId?: string, body?: Record, method?: string, ) => new Promise<{ status: number; body: Record }>((resolve, reject) => { const request = httpRequest({ hostname: "127.0.0.1", port, path, method: method ?? (body ? "POST" : "GET"), headers: { Authorization: `Bearer ${token}`, ...(projectId ? { "X-Project-Id": projectId } : {}), ...(body ? { "Content-Type": "application/json" } : {}), }, }, (response) => { const chunks: Buffer[] = []; response.on("data", (chunk) => chunks.push(Buffer.from(chunk))); response.on("end", () => { resolve({ status: response.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"), }); }); }); request.on("error", reject); request.end(body ? JSON.stringify(body) : undefined); });