// 레드팀 r3-0: push 전 tombstone 읽기 실패 시 upsert 보류(fail closed), // 서버에 없는 활성 명령이 설정 항목을 끝없는 재시도에 묶어 설정 pull을 막던 문제. 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 } from '../../../src/main/db/schema' import { configGet, configSet, 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 { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox' import { SyncRemoteError, type RemotePageRequest, type RemoteRow, } from '../../../src/main/services/sync/sync-types' import { pushableEntries } from '../../../src/main/services/sync/push-gate-policy' import { isActiveInstructionMissing, planActiveInstructionStep, shouldResyncSettingsAfterInstructionPush, } from '../../../src/main/services/sync/active-instruction-push-policy' import type { EmbeddingPort } from '../../../src/main/services/rag/embedding-port' const USER = '11111111-1111-4111-8111-111111111111' const U = '22222222-2222-4222-8222-222222222222' /** * 운영 서버처럼 INSERT 때 updated_at을 서버 시각으로 새로 찍고(0037 stamp_sync_timestamp_v1), * sync_tombstones 읽기를 지정한 횟수만큼 일시 오류로 실패시키는 원격. */ class ServerStampingRemote extends FakeSyncRemote { failTombstoneReads = 0 override async fetchPage(request: RemotePageRequest): Promise { if (request.table === 'sync_tombstones' && this.failTombstoneReads > 0) { this.failTombstoneReads-- throw new SyncRemoteError('canceling statement due to statement timeout', '57014', true) } return super.fetchPage(request) } override async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): Promise { const existing = new Set(this.rows(table).map((r) => String(r.id))) await super.upsert(table, rows, onConflict) for (const row of this.rows(table)) { if (!existing.has(String(row.id))) row.updated_at = this.now() } } } 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')), } let testDb: ReturnType let remote: ServerStampingRemote let engine: SyncEngine let clock: number beforeEach(() => { testDb = createTestDb() bindTestDatabase(testDb.db, USER) initInMemoryConfig() resetCustomInstructionServiceForTests() resetDictationTemplateServiceForTests() resetMeetingDocTemplateServiceForTests() resetRAGServiceForTests({ embedder: offlineEmbedder, notify: () => undefined, yieldMs: 0 }) getCustomInstructionService().initialize() remote = new ServerStampingRemote(USER) clock = Date.parse('2026-09-28T00:00:00.000Z') engine = new SyncEngine({ remote, userId: USER, now: () => clock }) }) afterEach(() => { vi.restoreAllMocks() engine.dispose() resetRAGServiceForTests() unbindTestDatabase() resetInMemoryConfig() testDb.close() }) function historyRow(id: string, text: string): Record { 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('push 전 tombstone 읽기 실패 → upsert 보류(fail closed)', () => { it('폰에서 지운 행의 대기 upsert가 서버에 행을 되살리지 않고, 다음 pull에서 삭제가 반영된다', async () => { const x = crypto.randomUUID() remote.mobileInsert('history', historyRow(x, 'will be deleted on phone')) await engine.runFullSync() expect(localHistoryIds()).toEqual([x]) // 데스크톱의 대기 편집(즐겨찾기 등) + 폰의 삭제(T1) enqueueChange('history', x, 'upsert', clock) remote.mobileDelete('history', x) remote.failTombstoneReads = 1 const flushed = await engine.flush() expect(flushed.errors.some((e) => e.startsWith('tombstones:'))).toBe(true) expect(flushed.pushed).toBe(0) expect(remote.find('history', x)).toBeUndefined() expect(pendingOps('history').get(x)).toBe('upsert') await engine.pull() expect(localHistoryIds()).toEqual([]) expect(pendingOps('history').has(x)).toBe(false) expect(remote.find('history', x)).toBeUndefined() }) it('tombstone 읽기가 실패해도 삭제는 보낸다', async () => { const x = crypto.randomUUID() const y = crypto.randomUUID() remote.mobileInsert('history', historyRow(x, 'edit me')) remote.mobileInsert('history', historyRow(y, 'delete me')) await engine.runFullSync() enqueueChange('history', x, 'upsert', clock) testDb.db.delete(history).where(eq(history.id, y)).run() enqueueChange('history', y, 'delete', clock) remote.failTombstoneReads = 1 const flushed = await engine.flush() expect(remote.find('history', y)).toBeUndefined() expect(pendingOps('history').has(y)).toBe(false) expect(pendingOps('history').get(x)).toBe('upsert') expect(flushed.pushed).toBe(1) }) it('runFullSync에서 tombstone 읽기가 실패하면 최초 대조·upsert를 미루고 다음 flush에서 이어 간다', async () => { const local = crypto.randomUUID() const at = clock testDb.db.insert(history).values({ id: local, originalText: 'offline note', duration: 1, createdAt: at, updatedAt: at }).run() remote.failTombstoneReads = 1 await engine.runFullSync() expect(remote.find('history', local)).toBeUndefined() await engine.flush() expect(remote.find('history', local)?.original_text).toBe('offline note') }) }) describe('서버에 없는 활성 명령과 설정 동기화', () => { function rejectInstructionUploads(error: SyncRemoteError | null): void { remote.rejectRow = (table) => (table === 'custom_instructions' ? error : null) } function seedRemoteSettings(): void { remote.mobileInsert('user_settings', { locale: 'ko', theme_mode: 'system', auto_polish_enabled: true, revision: 1, active_instruction_id: null, }) } it('올라가지 못한(보관된) 명령이 활성이어도 설정 항목은 완료되고, 폰의 설정 변경을 계속 받는다', async () => { seedRemoteSettings() const command = getCustomInstructionService().create({ name: 'Local only', description: '', prompt: 'Keep it local' }) rejectInstructionUploads(new SyncRemoteError('value too long for type character varying(4000)', '22001', false)) await engine.runFullSync() expect(pendingOps('custom_instructions').get(command.id)).toBe('upsert') // 서버에 없는 명령을 활성으로 고르고 테마를 바꾼다(CloudSyncService가 설정 항목을 넣는다) configSet('activeInstructionId', command.id) configSet('theme', 'dark') enqueueChange('user_settings', 'self', 'upsert', clock) const flushed = await engine.flush() expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([]) expect(pendingOps('user_settings').has('self')).toBe(false) expect(remote.rows('user_settings')[0].theme_mode).toBe('dark') expect(configGet('activeInstructionId')).toBe(command.id) expect(remote.calls).not.toContain('rpc:set_active_custom_instruction') const settings = remote.rows('user_settings')[0] Object.assign(settings, { theme_mode: 'light', revision: Number(settings.revision) + 1, updated_at: remote.now() }) await engine.pull() expect(configGet('theme')).toBe('light') }) it('outbox에 없는데 서버가 모르는 명령(P0002)은 설정 항목을 재시도에 묶지 않는다', async () => { seedRemoteSettings() const command = getCustomInstructionService().create({ name: 'Gone', description: '', prompt: 'Vanished on server' }) await engine.runFullSync() // 서버에서 tombstone 없이 사라진 경우(보존 기간 정리 등) const rows = remote.rows('custom_instructions') rows.splice(rows.findIndex((r) => r.id === command.id), 1) configSet('activeInstructionId', command.id) enqueueChange('user_settings', 'self', 'upsert', clock) const flushed = await engine.flush() expect(remote.calls).toContain('rpc:set_active_custom_instruction') expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([]) expect(pendingOps('user_settings').has('self')).toBe(false) }) it('미룬 활성 명령이 나중에 서버에 올라가면 설정을 다시 올려 서버 활성 명령을 맞춘다', async () => { seedRemoteSettings() const command = getCustomInstructionService().create({ name: 'Late', description: '', prompt: 'Arrives later' }) rejectInstructionUploads(new SyncRemoteError('canceling statement due to statement timeout', '57014', true)) await engine.runFullSync() configSet('activeInstructionId', command.id) enqueueChange('user_settings', 'self', 'upsert', clock) await engine.flush() expect(pendingOps('user_settings').has('self')).toBe(false) expect(remote.rows('user_settings')[0].active_instruction_id).toBeNull() expect(remote.calls).not.toContain('rpc:set_active_custom_instruction') rejectInstructionUploads(null) clock += 60 * 60_000 await engine.flush() expect(remote.find('custom_instructions', command.id)).toBeDefined() expect(pendingOps('user_settings').has('self')).toBe(true) await engine.flush() expect(remote.rows('user_settings')[0].active_instruction_id).toBe(command.id) expect(pendingOps('user_settings').has('self')).toBe(false) }) }) describe('push-gate-policy', () => { const entries = [ { op: 'upsert' as const, rowId: 'a' }, { op: 'delete' as const, rowId: 'b' }, ] it('tombstone을 반영했으면 전부, 실패했으면 삭제만 push한다', () => { expect(pushableEntries(entries, { tombstonesSynced: true })).toEqual(entries) expect(pushableEntries(entries, { tombstonesSynced: false })).toEqual([{ op: 'delete', rowId: 'b' }]) }) }) describe('active-instruction-push-policy', () => { it('서버에 아직 없는 명령은 미루고, 나머지는 RPC로 보낸다', () => { expect(planActiveInstructionStep(null, new Set([U]))).toEqual({ kind: 'none' }) expect(planActiveInstructionStep({ instructionId: U }, new Set([U]))).toEqual({ kind: 'defer', instructionId: U }) expect(planActiveInstructionStep({ instructionId: U }, new Set())).toEqual({ kind: 'rpc', instructionId: U }) expect(planActiveInstructionStep({ instructionId: null }, new Set([U]))).toEqual({ kind: 'rpc', instructionId: null }) }) it('P0002만 "서버에 명령 없음"으로 본다', () => { expect(isActiveInstructionMissing(new SyncRemoteError('instruction_not_found', 'P0002', true))).toBe(true) expect(isActiveInstructionMissing(new SyncRemoteError('fetch failed', 'network', true))).toBe(false) }) it('미룬 명령이 올라갔을 때만 설정을 다시 올린다', () => { expect(shouldResyncSettingsAfterInstructionPush(U, [U])).toBe(true) expect(shouldResyncSettingsAfterInstructionPush(U, ['other'])).toBe(false) expect(shouldResyncSettingsAfterInstructionPush('', [U])).toBe(false) expect(shouldResyncSettingsAfterInstructionPush(null, [U])).toBe(false) }) })