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 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 { await act(async () => { for (let index = 0; index < 12; index += 1) await Promise.resolve() }) } async function mount(restoredSession: Session | null): Promise { storedSession = restoredSession await act(async () => { renderer = create() }) 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 { 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[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((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() }) })