d3ro-voice/apps/desktop/tests/main/services/runtime-provisioner-redteam-r3-5.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

195 lines
7.2 KiB
TypeScript

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