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.
This commit is contained in:
Yun Chan 2026-09-28 20:45:52 +09:00
parent 2428ede03d
commit ba9ef9741e
161 changed files with 17056 additions and 2379 deletions

View file

@ -0,0 +1,286 @@
// packages/core/__tests__/keybinding-altgr-redteam-r3-8.test.ts
// 레드팀 r3-8 회귀 테스트 — AltGr 배열(독일어·프랑스어·폴란드어·북유럽).
//
// Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 타임스탬프)으로 보내고, libuiohook 은
// 가짜 LCtrl 로 Ctrl 수정자를 켠다. 그래서
// 1) '@'(AltGr+Q) 를 칠 때마다 Ctrl+RightAlt('command') 가 발동해 음성 세션이 열렸고,
// 2) 기본 dictation(RightAlt 단독) · hands-free(더블탭) 는 영영 발동하지 않았다.
// 데스크톱 어댑터와 같은 순서(AltGrPressDetector → noteKeyDown → keyDown)로 흘려 검증한다.
import { describe, it, expect, beforeEach } from 'vitest'
import {
ALTGR_CHORD_GRACE_MS,
AltGrPressDetector,
ChordStateMachine
} from '../src/keybinding-runtime'
import type { ChordBindingEntry, ChordClock, ChordTriggerEvent } from '../src/keybinding-runtime'
import { KEYBINDING_ACTIONS, VK, bindingKey, createDefaultBindingMap } from '../src/keybinding'
const VK_Q = 0x51
// ============================================================
// 가짜 시계
// ============================================================
class FakeClock implements ChordClock {
private _now = 1_000
private _timers: { at: number; callback: () => void; active: boolean }[] = []
now(): number {
return this._now
}
schedule(callback: () => void, delayMs: number): () => void {
const timer = { at: this._now + delayMs, callback, active: true }
this._timers.push(timer)
return () => {
timer.active = false
}
}
advance(ms: number): void {
const target = this._now + ms
for (;;) {
const due = this._timers
.filter((t) => t.active && t.at <= target)
.sort((a, b) => a.at - b.at)[0]
if (due === undefined) break
this._now = due.at
due.active = false
due.callback()
}
this._timers = this._timers.filter((t) => t.active)
this._now = target
}
}
// ============================================================
// 어댑터 흉내 (KeyBindingService._handleKeyDown / _handleKeyUp 와 같은 순서)
// ============================================================
interface Mods {
ctrl?: boolean
alt?: boolean
}
function defaultEntries(): ChordBindingEntry[] {
const map = createDefaultBindingMap()
return KEYBINDING_ACTIONS.flatMap((spec) =>
map[spec.id].map((binding) => ({
actionId: spec.id,
holdMode: spec.holdMode,
doublePress: spec.doublePress,
binding
}))
)
}
class Harness {
readonly clock = new FakeClock()
readonly events: string[] = []
readonly detector = new AltGrPressDetector()
readonly machine = new ChordStateMachine({
clock: this.clock,
onTrigger: (e: ChordTriggerEvent) => this.events.push(`${e.actionId}:${e.type}`)
})
constructor() {
this.machine.setBindings(defaultEntries())
}
private _key(vk: number, mods: Mods, isAltGr: boolean): string {
return bindingKey({
device: 'keyboard',
code: vk,
ctrl: (mods.ctrl ?? false) && !isAltGr,
alt: mods.alt ?? false,
shift: false,
meta: false
})
}
down(vk: number, mods: Mods = {}): void {
const isAltGr = this.detector.keyDown(vk, this.clock.now())
this.machine.noteKeyDown(vk)
this.machine.keyDown(this._key(vk, mods, isAltGr), { altGr: isAltGr })
}
up(vk: number, mods: Mods = {}): void {
this.detector.keyUp(vk)
this.machine.keyUp(this._key(vk, mods, false), vk)
}
/** AltGr 누름: 가짜 LCtrl → RAlt (같은 타임스탬프, RAlt 에는 Ctrl+Alt 가 실린다) */
altGrDown(): void {
this.down(VK.CtrlLeft, { ctrl: true })
this.down(VK.AltRight, { ctrl: true, alt: true })
}
/** AltGr 놓음: 가짜 LCtrl ↑ → RAlt ↑ */
altGrUp(): void {
this.up(VK.CtrlLeft, { alt: true })
this.up(VK.AltRight)
}
wait(ms: number): void {
this.clock.advance(ms)
}
}
describe('AltGr 배열 — 문자 입력은 음성 트리거를 발동하지 않는다', () => {
let h: Harness
beforeEach(() => {
h = new Harness()
})
it("AltGr+Q('@') 를 치면 어떤 트리거도 나가지 않는다", () => {
h.altGrDown()
h.wait(40)
h.down(VK_Q, { ctrl: true, alt: true })
h.up(VK_Q, { ctrl: true, alt: true })
h.wait(20)
h.altGrUp()
h.wait(1_000)
expect(h.events).toEqual([])
})
it("AltGr 를 700ms 넘게 누른 채 '{' '}' 를 쳐도 트리거가 나가지 않는다", () => {
h.altGrDown()
h.wait(100)
h.down(0x37, { ctrl: true, alt: true }) // 7 → '{'
h.up(0x37, { ctrl: true, alt: true })
h.wait(300)
h.down(0x30, { ctrl: true, alt: true }) // 0 → '}'
h.up(0x30, { ctrl: true, alt: true })
h.wait(400)
h.altGrUp()
expect(h.events).toEqual([])
})
it('좌표계로 옮기지 못한 문자 키(vk=null, 예: VK_OEM_102 "<")도 보류를 취소한다', () => {
h.altGrDown()
h.wait(30)
h.detector.keyDown(null, h.clock.now())
h.machine.noteKeyDown(null)
h.wait(30)
h.altGrUp()
h.wait(1_000)
expect(h.events).toEqual([])
})
it('판별기가 AltGr 를 놓쳐도(타임스탬프 불일치) 문자 키가 Ctrl+RightAlt 보류를 취소한다', () => {
h.down(VK.CtrlLeft, { ctrl: true })
h.wait(5)
h.down(VK.AltRight, { ctrl: true, alt: true }) // 'command' 로 보임 → AltGr 형태 보류
h.down(VK_Q, { ctrl: true, alt: true })
h.up(VK_Q, { ctrl: true, alt: true })
h.up(VK.CtrlLeft, { alt: true })
h.up(VK.AltRight)
h.wait(1_000)
expect(h.events).toEqual([])
})
})
describe('AltGr 배열 — 기본 dictation · hands-free 가 발동한다', () => {
let h: Harness
beforeEach(() => {
h = new Harness()
})
it('AltGr 를 누르고 있으면 유예 뒤 dictation 이 pressed, 놓으면 released', () => {
h.altGrDown()
expect(h.events).toEqual([])
h.wait(ALTGR_CHORD_GRACE_MS)
expect(h.events).toEqual(['dictation:pressed'])
h.wait(1_500)
h.altGrUp()
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
})
it('AltGr auto-repeat(가짜 LCtrl + RAlt 반복)는 누름을 다시 트리거하지 않는다', () => {
h.altGrDown()
h.wait(ALTGR_CHORD_GRACE_MS)
h.wait(500)
h.altGrDown()
h.wait(33)
h.altGrDown()
h.altGrUp()
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
})
it('AltGr 더블탭은 hands-free 를 켠다', () => {
h.altGrDown()
h.wait(60)
h.altGrUp()
h.wait(120)
h.altGrDown()
h.wait(60)
h.altGrUp()
expect(h.events).toEqual([
'dictation:pressed',
'dictation:released',
'hands-free:pressed',
'hands-free:released'
])
})
it("'@' 직후 AltGr 를 탭해도 hands-free 로 세지 않는다 (취소된 누름은 첫 탭이 아니다)", () => {
h.altGrDown()
h.down(VK_Q, { ctrl: true, alt: true })
h.up(VK_Q, { ctrl: true, alt: true })
h.altGrUp()
h.wait(100)
h.altGrDown()
h.wait(60)
h.altGrUp()
expect(h.events).toEqual(['dictation:pressed', 'dictation:released'])
})
it('진짜 LCtrl 을 먼저 누르고 AltGr 를 누르면 command(Ctrl+RightAlt) 다', () => {
h.down(VK.CtrlLeft, { ctrl: true })
h.wait(80)
h.altGrDown() // Windows 는 LCtrl 이 눌려 있어도 가짜 LCtrl 을 보낸다
h.wait(ALTGR_CHORD_GRACE_MS)
expect(h.events).toEqual(['command:pressed'])
h.up(VK.AltRight, { ctrl: true })
h.up(VK.CtrlLeft)
expect(h.events).toEqual(['command:pressed', 'command:released'])
})
})
describe('AltGrPressDetector', () => {
it('같은 타임스탬프의 LCtrl 직후 RAlt 만 AltGr 다', () => {
const d = new AltGrPressDetector()
expect(d.keyDown(VK.CtrlLeft, 10)).toBe(false)
expect(d.keyDown(VK.AltRight, 10)).toBe(true)
})
it('타임스탬프가 다르거나 사이에 다른 키가 끼면 AltGr 가 아니다', () => {
const a = new AltGrPressDetector()
a.keyDown(VK.CtrlLeft, 10)
expect(a.keyDown(VK.AltRight, 26)).toBe(false)
const b = new AltGrPressDetector()
b.keyDown(VK.CtrlLeft, 10)
b.keyDown(VK_Q, 10)
expect(b.keyDown(VK.AltRight, 10)).toBe(false)
})
it('RAlt 를 놓으면 다음 누름은 새로 판정한다', () => {
const d = new AltGrPressDetector()
d.keyDown(VK.CtrlLeft, 10)
expect(d.keyDown(VK.AltRight, 10)).toBe(true)
d.keyUp(VK.CtrlLeft)
d.keyUp(VK.AltRight)
expect(d.keyDown(VK.AltRight, 500)).toBe(false)
})
it('reset 은 상태를 비운다', () => {
const d = new AltGrPressDetector()
d.keyDown(VK.CtrlLeft, 10)
d.reset()
expect(d.keyDown(VK.AltRight, 10)).toBe(false)
})
})