193 lines
11 KiB
TypeScript
193 lines
11 KiB
TypeScript
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<void>((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<void>((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" }]);
|
|
const pagedMessages = Array.from({ length: 105 }, (_, index) => ({ id: `message-${index}`, role: "user", parts: [{ type: "text", text: `record ${index}` }] }));
|
|
await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, { sessionId: "ordinary-session", messages: pagedMessages });
|
|
const latestPage = await getJson(address.port, "/api/v1/session/ordinary-session?message_limit=40", "token-a", ownerContext.projectId);
|
|
expect(latestPage.body.messages).toEqual(pagedMessages.slice(65));
|
|
const cursor = encodeURIComponent(latestPage.body.message_page.before_cursor);
|
|
const olderPage = await getJson(address.port, `/api/v1/session/ordinary-session/messages?limit=40&before=${cursor}`, "token-a", ownerContext.projectId);
|
|
expect(olderPage.body.messages).toEqual(pagedMessages.slice(25, 65));
|
|
expect((await getJson(address.port, `/api/v1/session/ordinary-session/messages?before=${cursor}`, "token-b", ownerContext.projectId)).status).toBe(404);
|
|
expect((await getJson(address.port, "/api/v1/session/ordinary-session/messages?limit=0", "token-a", ownerContext.projectId)).status).toBe(400);
|
|
expect((await getJson(address.port, "/api/v1/session/ordinary-session?message_limit=101", "token-a", ownerContext.projectId)).status).toBe(400);
|
|
expect((await getJson(address.port, "/api/v1/sessions?q=not-found&limit=30", "token-a", ownerContext.projectId)).body.sessions).toEqual([]);
|
|
expect((await getJson(address.port, "/api/v1/sessions?limit=1", "token-a", ownerContext.projectId)).body.sessions).toHaveLength(1);
|
|
const restored = await originalFetch(`http://127.0.0.1:${address.port}/api/v1/session/ordinary-session/stream?message_limit=40`, {
|
|
headers: { Authorization: "Bearer token-a", "X-Project-Id": ownerContext.projectId },
|
|
});
|
|
const snapshot = JSON.parse((await restored.text()).split("data: ")[1]!.trim());
|
|
expect(snapshot.messages).toEqual(pagedMessages.slice(65));
|
|
expect(snapshot.message_page.has_more).toBe(true);
|
|
expect((await sessionUiStateStore.read({ ...ownerContext, sessionId: "ordinary-session" }))?.messages).toHaveLength(105);
|
|
await sessionUiStateStore.write({ ...ownerContext, sessionId: "ordinary-session" }, {
|
|
sessionId: "ordinary-session", messages: [{ 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<string, unknown>,
|
|
method?: string,
|
|
) => new Promise<{ status: number; body: Record<string, any> }>((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);
|
|
});
|