import { describe, it, expect, vi, beforeEach } from 'vitest'; import { BaseConnector, type ConnectorAction, type ConnectorResult, type WaggleConnector } from '../src/connector-sdk.js'; import { ConnectorRegistry, type AuditLogger } from '../src/connector-registry.js'; import { needsConfirmationWithAutonomy } from '../src/confirmation.js'; import type { VaultStore } from '@waggle/core'; import type { ConnectorHealth, ConnectorStatus } from '@waggle/shared'; // ─── Mock Connector ────────────────────────────────────────────────────── class MockConnector extends BaseConnector { readonly id = 'mock'; readonly name = 'Mock Service'; readonly description = 'A mock connector for testing'; readonly service = 'mock.example.com'; readonly authType = 'bearer' as const; readonly substrate = 'waggle' as const; readonly actions: ConnectorAction[] = [ { name: 'list_items', description: 'List all items', inputSchema: { properties: { limit: { type: 'number' } } }, riskLevel: 'low', }, { name: 'create_item', description: 'Create a new item', inputSchema: { properties: { name: { type: 'string' } }, required: ['name'] }, riskLevel: 'medium', }, { name: 'delete_item', description: 'Delete an item permanently', inputSchema: { properties: { id: { type: 'string' } }, required: ['id'] }, riskLevel: 'high', }, ]; private token: string | null = null; connectCalled = false; healthCheckCalled = false; executeCalls: Array<{ action: string; params: Record }> = []; async connect(vault: VaultStore): Promise { this.connectCalled = true; const cred = vault.getConnectorCredential(this.id); this.token = cred?.value ?? null; } async healthCheck(): Promise { this.healthCheckCalled = true; return { id: this.id, name: this.name, status: this.token ? 'connected' : 'disconnected', lastChecked: new Date().toISOString(), }; } async execute(action: string, params: Record): Promise { this.executeCalls.push({ action, params }); const known = this.actions.find(a => a.name === action); if (!known) return { success: false, error: `Unknown action: ${action}` }; return { success: true, data: { action, params, token: this.token } }; } } // ─── Mock Vault ────────────────────────────────────────────────────────── function createMockVault(credentials: Record = {}): VaultStore { return { getConnectorCredential: vi.fn((id: string) => { const cred = credentials[id]; if (!cred) return null; return { value: cred.value, type: 'bearer', isExpired: cred.isExpired }; }), setConnectorCredential: vi.fn(), set: vi.fn(), get: vi.fn(), delete: vi.fn(), list: vi.fn(() => []), has: vi.fn(() => false), migrateFromConfig: vi.fn(() => 0), } as unknown as VaultStore; } async function registerAndHydrate( registry: ConnectorRegistry, connector: WaggleConnector, ): Promise { registry.register(connector); expect(await registry.hydrate(connector.id)).toBe(true); } // ─── WaggleConnector Interface ─────────────────────────────────────────── describe('WaggleConnector interface', () => { it('a connector implementing the interface can be instantiated', () => { const connector = new MockConnector(); expect(connector.id).toBe('mock'); expect(connector.name).toBe('Mock Service'); expect(connector.actions).toHaveLength(3); }); it('toDefinition() maps WaggleConnector → ConnectorDefinition correctly', () => { const connector = new MockConnector(); const def = connector.toDefinition('connected'); expect(def.id).toBe('mock'); expect(def.name).toBe('Mock Service'); expect(def.description).toBe('A mock connector for testing'); expect(def.service).toBe('mock.example.com'); expect(def.authType).toBe('bearer'); expect(def.status).toBe('connected'); expect(def.substrate).toBe('waggle'); }); it('actions map to tools[] and capabilities[] in the definition', () => { const connector = new MockConnector(); const def = connector.toDefinition('connected'); // tools should be connector__ expect(def.tools).toEqual([ 'connector_mock_list_items', 'connector_mock_create_item', 'connector_mock_delete_item', ]); // capabilities derived from actions: list=search+read, create=write, delete=write expect(def.capabilities).toContain('read'); expect(def.capabilities).toContain('write'); expect(def.capabilities).toContain('search'); // actions metadata present expect(def.actions).toHaveLength(3); expect(def.actions![0]).toEqual({ name: 'list_items', description: 'List all items', riskLevel: 'low' }); expect(def.actions![2].riskLevel).toBe('high'); }); }); // ─── ConnectorRegistry ─────────────────────────────────────────────────── describe('ConnectorRegistry', () => { let vault: VaultStore; let registry: ConnectorRegistry; beforeEach(() => { vault = createMockVault(); registry = new ConnectorRegistry(vault); }); it('register() adds a connector to the registry', () => { registry.register(new MockConnector()); expect(registry.getAll()).toHaveLength(1); expect(registry.get('mock')).toBeDefined(); }); it('getAll() returns all registered connectors', () => { registry.register(new MockConnector()); const c2 = new MockConnector(); (c2 as { id: string }).id = 'mock2'; // Override for second registration // Note: can't easily override readonly. Use Object.defineProperty. Object.defineProperty(c2, 'id', { value: 'mock2' }); registry.register(c2); expect(registry.getAll()).toHaveLength(2); }); it('getConnected() returns only connectors with valid vault credentials', async () => { vault = createMockVault({ mock: { value: 'token123', isExpired: false } }); registry = new ConnectorRegistry(vault); await registerAndHydrate(registry, new MockConnector()); const connected = registry.getConnected(); expect(connected).toHaveLength(1); expect(connected[0].id).toBe('mock'); }); it('getConnected() excludes connectors with expired credentials', async () => { vault = createMockVault({ mock: { value: 'token123', isExpired: true } }); registry = new ConnectorRegistry(vault); await registerAndHydrate(registry, new MockConnector()); expect(registry.getConnected()).toHaveLength(0); }); it('getConnected() excludes connectors without credentials', async () => { await registerAndHydrate(registry, new MockConnector()); expect(registry.getConnected()).toHaveLength(0); }); it('generateTools() returns ToolDefinition[] only for connected connectors', async () => { vault = createMockVault({ mock: { value: 'token123', isExpired: false } }); registry = new ConnectorRegistry(vault); await registerAndHydrate(registry, new MockConnector()); const tools = registry.generateTools(); expect(tools).toHaveLength(3); // 3 actions = 3 tools }); it('generateTools() creates tools named connector__', async () => { vault = createMockVault({ mock: { value: 'token123', isExpired: false } }); registry = new ConnectorRegistry(vault); await registerAndHydrate(registry, new MockConnector()); const tools = registry.generateTools(); const names = tools.map(t => t.name); expect(names).toEqual([ 'connector_mock_list_items', 'connector_mock_create_item', 'connector_mock_delete_item', ]); }); it('propagates trusted action risk so declared-high actions stay gated at YOLO', async () => { vault = createMockVault({ mock: { value: 'token123', isExpired: false } }); registry = new ConnectorRegistry(vault); const connector = new MockConnector(); connector.actions.splice(0, connector.actions.length, { name: 'read_action', description: 'Read harmless data', inputSchema: {}, riskLevel: 'low', }, { name: 'execute_action', description: 'Execute a provider action', inputSchema: {}, riskLevel: 'high', }, ); await registerAndHydrate(registry, connector); const tools = registry.generateTools(); const readTool = tools.find(tool => tool.name === 'connector_mock_read_action')!; const executeTool = tools.find(tool => tool.name === 'connector_mock_execute_action')!; expect(needsConfirmationWithAutonomy(executeTool.name, {}, 'yolo', executeTool.riskLevel)).toBe(true); expect(needsConfirmationWithAutonomy(readTool.name, {}, 'yolo', readTool.riskLevel)).toBe(false); expect(tools.map(tool => tool.riskLevel)).toEqual(['low', 'high']); }); it('generateTools() returns empty array for disconnected connectors', async () => { await registerAndHydrate(registry, new MockConnector()); expect(registry.generateTools()).toEqual([]); }); it('healthCheck() delegates to connector healthCheck()', async () => { const connector = new MockConnector(); registry.register(connector); const health = await registry.healthCheck('mock'); expect(health).not.toBeNull(); expect(health!.id).toBe('mock'); expect(connector.healthCheckCalled).toBe(true); }); it('healthCheck() returns null for unknown connector', async () => { expect(await registry.healthCheck('nonexistent')).toBeNull(); }); it('unregister() removes a connector', () => { registry.register(new MockConnector()); expect(registry.getAll()).toHaveLength(1); const removed = registry.unregister('mock'); expect(removed).toBe(true); expect(registry.getAll()).toHaveLength(0); }); it('getDefinitions() returns definitions with live status', async () => { vault = createMockVault({ mock: { value: 'tok', isExpired: false } }); registry = new ConnectorRegistry(vault); await registerAndHydrate(registry, new MockConnector()); const defs = registry.getDefinitions(); expect(defs).toHaveLength(1); expect(defs[0].status).toBe('connected'); expect(defs[0].id).toBe('mock'); }); }); // ─── Dynamic Tool Generation ───────────────────────────────────────────── describe('Dynamic tool generation', () => { it('tool execute() delegates to connector.execute()', async () => { const vault = createMockVault({ mock: { value: 'tok', isExpired: false } }); const registry = new ConnectorRegistry(vault); const connector = new MockConnector(); await registerAndHydrate(registry, connector); const tools = registry.generateTools(); const listTool = tools.find(t => t.name === 'connector_mock_list_items')!; const result = await listTool.execute({ limit: 10 }); const parsed = JSON.parse(result); expect(parsed.success).toBe(true); expect(parsed.data.action).toBe('list_items'); expect(parsed.data.params).toEqual({ limit: 10 }); }); it('tool input_schema matches ConnectorAction.inputSchema', async () => { const vault = createMockVault({ mock: { value: 'tok', isExpired: false } }); const registry = new ConnectorRegistry(vault); await registerAndHydrate(registry, new MockConnector()); const tools = registry.generateTools(); const listTool = tools.find(t => t.name === 'connector_mock_list_items')!; expect(listTool.parameters).toMatchObject({ type: 'object', properties: { limit: { type: 'number' } }, }); }); it('tool parameters do NOT include _connectorMeta (security: prevents LLM injection)', async () => { const vault = createMockVault({ mock: { value: 'tok', isExpired: false } }); const registry = new ConnectorRegistry(vault); await registerAndHydrate(registry, new MockConnector()); const tools = registry.generateTools(); expect(tools).toHaveLength(3); // No tool should have _connectorMeta in its schema (risk is determined by tool name, not args) for (const tool of tools) { expect(tool.parameters._connectorMeta).toBeUndefined(); } }); it('audit trail entry created on tool execution', async () => { const vault = createMockVault({ mock: { value: 'tok', isExpired: false } }); const auditLog = vi.fn(); const auditLogger: AuditLogger = { log: auditLog }; const registry = new ConnectorRegistry(vault, auditLogger); await registerAndHydrate(registry, new MockConnector()); const tools = registry.generateTools(); const createTool = tools.find(t => t.name === 'connector_mock_create_item')!; await createTool.execute({ name: 'Test Item' }); expect(auditLog).toHaveBeenCalledWith({ actionType: 'connector.mock.create_item', description: 'Connector action: Mock Service → create_item', requiresApproval: true, }); }); it('tool execution handles errors gracefully', async () => { const vault = createMockVault({ mock: { value: 'tok', isExpired: false } }); const registry = new ConnectorRegistry(vault); // Create a connector that throws const connector = new MockConnector(); connector.execute = async () => { throw new Error('API timeout'); }; await registerAndHydrate(registry, connector); const tools = registry.generateTools(); const listTool = tools.find(t => t.name === 'connector_mock_list_items')!; const result = JSON.parse(await listTool.execute({})); expect(result.success).toBe(false); expect(result.error).toBe('API timeout'); }); it('connector receives clean args without internal metadata', async () => { const vault = createMockVault({ mock: { value: 'tok', isExpired: false } }); const registry = new ConnectorRegistry(vault); const connector = new MockConnector(); await registerAndHydrate(registry, connector); const tools = registry.generateTools(); const createTool = tools.find(t => t.name === 'connector_mock_create_item')!; await createTool.execute({ name: 'Test' }); expect(connector.executeCalls[0].params).toEqual({ name: 'Test' }); }); });