/** * PR9 — OCR config service. * * Validates: * 1. Per-port save/read round-trip (key encrypted at rest, decrypted on resolve) * 2. Public view never echoes the raw key * 3. Global fallback when port row sets useGlobal=true * 4. Source field is correctly tagged ('port' | 'global' | 'none') * 5. clearApiKey wipes the stored key */ import { describe, it, expect, beforeEach } from 'vitest'; import { eq, isNull, and } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema/system'; import { saveOcrConfig, getResolvedOcrConfig, getPublicOcrConfig, } from '@/lib/services/ocr-config.service'; import { makePort } from '../helpers/factories'; beforeEach(async () => { await db.delete(systemSettings).where(eq(systemSettings.key, 'ocr.config')); }); describe('OCR config', () => { it('round-trips a per-port config and decrypts the key on resolve', async () => { const port = await makePort(); await saveOcrConfig( port.id, { provider: 'openai', model: 'gpt-4o-mini', apiKey: 'sk-test-abc-123' }, 'user-1', ); const resolved = await getResolvedOcrConfig(port.id); expect(resolved.provider).toBe('openai'); expect(resolved.model).toBe('gpt-4o-mini'); expect(resolved.apiKey).toBe('sk-test-abc-123'); expect(resolved.hasApiKey).toBe(true); expect(resolved.source).toBe('port'); }); it('public view never includes the raw key', async () => { const port = await makePort(); await saveOcrConfig( port.id, { provider: 'claude', model: 'claude-haiku-4-5', apiKey: 'sk-secret' }, 'user-1', ); const pub = await getPublicOcrConfig(port.id); expect(pub).not.toHaveProperty('apiKey'); expect(pub.hasApiKey).toBe(true); expect(pub.provider).toBe('claude'); }); it('falls back to global when useGlobal is true on the port row', async () => { const port = await makePort(); // Set up the global row. await saveOcrConfig( null, { provider: 'openai', model: 'gpt-4o', apiKey: 'global-key' }, 'user-1', ); // Port row opts in. await saveOcrConfig( port.id, { provider: 'claude', model: 'claude-haiku-4-5', apiKey: 'port-key', useGlobal: true }, 'user-1', ); const resolved = await getResolvedOcrConfig(port.id); expect(resolved.source).toBe('global'); expect(resolved.apiKey).toBe('global-key'); expect(resolved.provider).toBe('openai'); expect(resolved.useGlobal).toBe(true); }); it('returns source=none when neither port nor global is configured', async () => { const port = await makePort(); const resolved = await getResolvedOcrConfig(port.id); expect(resolved.source).toBe('none'); expect(resolved.apiKey).toBeNull(); expect(resolved.hasApiKey).toBe(false); }); it('clearApiKey nulls the stored key but preserves provider/model', async () => { const port = await makePort(); await saveOcrConfig( port.id, { provider: 'openai', model: 'gpt-4o-mini', apiKey: 'first-key' }, 'user-1', ); await saveOcrConfig( port.id, { provider: 'openai', model: 'gpt-4o-mini', clearApiKey: true }, 'user-1', ); const resolved = await getResolvedOcrConfig(port.id); expect(resolved.apiKey).toBeNull(); expect(resolved.hasApiKey).toBe(false); expect(resolved.provider).toBe('openai'); }); it('omitting apiKey on save preserves the existing one', async () => { const port = await makePort(); await saveOcrConfig( port.id, { provider: 'openai', model: 'gpt-4o-mini', apiKey: 'keep-me' }, 'user-1', ); // Update model only — no apiKey field provided. await saveOcrConfig(port.id, { provider: 'openai', model: 'gpt-4o' }, 'user-1'); const resolved = await getResolvedOcrConfig(port.id); expect(resolved.apiKey).toBe('keep-me'); expect(resolved.model).toBe('gpt-4o'); }); it('global rows force useGlobal=false on save (not meaningful at global scope)', async () => { await saveOcrConfig( null, { provider: 'openai', model: 'gpt-4o-mini', apiKey: 'g', useGlobal: true }, 'user-1', ); const [row] = await db .select() .from(systemSettings) .where(and(eq(systemSettings.key, 'ocr.config'), isNull(systemSettings.portId))); expect((row?.value as { useGlobal: boolean }).useGlobal).toBe(false); }); });