Skip to content
Open
166 changes: 166 additions & 0 deletions packages/server-core/src/handlers/memory.handlers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Memory } from "@voltagent/core";
import type { Agent, Logger, ServerProviderDeps, VoltOpsClient } from "@voltagent/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { InMemoryStorageAdapter } from "../../../core/src/memory/adapters/storage/in-memory";
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
import {
handleDeleteMemoryConversation,
handleGetMemoryConversation,
handleListMemoryConversationMessages,
handleUpdateMemoryConversation,
} from "./memory.handlers";

function createAgentWithMemory(agentId: string, agentName: string, memory: Memory): Agent {
return {
getFullState: () => ({
id: agentId,
name: agentName,
instructions: "",
status: "idle",
model: "test-model",
tools: [],
subAgents: [],
memory: {},
}),
getMemory: () => memory,
} as unknown as Agent;
}

function createDepsWithAgents(agents: Agent[]): ServerProviderDeps {
const logger: Logger = {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn().mockReturnThis(),
level: "info",
silent: vi.fn(),
} as unknown as Logger;

return {
agentRegistry: {
getAgent: vi.fn((agentId: string) =>
agents.find((agent) => agent.getFullState().id === agentId),
),
getAllAgents: vi.fn().mockReturnValue(agents),
getAgentCount: vi.fn().mockReturnValue(agents.length),
removeAgent: vi.fn(),
registerAgent: vi.fn(),
getGlobalVoltOpsClient: vi.fn().mockReturnValue(undefined as unknown as VoltOpsClient),
getGlobalLogger: vi.fn().mockReturnValue(logger),
},
workflowRegistry: {
getWorkflow: vi.fn(),
getWorkflowsForApi: vi.fn().mockReturnValue([]),
getWorkflowDetailForApi: vi.fn(),
getWorkflowCount: vi.fn().mockReturnValue(0),
on: vi.fn(),
off: vi.fn(),
activeExecutions: new Map(),
resumeSuspendedWorkflow: vi.fn(),
},
triggerRegistry: {
list: vi.fn().mockReturnValue([]),
register: vi.fn(),
registerMany: vi.fn(),
get: vi.fn(),
getByPath: vi.fn(),
unregister: vi.fn(),
clear: vi.fn(),
} as any,
logger,
} as unknown as ServerProviderDeps;
}

describe("memory handlers ownership checks", () => {
let memory: Memory;
let deps: ServerProviderDeps;
const agentId = "agent-1";
const ownerUserId = "user-alice";
const otherUserId = "user-bob";
const conversationId = "conv-private";

beforeEach(async () => {
memory = new Memory({
storage: new InMemoryStorageAdapter(),
});

await memory.createConversation({
id: conversationId,
resourceId: agentId,
userId: ownerUserId,
title: "Alice Private",
metadata: {},
});

await memory.addMessage(
{
id: "msg-1",
role: "user",
parts: [{ type: "text", text: "Confidential" }],
},
ownerUserId,
conversationId,
);

deps = createDepsWithAgents([createAgentWithMemory(agentId, "Agent One", memory)]);
});

it("rejects reading a conversation owned by a different authenticated user", async () => {
const result = await handleGetMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: otherUserId,
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);
});

it("rejects listing messages for a conversation owned by a different authenticated user", async () => {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
const result = await handleListMemoryConversationMessages(deps, conversationId, {
agentId,
requestingUserId: otherUserId,
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("rejects updating a conversation owned by a different authenticated user", async () => {
const result = await handleUpdateMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: otherUserId,
title: "Bob title",
} as any);

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);

const conversation = await memory.getConversation(conversationId);
expect(conversation?.title).toBe("Alice Private");
});

it("rejects deleting a conversation owned by a different authenticated user", async () => {
const result = await handleDeleteMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: otherUserId,
});

expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);

await expect(memory.getConversation(conversationId)).resolves.not.toBeNull();
});

it("allows the owning authenticated user to manage the conversation", async () => {
const result = await handleGetMemoryConversation(deps, conversationId, {
agentId,
requestingUserId: ownerUserId,
});

expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.conversation.userId).toBe(ownerUserId);
});
});
63 changes: 61 additions & 2 deletions packages/server-core/src/handlers/memory.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,25 @@ function buildErrorResponse(error: unknown): ApiResponse {
};
}

function buildForbiddenResponse(): ApiResponse {
return {
success: false,
error: "Forbidden",
httpStatus: 403,
};
}

function assertConversationOwner(
conversation: Conversation,
requestingUserId?: string,
): ApiResponse | null {
if (requestingUserId && conversation.userId !== requestingUserId) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
return buildForbiddenResponse();
}

return null;
}

