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,114 @@
// tests/red/data-policy-redteam-r2-1.test.ts
// 로컬이 서버 쓰기 제약과 같은 규칙으로 입력을 거절한다(실제 서비스 + in-memory DB):
// - 사전: 대소문자만 다른 단어는 같은 단어(서버 lower(btrim(word)) 유일성) — 추가·수정·가져오기
// - 명령어: 편집으로 이름·프롬프트를 비울 수 없다(생성과 같은 규칙)
// - 메모 태그: 80자를 넘으면 저장하지 않는다
// - 검색: 기록은 다듬은 문장·제목·요약으로도 찾고, %·_ 는 글자 그대로 찾는다(사전 검색 포함)
import { describe, it, expect } from 'vitest'
import fs from 'fs'
import os from 'os'
import path from 'path'
import { ErrorCode } from '@d3ro/core/errors'
import { useRedHarness, historyInput } from './harness'
useRedHarness()
import { getDictionaryService } from '../../src/main/services/DictionaryService'
import { getCustomInstructionService } from '../../src/main/services/CustomInstructionService'
import { getMemoService } from '../../src/main/services/MemoService'
import { getHistoryService } from '../../src/main/services/HistoryService'
describe('사전 — 대소문자 무시 식별', () => {
it("'API' 가 있으면 'api' 추가는 DictionaryDuplicate 다(발음이 동기화에서 조용히 사라지지 않게)", () => {
const dict = getDictionaryService()
dict.add({ word: 'API' })
expect(() => dict.add({ word: ' api ', pronunciation: '에이피아이' })).toThrow(
expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }),
)
})
it('다른 카테고리는 같은 단어를 허용한다', () => {
const dict = getDictionaryService()
dict.add({ word: 'API', category: 'user' })
expect(() => dict.add({ word: 'api', category: 'technical' })).not.toThrow()
})
it('수정으로 다른 항목과 대소문자만 다른 단어가 되면 거절하고, 자기 자신의 대소문자 변경은 허용한다', () => {
const dict = getDictionaryService()
dict.add({ word: 'Kubernetes' })
const other = dict.add({ word: 'Docker' })
expect(() => dict.update({ id: other.id, word: 'KUBERNETES' })).toThrow(
expect.objectContaining({ code: ErrorCode.DictionaryDuplicate }),
)
expect(dict.update({ id: other.id, word: 'DOCKER' })?.word).toBe('DOCKER')
})
it('가져오기는 대소문자만 다른 단어를 건너뛴다', () => {
const dict = getDictionaryService()
dict.add({ word: 'GraphQL' })
const file = path.join(os.tmpdir(), `d3ro-dict-r2-1-${Date.now()}.json`)
fs.writeFileSync(file, JSON.stringify([{ word: 'graphql' }, { word: 'gRPC' }, { word: 'GRPC' }]))
try {
const result = dict.importDictionary({ filePath: file, format: 'json' })
expect(result).toEqual({ imported: 1, skipped: 2, errors: 0 })
} finally {
fs.rmSync(file, { force: true })
}
})
it('사전 검색의 %·_ 는 와일드카드가 아니다', () => {
const dict = getDictionaryService()
dict.add({ word: 'alpha' })
dict.add({ word: 'snake_case' })
expect(dict.search({ query: '_', page: 0, pageSize: 20 }).entries.map((e) => e.word)).toEqual(['snake_case'])
expect(dict.search({ query: '%', page: 0, pageSize: 20 }).total).toBe(0)
})
})
describe('명령어 — 빈 값 검증', () => {
it('편집으로 프롬프트나 이름을 비울 수 없다', () => {
const svc = getCustomInstructionService()
svc.initialize()
const created = svc.create({ name: '요약해', description: '', prompt: '요약해 주세요' })
expect(() => svc.update(created.id, { prompt: ' ' })).toThrow(/prompt is empty/)
expect(() => svc.update(created.id, { name: '' })).toThrow(/name is empty/)
expect(svc.getById(created.id)?.prompt).toBe('요약해 주세요')
})
})
describe('메모 태그 — 서버 길이 제한', () => {
it('80자를 넘는 태그는 저장하지 않는다(80자는 허용)', () => {
const entry = getHistoryService().create(historyInput())
const memo = getMemoService()
expect(() => memo.addTag(entry.id, 't'.repeat(81))).toThrow(/too long/)
expect(memo.getTagsForEntry(entry.id)).toEqual([])
expect(memo.addTag(entry.id, 't'.repeat(80)).tag).toHaveLength(80)
})
})
describe('기록 검색', () => {
it('카드에 보이는 다듬은 문장·제목·요약으로도 찾는다', () => {
const history = getHistoryService()
history.create(historyInput({ originalText: '한국어 원문', polishedText: 'Quarterly roadmap review' }))
history.create(historyInput({ originalText: '다른 원문', title: '출시 회의록' }))
history.create(historyInput({ originalText: '세 번째', summaryText: '결정: 예산 증액' }))
const search = (query: string) => history.search({ query, page: 0, pageSize: 20 }).entries.length
expect(search('roadmap')).toBe(1)
expect(search('출시 회의')).toBe(1)
expect(search('예산 증액')).toBe(1)
})
it('%·_ 는 글자 그대로 찾는다', () => {
const history = getHistoryService()
history.create(historyInput({ originalText: '매출이 100% 늘었다' }))
history.create(historyInput({ originalText: '매출이 1005 건이다' }))
history.create(historyInput({ originalText: 'file_name 확인' }))
const texts = (query: string) =>
history.search({ query, page: 0, pageSize: 20 }).entries.map((e) => e.originalText)
expect(texts('100%')).toEqual(['매출이 100% 늘었다'])
expect(texts('_')).toEqual(['file_name 확인'])
})
})