d3ro-voice/apps/desktop/tests/red/meeting-recording-redteam-r2-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

243 lines
10 KiB
TypeScript

// tests/red/meeting-recording-redteam-r2-1.test.ts
// MeetingModeService 녹음 수명주기 회귀 테스트 (실제 DB + 가짜 CaptionService / LLM):
// - 정지를 두 번 눌러도(마지막 문장 flush 중) 정지·저장은 한 번 — 마지막 문장 유지, durationMs > 0, 완료 알림 1회
// - 앱 종료(shutdown) 시 녹음 중이던 전사가 저장되고 행이 'recording' 으로 남지 않는다
// - 시작 시 중단된 'recording'/'processing' 행을 닫는다
// - 페이지를 다시 열 때 진행 중인 전사·메모를 돌려준다
// - 긴 회의: 문서 생성은 조각별로 줄여 한 메시지 한도를 넘지 않고, 채팅은 관련 발췌만 넣는다
// - 회의 채팅 지우기·취소는 그 회의만
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { EventEmitter } from 'events'
import { eq } from 'drizzle-orm'
import type { CaptionSegment } from '@d3ro/core/types'
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
import { useRedHarness } from './harness'
const caption = vi.hoisted(() => ({
bus: null as EventEmitter | null,
state: 'inactive' as string,
stopCalls: 0,
/** 정지(flush)를 붙잡아 두는 게이트 — null 이면 바로 끝난다 */
gate: null as Promise<void> | null,
}))
vi.mock('../../src/main/services/CaptionService', async () => {
const { EventEmitter: Emitter } = await import('events')
const bus = new Emitter()
caption.bus = bus
const service = {
getState: () => caption.state,
on: (ev: string, fn: (...args: unknown[]) => void) => bus.on(ev, fn),
off: (ev: string, fn: (...args: unknown[]) => void) => bus.off(ev, fn),
start: async () => {
caption.state = 'active'
},
stop: async () => {
caption.stopCalls++
if (caption.state !== 'active') return null
caption.state = 'stopping'
if (caption.gate) await caption.gate
const segment: CaptionSegment = { id: 'seg-last', text: '마지막 문장', timestamp: Date.now(), isFinal: true }
bus.emit('segment', segment)
caption.state = 'inactive'
return { sessionId: 'cap', historyId: null, segments: [segment], startedAt: 0, endedAt: 0, totalDurationMs: 0 }
},
}
return { getCaptionService: () => service, resetCaptionServiceForTests: () => undefined }
})
vi.mock('../../src/main/services/AudioCaptureService', () => ({
getAudioCaptureService: () => ({ on: vi.fn(), off: vi.fn(), start: vi.fn(), stop: vi.fn(async () => undefined) }),
calculateRMS: () => 0,
}))
const llm = vi.hoisted(() => ({
generateInputs: [] as string[],
chatCalls: [] as Array<Array<{ role: string; content: string }>>,
}))
vi.mock('../../src/main/services/LocalLLMService', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/main/services/LocalLLMService')>()
return {
...actual,
getLocalLLMService: () => ({
isAvailable: () => true,
async *chatStream(messages: Array<{ role: string; content: string }>): AsyncGenerator<string, string> {
llm.chatCalls.push(messages)
yield '답변'
return '답변'
},
generate: async (text: string) => {
llm.generateInputs.push(text)
return { text: `요약(${text.length})`, model: 'fx-local' }
},
cancelGeneration: vi.fn(),
}),
}
})
useRedHarness()
import { getMeetingModeService } from '../../src/main/services/MeetingModeService'
import { configSet } from '../../src/main/services/ConfigService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
import { syncStateRecordingLedger } from '../../src/main/services/meeting/local-recording-ledger'
function row(id: string) {
return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get()
}
function insertSession(id: string, fields: Partial<typeof meetingSessions.$inferInsert> = {}): void {
const now = Date.now()
getDatabase()
.insert(meetingSessions)
.values({ id, title: id, status: 'completed', startedAt: now - 60_000, createdAt: now, updatedAt: now, ...fields })
.run()
}
function longTranscript(lines: number): string {
return Array.from({ length: lines }, (_, i) => {
const mm = String(Math.floor(i / 60)).padStart(2, '0')
const ss = String(i % 60).padStart(2, '0')
return `[${mm}:${ss}] 이번 분기 예산과 일정에 대한 ${i}번째 발언이 이어집니다 항목 ${i}`
}).join('\n')
}
beforeEach(() => {
caption.state = 'inactive'
caption.stopCalls = 0
caption.gate = null
llm.generateInputs = []
llm.chatCalls = []
configSet('llmBackend', 'local')
})
describe('회의 정지 — 동시에 두 번', () => {
it('두 번째 정지는 진행 중인 정지를 함께 기다린다 — 마지막 문장·durationMs 유지, 완료 1회', async () => {
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
caption.bus?.emit('segment', { id: 'seg-1', text: '첫 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
await new Promise((resolve) => setTimeout(resolve, 5))
let release: () => void = () => undefined
caption.gate = new Promise<void>((resolve) => {
release = resolve
})
const completed = vi.fn()
meeting.on('session-completed', completed)
const first = meeting.stopRecording()
const second = meeting.stopRecording()
// 정지 중에는 '처리 중' — 화면의 정지 버튼이 사라진다
expect(meeting.getState()).toBe('processing')
release()
await Promise.all([first, second])
expect(caption.stopCalls).toBe(1)
expect(completed).toHaveBeenCalledTimes(1)
const saved = row(sessionId)
expect(saved?.status).toBe('completed')
expect(saved?.durationMs ?? 0).toBeGreaterThan(0)
expect((saved?.rawTranscript ?? '').split('\n')).toHaveLength(2)
expect(saved?.rawTranscript).toContain('마지막 문장')
expect(meeting.getState()).toBe('idle')
await expect(meeting.stopRecording()).rejects.toBeTruthy()
})
})
describe('앱 종료·중단 복구', () => {
it('종료(shutdown) 시 녹음 중이던 전사를 저장하고 행을 completed 로 닫는다', async () => {
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
caption.bus?.emit('segment', { id: 'seg-a', text: '종료 전 문장', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
await meeting.shutdown()
const saved = row(sessionId)
expect(saved?.status).toBe('completed')
expect(saved?.rawTranscript).toContain('종료 전 문장')
expect(saved?.endedAt).not.toBeNull()
expect(meeting.getState()).toBe('idle')
})
it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => {
insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' })
// 전사가 없는 행은 이 기기에서 시작한 녹음(원장 표식)일 때만 닫는다 — redteam r3: 폰의 진행 중 회의 보호
insertSession('stuck-empty', { status: 'processing' })
syncStateRecordingLedger.mark('stuck-empty')
insertSession('phone-live', { status: 'recording' })
insertSession('done', { status: 'completed', rawTranscript: 'x' })
expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2)
expect(row('stuck-with-text')?.status).toBe('completed')
expect(row('stuck-with-text')?.endedAt).not.toBeNull()
expect(row('stuck-empty')?.status).toBe('error')
// 표식도 전사도 없는 행은 다른 기기(폰)의 진행 중 회의일 수 있어 그대로 둔다
expect(row('phone-live')?.status).toBe('recording')
expect(row('done')?.status).toBe('completed')
})
})
describe('녹음 화면 복원', () => {
it('진행 중인 녹음의 세그먼트와 메모를 돌려준다', async () => {
const meeting = getMeetingModeService()
const { sessionId } = await meeting.startRecording()
caption.bus?.emit('segment', { id: 'seg-x', text: '앞부분 발언', timestamp: Date.now(), isFinal: true } satisfies CaptionSegment)
await meeting.addMemo('중요')
const live = meeting.getLiveSession()
expect(live?.sessionId).toBe(sessionId)
expect(live?.segments.map((s) => s.text)).toEqual(['앞부분 발언'])
expect(live?.memos.map((m) => m.content)).toEqual(['중요'])
await meeting.stopRecording()
expect(meeting.getLiveSession()).toBeNull()
})
})
describe('긴 회의의 LLM 입력', () => {
it('문서 생성은 전사를 조각별로 줄여, 어떤 호출도 메시지 한도를 넘지 않는다', async () => {
const transcript = longTranscript(400)
expect(transcript.length).toBeGreaterThan(LLM_PROXY_CHAT_LIMITS.maxMessageChars)
insertSession('long-doc', { rawTranscript: transcript })
const doc = await getMeetingModeService().generateDocument({ sessionId: 'long-doc', templateId: 'builtin-minutes' })
expect(doc.content).toBeTruthy()
expect(llm.generateInputs.length).toBeGreaterThan(2)
for (const input of llm.generateInputs) {
expect(input.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxMessageChars)
}
})
it('채팅은 system 한도 안에 질문과 관련된 발췌를 넣고, 발췌라는 사실을 모델에 알린다', async () => {
const lines = longTranscript(300).split('\n')
lines[280] = '[04:40] 마지막에 출시 담당자는 김민수로 결정했습니다'
insertSession('long-chat', { rawTranscript: lines.join('\n') })
await getMeetingModeService().chatWithMeeting('long-chat', '출시 담당자는 누구로 결정했나요?')
const system = llm.chatCalls[0].find((m) => m.role === 'system')?.content ?? ''
expect(system.length).toBeLessThanOrEqual(LLM_PROXY_CHAT_LIMITS.maxSystemChars)
expect(system).toContain('김민수')
expect(system).toContain('발췌')
})
})
describe('회의 채팅 범위', () => {
it('한 회의의 지우기는 다른 회의 대화를 남긴다', async () => {
insertSession('chat-a', { rawTranscript: '[00:01] A' })
insertSession('chat-b', { rawTranscript: '[00:01] B' })
const meeting = getMeetingModeService()
await meeting.chatWithMeeting('chat-a', 'A?')
await meeting.chatWithMeeting('chat-b', 'B?')
meeting.clearMeetingChatHistory('chat-a')
expect(meeting.getMeetingChatHistory('chat-a')).toEqual([])
expect(meeting.getMeetingChatHistory('chat-b')).toHaveLength(2)
})
})