d3ro-voice/apps/desktop/tests/main/sync/knowledge-reindex-redteam-r3-2.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

159 lines
7.2 KiB
TypeScript

// 레드팀 r3-2: 다른 기기에서 재색인한 지식 문서가 이미 문서를 가진 데스크톱에도 반영된다.
// - 원문이 바뀌었으면 청크를 교체하고(옛 임베딩 제거) 다시 색인한다
// - 원문이 같으면(자기 push의 에코 등) 로컬 청크·임베딩을 건드리지 않는다
// - 재업로드가 중간이면 잘린 청크로 덮지 않고 미뤘다가 완성본을 반영한다
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { eq } from 'drizzle-orm'
import { createTestDb } from '../../helpers/createTestDb'
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
import { ragChunks, ragDocuments } from '../../../src/main/db/schema'
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import {
getCustomInstructionService,
resetCustomInstructionServiceForTests,
} from '../../../src/main/services/CustomInstructionService'
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { planRemoteDocument, sameChunks, usableChunks } from '../../../src/main/services/rag/remote-document'
const USER = '11111111-1111-4111-8111-111111111111'
let testDb: ReturnType<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
resetCustomInstructionServiceForTests()
resetDictationTemplateServiceForTests()
resetMeetingDocTemplateServiceForTests()
resetRAGServiceForTests()
getCustomInstructionService().initialize()
// 임베딩 서버(Ollama)는 없다 — 원문만 저장되는 경로를 탄다.
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
remote = new FakeSyncRemote(USER)
engine = new SyncEngine({ remote, userId: USER })
})
afterEach(() => {
vi.restoreAllMocks()
engine.dispose()
unbindTestDatabase()
resetInMemoryConfig()
resetRAGServiceForTests()
testDb.close()
})
function localChunks(id: string): Array<{ content: string; embedding: string }> {
return testDb.db
.select()
.from(ragChunks)
.where(eq(ragChunks.documentId, id))
.all()
.sort((a, b) => a.chunkIndex - b.chunkIndex)
.map((c) => ({ content: c.content, embedding: c.embedding }))
}
function markEmbedded(id: string): void {
testDb.db.update(ragChunks).set({ embedding: '[1,2]' }).where(eq(ragChunks.documentId, id)).run()
testDb.db.update(ragDocuments).set({ indexed: true, indexedAt: 1 }).where(eq(ragDocuments.id, id)).run()
}
/** 다른 데스크톱(A)의 재색인 push를 흉내: 행 upsert(updated_at 이동) → 청크 전부 교체 */
function remoteReindex(id: string, chunks: string[], uploaded = chunks.length): void {
remote.mobileUpdate('knowledge_documents', id, { chunk_count: chunks.length })
const rows = remote.rows('knowledge_chunks')
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].document_id === id) rows.splice(i, 1)
chunks.slice(0, uploaded).forEach((content, chunk_index) =>
rows.push({ id: crypto.randomUUID(), document_id: id, chunk_index, content })
)
}
function uploadRest(id: string, chunks: string[], from: number): void {
chunks.slice(from).forEach((content, offset) =>
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: from + offset, content })
)
}
async function seedSyncedDocument(chunks: string[]): Promise<string> {
const id = crypto.randomUUID()
remote.mobileInsert('knowledge_documents', { id, title: 'Doc', file_name: 'doc.md', file_type: 'md', chunk_count: chunks.length })
chunks.forEach((content, chunk_index) =>
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index, content })
)
await engine.runFullSync()
expect(localChunks(id).map((c) => c.content)).toEqual(chunks)
// 받은 직후 시작된 색인 시도(임베딩 서버 없음)가 끝난 뒤에 "이미 색인됨" 상태를 만든다
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0))
markEmbedded(id)
return id
}
describe('재색인된 지식 문서 동기화', () => {
it('다른 기기에서 원문이 바뀌면 이미 있는 문서의 청크를 교체하고 옛 임베딩을 버린다', async () => {
const id = await seedSyncedDocument(['old intro', 'old body'])
remoteReindex(id, ['new intro', 'new body', 'new appendix'])
await engine.pull()
const chunks = localChunks(id)
expect(chunks.map((c) => c.content)).toEqual(['new intro', 'new body', 'new appendix'])
// 옛 벡터가 새 원문에 붙어 검색되지 않는다(임베딩 서버가 없어 아직 비어 있다)
expect(chunks.every((c) => c.embedding === '')).toBe(true)
const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()
expect(doc?.chunkCount).toBe(3)
expect(doc?.indexed).toBe(false)
})
it('원문이 같으면(에코·메타데이터만 변경) 로컬 청크와 임베딩을 그대로 둔다', async () => {
const id = await seedSyncedDocument(['same a', 'same b'])
remote.mobileUpdate('knowledge_documents', id, { indexed: true })
await engine.pull()
expect(localChunks(id)).toEqual([
{ content: 'same a', embedding: '[1,2]' },
{ content: 'same b', embedding: '[1,2]' },
])
expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()?.indexed).toBe(true)
})
it('재업로드가 중간이면 잘린 청크로 덮지 않고, 완성되면 반영한다', async () => {
const id = await seedSyncedDocument(['v1 a', 'v1 b'])
const next = ['v2 a', 'v2 b', 'v2 c']
remoteReindex(id, next, 1)
await engine.pull()
expect(localChunks(id).map((c) => c.content)).toEqual(['v1 a', 'v1 b'])
uploadRest(id, next, 1)
await engine.pull()
expect(localChunks(id).map((c) => c.content)).toEqual(next)
})
})
describe('planRemoteDocument', () => {
it('로컬에 없으면 insert, 같으면 skip, 다르면 replace', () => {
expect(planRemoteDocument(null, ['a', ' ', 'b'])).toEqual({ kind: 'insert', chunks: ['a', 'b'] })
expect(planRemoteDocument(['a', 'b'], ['a', 'b'])).toEqual({ kind: 'skip' })
expect(planRemoteDocument(['a', 'b'], ['a', 'c'])).toEqual({ kind: 'replace', chunks: ['a', 'c'] })
expect(planRemoteDocument(['a'], ['a', 'b'])).toEqual({ kind: 'replace', chunks: ['a', 'b'] })
})
it('받은 청크가 전부 비어 있으면 로컬을 비우지 않는다', () => {
expect(planRemoteDocument(['a'], ['', ' '])).toEqual({ kind: 'skip' })
expect(planRemoteDocument(null, [])).toEqual({ kind: 'skip' })
})
it('빈 청크는 비교에서 빠진다', () => {
expect(usableChunks(['a', '', ' b '])).toEqual(['a', ' b '])
expect(planRemoteDocument(['a', ''], ['a'])).toEqual({ kind: 'skip' })
expect(sameChunks(['a'], ['a', 'b'])).toBe(false)
})
})