feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View 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')
})
})