d3ro-voice/packages/core/__tests__/dictionary-limits-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

113 lines
4.1 KiB
TypeScript

// packages/core/__tests__/dictionary-limits-redteam-r3-26.test.ts
// 사전 쓰기 정책이 서버 트리거(normalize_dictionary_word, 마이그레이션 20260821000005)의
// 길이 제한(단어 120·발음 200 코드 포인트)을 로컬에서 먼저 거부하는지 검증한다.
import { describe, expect, it } from 'vitest'
import { D3ROError, ErrorCode } from '../src/errors'
import {
DICTIONARY_LIMITS,
buildDictionaryUpdatePatch,
dictionaryWriteProblemError,
validateDictionaryDraft
} from '../src/dictionary-policy'
describe('DICTIONARY_LIMITS', () => {
it('서버 트리거와 같은 값이다', () => {
expect(DICTIONARY_LIMITS).toEqual({ word: 120, pronunciation: 200 })
})
})
describe('validateDictionaryDraft', () => {
it('정상 입력을 정규화한다', () => {
expect(validateDictionaryDraft({ word: ' GPT ', pronunciation: ' 지피티 ' })).toEqual({
ok: true,
draft: { word: 'GPT', pronunciation: '지피티' }
})
expect(validateDictionaryDraft({ word: 'GPT', pronunciation: ' ' })).toEqual({
ok: true,
draft: { word: 'GPT', pronunciation: null }
})
})
it('빈 단어를 거부한다', () => {
expect(validateDictionaryDraft({ word: ' ' })).toEqual({ ok: false, reason: 'empty-word' })
})
it('정확히 120자 단어·200자 발음은 받아들인다 (앞뒤 공백 제외)', () => {
const result = validateDictionaryDraft({
word: ` ${'가'.repeat(120)} `,
pronunciation: ` ${'a'.repeat(200)} `
})
expect(result.ok).toBe(true)
})
it('121자 단어를 too-long 으로 거부한다', () => {
expect(validateDictionaryDraft({ word: 'a'.repeat(121) })).toEqual({
ok: false,
reason: 'too-long',
field: 'word',
max: 120
})
})
it('201자 발음을 too-long 으로 거부한다', () => {
expect(validateDictionaryDraft({ word: 'ok', pronunciation: 'b'.repeat(201) })).toEqual({
ok: false,
reason: 'too-long',
field: 'pronunciation',
max: 200
})
})
it('코드 포인트로 센다 — 서로게이트 쌍 이모지 120개는 허용', () => {
const emoji = '\u{1F600}'.repeat(120) // UTF-16 길이 240
expect(validateDictionaryDraft({ word: emoji }).ok).toBe(true)
expect(validateDictionaryDraft({ word: `${emoji}\u{1F600}` }).ok).toBe(false)
})
})
describe('buildDictionaryUpdatePatch 길이 제한', () => {
it('121자 단어로 바꾸는 편집을 거부한다', () => {
expect(buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(121) })).toEqual({
ok: false,
reason: 'too-long',
field: 'word',
max: 120
})
})
it('201자 발음으로 바꾸는 편집을 거부한다', () => {
expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: 'p'.repeat(201) })).toEqual({
ok: false,
reason: 'too-long',
field: 'pronunciation',
max: 200
})
})
it('제한 이내 편집과 발음 지우기는 그대로 통과한다', () => {
expect(
buildDictionaryUpdatePatch({ id: 'a', word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) })
).toEqual({ ok: true, patch: { word: 'w'.repeat(120), pronunciation: 'p'.repeat(200) } })
expect(buildDictionaryUpdatePatch({ id: 'a', pronunciation: null })).toEqual({
ok: true,
patch: { pronunciation: null }
})
})
})
describe('dictionaryWriteProblemError', () => {
it('too-long 은 field·max 를 details 로 담은 DictionaryImportInvalidFormat 이다', () => {
const err = dictionaryWriteProblemError({ reason: 'too-long', field: 'pronunciation', max: 200 })
expect(err).toBeInstanceOf(D3ROError)
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
expect(err.details).toEqual({ reason: 'too-long', field: 'pronunciation', max: 200 })
})
it('empty-word 는 기존 메시지를 유지한다', () => {
const err = dictionaryWriteProblemError({ reason: 'empty-word' })
expect(err.code).toBe(ErrorCode.DictionaryImportInvalidFormat)
expect(err.message).toBe('Dictionary word is empty')
expect(err.details).toEqual({ reason: 'empty-word', field: 'word' })
})
})