281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
// 레드팀 r2-0: 원격 삭제(tombstone) 반영의 복원·보존 기간 경계, 지식 문서 청크 완전성.
|
|
|
|
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 { history, 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 { completeKnowledgeChunks } from '../../../src/main/services/sync/sync-adapters'
|
|
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
|
|
import {
|
|
TOMBSTONE_RETENTION_MS,
|
|
earliestDeletedAt,
|
|
isRestoredAfter,
|
|
remoteTimeAfter,
|
|
tombstoneWindowExpired,
|
|
} from '../../../src/main/services/sync/tombstone-policy'
|
|
import type { EmbeddingPort } from '../../../src/main/services/rag/embedding-port'
|
|
|
|
const USER = '11111111-1111-4111-8111-111111111111'
|
|
const DAY = 24 * 60 * 60 * 1000
|
|
|
|
let testDb: ReturnType<typeof createTestDb>
|
|
let remote: FakeSyncRemote
|
|
let engine: SyncEngine
|
|
let clock: number
|
|
|
|
const offlineEmbedder: EmbeddingPort = {
|
|
model: 'test-embed',
|
|
ensureModel: () => Promise.reject(new Error('no embedding server in tests')),
|
|
embed: () => Promise.reject(new Error('no embedding server in tests')),
|
|
}
|
|
|
|
function makeEngine(): SyncEngine {
|
|
return new SyncEngine({ remote, userId: USER, now: () => clock })
|
|
}
|
|
|
|
beforeEach(() => {
|
|
testDb = createTestDb()
|
|
bindTestDatabase(testDb.db, USER)
|
|
initInMemoryConfig()
|
|
resetCustomInstructionServiceForTests()
|
|
resetDictationTemplateServiceForTests()
|
|
resetMeetingDocTemplateServiceForTests()
|
|
resetRAGServiceForTests({ embedder: offlineEmbedder, notify: () => undefined, yieldMs: 0 })
|
|
getCustomInstructionService().initialize()
|
|
remote = new FakeSyncRemote(USER)
|
|
clock = Date.parse('2026-09-27T00:00:00.000Z')
|
|
engine = makeEngine()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
engine.dispose()
|
|
resetRAGServiceForTests()
|
|
unbindTestDatabase()
|
|
resetInMemoryConfig()
|
|
testDb.close()
|
|
})
|
|
|
|
function historyRow(id: string, text: string): Record<string, unknown> {
|
|
return { id, original_text: text, duration: 1, mode: 'dictation', status: 'completed' }
|
|
}
|
|
|
|
function localHistoryIds(): string[] {
|
|
return testDb.db.select({ id: history.id }).from(history).all().map((r) => r.id).sort()
|
|
}
|
|
|
|
describe('tombstone 뒤 계정 보관본 복원', () => {
|
|
it('삭제를 이미 반영한 데스크톱: 복원된 행을 받고, 이후 pull의 겹쳐 읽기가 다시 지우지 않는다', async () => {
|
|
const x = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(x, 'keep me'))
|
|
await engine.runFullSync()
|
|
expect(localHistoryIds()).toEqual([x])
|
|
|
|
remote.mobileDelete('history', x)
|
|
await engine.pull()
|
|
expect(localHistoryIds()).toEqual([])
|
|
|
|
// restore_account_portability: 같은 id로 다시 넣고, 0037 이후 서버가 updated_at을 새로 찍는다.
|
|
remote.mobileInsert('history', historyRow(x, 'keep me'))
|
|
await engine.pull()
|
|
expect(localHistoryIds()).toEqual([x])
|
|
|
|
// tombstone 커서는 여전히 T1 근처라 다음 pull은 같은 tombstone을 다시 읽는다.
|
|
await engine.pull()
|
|
await engine.pull()
|
|
expect(localHistoryIds()).toEqual([x])
|
|
})
|
|
|
|
it('삭제와 복원 사이 오프라인이던 데스크톱: 첫 pull에서 복원된 행을 지우지 않는다', async () => {
|
|
const x = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(x, 'v1'))
|
|
await engine.runFullSync()
|
|
|
|
remote.mobileDelete('history', x)
|
|
remote.mobileInsert('history', historyRow(x, 'restored'))
|
|
const result = await engine.pull()
|
|
|
|
expect(result.deleted).toBe(0)
|
|
expect(localHistoryIds()).toEqual([x])
|
|
expect(testDb.db.select().from(history).where(eq(history.id, x)).get()?.originalText).toBe('restored')
|
|
})
|
|
|
|
it('runFullSync(push 전 tombstone + pull 안의 tombstone)도 복원된 행을 지우지 않는다', async () => {
|
|
const x = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(x, 'v1'))
|
|
await engine.runFullSync()
|
|
|
|
remote.mobileDelete('history', x)
|
|
remote.mobileInsert('history', historyRow(x, 'restored'))
|
|
await engine.runFullSync()
|
|
await engine.runFullSync()
|
|
expect(localHistoryIds()).toEqual([x])
|
|
})
|
|
|
|
it('복원되지 않은 삭제는 그대로 반영하고, 로컬 대기 변경보다 우선한다', async () => {
|
|
const kept = crypto.randomUUID()
|
|
const gone = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(kept, 'k'))
|
|
remote.mobileInsert('history', historyRow(gone, 'g'))
|
|
await engine.runFullSync()
|
|
|
|
enqueueChange('history', gone, 'upsert')
|
|
remote.mobileDelete('history', gone)
|
|
remote.mobileDelete('history', kept)
|
|
remote.mobileInsert('history', historyRow(kept, 'k restored'))
|
|
await engine.pull()
|
|
|
|
expect(localHistoryIds()).toEqual([kept])
|
|
expect(pendingOps('history').has(gone)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('tombstone 보존 기간(180일) 초과', () => {
|
|
function pruneAllTombstones(): void {
|
|
remote.rows('sync_tombstones').splice(0)
|
|
}
|
|
|
|
it('보존 기간보다 오래 쉰 기기는 서버에 없는 로컬 행을 지워 전체 대조한다', async () => {
|
|
const stale = crypto.randomUUID()
|
|
const alive = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(stale, 'deleted on phone long ago'))
|
|
remote.mobileInsert('history', historyRow(alive, 'still here'))
|
|
await engine.runFullSync()
|
|
// 다음 tombstone 커서가 생기도록 다른 행 하나를 지워 둔다
|
|
const other = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(other, 'other'))
|
|
await engine.pull()
|
|
remote.mobileDelete('history', other)
|
|
await engine.pull()
|
|
|
|
remote.mobileDelete('history', stale)
|
|
pruneAllTombstones()
|
|
clock += TOMBSTONE_RETENTION_MS + DAY
|
|
|
|
const result = await engine.pull()
|
|
expect(result.errors).toEqual([])
|
|
expect(localHistoryIds()).toEqual([alive])
|
|
expect(result.deleted).toBe(1)
|
|
|
|
// 편집해도 지운 행이 서버에 되살아나지 않는다(로컬에 없다)
|
|
expect(remote.find('history', stale)).toBeUndefined()
|
|
})
|
|
|
|
it('아직 올리지 않은 로컬 변경이 있는 행은 전체 대조가 지우지 않는다', async () => {
|
|
const seed = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(seed, 'seed'))
|
|
await engine.runFullSync()
|
|
remote.mobileDelete('history', seed)
|
|
await engine.pull()
|
|
|
|
const local = crypto.randomUUID()
|
|
const at = clock
|
|
testDb.db.insert(history).values({ id: local, originalText: 'offline note', duration: 1, createdAt: at, updatedAt: at }).run()
|
|
enqueueChange('history', local, 'upsert')
|
|
pruneAllTombstones()
|
|
clock += TOMBSTONE_RETENTION_MS + DAY
|
|
|
|
await engine.pull()
|
|
expect(localHistoryIds()).toEqual([local])
|
|
})
|
|
|
|
it('보존 기간 안이면 전체 대조를 하지 않는다(서버에 없는 행을 함부로 지우지 않는다)', async () => {
|
|
const seed = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(seed, 'seed'))
|
|
await engine.runFullSync()
|
|
remote.mobileDelete('history', seed)
|
|
await engine.pull()
|
|
|
|
const local = crypto.randomUUID()
|
|
testDb.db.insert(history).values({ id: local, originalText: 'x', duration: 1, createdAt: clock, updatedAt: clock }).run()
|
|
clock += 30 * DAY
|
|
await engine.pull()
|
|
expect(localHistoryIds()).toEqual([local])
|
|
})
|
|
|
|
it('한 번 대조하면 다음 pull부터는 다시 대조하지 않는다', async () => {
|
|
const seed = crypto.randomUUID()
|
|
remote.mobileInsert('history', historyRow(seed, 'seed'))
|
|
await engine.runFullSync()
|
|
remote.mobileDelete('history', seed)
|
|
await engine.pull()
|
|
clock += TOMBSTONE_RETENTION_MS + DAY
|
|
await engine.pull()
|
|
|
|
const fresh = crypto.randomUUID()
|
|
testDb.db.insert(history).values({ id: fresh, originalText: 'y', duration: 1, createdAt: clock, updatedAt: clock }).run()
|
|
clock += DAY
|
|
await engine.pull()
|
|
expect(localHistoryIds()).toEqual([fresh])
|
|
})
|
|
})
|
|
|
|
describe('tombstone 정책 (순수 함수)', () => {
|
|
it('서버 시각 비교는 마이크로초까지 본다', () => {
|
|
expect(remoteTimeAfter('2026-09-27T00:00:00.000200+00:00', '2026-09-27T00:00:00.000100+00:00')).toBe(true)
|
|
expect(remoteTimeAfter('2026-09-27T00:00:01Z', '2026-09-27T00:00:02Z')).toBe(false)
|
|
expect(remoteTimeAfter('bad', '2026-09-27T00:00:02Z')).toBe(false)
|
|
})
|
|
|
|
it('복원 판정과 가장 이른 삭제 시각', () => {
|
|
const ref = { rowId: 'a', deletedAt: '2026-09-27T00:00:05Z' }
|
|
expect(isRestoredAfter(ref, '2026-09-27T00:00:06Z')).toBe(true)
|
|
expect(isRestoredAfter(ref, '2026-09-27T00:00:04Z')).toBe(false)
|
|
expect(isRestoredAfter({ rowId: 'a', deletedAt: null }, '2026-09-27T00:00:06Z')).toBe(false)
|
|
expect(earliestDeletedAt([ref, { rowId: 'b', deletedAt: '2026-09-27T00:00:01Z' }, { rowId: 'c', deletedAt: null }])).toBe(
|
|
'2026-09-27T00:00:01Z'
|
|
)
|
|
})
|
|
|
|
it('보존 기간 창', () => {
|
|
const now = Date.parse('2026-09-27T00:00:00Z')
|
|
expect(tombstoneWindowExpired(null, now)).toBe(false)
|
|
expect(tombstoneWindowExpired(now - 30 * DAY, now)).toBe(false)
|
|
expect(tombstoneWindowExpired(now - 179 * DAY, now)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('지식 문서 청크 완전성', () => {
|
|
it('문서 행의 chunk_count보다 적은 청크만 올라와 있으면 미루고, 다 올라오면 전부 저장한다', async () => {
|
|
const id = crypto.randomUUID()
|
|
remote.mobileInsert('knowledge_documents', { id, title: 'Big', file_name: 'big.txt', file_type: 'txt', chunk_count: 3 })
|
|
remote.rows('knowledge_chunks').push(
|
|
{ id: crypto.randomUUID(), document_id: id, chunk_index: 0, content: 'c0' },
|
|
{ id: crypto.randomUUID(), document_id: id, chunk_index: 1, content: 'c1' }
|
|
)
|
|
await engine.runFullSync()
|
|
expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()).toBeUndefined()
|
|
|
|
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: 2, content: 'c2' })
|
|
await engine.pull()
|
|
const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()
|
|
expect(doc?.chunkCount).toBe(3)
|
|
const chunks = testDb.db.select().from(ragChunks).where(eq(ragChunks.documentId, id)).all()
|
|
expect(chunks.sort((a, b) => a.chunkIndex - b.chunkIndex).map((c) => c.content)).toEqual(['c0', 'c1', 'c2'])
|
|
})
|
|
|
|
it('completeKnowledgeChunks: 개수·연속 index를 확인한다', () => {
|
|
const rows = [
|
|
{ chunk_index: 1, content: 'b' },
|
|
{ chunk_index: 0, content: 'a' },
|
|
]
|
|
expect(completeKnowledgeChunks(rows, 2)).toEqual(['a', 'b'])
|
|
expect(completeKnowledgeChunks(rows, 3)).toBeNull()
|
|
expect(completeKnowledgeChunks(rows, null)).toEqual(['a', 'b'])
|
|
expect(completeKnowledgeChunks([{ chunk_index: 0, content: 'a' }, { chunk_index: 2, content: 'c' }], null)).toBeNull()
|
|
expect(completeKnowledgeChunks([{ chunk_index: 0, content: 'a' }, { chunk_index: 0, content: 'a' }], 2)).toBeNull()
|
|
expect(completeKnowledgeChunks([], 0)).toBeNull()
|
|
expect(completeKnowledgeChunks([{ chunk_index: 0, content: null }], 1)).toBeNull()
|
|
})
|
|
})
|