fix: red-team round 3 hardening across desktop, mobile, core and server

Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

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

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
This commit is contained in:
Yun Chan 2026-09-28 20:45:52 +09:00
parent 2428ede03d
commit ba9ef9741e
161 changed files with 17056 additions and 2379 deletions

View file

@ -83,6 +83,7 @@ import { getMeetingModeService } from '../../src/main/services/MeetingModeServic
import { configSet } from '../../src/main/services/ConfigService'
import { getDatabase } from '../../src/main/db'
import { meetingSessions } from '../../src/main/db/schema'
import { syncStateRecordingLedger } from '../../src/main/services/meeting/local-recording-ledger'
function row(id: string) {
return getDatabase().select().from(meetingSessions).where(eq(meetingSessions.id, id)).get()
@ -163,7 +164,10 @@ describe('앱 종료·중단 복구', () => {
it('남은 recording/processing 행을 닫는다 — 전사가 있으면 completed, 없으면 error', () => {
insertSession('stuck-with-text', { status: 'recording', rawTranscript: '[00:01] 저장된 부분' })
// 전사가 없는 행은 이 기기에서 시작한 녹음(원장 표식)일 때만 닫는다 — redteam r3: 폰의 진행 중 회의 보호
insertSession('stuck-empty', { status: 'processing' })
syncStateRecordingLedger.mark('stuck-empty')
insertSession('phone-live', { status: 'recording' })
insertSession('done', { status: 'completed', rawTranscript: 'x' })
expect(getMeetingModeService().recoverInterruptedSessions()).toBe(2)
@ -171,6 +175,8 @@ describe('앱 종료·중단 복구', () => {
expect(row('stuck-with-text')?.status).toBe('completed')
expect(row('stuck-with-text')?.endedAt).not.toBeNull()
expect(row('stuck-empty')?.status).toBe('error')
// 표식도 전사도 없는 행은 다른 기기(폰)의 진행 중 회의일 수 있어 그대로 둔다
expect(row('phone-live')?.status).toBe('recording')
expect(row('done')?.status).toBe('completed')
})
})

View file

@ -0,0 +1,90 @@
// 받아쓰기 템플릿 필드 id 회귀 테스트 (redteam r3-7)
// 버그: 편집기가 필드 개수로 id를 만들고(중간 삭제 후 재사용) 이름을 그대로 id로 써서
// 빈 id·중복 id 템플릿이 저장됐다. 세션에서 같은 id 필드의 받아쓴 값이 앞 값을 덮어써
// 출력({{id}})에서 앞 값이 조용히 사라졌다. 이제 서비스가 저장 전에 TemplateInvalidFormat 으로 거부한다.
import { describe, expect, it } from 'vitest'
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
import type { TemplateField } from '@d3ro/core/types'
import { getDictationTemplateService } from '../../src/main/services/DictationTemplateService'
import { useRedHarness } from './harness'
useRedHarness()
function field(id: string): TemplateField {
return { id, name: id, label: id, promptText: '', required: true, maxDurationSec: 30 }
}
function expectInvalidFormat(run: () => unknown): void {
let caught: unknown
try {
run()
} catch (err) {
caught = err
}
expect(caught).toBeInstanceOf(D3ROError)
expect((caught as D3ROError).code).toBe(ErrorCode.TemplateInvalidFormat)
}
describe('받아쓰기 템플릿 필드 id 검증', () => {
it('중복 필드 id 템플릿 생성을 거부하고 저장하지 않는다', () => {
const svc = getDictationTemplateService()
const before = svc.getAll().length
// 편집기 시나리오: [field1, field2, field3] → field1 삭제 → 추가 → [field2, field3, field3]
expectInvalidFormat(() =>
svc.create({
name: 'dup',
description: '',
fields: [field('field2'), field('field3'), field('field3')],
outputFormat: '{{field2}} {{field3}}',
}),
)
expect(svc.getAll()).toHaveLength(before)
})
it('빈/공백 필드 id 템플릿 생성을 거부한다', () => {
const svc = getDictationTemplateService()
expectInvalidFormat(() =>
svc.create({ name: 'empty', description: '', fields: [field('a'), field('')], outputFormat: '{{a}}' }),
)
expectInvalidFormat(() =>
svc.create({ name: 'blank', description: '', fields: [field(' ')], outputFormat: '' }),
)
})
it('수정으로 중복 필드 id를 넣으면 거부하고 기존 필드를 유지한다', () => {
const svc = getDictationTemplateService()
const created = svc.create({
name: 'ok',
description: '',
fields: [field('a'), field('b')],
outputFormat: '{{a}} {{b}}',
})
expectInvalidFormat(() => svc.update({ id: created.id, fields: [field('a'), field('a')] }))
expect(svc.getById(created.id)?.fields.map((f) => f.id)).toEqual(['a', 'b'])
})
it('필드를 바꾸지 않는 수정과 유효한 필드 수정은 그대로 된다', () => {
const svc = getDictationTemplateService()
const created = svc.create({ name: 'ok', description: '', fields: [field('a')], outputFormat: '{{a}}' })
expect(svc.update({ id: created.id, name: 'renamed' }).name).toBe('renamed')
expect(svc.update({ id: created.id, fields: [field('a'), field('b')] }).fields).toHaveLength(2)
})
it('유효한 템플릿은 필드마다 받아쓴 값이 모두 출력에 남는다', () => {
const svc = getDictationTemplateService()
const created = svc.create({
name: 'flow',
description: '',
fields: [field('field2'), field('field3'), field('field4')],
outputFormat: '{{field2}}|{{field3}}|{{field4}}',
})
const outputs: string[] = []
svc.on('session-completed', (e: { outputText: string }) => outputs.push(e.outputText))
svc.startSession(created.id)
svc.consumeDictatedText('one')
svc.consumeDictatedText('two')
svc.consumeDictatedText('three')
expect(outputs).toEqual(['one|two|three'])
})
})