fix(desktop): harden session, meeting, caption and LLM lifecycles; route LLM calls through the gateway

This commit is contained in:
Yun Chan 2026-09-28 02:16:15 +09:00
parent 3a46437f28
commit ddc78546f0
62 changed files with 4786 additions and 648 deletions

View file

@ -0,0 +1,132 @@
// 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<typeof import('../../src/main/services/LocalLLMService')>()
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')
})
})