/**
* mysql-mcp - Router Tools Unit Tests
*
* Tests for router tool definitions, annotations, and handler execution.
* Mocks https module to test MySQL Router REST API calls.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getRouterTools } from '../router.js';
import type { MySQLAdapter } from '../../MySQLAdapter.js';
import { createMockMySQLAdapter, createMockRequestContext } from '../../../../__tests__/mocks/index.js';
import { EventEmitter } from 'events';
// Mock https module
vi.mock('node:https', () => ({
default: {
request: vi.fn()
}
}));
import https from 'node:https';
const mockRequest = https.request as ReturnType<typeof vi.fn>;
describe('getRouterTools', () => {
let tools: ReturnType<typeof getRouterTools>;
beforeEach(() => {
vi.clearAllMocks();
tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
});
it('should return 9 router tools', () => {
expect(tools).toHaveLength(9);
});
it('should have router group for all tools', () => {
for (const tool of tools) {
expect(tool.group).toBe('router');
}
});
it('should have handler functions for all tools', () => {
for (const tool of tools) {
expect(typeof tool.handler).toBe('function');
}
});
it('should have inputSchema for all tools', () => {
for (const tool of tools) {
expect(tool.inputSchema).toBeDefined();
}
});
it('should include all expected tool names', () => {
const toolNames = tools.map(t => t.name);
expect(toolNames).toContain('mysql_router_status');
expect(toolNames).toContain('mysql_router_routes');
expect(toolNames).toContain('mysql_router_route_status');
expect(toolNames).toContain('mysql_router_route_health');
expect(toolNames).toContain('mysql_router_route_connections');
expect(toolNames).toContain('mysql_router_route_destinations');
expect(toolNames).toContain('mysql_router_route_blocked_hosts');
expect(toolNames).toContain('mysql_router_metadata_status');
expect(toolNames).toContain('mysql_router_pool_status');
});
});
describe('Tool Structure Validation', () => {
let tools: ReturnType<typeof getRouterTools>;
beforeEach(() => {
vi.clearAllMocks();
tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
});
it('mysql_router_status should have correct structure', () => {
const tool = tools.find(t => t.name === 'mysql_router_status')!;
expect(tool.name).toBe('mysql_router_status');
expect(tool.description).toBeDefined();
expect(tool.annotations?.readOnlyHint).toBe(true);
expect(tool.annotations?.openWorldHint).toBe(true);
});
it('all router tools should be read-only', () => {
for (const tool of tools) {
expect(tool.annotations?.readOnlyHint).toBe(true);
}
});
it('all router tools should have correct requiredScopes', () => {
for (const tool of tools) {
expect(tool.requiredScopes).toContain('read');
}
});
it('all tools should have openWorldHint true', () => {
for (const tool of tools) {
expect(tool.annotations?.openWorldHint).toBe(true);
}
});
it('all tools should have idempotentHint true', () => {
for (const tool of tools) {
expect(tool.annotations?.idempotentHint).toBe(true);
}
});
});
describe('Handler Execution', () => {
let tools: ReturnType<typeof getRouterTools>;
let mockContext: ReturnType<typeof createMockRequestContext>;
beforeEach(() => {
vi.clearAllMocks();
tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
mockContext = createMockRequestContext();
});
afterEach(() => {
vi.restoreAllMocks();
});
// Helper to mock successful https response
const mockHttpsResponse = (data: unknown, statusCode = 200) => {
const mockReq = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void };
mockReq.end = vi.fn();
mockReq.destroy = vi.fn();
mockRequest.mockImplementation((_options, callback) => {
const mockRes = new EventEmitter() as EventEmitter & { statusCode: number; statusMessage: string };
mockRes.statusCode = statusCode;
mockRes.statusMessage = statusCode === 200 ? 'OK' : 'Error';
setImmediate(() => {
callback?.(mockRes);
mockRes.emit('data', JSON.stringify(data));
mockRes.emit('end');
});
return mockReq;
});
};
describe('mysql_router_status', () => {
it('should fetch router status and return result', async () => {
const mockStatus = {
processId: 1234,
version: '8.0.35',
hostname: 'router-host',
timeStarted: '2024-01-01T00:00:00Z'
};
mockHttpsResponse(mockStatus);
const tool = tools.find(t => t.name === 'mysql_router_status')!;
const result = await tool.handler({}, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.method).toBe('GET');
expect(options.path).toContain('/router/status');
expect(result).toEqual({
success: true,
status: mockStatus
});
});
});
describe('mysql_router_routes', () => {
it('should fetch all routes', async () => {
const mockRoutes = {
items: [
{ name: 'bootstrap_ro' },
{ name: 'bootstrap_rw' }
]
};
mockHttpsResponse(mockRoutes);
const tool = tools.find(t => t.name === 'mysql_router_routes')!;
const result = await tool.handler({}, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('/routes');
expect(result).toEqual({
success: true,
routes: mockRoutes
});
});
});
describe('mysql_router_route_status', () => {
it('should fetch status for specific route', async () => {
const mockRouteStatus = {
activeConnections: 5,
totalConnections: 100,
blockedHosts: 0
};
mockHttpsResponse(mockRouteStatus);
const tool = tools.find(t => t.name === 'mysql_router_route_status')!;
const result = await tool.handler({ routeName: 'bootstrap_ro' }, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('/routes/bootstrap_ro/status');
expect(result).toEqual({
success: true,
routeName: 'bootstrap_ro',
status: mockRouteStatus
});
});
it('should URL-encode route names', async () => {
mockHttpsResponse({});
const tool = tools.find(t => t.name === 'mysql_router_route_status')!;
await tool.handler({ routeName: 'route/with/slashes' }, mockContext);
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('route%2Fwith%2Fslashes');
});
});
describe('mysql_router_route_health', () => {
it('should check route health', async () => {
const mockHealth = { isAlive: true };
mockHttpsResponse(mockHealth);
const tool = tools.find(t => t.name === 'mysql_router_route_health')!;
const result = await tool.handler({ routeName: 'bootstrap_ro' }, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('/routes/bootstrap_ro/health');
expect(result).toEqual({
success: true,
routeName: 'bootstrap_ro',
health: mockHealth
});
});
});
describe('mysql_router_route_connections', () => {
it('should list active connections', async () => {
const mockConnections = {
items: [
{ sourceAddress: '192.168.1.1', destinationAddress: '10.0.0.1', bytesIn: 1024 }
]
};
mockHttpsResponse(mockConnections);
const tool = tools.find(t => t.name === 'mysql_router_route_connections')!;
const result = await tool.handler({ routeName: 'bootstrap_rw' }, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('/routes/bootstrap_rw/connections');
expect(result).toEqual({
success: true,
routeName: 'bootstrap_rw',
connections: mockConnections
});
});
});
describe('mysql_router_route_destinations', () => {
it('should list backend destinations', async () => {
const mockDestinations = {
items: [
{ address: 'mysql-1.example.com', port: 3306 },
{ address: 'mysql-2.example.com', port: 3306 }
]
};
mockHttpsResponse(mockDestinations);
const tool = tools.find(t => t.name === 'mysql_router_route_destinations')!;
const result = await tool.handler({ routeName: 'bootstrap_ro' }, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('/routes/bootstrap_ro/destinations');
expect(result).toEqual({
success: true,
routeName: 'bootstrap_ro',
destinations: mockDestinations
});
});
});
describe('mysql_router_route_blocked_hosts', () => {
it('should list blocked hosts', async () => {
const mockBlockedHosts = {
items: [
{ address: '192.168.1.100' }
]
};
mockHttpsResponse(mockBlockedHosts);
const tool = tools.find(t => t.name === 'mysql_router_route_blocked_hosts')!;
const result = await tool.handler({ routeName: 'bootstrap_rw' }, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('/routes/bootstrap_rw/blockedHosts');
expect(result).toEqual({
success: true,
routeName: 'bootstrap_rw',
blockedHosts: mockBlockedHosts
});
});
});
describe('mysql_router_metadata_status', () => {
it('should fetch metadata cache status', async () => {
const mockMetadata = {
refreshTotal: 100,
refreshSucceeded: 99,
lastRefreshHostName: 'mysql-primary.example.com'
};
mockHttpsResponse(mockMetadata);
const tool = tools.find(t => t.name === 'mysql_router_metadata_status')!;
const result = await tool.handler({ metadataName: 'my_cluster' }, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('/metadata/my_cluster/status');
expect(result).toEqual({
success: true,
metadataName: 'my_cluster',
status: mockMetadata
});
});
});
describe('mysql_router_pool_status', () => {
it('should fetch connection pool status', async () => {
const mockPoolStatus = {
reusedConnections: 50,
idleServerConnections: 10
};
mockHttpsResponse(mockPoolStatus);
const tool = tools.find(t => t.name === 'mysql_router_pool_status')!;
const result = await tool.handler({ poolName: 'default' }, mockContext);
expect(mockRequest).toHaveBeenCalled();
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
expect(options.path).toContain('/connection_pool/default/status');
expect(result).toEqual({
success: true,
poolName: 'default',
status: mockPoolStatus
});
});
});
});
describe('HTTP Header Handling', () => {
let tools: ReturnType<typeof getRouterTools>;
let mockContext: ReturnType<typeof createMockRequestContext>;
beforeEach(() => {
vi.clearAllMocks();
tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
mockContext = createMockRequestContext();
});
it('should send Accept: application/json header', async () => {
const mockReq = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void };
mockReq.end = vi.fn();
mockReq.destroy = vi.fn();
mockRequest.mockImplementation((_options, callback) => {
const mockRes = new EventEmitter() as EventEmitter & { statusCode: number; statusMessage: string };
mockRes.statusCode = 200;
mockRes.statusMessage = 'OK';
setImmediate(() => {
callback?.(mockRes);
mockRes.emit('data', '{}');
mockRes.emit('end');
});
return mockReq;
});
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await tool.handler({}, mockContext);
const options = mockRequest.mock.calls[0][0] as Record<string, Record<string, string>>;
expect(options.headers?.Accept).toBe('application/json');
});
});
describe('Error Handling', () => {
let tools: ReturnType<typeof getRouterTools>;
let mockContext: ReturnType<typeof createMockRequestContext>;
beforeEach(() => {
vi.clearAllMocks();
tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
mockContext = createMockRequestContext();
});
it('should throw on non-ok response', async () => {
const mockReq = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void };
mockReq.end = vi.fn();
mockReq.destroy = vi.fn();
mockRequest.mockImplementation((_options, callback) => {
const mockRes = new EventEmitter() as EventEmitter & { statusCode: number; statusMessage: string };
mockRes.statusCode = 401;
mockRes.statusMessage = 'Unauthorized';
setImmediate(() => {
callback?.(mockRes);
mockRes.emit('end');
});
return mockReq;
});
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await expect(tool.handler({}, mockContext)).rejects.toThrow('Router API error: 401 Unauthorized');
});
it('should throw on 404 response', async () => {
const mockReq = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void };
mockReq.end = vi.fn();
mockReq.destroy = vi.fn();
mockRequest.mockImplementation((_options, callback) => {
const mockRes = new EventEmitter() as EventEmitter & { statusCode: number; statusMessage: string };
mockRes.statusCode = 404;
mockRes.statusMessage = 'Not Found';
setImmediate(() => {
callback?.(mockRes);
mockRes.emit('end');
});
return mockReq;
});
const tool = tools.find(t => t.name === 'mysql_router_route_status')!;
await expect(tool.handler({ routeName: 'nonexistent' }, mockContext)).rejects.toThrow('Router API error: 404 Not Found');
});
it('should throw on network error', async () => {
const mockReq = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void };
mockReq.end = vi.fn();
mockReq.destroy = vi.fn();
mockRequest.mockImplementation(() => {
setImmediate(() => {
mockReq.emit('error', new Error('Network error'));
});
return mockReq;
});
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await expect(tool.handler({}, mockContext)).rejects.toThrow('Network error');
});
it('should throw on connection refused', async () => {
const mockReq = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void };
mockReq.end = vi.fn();
mockReq.destroy = vi.fn();
mockRequest.mockImplementation(() => {
setImmediate(() => {
mockReq.emit('error', new Error('ECONNREFUSED'));
});
return mockReq;
});
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await expect(tool.handler({}, mockContext)).rejects.toThrow('ECONNREFUSED');
});
});
describe('Authentication and TLS Handling', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
vi.clearAllMocks();
// Save original env
originalEnv = { ...process.env };
// Clear relevant env vars
delete process.env['MYSQL_ROUTER_URL'];
delete process.env['MYSQL_ROUTER_USER'];
delete process.env['MYSQL_ROUTER_PASSWORD'];
delete process.env['MYSQL_ROUTER_INSECURE'];
delete process.env['NODE_TLS_REJECT_UNAUTHORIZED'];
});
afterEach(() => {
vi.restoreAllMocks();
// Restore original env
process.env = originalEnv;
});
// Helper to mock response
const setupMockRequest = () => {
const mockReq = new EventEmitter() as EventEmitter & { end: () => void; destroy: () => void };
mockReq.end = vi.fn();
mockReq.destroy = vi.fn();
mockRequest.mockImplementation((_options, callback) => {
const mockRes = new EventEmitter() as EventEmitter & { statusCode: number; statusMessage: string };
mockRes.statusCode = 200;
mockRes.statusMessage = 'OK';
setImmediate(() => {
callback?.(mockRes);
mockRes.emit('data', '{}');
mockRes.emit('end');
});
return mockReq;
});
return mockReq;
};
it('should add Basic auth header when credentials provided', async () => {
process.env['MYSQL_ROUTER_USER'] = 'admin';
process.env['MYSQL_ROUTER_PASSWORD'] = 'secret';
setupMockRequest();
const tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await tool.handler({}, createMockRequestContext());
const options = mockRequest.mock.calls[0][0] as Record<string, Record<string, string>>;
expect(options.headers?.Authorization).toMatch(/^Basic /);
// Verify the encoded value
const expectedAuth = Buffer.from('admin:secret').toString('base64');
expect(options.headers?.Authorization).toBe(`Basic ${expectedAuth}`);
});
it('should not add auth header when no credentials provided', async () => {
setupMockRequest();
const tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await tool.handler({}, createMockRequestContext());
const options = mockRequest.mock.calls[0][0] as Record<string, Record<string, string>>;
expect(options.headers?.Authorization).toBeUndefined();
});
it('should set rejectUnauthorized=false for HTTPS with insecure=true', async () => {
process.env['MYSQL_ROUTER_URL'] = 'https://localhost:8443';
process.env['MYSQL_ROUTER_INSECURE'] = 'true';
setupMockRequest();
const tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await tool.handler({}, createMockRequestContext());
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
// insecure=true means rejectUnauthorized should be false
expect(options.rejectUnauthorized).toBe(false);
});
it('should set rejectUnauthorized=true for secure HTTPS requests', async () => {
process.env['MYSQL_ROUTER_URL'] = 'https://localhost:8443';
process.env['MYSQL_ROUTER_INSECURE'] = 'false';
setupMockRequest();
const tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await tool.handler({}, createMockRequestContext());
const options = mockRequest.mock.calls[0][0] as Record<string, unknown>;
// insecure=false means rejectUnauthorized should be true
expect(options.rejectUnauthorized).toBe(true);
});
it('should not modify NODE_TLS_REJECT_UNAUTHORIZED env var', async () => {
process.env['MYSQL_ROUTER_URL'] = 'https://localhost:8443';
process.env['MYSQL_ROUTER_INSECURE'] = 'true';
const originalValue = process.env['NODE_TLS_REJECT_UNAUTHORIZED'];
setupMockRequest();
const tools = getRouterTools(createMockMySQLAdapter() as unknown as MySQLAdapter);
const tool = tools.find(t => t.name === 'mysql_router_status')!;
await tool.handler({}, createMockRequestContext());
// New implementation should not modify this env var
expect(process.env['NODE_TLS_REJECT_UNAUTHORIZED']).toBe(originalValue);
});
});