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,168 @@
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()
})
})