import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils'; import Setting from '../../models/Setting'; import AiService from './AiService'; import deriveClassification from './classification'; import { ChatRole } from './types'; describe('AiService', () => { beforeEach(async () => { await setupDatabaseAndSynchronizer(1); await switchClient(1); AiService.instance().invalidateProvider(); }); it('throws when AI is disabled', async () => { Setting.setValue('ai.enabled', false); await expect(AiService.instance().chat([{ role: ChatRole.User, content: 'hi' }])) .rejects.toMatchObject({ code: 'aiDisabled' }); }); it('throws when remote provider is selected but allowRemote is off', async () => { Setting.setValue('ai.enabled', true); Setting.setValue('ai.allowRemote', false); Setting.setValue('ai.chat.providerType', 'anthropic'); Setting.setValue('ai.chat.apiKey', 'sk-test'); Setting.setValue('ai.chat.model', 'claude-3-5-sonnet-latest'); await expect(AiService.instance().chat([{ role: ChatRole.User, content: 'hi' }])) .rejects.toMatchObject({ code: 'aiRemoteNotAllowed' }); }); it.each([ ['http://localhost:11434/v1', 'local'], ['http://127.0.0.1:11434/v1', 'local'], ['http://[::1]:11434/v1', 'local'], // Obfuscated loopback: new URL() resolves these to 127.0.0.1. ['http://0177.0.0.1:11434/v1', 'local'], ['http://2130706433:11434/v1', 'local'], ['http://127.1:11434/v1', 'local'], // LAN and internal-only hosts stay within the user's private network. ['http://10.0.0.5:11434/v1', 'local'], ['http://172.16.3.9:11434/v1', 'local'], ['http://192.168.1.50:11434/v1', 'local'], ['http://169.254.1.1:11434/v1', 'local'], ['http://ollama.internal:11434/v1', 'local'], ['http://ollama.home.arpa:11434/v1', 'local'], ['http://[fd12:3456::1]:11434/v1', 'local'], ['http://[fe80::1]:11434/v1', 'local'], // 172.15 and 172.32 are outside the 172.16/12 block. ['http://172.15.0.1:11434/v1', 'remote'], ['http://172.32.0.1:11434/v1', 'remote'], // mDNS can resolve .local to any machine on an untrusted network. ['http://ollama.local:11434/v1', 'remote'], // Site-local, deprecated by RFC 3879 and not treated as private. ['http://[fec0::1]:11434/v1', 'remote'], // Leading zero makes this octal, so it resolves to the public 8.0.0.5. ['http://010.0.0.5:11434/v1', 'remote'], ['http://8.8.8.8:11434/v1', 'remote'], ['https://api.openai.com/v1', 'remote'], ['not a url', 'remote'], ['', 'remote'], ])('classifies openai-compatible %s as %s', (baseUrl, expected) => { expect(deriveClassification('openai-compatible', baseUrl)).toBe(expected); }); it('classifies anthropic and joplin-cloud as remote regardless of baseUrl', () => { expect(deriveClassification('anthropic', '')).toBe('remote'); expect(deriveClassification('joplin-cloud', '')).toBe('remote'); }); it('writes joplin-cloud default on first ai.enabled if sync target is Joplin Cloud', () => { Setting.setValue('sync.target', 10); Setting.setValue('ai.chat.providerType.configured', false); Setting.setValue('ai.chat.providerType', 'openai-compatible'); AiService.instance().applyFirstEnableDefault(); expect(Setting.value('ai.chat.providerType')).toBe('joplin-cloud'); expect(Setting.value('ai.chat.providerType.configured')).toBe(true); }); it('does not touch providerType on first enable when not on Joplin Cloud', () => { Setting.setValue('sync.target', 7); Setting.setValue('ai.chat.providerType.configured', false); Setting.setValue('ai.chat.providerType', 'openai-compatible'); AiService.instance().applyFirstEnableDefault(); expect(Setting.value('ai.chat.providerType')).toBe('openai-compatible'); expect(Setting.value('ai.chat.providerType.configured')).toBe(true); }); it('does not overwrite an explicit provider choice on subsequent enable', () => { Setting.setValue('sync.target', 10); Setting.setValue('ai.chat.providerType.configured', true); Setting.setValue('ai.chat.providerType', 'anthropic'); AiService.instance().applyFirstEnableDefault(); expect(Setting.value('ai.chat.providerType')).toBe('anthropic'); }); });