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

317 lines
13 KiB
TypeScript

// tests/main/services/suggestion-redteam-r3-1.test.ts
// 제안 서비스 회귀 (r3-1):
// - 케어렛을 모르는 문서 중간 편집 뒤에는 수락하지 않는다
// - 다른 기능의 짧은 keep_alive 로 모델이 내려갔으면 warm 이 아니다 / 시간 초과면 콜드로 본다
// - 입력 수집 동의가 없으면 모델을 데우지 않고, 철회하면 세션·보관 문맥을 버린다
// - 한 세션은 한 이력 행이다 (채우기 후보는 행을 늘리지 않고, 수락은 세션 행에 기록)
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PersonalPhrase } from '@d3ro/core/input-intelligence'
const config = vi.hoisted(() => ({
suggestionEnabled: true as boolean,
suggestionModelId: 'model-a' as string | null,
llmModelId: 'model-a' as string | null,
inputExcludedApps: [] as string[],
suggestionTriggerDelayMs: 600,
suggestionMinPrefixChars: 8,
suggestionMaxRequestsPerMinute: 12,
suggestionDailyBudget: 500,
suggestionRequestTimeoutMs: 8000,
inputLearnTypedText: false as boolean,
inputTelemetryEnabled: false as boolean,
inputTelemetryPaused: false as boolean,
suggestionOverlayInteractive: true,
insertMethod: 'clipboard'
}))
const harness = vi.hoisted(() => ({
isAvailable: vi.fn(() => true),
streamGenerate: vi.fn(),
insertText: vi.fn(async (text: string) => ({ success: true, method: 'clipboard', textLength: text.length })),
phrases: [] as PersonalPhrase[]
}))
vi.mock('../../../src/main/services/ConfigService', () => ({
configGet: vi.fn((key: keyof typeof config) => config[key]),
configSet: vi.fn((key: keyof typeof config, value: never) => {
config[key] = value
})
}))
vi.mock('../../../src/main/services/LoggerService', () => ({
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
}))
vi.mock('../../../src/main/services/LocalLLMService', () => ({
getLocalLLMService: () => ({ isAvailable: harness.isAvailable, streamGenerate: harness.streamGenerate })
}))
vi.mock('../../../src/main/services/InputTelemetryService', () => ({
getInputTelemetryService: () => ({ listPhrases: () => harness.phrases })
}))
vi.mock('../../../src/main/services/PersonalGraphService', () => ({
getPersonalGraphService: () => ({ retrieveContext: () => ({ continuations: [], related: [] }) })
}))
vi.mock('../../../src/main/services/TextInsertService', () => ({
getTextInsertService: () => ({ insertText: harness.insertText })
}))
vi.mock('../../../src/main/services/modifier-state', () => ({ waitForModifiersReleased: async () => true }))
vi.mock('../../../src/main/utils/win32-foreground', () => ({ getForegroundWindowInfo: () => null }))
vi.mock('../../../src/main/db', () => ({ getDatabase: vi.fn() }))
vi.mock('../../../src/main/db/schema', () => ({ suggestions: {} }))
import { SuggestionService, type SuggestionServiceDeps } from '../../../src/main/services/SuggestionService'
import { SuggestionBudget } from '../../../src/main/services/suggestion/SuggestionBudget'
import { ModelWarmTracker } from '../../../src/main/services/suggestion/ModelWarmTracker'
import type { TypingContext } from '../../../src/main/services/InputTelemetryService'
const PREFIX = '오늘 회의에서 논의한 내용을 정리해서'
const SUGGESTION = '다음 단계도 확인하겠습니다.'
function typingContext(overrides: Partial<TypingContext> = {}): TypingContext {
const prefix = overrides.prefix ?? PREFIX
return {
prefix,
fullText: prefix,
caretOffset: prefix.length,
anchor: { x: 100, y: 100, width: 2, height: 20 },
anchorKind: 'caret',
isPassword: false,
isEditable: true,
isComposing: false,
hasSelection: false,
available: true,
appName: 'Slack.exe',
windowTitle: 'general',
windowHandle: 101,
idleMs: 2000,
capturedAt: Date.now(),
editedSinceFocus: true,
typedRecently: true,
...overrides
}
}
interface Fakes {
deps: SuggestionServiceDeps
record: ReturnType<typeof vi.fn>
markAccepted: ReturnType<typeof vi.fn>
expectInsert: ReturnType<typeof vi.fn>
recordAccepted: ReturnType<typeof vi.fn>
}
function fakes(): Fakes {
const record = vi.fn()
const markAccepted = vi.fn()
const expectInsert = vi.fn(() => vi.fn())
const recordAccepted = vi.fn()
return {
record,
markAccepted,
expectInsert,
recordAccepted,
deps: {
repository: { record, markAccepted, list: vi.fn(() => []) },
budget: new SuggestionBudget(),
warmth: new ModelWarmTracker(),
foreground: { currentWindowHandle: () => 101 },
learning: { expectProgrammaticInsert: expectInsert, recordAccepted }
}
}
}
function streamOf(text: string): AsyncGenerator<string> {
return (async function* () {
yield text
})()
}
/** 호출자가 풀어 줄 때까지 기다리는 스트림 — abort 되면 예외로 끝난다. */
function gatedStream(signal: AbortSignal | undefined, gate: Promise<string>): AsyncGenerator<string> {
return (async function* () {
const text = await new Promise<string>((resolve, reject) => {
signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
void gate.then(resolve)
})
yield text
})()
}
interface Internal {
_generate(prefix: string, context: TypingContext, maxCandidates: number, maxChars: number): Promise<void>
_lastContext: TypingContext | null
_inFlight: boolean
_warmUpPromise: Promise<void> | null
}
let services: SuggestionService[] = []
function create(deps: SuggestionServiceDeps, warm = true): SuggestionService {
if (warm) deps.warmth.noteWarm('model-a', Date.now() + 600_000)
const service = new SuggestionService(deps)
services.push(service)
return service
}
import { ModelResidencyLedger, parseKeepAliveMs } from '../../../src/main/services/llm/model-residency'
beforeEach(() => {
config.suggestionEnabled = true
config.suggestionModelId = 'model-a'
config.llmModelId = 'model-a'
config.suggestionRequestTimeoutMs = 8000
config.inputLearnTypedText = false
config.inputTelemetryEnabled = false
config.inputTelemetryPaused = false
harness.isAvailable.mockReset()
harness.isAvailable.mockReturnValue(true)
harness.streamGenerate.mockReset()
harness.insertText.mockClear()
harness.phrases = []
})
afterEach(() => {
for (const service of services) service.dispose()
services = []
})
async function flush(): Promise<void> {
for (let i = 0; i < 8; i += 1) await new Promise((resolve) => setTimeout(resolve, 0))
}
describe('케어렛을 모르는 필드 (item 3)', () => {
it('문서 중간 편집 뒤(caretReliable=false)에는 표시 중인 후보를 수락하지 않는다', async () => {
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const fake = fakes()
const service = create(fake.deps)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
expect(service.isVisible).toBe(true)
const internal = service as unknown as Internal
internal._lastContext = typingContext({ caretOffset: null, caretReliable: false })
const result = await service.accept()
expect(result).toEqual({ ok: false, reason: 'caret-unknown' })
expect(harness.insertText).not.toHaveBeenCalled()
})
it('케어렛을 모르는 문맥이 오면 세션을 닫는다', async () => {
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const fake = fakes()
const service = create(fake.deps)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
service.handleTypingContext(typingContext({ caretOffset: null, caretReliable: false }))
expect(service.isVisible).toBe(false)
expect(service.getState().lastSkipReason).toBe('caret-unknown')
})
})
describe('모델 상주 추정 (item 4)', () => {
it('keep_alive 문자열을 해석한다', () => {
expect(parseKeepAliveMs('10m')).toBe(600_000)
expect(parseKeepAliveMs('2m')).toBe(120_000)
expect(parseKeepAliveMs(undefined)).toBe(300_000)
expect(parseKeepAliveMs('1h30m')).toBe(5_400_000)
})
it('같은 모델을 더 짧은 keep_alive 로 쓴 요청이 있으면 그 만료 뒤에는 warm 이 아니다', () => {
const ledger = new ModelResidencyLedger()
const tracker = new ModelWarmTracker(ledger)
const t0 = 1_000_000
ledger.noteRequest('model-a', '10m', t0)
tracker.noteWarm('model-a', t0 + 570_000)
// 1분 뒤 음성 대화가 같은 모델을 keep_alive 2m 로 사용
ledger.noteRequest('model-a', '2m', t0 + 60_000)
expect(tracker.isWarm('model-a', t0 + 120_000)).toBe(true)
expect(tracker.isWarm('model-a', t0 + 6 * 60_000)).toBe(false)
})
it('warm 이라 믿은 요청이 시간 초과하면 콜드로 본다 (다음 멈춤은 워밍업)', async () => {
config.suggestionRequestTimeoutMs = 20
harness.streamGenerate.mockImplementation((_text: string, options: { signal?: AbortSignal }) =>
gatedStream(options.signal, new Promise<string>(() => undefined))
)
const fake = fakes()
const service = create(fake.deps)
expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(true)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
expect(fake.deps.warmth.isWarm('model-a', Date.now())).toBe(false)
})
})
describe('입력 수집 동의 (item 7)', () => {
it('동의 없이 제안을 켜면 모델을 데우지 않는다', async () => {
const fake = fakes()
const service = create(fake.deps, false)
service.setEnabled(true)
await flush()
expect(harness.streamGenerate).not.toHaveBeenCalled()
})
it('동의를 철회하면 떠 있는 세션을 닫고 보관 문맥을 버린다 (수동 요청이 옛 접두로 생성하지 않음)', async () => {
config.inputTelemetryEnabled = true
harness.streamGenerate.mockImplementation(() => streamOf(SUGGESTION))
const fake = fakes()
const service = create(fake.deps)
service.handleTypingContext(typingContext())
await flush()
expect(service.isVisible).toBe(true)
config.inputTelemetryEnabled = false
harness.streamGenerate.mockClear()
service.handleInputConsentChanged()
expect(service.isVisible).toBe(false)
expect(await service.requestNow()).toEqual({ ok: false, reason: 'not-editable' })
expect(harness.streamGenerate).not.toHaveBeenCalled()
})
})
describe('제안 이력은 세션당 한 행 (item 8)', () => {
function sessionRepository() {
return {
record: vi.fn(() => 'row-1'),
updateSessionCandidates: vi.fn(),
markSessionAccepted: vi.fn(),
markAccepted: vi.fn(),
list: vi.fn(() => [])
}
}
it('채우기 후보는 새 행(지연 0)을 만들지 않고 세션 행의 후보 수만 갱신한다', async () => {
const outputs = ['첫 번째 후보 문장입니다.', '두 번째 후보 문장입니다.', '세 번째 후보 문장입니다.']
let call = 0
harness.streamGenerate.mockImplementation(() => streamOf(outputs[Math.min(call++, outputs.length - 1)]))
const fake = fakes()
const repository = sessionRepository()
fake.deps.repository = repository
const service = create(fake.deps)
await (service as unknown as Internal)._generate(PREFIX, typingContext(), 3, 160)
await flush()
expect(service.getState().candidates.length).toBeGreaterThanOrEqual(2)
expect(repository.record).toHaveBeenCalledTimes(1)
expect(repository.updateSessionCandidates).toHaveBeenCalledWith('row-1', expect.any(Number))
})
it('로컬 기억 세션에서 2번째 후보를 수락하면 그 세션 행에 기록한다', async () => {
config.suggestionModelId = null
config.llmModelId = null
harness.phrases = [
{ id: 'p1', phrase: '다음 단계도 확인하겠습니다.', count: 3, score: 3, lastUsedAt: Date.now(), appName: null, source: 'typed' },
{ id: 'p2', phrase: '회의록은 내일 공유드리겠습니다.', count: 2, score: 2, lastUsedAt: Date.now(), appName: null, source: 'typed' }
] as unknown as typeof harness.phrases
const fake = fakes()
const repository = sessionRepository()
fake.deps.repository = repository
const service = create(fake.deps, false)
const context = typingContext({ prefix: '오늘 회의 정리했습니다.' })
const internal = service as unknown as Internal
internal._lastContext = context
await internal._generate(context.prefix, context, 3, 160)
const state = service.getState()
expect(state.candidates.length).toBeGreaterThanOrEqual(2)
expect(repository.record).toHaveBeenCalledTimes(1)
const second = state.candidates[1].text
expect(await service.accept(1)).toEqual({ ok: true })
expect(repository.markSessionAccepted).toHaveBeenCalledWith('row-1', second)
expect(repository.markAccepted).not.toHaveBeenCalled()
})
})