d3ro-voice/apps/desktop/tests/main/ipc/ipc-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

138 lines
6.3 KiB
TypeScript

// tests/main/ipc/ipc-redteam-r3-1.test.ts
// IPC 회귀 (r3-1):
// - caption:stop 은 사용자 자막만 멈추고 회의가 소유한 자막은 거부한다 (items 13·41)
// - stt:setModel 은 설치된 모델만 올린다 — 미설치 모델은 설정만 저장 (item 10)
// - 입력 수집 동의·일시정지 변경을 제안 서비스에 알린다 (item 7)
// - realtime-token 은 주입된 자격 증명 포트로 호출한다 (item 2)
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
const handlers = vi.hoisted(() => new Map<string, (event: unknown, params?: unknown) => Promise<unknown>>())
const caption = vi.hoisted(() => ({ stop: vi.fn(), start: vi.fn(), getState: vi.fn(() => 'active') }))
const stt = vi.hoisted(() => ({ initialize: vi.fn(async () => undefined), isModelInstalled: vi.fn(() => false), on: vi.fn() }))
const sttManager = vi.hoisted(() => ({ getActiveProvider: vi.fn(() => 'local'), on: vi.fn() }))
const config = vi.hoisted(() => ({ configGet: vi.fn(), configSet: vi.fn() }))
const telemetry = vi.hoisted(() => ({ setEnabled: vi.fn(), setPaused: vi.fn(), getState: vi.fn(() => ({})) }))
const suggestion = vi.hoisted(() => ({ handleInputConsentChanged: vi.fn(), applyConfig: vi.fn() }))
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn((channel: string, handler: (event: unknown, params?: unknown) => Promise<unknown>) => {
handlers.set(channel, handler)
}),
on: vi.fn(),
},
session: { defaultSession: { setDisplayMediaRequestHandler: vi.fn() } },
desktopCapturer: { getSources: vi.fn(async () => []) },
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
}))
vi.mock('../../../src/main/services/CaptionService', () => ({ getCaptionService: () => caption }))
vi.mock('../../../src/main/windows/WindowManager', () => ({
endCaptionOverlayDrag: vi.fn(),
resetCaptionOverlayPosition: vi.fn(),
setCaptionOverlayInteractive: vi.fn(),
startCaptionOverlayDrag: vi.fn(),
getMainWindow: vi.fn(() => null),
}))
vi.mock('../../../src/main/services/LocalSTTService', () => ({ getLocalSTTService: () => stt }))
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ on: vi.fn() }) }))
vi.mock('../../../src/main/services/stt/STTManager', () => ({ getSTTManager: () => sttManager }))
vi.mock('../../../src/main/services/ConfigService', () => config)
vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => telemetry }))
vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: vi.fn() }))
vi.mock('../../../src/main/services/SuggestionService', () => ({ getSuggestionService: () => suggestion }))
vi.mock('../../../src/main/services/VoiceConversationService', () => ({ getVoiceConversationService: vi.fn() }))
vi.mock('../../../src/main/services/CloudSyncService', () => ({
getCloudSyncService: () => {
throw new Error('handlers must use the injected credentials port')
},
}))
async function invoke(channel: string, params?: unknown): Promise<{ success: boolean; error?: { code: number } }> {
const handler = handlers.get(channel)
if (!handler) throw new Error(`handler not registered: ${channel}`)
return (await handler({}, params)) as { success: boolean; error?: { code: number } }
}
beforeEach(() => {
vi.clearAllMocks()
handlers.clear()
})
describe('caption:stop 소유자 확인', () => {
beforeEach(async () => {
const mod = await import('../../../src/main/ipc/caption-handlers')
mod.registerCaptionHandlers()
})
it("owner 'user' 로만 멈춘다", async () => {
caption.stop.mockResolvedValue(null)
expect((await invoke(IPC_CHANNELS.CAPTION.STOP)).success).toBe(true)
expect(caption.stop).toHaveBeenCalledWith('user')
})
it('회의가 소유한 자막이면 거부한다 (강제 정지하지 않음)', async () => {
caption.stop.mockResolvedValue(false)
const result = await invoke(IPC_CHANNELS.CAPTION.STOP)
expect(result.success).toBe(false)
expect(caption.stop).not.toHaveBeenCalledWith()
})
})
describe('stt:setModel 미설치 모델', () => {
beforeEach(async () => {
const mod = await import('../../../src/main/ipc/stt-handlers')
mod.registerSTTHandlers()
})
it('미설치 모델은 설정만 저장하고 사이드카에 올리지 않는다', async () => {
stt.isModelInstalled.mockReturnValue(false)
const result = await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'large-v3' })
expect(result.success).toBe(true)
expect(config.configSet).toHaveBeenCalledWith('sttModelId', 'large-v3')
expect(stt.initialize).not.toHaveBeenCalled()
})
it('설치된 모델은 바로 올린다', async () => {
stt.isModelInstalled.mockReturnValue(true)
await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'small' })
expect(stt.initialize).toHaveBeenCalledWith('small')
})
})
describe('입력 수집 동의 변경', () => {
beforeEach(async () => {
const mod = await import('../../../src/main/ipc/input-telemetry-handlers')
mod.registerInputTelemetryHandlers()
})
it('동의를 끄면 제안 서비스에 알린다', async () => {
await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED, { enabled: false })
expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1)
})
it('일시정지도 제안 서비스에 알린다', async () => {
await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED, { paused: true })
expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1)
})
})
describe('realtime-token 은 자격 증명 포트를 쓴다', () => {
it('로그아웃 상태의 포트가 error 를 주면 실패로 돌려준다', async () => {
const mod = await import('../../../src/main/ipc/voice-conversation-handlers')
const invokeFn = vi.fn(async () => ({ data: null, error: { message: 'No active session' } }))
mod.registerVoiceConversationHandlers({
hasCredentials: () => false,
accessToken: async () => null,
invoke: invokeFn,
invokeStream: async () => ({ stream: null, error: { message: 'No active session' } }),
})
const result = await invoke(IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, {})
expect(result.success).toBe(false)
expect(invokeFn).toHaveBeenCalledWith('realtime-token', {})
})
})