d3ro-voice/apps/desktop/tests/main/services/voice-command-redteam-r3-9.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

168 lines
6.3 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import type { VoiceCommandRule } from '@d3ro/core/types'
import {
DEFAULT_VOICE_COMMAND_KEYWORDS,
buildDefaultRules,
findRuleMatch,
matchKeyword,
migrateLegacyDefaultRules
} from '../../../src/main/services/voice-command-policy'
import {
createVoiceCommandService,
type VoiceCommandStorePort
} from '../../../src/main/services/VoiceCommandService'
function memoryStore(
initial: { rules?: VoiceCommandRule[]; enabled?: boolean } = {}
): VoiceCommandStorePort & { saved: VoiceCommandRule[] | undefined } {
const state = {
rules: initial.rules,
enabled: initial.enabled,
saved: undefined as VoiceCommandRule[] | undefined
}
return {
get saved() {
return state.saved
},
loadRules: () => state.rules,
saveRules: (rules) => {
state.rules = rules
state.saved = rules
},
loadEnabled: () => state.enabled,
saveEnabled: (enabled) => {
state.enabled = enabled
}
}
}
let seq = 0
const defaultRules = (): VoiceCommandRule[] => buildDefaultRules(() => `r${seq++}`)
describe('기본 키워드: 일반 받아쓰기 문장을 명령으로 삼키지 않는다', () => {
const dictation = [
'요약 보고서 첨부해서 보내드립니다.',
'요약.',
'설명 드리겠습니다, 이번 배포는 늦어집니다.',
'번역 작업은 다음 주에 끝납니다.',
'영어로 된 문서를 보내 주세요.',
'다듬기 작업이 남았습니다.',
'Explain to the team that the deploy is delayed',
'Polish the slides before the meeting',
'Translate team is out today',
'Summarize: nothing yet'
]
for (const text of dictation) {
it(`"${text}" 는 매칭되지 않는다`, () => {
expect(findRuleMatch(text, defaultRules())).toBeNull()
})
}
})
describe('기본 키워드: 명령형은 매칭하고 키워드를 떼어낸다', () => {
const cases: Array<[string, string, string]> = [
['번역해줘 이 문장', 'builtin-translate', '이 문장'],
['번역해 줘, 오늘 회의 끝', 'builtin-translate', '오늘 회의 끝'],
['영어로 번역해줘. 안녕하세요', 'builtin-translate', '안녕하세요'],
['Translate this: good morning', 'builtin-translate', 'good morning'],
['요약해줘 긴 글', 'builtin-summarize', '긴 글'],
['요약해 주세요. 오늘 회의 내용', 'builtin-summarize', '오늘 회의 내용'],
['다듬어줘 메일 초안', 'builtin-formal', '메일 초안'],
['설명해줘 const x = 1', 'builtin-explain-code', 'const x = 1'],
['explain this "for (;;)"', 'builtin-explain-code', '"for (;;)"']
]
for (const [text, instructionId, cleaned] of cases) {
it(`"${text}" → ${instructionId}`, () => {
const found = findRuleMatch(text, defaultRules())
expect(found?.rule.instructionId).toBe(instructionId)
expect(found?.cleanedText).toBe(cleaned)
})
}
it('명령어만 말하고 내용이 없으면 명령으로 보지 않는다', () => {
expect(findRuleMatch('요약해줘.', defaultRules())).toBeNull()
expect(findRuleMatch('번역해줘', defaultRules())).toBeNull()
})
it('기본 키워드에 맨 명사가 없다', () => {
const all = DEFAULT_VOICE_COMMAND_KEYWORDS.flatMap((e) => e.keywords.map((k) => k.keyword))
for (const bare of ['요약', '설명', '번역', '영어로', '다듬기', 'explain', 'polish', 'translate', 'summarize']) {
expect(all).not.toContain(bare)
}
})
})
describe('matchKeyword: 잘라낸 자리의 구두점과 빈 내용', () => {
const table: Array<[string, string, 'prefix' | 'suffix' | 'contains', boolean, string]> = [
['짧게, 오늘 회의 내용', '짧게', 'prefix', true, '오늘 회의 내용'],
['짧게.', '짧게', 'prefix', false, '짧게.'],
['짧게 ...', '짧게', 'prefix', false, '짧게 ...'],
['짧게요 오늘', '짧게', 'prefix', false, '짧게요 오늘'],
['오늘 회의 내용, 번역해줘', '번역해줘', 'suffix', true, '오늘 회의 내용'],
['번역해줘', '번역해줘', 'suffix', false, '번역해줘'],
['이 메일 정중하게 써줘', '정중하게', 'contains', true, '이 메일 써줘'],
['정중하게', '정중하게', 'contains', false, '정중하게'],
['anything', '', 'prefix', false, 'anything']
]
for (const [text, keyword, mode, matched, cleaned] of table) {
it(`${mode} "${keyword}" in "${text}"`, () => {
expect(matchKeyword(text, keyword, mode)).toEqual({ matched, cleanedText: cleaned })
})
}
})
describe('레거시 기본값 이관', () => {
const legacy = (): VoiceCommandRule[] => [
{
id: 'a',
instructionId: 'builtin-summarize',
keywords: [
{ keyword: '요약해줘', matchMode: 'prefix' },
{ keyword: '요약', matchMode: 'prefix' },
{ keyword: 'summarize', matchMode: 'prefix' }
],
enabled: true,
priority: 1
},
{
id: 'b',
instructionId: 'builtin-translate',
keywords: [{ keyword: '영작', matchMode: 'prefix' }],
enabled: true,
priority: 0
}
]
it('손대지 않은 레거시 기본값만 교체하고 사용자 키워드는 둔다', () => {
const { rules, migrated } = migrateLegacyDefaultRules(legacy())
expect(migrated).toBe(1)
expect(rules[0].id).toBe('a')
expect(rules[0].keywords.map((k) => k.keyword)).not.toContain('요약')
expect(rules[1].keywords).toEqual([{ keyword: '영작', matchMode: 'prefix' }])
expect(migrateLegacyDefaultRules(rules).migrated).toBe(0)
})
it('서비스 초기화 때 저장된 레거시 기본값을 이관하고 저장한다', () => {
const store = memoryStore({ rules: legacy(), enabled: true })
const svc = createVoiceCommandService(store)
svc.initialize()
svc.initDefaultKeywords()
expect(svc.match('요약 보고서 첨부해서 보내드립니다.').matched).toBe(false)
const cmd = svc.match('요약해줘 긴 글')
expect(cmd.instructionId).toBe('builtin-summarize')
expect(cmd.cleanedText).toBe('긴 글')
expect(store.saved?.find((r) => r.id === 'a')?.keywords.map((k) => k.keyword)).not.toContain(
'요약'
)
})
it('이관할 것이 없으면 저장하지 않는다', () => {
const store = memoryStore({ rules: defaultRules(), enabled: true })
createVoiceCommandService(store).initialize()
expect(store.saved).toBeUndefined()
})
})