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
317
apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts
Normal file
317
apps/desktop/tests/main/services/suggestion-redteam-r3-1.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue