d3ro-voice/apps/mobile-rn/__tests__/auth-redteam-r3-16.test.tsx
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

298 lines
11 KiB
TypeScript

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