export async function handleListMemoryConversations(
deps: ServerProviderDeps,
query: {
Expand Down Expand Up @@ -163,7 +182,7 @@ export async function handleListMemoryConversations(
export async function handleGetMemoryConversation(
deps: ServerProviderDeps,
conversationId: string,
query: { agentId?: string },
query: { agentId?: string; requestingUserId?: string },
): Promise<ApiResponse<{ conversation: Conversation }>> {
try {
const resolved = resolveMemory(deps, query.agentId);
Expand All @@ -184,6 +203,11 @@ export async function handleGetMemoryConversation(
};
}

const ownershipError = assertConversationOwner(conversation, query.requestingUserId);
if (ownershipError) {
return ownershipError;
}

return {
success: true,
data: { conversation },
Expand All @@ -203,6 +227,7 @@ export async function handleListMemoryConversationMessages(
after?: Date;
roles?: string[];
userId?: string;
requestingUserId?: string;
},
): Promise<ApiResponse<{ conversation: Conversation; messages: UIMessage[] }>> {
try {
Expand All @@ -224,6 +249,11 @@ export async function handleListMemoryConversationMessages(
};
}

const ownershipError = assertConversationOwner(conversation, query.requestingUserId);
if (ownershipError) {
return ownershipError;
}

const userId = query.userId ?? conversation.userId;
const messages = await resolved.memory.getMessages(userId, conversationId, {
limit: query.limit,
Expand Down Expand Up @@ -498,6 +528,7 @@ export async function handleUpdateMemoryConversation(
agentId?: string;
resourceId?: string;
userId?: string;
requestingUserId?: string;
title?: string;
metadata?: Record<string, unknown>;
},
Expand Down Expand Up @@ -534,6 +565,20 @@ export async function handleUpdateMemoryConversation(
};
}

const existingConversation = await resolved.memory.getConversation(conversationId);
if (!existingConversation) {
return {
success: false,
error: "Conversation not found",
httpStatus: 404,
};
}

const ownershipError = assertConversationOwner(existingConversation, body.requestingUserId);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (ownershipError) {
return ownershipError;
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const conversation = await resolved.memory.updateConversation(conversationId, updates);
return {
success: true,
Expand All @@ -554,7 +599,7 @@ export async function handleUpdateMemoryConversation(
export async function handleDeleteMemoryConversation(
deps: ServerProviderDeps,
conversationId: string,
query: { agentId?: string },
query: { agentId?: string; requestingUserId?: string },
): Promise<ApiResponse<{ deleted: boolean }>> {
try {
const resolved = resolveMemory(deps, query.agentId);
Expand All @@ -566,6 +611,20 @@ export async function handleDeleteMemoryConversation(
};
}

const conversation = await resolved.memory.getConversation(conversationId);
if (!conversation) {
return {
success: false,
error: "Conversation not found",
httpStatus: 404,
};
}

const ownershipError = assertConversationOwner(conversation, query.requestingUserId);
if (ownershipError) {
return ownershipError;
}

await resolved.memory.deleteConversation(conversationId);
return {
success: true,
Expand Down
23 changes: 19 additions & 4 deletions packages/server-elysia/src/routes/memory.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ function parseDate(value?: string): Date | undefined {
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}

function getAuthenticatedUserId(store: { authenticatedUser?: unknown }): string | undefined {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
const user = store.authenticatedUser as { id?: unknown; sub?: unknown } | null | undefined;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
if (typeof user?.id === "string") {
return user.id;
}
if (typeof user?.sub === "string") {
return user.sub;
}
return undefined;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

type MemoryRoutesCompat = typeof MEMORY_ROUTES & {
getWorkingMemory?: { path: string };
};
Expand Down Expand Up @@ -69,17 +80,18 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg
return response;
});

app.get(MEMORY_ROUTES.getConversation.path, async ({ params, query, set }) => {
app.get(MEMORY_ROUTES.getConversation.path, async ({ params, query, set, store }) => {
const conversationId = params.conversationId;
logger.trace(`GET /api/memory/conversations/${conversationId} - fetching conversation`);
const response = await handleGetMemoryConversation(deps, conversationId, {
agentId: query.agentId as string | undefined,
requestingUserId: getAuthenticatedUserId(store),
});
set.status = response.success ? 200 : (response.httpStatus ?? 500);
return response;
});

app.get(MEMORY_ROUTES.listMessages.path, async ({ params, query, set }) => {
app.get(MEMORY_ROUTES.listMessages.path, async ({ params, query, set, store }) => {
const conversationId = params.conversationId;
logger.trace(`GET /api/memory/conversations/${conversationId}/messages - fetching messages`, {
query,
Expand All @@ -91,6 +103,7 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg
after: parseDate(query.after as string | undefined),
roles: query.roles ? String(query.roles).split(",") : undefined,
userId: query.userId as string | undefined,
requestingUserId: getAuthenticatedUserId(store),
});
set.status = response.success ? 200 : (response.httpStatus ?? 500);
return response;
Expand Down Expand Up @@ -135,23 +148,25 @@ export function registerMemoryRoutes(app: Elysia, deps: ServerProviderDeps, logg
return response;
});

app.patch(MEMORY_ROUTES.updateConversation.path, async ({ params, body, query, set }) => {
app.patch(MEMORY_ROUTES.updateConversation.path, async ({ params, body, query, set, store }) => {
const conversationId = params.conversationId;
const payload = body as Record<string, unknown> | undefined;
logger.trace(`PATCH /api/memory/conversations/${conversationId} - updating conversation`);
const response = await handleUpdateMemoryConversation(deps, conversationId, {
...(payload ?? {}),
agentId: (payload?.agentId as string | undefined) ?? (query.agentId as string | undefined),
requestingUserId: getAuthenticatedUserId(store),
});
set.status = response.success ? 200 : (response.httpStatus ?? 500);
return response;
});

app.delete(MEMORY_ROUTES.deleteConversation.path, async ({ params, query, set }) => {
app.delete(MEMORY_ROUTES.deleteConversation.path, async ({ params, query, set, store }) => {
const conversationId = params.conversationId;
logger.trace(`DELETE /api/memory/conversations/${conversationId} - deleting conversation`);
const response = await handleDeleteMemoryConversation(deps, conversationId, {
agentId: query.agentId as string | undefined,
requestingUserId: getAuthenticatedUserId(store),
});
set.status = response.success ? 200 : (response.httpStatus ?? 500);
return response;
Expand Down
Loading