d3ro-voice/apps/desktop/tests/unit/system-audio-capture-redteam-r3-22.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

319 lines
9.9 KiB
TypeScript

// tests/unit/system-audio-capture-redteam-r3-22.test.ts
// SystemAudioCaptureController: stop-during-start 취소, off/on 중복 획득 방지,
// getDisplayMedia 실패 시 loopback 해제를 fake port로 검증한다.
import { describe, it, expect, vi } from 'vitest'
import {
SystemAudioCaptureController,
type PcmPipeline,
type SystemAudioPort,
type SystemAudioStream,
} from '../../src/renderer/utils/systemAudioCaptureController'
import { float32ToPcm16 } from '../../src/renderer/utils/systemAudioCapture'
interface Deferred<T> {
promise: Promise<T>
resolve: (v: T) => void
reject: (e: unknown) => void
}
function deferred<T>(): Deferred<T> {
let resolve!: (v: T) => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
class FakeStream implements SystemAudioStream {
stopped = false
videoDropped = false
constructor(readonly id: number, private readonly audio = true) {}
dropVideoTracks(): void {
this.videoDropped = true
}
hasAudioTrack(): boolean {
return this.audio
}
stop(): void {
this.stopped = true
}
}
class FakePipeline implements PcmPipeline {
closed = false
constructor(readonly stream: FakeStream, readonly onPcm: (b: ArrayBuffer) => void) {}
close(): void {
this.closed = true
}
}
class FakePort implements SystemAudioPort<FakeStream> {
loopbackEnabled = false
enableCalls = 0
disableCalls = 0
displayRequests: Deferred<FakeStream>[] = []
pipelines: FakePipeline[] = []
/** 루프백 핸들러가 꺼진 상태에서 getDisplayMedia 요청 → 실제 Electron처럼 실패 */
requestsWithoutLoopback = 0
async enableLoopback(): Promise<void> {
this.enableCalls++
this.loopbackEnabled = true
}
async disableLoopback(): Promise<void> {
this.disableCalls++
this.loopbackEnabled = false
}
getDisplayMedia(): Promise<FakeStream> {
if (!this.loopbackEnabled) this.requestsWithoutLoopback++
const d = deferred<FakeStream>()
this.displayRequests.push(d)
return d.promise
}
createPcmPipeline(stream: FakeStream, onPcm: (b: ArrayBuffer) => void): PcmPipeline {
const p = new FakePipeline(stream, onPcm)
this.pipelines.push(p)
return p
}
openPipelines(): FakePipeline[] {
return this.pipelines.filter((p) => !p.closed)
}
}
/** 마이크로태스크 큐 비우기 */
async function flush(): Promise<void> {
for (let i = 0; i < 10; i++) await Promise.resolve()
}
function setup(): { port: FakePort; ctrl: SystemAudioCaptureController<FakeStream>; onPcm: ReturnType<typeof vi.fn> } {
const port = new FakePort()
const onPcm = vi.fn()
const ctrl = new SystemAudioCaptureController(port, { onPcm })
return { port, ctrl, onPcm }
}
describe('SystemAudioCaptureController', () => {
it('starts: enable loopback → getDisplayMedia → disable loopback → pipeline', async () => {
const { port, ctrl, onPcm } = setup()
const started = ctrl.start()
expect(ctrl.getState()).toBe('starting')
await flush()
const stream = new FakeStream(1)
port.displayRequests[0].resolve(stream)
await started
expect(ctrl.getState()).toBe('active')
expect(ctrl.isActive()).toBe(true)
expect(port.enableCalls).toBe(1)
expect(port.disableCalls).toBe(1)
expect(port.loopbackEnabled).toBe(false)
expect(stream.videoDropped).toBe(true)
expect(port.openPipelines()).toHaveLength(1)
expect(port.pipelines[0].onPcm).toBe(onPcm)
ctrl.stop()
expect(ctrl.getState()).toBe('idle')
expect(port.pipelines[0].closed).toBe(true)
expect(stream.stopped).toBe(true)
})
it('stop while getDisplayMedia is pending cancels the start and releases the stream', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
await flush()
expect(port.displayRequests).toHaveLength(1)
ctrl.stop() // 대기 중 STOP 도착
expect(ctrl.getState()).toBe('idle')
const stream = new FakeStream(1)
port.displayRequests[0].resolve(stream)
await expect(started).resolves.toBeUndefined()
expect(ctrl.getState()).toBe('idle')
expect(stream.stopped).toBe(true)
expect(port.pipelines).toHaveLength(0) // 캡처 파이프라인이 만들어지지 않음
expect(port.loopbackEnabled).toBe(false)
})
it('stop issued synchronously after start acquires nothing', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
ctrl.stop()
await started
expect(port.displayRequests).toHaveLength(0)
expect(port.loopbackEnabled).toBe(false)
expect(ctrl.getState()).toBe('idle')
})
it('stop while enableLoopback is pending disables loopback without requesting media', async () => {
const { port, ctrl } = setup()
const enable = deferred<void>()
port.enableLoopback = async () => {
port.enableCalls++
await enable.promise
port.loopbackEnabled = true
}
const started = ctrl.start()
await flush()
expect(port.enableCalls).toBe(1)
ctrl.stop()
enable.resolve()
await started
expect(port.displayRequests).toHaveLength(0)
expect(port.loopbackEnabled).toBe(false)
expect(port.disableCalls).toBe(1)
})
it('start while starting returns the same pending promise (no second acquisition)', async () => {
const { port, ctrl } = setup()
const a = ctrl.start()
const b = ctrl.start()
expect(b).toBe(a)
await flush()
expect(port.enableCalls).toBe(1)
expect(port.displayRequests).toHaveLength(1)
port.displayRequests[0].resolve(new FakeStream(1))
await a
expect(port.openPipelines()).toHaveLength(1)
})
it('start while active is a no-op', async () => {
const { port, ctrl } = setup()
const a = ctrl.start()
await flush()
port.displayRequests[0].resolve(new FakeStream(1))
await a
await ctrl.start()
expect(port.enableCalls).toBe(1)
expect(port.openPipelines()).toHaveLength(1)
})
it('on → off → on inside the pending window leaves exactly one stoppable capture', async () => {
const { port, ctrl } = setup()
const first = ctrl.start()
await flush()
ctrl.stop()
const second = ctrl.start()
await flush()
// 두 번째 start는 첫 starter 정리 전까지 loopback/getDisplayMedia를 건드리지 않는다
expect(port.displayRequests).toHaveLength(1)
const s1 = new FakeStream(1)
port.displayRequests[0].resolve(s1)
await first
await flush()
expect(s1.stopped).toBe(true)
// 이제 두 번째 starter가 자기 loopback 핸들러로 요청
expect(port.displayRequests).toHaveLength(2)
expect(port.requestsWithoutLoopback).toBe(0)
const s2 = new FakeStream(2)
port.displayRequests[1].resolve(s2)
await second
expect(ctrl.getState()).toBe('active')
expect(port.openPipelines()).toHaveLength(1)
expect(port.openPipelines()[0].stream).toBe(s2)
ctrl.stop()
expect(port.openPipelines()).toHaveLength(0)
expect(s2.stopped).toBe(true)
})
it('on → off → on → off leaves nothing running', async () => {
const { port, ctrl } = setup()
const first = ctrl.start()
await flush()
ctrl.stop()
const second = ctrl.start()
ctrl.stop()
port.displayRequests[0].resolve(new FakeStream(1))
await first
await second
await flush()
expect(ctrl.getState()).toBe('idle')
expect(port.displayRequests).toHaveLength(1) // 두 번째 starter는 시작 전에 취소됨
expect(port.pipelines).toHaveLength(0)
expect(port.loopbackEnabled).toBe(false)
})
it('failed getDisplayMedia always disables loopback and rejects to the caller', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
await flush()
port.displayRequests[0].reject(new Error('denied'))
await expect(started).rejects.toThrow('denied')
expect(port.loopbackEnabled).toBe(false)
expect(port.disableCalls).toBe(1)
expect(ctrl.getState()).toBe('idle')
// 실패 후 재시작 가능
const again = ctrl.start()
await flush()
port.displayRequests[1].resolve(new FakeStream(2))
await again
expect(ctrl.getState()).toBe('active')
})
it('failed getDisplayMedia after stop still disables loopback but does not reject', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
await flush()
ctrl.stop()
port.displayRequests[0].reject(new Error('denied'))
await expect(started).resolves.toBeUndefined()
expect(port.loopbackEnabled).toBe(false)
})
it('stream without an audio track is stopped and the start rejects', async () => {
const { port, ctrl } = setup()
const started = ctrl.start()
await flush()
const stream = new FakeStream(1, false)
port.displayRequests[0].resolve(stream)
await expect(started).rejects.toThrow('No audio track')
expect(stream.stopped).toBe(true)
expect(port.pipelines).toHaveLength(0)
expect(ctrl.getState()).toBe('idle')
})
it('stop is idempotent when nothing was started', () => {
const { port, ctrl } = setup()
expect(() => {
ctrl.stop()
ctrl.stop()
}).not.toThrow()
expect(ctrl.getState()).toBe('idle')
expect(port.enableCalls).toBe(0)
})
it('cleanup errors are reported, not thrown', async () => {
const port = new FakePort()
const onCleanupError = vi.fn()
const ctrl = new SystemAudioCaptureController(port, { onPcm: vi.fn(), onCleanupError })
const started = ctrl.start()
await flush()
const stream = new FakeStream(1)
stream.stop = () => {
throw new Error('stop failed')
}
port.displayRequests[0].resolve(stream)
await started
expect(() => ctrl.stop()).not.toThrow()
expect(onCleanupError).toHaveBeenCalledTimes(1)
})
})
describe('float32ToPcm16', () => {
it('clamps and scales samples to PCM16', () => {
const out = new Int16Array(float32ToPcm16(new Float32Array([0, 1, -1, 2, -2, 0.5])))
expect(Array.from(out)).toEqual([0, 32767, -32768, 32767, -32768, 16383])
})
})