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:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue