d3ro-voice/apps/desktop/tests/unit/keybinding-picker-capture-redteam-r3-23.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

147 lines
5.3 KiB
TypeScript

import fs from 'fs'
import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MouseButton, VK } from '@d3ro/core/keybinding'
type EffectFn = () => void | (() => void)
const effects: EffectFn[] = []
vi.mock('react', () => ({
useEffect: (fn: EffectFn) => {
effects.push(fn)
},
}))
const {
KeyBindingCaptureSession,
resolveCapturePort,
useKeyBindingCaptureSuspension,
} = await import('../../src/renderer/components/keybinding/keybinding-capture')
const { BROWSER_BUTTON_TO_MOUSE, NO_MODIFIERS, buildBinding, resolveKeyCode } = await import(
'../../src/renderer/components/keybinding/keybinding-recording'
)
const PICKER = path.resolve(
__dirname,
'../../src/renderer/components/keybinding/KeyBindingPicker.tsx'
)
function fakePort() {
const calls: string[] = []
return {
calls,
port: {
beginCapture: vi.fn(async () => {
calls.push('begin')
}),
endCapture: vi.fn(async () => {
calls.push('end')
}),
},
}
}
describe('KeyBindingPicker — 녹화 중 전역 트리거 중단 (r3-23)', () => {
beforeEach(() => {
effects.length = 0
})
it('피커가 열려 있는 동안 캡처 억제 훅을 open 으로 건다', () => {
const source = fs.readFileSync(PICKER, 'utf8')
expect(source).toContain('useKeyBindingCaptureSuspension(open)')
// 전역 on/off 는 hotkeyEnabled 를 저장하므로 캡처에 쓰면 안 된다.
expect(source).not.toMatch(/keybinding\s*\.\s*setEnabled/)
})
it('열리면 beginCapture, 닫히거나 언마운트되면 endCapture 를 한 번씩 보낸다', () => {
const { port, calls } = fakePort()
useKeyBindingCaptureSuspension(true, () => port)
expect(effects).toHaveLength(1)
const cleanup = effects[0]()
expect(calls).toEqual(['begin'])
expect(typeof cleanup).toBe('function')
if (typeof cleanup === 'function') cleanup()
expect(calls).toEqual(['begin', 'end'])
})
it('닫혀 있으면 아무것도 보내지 않는다', () => {
const { port, calls } = fakePort()
useKeyBindingCaptureSuspension(false, () => port)
const cleanup = effects[0]()
expect(cleanup).toBeUndefined()
expect(calls).toEqual([])
})
it('세션은 begin/end 를 중복 전송하지 않고 begin 없이 end 를 보내지 않는다', () => {
const { port, calls } = fakePort()
const session = new KeyBindingCaptureSession(port)
session.end()
session.begin()
session.begin()
expect(session.isActive).toBe(true)
session.end()
session.end()
expect(session.isActive).toBe(false)
expect(calls).toEqual(['begin', 'end'])
})
it('IPC 실패는 녹화를 막지 않는다', async () => {
const session = new KeyBindingCaptureSession({
beginCapture: () => Promise.reject(new Error('no handler')),
endCapture: () => Promise.reject(new Error('no handler')),
})
expect(() => {
session.begin()
session.end()
}).not.toThrow()
await Promise.resolve()
})
it('포트가 없으면(null) 조용히 아무것도 하지 않는다', () => {
const session = new KeyBindingCaptureSession(null)
session.begin()
expect(session.isActive).toBe(false)
})
it('preload API 구조로 포트를 판별한다', () => {
expect(resolveCapturePort(undefined)).toBeNull()
expect(resolveCapturePort({ setEnabled: () => undefined })).toBeNull()
expect(resolveCapturePort({ beginCapture: () => undefined })).toBeNull()
const begin = vi.fn(() => Promise.resolve({ success: true }))
const end = vi.fn(() => Promise.resolve({ success: true }))
const port = resolveCapturePort({ beginCapture: begin, endCapture: end })
expect(port).not.toBeNull()
void port?.beginCapture()
void port?.endCapture()
expect(begin).toHaveBeenCalledTimes(1)
expect(end).toHaveBeenCalledTimes(1)
})
})
describe('keybinding-recording — 추출된 녹화 규칙 (동작 보존)', () => {
it('좌/우 수정자를 location 으로 구분한다', () => {
expect(resolveKeyCode({ keyCode: 17, which: 17, location: 1 })).toBe(VK.CtrlLeft)
expect(resolveKeyCode({ keyCode: 17, which: 17, location: 2 })).toBe(VK.CtrlRight)
expect(resolveKeyCode({ keyCode: 16, which: 16, location: 2 })).toBe(VK.ShiftRight)
expect(resolveKeyCode({ keyCode: 18, which: 18, location: 1 })).toBe(VK.AltLeft)
expect(resolveKeyCode({ keyCode: 93, which: 93, location: 2 })).toBe(VK.MetaRight)
expect(resolveKeyCode({ keyCode: 93, which: 93, location: 0 })).toBe(93)
expect(resolveKeyCode({ keyCode: 0, which: 65, location: 0 })).toBe(65)
})
it('주 키는 비수정자 키, 없으면 마지막 수정자', () => {
expect(buildBinding([], NO_MODIFIERS)).toBeNull()
const combo = buildBinding([VK.CtrlLeft, 65], { ...NO_MODIFIERS, ctrl: true })
expect(combo).toMatchObject({ device: 'keyboard', code: 65, ctrl: true })
const mods = buildBinding([VK.CtrlLeft, VK.AltRight], { ...NO_MODIFIERS, ctrl: true })
expect(mods?.code).toBe(VK.AltRight)
})
it('브라우저 마우스 버튼을 uiohook 코드로 옮긴다', () => {
expect(BROWSER_BUTTON_TO_MOUSE[1]).toBe(MouseButton.Middle)
expect(BROWSER_BUTTON_TO_MOUSE[2]).toBe(MouseButton.Right)
expect(BROWSER_BUTTON_TO_MOUSE[3]).toBe(MouseButton.Back)
expect(BROWSER_BUTTON_TO_MOUSE[4]).toBe(MouseButton.Forward)
})
})