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

81 lines
3.3 KiB
TypeScript

// tests/main/sync/audio-sync-redteam-r3-1.test.ts
// 동기화 회귀 (r3-1):
// - 파일 전사 기록(audioLocalPath = 사용자가 고른 원본 미디어)은 '녹음 동기화'로 올리지 않는다
// - 대기 중인 원격 삭제를 뒤늦은 upsert(예: 자동 제목)가 덮어쓰지 않는다
import fs from 'fs'
import os from 'os'
import path from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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 { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
import { listLocalAudioOwners, localAudioPath } from '../../../src/main/services/sync/audio-sync'
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
const USER = '11111111-1111-4111-8111-111111111111'
let testDb: ReturnType<typeof createTestDb>
let remote: FakeSyncRemote
let engine: SyncEngine
let tmpDir: string
beforeEach(() => {
testDb = createTestDb()
bindTestDatabase(testDb.db, USER)
initInMemoryConfig()
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
remote = new FakeSyncRemote(USER)
engine = new SyncEngine({ remote, userId: USER })
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-audio-r3-1-'))
})
afterEach(() => {
vi.restoreAllMocks()
engine.dispose()
unbindTestDatabase()
resetInMemoryConfig()
testDb.close()
fs.rmSync(tmpDir, { recursive: true, force: true })
})
function insertHistory(mode: 'dictation' | 'file-transcription', fileName: string): string {
const id = crypto.randomUUID()
const file = path.join(tmpDir, fileName)
fs.writeFileSync(file, Buffer.from('RIFF....WAVEfmt fake media'))
const now = Date.now()
testDb.db
.insert(history)
.values({ id, originalText: 'spoken', duration: 2, mode, audioLocalPath: file, createdAt: now, updatedAt: now })
.run()
return id
}
describe('파일 전사 원본은 녹음으로 올리지 않는다 (item 19)', () => {
it('파일 전사 기록의 원본 미디어는 업로드 대상이 아니다', async () => {
const fileId = insertHistory('file-transcription', 'meeting-video.mp4')
const dictationId = insertHistory('dictation', 'dictation.wav')
expect(localAudioPath('history', fileId)).toBeNull()
expect(localAudioPath('history', dictationId)).not.toBeNull()
expect(listLocalAudioOwners('history')).toEqual([dictationId])
const result = await engine.runFullSync()
expect(result.errors).toEqual([])
expect(remote.objects.has(`audio/${USER}/desktop/history/${fileId}.mp4`)).toBe(false)
expect(remote.objects.has(`audio/${USER}/desktop/history/${dictationId}.wav`)).toBe(true)
})
})
describe('대기 중인 삭제는 뒤늦은 upsert 에 덮이지 않는다 (item 37)', () => {
it('delete 뒤에 같은 행의 upsert 가 와도 op 는 delete 로 남는다', () => {
const id = crypto.randomUUID()
enqueueChange('history', id, 'upsert')
enqueueChange('history', id, 'delete')
enqueueChange('history', id, 'upsert')
expect(pendingOps('history').get(id)).toBe('delete')
})
})