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