// tests/red/llm-privacy-routing-redteam-r2-1.test.ts // LLM 백엔드 선택이 게이트웨이 한 곳을 따른다(실제 서비스 + in-memory DB, LLM 어댑터만 가짜): // - llmBackend='local' 이면 자막 자동 요약·받아쓰기 자동 제목이 Premium(클라우드·쿼터)을 부르지 않는다 // - llm:process 는 online + Supabase 로그인(레거시 authToken 없음)이면 Premium 을 탄다(.NET 경로 아님) import { describe, it, expect, vi, beforeEach } from 'vitest' import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' import { invokeIpc, useRedHarness, historyInput } from './harness' const llm = vi.hoisted(() => ({ localGenerate: vi.fn(), localProcess: vi.fn(), premiumGenerate: vi.fn(), premiumProcess: vi.fn(), premiumAvailable: true, onlineProcess: vi.fn(), })) vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => { const actual = await importOriginal() return { ...actual, getLocalLLMService: () => ({ isAvailable: () => true, generate: (...args: unknown[]) => llm.localGenerate(...args), processText: (...args: unknown[]) => llm.localProcess(...args), chatStream: async function* () { yield 'x' return 'x' }, cancelGeneration: vi.fn(), on: vi.fn(), off: vi.fn(), getStatus: () => ({}), }), } }) vi.mock('../../src/main/services/PremiumLLMService', () => ({ getPremiumLLMService: () => ({ isAvailable: () => llm.premiumAvailable, generate: (...args: unknown[]) => llm.premiumGenerate(...args), processText: (...args: unknown[]) => llm.premiumProcess(...args), chatStream: async function* () { yield 'p' return 'p' }, cancelGeneration: vi.fn(), on: vi.fn(), off: vi.fn(), getLastQuota: () => null, }), resetPremiumLLMServiceForTests: () => undefined, })) vi.mock('../../src/main/services/OnlineLLMService', () => ({ getOnlineLLMService: () => ({ processText: (...args: unknown[]) => llm.onlineProcess(...args), cancelGeneration: vi.fn(), }), resetOnlineLLMServiceForTests: () => undefined, })) useRedHarness() import { configSet } from '../../src/main/services/ConfigService' import { getHistoryService } from '../../src/main/services/HistoryService' import { getMeetingSummaryService } from '../../src/main/services/MeetingSummaryService' import { registerLLMHandlers } from '../../src/main/ipc/llm-handlers' import { setLlmGatewayForTests } from '../../src/main/services/llm/LlmGateway' beforeEach(() => { setLlmGatewayForTests(null) llm.premiumAvailable = true llm.localGenerate.mockReset().mockResolvedValue({ text: '## 요약\n로컬 요약', model: 'local' }) llm.localProcess.mockReset().mockResolvedValue('local-result') llm.premiumGenerate.mockReset().mockResolvedValue({ text: 'premium' }) llm.premiumProcess.mockReset().mockResolvedValue('premium-result') llm.onlineProcess.mockReset().mockResolvedValue('dotnet-result') }) describe('llmBackend=local 은 텍스트를 클라우드로 보내지 않는다', () => { it('자막 요약은 로컬 LLM 을 쓰고 Premium 을 부르지 않는다', async () => { configSet('llmBackend', 'local') const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' })) await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled()) llm.localGenerate.mockClear() const result = await getMeetingSummaryService().summarize(entry.id) expect(result.summary).toContain('로컬 요약') expect(llm.localGenerate).toHaveBeenCalled() expect(llm.premiumGenerate).not.toHaveBeenCalled() }) it('받아쓰기 자동 제목도 Premium 을 부르지 않는다', async () => { configSet('llmBackend', 'local') const entry = getHistoryService().create(historyInput({ originalText: '다음 주 출시 일정을 정리해야 합니다' })) await vi.waitFor(() => expect(llm.localGenerate).toHaveBeenCalled()) expect(llm.premiumGenerate).not.toHaveBeenCalled() await vi.waitFor(() => expect(getHistoryService().getById(entry.id)?.title).toBeTruthy()) }) it('online 을 고른 사용자는 요약이 Premium 을 탄다(명시적 선택)', async () => { configSet('llmBackend', 'online') const entry = getHistoryService().create(historyInput({ originalText: '회의 내용 '.repeat(20), mode: 'caption' })) await getMeetingSummaryService().summarize(entry.id) expect(llm.premiumGenerate).toHaveBeenCalled() }) }) describe('llm:process 는 실제 받아쓰기와 같은 경로를 탄다', () => { it('online + Supabase 로그인(레거시 authToken 없음) → Premium, .NET 경로 아님', async () => { configSet('llmBackend', 'online') configSet('authToken', null) registerLLMHandlers() const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' }) expect(res.success).toBe(true) if (res.success) expect(res.data.processedText).toBe('premium-result') expect(llm.onlineProcess).not.toHaveBeenCalled() }) it('online 인데 Premium 을 쓸 수 없으면 받아쓰기처럼 로컬로 폴백한다', async () => { configSet('llmBackend', 'online') configSet('authToken', null) llm.premiumAvailable = false registerLLMHandlers() const res = await invokeIpc<{ processedText: string }>(IPC_CHANNELS.LLM.PROCESS, { text: '원문', action: 'refine' }) expect(res.success).toBe(true) if (res.success) expect(res.data.processedText).toBe('local-result') }) })