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
224
apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.test.ts
Normal file
224
apps/mobile-rn/__tests__/account-deletion-redteam-r3-19.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
jest.mock('../src/lib/supabase', () => ({
|
||||
supabase: { functions: { invoke: jest.fn() } },
|
||||
}))
|
||||
|
||||
import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js'
|
||||
import { supabase } from '../src/lib/supabase'
|
||||
import {
|
||||
edgeFailureField,
|
||||
invokeEdgeFunction,
|
||||
readEdgeFunctionHttpFailure,
|
||||
type EdgeFunctionFailure,
|
||||
} from '../src/lib/edge-functions'
|
||||
import {
|
||||
accountDeletionFailureMessageKey,
|
||||
edgeAccountDeletionService,
|
||||
runAccountDeletion,
|
||||
type AccountDeletionService,
|
||||
} from '../src/features/account/account-deletion-service'
|
||||
|
||||
const mockInvoke = (supabase as unknown as { functions: { invoke: jest.Mock } }).functions.invoke
|
||||
|
||||
function jsonResponse(body: unknown, status: number): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Mirrors functions-js 2.103: non-2xx → { data: null, error: FunctionsHttpError(response) }. */
|
||||
function httpFailure(body: unknown, status: number): { data: null; error: FunctionsHttpError } {
|
||||
return { data: null, error: new FunctionsHttpError(jsonResponse(body, status)) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockInvoke.mockReset()
|
||||
})
|
||||
|
||||
describe('invokeEdgeFunction adapter', () => {
|
||||
test('decodes the JSON body of a 403 FunctionsHttpError instead of relying on data', async () => {
|
||||
mockInvoke.mockResolvedValue(httpFailure({
|
||||
error: 'Recent authentication is required',
|
||||
code: 'REAUTHENTICATION_REQUIRED',
|
||||
}, 403))
|
||||
|
||||
const result = await invokeEdgeFunction('account-delete', { confirmation: 'x' })
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('account-delete', { body: { confirmation: 'x' } })
|
||||
expect(result.ok).toBe(false)
|
||||
const failure = result as EdgeFunctionFailure
|
||||
expect(failure.status).toBe(403)
|
||||
expect(failure.body).toEqual({
|
||||
readable: true,
|
||||
payload: { error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED' },
|
||||
})
|
||||
expect(edgeFailureField(failure, 'code')).toBe('REAUTHENTICATION_REQUIRED')
|
||||
expect(edgeFailureField(failure, 'error')).toBe('Recent authentication is required')
|
||||
expect(failure.message).toBe('Edge Function returned a non-2xx status code')
|
||||
})
|
||||
|
||||
test('reports an unreadable body without inventing a code', async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
data: null,
|
||||
error: new FunctionsHttpError(new Response('<html>bad gateway</html>', { status: 502 })),
|
||||
})
|
||||
|
||||
const result = await invokeEdgeFunction('team-invite', {})
|
||||
|
||||
expect(result).toMatchObject({ ok: false, status: 502, body: { readable: false } })
|
||||
expect(edgeFailureField(result as EdgeFunctionFailure, 'code')).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps fetch errors and thrown values as failures without an HTTP status', async () => {
|
||||
mockInvoke.mockResolvedValueOnce({ data: null, error: new FunctionsFetchError('offline') })
|
||||
await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: null,
|
||||
body: null,
|
||||
})
|
||||
|
||||
const thrown = new TypeError('Network request failed')
|
||||
mockInvoke.mockRejectedValueOnce(thrown)
|
||||
await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
status: null,
|
||||
body: null,
|
||||
message: 'Network request failed',
|
||||
cause: thrown,
|
||||
})
|
||||
})
|
||||
|
||||
test('returns data on success', async () => {
|
||||
mockInvoke.mockResolvedValue({ data: { success: true }, error: null })
|
||||
await expect(invokeEdgeFunction('account-delete', {})).resolves.toEqual({
|
||||
ok: true,
|
||||
data: { success: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('readEdgeFunctionHttpFailure ignores errors without a Response context', async () => {
|
||||
await expect(readEdgeFunctionHttpFailure(new Error('x'))).resolves.toBeNull()
|
||||
await expect(readEdgeFunctionHttpFailure({ context: 'nope' })).resolves.toBeNull()
|
||||
await expect(readEdgeFunctionHttpFailure(null)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('edgeAccountDeletionService', () => {
|
||||
test('recognises REAUTHENTICATION_REQUIRED from a 403 response body', async () => {
|
||||
mockInvoke.mockResolvedValue(httpFailure({
|
||||
error: 'Recent authentication is required',
|
||||
code: 'REAUTHENTICATION_REQUIRED',
|
||||
}, 403))
|
||||
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'REAUTHENTICATION_REQUIRED',
|
||||
message: 'Recent authentication is required',
|
||||
})
|
||||
expect(mockInvoke).toHaveBeenCalledWith('account-delete', {
|
||||
body: { confirmation: 'DELETE_MY_ACCOUNT' },
|
||||
})
|
||||
})
|
||||
|
||||
test('recognises ACTIVE_SUBSCRIPTION from a 409 response body', async () => {
|
||||
mockInvoke.mockResolvedValue(httpFailure({
|
||||
error: 'Cancel the active subscription before deleting the account',
|
||||
code: 'ACTIVE_SUBSCRIPTION',
|
||||
}, 409))
|
||||
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'ACTIVE_SUBSCRIPTION',
|
||||
})
|
||||
})
|
||||
|
||||
test('maps a missing function (404) to SERVER_ENDPOINT_UNAVAILABLE', async () => {
|
||||
mockInvoke.mockResolvedValue(httpFailure({ code: 'NOT_FOUND', message: 'Requested function was not found' }, 404))
|
||||
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
code: 'SERVER_ENDPOINT_UNAVAILABLE',
|
||||
})
|
||||
})
|
||||
|
||||
test('treats a 2xx without success=true as REQUEST_FAILED and succeeds only on success=true', async () => {
|
||||
mockInvoke.mockResolvedValueOnce({ data: { success: false, error: 'nope' }, error: null })
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({
|
||||
ok: false,
|
||||
code: 'REQUEST_FAILED',
|
||||
message: 'nope',
|
||||
})
|
||||
|
||||
mockInvoke.mockResolvedValueOnce({ data: { success: true }, error: null })
|
||||
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('accountDeletionFailureMessageKey', () => {
|
||||
test('always maps to translated copy, never raw server/SDK text', () => {
|
||||
expect(accountDeletionFailureMessageKey('REAUTHENTICATION_REQUIRED'))
|
||||
.toBe('mobile.account.reauthenticationRequired')
|
||||
expect(accountDeletionFailureMessageKey('SERVER_ENDPOINT_UNAVAILABLE'))
|
||||
.toBe('mobile.account.deleteUnavailable')
|
||||
expect(accountDeletionFailureMessageKey('ACTIVE_SUBSCRIPTION')).toBe('mobile.account.deleteFailed')
|
||||
expect(accountDeletionFailureMessageKey('REQUEST_FAILED')).toBe('mobile.account.deleteFailed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runAccountDeletion ordering', () => {
|
||||
function service(result: Awaited<ReturnType<AccountDeletionService['deleteCurrentAccount']>>): AccountDeletionService {
|
||||
return { deleteCurrentAccount: jest.fn(async () => result) }
|
||||
}
|
||||
|
||||
test('a refused deletion leaves push registration and the local session untouched', async () => {
|
||||
const detachPushRegistration = jest.fn(async () => undefined)
|
||||
const purgeLocalSession = jest.fn(async () => undefined)
|
||||
|
||||
await expect(runAccountDeletion({
|
||||
deletionService: service({ ok: false, code: 'REAUTHENTICATION_REQUIRED' }),
|
||||
detachPushRegistration,
|
||||
purgeLocalSession,
|
||||
})).resolves.toEqual({ ok: false, code: 'REAUTHENTICATION_REQUIRED' })
|
||||
|
||||
expect(detachPushRegistration).not.toHaveBeenCalled()
|
||||
expect(purgeLocalSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('detaches push after server confirmation and before the local purge', async () => {
|
||||
const calls: string[] = []
|
||||
const deletionService: AccountDeletionService = {
|
||||
deleteCurrentAccount: jest.fn(async () => {
|
||||
calls.push('delete')
|
||||
return { ok: true } as const
|
||||
}),
|
||||
}
|
||||
|
||||
await expect(runAccountDeletion({
|
||||
deletionService,
|
||||
detachPushRegistration: async () => { calls.push('detach') },
|
||||
purgeLocalSession: async () => { calls.push('purge') },
|
||||
})).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(calls).toEqual(['delete', 'detach', 'purge'])
|
||||
})
|
||||
|
||||
test('a push detach failure after deletion does not block the local purge', async () => {
|
||||
const purgeLocalSession = jest.fn(async () => undefined)
|
||||
|
||||
await expect(runAccountDeletion({
|
||||
deletionService: service({ ok: true }),
|
||||
detachPushRegistration: async () => { throw new Error('both push boundaries failed') },
|
||||
purgeLocalSession,
|
||||
})).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(purgeLocalSession).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('a local purge failure propagates to the caller', async () => {
|
||||
await expect(runAccountDeletion({
|
||||
deletionService: service({ ok: true }),
|
||||
detachPushRegistration: async () => undefined,
|
||||
purgeLocalSession: async () => { throw new Error('purge failed') },
|
||||
})).rejects.toThrow('purge failed')
|
||||
})
|
||||
})
|
||||
214
apps/mobile-rn/__tests__/account-retention-redteam-r3-16.test.ts
Normal file
214
apps/mobile-rn/__tests__/account-retention-redteam-r3-16.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
const mockClearHistory = jest.fn()
|
||||
const mockClearEntitlements = jest.fn()
|
||||
const mockClearPreferences = jest.fn()
|
||||
const mockCancelRecorder = jest.fn()
|
||||
const mockAcquireRecorder = jest.fn()
|
||||
const mockClearQueuedAudio = jest.fn()
|
||||
const mockRetainQueuedAudio = jest.fn()
|
||||
const mockClearActions = jest.fn()
|
||||
const mockClearGenerationKeys = jest.fn()
|
||||
const mockDeletePush = jest.fn()
|
||||
|
||||
jest.mock('../src/features/history/history-cache', () => ({
|
||||
clearAllHistoryCaches: () => mockClearHistory(),
|
||||
}))
|
||||
jest.mock('../src/lib/entitlement-context', () => ({
|
||||
clearAllEntitlementCaches: () => mockClearEntitlements(),
|
||||
}))
|
||||
jest.mock('../src/lib/preferences-context', () => ({
|
||||
clearAllUserPreferenceCaches: () => mockClearPreferences(),
|
||||
}))
|
||||
jest.mock('../src/lib/audio-recorder', () => ({
|
||||
audioRecorder: {
|
||||
cancel: () => mockCancelRecorder(),
|
||||
acquire: (owner: string) => mockAcquireRecorder(owner),
|
||||
},
|
||||
}))
|
||||
jest.mock('../src/features/recording/durable-processing-queue', () => ({
|
||||
clearAllQueuedAudio: () => mockClearQueuedAudio(),
|
||||
retainQueuedAudioOnlyForUser: (userId: string) => mockRetainQueuedAudio(userId),
|
||||
}))
|
||||
jest.mock('../src/features/actions/action-service', () => ({
|
||||
clearAllActionHistories: () => mockClearActions(),
|
||||
}))
|
||||
jest.mock('../src/features/templates', () => ({
|
||||
clearEveryGenerationIdempotencyKey: () => mockClearGenerationKeys(),
|
||||
}))
|
||||
jest.mock('../src/features/notifications/notification-native', () => ({
|
||||
deleteNativePushRegistration: () => mockDeletePush(),
|
||||
}))
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { purgeAllAccountLocalData } from '../src/lib/account-local-data'
|
||||
import { DISCARD_UNSYNCED_WORK } from '../src/lib/auth-transition-policy'
|
||||
import { RecorderBusyError } from '../src/lib/recorder/recorder-errors'
|
||||
import type {
|
||||
RecorderSession,
|
||||
RecordingRuntimeSnapshot,
|
||||
} from '../src/lib/recorder/recorder-types'
|
||||
import { stopLiveCaptureKeepingFile } from '../src/lib/retain-live-capture'
|
||||
import {
|
||||
createRetainedWorkOwnerStore,
|
||||
retainedAccountWork,
|
||||
retainedAccountWorkTestContract,
|
||||
} from '../src/lib/retained-account-work'
|
||||
|
||||
const USER_A = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
const cacheOperations = [
|
||||
mockClearHistory,
|
||||
mockClearEntitlements,
|
||||
mockClearPreferences,
|
||||
mockClearActions,
|
||||
mockClearGenerationKeys,
|
||||
mockDeletePush,
|
||||
]
|
||||
|
||||
function snapshot(state: RecordingRuntimeSnapshot['state'], withFile: boolean): RecordingRuntimeSnapshot {
|
||||
return {
|
||||
state,
|
||||
recording: withFile
|
||||
? { uri: 'file:///c.wav', path: '/c.wav', fileName: 'c.wav', mimeType: 'audio/wav', size: 10, durationMs: 90 * 60_000 }
|
||||
: null,
|
||||
meetingId: null,
|
||||
interruptionReason: null,
|
||||
startedAtMs: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSession(overrides: Partial<RecorderSession> = {}): jest.Mocked<RecorderSession> {
|
||||
return {
|
||||
owner: 'record',
|
||||
start: jest.fn(async () => undefined),
|
||||
pause: jest.fn(async () => undefined),
|
||||
resume: jest.fn(async () => undefined),
|
||||
stop: jest.fn(async () => snapshot('stopped', true).recording!),
|
||||
cleanup: jest.fn(async () => undefined),
|
||||
cancel: jest.fn(async () => undefined),
|
||||
restore: jest.fn(async () => snapshot('stopped', true)),
|
||||
...overrides,
|
||||
} as jest.Mocked<RecorderSession>
|
||||
}
|
||||
|
||||
describe('account purge keeps unsynced work on involuntary sign-out (redteam r3-16 #2)', () => {
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear()
|
||||
await retainedAccountWork.release()
|
||||
for (const operation of [...cacheOperations, mockCancelRecorder, mockClearQueuedAudio, mockRetainQueuedAudio]) {
|
||||
operation.mockReset().mockResolvedValue(undefined)
|
||||
}
|
||||
mockAcquireRecorder.mockReset().mockResolvedValue(fakeSession())
|
||||
})
|
||||
|
||||
it('purges caches but keeps the capture and the owner queue', async () => {
|
||||
await purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A })
|
||||
|
||||
for (const operation of cacheOperations) expect(operation).toHaveBeenCalledTimes(1)
|
||||
expect(mockCancelRecorder).not.toHaveBeenCalled()
|
||||
expect(mockClearQueuedAudio).not.toHaveBeenCalled()
|
||||
expect(mockRetainQueuedAudio).toHaveBeenCalledWith(USER_A)
|
||||
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe(USER_A)
|
||||
})
|
||||
|
||||
it('discards everything and releases the retention marker on a discard boundary', async () => {
|
||||
await retainedAccountWork.retain(USER_A)
|
||||
await purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK)
|
||||
|
||||
expect(mockCancelRecorder).toHaveBeenCalledTimes(1)
|
||||
expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1)
|
||||
expect(retainedAccountWork.current()).toBeNull()
|
||||
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBeNull()
|
||||
})
|
||||
|
||||
it('runs a discard requested during a retain run instead of sharing it', async () => {
|
||||
let finishRetain: (() => void) | null = null
|
||||
mockRetainQueuedAudio.mockReturnValueOnce(new Promise<void>((resolve) => {
|
||||
finishRetain = resolve
|
||||
}))
|
||||
const retain = purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A })
|
||||
const discard = purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK)
|
||||
expect(discard).not.toBe(retain)
|
||||
finishRetain?.()
|
||||
await Promise.all([retain, discard])
|
||||
|
||||
expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1)
|
||||
expect(mockCancelRecorder).toHaveBeenCalledTimes(1)
|
||||
expect(retainedAccountWork.current()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopLiveCaptureKeepingFile (redteam r3-16 #2)', () => {
|
||||
it('stops an in-process capture and keeps its reattachable file', async () => {
|
||||
const session = fakeSession()
|
||||
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
|
||||
expect(session.stop).toHaveBeenCalledTimes(1)
|
||||
expect(session.cancel).not.toHaveBeenCalled()
|
||||
expect(session.cleanup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reattaches a capture that outlived a JS restart before stopping it', async () => {
|
||||
const session = fakeSession({
|
||||
stop: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('Cannot stop recorder while it is idle'))
|
||||
.mockResolvedValueOnce(snapshot('stopped', true).recording),
|
||||
restore: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(snapshot('recording', false))
|
||||
.mockResolvedValueOnce(snapshot('stopped', true)),
|
||||
})
|
||||
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
|
||||
expect(session.stop).toHaveBeenCalledTimes(2)
|
||||
expect(session.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to discarding a capture the backend cannot hand back', async () => {
|
||||
const session = fakeSession({ restore: jest.fn(async () => snapshot('idle', false)) })
|
||||
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
|
||||
expect(session.cancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('discards a transient Talk capture through its own owner session', async () => {
|
||||
const talk = fakeSession({ owner: 'talk' })
|
||||
const acquire = jest.fn(async (owner: string) => {
|
||||
if (owner === 'record') throw new RecorderBusyError('talk')
|
||||
return talk
|
||||
})
|
||||
await stopLiveCaptureKeepingFile({ acquire })
|
||||
expect(acquire).toHaveBeenLastCalledWith('talk')
|
||||
expect(talk.cancel).toHaveBeenCalledTimes(1)
|
||||
expect(talk.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('retained work owner store (redteam r3-16 #2)', () => {
|
||||
it('persists the owner across a restart and ignores a stale load', async () => {
|
||||
const values = new Map<string, string>()
|
||||
let resolveRead: ((value: string | null) => void) | null = null
|
||||
const storage = {
|
||||
getItem: jest.fn((key: string) => {
|
||||
const valueAtRead = values.get(key) ?? null
|
||||
return new Promise<string | null>((resolve) => {
|
||||
resolveRead = () => resolve(valueAtRead)
|
||||
})
|
||||
}),
|
||||
setItem: jest.fn(async (key: string, value: string) => { values.set(key, value) }),
|
||||
removeItem: jest.fn(async (key: string) => { values.delete(key) }),
|
||||
}
|
||||
const first = createRetainedWorkOwnerStore(storage)
|
||||
await first.retain('owner-a')
|
||||
|
||||
const restarted = createRetainedWorkOwnerStore(storage)
|
||||
const loading = restarted.load()
|
||||
resolveRead?.(null)
|
||||
await expect(loading).resolves.toBe('owner-a')
|
||||
expect(restarted.current()).toBe('owner-a')
|
||||
|
||||
const staleLoad = createRetainedWorkOwnerStore(storage)
|
||||
const pending = staleLoad.load()
|
||||
await staleLoad.release()
|
||||
resolveRead?.(null)
|
||||
await expect(pending).resolves.toBeNull()
|
||||
expect(staleLoad.current()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -102,19 +102,16 @@ describe('mobile auth redirect boundary', () => {
|
|||
expect(exchangeCodeForSession).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('accepts a complete legacy token pair once and rejects partial credentials', async () => {
|
||||
// redteam r3-16 #1: 토큰 쌍 콜백(implicit flow)은 로그인 CSRF 경로라 완전한 쌍이어도 세션으로 바꾸지 않는다.
|
||||
// 이전 계약("완전한 레거시 쌍은 한 번 받아들인다")을 대체한다 — auth-redteam-r3-16.test.tsx 와 같은 계약.
|
||||
it('rejects token-pair callbacks, complete or partial, without touching the session', async () => {
|
||||
const { operations, setSession } = createOperations()
|
||||
const complete = createAuthRedirectHandler(operations)
|
||||
const url = 'd3ro-voice://auth-callback#access_token=access&refresh_token=refresh&type=recovery'
|
||||
|
||||
await expect(complete(url)).resolves.toBe('recovery')
|
||||
await expect(complete(url)).resolves.toBe('recovery')
|
||||
expect(setSession).toHaveBeenCalledTimes(1)
|
||||
expect(setSession).toHaveBeenCalledWith({
|
||||
access_token: 'access',
|
||||
refresh_token: 'refresh',
|
||||
})
|
||||
await expect(complete('d3ro-voice://auth-callback#access_token=access&refresh_token=refresh&type=recovery'))
|
||||
.rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
await expect(complete('d3ro-voice://auth-callback#access_token=partial'))
|
||||
.rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
expect(setSession).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
298
apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx
Normal file
298
apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import React from 'react'
|
||||
import { Linking } from 'react-native'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { AuthChangeEvent, Session } from '@supabase/supabase-js'
|
||||
|
||||
const mockGetSession = jest.fn()
|
||||
const mockOnAuthStateChange = jest.fn()
|
||||
const mockStopAutoRefresh = jest.fn()
|
||||
const mockStartAutoRefresh = jest.fn()
|
||||
const mockSetSession = jest.fn()
|
||||
const mockExchangeCodeForSession = jest.fn()
|
||||
const mockPurgeAccountLocalData = jest.fn()
|
||||
const mockClearSecureAuthStorage = jest.fn()
|
||||
|
||||
jest.mock('../src/lib/supabase', () => ({
|
||||
isSupabaseConfigured: () => true,
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: (...args: unknown[]) => mockGetSession(...args),
|
||||
onAuthStateChange: (...args: unknown[]) => mockOnAuthStateChange(...args),
|
||||
stopAutoRefresh: (...args: unknown[]) => mockStopAutoRefresh(...args),
|
||||
startAutoRefresh: (...args: unknown[]) => mockStartAutoRefresh(...args),
|
||||
setSession: (...args: unknown[]) => mockSetSession(...args),
|
||||
exchangeCodeForSession: (...args: unknown[]) => mockExchangeCodeForSession(...args),
|
||||
},
|
||||
},
|
||||
}))
|
||||
jest.mock('../src/lib/account-local-data', () => ({
|
||||
purgeAllAccountLocalData: (...args: unknown[]) => mockPurgeAccountLocalData(...args),
|
||||
}))
|
||||
jest.mock('../src/lib/secure-auth-storage', () => ({
|
||||
clearAllSecureAuthStorage: (...args: unknown[]) => mockClearSecureAuthStorage(...args),
|
||||
}))
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { AuthProvider, useAuth } from '../src/lib/auth-context'
|
||||
import { createAuthRedirectHandler } from '../src/lib/auth-redirect'
|
||||
import {
|
||||
DISCARD_UNSYNCED_WORK,
|
||||
planAuthTransition,
|
||||
type AuthTransitionFacts,
|
||||
} from '../src/lib/auth-transition-policy'
|
||||
import {
|
||||
retainedAccountWork,
|
||||
retainedAccountWorkTestContract,
|
||||
} from '../src/lib/retained-account-work'
|
||||
|
||||
type AuthSnapshot = ReturnType<typeof useAuth>
|
||||
type AuthCallback = (event: AuthChangeEvent, session: Session | null) => void
|
||||
type UrlListener = (event: { url: string }) => void
|
||||
|
||||
const ATTACKER_LINK = 'd3ro-voice://auth-callback#access_token=attacker-access&refresh_token=attacker-refresh'
|
||||
|
||||
let latest: AuthSnapshot
|
||||
let authCallback: AuthCallback | null = null
|
||||
let urlListener: UrlListener | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let storedSession: Session | null = null
|
||||
|
||||
function session(userId: string): Session {
|
||||
return {
|
||||
access_token: `access-${userId}`,
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
expires_at: 4_000_000_000,
|
||||
refresh_token: `refresh-${userId}`,
|
||||
user: { id: userId },
|
||||
} as unknown as Session
|
||||
}
|
||||
|
||||
function Probe(): null {
|
||||
latest = useAuth()
|
||||
return null
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 12; index += 1) await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function mount(restoredSession: Session | null): Promise<void> {
|
||||
storedSession = restoredSession
|
||||
await act(async () => {
|
||||
renderer = create(<AuthProvider><Probe /></AuthProvider>)
|
||||
})
|
||||
await flush()
|
||||
}
|
||||
|
||||
function emit(event: AuthChangeEvent, nextSession: Session | null): void {
|
||||
storedSession = nextSession
|
||||
if (authCallback === null) throw new Error('auth callback is not subscribed')
|
||||
authCallback(event, nextSession)
|
||||
}
|
||||
|
||||
function facts(overrides: Partial<AuthTransitionFacts>): AuthTransitionFacts {
|
||||
return {
|
||||
previousUserId: null,
|
||||
nextUserId: null,
|
||||
retainedOwnerUserId: null,
|
||||
forcePurge: false,
|
||||
cleanupPending: false,
|
||||
explicit: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile login CSRF via implicit token callback (redteam r3-16 #1)', () => {
|
||||
it('never turns a token-pair callback into a session', async () => {
|
||||
const setSession = jest.fn(async () => ({ data: { session: null, user: null }, error: null }))
|
||||
const exchangeCodeForSession = jest.fn()
|
||||
const complete = createAuthRedirectHandler({
|
||||
exchangeCodeForSession,
|
||||
setSession,
|
||||
} as unknown as Parameters<typeof createAuthRedirectHandler>[0])
|
||||
|
||||
await expect(complete(ATTACKER_LINK)).rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
await expect(complete(`${ATTACKER_LINK}&type=recovery`))
|
||||
.rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
await expect(complete('d3ro-voice://auth-callback?code=c#access_token=a&refresh_token=r'))
|
||||
.rejects.toMatchObject({ code: 'invalid_callback' })
|
||||
expect(setSession).not.toHaveBeenCalled()
|
||||
expect(exchangeCodeForSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the signed-in victim and their local data when the link is opened', async () => {
|
||||
authCallback = null
|
||||
urlListener = null
|
||||
mockGetSession.mockReset().mockImplementation(async () => ({
|
||||
data: { session: storedSession },
|
||||
error: null,
|
||||
}))
|
||||
mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => {
|
||||
authCallback = callback
|
||||
return { data: { subscription: { unsubscribe: jest.fn() } } }
|
||||
})
|
||||
mockSetSession.mockReset().mockResolvedValue({ data: { session: null, user: null }, error: null })
|
||||
mockPurgeAccountLocalData.mockReset().mockResolvedValue(undefined)
|
||||
jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null)
|
||||
jest.spyOn(Linking, 'addEventListener').mockImplementation(((_type: string, listener: UrlListener) => {
|
||||
urlListener = listener
|
||||
return { remove: jest.fn() }
|
||||
}) as unknown as typeof Linking.addEventListener)
|
||||
|
||||
await mount(session('victim'))
|
||||
expect(latest.user?.id).toBe('victim')
|
||||
|
||||
await act(async () => {
|
||||
urlListener?.({ url: ATTACKER_LINK })
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(mockSetSession).not.toHaveBeenCalled()
|
||||
expect(mockPurgeAccountLocalData).not.toHaveBeenCalled()
|
||||
expect(latest.user?.id).toBe('victim')
|
||||
expect(latest.authError).toBe('callback_failed')
|
||||
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
describe('auth transition policy (redteam r3-16 #2)', () => {
|
||||
it('retains the owner work on involuntary session loss', () => {
|
||||
expect(planAuthTransition(facts({ previousUserId: 'a' }))).toEqual({
|
||||
purge: true,
|
||||
unsyncedWork: { kind: 'retain', ownerUserId: 'a' },
|
||||
})
|
||||
})
|
||||
|
||||
it('discards on explicit logout and on a different account', () => {
|
||||
expect(planAuthTransition(facts({ previousUserId: 'a', explicit: true, forcePurge: true })))
|
||||
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
|
||||
expect(planAuthTransition(facts({ previousUserId: 'a', nextUserId: 'b' })))
|
||||
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
|
||||
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', nextUserId: 'b' })))
|
||||
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
|
||||
})
|
||||
|
||||
it('lets the owner come back without a purge and releases the marker', () => {
|
||||
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', nextUserId: 'a' })))
|
||||
.toEqual({ purge: false, releaseRetainedWork: true })
|
||||
expect(planAuthTransition(facts({ previousUserId: 'a', nextUserId: 'a' })))
|
||||
.toEqual({ purge: false, releaseRetainedWork: false })
|
||||
})
|
||||
|
||||
it('keeps retained work across a cold boot without a session, discards unowned work', () => {
|
||||
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', forcePurge: true })))
|
||||
.toEqual({ purge: true, unsyncedWork: { kind: 'retain', ownerUserId: 'a' } })
|
||||
expect(planAuthTransition(facts({ forcePurge: true })))
|
||||
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
|
||||
})
|
||||
})
|
||||
|
||||
describe('AuthProvider keeps unsynced recordings on involuntary sign-out (redteam r3-16 #2)', () => {
|
||||
beforeEach(async () => {
|
||||
authCallback = null
|
||||
renderer = null
|
||||
storedSession = null
|
||||
await AsyncStorage.clear()
|
||||
await retainedAccountWork.release()
|
||||
mockGetSession.mockReset().mockImplementation(async () => ({
|
||||
data: { session: storedSession },
|
||||
error: null,
|
||||
}))
|
||||
mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => {
|
||||
authCallback = callback
|
||||
return { data: { subscription: { unsubscribe: jest.fn() } } }
|
||||
})
|
||||
mockStopAutoRefresh.mockReset().mockResolvedValue(undefined)
|
||||
mockStartAutoRefresh.mockReset().mockResolvedValue(undefined)
|
||||
mockClearSecureAuthStorage.mockReset().mockResolvedValue(undefined)
|
||||
// The real purge records the retained owner; mirror that contract here.
|
||||
mockPurgeAccountLocalData.mockReset().mockImplementation(async (disposition?: {
|
||||
kind: 'discard' | 'retain'
|
||||
ownerUserId?: string
|
||||
}) => {
|
||||
if (disposition?.kind === 'retain' && disposition.ownerUserId !== undefined) {
|
||||
await retainedAccountWork.retain(disposition.ownerUserId)
|
||||
} else {
|
||||
await retainedAccountWork.release()
|
||||
}
|
||||
})
|
||||
jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (renderer !== null) act(() => renderer?.unmount())
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('retains A work on SIGNED_OUT and resumes it without a purge when A returns', async () => {
|
||||
await mount(session('user-a'))
|
||||
act(() => emit('SIGNED_OUT', null))
|
||||
await flush()
|
||||
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenCalledTimes(1)
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith({ kind: 'retain', ownerUserId: 'user-a' })
|
||||
expect(mockClearSecureAuthStorage).toHaveBeenCalledTimes(1)
|
||||
expect(latest.user).toBeNull()
|
||||
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe('user-a')
|
||||
|
||||
act(() => emit('SIGNED_IN', session('user-a')))
|
||||
await flush()
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenCalledTimes(1)
|
||||
expect(latest.user?.id).toBe('user-a')
|
||||
expect(retainedAccountWork.current()).toBeNull()
|
||||
})
|
||||
|
||||
it('discards retained work before a different account is committed', async () => {
|
||||
await mount(session('user-a'))
|
||||
act(() => emit('SIGNED_OUT', null))
|
||||
await flush()
|
||||
|
||||
let finishDiscard: (() => void) | null = null
|
||||
mockPurgeAccountLocalData.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
finishDiscard = resolve
|
||||
}))
|
||||
act(() => emit('SIGNED_IN', session('user-b')))
|
||||
await flush()
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith(DISCARD_UNSYNCED_WORK)
|
||||
expect(latest.user).toBeNull()
|
||||
|
||||
finishDiscard?.()
|
||||
await flush()
|
||||
expect(latest.user?.id).toBe('user-b')
|
||||
})
|
||||
|
||||
it('keeps retained work across a cold boot with no session', async () => {
|
||||
await retainedAccountWork.retain('user-a')
|
||||
await mount(null)
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenCalledWith({ kind: 'retain', ownerUserId: 'user-a' })
|
||||
})
|
||||
|
||||
it('still discards everything on an explicit logout', async () => {
|
||||
await mount(session('user-a'))
|
||||
await act(async () => {
|
||||
await latest.purgeLocalSession()
|
||||
})
|
||||
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith(DISCARD_UNSYNCED_WORK)
|
||||
})
|
||||
|
||||
it('discards retained work when a racing newer session belongs to another account', async () => {
|
||||
await mount(session('user-a'))
|
||||
storedSession = session('user-b')
|
||||
act(() => {
|
||||
authCallback?.('SIGNED_OUT', null)
|
||||
})
|
||||
await flush()
|
||||
expect(mockPurgeAccountLocalData.mock.calls.map(([disposition]) => disposition)).toEqual([
|
||||
{ kind: 'retain', ownerUserId: 'user-a' },
|
||||
DISCARD_UNSYNCED_WORK,
|
||||
])
|
||||
expect(latest.user?.id).toBe('user-b')
|
||||
expect(mockClearSecureAuthStorage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { FileSystem } from 'react-native-file-access'
|
||||
import type {
|
||||
AudioPipelineResult,
|
||||
LocalAudioInput,
|
||||
} from '../src/features/import/audio-import-types'
|
||||
|
||||
const mockProcessAudioInput = jest.fn<Promise<AudioPipelineResult>, [LocalAudioInput, unknown]>()
|
||||
|
||||
jest.mock('../src/features/import/audio-transcription-service', () => ({
|
||||
processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options),
|
||||
}))
|
||||
jest.mock('../src/features/meetings/meetings-service', () => ({
|
||||
failMeetingRecording: jest.fn(async () => undefined),
|
||||
markMeetingProcessingFailure: jest.fn(async () => undefined),
|
||||
queueMeetingRecording: jest.fn(async () => undefined),
|
||||
}))
|
||||
|
||||
import {
|
||||
clearQueuedAudioForUser,
|
||||
durableQueueTestContract,
|
||||
resumeQueuedAudioForUser,
|
||||
retainQueuedAudioOnlyForUser,
|
||||
type DurableQueueItem,
|
||||
} from '../src/features/recording/durable-processing-queue'
|
||||
|
||||
const START_MS = 1_700_000_000_000
|
||||
const USER_A = '11111111-1111-4111-8111-111111111111'
|
||||
const USER_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
const mockFileSystem = FileSystem as typeof FileSystem & { filesystem: Map<string, string> }
|
||||
|
||||
function queued(
|
||||
userId: string,
|
||||
suffix: string,
|
||||
overrides: Partial<DurableQueueItem> = {},
|
||||
): DurableQueueItem {
|
||||
const path = `${durableQueueTestContract.queueDirectory}/queued-${suffix}.wav`
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: suffix,
|
||||
userId,
|
||||
meetingId: null,
|
||||
path,
|
||||
uri: `file://${path}`,
|
||||
fileName: `${suffix}.wav`,
|
||||
mimeType: 'audio/wav',
|
||||
sizeBytes: 5,
|
||||
durationMs: 100,
|
||||
source: 'recording',
|
||||
languageCode: 'ko',
|
||||
status: 'retry',
|
||||
phase: null,
|
||||
attempts: 1,
|
||||
uploadedBytes: 0,
|
||||
nextAttemptAtMs: START_MS + 5_000,
|
||||
lastErrorCode: 'upload',
|
||||
lastErrorMessage: null,
|
||||
createdAtMs: 1,
|
||||
updatedAtMs: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function result(): AudioPipelineResult {
|
||||
return {
|
||||
historyId: null,
|
||||
audioFileId: 'audio-file',
|
||||
meetingId: null,
|
||||
processingJobId: null,
|
||||
transcript: 'ok',
|
||||
provider: 'test',
|
||||
language: 'ko',
|
||||
durationSeconds: 1,
|
||||
deduplicated: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function seed(items: DurableQueueItem[]): Promise<void> {
|
||||
for (const item of items) mockFileSystem.filesystem.set(item.path, 'audio')
|
||||
await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify(items))
|
||||
}
|
||||
|
||||
async function stored(): Promise<DurableQueueItem[]> {
|
||||
return durableQueueTestContract.parseQueue(
|
||||
await AsyncStorage.getItem(durableQueueTestContract.storageKey),
|
||||
)
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0)
|
||||
}
|
||||
|
||||
describe('durable queue keeps the signed-out owner recordings (redteam r3-16 #2)', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers({ now: START_MS })
|
||||
await AsyncStorage.clear()
|
||||
mockFileSystem.filesystem.clear()
|
||||
mockProcessAudioInput.mockReset().mockResolvedValue(result())
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const userId of [USER_A, USER_B]) {
|
||||
await clearQueuedAudioForUser(userId).catch(() => undefined)
|
||||
}
|
||||
await settle()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
test('keeps the owner items and files, discards other accounts', async () => {
|
||||
const ownerRetry = queued(USER_A, 'aaa001')
|
||||
const ownerPending = queued(USER_A, 'aaa002', { status: 'pending', attempts: 0, nextAttemptAtMs: START_MS })
|
||||
const other = queued(USER_B, 'bbb001')
|
||||
await seed([ownerRetry, ownerPending, other])
|
||||
|
||||
await retainQueuedAudioOnlyForUser(USER_A)
|
||||
|
||||
expect((await stored()).map(item => item.id).sort()).toEqual(['aaa001', 'aaa002'])
|
||||
expect(mockFileSystem.filesystem.has(ownerRetry.path)).toBe(true)
|
||||
expect(mockFileSystem.filesystem.has(ownerPending.path)).toBe(true)
|
||||
expect(mockFileSystem.filesystem.has(other.path)).toBe(false)
|
||||
})
|
||||
|
||||
test('does not spend retries while the owner is signed out, resumes on sign-in', async () => {
|
||||
const item = queued(USER_A, 'aaa003', { nextAttemptAtMs: START_MS })
|
||||
await seed([item])
|
||||
|
||||
await retainQueuedAudioOnlyForUser(USER_A)
|
||||
await jest.advanceTimersByTimeAsync(60 * 60_000)
|
||||
await settle()
|
||||
expect(mockProcessAudioInput).not.toHaveBeenCalled()
|
||||
expect((await stored())[0]?.attempts).toBe(1)
|
||||
|
||||
await resumeQueuedAudioForUser(USER_A)
|
||||
await settle()
|
||||
expect(mockProcessAudioInput).toHaveBeenCalledTimes(1)
|
||||
expect(await stored()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
const mockRealtimeOn = jest.fn()
|
||||
const mockRealtimeSubscribe = jest.fn()
|
||||
const mockRemoveChannel = jest.fn(async () => 'ok')
|
||||
const mockRealtimeChannel: Record<string, unknown> = {
|
||||
on: mockRealtimeOn,
|
||||
subscribe: mockRealtimeSubscribe,
|
||||
}
|
||||
mockRealtimeOn.mockReturnValue(mockRealtimeChannel)
|
||||
mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel)
|
||||
|
||||
jest.mock('../src/lib/supabase', () => ({
|
||||
supabase: {
|
||||
from: jest.fn(),
|
||||
channel: jest.fn(() => mockRealtimeChannel),
|
||||
removeChannel: (...args: unknown[]) => mockRemoveChannel(...(args as [])),
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
type ForegroundStatePort,
|
||||
type IntervalPort,
|
||||
KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS,
|
||||
knowledgeRealtimeBindings,
|
||||
startForegroundReconciliation,
|
||||
} from '../src/features/knowledge/knowledge-realtime'
|
||||
import { subscribeToKnowledgeDocuments } from '../src/features/knowledge/knowledge-service'
|
||||
|
||||
const USER_A = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
interface FakeForeground extends ForegroundStatePort {
|
||||
set: (active: boolean) => void
|
||||
listenerCount: () => number
|
||||
}
|
||||
|
||||
function fakeForeground(initial: boolean): FakeForeground {
|
||||
let active = initial
|
||||
const listeners = new Set<(active: boolean) => void>()
|
||||
return {
|
||||
isActive: () => active,
|
||||
onChange: (listener) => {
|
||||
listeners.add(listener)
|
||||
return { remove: () => { listeners.delete(listener) } }
|
||||
},
|
||||
set: (next) => {
|
||||
active = next
|
||||
for (const listener of listeners) listener(next)
|
||||
},
|
||||
listenerCount: () => listeners.size,
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeInterval extends IntervalPort {
|
||||
tick: () => void
|
||||
cleared: () => boolean
|
||||
lastMs: () => number | null
|
||||
}
|
||||
|
||||
function fakeInterval(): FakeInterval {
|
||||
let callback: (() => void) | null = null
|
||||
let cleared = false
|
||||
let lastMs: number | null = null
|
||||
const handle = 1 as unknown as ReturnType<typeof setInterval>
|
||||
return {
|
||||
set: (cb, ms) => { callback = cb; lastMs = ms; return handle },
|
||||
clear: () => { cleared = true; callback = null },
|
||||
tick: () => { callback?.() },
|
||||
cleared: () => cleared,
|
||||
lastMs: () => lastMs,
|
||||
}
|
||||
}
|
||||
|
||||
function subscribedBindings(): Array<{ event: string, filter?: string, table: string }> {
|
||||
return mockRealtimeOn.mock.calls.map((call) => call[1] as {
|
||||
event: string
|
||||
filter?: string
|
||||
table: string
|
||||
})
|
||||
}
|
||||
|
||||
describe('knowledge realtime policy (redteam r3-20)', () => {
|
||||
beforeEach(() => {
|
||||
mockRealtimeOn.mockClear()
|
||||
mockRealtimeSubscribe.mockClear()
|
||||
mockRemoveChannel.mockClear()
|
||||
})
|
||||
|
||||
it('never subscribes to unfiltered DELETE events on knowledge_documents', () => {
|
||||
const subscription = subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
|
||||
reconciliation: { foreground: fakeForeground(true), interval: fakeInterval() },
|
||||
})
|
||||
const events = subscribedBindings().map((binding) => binding.event)
|
||||
expect(events).not.toContain('DELETE')
|
||||
expect(events).not.toContain('*')
|
||||
expect(events).toEqual(['INSERT', 'UPDATE'])
|
||||
void subscription.unsubscribe()
|
||||
})
|
||||
|
||||
it('narrows INSERT/UPDATE to the owner when a user id is supplied', () => {
|
||||
const subscription = subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
|
||||
userId: USER_A,
|
||||
reconciliation: { foreground: fakeForeground(true), interval: fakeInterval() },
|
||||
})
|
||||
expect(subscribedBindings()).toEqual([
|
||||
expect.objectContaining({ event: 'INSERT', filter: `user_id=eq.${USER_A}` }),
|
||||
expect.objectContaining({ event: 'UPDATE', filter: `user_id=eq.${USER_A}` }),
|
||||
])
|
||||
void subscription.unsubscribe()
|
||||
})
|
||||
|
||||
it('rejects a malformed owner id instead of interpolating it into the filter', () => {
|
||||
expect(() => subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
|
||||
userId: 'x,user_id=neq.0',
|
||||
})).toThrow()
|
||||
expect(mockRealtimeOn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconciles deletions by foreground polling and stops on unsubscribe', async () => {
|
||||
const onChanged = jest.fn()
|
||||
const foreground = fakeForeground(true)
|
||||
const interval = fakeInterval()
|
||||
const subscription = subscribeToKnowledgeDocuments(onChanged, jest.fn(), {
|
||||
reconciliation: { foreground, interval },
|
||||
})
|
||||
expect(interval.lastMs()).toBe(KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS)
|
||||
|
||||
interval.tick()
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
|
||||
await subscription.unsubscribe()
|
||||
expect(interval.cleared()).toBe(true)
|
||||
expect(foreground.listenerCount()).toBe(0)
|
||||
expect(mockRemoveChannel).toHaveBeenCalledWith(mockRealtimeChannel)
|
||||
interval.tick()
|
||||
foreground.set(false)
|
||||
foreground.set(true)
|
||||
expect(onChanged).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('pauses polling in the background and reconciles once on resume', () => {
|
||||
const onReconcile = jest.fn()
|
||||
const foreground = fakeForeground(true)
|
||||
const interval = fakeInterval()
|
||||
const reconciliation = startForegroundReconciliation(onReconcile, { foreground, interval })
|
||||
|
||||
foreground.set(false)
|
||||
interval.tick()
|
||||
expect(onReconcile).not.toHaveBeenCalled()
|
||||
|
||||
foreground.set(true)
|
||||
expect(onReconcile).toHaveBeenCalledTimes(1)
|
||||
foreground.set(true)
|
||||
expect(onReconcile).toHaveBeenCalledTimes(1)
|
||||
|
||||
interval.tick()
|
||||
expect(onReconcile).toHaveBeenCalledTimes(2)
|
||||
reconciliation.stop()
|
||||
reconciliation.stop()
|
||||
})
|
||||
|
||||
it('builds bindings without DELETE and without a filter when no owner is given', () => {
|
||||
expect(knowledgeRealtimeBindings()).toEqual([
|
||||
{ event: 'INSERT', schema: 'public', table: 'knowledge_documents' },
|
||||
{ event: 'UPDATE', schema: 'public', table: 'knowledge_documents' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
@ -235,9 +235,11 @@ describe('knowledge edge contracts', () => {
|
|||
})).rejects.toMatchObject({ code: 'invalid-response' })
|
||||
})
|
||||
|
||||
it('subscribes to document inserts, updates, and deletes through RLS realtime', () => {
|
||||
// redteam r3-20: 필터가 걸린 postgres_changes 는 DELETE 를 보내지 않고, 필터 없는 DELETE 구독은 남의 행 id 를 흘린다.
|
||||
// 삭제는 knowledge-realtime 의 다른 경로로 받는다 — knowledge-realtime-redteam-r3-20.test.ts 와 같은 계약.
|
||||
it('subscribes to document inserts and updates through RLS realtime, never to DELETE', () => {
|
||||
subscribeToKnowledgeDocuments(jest.fn(), jest.fn())
|
||||
const events = mockRealtimeOn.mock.calls.map((call) => (call[1] as { event: string }).event)
|
||||
expect(events).toEqual(['INSERT', 'UPDATE', 'DELETE'])
|
||||
expect(events).toEqual(['INSERT', 'UPDATE'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
jest.mock('../src/lib/supabase', () => ({
|
||||
supabase: {
|
||||
rpc: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import type { Meeting, MeetingStatus } from '@d3ro/api-client';
|
||||
import { supabase } from '../src/lib/supabase';
|
||||
import {
|
||||
beginMeetingRecording,
|
||||
cancelMeetingRecording,
|
||||
completeMeetingProcessing,
|
||||
failMeetingRecording,
|
||||
queueMeetingRecording,
|
||||
} from '../src/features/meetings/meetings-service';
|
||||
import {
|
||||
confirmsMeetingTransition,
|
||||
expectedMeetingStates,
|
||||
} from '../src/features/meetings/meeting-recording-state-policy';
|
||||
|
||||
const USER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const OTHER_USER_ID = '55555555-5555-4555-8555-555555555555';
|
||||
const MEETING_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const AUDIO_ID = '33333333-3333-4333-8333-333333333333';
|
||||
const IDEMPOTENCY = `mobile-meeting:${MEETING_ID}:${'a'.repeat(64)}`;
|
||||
const mockRpc = supabase.rpc as jest.Mock;
|
||||
|
||||
function meeting(status: MeetingStatus, userId = USER_ID): Meeting {
|
||||
return {
|
||||
id: MEETING_ID,
|
||||
user_id: userId,
|
||||
team_id: null,
|
||||
title: 'Re-recorded meeting',
|
||||
status,
|
||||
started_at: '2026-08-21T00:00:00.000Z',
|
||||
ended_at: '2026-08-21T00:01:00.000Z',
|
||||
duration_ms: 60_000,
|
||||
raw_transcript: 'earlier transcript',
|
||||
edited_transcript: null,
|
||||
minutes_markdown: 'earlier minutes',
|
||||
minutes_json: null,
|
||||
stt_model: 'whisper',
|
||||
llm_model: null,
|
||||
stt_latency_ms: 100,
|
||||
llm_latency_ms: null,
|
||||
error_message: null,
|
||||
audio_storage_key: `${USER_ID}/imports/earlier.wav`,
|
||||
created_at: '2026-08-21T00:00:00.000Z',
|
||||
updated_at: '2026-08-21T00:01:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('re-record failure paths (server restores a meeting with content to completed)', () => {
|
||||
beforeEach(() => mockRpc.mockReset());
|
||||
|
||||
test('cancelMeetingRecording accepts the restored completed meeting', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null });
|
||||
await expect(cancelMeetingRecording(USER_ID, MEETING_ID))
|
||||
.resolves.toMatchObject({ status: 'completed', raw_transcript: 'earlier transcript' });
|
||||
expect(mockRpc).toHaveBeenCalledWith('mobile_cancel_meeting_recording', { p_meeting_id: MEETING_ID });
|
||||
});
|
||||
|
||||
test('failMeetingRecording accepts the restored completed meeting', async () => {
|
||||
mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null });
|
||||
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'Queued audio processing was cancelled'))
|
||||
.resolves.toMatchObject({ status: 'completed' });
|
||||
expect(mockRpc).toHaveBeenCalledWith('mobile_fail_meeting_recording', {
|
||||
p_meeting_id: MEETING_ID,
|
||||
p_error_message: 'Queued audio processing was cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
test('a first recording still confirms the error outcome', async () => {
|
||||
mockRpc
|
||||
.mockResolvedValueOnce({ data: meeting('error'), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting('error'), error: null });
|
||||
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).resolves.toMatchObject({ status: 'error' });
|
||||
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).resolves.toMatchObject({ status: 'error' });
|
||||
});
|
||||
|
||||
test.each<MeetingStatus>(['recording', 'processing'])(
|
||||
'cancel/fail still reject a meeting left in %s',
|
||||
async (status) => {
|
||||
mockRpc
|
||||
.mockResolvedValueOnce({ data: meeting(status), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting(status), error: null });
|
||||
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({
|
||||
code: 'server',
|
||||
message: 'Meeting cancellation was not confirmed',
|
||||
});
|
||||
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({
|
||||
code: 'server',
|
||||
message: 'Meeting error state was not confirmed',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test('cancel/fail still reject a row owned by another user', async () => {
|
||||
mockRpc
|
||||
.mockResolvedValueOnce({ data: meeting('completed', OTHER_USER_ID), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting('error', OTHER_USER_ID), error: null });
|
||||
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ code: 'server' });
|
||||
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({ code: 'server' });
|
||||
});
|
||||
|
||||
test('the other transitions keep their single confirmed state', async () => {
|
||||
mockRpc
|
||||
.mockResolvedValueOnce({ data: meeting('completed'), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting('completed'), error: null })
|
||||
.mockResolvedValueOnce({ data: meeting('error'), error: null });
|
||||
await expect(beginMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({
|
||||
message: 'Meeting recording state was not confirmed',
|
||||
});
|
||||
await expect(queueMeetingRecording(USER_ID, MEETING_ID, 1_000)).rejects.toMatchObject({
|
||||
message: 'Queued meeting state was not confirmed',
|
||||
});
|
||||
await expect(completeMeetingProcessing(USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY, {
|
||||
transcript: 't',
|
||||
language: 'ko',
|
||||
provider: 'whisper',
|
||||
durationMs: 1_000,
|
||||
sttLatencyMs: 10,
|
||||
})).rejects.toMatchObject({ message: 'Meeting completion was not confirmed' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('meeting recording state policy', () => {
|
||||
test('lists the states each transition may produce', () => {
|
||||
expect(expectedMeetingStates('recording-started')).toEqual(['recording']);
|
||||
expect(expectedMeetingStates('recording-queued')).toEqual(['processing']);
|
||||
expect(expectedMeetingStates('processing-completed')).toEqual(['completed']);
|
||||
expect(expectedMeetingStates('capture-abandoned')).toEqual(['error', 'completed']);
|
||||
});
|
||||
|
||||
test('confirms only the caller-owned meeting in an expected state', () => {
|
||||
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'completed' }, USER_ID, 'capture-abandoned')).toBe(true);
|
||||
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(true);
|
||||
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'processing' }, USER_ID, 'capture-abandoned')).toBe(false);
|
||||
expect(confirmsMeetingTransition({ user_id: OTHER_USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(false);
|
||||
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'processing-completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const mockMaybeSingle = jest.fn(async () => ({
|
||||
data: null,
|
||||
error: { message: 'network down', code: 'NETWORK' },
|
||||
}))
|
||||
|
||||
jest.mock('../src/lib/auth-context', () => ({
|
||||
useAuth: () => ({ user: { id: '22222222-2222-4222-8222-222222222222' } }),
|
||||
}))
|
||||
|
||||
jest.mock('../src/lib/supabase', () => {
|
||||
const channel = {
|
||||
on: () => channel,
|
||||
subscribe: () => channel,
|
||||
}
|
||||
const builder = {
|
||||
select: () => builder,
|
||||
eq: () => builder,
|
||||
maybeSingle: () => mockMaybeSingle(),
|
||||
}
|
||||
return {
|
||||
supabase: {
|
||||
from: () => builder,
|
||||
channel: () => channel,
|
||||
removeChannel: jest.fn(async () => 'ok'),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
MobilePreferencesProvider,
|
||||
useMobilePreferences,
|
||||
} from '../src/lib/preferences-context'
|
||||
|
||||
type RetrySync = () => Promise<void>
|
||||
|
||||
const loadingHistory: boolean[] = []
|
||||
let screenMounts = 0
|
||||
let latestRetrySync: RetrySync | null = null
|
||||
let latestSyncStatus: string | null = null
|
||||
|
||||
function Screen(): React.ReactElement | null {
|
||||
useEffect(() => {
|
||||
screenMounts += 1
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function Gate(): React.ReactElement | null {
|
||||
const { loading, retrySync, syncStatus } = useMobilePreferences()
|
||||
loadingHistory.push(loading)
|
||||
latestRetrySync = retrySync
|
||||
latestSyncStatus = syncStatus
|
||||
// Mirrors App.tsx: while loading, the navigation tree is replaced by a boot screen.
|
||||
return loading ? null : <Screen />
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('mobile preferences background resync (redteam r3-18)', () => {
|
||||
let appStateListeners: Array<(state: AppStateStatus) => void>
|
||||
let renderer: ReactTestRenderer | null
|
||||
|
||||
beforeEach(async () => {
|
||||
await AsyncStorage.clear()
|
||||
loadingHistory.length = 0
|
||||
screenMounts = 0
|
||||
latestRetrySync = null
|
||||
latestSyncStatus = null
|
||||
renderer = null
|
||||
appStateListeners = []
|
||||
jest.spyOn(AppState, 'addEventListener').mockImplementation((type, listener) => {
|
||||
if (type === 'change') {
|
||||
appStateListeners.push(listener as (state: AppStateStatus) => void)
|
||||
}
|
||||
return {
|
||||
remove: () => {
|
||||
appStateListeners = appStateListeners.filter((entry) => entry !== listener)
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderer?.unmount()
|
||||
})
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function renderOffline(): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
<MobilePreferencesProvider>
|
||||
<Gate />
|
||||
</MobilePreferencesProvider>,
|
||||
)
|
||||
await flushAsync()
|
||||
})
|
||||
expect(latestSyncStatus).toBe('offline')
|
||||
expect(loadingHistory[loadingHistory.length - 1]).toBe(false)
|
||||
expect(screenMounts).toBe(1)
|
||||
loadingHistory.length = 0
|
||||
}
|
||||
|
||||
test('foreground resync after an offline start never unmounts the app tree', async () => {
|
||||
await renderOffline()
|
||||
|
||||
act(() => {
|
||||
for (const listener of appStateListeners) listener('active')
|
||||
})
|
||||
await act(async () => {
|
||||
await flushAsync()
|
||||
})
|
||||
|
||||
expect(mockMaybeSingle.mock.calls.length).toBeGreaterThanOrEqual(2)
|
||||
expect(loadingHistory).not.toContain(true)
|
||||
expect(screenMounts).toBe(1)
|
||||
expect(latestSyncStatus).toBe('offline')
|
||||
})
|
||||
|
||||
test('Settings retry with no pending patch keeps the current screen mounted', async () => {
|
||||
await renderOffline()
|
||||
const retry = latestRetrySync
|
||||
if (retry === null) throw new Error('retrySync missing')
|
||||
|
||||
let pending: Promise<void> = Promise.resolve()
|
||||
act(() => {
|
||||
pending = retry()
|
||||
})
|
||||
await act(async () => {
|
||||
await pending
|
||||
await flushAsync()
|
||||
})
|
||||
|
||||
expect(loadingHistory).not.toContain(true)
|
||||
expect(screenMounts).toBe(1)
|
||||
})
|
||||
})
|
||||
356
apps/mobile-rn/__tests__/preferences-store.test.ts
Normal file
356
apps/mobile-rn/__tests__/preferences-store.test.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
import {
|
||||
createPreferencesStore,
|
||||
preferencesOwnerFor,
|
||||
getUserCacheKey,
|
||||
INSTALLATION_CACHE_KEY,
|
||||
type PreferencesRemote,
|
||||
type PreferencesStorage,
|
||||
type PreferencesStore,
|
||||
type PreferencesStoreSnapshot,
|
||||
type RemoteInsertResult,
|
||||
type UserSettingsRowMutation,
|
||||
} from '../src/lib/preferences-store'
|
||||
|
||||
const USER_A = 'user-a'
|
||||
const USER_B = 'user-b'
|
||||
const NOW = '2026-09-28T00:00:00.000Z'
|
||||
|
||||
function createMemoryStorage(): PreferencesStorage & { data: Map<string, string>, failReads: boolean, failWrites: boolean } {
|
||||
const data = new Map<string, string>()
|
||||
const storage = {
|
||||
data,
|
||||
failReads: false,
|
||||
failWrites: false,
|
||||
async multiGet(keys: readonly string[]) {
|
||||
if (storage.failReads) throw new Error('read failed')
|
||||
return keys.map((key) => [key, data.get(key) ?? null] as const)
|
||||
},
|
||||
async setItem(key: string, value: string) {
|
||||
if (storage.failWrites) throw new Error('write failed')
|
||||
data.set(key, value)
|
||||
},
|
||||
async multiSet(pairs: Array<[string, string]>) {
|
||||
if (storage.failWrites) throw new Error('write failed')
|
||||
for (const [key, value] of pairs) data.set(key, value)
|
||||
},
|
||||
async getAllKeys() {
|
||||
return [...data.keys()]
|
||||
},
|
||||
async multiRemove(keys: readonly string[]) {
|
||||
for (const key of keys) data.delete(key)
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
function row(userId: string, overrides: Partial<UserSettingsRowMutation> = {}): UserSettingsRowMutation {
|
||||
return {
|
||||
user_id: userId,
|
||||
theme_mode: 'dark',
|
||||
locale: 'en',
|
||||
haptic_enabled: true,
|
||||
auto_polish_enabled: true,
|
||||
preferred_stt_model: null,
|
||||
preferred_llm_model: null,
|
||||
onboarding_version: 1,
|
||||
tutorial_completed_at: null,
|
||||
revision: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeRemote extends PreferencesRemote {
|
||||
rows: Map<string, UserSettingsRowMutation>
|
||||
failFetch: boolean
|
||||
fetchRow: jest.Mock<Promise<unknown>, [string]>
|
||||
insertRow: jest.Mock<Promise<RemoteInsertResult>, [UserSettingsRowMutation]>
|
||||
updateRowAtRevision: jest.Mock<Promise<unknown>, [UserSettingsRowMutation, number]>
|
||||
}
|
||||
|
||||
function createFakeRemote(): FakeRemote {
|
||||
const rows = new Map<string, UserSettingsRowMutation>()
|
||||
const remote: FakeRemote = {
|
||||
rows,
|
||||
failFetch: false,
|
||||
fetchRow: jest.fn(async (userId: string) => {
|
||||
if (remote.failFetch) throw new Error('offline')
|
||||
return rows.get(userId) ?? null
|
||||
}),
|
||||
insertRow: jest.fn(async (value: UserSettingsRowMutation): Promise<RemoteInsertResult> => {
|
||||
if (rows.has(value.user_id)) return { status: 'duplicate' }
|
||||
rows.set(value.user_id, value)
|
||||
return { status: 'inserted', row: value }
|
||||
}),
|
||||
updateRowAtRevision: jest.fn(async (value: UserSettingsRowMutation, expected: number) => {
|
||||
const current = rows.get(value.user_id)
|
||||
if (current === undefined || current.revision !== expected) return null
|
||||
rows.set(value.user_id, value)
|
||||
return value
|
||||
}),
|
||||
}
|
||||
return remote
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 30; index += 1) await Promise.resolve()
|
||||
}
|
||||
|
||||
function recordPhases(store: PreferencesStore): PreferencesStoreSnapshot[] {
|
||||
const history: PreferencesStoreSnapshot[] = []
|
||||
store.subscribe(() => history.push(store.getSnapshot()))
|
||||
return history
|
||||
}
|
||||
|
||||
describe('preferences store', () => {
|
||||
test('first load of an owner enters initial-loading, then becomes ready', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A))
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
const history = recordPhases(store)
|
||||
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
expect(history[0]?.phase).toBe('initial-loading')
|
||||
expect(store.getSnapshot()).toMatchObject({
|
||||
ownerKey: `user:${USER_A}`,
|
||||
phase: 'ready',
|
||||
syncStatus: 'synced',
|
||||
errorCode: null,
|
||||
lastSyncedAt: NOW,
|
||||
})
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('dark')
|
||||
})
|
||||
|
||||
test('resync after an offline start never re-enters initial-loading', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
const remote = createFakeRemote()
|
||||
remote.failFetch = true
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'offline' })
|
||||
expect(store.needsForegroundResync()).toBe(true)
|
||||
|
||||
const history = recordPhases(store)
|
||||
await store.resync()
|
||||
remote.failFetch = false
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'light', revision: 4 }))
|
||||
await store.resync()
|
||||
|
||||
expect(history.length).toBeGreaterThan(0)
|
||||
expect(history.every((entry) => entry.phase === 'ready')).toBe(true)
|
||||
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
expect(store.needsForegroundResync()).toBe(false)
|
||||
})
|
||||
|
||||
test('reloading the same ready owner is a background refresh', async () => {
|
||||
const store = createPreferencesStore({
|
||||
storage: createMemoryStorage(),
|
||||
remote: createFakeRemote(),
|
||||
now: () => NOW,
|
||||
})
|
||||
await store.load(preferencesOwnerFor(null))
|
||||
const history = recordPhases(store)
|
||||
|
||||
await store.load(preferencesOwnerFor(null))
|
||||
|
||||
expect(history.every((entry) => entry.phase === 'ready')).toBe(true)
|
||||
})
|
||||
|
||||
test('concurrent resync calls share one refresh', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.failFetch = true
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
remote.fetchRow.mockClear()
|
||||
|
||||
await Promise.all([store.resync(), store.resync(), store.resync()])
|
||||
|
||||
expect(remote.fetchRow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('resync with a pending patch flushes it instead of reloading', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { revision: 2 }))
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
remote.failFetch = true
|
||||
const result = await store.commit({ hapticEnabled: false })
|
||||
expect(result).toEqual({ localSaved: true, serverSynced: false })
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_FAILED' })
|
||||
|
||||
remote.failFetch = false
|
||||
await store.resync()
|
||||
|
||||
expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false, revision: 3 })
|
||||
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
|
||||
})
|
||||
|
||||
test('revision conflicts are retried against the latest row', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { revision: 5 }))
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
remote.updateRowAtRevision.mockImplementationOnce(async () => {
|
||||
// Another device bumps the revision between our read and write.
|
||||
remote.rows.set(USER_A, row(USER_A, { revision: 6, locale: 'ko' }))
|
||||
return null
|
||||
})
|
||||
|
||||
const result = await store.commit({ themeMode: 'light' })
|
||||
|
||||
expect(result.serverSynced).toBe(true)
|
||||
expect(remote.rows.get(USER_A)).toMatchObject({ theme_mode: 'light', locale: 'ko', revision: 7 })
|
||||
})
|
||||
|
||||
test('repeated revision conflicts surface SYNC_CONFLICT', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A))
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
remote.updateRowAtRevision.mockImplementation(async () => null)
|
||||
|
||||
const result = await store.commit({ themeMode: 'light' })
|
||||
|
||||
expect(result.serverSynced).toBe(false)
|
||||
expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(3)
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_CONFLICT' })
|
||||
})
|
||||
|
||||
test('an insert race (unique violation) falls back to the revision-checked update', async () => {
|
||||
const remote = createFakeRemote()
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
remote.insertRow.mockImplementationOnce(async () => {
|
||||
remote.rows.set(USER_A, row(USER_A, { revision: 3 }))
|
||||
return { status: 'duplicate' }
|
||||
})
|
||||
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(1)
|
||||
expect(remote.rows.get(USER_A)?.revision).toBe(4)
|
||||
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
|
||||
})
|
||||
|
||||
test('a stale server response is dropped after the owner switches', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
|
||||
remote.rows.set(USER_B, row(USER_B, { theme_mode: 'light' }))
|
||||
let releaseA: () => void = () => undefined
|
||||
remote.fetchRow.mockImplementationOnce(async (userId: string) => {
|
||||
await new Promise<void>((resolve) => { releaseA = resolve })
|
||||
return remote.rows.get(userId) ?? null
|
||||
})
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
|
||||
const loadA = store.load(preferencesOwnerFor(USER_A))
|
||||
await settle()
|
||||
await store.load(preferencesOwnerFor(USER_B))
|
||||
releaseA()
|
||||
await loadA
|
||||
|
||||
expect(store.getSnapshot()).toMatchObject({ ownerKey: `user:${USER_B}`, phase: 'ready' })
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
test('realtime rows are ignored while local edits are pending', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
remote.failFetch = true
|
||||
await store.commit({ themeMode: 'light' })
|
||||
|
||||
store.applyRemote(USER_A, row(USER_A, { theme_mode: 'system', revision: 9 }))
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
|
||||
store.applyRemote(USER_B, row(USER_B, { theme_mode: 'system' }))
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
test('realtime rows apply when nothing is pending', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
|
||||
const storage = createMemoryStorage()
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
store.applyRemote(USER_A, row(USER_A, { theme_mode: 'light', revision: 2 }))
|
||||
await settle()
|
||||
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'synced', phase: 'ready' })
|
||||
expect(store.getSnapshot().preferences.themeMode).toBe('light')
|
||||
expect(storage.data.get(getUserCacheKey(USER_A))).toContain('"themeMode":"light"')
|
||||
})
|
||||
|
||||
test('cache read failure outranks the sync failure code', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
storage.failReads = true
|
||||
const remote = createFakeRemote()
|
||||
remote.failFetch = true
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_READ_FAILED' })
|
||||
})
|
||||
|
||||
test('cache write failure outranks the sync failure code', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
storage.failWrites = true
|
||||
const remote = createFakeRemote()
|
||||
remote.failFetch = true
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_WRITE_FAILED' })
|
||||
})
|
||||
|
||||
test('signed-out commits stay local and persist a pending installation patch', async () => {
|
||||
const storage = createMemoryStorage()
|
||||
const remote = createFakeRemote()
|
||||
const store = createPreferencesStore({ storage, remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(null))
|
||||
|
||||
const result = await store.commit({ locale: 'en' })
|
||||
|
||||
expect(result).toEqual({ localSaved: true, serverSynced: false })
|
||||
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'local', ownerKey: 'installation' })
|
||||
expect(storage.data.get(INSTALLATION_CACHE_KEY)).toContain('"pendingPatch":{"locale":"en"}')
|
||||
expect(remote.fetchRow).not.toHaveBeenCalled()
|
||||
expect(store.needsForegroundResync()).toBe(false)
|
||||
})
|
||||
|
||||
test('a commit made during a background refresh stays pending on top of the server value', async () => {
|
||||
const remote = createFakeRemote()
|
||||
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark', revision: 2 }))
|
||||
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
|
||||
await store.load(preferencesOwnerFor(USER_A))
|
||||
remote.failFetch = true
|
||||
await store.resync() // -> offline, nothing pending
|
||||
remote.failFetch = false
|
||||
|
||||
let releaseFetch: () => void = () => undefined
|
||||
remote.fetchRow.mockImplementationOnce(async (userId: string) => {
|
||||
await new Promise<void>((resolve) => { releaseFetch = resolve })
|
||||
return remote.rows.get(userId) ?? null
|
||||
})
|
||||
const refresh = store.resync()
|
||||
await settle()
|
||||
const commit = store.commit({ hapticEnabled: false })
|
||||
await settle()
|
||||
releaseFetch()
|
||||
await refresh
|
||||
const result = await commit
|
||||
|
||||
expect(result.serverSynced).toBe(true)
|
||||
expect(store.getSnapshot().preferences.hapticEnabled).toBe(false)
|
||||
expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false })
|
||||
expect(store.getSnapshot().phase).toBe('ready')
|
||||
})
|
||||
})
|
||||
314
apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.test.ts
Normal file
314
apps/mobile-rn/__tests__/stt-cloud-client-redteam-r3-17.test.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue