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 mockPurgeAccountLocalData = jest.fn() const mockClearSecureAuthStorage = jest.fn() const mockUnsubscribe = 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), }, }, })) jest.mock('../src/lib/account-local-data', () => ({ purgeAllAccountLocalData: (...args: unknown[]) => mockPurgeAccountLocalData(...args), })) jest.mock('../src/lib/secure-auth-storage', () => ({ clearAllSecureAuthStorage: (...args: unknown[]) => mockClearSecureAuthStorage(...args), })) jest.mock('../src/lib/auth-redirect', () => ({ isAuthRedirectUrl: () => false, completeAuthRedirect: jest.fn(), })) import { AuthPrivacyBoundaryError, AuthProvider, useAuth, } from '../src/lib/auth-context' type AuthSnapshot = ReturnType type AuthCallback = (event: AuthChangeEvent, session: Session | null) => void let latest: AuthSnapshot let authCallback: AuthCallback | 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 () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() await Promise.resolve() }) } async function mount(restoredSession: Session | null): Promise { storedSession = restoredSession await act(async () => { renderer = create() await Promise.resolve() await Promise.resolve() await Promise.resolve() }) 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) } describe('AuthProvider account-local privacy boundary', () => { beforeEach(() => { authCallback = null renderer = null storedSession = null mockGetSession.mockReset().mockImplementation(async () => ({ data: { session: storedSession }, error: null, })) mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => { authCallback = callback return { data: { subscription: { unsubscribe: mockUnsubscribe } } } }) mockStopAutoRefresh.mockReset().mockResolvedValue(undefined) mockStartAutoRefresh.mockReset().mockResolvedValue(undefined) mockPurgeAccountLocalData.mockReset().mockResolvedValue(undefined) mockClearSecureAuthStorage.mockReset().mockResolvedValue(undefined) mockUnsubscribe.mockReset() jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null) }) afterEach(() => { if (renderer !== null) act(() => renderer?.unmount()) jest.restoreAllMocks() }) it('keeps cold no-session bootstrap blocked until every account artifact is purged', async () => { let finishPurge: (() => void) | null = null mockPurgeAccountLocalData.mockReturnValueOnce(new Promise((resolve) => { finishPurge = resolve })) await act(async () => { renderer = create() await Promise.resolve() await Promise.resolve() await Promise.resolve() }) expect(latest.loading).toBe(true) expect(latest.privacyCleanupState).toBe('purging') expect(latest.user).toBeNull() expect(authCallback).toBeNull() finishPurge?.() await flush() expect(latest.loading).toBe(false) expect(latest.privacyCleanupState).toBe('ready') expect(latest.user).toBeNull() expect(mockClearSecureAuthStorage).not.toHaveBeenCalled() }) it.each(['SIGNED_OUT', 'TOKEN_REFRESHED'])( 'awaits cleanup and clears secure auth before exposing %s session loss', async (event) => { const userA = session('user-a') await mount(userA) expect(latest.user?.id).toBe('user-a') let finishPurge: (() => void) | null = null mockPurgeAccountLocalData.mockReturnValueOnce(new Promise((resolve) => { finishPurge = resolve })) act(() => emit(event, null)) await flush() expect(latest.loading).toBe(true) expect(latest.privacyCleanupState).toBe('purging') expect(latest.user).toBeNull() expect(mockClearSecureAuthStorage).not.toHaveBeenCalled() finishPurge?.() await flush() expect(mockClearSecureAuthStorage).toHaveBeenCalledTimes(1) expect(latest.privacyCleanupState).toBe('ready') expect(latest.user).toBeNull() }, ) it('keeps both auth branches blocked after failure and retries without raw errors', async () => { await mount(session('user-a')) mockPurgeAccountLocalData .mockRejectedValueOnce(new Error('private filesystem path')) .mockResolvedValueOnce(undefined) act(() => emit('SIGNED_OUT', null)) await flush() expect(latest.privacyCleanupState).toBe('failed') expect(latest.loading).toBe(true) expect(latest.user).toBeNull() expect(mockClearSecureAuthStorage).not.toHaveBeenCalled() await act(async () => { await latest.retryPrivacyCleanup() }) expect(latest.privacyCleanupState).toBe('ready') expect(latest.loading).toBe(false) expect(latest.user).toBeNull() expect(mockClearSecureAuthStorage).toHaveBeenCalledTimes(1) }) it('purges A before committing B without deleting B secure session', async () => { await mount(session('user-a')) let finishPurge: (() => void) | null = null mockPurgeAccountLocalData.mockReturnValueOnce(new Promise((resolve) => { finishPurge = resolve })) act(() => emit('SIGNED_IN', session('user-b'))) await flush() expect(latest.privacyCleanupState).toBe('purging') expect(latest.user).toBeNull() finishPurge?.() await flush() expect(latest.user?.id).toBe('user-b') expect(latest.privacyCleanupState).toBe('ready') expect(mockClearSecureAuthStorage).not.toHaveBeenCalled() }) it('uses generation ordering when SIGNED_OUT is immediately replaced by account B', async () => { await mount(session('user-a')) let finishPurge: (() => void) | null = null const sharedPurge = new Promise((resolve) => { finishPurge = resolve }) mockPurgeAccountLocalData.mockReturnValue(sharedPurge) act(() => { emit('SIGNED_OUT', null) emit('SIGNED_IN', session('user-b')) }) await flush() finishPurge?.() await flush() expect(latest.user?.id).toBe('user-b') expect(latest.privacyCleanupState).toBe('ready') expect(mockClearSecureAuthStorage).not.toHaveBeenCalled() }) it('uses the same awaited boundary for explicit logout and exposes only a stable failure', async () => { await mount(session('user-a')) let finishPurge: (() => void) | null = null mockPurgeAccountLocalData.mockReturnValueOnce(new Promise((resolve) => { finishPurge = resolve })) let logout: Promise | null = null act(() => { logout = latest.purgeLocalSession() }) await flush() expect(latest.privacyCleanupState).toBe('purging') expect(mockClearSecureAuthStorage).not.toHaveBeenCalled() finishPurge?.() await act(async () => { await logout }) expect(mockClearSecureAuthStorage).toHaveBeenCalledTimes(1) expect(latest.user).toBeNull() act(() => renderer?.unmount()) renderer = null authCallback = null await mount(session('user-a')) mockPurgeAccountLocalData.mockRejectedValueOnce(new Error('secret local path')) let failure: unknown = null await act(async () => { try { await latest.purgeLocalSession() } catch (error) { failure = error } }) expect(failure).toBeInstanceOf(AuthPrivacyBoundaryError) expect((failure as Error).message).toBe('auth_privacy_boundary_failed') expect(latest.privacyCleanupState).toBe('failed') }) })