268 lines
8.7 KiB
TypeScript
268 lines
8.7 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 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<typeof useAuth>
|
|
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<void> {
|
|
await act(async () => {
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
})
|
|
}
|
|
|
|
async function mount(restoredSession: Session | null): Promise<void> {
|
|
storedSession = restoredSession
|
|
await act(async () => {
|
|
renderer = create(<AuthProvider><Probe /></AuthProvider>)
|
|
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<void>((resolve) => {
|
|
finishPurge = resolve
|
|
}))
|
|
|
|
await act(async () => {
|
|
renderer = create(<AuthProvider><Probe /></AuthProvider>)
|
|
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<AuthChangeEvent>(['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<void>((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<void>((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<void>((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<void>((resolve) => {
|
|
finishPurge = resolve
|
|
}))
|
|
|
|
let logout: Promise<void> | 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')
|
|
})
|
|
})
|