d3ro-voice/apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.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

314 lines
11 KiB
TypeScript

import { FileSystem } from 'react-native-file-access'
import { AudioPipelineError, type LocalAudioInput } from '../src/features/import/audio-import-types'
import { transcribeAudioLocally } from '../src/features/import/local-whisper-transcription'
import { classifySttResponse, parseSttResult } from '../src/features/import/stt-cloud-client'
import { transcribeWithLocalFallback } from '../src/features/import/stt-engine'
import { isRetryable, shouldFallBackToLocal } from '../src/features/import/stt-policy'
import { processAudioInput } from '../src/features/import/audio-transcription-service'
import { transcribeTalkRecording } from '../src/features/talk/talk-transcription-service'
import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input'
import {
beginMeetingProcessing,
completeMeetingProcessing,
} from '../src/features/meetings/meetings-service'
jest.mock('../src/features/import/local-whisper-transcription', () => ({
transcribeAudioLocally: jest.fn(),
}))
jest.mock('../src/features/import/recorded-audio-input', () => ({
prepareRecordedAudio: jest.fn(),
}))
jest.mock('../src/features/import/resumable-audio-upload', () => ({
uploadAudioResumably: jest.fn(),
}))
jest.mock('../src/features/meetings/meetings-service', () => ({
beginMeetingProcessing: jest.fn(),
completeMeetingProcessing: jest.fn(),
}))
jest.mock('../src/lib/native-config', () => ({
getMobileRuntimeConfig: () => ({ appVersion: '1.9.0' }),
}))
interface RecordedQuery {
table: string
calls: Array<[string, unknown[]]>
}
type QueryResult = { data: unknown; error: unknown }
const mockQueries: RecordedQuery[] = []
let mockResolveQuery: (query: RecordedQuery) => QueryResult = () => ({ data: null, error: null })
function mockBuilder(table: string): Record<string, unknown> {
const recorded: RecordedQuery = { table, calls: [] }
mockQueries.push(recorded)
const builder: Record<string, unknown> = {}
for (const method of ['select', 'insert', 'update', 'delete', 'eq', 'neq', 'order', 'limit']) {
builder[method] = (...args: unknown[]) => {
recorded.calls.push([method, args])
return builder
}
}
for (const terminal of ['maybeSingle', 'single']) {
builder[terminal] = async () => {
recorded.calls.push([terminal, []])
return mockResolveQuery(recorded)
}
}
builder.then = (
onFulfilled: (value: QueryResult) => unknown,
onRejected?: (reason: unknown) => unknown,
) => Promise.resolve(mockResolveQuery(recorded)).then(onFulfilled, onRejected)
return builder
}
jest.mock('../src/lib/supabase', () => ({
supabase: {
auth: {
getSession: jest.fn(async () => ({
data: { session: { user: { id: '00000000-0000-4000-8000-000000000001' }, access_token: 'user-token' } },
error: null,
})),
},
from: jest.fn((table: string) => mockBuilder(table)),
},
}))
const originalFetch = global.fetch
const SHA = 'a'.repeat(64)
const USER_ID = '00000000-0000-4000-8000-000000000001'
function input(): LocalAudioInput {
return {
uri: 'file:///cache/meeting.m4a',
path: '/cache/meeting.m4a',
fileName: 'meeting.m4a',
mimeType: 'audio/mp4',
sizeBytes: 3,
durationMs: 1_500,
source: 'recording',
dispose: jest.fn().mockResolvedValue(undefined),
}
}
function uploadedAudio(meetingId: string | null): Record<string, unknown> {
return {
id: 'audio-1',
user_id: USER_ID,
storage_key: `${USER_ID}/imports/${SHA}/meeting.m4a`,
upload_status: 'uploaded',
history_id: null,
meeting_id: meetingId,
}
}
function historyWrites(): RecordedQuery[] {
return mockQueries.filter(query =>
query.table === 'history'
&& query.calls.some(([method]) => method === 'insert' || method === 'update'))
}
function writtenPayload(query: RecordedQuery): Record<string, unknown> {
const call = query.calls.find(([method]) => method === 'insert' || method === 'update')
return (call?.[1][0] ?? {}) as Record<string, unknown>
}
beforeEach(() => {
jest.clearAllMocks()
mockQueries.length = 0
;(FileSystem.hash as jest.Mock).mockResolvedValue(SHA)
;(FileSystem.readFile as jest.Mock).mockResolvedValue('AQID')
})
afterEach(() => {
global.fetch = originalFetch
})
describe('stt-proxy status classification (single contract table)', () => {
it.each([
[200, null],
[400, 'transcription'],
[401, 'auth'],
[403, 'auth'],
[413, 'file-too-large'],
[415, 'transcription'],
[422, 'no-speech'],
[429, 'quota'],
[500, 'transcription'],
[502, 'provider-unavailable'],
[503, 'provider-unavailable'],
[504, 'provider-unavailable'],
])('HTTP %i -> %s', (status, code) => {
const error = classifySttResponse(status, '{"error":"x"}')
expect(error === null ? null : error.code).toBe(code)
})
it('keeps the server error code in the message for diagnostics', () => {
expect(classifySttResponse(422, '{"error":"stt_no_speech","attempts":[]}')?.message)
.toContain('stt_no_speech')
})
it('accepts a result without confidence and rejects an empty transcript', () => {
expect(parseSttResult({
transcript: ' hi ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram',
}, 5)).toMatchObject({ text: 'hi', confidence: null })
expect(() => parseSttResult({
transcript: ' ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram', confidence: 1,
}, 5)).toThrow(expect.objectContaining({ code: 'transcription' }))
})
})
describe('stt policy', () => {
it('only lets provider/transport failures fall back to on-device whisper', () => {
expect(shouldFallBackToLocal('provider-unavailable')).toBe(true)
expect(shouldFallBackToLocal('transcription')).toBe(true)
for (const code of ['no-speech', 'auth', 'quota', 'file-too-large', 'cancelled', 'file-read'] as const) {
expect(shouldFallBackToLocal(code)).toBe(false)
}
})
it('keeps retry separate from fallback: auth retries but never falls back, no-speech does neither', () => {
expect(isRetryable('auth')).toBe(true)
expect(shouldFallBackToLocal('auth')).toBe(false)
expect(isRetryable('no-speech')).toBe(false)
expect(isRetryable('quota')).toBe(false)
})
})
describe('transcribeWithLocalFallback', () => {
const signal = new AbortController().signal
it('does not run the local engine when the cloud reports no speech', async () => {
const local = jest.fn()
await expect(transcribeWithLocalFallback({
input: input(),
languageCode: 'ko',
signal,
cloud: async () => { throw new AudioPipelineError('no-speech', 'silent') },
local,
})).rejects.toMatchObject({ code: 'no-speech' })
expect(local).not.toHaveBeenCalled()
})
it('turns an empty local transcript into no-speech instead of a result', async () => {
const onFallback = jest.fn()
await expect(transcribeWithLocalFallback({
input: input(),
languageCode: 'ko',
signal,
cloud: async () => { throw new AudioPipelineError('provider-unavailable', 'down') },
local: async () => ({
text: '', confidence: null, language: 'ko', durationSeconds: 1, provider: 'local', latencyMs: 1,
}),
onFallback,
})).rejects.toMatchObject({ code: 'no-speech' })
expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ code: 'provider-unavailable' }))
})
})
describe('file/meeting pipeline with stt-proxy 422 stt_no_speech', () => {
function respondNoSpeech(): jest.Mock {
const fetchMock = jest.fn().mockResolvedValue({
ok: false,
status: 422,
text: async () => JSON.stringify({ error: 'stt_no_speech', attempts: [] }),
})
global.fetch = fetchMock
return fetchMock
}
it('fails a history transcription as no-speech without local fallback and records the failure', async () => {
mockResolveQuery = query => {
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
return { data: uploadedAudio(null), error: null }
}
if (query.table === 'history') return { data: { id: 'history-1' }, error: null }
return { data: null, error: null }
}
const fetchMock = respondNoSpeech()
await expect(processAudioInput(input(), {
expectedUserId: USER_ID,
languageCode: 'ko',
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'no-speech' })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(transcribeAudioLocally).not.toHaveBeenCalled()
const writes = historyWrites()
expect(writes).toHaveLength(1)
expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' })
})
it('fails a meeting transcription as no-speech without completing it or running whisper', async () => {
mockResolveQuery = query => {
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
return { data: uploadedAudio('meeting-1'), error: null }
}
return { data: null, error: null }
}
;(beginMeetingProcessing as jest.Mock).mockResolvedValue({ id: 'job-1' })
respondNoSpeech()
await expect(processAudioInput(input(), {
expectedUserId: USER_ID,
languageCode: 'ko',
signal: new AbortController().signal,
meetingId: 'meeting-1',
})).rejects.toMatchObject({ code: 'no-speech' })
expect(transcribeAudioLocally).not.toHaveBeenCalled()
expect(completeMeetingProcessing).not.toHaveBeenCalled()
})
it('does not save an empty on-device transcript as a completed history', async () => {
mockResolveQuery = query => {
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
return { data: uploadedAudio(null), error: null }
}
if (query.table === 'history') return { data: { id: 'history-1' }, error: null }
return { data: null, error: null }
}
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503, text: async () => '' })
;(transcribeAudioLocally as jest.Mock).mockResolvedValue({
text: '', confidence: null, language: 'ko', durationSeconds: 1.5, provider: 'whisper.cpp-tiny-local', latencyMs: 10,
})
await expect(processAudioInput(input(), {
expectedUserId: USER_ID,
languageCode: 'ko',
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'no-speech' })
const writes = historyWrites()
expect(writes).toHaveLength(1)
expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' })
})
})
describe('Talk with stt-proxy 422 stt_no_speech', () => {
it('reports no-speech without running on-device whisper', async () => {
const recorded = input()
;(prepareRecordedAudio as jest.Mock).mockResolvedValue(recorded)
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 422,
text: async () => JSON.stringify({ error: 'stt_no_speech' }),
})
await expect(transcribeTalkRecording({
uri: recorded.uri,
path: recorded.path,
fileName: recorded.fileName,
mimeType: recorded.mimeType,
size: recorded.sizeBytes,
durationMs: 1_500,
}, {
accessToken: 'user-token',
languageCode: 'ko',
signal: new AbortController().signal,
disposeRecording: jest.fn(),
})).rejects.toMatchObject({ code: 'no-speech' })
expect(transcribeAudioLocally).not.toHaveBeenCalled()
expect(recorded.dispose).toHaveBeenCalledTimes(1)
})
})