import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { NotionConnector } from '../../src/connectors/notion-connector.js'; import { ConfluenceConnector } from '../../src/connectors/confluence-connector.js'; import { ObsidianConnector } from '../../src/connectors/obsidian-connector.js'; import type { VaultStore } from '@waggle/core'; /** Shape of ConnectorResult.data fields asserted by these knowledge-connector tests. */ type KnowledgeData = { created?: boolean; updated?: boolean; content?: string; name?: string; notes?: { name: string }[]; results?: { name: string }[]; }; function createMockVault( connectorId: string, cred?: { value: string; isExpired: boolean }, extras?: Record, ): VaultStore { return { getConnectorCredential: vi.fn((id: string) => { if (id === connectorId && cred) return { ...cred, type: 'api_key' }; return null; }), get: vi.fn((key: string) => extras?.[key] ?? null), set: vi.fn(), delete: vi.fn(), list: vi.fn(() => []), has: vi.fn(() => false), setConnectorCredential: vi.fn(), migrateFromConfig: vi.fn(() => 0), } as unknown as VaultStore; } // ── Notion Connector ────────────────────────────────────────────────── describe('NotionConnector', () => { let connector: NotionConnector; let originalFetch: typeof globalThis.fetch; beforeEach(() => { connector = new NotionConnector(); originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it('has correct id, name, and actions', () => { expect(connector.id).toBe('notion'); expect(connector.name).toBe('Notion'); expect(connector.service).toBe('notion.so'); expect(connector.authType).toBe('bearer'); expect(connector.substrate).toBe('waggle'); expect(connector.actions).toHaveLength(7); expect(connector.actions.map(a => a.name)).toEqual([ 'search_pages', 'get_page', 'list_databases', 'query_database', 'create_page', 'update_page', 'get_block_children', ]); }); it('action risk levels are correct', () => { const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel])); expect(risks.search_pages).toBe('low'); expect(risks.get_page).toBe('low'); expect(risks.list_databases).toBe('low'); expect(risks.query_database).toBe('low'); expect(risks.create_page).toBe('medium'); expect(risks.update_page).toBe('medium'); expect(risks.get_block_children).toBe('low'); }); it('execute returns error when not connected', async () => { const result = await connector.execute('search_pages', { query: 'test' }); expect(result.success).toBe(false); expect(result.error).toContain('Not connected'); }); it('connect() retrieves token from vault', async () => { const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false }); await connector.connect(vault); expect(vault.getConnectorCredential).toHaveBeenCalledWith('notion'); }); it('healthCheck() returns connected when API responds OK', async () => { const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false }); await connector.connect(vault); globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ type: 'bot' }) }) as unknown as typeof fetch; const health = await connector.healthCheck(); expect(health.status).toBe('connected'); expect(health.id).toBe('notion'); }); it('execute(search_pages) calls POST /search', async () => { const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false }); await connector.connect(vault); const mockResults = { results: [{ id: 'page-1', object: 'page' }] }; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResults }) as unknown as typeof fetch; const result = await connector.execute('search_pages', { query: 'project plan' }); expect(result.success).toBe(true); expect(result.data).toEqual(mockResults); const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0]; expect(fetchCall[0]).toContain('/search'); expect(fetchCall[1].method).toBe('POST'); }); it('execute(get_page) calls GET /pages/{id}', async () => { const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false }); await connector.connect(vault); const mockPage = { id: 'page-1', object: 'page' }; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockPage }) as unknown as typeof fetch; const result = await connector.execute('get_page', { page_id: 'page-1' }); expect(result.success).toBe(true); expect(result.data).toEqual(mockPage); }); it('toDefinition() maps correctly', () => { const def = connector.toDefinition('connected'); expect(def.id).toBe('notion'); expect(def.tools).toContain('connector_notion_search_pages'); expect(def.tools).toContain('connector_notion_create_page'); expect(def.tools).toHaveLength(7); }); it('execute() returns error for unknown action', async () => { const vault = createMockVault('notion', { value: 'ntn_test123', isExpired: false }); await connector.connect(vault); const result = await connector.execute('unknown_action', {}); expect(result.success).toBe(false); expect(result.error).toContain('Unknown action'); }); }); // ── Confluence Connector ────────────────────────────────────────────── describe('ConfluenceConnector', () => { let connector: ConfluenceConnector; let originalFetch: typeof globalThis.fetch; beforeEach(() => { connector = new ConfluenceConnector(); originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); it('has correct id, name, and actions', () => { expect(connector.id).toBe('confluence'); expect(connector.name).toBe('Confluence'); expect(connector.service).toBe('atlassian.net'); expect(connector.authType).toBe('basic'); expect(connector.substrate).toBe('waggle'); expect(connector.actions).toHaveLength(5); expect(connector.actions.map(a => a.name)).toEqual([ 'search_content', 'get_page', 'list_spaces', 'create_page', 'update_page', ]); }); it('action risk levels are correct', () => { const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel])); expect(risks.search_content).toBe('low'); expect(risks.get_page).toBe('low'); expect(risks.list_spaces).toBe('low'); expect(risks.create_page).toBe('medium'); expect(risks.update_page).toBe('medium'); }); it('execute returns error when not connected', async () => { const result = await connector.execute('search_content', { cql: 'type=page' }); expect(result.success).toBe(false); expect(result.error).toContain('Not connected'); }); it('connect() retrieves credentials and domain from vault', async () => { const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, { 'connector:confluence:email': { value: 'user@example.com' }, 'connector:confluence:domain': { value: 'mycompany' }, }); await connector.connect(vault); expect(vault.getConnectorCredential).toHaveBeenCalledWith('confluence'); expect(vault.get).toHaveBeenCalledWith('connector:confluence:email'); expect(vault.get).toHaveBeenCalledWith('connector:confluence:domain'); }); it('healthCheck() returns connected when API responds OK', async () => { const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, { 'connector:confluence:email': { value: 'user@example.com' }, 'connector:confluence:domain': { value: 'mycompany' }, }); await connector.connect(vault); globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ results: [] }) }) as unknown as typeof fetch; const health = await connector.healthCheck(); expect(health.status).toBe('connected'); expect(health.id).toBe('confluence'); }); it('healthCheck() returns disconnected when no domain configured', async () => { const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, { 'connector:confluence:email': { value: 'user@example.com' }, // no domain entry }); await connector.connect(vault); const health = await connector.healthCheck(); expect(health.status).toBe('disconnected'); }); it('execute(search_content) calls GET /search with cql', async () => { const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, { 'connector:confluence:email': { value: 'user@example.com' }, 'connector:confluence:domain': { value: 'mycompany' }, }); await connector.connect(vault); const mockResults = { results: [{ id: '123', title: 'Test Page' }] }; globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => mockResults }) as unknown as typeof fetch; const result = await connector.execute('search_content', { cql: 'type=page AND text~"test"' }); expect(result.success).toBe(true); expect(result.data).toEqual(mockResults); const fetchCall = vi.mocked(globalThis.fetch).mock.calls[0]; expect(fetchCall[0]).toContain('mycompany.atlassian.net/wiki/api/v2/search'); expect(fetchCall[0]).toContain('cql='); }); it('toDefinition() maps correctly', () => { const def = connector.toDefinition('connected'); expect(def.id).toBe('confluence'); expect(def.tools).toContain('connector_confluence_search_content'); expect(def.tools).toContain('connector_confluence_create_page'); expect(def.tools).toHaveLength(5); }); it('execute() returns error for unknown action', async () => { const vault = createMockVault('confluence', { value: 'api-token-123', isExpired: false }, { 'connector:confluence:email': { value: 'user@example.com' }, 'connector:confluence:domain': { value: 'mycompany' }, }); await connector.connect(vault); const result = await connector.execute('unknown_action', {}); expect(result.success).toBe(false); expect(result.error).toContain('Unknown action'); }); }); // ── Obsidian Connector ──────────────────────────────────────────────── describe('ObsidianConnector', () => { let connector: ObsidianConnector; let tmpDir: string; let siblingDir: string; beforeEach(() => { connector = new ObsidianConnector(); // Create a temp directory as a mock Obsidian vault tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-obsidian-test-')); siblingDir = `${tmpDir}-evil`; }); afterEach(() => { // Clean up temp directory fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(siblingDir, { recursive: true, force: true }); }); it('has correct id, name, and actions', () => { expect(connector.id).toBe('obsidian'); expect(connector.name).toBe('Obsidian'); expect(connector.service).toBe('local'); expect(connector.authType).toBe('api_key'); expect(connector.substrate).toBe('waggle'); expect(connector.actions).toHaveLength(6); expect(connector.actions.map(a => a.name)).toEqual([ 'search_notes', 'get_note', 'list_notes', 'create_note', 'update_note', 'list_folders', ]); }); it('action risk levels are correct', () => { const risks = Object.fromEntries(connector.actions.map(a => [a.name, a.riskLevel])); expect(risks.search_notes).toBe('low'); expect(risks.get_note).toBe('low'); expect(risks.list_notes).toBe('low'); expect(risks.create_note).toBe('medium'); expect(risks.update_note).toBe('medium'); expect(risks.list_folders).toBe('low'); }); it('execute returns error when not connected', async () => { const result = await connector.execute('search_notes', { query: 'test' }); expect(result.success).toBe(false); expect(result.error).toContain('Not connected'); }); it('connect() retrieves vault path from vault', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); expect(vault.getConnectorCredential).toHaveBeenCalledWith('obsidian'); }); it('healthCheck() returns connected when directory exists', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); const health = await connector.healthCheck(); expect(health.status).toBe('connected'); expect(health.id).toBe('obsidian'); }); it('healthCheck() returns error when directory does not exist', async () => { const vault = createMockVault('obsidian', { value: path.join(tmpDir, 'nonexistent'), isExpired: false }); await connector.connect(vault); const health = await connector.healthCheck(); expect(health.status).toBe('error'); expect(health.error).toBeDefined(); }); it('create_note creates a new file', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); const result = await connector.execute('create_note', { path: 'test-note.md', content: '# Hello World\n\nThis is a test note.', }); expect(result.success).toBe(true); expect((result.data as KnowledgeData).created).toBe(true); // Verify the file exists const filePath = path.join(tmpDir, 'test-note.md'); expect(fs.existsSync(filePath)).toBe(true); expect(fs.readFileSync(filePath, 'utf-8')).toBe('# Hello World\n\nThis is a test note.'); }); it('create_note creates parent directories', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); const result = await connector.execute('create_note', { path: 'Projects/subfolder/deep-note.md', content: 'Deep content', }); expect(result.success).toBe(true); expect(fs.existsSync(path.join(tmpDir, 'Projects', 'subfolder', 'deep-note.md'))).toBe(true); }); it('create_note rejects duplicate', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.writeFileSync(path.join(tmpDir, 'existing.md'), 'old content'); const result = await connector.execute('create_note', { path: 'existing.md', content: 'new content', }); expect(result.success).toBe(false); expect(result.error).toContain('already exists'); }); it('get_note reads file content', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.writeFileSync(path.join(tmpDir, 'read-me.md'), '# Test\nContent here'); const result = await connector.execute('get_note', { path: 'read-me.md' }); expect(result.success).toBe(true); expect((result.data as KnowledgeData).content).toBe('# Test\nContent here'); expect((result.data as KnowledgeData).name).toBe('read-me.md'); }); it('get_note returns error for missing file', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); const result = await connector.execute('get_note', { path: 'nonexistent.md' }); expect(result.success).toBe(false); expect(result.error).toContain('not found'); }); it('update_note overwrites file content', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.writeFileSync(path.join(tmpDir, 'update-me.md'), 'old content'); const result = await connector.execute('update_note', { path: 'update-me.md', content: 'new content', }); expect(result.success).toBe(true); expect((result.data as KnowledgeData).updated).toBe(true); expect(fs.readFileSync(path.join(tmpDir, 'update-me.md'), 'utf-8')).toBe('new content'); }); it('list_notes returns all markdown files', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.writeFileSync(path.join(tmpDir, 'note1.md'), 'content 1'); fs.writeFileSync(path.join(tmpDir, 'note2.md'), 'content 2'); fs.writeFileSync(path.join(tmpDir, 'not-markdown.txt'), 'ignored'); const result = await connector.execute('list_notes', {}); expect(result.success).toBe(true); const notes = (result.data as { notes: { name: string }[] }).notes; expect(notes).toHaveLength(2); expect(notes.map((n) => n.name).sort()).toEqual(['note1.md', 'note2.md']); }); it('list_notes includes subfolder files', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.mkdirSync(path.join(tmpDir, 'Projects')); fs.writeFileSync(path.join(tmpDir, 'root.md'), 'root'); fs.writeFileSync(path.join(tmpDir, 'Projects', 'sub.md'), 'sub'); const result = await connector.execute('list_notes', {}); expect(result.success).toBe(true); expect((result.data as KnowledgeData).notes).toHaveLength(2); }); it('list_notes skips hidden directories', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.mkdirSync(path.join(tmpDir, '.obsidian')); fs.writeFileSync(path.join(tmpDir, '.obsidian', 'config.md'), 'hidden'); fs.writeFileSync(path.join(tmpDir, 'visible.md'), 'visible'); const result = await connector.execute('list_notes', {}); expect(result.success).toBe(true); expect((result.data as KnowledgeData).notes).toHaveLength(1); expect((result.data as KnowledgeData).notes[0].name).toBe('visible.md'); }); it('search_notes finds by filename', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.writeFileSync(path.join(tmpDir, 'project-plan.md'), 'Some content'); fs.writeFileSync(path.join(tmpDir, 'meeting-notes.md'), 'Other content'); const result = await connector.execute('search_notes', { query: 'project' }); expect(result.success).toBe(true); expect((result.data as KnowledgeData).results).toHaveLength(1); expect((result.data as KnowledgeData).results[0].name).toBe('project-plan.md'); }); it('search_notes finds by content', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.writeFileSync(path.join(tmpDir, 'note-a.md'), 'This is about JavaScript'); fs.writeFileSync(path.join(tmpDir, 'note-b.md'), 'This is about TypeScript and Waggle'); const result = await connector.execute('search_notes', { query: 'waggle' }); expect(result.success).toBe(true); expect((result.data as KnowledgeData).results).toHaveLength(1); expect((result.data as KnowledgeData).results[0].name).toBe('note-b.md'); }); it('list_folders returns subdirectories', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.mkdirSync(path.join(tmpDir, 'Projects')); fs.mkdirSync(path.join(tmpDir, 'Archive')); fs.mkdirSync(path.join(tmpDir, '.obsidian')); // hidden, should be excluded const result = await connector.execute('list_folders', {}); expect(result.success).toBe(true); const folders = (result.data as { folders: { name: string }[] }).folders; expect(folders).toHaveLength(2); expect(folders.map((f) => f.name).sort()).toEqual(['Archive', 'Projects']); }); it('rejects path traversal', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); const result = await connector.execute('get_note', { path: '../../etc/passwd' }); expect(result.success).toBe(false); expect(result.error).toContain('path traversal'); }); it('rejects sibling-prefix traversal outside the vault', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.mkdirSync(siblingDir); fs.writeFileSync(path.join(siblingDir, 'secret.md'), 'outside secret'); const siblingPath = path.relative(tmpDir, path.join(siblingDir, 'secret.md')); const result = await connector.execute('get_note', { path: siblingPath }); expect(result.success).toBe(false); expect(result.error).toContain('path traversal'); }); it('rejects absolute, drive-qualified, UNC, and mixed-separator paths', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.mkdirSync(siblingDir); fs.writeFileSync(path.join(tmpDir, 'inside.md'), 'inside'); fs.writeFileSync(path.join(siblingDir, 'secret.md'), 'outside secret'); const invalidPaths = [ path.join(tmpDir, 'inside.md'), 'C:relative.md', '\\\\server\\share\\secret.md', '/absolute/secret.md', `..\\${path.basename(siblingDir)}/secret.md`, ]; for (const invalidPath of invalidPaths) { const result = await connector.execute('get_note', { path: invalidPath }); expect(result.success, invalidPath).toBe(false); expect(result.error, invalidPath).toContain('path traversal'); } }); it('rejects reads through an out-of-vault symlink or Windows junction', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.mkdirSync(siblingDir); fs.writeFileSync(path.join(siblingDir, 'secret.md'), 'outside secret'); fs.symlinkSync( siblingDir, path.join(tmpDir, 'linked-out'), process.platform === 'win32' ? 'junction' : 'dir', ); const result = await connector.execute('get_note', { path: 'linked-out/secret.md' }); expect(result.success).toBe(false); expect(result.error).toContain('path traversal'); }); it('rejects writes below a dangling link', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); fs.mkdirSync(siblingDir); fs.symlinkSync( siblingDir, path.join(tmpDir, 'dangling-out'), process.platform === 'win32' ? 'junction' : 'dir', ); fs.rmSync(siblingDir, { recursive: true, force: true }); const blocked = await connector.execute('create_note', { path: 'dangling-out/blocked.md', content: 'must not escape', }); expect(blocked.success).toBe(false); expect(blocked.error).toContain('path traversal'); }); it('toDefinition() maps correctly', () => { const def = connector.toDefinition('connected'); expect(def.id).toBe('obsidian'); expect(def.tools).toContain('connector_obsidian_search_notes'); expect(def.tools).toContain('connector_obsidian_create_note'); expect(def.tools).toHaveLength(6); }); it('execute() returns error for unknown action', async () => { const vault = createMockVault('obsidian', { value: tmpDir, isExpired: false }); await connector.connect(vault); const result = await connector.execute('unknown_action', {}); expect(result.success).toBe(false); expect(result.error).toContain('Unknown action'); }); });