d3ro-voice/apps/desktop/tests/main/sync/sync-redteam-r3-0.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

269 lines
12 KiB
TypeScript

// 레드팀 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<RemoteRow[]> {
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<void> {
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<typeof createTestDb>
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<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('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)
})
})