// 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 확인']) }) })