diff --git a/packages/server/src/__tests__/unit/api/message-servers.test.ts b/packages/server/src/__tests__/unit/api/message-servers.test.ts new file mode 100644 index 000000000000..ee623d4d8a21 --- /dev/null +++ b/packages/server/src/__tests__/unit/api/message-servers.test.ts @@ -0,0 +1,446 @@ +/** + * Tests for message servers API routes + * + * These tests verify the actual route behavior including: + * - UUID validation (400 errors) + * - RLS security checks (403 errors) + * - Required field validation (400 errors) + * - Successful operations (200/201 responses) + * - Deprecated route forwarding + */ + +import { describe, it, expect, beforeEach, jest } from 'bun:test'; +import express from 'express'; +import type { UUID } from '@elizaos/core'; +import { createMessageServersRouter } from '../../../api/messaging/messageServers'; +import type { AgentServer } from '../../../index'; + +// Test UUIDs +const CURRENT_SERVER_ID = '00000000-0000-0000-0000-000000000001' as UUID; +const OTHER_SERVER_ID = '99999999-9999-9999-9999-999999999999' as UUID; +const VALID_AGENT_ID = '11111111-1111-1111-1111-111111111111' as UUID; + +// Helper to simulate Express request/response +async function simulateRequest( + app: express.Application, + method: string, + path: string, + body?: Record +): Promise<{ status: number; body: Record }> { + return new Promise((resolve) => { + let responseStatus = 200; + let responseBody: Record = {}; + let responseSent = false; + + const headers: Record = { + 'content-type': 'application/json', + }; + + const req = { + method: method.toUpperCase(), + url: path, + path, + originalUrl: path, + baseUrl: '', + body: body || {}, + query: {}, + params: {}, + headers, + get(header: string) { + return headers[header.toLowerCase()]; + }, + header(header: string) { + return headers[header.toLowerCase()]; + }, + } as unknown as express.Request; + + const resState = { statusCode: 200 }; + + const res = { + get statusCode() { + return resState.statusCode; + }, + set statusCode(code: number) { + resState.statusCode = code; + }, + headers: {} as Record, + locals: {}, + headersSent: false, + status(code: number) { + if (!responseSent) { + responseStatus = code; + resState.statusCode = code; + } + return this; + }, + json(data: Record) { + if (!responseSent) { + responseSent = true; + responseBody = data; + resolve({ status: responseStatus, body: data }); + } + return this; + }, + send(data: Record) { + if (!responseSent) { + responseSent = true; + responseBody = data; + resolve({ status: responseStatus, body: data }); + } + return this; + }, + setHeader() { + return this; + }, + set() { + return this; + }, + end() { + if (!responseSent) { + responseSent = true; + resolve({ status: responseStatus, body: responseBody }); + } + }, + } as unknown as express.Response; + + const next: express.NextFunction = (err?: unknown) => { + if (!responseSent) { + if (err && typeof err === 'object') { + const error = err as { + statusCode?: number; + status?: number; + message?: string; + code?: string; + }; + responseStatus = error.statusCode || error.status || 500; + responseBody = { + error: error.message || 'Internal Server Error', + code: error.code, + }; + } else if (!err) { + responseStatus = 404; + responseBody = { error: 'Not found' }; + } + resolve({ status: responseStatus, body: responseBody }); + } + }; + + try { + app(req, res, next); + } catch (error) { + if (!responseSent) { + responseStatus = 500; + responseBody = { error: error instanceof Error ? error.message : 'Internal Server Error' }; + resolve({ status: responseStatus, body: responseBody }); + } + } + }); +} + +// Create mock server instance +function createMockServerInstance(overrides?: Partial): AgentServer { + return { + messageServerId: CURRENT_SERVER_ID, + getServers: jest + .fn() + .mockResolvedValue([ + { id: CURRENT_SERVER_ID, name: 'Test Server', sourceType: 'eliza_default' }, + ]), + createServer: jest.fn().mockResolvedValue({ + id: CURRENT_SERVER_ID, + name: 'New Server', + sourceType: 'discord', + }), + getAgentsForMessageServer: jest.fn().mockResolvedValue([VALID_AGENT_ID]), + addAgentToMessageServer: jest.fn().mockResolvedValue(undefined), + removeAgentFromMessageServer: jest.fn().mockResolvedValue(undefined), + getMessageServersForAgent: jest.fn().mockResolvedValue([CURRENT_SERVER_ID]), + ...overrides, + } as unknown as AgentServer; +} + +describe('Message Servers API', () => { + let app: express.Application; + let mockServerInstance: AgentServer; + + beforeEach(() => { + jest.clearAllMocks(); + mockServerInstance = createMockServerInstance(); + app = express(); + app.use(express.json()); + app.use(createMessageServersRouter(mockServerInstance)); + }); + + // ========================================================================== + // GET /message-server/current + // ========================================================================== + describe('GET /message-server/current', () => { + it('returns current server messageServerId', async () => { + const res = await simulateRequest(app, 'GET', '/message-server/current'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect((res.body.data as Record).messageServerId).toBe(CURRENT_SERVER_ID); + }); + }); + + // ========================================================================== + // GET /message-servers + // ========================================================================== + describe('GET /message-servers', () => { + it('returns list of message servers', async () => { + const res = await simulateRequest(app, 'GET', '/message-servers'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(Array.isArray((res.body.data as Record).messageServers)).toBe(true); + expect(mockServerInstance.getServers).toHaveBeenCalled(); + }); + }); + + // ========================================================================== + // POST /message-servers + // ========================================================================== + describe('POST /message-servers', () => { + it('returns 400 when name is missing', async () => { + const res = await simulateRequest(app, 'POST', '/message-servers', { + sourceType: 'discord', + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('Missing required fields'); + }); + + it('returns 400 when sourceType is missing', async () => { + const res = await simulateRequest(app, 'POST', '/message-servers', { + name: 'Test Server', + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('Missing required fields'); + }); + + it('creates server with valid data', async () => { + const res = await simulateRequest(app, 'POST', '/message-servers', { + name: 'New Server', + sourceType: 'discord', + sourceId: '123456', + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(mockServerInstance.createServer).toHaveBeenCalledWith({ + name: 'New Server', + sourceType: 'discord', + sourceId: '123456', + metadata: undefined, + }); + }); + }); + + // ========================================================================== + // GET /message-servers/:messageServerId/agents + // ========================================================================== + describe('GET /message-servers/:messageServerId/agents', () => { + it('returns 400 for invalid UUID', async () => { + const res = await simulateRequest(app, 'GET', '/message-servers/not-a-uuid/agents'); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('Invalid messageServerId'); + }); + + it('returns 403 when accessing different server (RLS)', async () => { + const res = await simulateRequest(app, 'GET', `/message-servers/${OTHER_SERVER_ID}/agents`); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('different server'); + }); + + it('returns agents for current server', async () => { + const res = await simulateRequest(app, 'GET', `/message-servers/${CURRENT_SERVER_ID}/agents`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + const data = res.body.data as Record; + expect(data.messageServerId).toBe(CURRENT_SERVER_ID); + expect(Array.isArray(data.agents)).toBe(true); + expect(mockServerInstance.getAgentsForMessageServer).toHaveBeenCalledWith(CURRENT_SERVER_ID); + }); + }); + + // ========================================================================== + // POST /message-servers/:messageServerId/agents + // ========================================================================== + describe('POST /message-servers/:messageServerId/agents', () => { + it('returns 400 for invalid messageServerId', async () => { + const res = await simulateRequest(app, 'POST', '/message-servers/invalid/agents', { + agentId: VALID_AGENT_ID, + }); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('Invalid messageServerId or agentId'); + }); + + it('returns 400 for invalid agentId', async () => { + const res = await simulateRequest( + app, + 'POST', + `/message-servers/${CURRENT_SERVER_ID}/agents`, + { agentId: 'not-a-uuid' } + ); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('Invalid messageServerId or agentId'); + }); + + it('returns 403 when modifying different server (RLS)', async () => { + const res = await simulateRequest(app, 'POST', `/message-servers/${OTHER_SERVER_ID}/agents`, { + agentId: VALID_AGENT_ID, + }); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('different server'); + }); + + it('adds agent to current server', async () => { + const res = await simulateRequest( + app, + 'POST', + `/message-servers/${CURRENT_SERVER_ID}/agents`, + { agentId: VALID_AGENT_ID } + ); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + const data = res.body.data as Record; + expect(data.messageServerId).toBe(CURRENT_SERVER_ID); + expect(data.agentId).toBe(VALID_AGENT_ID); + expect(mockServerInstance.addAgentToMessageServer).toHaveBeenCalledWith( + CURRENT_SERVER_ID, + VALID_AGENT_ID + ); + }); + }); + + // ========================================================================== + // DELETE /message-servers/:messageServerId/agents/:agentId + // ========================================================================== + describe('DELETE /message-servers/:messageServerId/agents/:agentId', () => { + it('returns 400 for invalid messageServerId', async () => { + const res = await simulateRequest( + app, + 'DELETE', + `/message-servers/invalid/agents/${VALID_AGENT_ID}` + ); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('Invalid messageServerId or agentId'); + }); + + it('returns 400 for invalid agentId', async () => { + const res = await simulateRequest( + app, + 'DELETE', + `/message-servers/${CURRENT_SERVER_ID}/agents/invalid` + ); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('Invalid messageServerId or agentId'); + }); + + it('returns 403 when modifying different server (RLS)', async () => { + const res = await simulateRequest( + app, + 'DELETE', + `/message-servers/${OTHER_SERVER_ID}/agents/${VALID_AGENT_ID}` + ); + + expect(res.status).toBe(403); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('different server'); + }); + + it('removes agent from current server', async () => { + const res = await simulateRequest( + app, + 'DELETE', + `/message-servers/${CURRENT_SERVER_ID}/agents/${VALID_AGENT_ID}` + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + const data = res.body.data as Record; + expect(data.messageServerId).toBe(CURRENT_SERVER_ID); + expect(data.agentId).toBe(VALID_AGENT_ID); + expect(mockServerInstance.removeAgentFromMessageServer).toHaveBeenCalledWith( + CURRENT_SERVER_ID, + VALID_AGENT_ID + ); + }); + }); + + // ========================================================================== + // GET /agents/:agentId/message-servers + // ========================================================================== + describe('GET /agents/:agentId/message-servers', () => { + it('returns 400 for invalid agentId', async () => { + const res = await simulateRequest(app, 'GET', '/agents/not-a-uuid/message-servers'); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error).toContain('Invalid agentId'); + }); + + it('returns message servers for agent', async () => { + const res = await simulateRequest(app, 'GET', `/agents/${VALID_AGENT_ID}/message-servers`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + const data = res.body.data as Record; + expect(data.agentId).toBe(VALID_AGENT_ID); + expect(Array.isArray(data.messageServers)).toBe(true); + expect(mockServerInstance.getMessageServersForAgent).toHaveBeenCalledWith(VALID_AGENT_ID); + }); + }); + + // ========================================================================== + // Deprecated routes - verify they still work + // ========================================================================== + describe('Deprecated routes (backward compatibility)', () => { + it('GET /central-servers returns servers with old key name', async () => { + const res = await simulateRequest(app, 'GET', '/central-servers'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + // Old response format uses 'servers' not 'messageServers' + expect((res.body.data as Record).servers).toBeDefined(); + }); + + it('POST /servers forwards to /message-servers', async () => { + const res = await simulateRequest(app, 'POST', '/servers', { + name: 'Test', + sourceType: 'discord', + }); + + // Should forward and create successfully + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + }); + + it('GET /servers/:serverId/agents forwards to /message-servers/:messageServerId/agents', async () => { + const res = await simulateRequest(app, 'GET', `/servers/${CURRENT_SERVER_ID}/agents`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + }); +}); diff --git a/packages/server/src/api/messaging/messageServers.ts b/packages/server/src/api/messaging/messageServers.ts index 910ed63813dc..981fe1275456 100644 --- a/packages/server/src/api/messaging/messageServers.ts +++ b/packages/server/src/api/messaging/messageServers.ts @@ -9,8 +9,12 @@ import type { AgentServer } from '../../index'; export function createMessageServersRouter(serverInstance: AgentServer): express.Router { const router = express.Router(); - // GET /server/current - Get current server's ID (for this running instance) - // This is the serverId that clients should use when creating channels/messages + // ============================================================================ + // CURRENT ROUTES - Canonical endpoints + // ============================================================================ + + // GET /message-server/current - Get current server's ID (for this running instance) + // This is the messageServerId that clients should use when creating channels/messages router.get('/message-server/current', async (_req: express.Request, res: express.Response) => { try { res.json({ @@ -46,8 +50,8 @@ export function createMessageServersRouter(serverInstance: AgentServer): express } }); - // POST /servers - Create a new server - router.post('/servers', async (req: express.Request, res: express.Response) => { + // POST /message-servers - Create a new message server + router.post('/message-servers', async (req: express.Request, res: express.Response) => { const { name, sourceType, sourceId, metadata } = req.body; if (!name || !sourceType) { @@ -69,89 +73,137 @@ export function createMessageServersRouter(serverInstance: AgentServer): express logger.error( { src: 'http', - path: '/servers', + path: '/message-servers', error: error instanceof Error ? error.message : String(error), }, - 'Error creating server' + 'Error creating message server' ); - res.status(500).json({ success: false, error: 'Failed to create server' }); + res.status(500).json({ success: false, error: 'Failed to create message server' }); } }); // =============================== - // Server-Agent Association Endpoints + // Message Server-Agent Association Endpoints // =============================== - // POST /servers/:serverId/agents - Add agent to server - router.post('/servers/:serverId/agents', async (req: express.Request, res: express.Response) => { - const serverId = validateUuid(req.params.serverId); - const { agentId } = req.body; + // GET /message-servers/:messageServerId/agents - List agents in message server + router.get( + '/message-servers/:messageServerId/agents', + async (req: express.Request, res: express.Response) => { + const messageServerId = validateUuid(req.params.messageServerId); - if (!serverId || !validateUuid(agentId)) { - return res.status(400).json({ - success: false, - error: 'Invalid serverId or agentId format', - }); - } + if (!messageServerId) { + return res.status(400).json({ + success: false, + error: 'Invalid messageServerId format', + }); + } - // RLS security: Only allow modifying agents for current server - if (serverId !== serverInstance.messageServerId) { - return res.status(403).json({ - success: false, - error: 'Cannot modify agents for a different server', - }); + // RLS security: Only allow accessing agents for current server + if (messageServerId !== serverInstance.messageServerId) { + return res.status(403).json({ + success: false, + error: 'Cannot access agents for a different server', + }); + } + + try { + const agents = await serverInstance.getAgentsForMessageServer(messageServerId); + res.json({ + success: true, + data: { + messageServerId, + agents, // Array of agent IDs + }, + }); + } catch (error) { + logger.error( + { + src: 'http', + path: req.path, + messageServerId, + error: error instanceof Error ? error.message : String(error), + }, + 'Error fetching agents for message server' + ); + res.status(500).json({ success: false, error: 'Failed to fetch message server agents' }); + } } + ); - try { - // Add agent to message server association - await serverInstance.addAgentToMessageServer(serverId, agentId as UUID); - - // Notify the agent's message bus service to start listening for this message server - internalMessageBus.emit('server_agent_update', { - type: 'agent_added_to_server' as const, - messageServerId: serverId, - agentId, - }); + // POST /message-servers/:messageServerId/agents - Add agent to message server + router.post( + '/message-servers/:messageServerId/agents', + async (req: express.Request, res: express.Response) => { + const messageServerId = validateUuid(req.params.messageServerId); + const { agentId } = req.body; - res.status(201).json({ - success: true, - data: { - serverId, - agentId, - message: 'Agent added to server successfully', - }, - }); - } catch (error) { - logger.error( - { - src: 'http', - path: req.path, - serverId, + if (!messageServerId || !validateUuid(agentId)) { + return res.status(400).json({ + success: false, + error: 'Invalid messageServerId or agentId format', + }); + } + + // RLS security: Only allow modifying agents for current server + if (messageServerId !== serverInstance.messageServerId) { + return res.status(403).json({ + success: false, + error: 'Cannot modify agents for a different server', + }); + } + + try { + // Add agent to message server association + await serverInstance.addAgentToMessageServer(messageServerId, agentId as UUID); + + // Notify the agent's message bus service to start listening for this message server + internalMessageBus.emit('server_agent_update', { + type: 'agent_added_to_server' as const, + messageServerId, agentId, - error: error instanceof Error ? error.message : String(error), - }, - 'Error adding agent to server' - ); - res.status(500).json({ success: false, error: 'Failed to add agent to server' }); + }); + + res.status(201).json({ + success: true, + data: { + messageServerId, + agentId, + message: 'Agent added to message server successfully', + }, + }); + } catch (error) { + logger.error( + { + src: 'http', + path: req.path, + messageServerId, + agentId, + error: error instanceof Error ? error.message : String(error), + }, + 'Error adding agent to message server' + ); + res.status(500).json({ success: false, error: 'Failed to add agent to message server' }); + } } - }); + ); - // DELETE /servers/:serverId/agents/:agentId - Remove agent from server + // DELETE /message-servers/:messageServerId/agents/:agentId - Remove agent from message server router.delete( - '/servers/:serverId/agents/:agentId', + '/message-servers/:messageServerId/agents/:agentId', async (req: express.Request, res: express.Response) => { - const serverId = validateUuid(req.params.serverId); + const messageServerId = validateUuid(req.params.messageServerId); const agentId = validateUuid(req.params.agentId); - if (!serverId || !agentId) { + if (!messageServerId || !agentId) { return res.status(400).json({ success: false, - error: 'Invalid serverId or agentId format', + error: 'Invalid messageServerId or agentId format', }); } // RLS security: Only allow modifying agents for current server - if (serverId !== serverInstance.messageServerId) { + if (messageServerId !== serverInstance.messageServerId) { return res.status(403).json({ success: false, error: 'Cannot modify agents for a different server', @@ -160,21 +212,21 @@ export function createMessageServersRouter(serverInstance: AgentServer): express try { // Remove agent from message server association - await serverInstance.removeAgentFromMessageServer(serverId, agentId); + await serverInstance.removeAgentFromMessageServer(messageServerId, agentId); // Notify the agent's message bus service to stop listening for this message server internalMessageBus.emit('server_agent_update', { type: 'agent_removed_from_server' as const, - messageServerId: serverId, + messageServerId, agentId, }); res.status(200).json({ success: true, data: { - serverId, + messageServerId, agentId, - message: 'Agent removed from server successfully', + message: 'Agent removed from message server successfully', }, }); } catch (error) { @@ -182,59 +234,19 @@ export function createMessageServersRouter(serverInstance: AgentServer): express { src: 'http', path: req.path, - serverId, + messageServerId, agentId, error: error instanceof Error ? error.message : String(error), }, - 'Error removing agent from server' + 'Error removing agent from message server' ); - res.status(500).json({ success: false, error: 'Failed to remove agent from server' }); + res + .status(500) + .json({ success: false, error: 'Failed to remove agent from message server' }); } } ); - // GET /servers/:serverId/agents - List agents in server - router.get('/servers/:serverId/agents', async (req: express.Request, res: express.Response) => { - const serverId = validateUuid(req.params.serverId); - - if (!serverId) { - return res.status(400).json({ - success: false, - error: 'Invalid serverId format', - }); - } - - // RLS security: Only allow accessing agents for current server - if (serverId !== serverInstance.messageServerId) { - return res.status(403).json({ - success: false, - error: 'Cannot access agents for a different server', - }); - } - - try { - const agents = await serverInstance.getAgentsForMessageServer(serverId); - res.json({ - success: true, - data: { - serverId, - agents, // Array of agent IDs - }, - }); - } catch (error) { - logger.error( - { - src: 'http', - path: req.path, - serverId, - error: error instanceof Error ? error.message : String(error), - }, - 'Error fetching agents for server' - ); - res.status(500).json({ success: false, error: 'Failed to fetch server agents' }); - } - }); - // GET /agents/:agentId/message-servers - List message servers agent belongs to router.get( '/agents/:agentId/message-servers', @@ -274,6 +286,8 @@ export function createMessageServersRouter(serverInstance: AgentServer): express // ============================================================================ // DEPRECATED ROUTES - For backward compatibility only + // These routes maintain the old naming (/servers, :serverId, /central-servers) + // and forward to the new endpoints. They will be removed in a future version. // ============================================================================ /** @@ -298,5 +312,74 @@ export function createMessageServersRouter(serverInstance: AgentServer): express } }); + /** + * @deprecated Use POST /message-servers instead + * Kept for backward compatibility. Will be removed in future versions. + */ + router.post( + '/servers', + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.warn('[DEPRECATED] POST /servers is deprecated. Use POST /message-servers instead.'); + + // Forward to new endpoint + req.url = '/message-servers'; + return (router as express.Router & { handle: express.RequestHandler }).handle(req, res, next); + } + ); + + /** + * @deprecated Use GET /message-servers/:messageServerId/agents instead + * Kept for backward compatibility. Will be removed in future versions. + */ + router.get( + '/servers/:serverId/agents', + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.warn( + '[DEPRECATED] GET /servers/:serverId/agents is deprecated. Use GET /message-servers/:messageServerId/agents instead.' + ); + + // Forward to new endpoint with parameter rename + req.url = req.url.replace('/servers/', '/message-servers/'); + req.params.messageServerId = req.params.serverId; + return (router as express.Router & { handle: express.RequestHandler }).handle(req, res, next); + } + ); + + /** + * @deprecated Use POST /message-servers/:messageServerId/agents instead + * Kept for backward compatibility. Will be removed in future versions. + */ + router.post( + '/servers/:serverId/agents', + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.warn( + '[DEPRECATED] POST /servers/:serverId/agents is deprecated. Use POST /message-servers/:messageServerId/agents instead.' + ); + + // Forward to new endpoint with parameter rename + req.url = req.url.replace('/servers/', '/message-servers/'); + req.params.messageServerId = req.params.serverId; + return (router as express.Router & { handle: express.RequestHandler }).handle(req, res, next); + } + ); + + /** + * @deprecated Use DELETE /message-servers/:messageServerId/agents/:agentId instead + * Kept for backward compatibility. Will be removed in future versions. + */ + router.delete( + '/servers/:serverId/agents/:agentId', + async (req: express.Request, res: express.Response, next: express.NextFunction) => { + logger.warn( + '[DEPRECATED] DELETE /servers/:serverId/agents/:agentId is deprecated. Use DELETE /message-servers/:messageServerId/agents/:agentId instead.' + ); + + // Forward to new endpoint with parameter rename + req.url = req.url.replace('/servers/', '/message-servers/'); + req.params.messageServerId = req.params.serverId; + return (router as express.Router & { handle: express.RequestHandler }).handle(req, res, next); + } + ); + return router; }