d3ro-voice/apps/desktop/tests/main/services/dictionary-redteam-r3-26.test.ts
Yun Chan ba9ef9741e fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
2026-09-28 20:45:52 +09:00

180 lines
6.3 KiB
TypeScript

// 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 })
})
})