feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
268
apps/mobile-rn/__tests__/auth-privacy-boundary.test.tsx
Normal file
268
apps/mobile-rn/__tests__/auth-privacy-boundary.test.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue