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
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue