d3ro-voice/apps/mobile-rn/__tests__/account-retention-redteam-r3-16.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

214 lines
8.3 KiB
TypeScript

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