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
46
apps/desktop/tests/main/index-activate-redteam-r3-4.test.ts
Normal file
46
apps/desktop/tests/main/index-activate-redteam-r3-4.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// redteam r3-4 회귀: macOS Dock 아이콘 클릭('activate')이 트레이로 숨긴 메인 창을 다시 띄운다.
|
||||
// 예전엔 'activate' 리스너가 없어 closeToTray(기본 true)로 창을 닫은 뒤 Dock 으로 돌아올 수 없었다.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Listener = (...args: unknown[]) => void
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
appListeners: new Map<string, Listener>(),
|
||||
showMainWindow: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
on: vi.fn((event: string, listener: Listener) => {
|
||||
state.appListeners.set(event, listener)
|
||||
}),
|
||||
// 부트스트랩은 이 테스트의 관심사가 아니다 — ready 를 오지 않게 둔다
|
||||
whenReady: vi.fn(() => new Promise<void>(() => undefined)),
|
||||
setAsDefaultProtocolClient: vi.fn(),
|
||||
requestSingleInstanceLock: vi.fn(() => true),
|
||||
disableHardwareAcceleration: vi.fn(),
|
||||
commandLine: { appendSwitch: vi.fn() },
|
||||
exit: vi.fn(),
|
||||
getPath: vi.fn(() => '.'),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/app-identity', () => ({ applyAppIdentity: vi.fn() }))
|
||||
vi.mock('../../src/main/bootstrap', () => ({ bootstrap: vi.fn(async () => undefined) }))
|
||||
vi.mock('../../src/main/lifecycle', () => ({ setupLifecycle: vi.fn() }))
|
||||
vi.mock('../../src/main/windows/WindowManager', () => ({ showMainWindow: state.showMainWindow }))
|
||||
vi.mock('../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
describe("app 'activate' (macOS Dock 클릭)", () => {
|
||||
it('메인 창을 다시 띄운다', async () => {
|
||||
await import('../../src/main/index')
|
||||
|
||||
const activate = state.appListeners.get('activate')
|
||||
expect(activate).toBeTypeOf('function')
|
||||
activate?.()
|
||||
expect(state.showMainWindow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
138
apps/desktop/tests/main/ipc/ipc-redteam-r3-1.test.ts
Normal file
138
apps/desktop/tests/main/ipc/ipc-redteam-r3-1.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// tests/main/ipc/ipc-redteam-r3-1.test.ts
|
||||
// IPC 회귀 (r3-1):
|
||||
// - caption:stop 은 사용자 자막만 멈추고 회의가 소유한 자막은 거부한다 (items 13·41)
|
||||
// - stt:setModel 은 설치된 모델만 올린다 — 미설치 모델은 설정만 저장 (item 10)
|
||||
// - 입력 수집 동의·일시정지 변경을 제안 서비스에 알린다 (item 7)
|
||||
// - realtime-token 은 주입된 자격 증명 포트로 호출한다 (item 2)
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
|
||||
const handlers = vi.hoisted(() => new Map<string, (event: unknown, params?: unknown) => Promise<unknown>>())
|
||||
|
||||
const caption = vi.hoisted(() => ({ stop: vi.fn(), start: vi.fn(), getState: vi.fn(() => 'active') }))
|
||||
const stt = vi.hoisted(() => ({ initialize: vi.fn(async () => undefined), isModelInstalled: vi.fn(() => false), on: vi.fn() }))
|
||||
const sttManager = vi.hoisted(() => ({ getActiveProvider: vi.fn(() => 'local'), on: vi.fn() }))
|
||||
const config = vi.hoisted(() => ({ configGet: vi.fn(), configSet: vi.fn() }))
|
||||
const telemetry = vi.hoisted(() => ({ setEnabled: vi.fn(), setPaused: vi.fn(), getState: vi.fn(() => ({})) }))
|
||||
const suggestion = vi.hoisted(() => ({ handleInputConsentChanged: vi.fn(), applyConfig: vi.fn() }))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn((channel: string, handler: (event: unknown, params?: unknown) => Promise<unknown>) => {
|
||||
handlers.set(channel, handler)
|
||||
}),
|
||||
on: vi.fn(),
|
||||
},
|
||||
session: { defaultSession: { setDisplayMediaRequestHandler: vi.fn() } },
|
||||
desktopCapturer: { getSources: vi.fn(async () => []) },
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({ getCaptionService: () => caption }))
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({
|
||||
endCaptionOverlayDrag: vi.fn(),
|
||||
resetCaptionOverlayPosition: vi.fn(),
|
||||
setCaptionOverlayInteractive: vi.fn(),
|
||||
startCaptionOverlayDrag: vi.fn(),
|
||||
getMainWindow: vi.fn(() => null),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({ getLocalSTTService: () => stt }))
|
||||
vi.mock('../../../src/main/services/RuntimeProvisioner', () => ({ getRuntimeProvisioner: () => ({ on: vi.fn() }) }))
|
||||
vi.mock('../../../src/main/services/stt/STTManager', () => ({ getSTTManager: () => sttManager }))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => config)
|
||||
vi.mock('../../../src/main/services/InputTelemetryService', () => ({ getInputTelemetryService: () => telemetry }))
|
||||
vi.mock('../../../src/main/services/PersonalGraphService', () => ({ getPersonalGraphService: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/SuggestionService', () => ({ getSuggestionService: () => suggestion }))
|
||||
vi.mock('../../../src/main/services/VoiceConversationService', () => ({ getVoiceConversationService: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/CloudSyncService', () => ({
|
||||
getCloudSyncService: () => {
|
||||
throw new Error('handlers must use the injected credentials port')
|
||||
},
|
||||
}))
|
||||
|
||||
async function invoke(channel: string, params?: unknown): Promise<{ success: boolean; error?: { code: number } }> {
|
||||
const handler = handlers.get(channel)
|
||||
if (!handler) throw new Error(`handler not registered: ${channel}`)
|
||||
return (await handler({}, params)) as { success: boolean; error?: { code: number } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
handlers.clear()
|
||||
})
|
||||
|
||||
describe('caption:stop 소유자 확인', () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../../src/main/ipc/caption-handlers')
|
||||
mod.registerCaptionHandlers()
|
||||
})
|
||||
|
||||
it("owner 'user' 로만 멈춘다", async () => {
|
||||
caption.stop.mockResolvedValue(null)
|
||||
expect((await invoke(IPC_CHANNELS.CAPTION.STOP)).success).toBe(true)
|
||||
expect(caption.stop).toHaveBeenCalledWith('user')
|
||||
})
|
||||
|
||||
it('회의가 소유한 자막이면 거부한다 (강제 정지하지 않음)', async () => {
|
||||
caption.stop.mockResolvedValue(false)
|
||||
const result = await invoke(IPC_CHANNELS.CAPTION.STOP)
|
||||
expect(result.success).toBe(false)
|
||||
expect(caption.stop).not.toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stt:setModel 미설치 모델', () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../../src/main/ipc/stt-handlers')
|
||||
mod.registerSTTHandlers()
|
||||
})
|
||||
|
||||
it('미설치 모델은 설정만 저장하고 사이드카에 올리지 않는다', async () => {
|
||||
stt.isModelInstalled.mockReturnValue(false)
|
||||
const result = await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'large-v3' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(config.configSet).toHaveBeenCalledWith('sttModelId', 'large-v3')
|
||||
expect(stt.initialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('설치된 모델은 바로 올린다', async () => {
|
||||
stt.isModelInstalled.mockReturnValue(true)
|
||||
await invoke(IPC_CHANNELS.STT.SET_MODEL, { modelId: 'small' })
|
||||
expect(stt.initialize).toHaveBeenCalledWith('small')
|
||||
})
|
||||
})
|
||||
|
||||
describe('입력 수집 동의 변경', () => {
|
||||
beforeEach(async () => {
|
||||
const mod = await import('../../../src/main/ipc/input-telemetry-handlers')
|
||||
mod.registerInputTelemetryHandlers()
|
||||
})
|
||||
|
||||
it('동의를 끄면 제안 서비스에 알린다', async () => {
|
||||
await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_ENABLED, { enabled: false })
|
||||
expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('일시정지도 제안 서비스에 알린다', async () => {
|
||||
await invoke(IPC_CHANNELS.INPUT_TELEMETRY.SET_PAUSED, { paused: true })
|
||||
expect(suggestion.handleInputConsentChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('realtime-token 은 자격 증명 포트를 쓴다', () => {
|
||||
it('로그아웃 상태의 포트가 error 를 주면 실패로 돌려준다', async () => {
|
||||
const mod = await import('../../../src/main/ipc/voice-conversation-handlers')
|
||||
const invokeFn = vi.fn(async () => ({ data: null, error: { message: 'No active session' } }))
|
||||
mod.registerVoiceConversationHandlers({
|
||||
hasCredentials: () => false,
|
||||
accessToken: async () => null,
|
||||
invoke: invokeFn,
|
||||
invokeStream: async () => ({ stream: null, error: { message: 'No active session' } }),
|
||||
})
|
||||
const result = await invoke(IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN, {})
|
||||
expect(result.success).toBe(false)
|
||||
expect(invokeFn).toHaveBeenCalledWith('realtime-token', {})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
// tests/main/services/dictionary-file-codec.test.ts
|
||||
// 사전 파일 형식(CSV·JSON) 순수 코덱 — DictionaryService 에서 분리한 로직의 단위 테스트
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
DICTIONARY_CSV_HEADER,
|
||||
parseDictionaryFile,
|
||||
serializeDictionary,
|
||||
toDictionaryImportRow
|
||||
} from '../../../src/main/services/dictionary/dictionary-file-codec'
|
||||
import type { DictionaryEntry } from '@d3ro/core/types'
|
||||
|
||||
const entry: DictionaryEntry = {
|
||||
id: 'e1',
|
||||
word: '=SUM, "quoted"',
|
||||
pronunciation: null,
|
||||
category: 'technical',
|
||||
usageCount: 3,
|
||||
lastUsedAt: null,
|
||||
createdAt: 100,
|
||||
updatedAt: 200
|
||||
}
|
||||
|
||||
describe('serializeDictionary', () => {
|
||||
it('CSV 는 BOM·헤더·CRLF 를 쓰고 수식·따옴표를 이스케이프한다', () => {
|
||||
const csv = serializeDictionary([entry], 'csv')
|
||||
expect(csv.startsWith(`\uFEFF${DICTIONARY_CSV_HEADER.join(',')}\r\n`)).toBe(true)
|
||||
expect(csv).toContain('"=SUM, ""quoted""",,technical,3,100,200\r\n')
|
||||
})
|
||||
|
||||
it('JSON 은 entries 객체로 쓴다', () => {
|
||||
expect(JSON.parse(serializeDictionary([entry], 'json'))).toEqual({ entries: [entry] })
|
||||
})
|
||||
|
||||
it('CSV 로 쓴 내용을 다시 읽으면 같은 값이 나온다', () => {
|
||||
const [record] = parseDictionaryFile(serializeDictionary([entry], 'csv'), 'csv')
|
||||
expect(record).toMatchObject({ word: entry.word, category: 'technical', usageCount: '3' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDictionaryFile', () => {
|
||||
it('JSON 배열과 entries 객체를 모두 받는다', () => {
|
||||
expect(parseDictionaryFile('[{"word":"a"}, 1, null]', 'json')).toEqual([{ word: 'a' }])
|
||||
expect(parseDictionaryFile('{"entries":[{"word":"b"}]}', 'json')).toEqual([{ word: 'b' }])
|
||||
})
|
||||
|
||||
it('형식이 맞지 않으면 Error 를 던진다', () => {
|
||||
expect(() => parseDictionaryFile('{"x":1}', 'json')).toThrow(/entries/)
|
||||
expect(() => parseDictionaryFile('', 'csv')).toThrow(/empty CSV/)
|
||||
expect(() => parseDictionaryFile('term\r\nx\r\n', 'csv')).toThrow(/word/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toDictionaryImportRow', () => {
|
||||
it('필드를 정규화하고 기본값을 채운다', () => {
|
||||
expect(
|
||||
toDictionaryImportRow(
|
||||
{ word: ' AI ', category: 'bogus', usage_count: '2.7', created_at: 50 },
|
||||
999
|
||||
)
|
||||
).toEqual({
|
||||
ok: true,
|
||||
row: {
|
||||
word: 'AI',
|
||||
pronunciation: null,
|
||||
category: 'user',
|
||||
usageCount: 2,
|
||||
lastUsedAt: null,
|
||||
createdAt: 50,
|
||||
updatedAt: 50
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('빈 단어·너무 긴 값을 문제로 돌려준다', () => {
|
||||
expect(toDictionaryImportRow({ pronunciation: 'x' }, 1)).toMatchObject({
|
||||
ok: false,
|
||||
problem: { reason: 'empty-word' }
|
||||
})
|
||||
expect(toDictionaryImportRow({ word: 'w'.repeat(121) }, 1)).toMatchObject({
|
||||
ok: false,
|
||||
problem: { reason: 'too-long', field: 'word', max: 120 }
|
||||
})
|
||||
expect(toDictionaryImportRow({ word: 'ok', pronunciation: 'p'.repeat(201) }, 1)).toMatchObject({
|
||||
ok: false,
|
||||
problem: { reason: 'too-long', field: 'pronunciation', max: 200 }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
// tests/main/services/dictionary-redteam-r3-26.test.ts
|
||||
// 서버 길이 제한(단어 120·발음 200)을 넘는 사전 항목은 로컬 저장·동기화 push 전에 거부돼야 한다.
|
||||
// 예전엔 로컬에 저장한 뒤 pushOne 했고, 서버가 22023 으로 거부해 아웃박스에 영구 보류됐다.
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { ErrorCode } from '@d3ro/core/errors'
|
||||
|
||||
const readFileSync = vi.fn()
|
||||
const pushOne = vi.fn(async () => undefined)
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
default: { writeFileSync: vi.fn(), readFileSync },
|
||||
writeFileSync: vi.fn(),
|
||||
readFileSync
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => '/tmp/d3ro' },
|
||||
dialog: { showSaveDialog: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/windows/WindowManager', () => ({ getMainWindow: () => null }))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/CloudSyncService', () => ({
|
||||
getCloudSyncService: () => ({ pushOne, pushDelete: vi.fn() })
|
||||
}))
|
||||
|
||||
function chain(result: { all?: unknown[]; get?: unknown }): Record<string, unknown> {
|
||||
const builder: Record<string, unknown> = {}
|
||||
builder.from = vi.fn(() => builder)
|
||||
builder.where = vi.fn(() => builder)
|
||||
builder.all = vi.fn(() => result.all ?? [])
|
||||
builder.get = vi.fn(() => result.get)
|
||||
return builder
|
||||
}
|
||||
|
||||
const existingRow = {
|
||||
id: 'row-1',
|
||||
word: 'AI',
|
||||
pronunciation: null,
|
||||
category: 'user',
|
||||
usageCount: 0,
|
||||
lastUsedAt: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
|
||||
const insertValues = vi.fn((_values: Record<string, unknown>) => ({
|
||||
run: vi.fn(() => ({ changes: 1 }))
|
||||
}))
|
||||
const updateSet = vi.fn(() => ({ where: vi.fn(() => ({ run: vi.fn(() => ({ changes: 1 })) })) }))
|
||||
|
||||
const txStub = {
|
||||
select: vi.fn(() => chain({ get: undefined })),
|
||||
insert: vi.fn(() => ({ values: insertValues }))
|
||||
}
|
||||
|
||||
const mockDb = {
|
||||
select: vi.fn(() => chain({ all: [], get: existingRow })),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: updateSet })),
|
||||
transaction: vi.fn((fn: (tx: typeof txStub) => void) => fn(txStub))
|
||||
}
|
||||
|
||||
vi.mock('../../../src/main/db', () => ({ getDatabase: () => mockDb }))
|
||||
|
||||
type ServiceModule = typeof import('../../../src/main/services/DictionaryService')
|
||||
let mod: ServiceModule
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
mod = await import('../../../src/main/services/DictionaryService')
|
||||
})
|
||||
|
||||
interface CapturedError {
|
||||
name: string
|
||||
message: string
|
||||
code: unknown
|
||||
details: unknown
|
||||
}
|
||||
|
||||
// vi.resetModules() 로 서비스가 다른 D3ROError 클래스 인스턴스를 쓰므로 instanceof 대신 모양으로 확인한다
|
||||
function captureError(fn: () => unknown): CapturedError {
|
||||
try {
|
||||
fn()
|
||||
} catch (err) {
|
||||
const captured = err as CapturedError
|
||||
expect(captured.name).toBe('D3ROError')
|
||||
return captured
|
||||
}
|
||||
throw new Error('expected a D3ROError')
|
||||
}
|
||||
|
||||
describe('DictionaryService.add — 서버 길이 제한', () => {
|
||||
it('121자 단어는 저장·push 없이 field/max 를 담아 거부한다', () => {
|
||||
const err = captureError(() => mod.getDictionaryService().add({ word: 'w'.repeat(121) }))
|
||||
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
|
||||
expect(err.details).toMatchObject({ field: 'word', max: 120 })
|
||||
expect(mockDb.insert).not.toHaveBeenCalled()
|
||||
expect(pushOne).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('201자 발음은 저장·push 없이 거부한다', () => {
|
||||
const err = captureError(() =>
|
||||
mod.getDictionaryService().add({ word: 'ok', pronunciation: 'p'.repeat(201) })
|
||||
)
|
||||
expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 })
|
||||
expect(mockDb.insert).not.toHaveBeenCalled()
|
||||
expect(pushOne).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('제한 이내 항목은 그대로 저장하고 push 한다', () => {
|
||||
const entry = mod.getDictionaryService().add({ word: 'w'.repeat(120), pronunciation: ' p ' })
|
||||
expect(entry.word).toBe('w'.repeat(120))
|
||||
expect(entry.pronunciation).toBe('p')
|
||||
expect(mockDb.insert).toHaveBeenCalledTimes(1)
|
||||
expect(pushOne).toHaveBeenCalledWith('dictionary', entry.id)
|
||||
})
|
||||
|
||||
it('빈 단어는 기존과 같은 메시지로 거부한다', () => {
|
||||
const err = captureError(() => mod.getDictionaryService().add({ word: ' ' }))
|
||||
expect(err.message).toBe('Dictionary word is empty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DictionaryService.update — 서버 길이 제한', () => {
|
||||
it('발음을 201자로 바꾸는 편집은 쓰기·push 없이 거부한다', () => {
|
||||
const err = captureError(() =>
|
||||
mod.getDictionaryService().update({ id: 'row-1', pronunciation: 'p'.repeat(201) })
|
||||
)
|
||||
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
|
||||
expect(err.details).toMatchObject({ field: 'pronunciation', max: 200 })
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
expect(pushOne).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('단어를 121자로 바꾸는 편집을 거부한다', () => {
|
||||
const err = captureError(() =>
|
||||
mod.getDictionaryService().update({ id: 'row-1', word: 'w'.repeat(121) })
|
||||
)
|
||||
expect(err.details).toMatchObject({ field: 'word', max: 120 })
|
||||
expect(mockDb.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DictionaryService.importDictionary — 서버 길이 제한', () => {
|
||||
it('너무 긴 단어·발음 행은 오류로 세고 저장·push 하지 않는다', () => {
|
||||
readFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
entries: [
|
||||
{ word: 'w'.repeat(121) },
|
||||
{ word: 'long-pron', pronunciation: 'p'.repeat(201) },
|
||||
{ word: 'ok', pronunciation: '오케이' }
|
||||
]
|
||||
})
|
||||
)
|
||||
const result = mod
|
||||
.getDictionaryService()
|
||||
.importDictionary({ filePath: '/in/d.json', format: 'json' })
|
||||
expect(result).toEqual({ imported: 1, skipped: 0, errors: 2 })
|
||||
expect(insertValues).toHaveBeenCalledTimes(1)
|
||||
expect(insertValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ word: 'ok', pronunciation: '오케이', category: 'user' })
|
||||
)
|
||||
expect(pushOne).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('CSV 의 너무 긴 행도 거부한다', () => {
|
||||
readFileSync.mockReturnValue(`word,pronunciation\r\n${'x'.repeat(121)},\r\nfine,\r\n`)
|
||||
const result = mod
|
||||
.getDictionaryService()
|
||||
.importDictionary({ filePath: '/in/d.csv', format: 'csv' })
|
||||
expect(result).toEqual({ imported: 1, skipped: 0, errors: 1 })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// tests/main/services/keybinding-altgr-redteam-r3-8.test.ts
|
||||
// 레드팀 r3-8 회귀 테스트 (데스크톱 어댑터) — AltGr 배열.
|
||||
//
|
||||
// Windows 는 AltGr 를 "가짜 LCtrl 눌림 → RAlt 눌림"(같은 time)으로 보내고, libuiohook 은 그
|
||||
// RAlt 눌림에 ctrlKey 를 싣는다. 어댑터가 그대로 옮기면 '@'(AltGr+Q) 마다 'command' 가 발동하고
|
||||
// 기본 dictation(RightAlt 단독)은 발동하지 않는다.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { uIOhook, UiohookKey } from 'uiohook-napi'
|
||||
import type { UiohookKeyboardEvent } from 'uiohook-napi'
|
||||
import { ALTGR_CHORD_GRACE_MS } from '@d3ro/core/keybinding-runtime'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import { getKeyBindingService } from '../../../src/main/services/KeyBindingService'
|
||||
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
|
||||
function setPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', { value: platform, configurable: true })
|
||||
}
|
||||
|
||||
function restorePlatform(): void {
|
||||
if (originalPlatform) Object.defineProperty(process, 'platform', originalPlatform)
|
||||
}
|
||||
|
||||
type KeyHandler = (e: UiohookKeyboardEvent) => void
|
||||
|
||||
function handlerOf(event: 'keydown' | 'keyup'): KeyHandler {
|
||||
const calls = vi.mocked(uIOhook.on).mock.calls.filter((c) => c[0] === event)
|
||||
const last = calls[calls.length - 1]
|
||||
if (!last) throw new Error(`no ${event} handler`)
|
||||
return last[1] as KeyHandler
|
||||
}
|
||||
|
||||
interface Mods {
|
||||
ctrl?: boolean
|
||||
alt?: boolean
|
||||
}
|
||||
|
||||
function keyEvent(keycode: number, time: number, mods: Mods = {}): UiohookKeyboardEvent {
|
||||
return {
|
||||
keycode,
|
||||
time,
|
||||
ctrlKey: mods.ctrl ?? false,
|
||||
altKey: mods.alt ?? false,
|
||||
shiftKey: false,
|
||||
metaKey: false
|
||||
} as unknown as UiohookKeyboardEvent
|
||||
}
|
||||
|
||||
describe('KeyBindingService — AltGr 배열 (Windows)', () => {
|
||||
const events: string[] = []
|
||||
const listener = (p: KeyBindingTriggerPayload): void => {
|
||||
events.push(`${p.actionId}:${p.type}`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
setPlatform('win32')
|
||||
initInMemoryConfig()
|
||||
vi.mocked(uIOhook.on).mockClear()
|
||||
events.length = 0
|
||||
const service = getKeyBindingService()
|
||||
service.loadFromConfig()
|
||||
service.start()
|
||||
service.on('triggered', listener)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const service = getKeyBindingService()
|
||||
service.off('triggered', listener)
|
||||
service.stop()
|
||||
resetInMemoryConfig()
|
||||
restorePlatform()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function altGrDown(time: number): void {
|
||||
handlerOf('keydown')(keyEvent(UiohookKey.Ctrl, time, { ctrl: true }))
|
||||
handlerOf('keydown')(keyEvent(UiohookKey.AltRight, time, { ctrl: true, alt: true }))
|
||||
}
|
||||
|
||||
function altGrUp(): void {
|
||||
handlerOf('keyup')(keyEvent(UiohookKey.Ctrl, 0, { alt: true }))
|
||||
handlerOf('keyup')(keyEvent(UiohookKey.AltRight, 0))
|
||||
}
|
||||
|
||||
it("AltGr+Q('@') 는 어떤 트리거도 내보내지 않는다", () => {
|
||||
altGrDown(5_000)
|
||||
handlerOf('keydown')(keyEvent(UiohookKey.Q, 5_040, { ctrl: true, alt: true }))
|
||||
handlerOf('keyup')(keyEvent(UiohookKey.Q, 5_080, { ctrl: true, alt: true }))
|
||||
altGrUp()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
|
||||
it('AltGr 를 누르고 있으면 기본 dictation 이 발동한다 (command 가 아니다)', () => {
|
||||
altGrDown(6_000)
|
||||
vi.advanceTimersByTime(ALTGR_CHORD_GRACE_MS)
|
||||
expect(events).toEqual(['dictation:pressed'])
|
||||
altGrUp()
|
||||
expect(events).toEqual(['dictation:pressed', 'dictation:released'])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
// tests/main/services/meeting-condense-redteam-r3-3.test.ts
|
||||
// 줄바꿈 없는 긴 전사(자막·파일 전사 히스토리, 폰 회의)도 map 조각이 프록시 한도 안에 들어가야 한다.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { condenseTranscriptToBudget } from '../../../src/main/services/meeting/transcript-condenser'
|
||||
|
||||
const PROXY_MESSAGE_LIMIT = 8_000
|
||||
|
||||
/** CaptionService fullText 처럼 세그먼트를 공백으로 이어 붙인 한 줄 전사 */
|
||||
const singleLine = Array.from({ length: 900 }, (_, i) => `${i}번째 발언에서 일정과 담당자를 정리했습니다.`).join(' ')
|
||||
|
||||
describe('condenseTranscriptToBudget — 한 줄짜리 긴 전사', () => {
|
||||
it('한 줄 20,000자 전사를 여러 조각으로 나눠 줄이고, 조각 입력은 모두 한도 안이다', async () => {
|
||||
expect(singleLine.includes('\n')).toBe(false)
|
||||
expect(singleLine.length).toBeGreaterThan(20_000)
|
||||
const inputs: string[] = []
|
||||
const llm = {
|
||||
generate: vi.fn(async (text: string) => {
|
||||
inputs.push(text)
|
||||
return { text: `요약 ${inputs.length}` }
|
||||
}),
|
||||
}
|
||||
const result = await condenseTranscriptToBudget(llm, singleLine, PROXY_MESSAGE_LIMIT)
|
||||
expect(result.condensed).toBe(true)
|
||||
expect(result.text.length).toBeLessThanOrEqual(PROXY_MESSAGE_LIMIT)
|
||||
expect(inputs.length).toBeGreaterThan(1)
|
||||
for (const input of inputs) expect(input.length).toBeLessThanOrEqual(PROXY_MESSAGE_LIMIT)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
// tests/main/services/runtime-provisioner-redteam-r3-5.test.ts
|
||||
// 런타임 인덱스의 플랫폼 구분과 "feed 가 앱보다 늦은" 경우의 회귀 테스트.
|
||||
//
|
||||
// 회귀 대상:
|
||||
// 1) runtime.json 에 platform/arch 가 없어 macOS 앱이 Windows 엔진(sidecar.exe)을 받아 풀고
|
||||
// 검증에서 떨어진 뒤, ensure 할 때마다 ~160MB 를 다시 받던 문제
|
||||
// 2) feed 버전이 앱의 RUNTIME_MIN_VERSION 보다 낮으면 설치된 엔진까지 버리고 로컬 STT 가
|
||||
// 완전히 멈추던 문제
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
RuntimeIndexRejectedError,
|
||||
isRuntimeIndexRejected,
|
||||
parseRuntimeIndex,
|
||||
} from '../../../src/main/services/runtime/runtime-index'
|
||||
import type { RuntimeFetch, RuntimeFetchResponse } from '../../../src/main/services/runtime/download-part'
|
||||
import { RuntimeProvisioner } from '../../../src/main/services/RuntimeProvisioner'
|
||||
|
||||
const FEED = 'https://feed.test/runtime-latest'
|
||||
const SHA = 'c'.repeat(64)
|
||||
const PART = 'd3ro-runtime-sidecar.tar.gz.001'
|
||||
|
||||
function index(version: string, target?: { platform: string; arch: string }): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
version,
|
||||
...(target ?? {}),
|
||||
components: {
|
||||
sidecar: {
|
||||
archive: 'd3ro-runtime-sidecar.tar.gz',
|
||||
sha256: SHA,
|
||||
totalSize: 10,
|
||||
parts: [{ name: PART, size: 10, sha256: SHA, url: `${FEED}/${PART}` }],
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function feed(body: Record<string, unknown>): { fetchImpl: RuntimeFetch; calls: string[] } {
|
||||
const calls: string[] = []
|
||||
const fetchImpl: RuntimeFetch = async (url) => {
|
||||
calls.push(url)
|
||||
if (url === `${FEED}/runtime.json`) {
|
||||
return new Response(JSON.stringify(body), { status: 200 }) as RuntimeFetchResponse
|
||||
}
|
||||
return new Response(new Uint8Array(10), { status: 200 }) as RuntimeFetchResponse
|
||||
}
|
||||
return { fetchImpl, calls }
|
||||
}
|
||||
|
||||
let workDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'd3ro-runtime-r3-5-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function provisioner(
|
||||
fetchImpl: RuntimeFetch,
|
||||
platform: NodeJS.Platform = 'win32',
|
||||
arch = 'x64',
|
||||
): RuntimeProvisioner {
|
||||
return new RuntimeProvisioner({
|
||||
userDataDir: () => workDir,
|
||||
fetchImpl,
|
||||
feedUrl: FEED,
|
||||
platform,
|
||||
arch,
|
||||
stallTimeoutMs: 1000,
|
||||
partAttempts: 1,
|
||||
})
|
||||
}
|
||||
|
||||
/** 예전에 받아 둔 엔진이 설치돼 있는 상태 (마커 버전 지정) */
|
||||
function installOldSidecar(p: RuntimeProvisioner, markerVersion: string): void {
|
||||
const dir = p.componentDir('sidecar')
|
||||
mkdirSync(join(dir, '_internal'), { recursive: true })
|
||||
writeFileSync(p.binaryPath('sidecar'), 'old engine')
|
||||
writeFileSync(join(dir, '.runtime-version'), markerVersion)
|
||||
}
|
||||
|
||||
describe('parseRuntimeIndex — 플랫폼 대상', () => {
|
||||
const WIN = { platform: 'win32', arch: 'x64' }
|
||||
|
||||
it('인덱스가 밝힌 플랫폼과 앱 플랫폼이 다르면 platform-mismatch 로 거부한다', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' })
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(caught).toBeInstanceOf(RuntimeIndexRejectedError)
|
||||
expect(isRuntimeIndexRejected(caught, 'platform-mismatch')).toBe(true)
|
||||
expect((caught as Error).message).toMatch(/darwin-arm64/)
|
||||
})
|
||||
|
||||
it('아키텍처만 달라도 거부한다', () => {
|
||||
expect(() =>
|
||||
parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', { platform: 'win32', arch: 'arm64' }),
|
||||
).toThrow(RuntimeIndexRejectedError)
|
||||
})
|
||||
|
||||
it('같은 플랫폼이면 통과하고, 대상을 넘기지 않으면 검사하지 않는다', () => {
|
||||
expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0', WIN).version).toBe('1.9.0')
|
||||
expect(parseRuntimeIndex(index('1.9.0', WIN), 'sidecar', '1.7.0').version).toBe('1.9.0')
|
||||
})
|
||||
|
||||
it('platform/arch 가 없는 예전 인덱스는 그대로 읽는다 (하위 호환)', () => {
|
||||
expect(parseRuntimeIndex(index('1.9.0'), 'sidecar', '1.7.0', { platform: 'darwin', arch: 'arm64' }).version).toBe(
|
||||
'1.9.0',
|
||||
)
|
||||
})
|
||||
|
||||
it('platform/arch 형식이 깨졌으면 형식 오류로 거부한다', () => {
|
||||
const broken = { ...index('1.9.0'), platform: 'win32', arch: 7 }
|
||||
let caught: unknown
|
||||
try {
|
||||
parseRuntimeIndex(broken, 'sidecar', '1.7.0', WIN)
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect((caught as Error).message).toMatch(/platform\/arch/)
|
||||
expect(isRuntimeIndexRejected(caught)).toBe(false)
|
||||
})
|
||||
|
||||
it('최소 버전 미만 feed 는 feed-outdated 로 구분된다', () => {
|
||||
let caught: unknown
|
||||
try {
|
||||
parseRuntimeIndex(index('1.6.0', WIN), 'sidecar', '1.7.0', WIN)
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(isRuntimeIndexRejected(caught, 'feed-outdated')).toBe(true)
|
||||
expect((caught as Error).message).toMatch(/최소 요구 버전/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeProvisioner — 다른 플랫폼 feed', () => {
|
||||
it('macOS 앱은 Windows 런타임 인덱스를 보면 부품을 받기 전에 실패한다', async () => {
|
||||
const f = feed(index('1.9.0', { platform: 'win32', arch: 'x64' }))
|
||||
const p = provisioner(f.fetchImpl, 'darwin', 'arm64')
|
||||
|
||||
await expect(p.ensure('sidecar')).rejects.toThrow(/이 플랫폼\(darwin-arm64\)용 런타임이 feed에 없습니다/)
|
||||
expect(f.calls).toEqual([`${FEED}/runtime.json`])
|
||||
expect(existsSync(join(workDir, 'runtime', '.download-sidecar'))).toBe(false)
|
||||
|
||||
// 다시 불러도 인덱스만 읽는다 — 대용량 재다운로드 루프가 없다
|
||||
f.calls.length = 0
|
||||
await expect(p.ensure('sidecar')).rejects.toThrow(RuntimeIndexRejectedError)
|
||||
expect(f.calls).toEqual([`${FEED}/runtime.json`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('RuntimeProvisioner — feed 가 앱의 최소 버전보다 낮을 때', () => {
|
||||
it('설치된 (낡은) 엔진이 있으면 그 경로를 돌려주고 부품을 받지 않는다', async () => {
|
||||
const f = feed(index('1.6.0'))
|
||||
const p = provisioner(f.fetchImpl)
|
||||
installOldSidecar(p, '1.6.0')
|
||||
|
||||
await expect(p.ensure('sidecar')).resolves.toBe(p.binaryPath('sidecar'))
|
||||
expect(f.calls).toEqual([`${FEED}/runtime.json`])
|
||||
expect(p.isInstalled('sidecar')).toBe(true)
|
||||
})
|
||||
|
||||
it('"다시 설치"(force)는 조용히 넘어가지 않고 실패를 알린다', async () => {
|
||||
const f = feed(index('1.6.0'))
|
||||
const p = provisioner(f.fetchImpl)
|
||||
installOldSidecar(p, '1.6.0')
|
||||
|
||||
await expect(p.ensure('sidecar', { force: true })).rejects.toThrow(/최소 요구 버전/)
|
||||
expect(p.isInstalled('sidecar')).toBe(true)
|
||||
})
|
||||
|
||||
it('설치된 엔진이 없으면 실패한다', async () => {
|
||||
const f = feed(index('1.6.0'))
|
||||
await expect(provisioner(f.fetchImpl).ensure('sidecar')).rejects.toThrow(/최소 요구 버전/)
|
||||
})
|
||||
|
||||
it('다른 이유의 실패(인덱스 404)는 설치된 엔진으로 덮지 않는다', async () => {
|
||||
const calls: string[] = []
|
||||
const fetchImpl: RuntimeFetch = async (url) => {
|
||||
calls.push(url)
|
||||
return new Response(null, { status: 404 }) as RuntimeFetchResponse
|
||||
}
|
||||
const p = provisioner(fetchImpl)
|
||||
installOldSidecar(p, '1.6.0')
|
||||
await expect(p.ensure('sidecar')).rejects.toThrow(/HTTP 404/)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// tests/main/services/stt-timeout-redteam-r3-1.test.ts
|
||||
// 최종 전사 응답 기한은 오디오 길이에 비례한다 (item 9) — 고정 120초가 몇 분짜리 핸즈프리 녹음을 버렸다.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { transcriptionTimeoutMs, PCM16_MONO_16K_BYTES_PER_SECOND } from '../../../src/main/services/stt/audio-utils'
|
||||
|
||||
describe('transcriptionTimeoutMs', () => {
|
||||
const options = { floorMs: 120_000, perAudioSecondMs: 3000 }
|
||||
|
||||
it('짧은 녹음은 하한(120초)을 쓴다', () => {
|
||||
expect(transcriptionTimeoutMs(PCM16_MONO_16K_BYTES_PER_SECOND * 5, options)).toBe(120_000)
|
||||
})
|
||||
|
||||
it('10분 녹음은 오디오 길이에 비례해 기다린다', () => {
|
||||
expect(transcriptionTimeoutMs(PCM16_MONO_16K_BYTES_PER_SECOND * 600, options)).toBe(1_800_000)
|
||||
})
|
||||
|
||||
it('음수·0 바이트는 하한', () => {
|
||||
expect(transcriptionTimeoutMs(0, options)).toBe(120_000)
|
||||
expect(transcriptionTimeoutMs(-5, options)).toBe(120_000)
|
||||
})
|
||||
})
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
// tests/main/services/voice-recording-store-redteam-r3-1.test.ts
|
||||
// 받아쓰기 녹음 저장 순서 회귀 (r3-1, items 11·12):
|
||||
// - 녹음 WAV 는 session-completed(히스토리 생성·history_audio 동기화 예약) 전에 저장된다
|
||||
// - 완료 이벤트는 실제 저장 경로를 싣고, 저장 실패·오디오 없음이면 null 이다
|
||||
// - 히스토리는 이벤트의 경로만 audioLocalPath 로 쓴다
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { EventEmitter } from 'events'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import type { KeyBindingTriggerPayload } from '../../../src/main/services/KeyBindingService'
|
||||
import { createFsRecordingStore, type RecordingStore } from '../../../src/main/services/recording/recording-store'
|
||||
import { pcmToWav } from '../../../src/main/services/stt/audio-utils'
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
type SttResult = { text: string; segments: never[]; language: string; duration: number; processingTime: number }
|
||||
const result = (text: string): SttResult => ({ text, segments: [], language: 'ko', duration: 1, processingTime: 1 })
|
||||
|
||||
const mockSTT = vi.hoisted(() => ({
|
||||
initialize: vi.fn(),
|
||||
transcribe: vi.fn(),
|
||||
transcribePartial: vi.fn(async () => ''),
|
||||
getModels: vi.fn(),
|
||||
getStatus: vi.fn(() => ({ engineState: 'ready', activeModel: 'base', engineVersion: null, gpuAccelerated: false })),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalSTTService', () => ({
|
||||
getLocalSTTService: () => mockSTT,
|
||||
resetLocalSTTServiceForTests: () => undefined,
|
||||
}))
|
||||
|
||||
const audioBus = new EventEmitter()
|
||||
const mockAudio = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn(), on: vi.fn(), off: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/AudioCaptureService', () => ({
|
||||
getAudioCaptureService: () => mockAudio,
|
||||
}))
|
||||
|
||||
const keyBinding = vi.hoisted(() => ({
|
||||
handler: null as ((payload: KeyBindingTriggerPayload) => void) | null,
|
||||
}))
|
||||
vi.mock('../../../src/main/services/KeyBindingService', () => ({
|
||||
getKeyBindingService: () => ({
|
||||
on: (_ev: string, fn: (payload: KeyBindingTriggerPayload) => void) => {
|
||||
keyBinding.handler = fn
|
||||
},
|
||||
off: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }))
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: vi.fn((key: string) => config.values[key]),
|
||||
}))
|
||||
|
||||
const mockInsert = vi.hoisted(() => ({ insertText: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/TextInsertService', () => ({
|
||||
getTextInsertService: () => mockInsert,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LocalLLMService', () => ({
|
||||
getLocalLLMService: () => ({ isAvailable: () => true }),
|
||||
}))
|
||||
|
||||
const gateway = vi.hoisted(() => ({ processText: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/llm/LlmGateway', () => ({
|
||||
getLlmGateway: () => gateway,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({
|
||||
getCaptionService: () => ({ getState: () => 'inactive', stop: vi.fn(), start: vi.fn() }),
|
||||
}))
|
||||
|
||||
const chain = vi.hoisted(() => ({ execute: vi.fn() }))
|
||||
vi.mock('../../../src/main/services/ChainService', () => ({
|
||||
getChainService: () => chain,
|
||||
}))
|
||||
|
||||
const instructions = vi.hoisted(() => ({
|
||||
byId: {} as Record<string, { id: string; name: string; prompt: string }>,
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CustomInstructionService', () => ({
|
||||
getCustomInstructionService: () => ({ getById: (id: string) => instructions.byId[id] ?? null }),
|
||||
}))
|
||||
|
||||
const voiceCommand = vi.hoisted(() => ({ instructionId: null as string | null }))
|
||||
vi.mock('../../../src/main/services/VoiceCommandService', () => ({
|
||||
getVoiceCommandService: () => ({
|
||||
isEnabled: () => voiceCommand.instructionId !== null,
|
||||
match: (text: string) =>
|
||||
voiceCommand.instructionId
|
||||
? { matched: true, ruleId: 'r', instructionId: voiceCommand.instructionId, cleanedText: text, matchedKeyword: 'k' }
|
||||
: { matched: false, ruleId: null, instructionId: null, cleanedText: text, matchedKeyword: null },
|
||||
}),
|
||||
}))
|
||||
|
||||
const screen = vi.hoisted(() => ({
|
||||
enabled: false,
|
||||
captureContext: vi.fn(),
|
||||
captureSelectedText: vi.fn(),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/ScreenContextService', () => ({
|
||||
getScreenContextService: () => ({
|
||||
isEnabled: () => screen.enabled,
|
||||
captureContext: screen.captureContext,
|
||||
captureSelectedText: screen.captureSelectedText,
|
||||
buildContextPrompt: (ctx: { appName: string | null; selectedText: string | null }) =>
|
||||
`[ctx app=${ctx.appName ?? '-'} sel=${ctx.selectedText ?? '-'}]\n`,
|
||||
}),
|
||||
}))
|
||||
|
||||
type VoiceModeModule = typeof import('../../../src/main/services/VoiceModeService')
|
||||
let getVoiceModeService: VoiceModeModule['getVoiceModeService']
|
||||
let createWithStore: VoiceModeModule['createVoiceModeServiceForTests']
|
||||
|
||||
const SPEECH = Buffer.alloc(16000 * 2) // 1초
|
||||
|
||||
async function flush(times = 8): Promise<void> {
|
||||
for (let i = 0; i < times; i++) await new Promise((r) => setImmediate(r))
|
||||
}
|
||||
|
||||
function advanceClock(ms: number): void {
|
||||
const base = Date.now()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(base + ms)
|
||||
}
|
||||
|
||||
function key(
|
||||
actionId: 'dictation' | 'hands-free',
|
||||
type: 'pressed' | 'released',
|
||||
isDoublePress: boolean,
|
||||
): KeyBindingTriggerPayload {
|
||||
return {
|
||||
actionId,
|
||||
type,
|
||||
isDoublePress,
|
||||
holdMode: actionId === 'dictation',
|
||||
timestamp: Date.now(),
|
||||
durationMs: 0,
|
||||
} as unknown as KeyBindingTriggerPayload
|
||||
}
|
||||
|
||||
function screenContext(appName: string): { context: { appName: string; windowTitle: string; selectedText: null; capturedAt: number }; selectedTextAttempted: boolean } {
|
||||
return { context: { appName, windowTitle: 'w', selectedText: null, capturedAt: 0 }, selectedTextAttempted: false }
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.resetModules()
|
||||
audioBus.removeAllListeners()
|
||||
config.values = { sttModelId: 'base', defaultLLMAction: 'none', autoInsert: true, llmBackend: 'local' }
|
||||
keyBinding.handler = null
|
||||
screen.enabled = false
|
||||
voiceCommand.instructionId = null
|
||||
instructions.byId = {}
|
||||
for (const fn of [
|
||||
mockSTT.initialize, mockSTT.transcribe, mockSTT.transcribePartial, mockSTT.getModels,
|
||||
mockAudio.start, mockAudio.stop, mockAudio.on, mockAudio.off, mockInsert.insertText,
|
||||
gateway.processText, chain.execute, screen.captureContext, screen.captureSelectedText,
|
||||
]) {
|
||||
fn.mockReset()
|
||||
}
|
||||
mockSTT.initialize.mockResolvedValue(undefined)
|
||||
mockSTT.transcribe.mockResolvedValue(result('기본 전사'))
|
||||
mockSTT.transcribePartial.mockResolvedValue('')
|
||||
mockSTT.getModels.mockReturnValue([
|
||||
{ id: 'base', name: 'Base', sizeBytes: 0, downloaded: true, languages: [], accuracy: 2, speed: 4 },
|
||||
])
|
||||
mockAudio.start.mockResolvedValue(undefined)
|
||||
mockAudio.stop.mockResolvedValue(undefined)
|
||||
mockAudio.on.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||
audioBus.on(ev, fn)
|
||||
})
|
||||
mockAudio.off.mockImplementation((ev: string, fn: (...args: unknown[]) => void) => {
|
||||
audioBus.off(ev, fn)
|
||||
})
|
||||
mockInsert.insertText.mockResolvedValue({ success: true, method: 'clipboard', textLength: 1, durationMs: 1 })
|
||||
gateway.processText.mockImplementation(async (text: string) => `LLM(${text})`)
|
||||
chain.execute.mockResolvedValue({ finalText: '체인 결과' })
|
||||
screen.captureContext.mockResolvedValue(screenContext('Code'))
|
||||
screen.captureSelectedText.mockResolvedValue('선택 문장')
|
||||
const mod = await import('../../../src/main/services/VoiceModeService')
|
||||
mod.resetVoiceModeServiceForTests()
|
||||
getVoiceModeService = mod.getVoiceModeService
|
||||
createWithStore = mod.createVoiceModeServiceForTests
|
||||
})
|
||||
|
||||
|
||||
async function recordAndStop(store: RecordingStore): Promise<{ order: string[]; completed: Array<{ audioPath: string | null }> }> {
|
||||
const order: string[] = []
|
||||
const completed: Array<{ audioPath: string | null }> = []
|
||||
const svc = createWithStore({
|
||||
save: async (sessionId, pcm) => {
|
||||
const saved = await store.save(sessionId, pcm)
|
||||
order.push('saved')
|
||||
return saved
|
||||
},
|
||||
})
|
||||
svc.on('session-completed', ({ audioPath }) => {
|
||||
order.push('completed')
|
||||
completed.push({ audioPath })
|
||||
})
|
||||
await svc.startSession('dictation')
|
||||
await vi.waitFor(() => expect(svc.isActive).toBe(true))
|
||||
audioBus.emit('audio-data', { buffer: SPEECH })
|
||||
advanceClock(3000)
|
||||
await svc.stopSession()
|
||||
await flush()
|
||||
return { order, completed }
|
||||
}
|
||||
|
||||
describe('녹음 저장 → 완료 순서', () => {
|
||||
it('WAV 저장이 끝난 뒤에 session-completed 가 나가고 실제 경로를 싣는다', async () => {
|
||||
let release: () => void = () => undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const store: RecordingStore = {
|
||||
save: async (sessionId) => {
|
||||
await gate
|
||||
return `/rec/${sessionId}.wav`
|
||||
},
|
||||
}
|
||||
const pending = recordAndStop(store)
|
||||
await flush()
|
||||
release()
|
||||
const { order, completed } = await pending
|
||||
expect(order).toEqual(['saved', 'completed'])
|
||||
expect(completed[0].audioPath).toMatch(/^\/rec\/.+\.wav$/)
|
||||
})
|
||||
|
||||
it('저장에 실패하면 audioPath 는 null 이다 (존재하지 않는 경로를 남기지 않음)', async () => {
|
||||
const { completed } = await recordAndStop({ save: async () => null })
|
||||
expect(completed).toEqual([{ audioPath: null }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fs 녹음 저장소', () => {
|
||||
it('pcmToWav 와 같은 바이트로 recordings/<id>.wav 에 쓴다', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-rec-r3-1-'))
|
||||
try {
|
||||
const store = createFsRecordingStore({
|
||||
resolveDir: async () => dir,
|
||||
mkdir: (target) => fs.promises.mkdir(target, { recursive: true }),
|
||||
writeFile: (target, data) => fs.promises.writeFile(target, data),
|
||||
})
|
||||
const pcm = Buffer.alloc(3200, 1)
|
||||
const saved = await store.save('abc', pcm)
|
||||
expect(saved).toBe(path.join(dir, 'abc.wav'))
|
||||
expect(fs.readFileSync(saved as string).equals(pcmToWav(pcm, 16000, 1, 16))).toBe(true)
|
||||
expect(await store.save('empty', Buffer.alloc(0))).toBeNull()
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('쓰기 실패는 예외 대신 null', async () => {
|
||||
const store = createFsRecordingStore({
|
||||
resolveDir: async () => '/nowhere',
|
||||
mkdir: async () => undefined,
|
||||
writeFile: async () => {
|
||||
throw new Error('EACCES')
|
||||
},
|
||||
})
|
||||
expect(await store.save('x', Buffer.alloc(10))).toBeNull()
|
||||
})
|
||||
})
|
||||
81
apps/desktop/tests/main/sync/audio-sync-redteam-r3-1.test.ts
Normal file
81
apps/desktop/tests/main/sync/audio-sync-redteam-r3-1.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// tests/main/sync/audio-sync-redteam-r3-1.test.ts
|
||||
// 동기화 회귀 (r3-1):
|
||||
// - 파일 전사 기록(audioLocalPath = 사용자가 고른 원본 미디어)은 '녹음 동기화'로 올리지 않는다
|
||||
// - 대기 중인 원격 삭제를 뒤늦은 upsert(예: 자동 제목)가 덮어쓰지 않는다
|
||||
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
|
||||
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
|
||||
import { history } from '../../../src/main/db/schema'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import { listLocalAudioOwners, localAudioPath } from '../../../src/main/services/sync/audio-sync'
|
||||
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
|
||||
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
let remote: FakeSyncRemote
|
||||
let engine: SyncEngine
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
bindTestDatabase(testDb.db, USER)
|
||||
initInMemoryConfig()
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
|
||||
remote = new FakeSyncRemote(USER)
|
||||
engine = new SyncEngine({ remote, userId: USER })
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'd3ro-audio-r3-1-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
engine.dispose()
|
||||
unbindTestDatabase()
|
||||
resetInMemoryConfig()
|
||||
testDb.close()
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function insertHistory(mode: 'dictation' | 'file-transcription', fileName: string): string {
|
||||
const id = crypto.randomUUID()
|
||||
const file = path.join(tmpDir, fileName)
|
||||
fs.writeFileSync(file, Buffer.from('RIFF....WAVEfmt fake media'))
|
||||
const now = Date.now()
|
||||
testDb.db
|
||||
.insert(history)
|
||||
.values({ id, originalText: 'spoken', duration: 2, mode, audioLocalPath: file, createdAt: now, updatedAt: now })
|
||||
.run()
|
||||
return id
|
||||
}
|
||||
|
||||
describe('파일 전사 원본은 녹음으로 올리지 않는다 (item 19)', () => {
|
||||
it('파일 전사 기록의 원본 미디어는 업로드 대상이 아니다', async () => {
|
||||
const fileId = insertHistory('file-transcription', 'meeting-video.mp4')
|
||||
const dictationId = insertHistory('dictation', 'dictation.wav')
|
||||
|
||||
expect(localAudioPath('history', fileId)).toBeNull()
|
||||
expect(localAudioPath('history', dictationId)).not.toBeNull()
|
||||
expect(listLocalAudioOwners('history')).toEqual([dictationId])
|
||||
|
||||
const result = await engine.runFullSync()
|
||||
expect(result.errors).toEqual([])
|
||||
expect(remote.objects.has(`audio/${USER}/desktop/history/${fileId}.mp4`)).toBe(false)
|
||||
expect(remote.objects.has(`audio/${USER}/desktop/history/${dictationId}.wav`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('대기 중인 삭제는 뒤늦은 upsert 에 덮이지 않는다 (item 37)', () => {
|
||||
it('delete 뒤에 같은 행의 upsert 가 와도 op 는 delete 로 남는다', () => {
|
||||
const id = crypto.randomUUID()
|
||||
enqueueChange('history', id, 'upsert')
|
||||
enqueueChange('history', id, 'delete')
|
||||
enqueueChange('history', id, 'upsert')
|
||||
expect(pendingOps('history').get(id)).toBe('delete')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
// tests/main/sync/cloud-credentials-redteam-r3-1.test.ts
|
||||
// 로그아웃이 오프라인·5xx 로 서버 폐기에 실패해도(auth-js 가 { error } 를 돌려주고 _removeSession 을 건너뜀)
|
||||
// 자격 증명 포트(Cloud STT·Premium·realtime-token)가 옛 계정 토큰을 더는 내주지 않는지 검증한다.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
|
||||
const USER_DATA = path.join(os.tmpdir(), `d3ro-cloudcred-r3-1-${process.pid}`)
|
||||
const TOKEN_FILE = path.join(USER_DATA, 'cloud-sync.token')
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
type AuthCallback = (event: string, session: unknown) => void
|
||||
const state = {
|
||||
currentUserId: '_local' as string | null,
|
||||
authCallback: null as AuthCallback | null,
|
||||
tierGate: null as Promise<void> | null,
|
||||
}
|
||||
return {
|
||||
state,
|
||||
db: {
|
||||
openForUser: (userId: string) => {
|
||||
state.currentUserId = userId
|
||||
return { created: false, dbPath: `/db/${userId}` }
|
||||
},
|
||||
openLocal: () => {
|
||||
state.currentUserId = '_local'
|
||||
return { created: false, dbPath: '/db/_local' }
|
||||
},
|
||||
},
|
||||
enqueueChange: (..._args: unknown[]) => undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const spies = vi.hoisted(() => ({
|
||||
refreshSession: null as null | ((...args: unknown[]) => Promise<unknown>),
|
||||
signOut: null as null | ((...args: unknown[]) => Promise<unknown>),
|
||||
clients: [] as Array<{ session: null | { access_token: string; user: { id: string } } }>,
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => path.join(os.tmpdir(), `d3ro-cloudcred-r3-1-${process.pid}`),
|
||||
getVersion: () => '1.0.0',
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plain: string) => Buffer.from(plain, 'utf-8'),
|
||||
decryptString: (data: Buffer) => data.toString('utf-8'),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/LoggerService', () => ({
|
||||
getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/ConfigService', () => ({
|
||||
configGet: () => undefined,
|
||||
configSet: vi.fn(),
|
||||
onConfigChanged: () => () => undefined,
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/db', () => ({
|
||||
LOCAL_USER_ID: '_local',
|
||||
openForUser: vi.fn((userId: string) => h.db.openForUser(userId)),
|
||||
openLocal: vi.fn(() => h.db.openLocal()),
|
||||
closeCurrent: vi.fn(() => {
|
||||
h.state.currentUserId = null
|
||||
}),
|
||||
getCurrentUserId: () => h.state.currentUserId,
|
||||
importLocalModeData: vi.fn(() => null),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/sync-outbox', () => ({
|
||||
enqueueChange: vi.fn((...args: unknown[]) => h.enqueueChange(...args)),
|
||||
getSyncState: () => null,
|
||||
setSyncState: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/SyncEngine', () => ({
|
||||
SyncEngine: class {
|
||||
on(): void {}
|
||||
dispose(): void {}
|
||||
async whenIdle(): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
async runFullSync() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
async flush() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
async pull() {
|
||||
return { pushed: 0, pulled: 0, deleted: 0, errors: [], changed: [] }
|
||||
}
|
||||
getStatus() {
|
||||
return { pending: 0, parked: 0 }
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../../src/main/services/sync/sync-adapters', () => ({
|
||||
SyncAbortedError: class SyncAbortedError extends Error {},
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/supabase-sync-remote', () => ({ SupabaseSyncRemote: class {} }))
|
||||
vi.mock('../../../src/main/services/sync/device-registration', () => ({
|
||||
checkInDesktopDevice: vi.fn(async () => ({ status: 'active', deviceId: 'dev-1' })),
|
||||
currentDeviceInfo: () => ({}),
|
||||
unregisterDesktopDevice: vi.fn(async () => undefined),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/sync-types', () => ({ realtimeTables: () => [] }))
|
||||
vi.mock('../../../src/main/services/sync/realtime-transport', () => ({ nodeRealtimeTransport: {} }))
|
||||
vi.mock('../../../src/main/services/sync/settings-sync', () => ({
|
||||
SETTINGS_ROW_ID: 'settings',
|
||||
SYNCED_CONFIG_KEYS: [],
|
||||
isApplyingRemoteSettings: () => false,
|
||||
}))
|
||||
vi.mock('../../../src/main/services/sync/audio-sync', () => ({ AUDIO_BUCKET: 'audio', listLocalAudioOwners: () => [] }))
|
||||
vi.mock('../../../src/main/windows/web-contents-hardening', () => ({ openExternalSafe: vi.fn(async () => true) }))
|
||||
vi.mock('../../../src/main/services/LicenseService', () => ({
|
||||
getLicenseService: () => ({ syncFromCloud: vi.fn(), resetToFree: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/VoiceModeService', () => ({
|
||||
getVoiceModeService: () => ({ isActive: false, cancelSession: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/MeetingModeService', () => ({
|
||||
getMeetingModeService: () => ({ isMeetingModeActive: () => false, stopRecording: vi.fn() }),
|
||||
}))
|
||||
vi.mock('../../../src/main/services/CaptionService', () => ({
|
||||
getCaptionService: () => ({ stop: vi.fn(async () => null) }),
|
||||
}))
|
||||
|
||||
vi.mock('@supabase/supabase-js', () => {
|
||||
const query = (): Record<string, unknown> => {
|
||||
const builder: Record<string, unknown> = {}
|
||||
for (const method of ['select', 'eq', 'order', 'limit']) builder[method] = () => builder
|
||||
builder.maybeSingle = async () => {
|
||||
if (h.state.tierGate) await h.state.tierGate
|
||||
return { data: null, error: null }
|
||||
}
|
||||
return builder
|
||||
}
|
||||
const channel = {
|
||||
on() {
|
||||
return channel
|
||||
},
|
||||
subscribe() {
|
||||
return channel
|
||||
},
|
||||
unsubscribe: async () => undefined,
|
||||
state: 'joined',
|
||||
}
|
||||
return {
|
||||
createClient: () => {
|
||||
const clientState = { session: null as null | { access_token: string; user: { id: string } } }
|
||||
spies.clients.push(clientState)
|
||||
return {
|
||||
auth: {
|
||||
refreshSession: async (...args: unknown[]) => {
|
||||
const result = (await spies.refreshSession!(...args)) as { data: { session: unknown } }
|
||||
if (result.data.session) clientState.session = result.data.session as typeof clientState.session
|
||||
return result
|
||||
},
|
||||
signOut: (...args: unknown[]) => spies.signOut!(...args),
|
||||
onAuthStateChange: (cb: (event: string, session: unknown) => void) => {
|
||||
h.state.authCallback = cb
|
||||
return { data: { subscription: { unsubscribe: () => undefined } } }
|
||||
},
|
||||
getSession: async () => ({ data: { session: clientState.session } }),
|
||||
stopAutoRefresh: vi.fn(async () => undefined),
|
||||
},
|
||||
from: () => query(),
|
||||
channel: () => channel,
|
||||
realtime: { setAuth: async () => undefined },
|
||||
functions: {
|
||||
invoke: vi.fn(async () => ({ data: { ok: true }, error: null })),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { getCloudSyncService, resetCloudSyncServiceForTests } from '../../../src/main/services/CloudSyncService'
|
||||
import { cloudSyncCredentials, createCloudSttGateway } from '../../../src/main/services/cloud/cloud-credentials'
|
||||
import { D3ROCloudDriver } from '../../../src/main/services/stt/drivers/D3ROCloudDriver'
|
||||
|
||||
function session() {
|
||||
return { access_token: 'at-user-1', refresh_token: 'rt-new', user: { id: 'user-1', email: 'u@example.test' } }
|
||||
}
|
||||
|
||||
describe('로그아웃 실패(retryable) 뒤 자격 증명 폐기', () => {
|
||||
beforeEach(() => {
|
||||
resetCloudSyncServiceForTests()
|
||||
vi.clearAllMocks()
|
||||
spies.clients.length = 0
|
||||
fs.rmSync(USER_DATA, { recursive: true, force: true })
|
||||
fs.mkdirSync(USER_DATA, { recursive: true })
|
||||
fs.writeFileSync(TOKEN_FILE, JSON.stringify({ v: 1, rt: 'rt-1', uid: 'user-1' }))
|
||||
h.state.currentUserId = 'user-1'
|
||||
h.state.tierGate = null
|
||||
spies.refreshSession = vi.fn(async () => ({ data: { session: session() }, error: null }))
|
||||
// auth-js: 네트워크 실패면 throw 하지 않고 { error } 를 돌려주며 클라이언트 세션을 지우지 않는다
|
||||
spies.signOut = vi.fn(async () => ({
|
||||
error: { name: 'AuthRetryableFetchError', status: 0, message: 'fetch failed' },
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetCloudSyncServiceForTests()
|
||||
fs.rmSync(USER_DATA, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('로그인 중에는 토큰과 Edge Function 호출이 가능하다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
expect(cloudSyncCredentials.hasCredentials()).toBe(true)
|
||||
await expect(cloudSyncCredentials.accessToken()).resolves.toBe('at-user-1')
|
||||
const result = await cloudSyncCredentials.invoke('realtime-token', {})
|
||||
expect(result.error).toBeNull()
|
||||
})
|
||||
|
||||
it('signOut 이 { error } 를 돌려줘도 접근자는 모두 null / no session 이다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
// 옛 클라이언트는 여전히 세션을 들고 있다(auth-js 동작 재현)
|
||||
expect(spies.clients[0].session).not.toBeNull()
|
||||
|
||||
await sync.signOut()
|
||||
|
||||
expect(sync.isAuthenticated()).toBe(false)
|
||||
expect(cloudSyncCredentials.hasCredentials()).toBe(false)
|
||||
await expect(sync.getAccessToken()).resolves.toBeNull()
|
||||
await expect(cloudSyncCredentials.accessToken()).resolves.toBeNull()
|
||||
const invoked = await cloudSyncCredentials.invoke('realtime-token', {})
|
||||
expect(invoked.error?.message).toMatch(/session/i)
|
||||
const streamed = await cloudSyncCredentials.invokeStream('llm-proxy', {})
|
||||
expect(streamed.stream).toBeNull()
|
||||
expect(streamed.error).not.toBeNull()
|
||||
})
|
||||
|
||||
it('실패한 signOut 뒤 옛 클라이언트는 자동 갱신을 멈추고 새 클라이언트로 바뀐다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
await sync.signOut()
|
||||
expect(spies.clients).toHaveLength(2)
|
||||
expect(spies.clients[1].session).toBeNull()
|
||||
})
|
||||
|
||||
it('Cloud STT 드라이버는 로그아웃 뒤 업로드하지 않는다', async () => {
|
||||
const sync = getCloudSyncService()
|
||||
await sync.init()
|
||||
await sync.signOut()
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
const driver = new D3ROCloudDriver({
|
||||
...createCloudSttGateway(),
|
||||
getSupabaseUrl: () => 'https://example.supabase.co',
|
||||
getAnonKey: () => 'anon',
|
||||
})
|
||||
await expect(driver.transcribe(Buffer.alloc(3200))).rejects.toThrow()
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
fetchSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
// 레드팀 r3-2: 다른 기기에서 재색인한 지식 문서가 이미 문서를 가진 데스크톱에도 반영된다.
|
||||
// - 원문이 바뀌었으면 청크를 교체하고(옛 임베딩 제거) 다시 색인한다
|
||||
// - 원문이 같으면(자기 push의 에코 등) 로컬 청크·임베딩을 건드리지 않는다
|
||||
// - 재업로드가 중간이면 잘린 청크로 덮지 않고 미뤘다가 완성본을 반영한다
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
|
||||
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
|
||||
import { ragChunks, ragDocuments } from '../../../src/main/db/schema'
|
||||
import { initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import {
|
||||
getCustomInstructionService,
|
||||
resetCustomInstructionServiceForTests,
|
||||
} from '../../../src/main/services/CustomInstructionService'
|
||||
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
|
||||
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
|
||||
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
|
||||
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
|
||||
import { planRemoteDocument, sameChunks, usableChunks } from '../../../src/main/services/rag/remote-document'
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
let remote: FakeSyncRemote
|
||||
let engine: SyncEngine
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
bindTestDatabase(testDb.db, USER)
|
||||
initInMemoryConfig()
|
||||
resetCustomInstructionServiceForTests()
|
||||
resetDictationTemplateServiceForTests()
|
||||
resetMeetingDocTemplateServiceForTests()
|
||||
resetRAGServiceForTests()
|
||||
getCustomInstructionService().initialize()
|
||||
// 임베딩 서버(Ollama)는 없다 — 원문만 저장되는 경로를 탄다.
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('fetch failed'))
|
||||
remote = new FakeSyncRemote(USER)
|
||||
engine = new SyncEngine({ remote, userId: USER })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
engine.dispose()
|
||||
unbindTestDatabase()
|
||||
resetInMemoryConfig()
|
||||
resetRAGServiceForTests()
|
||||
testDb.close()
|
||||
})
|
||||
|
||||
function localChunks(id: string): Array<{ content: string; embedding: string }> {
|
||||
return testDb.db
|
||||
.select()
|
||||
.from(ragChunks)
|
||||
.where(eq(ragChunks.documentId, id))
|
||||
.all()
|
||||
.sort((a, b) => a.chunkIndex - b.chunkIndex)
|
||||
.map((c) => ({ content: c.content, embedding: c.embedding }))
|
||||
}
|
||||
|
||||
function markEmbedded(id: string): void {
|
||||
testDb.db.update(ragChunks).set({ embedding: '[1,2]' }).where(eq(ragChunks.documentId, id)).run()
|
||||
testDb.db.update(ragDocuments).set({ indexed: true, indexedAt: 1 }).where(eq(ragDocuments.id, id)).run()
|
||||
}
|
||||
|
||||
/** 다른 데스크톱(A)의 재색인 push를 흉내: 행 upsert(updated_at 이동) → 청크 전부 교체 */
|
||||
function remoteReindex(id: string, chunks: string[], uploaded = chunks.length): void {
|
||||
remote.mobileUpdate('knowledge_documents', id, { chunk_count: chunks.length })
|
||||
const rows = remote.rows('knowledge_chunks')
|
||||
for (let i = rows.length - 1; i >= 0; i--) if (rows[i].document_id === id) rows.splice(i, 1)
|
||||
chunks.slice(0, uploaded).forEach((content, chunk_index) =>
|
||||
rows.push({ id: crypto.randomUUID(), document_id: id, chunk_index, content })
|
||||
)
|
||||
}
|
||||
|
||||
function uploadRest(id: string, chunks: string[], from: number): void {
|
||||
chunks.slice(from).forEach((content, offset) =>
|
||||
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index: from + offset, content })
|
||||
)
|
||||
}
|
||||
|
||||
async function seedSyncedDocument(chunks: string[]): Promise<string> {
|
||||
const id = crypto.randomUUID()
|
||||
remote.mobileInsert('knowledge_documents', { id, title: 'Doc', file_name: 'doc.md', file_type: 'md', chunk_count: chunks.length })
|
||||
chunks.forEach((content, chunk_index) =>
|
||||
remote.rows('knowledge_chunks').push({ id: crypto.randomUUID(), document_id: id, chunk_index, content })
|
||||
)
|
||||
await engine.runFullSync()
|
||||
expect(localChunks(id).map((c) => c.content)).toEqual(chunks)
|
||||
// 받은 직후 시작된 색인 시도(임베딩 서버 없음)가 끝난 뒤에 "이미 색인됨" 상태를 만든다
|
||||
for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0))
|
||||
markEmbedded(id)
|
||||
return id
|
||||
}
|
||||
|
||||
describe('재색인된 지식 문서 동기화', () => {
|
||||
it('다른 기기에서 원문이 바뀌면 이미 있는 문서의 청크를 교체하고 옛 임베딩을 버린다', async () => {
|
||||
const id = await seedSyncedDocument(['old intro', 'old body'])
|
||||
|
||||
remoteReindex(id, ['new intro', 'new body', 'new appendix'])
|
||||
await engine.pull()
|
||||
|
||||
const chunks = localChunks(id)
|
||||
expect(chunks.map((c) => c.content)).toEqual(['new intro', 'new body', 'new appendix'])
|
||||
// 옛 벡터가 새 원문에 붙어 검색되지 않는다(임베딩 서버가 없어 아직 비어 있다)
|
||||
expect(chunks.every((c) => c.embedding === '')).toBe(true)
|
||||
const doc = testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()
|
||||
expect(doc?.chunkCount).toBe(3)
|
||||
expect(doc?.indexed).toBe(false)
|
||||
})
|
||||
|
||||
it('원문이 같으면(에코·메타데이터만 변경) 로컬 청크와 임베딩을 그대로 둔다', async () => {
|
||||
const id = await seedSyncedDocument(['same a', 'same b'])
|
||||
|
||||
remote.mobileUpdate('knowledge_documents', id, { indexed: true })
|
||||
await engine.pull()
|
||||
|
||||
expect(localChunks(id)).toEqual([
|
||||
{ content: 'same a', embedding: '[1,2]' },
|
||||
{ content: 'same b', embedding: '[1,2]' },
|
||||
])
|
||||
expect(testDb.db.select().from(ragDocuments).where(eq(ragDocuments.id, id)).get()?.indexed).toBe(true)
|
||||
})
|
||||
|
||||
it('재업로드가 중간이면 잘린 청크로 덮지 않고, 완성되면 반영한다', async () => {
|
||||
const id = await seedSyncedDocument(['v1 a', 'v1 b'])
|
||||
const next = ['v2 a', 'v2 b', 'v2 c']
|
||||
|
||||
remoteReindex(id, next, 1)
|
||||
await engine.pull()
|
||||
expect(localChunks(id).map((c) => c.content)).toEqual(['v1 a', 'v1 b'])
|
||||
|
||||
uploadRest(id, next, 1)
|
||||
await engine.pull()
|
||||
expect(localChunks(id).map((c) => c.content)).toEqual(next)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planRemoteDocument', () => {
|
||||
it('로컬에 없으면 insert, 같으면 skip, 다르면 replace', () => {
|
||||
expect(planRemoteDocument(null, ['a', ' ', 'b'])).toEqual({ kind: 'insert', chunks: ['a', 'b'] })
|
||||
expect(planRemoteDocument(['a', 'b'], ['a', 'b'])).toEqual({ kind: 'skip' })
|
||||
expect(planRemoteDocument(['a', 'b'], ['a', 'c'])).toEqual({ kind: 'replace', chunks: ['a', 'c'] })
|
||||
expect(planRemoteDocument(['a'], ['a', 'b'])).toEqual({ kind: 'replace', chunks: ['a', 'b'] })
|
||||
})
|
||||
|
||||
it('받은 청크가 전부 비어 있으면 로컬을 비우지 않는다', () => {
|
||||
expect(planRemoteDocument(['a'], ['', ' '])).toEqual({ kind: 'skip' })
|
||||
expect(planRemoteDocument(null, [])).toEqual({ kind: 'skip' })
|
||||
})
|
||||
|
||||
it('빈 청크는 비교에서 빠진다', () => {
|
||||
expect(usableChunks(['a', '', ' b '])).toEqual(['a', ' b '])
|
||||
expect(planRemoteDocument(['a', ''], ['a'])).toEqual({ kind: 'skip' })
|
||||
expect(sameChunks(['a'], ['a', 'b'])).toBe(false)
|
||||
})
|
||||
})
|
||||
269
apps/desktop/tests/main/sync/sync-redteam-r3-0.test.ts
Normal file
269
apps/desktop/tests/main/sync/sync-redteam-r3-0.test.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// 레드팀 r3-0: push 전 tombstone 읽기 실패 시 upsert 보류(fail closed),
|
||||
// 서버에 없는 활성 명령이 설정 항목을 끝없는 재시도에 묶어 설정 pull을 막던 문제.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { createTestDb } from '../../helpers/createTestDb'
|
||||
import { FakeSyncRemote } from '../../helpers/fakeSyncRemote'
|
||||
import { bindTestDatabase, unbindTestDatabase } from '../../../src/main/db'
|
||||
import { history } from '../../../src/main/db/schema'
|
||||
import { configGet, configSet, initInMemoryConfig, resetInMemoryConfig } from '../../../src/main/services/ConfigService'
|
||||
import {
|
||||
getCustomInstructionService,
|
||||
resetCustomInstructionServiceForTests,
|
||||
} from '../../../src/main/services/CustomInstructionService'
|
||||
import { resetDictationTemplateServiceForTests } from '../../../src/main/services/DictationTemplateService'
|
||||
import { resetMeetingDocTemplateServiceForTests } from '../../../src/main/services/MeetingDocTemplateService'
|
||||
import { resetRAGServiceForTests } from '../../../src/main/services/RAGService'
|
||||
import { SyncEngine } from '../../../src/main/services/sync/SyncEngine'
|
||||
import { enqueueChange, pendingOps } from '../../../src/main/services/sync/sync-outbox'
|
||||
import {
|
||||
SyncRemoteError,
|
||||
type RemotePageRequest,
|
||||
type RemoteRow,
|
||||
} from '../../../src/main/services/sync/sync-types'
|
||||
import { pushableEntries } from '../../../src/main/services/sync/push-gate-policy'
|
||||
import {
|
||||
isActiveInstructionMissing,
|
||||
planActiveInstructionStep,
|
||||
shouldResyncSettingsAfterInstructionPush,
|
||||
} from '../../../src/main/services/sync/active-instruction-push-policy'
|
||||
import type { EmbeddingPort } from '../../../src/main/services/rag/embedding-port'
|
||||
|
||||
const USER = '11111111-1111-4111-8111-111111111111'
|
||||
const U = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
/**
|
||||
* 운영 서버처럼 INSERT 때 updated_at을 서버 시각으로 새로 찍고(0037 stamp_sync_timestamp_v1),
|
||||
* sync_tombstones 읽기를 지정한 횟수만큼 일시 오류로 실패시키는 원격.
|
||||
*/
|
||||
class ServerStampingRemote extends FakeSyncRemote {
|
||||
failTombstoneReads = 0
|
||||
|
||||
override async fetchPage(request: RemotePageRequest): Promise<RemoteRow[]> {
|
||||
if (request.table === 'sync_tombstones' && this.failTombstoneReads > 0) {
|
||||
this.failTombstoneReads--
|
||||
throw new SyncRemoteError('canceling statement due to statement timeout', '57014', true)
|
||||
}
|
||||
return super.fetchPage(request)
|
||||
}
|
||||
|
||||
override async upsert(table: string, rows: RemoteRow[], onConflict = 'id'): Promise<void> {
|
||||
const existing = new Set(this.rows(table).map((r) => String(r.id)))
|
||||
await super.upsert(table, rows, onConflict)
|
||||
for (const row of this.rows(table)) {
|
||||
if (!existing.has(String(row.id))) row.updated_at = this.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const offlineEmbedder: EmbeddingPort = {
|
||||
model: 'test-embed',
|
||||
ensureModel: () => Promise.reject(new Error('no embedding server in tests')),
|
||||
embed: () => Promise.reject(new Error('no embedding server in tests')),
|
||||
}
|
||||
|
||||
let testDb: ReturnType<typeof createTestDb>
|
||||
let remote: ServerStampingRemote
|
||||
let engine: SyncEngine
|
||||
let clock: number
|
||||
|
||||
beforeEach(() => {
|
||||
testDb = createTestDb()
|
||||
bindTestDatabase(testDb.db, USER)
|
||||
initInMemoryConfig()
|
||||
resetCustomInstructionServiceForTests()
|
||||
resetDictationTemplateServiceForTests()
|
||||
resetMeetingDocTemplateServiceForTests()
|
||||
resetRAGServiceForTests({ embedder: offlineEmbedder, notify: () => undefined, yieldMs: 0 })
|
||||
getCustomInstructionService().initialize()
|
||||
remote = new ServerStampingRemote(USER)
|
||||
clock = Date.parse('2026-09-28T00:00:00.000Z')
|
||||
engine = new SyncEngine({ remote, userId: USER, now: () => clock })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
engine.dispose()
|
||||
resetRAGServiceForTests()
|
||||
unbindTestDatabase()
|
||||
resetInMemoryConfig()
|
||||
testDb.close()
|
||||
})
|
||||
|
||||
function historyRow(id: string, text: string): Record<string, unknown> {
|
||||
return { id, original_text: text, duration: 1, mode: 'dictation', status: 'completed' }
|
||||
}
|
||||
|
||||
function localHistoryIds(): string[] {
|
||||
return testDb.db.select({ id: history.id }).from(history).all().map((r) => r.id).sort()
|
||||
}
|
||||
|
||||
describe('push 전 tombstone 읽기 실패 → upsert 보류(fail closed)', () => {
|
||||
it('폰에서 지운 행의 대기 upsert가 서버에 행을 되살리지 않고, 다음 pull에서 삭제가 반영된다', async () => {
|
||||
const x = crypto.randomUUID()
|
||||
remote.mobileInsert('history', historyRow(x, 'will be deleted on phone'))
|
||||
await engine.runFullSync()
|
||||
expect(localHistoryIds()).toEqual([x])
|
||||
|
||||
// 데스크톱의 대기 편집(즐겨찾기 등) + 폰의 삭제(T1)
|
||||
enqueueChange('history', x, 'upsert', clock)
|
||||
remote.mobileDelete('history', x)
|
||||
|
||||
remote.failTombstoneReads = 1
|
||||
const flushed = await engine.flush()
|
||||
expect(flushed.errors.some((e) => e.startsWith('tombstones:'))).toBe(true)
|
||||
expect(flushed.pushed).toBe(0)
|
||||
expect(remote.find('history', x)).toBeUndefined()
|
||||
expect(pendingOps('history').get(x)).toBe('upsert')
|
||||
|
||||
await engine.pull()
|
||||
expect(localHistoryIds()).toEqual([])
|
||||
expect(pendingOps('history').has(x)).toBe(false)
|
||||
expect(remote.find('history', x)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('tombstone 읽기가 실패해도 삭제는 보낸다', async () => {
|
||||
const x = crypto.randomUUID()
|
||||
const y = crypto.randomUUID()
|
||||
remote.mobileInsert('history', historyRow(x, 'edit me'))
|
||||
remote.mobileInsert('history', historyRow(y, 'delete me'))
|
||||
await engine.runFullSync()
|
||||
|
||||
enqueueChange('history', x, 'upsert', clock)
|
||||
testDb.db.delete(history).where(eq(history.id, y)).run()
|
||||
enqueueChange('history', y, 'delete', clock)
|
||||
remote.failTombstoneReads = 1
|
||||
const flushed = await engine.flush()
|
||||
|
||||
expect(remote.find('history', y)).toBeUndefined()
|
||||
expect(pendingOps('history').has(y)).toBe(false)
|
||||
expect(pendingOps('history').get(x)).toBe('upsert')
|
||||
expect(flushed.pushed).toBe(1)
|
||||
})
|
||||
|
||||
it('runFullSync에서 tombstone 읽기가 실패하면 최초 대조·upsert를 미루고 다음 flush에서 이어 간다', async () => {
|
||||
const local = crypto.randomUUID()
|
||||
const at = clock
|
||||
testDb.db.insert(history).values({ id: local, originalText: 'offline note', duration: 1, createdAt: at, updatedAt: at }).run()
|
||||
|
||||
remote.failTombstoneReads = 1
|
||||
await engine.runFullSync()
|
||||
expect(remote.find('history', local)).toBeUndefined()
|
||||
|
||||
await engine.flush()
|
||||
expect(remote.find('history', local)?.original_text).toBe('offline note')
|
||||
})
|
||||
})
|
||||
|
||||
describe('서버에 없는 활성 명령과 설정 동기화', () => {
|
||||
function rejectInstructionUploads(error: SyncRemoteError | null): void {
|
||||
remote.rejectRow = (table) => (table === 'custom_instructions' ? error : null)
|
||||
}
|
||||
|
||||
function seedRemoteSettings(): void {
|
||||
remote.mobileInsert('user_settings', {
|
||||
locale: 'ko',
|
||||
theme_mode: 'system',
|
||||
auto_polish_enabled: true,
|
||||
revision: 1,
|
||||
active_instruction_id: null,
|
||||
})
|
||||
}
|
||||
|
||||
it('올라가지 못한(보관된) 명령이 활성이어도 설정 항목은 완료되고, 폰의 설정 변경을 계속 받는다', async () => {
|
||||
seedRemoteSettings()
|
||||
const command = getCustomInstructionService().create({ name: 'Local only', description: '', prompt: 'Keep it local' })
|
||||
rejectInstructionUploads(new SyncRemoteError('value too long for type character varying(4000)', '22001', false))
|
||||
await engine.runFullSync()
|
||||
expect(pendingOps('custom_instructions').get(command.id)).toBe('upsert')
|
||||
|
||||
// 서버에 없는 명령을 활성으로 고르고 테마를 바꾼다(CloudSyncService가 설정 항목을 넣는다)
|
||||
configSet('activeInstructionId', command.id)
|
||||
configSet('theme', 'dark')
|
||||
enqueueChange('user_settings', 'self', 'upsert', clock)
|
||||
const flushed = await engine.flush()
|
||||
expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([])
|
||||
expect(pendingOps('user_settings').has('self')).toBe(false)
|
||||
expect(remote.rows('user_settings')[0].theme_mode).toBe('dark')
|
||||
expect(configGet('activeInstructionId')).toBe(command.id)
|
||||
expect(remote.calls).not.toContain('rpc:set_active_custom_instruction')
|
||||
|
||||
const settings = remote.rows('user_settings')[0]
|
||||
Object.assign(settings, { theme_mode: 'light', revision: Number(settings.revision) + 1, updated_at: remote.now() })
|
||||
await engine.pull()
|
||||
expect(configGet('theme')).toBe('light')
|
||||
})
|
||||
|
||||
it('outbox에 없는데 서버가 모르는 명령(P0002)은 설정 항목을 재시도에 묶지 않는다', async () => {
|
||||
seedRemoteSettings()
|
||||
const command = getCustomInstructionService().create({ name: 'Gone', description: '', prompt: 'Vanished on server' })
|
||||
await engine.runFullSync()
|
||||
// 서버에서 tombstone 없이 사라진 경우(보존 기간 정리 등)
|
||||
const rows = remote.rows('custom_instructions')
|
||||
rows.splice(rows.findIndex((r) => r.id === command.id), 1)
|
||||
|
||||
configSet('activeInstructionId', command.id)
|
||||
enqueueChange('user_settings', 'self', 'upsert', clock)
|
||||
const flushed = await engine.flush()
|
||||
expect(remote.calls).toContain('rpc:set_active_custom_instruction')
|
||||
expect(flushed.errors.filter((e) => e.startsWith('user_settings'))).toEqual([])
|
||||
expect(pendingOps('user_settings').has('self')).toBe(false)
|
||||
})
|
||||
|
||||
it('미룬 활성 명령이 나중에 서버에 올라가면 설정을 다시 올려 서버 활성 명령을 맞춘다', async () => {
|
||||
seedRemoteSettings()
|
||||
const command = getCustomInstructionService().create({ name: 'Late', description: '', prompt: 'Arrives later' })
|
||||
rejectInstructionUploads(new SyncRemoteError('canceling statement due to statement timeout', '57014', true))
|
||||
await engine.runFullSync()
|
||||
configSet('activeInstructionId', command.id)
|
||||
enqueueChange('user_settings', 'self', 'upsert', clock)
|
||||
await engine.flush()
|
||||
expect(pendingOps('user_settings').has('self')).toBe(false)
|
||||
expect(remote.rows('user_settings')[0].active_instruction_id).toBeNull()
|
||||
expect(remote.calls).not.toContain('rpc:set_active_custom_instruction')
|
||||
|
||||
rejectInstructionUploads(null)
|
||||
clock += 60 * 60_000
|
||||
await engine.flush()
|
||||
expect(remote.find('custom_instructions', command.id)).toBeDefined()
|
||||
expect(pendingOps('user_settings').has('self')).toBe(true)
|
||||
|
||||
await engine.flush()
|
||||
expect(remote.rows('user_settings')[0].active_instruction_id).toBe(command.id)
|
||||
expect(pendingOps('user_settings').has('self')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('push-gate-policy', () => {
|
||||
const entries = [
|
||||
{ op: 'upsert' as const, rowId: 'a' },
|
||||
{ op: 'delete' as const, rowId: 'b' },
|
||||
]
|
||||
|
||||
it('tombstone을 반영했으면 전부, 실패했으면 삭제만 push한다', () => {
|
||||
expect(pushableEntries(entries, { tombstonesSynced: true })).toEqual(entries)
|
||||
expect(pushableEntries(entries, { tombstonesSynced: false })).toEqual([{ op: 'delete', rowId: 'b' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('active-instruction-push-policy', () => {
|
||||
it('서버에 아직 없는 명령은 미루고, 나머지는 RPC로 보낸다', () => {
|
||||
expect(planActiveInstructionStep(null, new Set([U]))).toEqual({ kind: 'none' })
|
||||
expect(planActiveInstructionStep({ instructionId: U }, new Set([U]))).toEqual({ kind: 'defer', instructionId: U })
|
||||
expect(planActiveInstructionStep({ instructionId: U }, new Set())).toEqual({ kind: 'rpc', instructionId: U })
|
||||
expect(planActiveInstructionStep({ instructionId: null }, new Set([U]))).toEqual({ kind: 'rpc', instructionId: null })
|
||||
})
|
||||
|
||||
it('P0002만 "서버에 명령 없음"으로 본다', () => {
|
||||
expect(isActiveInstructionMissing(new SyncRemoteError('instruction_not_found', 'P0002', true))).toBe(true)
|
||||
expect(isActiveInstructionMissing(new SyncRemoteError('fetch failed', 'network', true))).toBe(false)
|
||||
})
|
||||
|
||||
it('미룬 명령이 올라갔을 때만 설정을 다시 올린다', () => {
|
||||
expect(shouldResyncSettingsAfterInstructionPush(U, [U])).toBe(true)
|
||||
expect(shouldResyncSettingsAfterInstructionPush(U, ['other'])).toBe(false)
|
||||
expect(shouldResyncSettingsAfterInstructionPush('', [U])).toBe(false)
|
||||
expect(shouldResyncSettingsAfterInstructionPush(null, [U])).toBe(false)
|
||||
})
|
||||
})
|
||||
83
apps/desktop/tests/main/update-policy-redteam-r3-5.test.ts
Normal file
83
apps/desktop/tests/main/update-policy-redteam-r3-5.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// tests/main/update-policy-redteam-r3-5.test.ts
|
||||
// 원격 정책의 안전 필드(killSwitch, stagingPercentage)가 형식이 틀리면 가장 허용적인 값이
|
||||
// 아니라 가장 보수적인 값으로 읽히는지 확인한다.
|
||||
//
|
||||
// 회귀: `"killSwitch": "true"` 가 false 로, `"stagingPercentage": "5"` / 5.5 가 100% 로
|
||||
// 조용히 바뀌어 운영자 의도와 반대로 전체 배포되던 문제.
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
DEFAULT_UPDATE_POLICY,
|
||||
evaluateUpdateOffer,
|
||||
parseUpdatePolicy,
|
||||
} from '../../src/main/update-policy'
|
||||
|
||||
const BASE = {
|
||||
schemaVersion: 1,
|
||||
defaultChannel: 'latest',
|
||||
channels: {
|
||||
latest: { allowPrerelease: false },
|
||||
beta: { allowPrerelease: true },
|
||||
alpha: { allowPrerelease: true },
|
||||
},
|
||||
minimumSupportedVersion: '1.0.0',
|
||||
forceInstallBelow: null,
|
||||
fullInstallOnMajorChange: true,
|
||||
fullInstallVersionGap: 3,
|
||||
stagingPercentage: 100,
|
||||
killSwitch: false,
|
||||
}
|
||||
|
||||
describe('parseUpdatePolicy — 안전 필드 fail-closed', () => {
|
||||
it.each(['true', 'false', 1, 0, null, {}])('killSwitch=%j 는 켜진 것으로 본다', (value) => {
|
||||
expect(parseUpdatePolicy({ ...BASE, killSwitch: value }).killSwitch).toBe(true)
|
||||
})
|
||||
|
||||
it.each([5.5, '5', null, Number.NaN, '100'])('stagingPercentage=%j 는 0% 로 본다', (value) => {
|
||||
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: value }).stagingPercentage).toBe(0)
|
||||
})
|
||||
|
||||
it('형식이 틀린 킬 스위치는 업데이트 제안을 막는다', () => {
|
||||
const policy = parseUpdatePolicy({ ...BASE, killSwitch: 'true' })
|
||||
const offer = evaluateUpdateOffer({
|
||||
policy,
|
||||
channel: 'latest',
|
||||
currentVersion: '1.8.0',
|
||||
targetVersion: '1.9.0',
|
||||
skippedVersion: null,
|
||||
deviceId: 'device-a',
|
||||
})
|
||||
expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' })
|
||||
})
|
||||
|
||||
it('형식이 틀린 staging 은 필수 업데이트가 아니면 아무에게도 노출하지 않는다', () => {
|
||||
const policy = parseUpdatePolicy({ ...BASE, stagingPercentage: '5' })
|
||||
for (const deviceId of ['a', 'b', 'c', 'd', 'e', 'f']) {
|
||||
const offer = evaluateUpdateOffer({
|
||||
policy,
|
||||
channel: 'latest',
|
||||
currentVersion: '1.8.0',
|
||||
targetVersion: '1.9.0',
|
||||
skippedVersion: null,
|
||||
deviceId,
|
||||
})
|
||||
expect(offer).toMatchObject({ action: 'ignore', reason: 'rollout' })
|
||||
}
|
||||
})
|
||||
|
||||
it('필드가 아예 없으면 기존처럼 기본값을 쓴다', () => {
|
||||
const { killSwitch: _k, stagingPercentage: _s, ...rest } = BASE
|
||||
const parsed = parseUpdatePolicy(rest)
|
||||
expect(parsed.killSwitch).toBe(DEFAULT_UPDATE_POLICY.killSwitch)
|
||||
expect(parsed.stagingPercentage).toBe(DEFAULT_UPDATE_POLICY.stagingPercentage)
|
||||
})
|
||||
|
||||
it('올바른 값은 그대로(범위 밖 정수는 기존처럼 잘라서) 읽는다', () => {
|
||||
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: 5, killSwitch: false })).toMatchObject({
|
||||
stagingPercentage: 5,
|
||||
killSwitch: false,
|
||||
})
|
||||
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: 250 }).stagingPercentage).toBe(100)
|
||||
expect(parseUpdatePolicy({ ...BASE, stagingPercentage: -3 }).stagingPercentage).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue