import AsyncStorage from '@react-native-async-storage/async-storage' const mockKeychainStore = new Map() const mockResetCalls: string[] = [] let mockSetResult: false | { service: string; storage: string } = { service: 'mock', storage: 'AES_GCM_NO_AUTH', } jest.mock('react-native-keychain', () => ({ ACCESSIBLE: { AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 'AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY' }, SECURITY_LEVEL: { SECURE_SOFTWARE: 'SECURE_SOFTWARE' }, STORAGE_TYPE: { AES_GCM_NO_AUTH: 'AES_GCM_NO_AUTH' }, getGenericPassword: jest.fn(async ({ service }: { service: string }) => ( mockKeychainStore.get(service) ?? false )), setGenericPassword: jest.fn(async ( username: string, password: string, { service }: { service: string }, ) => { if (mockSetResult === false) return false mockKeychainStore.set(service, { username, password }) return { ...mockSetResult, service } }), resetGenericPassword: jest.fn(async ({ service }: { service: string }) => { mockResetCalls.push(service) mockKeychainStore.delete(service) return true }), getAllGenericPasswordServices: jest.fn(async () => [...mockKeychainStore.keys()]), })) import { clearAllSecureAuthStorage, secureAuthStorage, secureAuthStorageInternals, } from '../src/lib/secure-auth-storage' describe('secure Supabase auth storage', () => { beforeEach(async () => { mockKeychainStore.clear() mockResetCalls.length = 0 mockSetResult = { service: 'mock', storage: 'AES_GCM_NO_AUTH' } await AsyncStorage.clear() jest.restoreAllMocks() }) test('stores sessions in Keychain and removes plaintext duplicates', async () => { const key = 'sb-project-auth-token' await AsyncStorage.setItem(key, 'old-plaintext') await secureAuthStorage.setItem(key, 'encrypted-session') const service = secureAuthStorageInternals.serviceForKey(key) expect(mockKeychainStore.get(service)).toEqual({ username: key, password: 'encrypted-session', }) await expect(AsyncStorage.getItem(key)).resolves.toBeNull() }) test('migrates a legacy AsyncStorage session before returning it', async () => { const key = 'sb-project-auth-token' await AsyncStorage.setItem(key, 'legacy-session') await expect(secureAuthStorage.getItem(key)).resolves.toBe('legacy-session') const service = secureAuthStorageInternals.serviceForKey(key) expect(mockKeychainStore.get(service)?.password).toBe('legacy-session') await expect(AsyncStorage.getItem(key)).resolves.toBeNull() }) test('removes a stale plaintext duplicate before returning a secure session', async () => { const key = 'sb-project-auth-token' const service = secureAuthStorageInternals.serviceForKey(key) mockKeychainStore.set(service, { username: key, password: 'secure-session' }) await AsyncStorage.setItem(key, 'stale-session') await expect(secureAuthStorage.getItem(key)).resolves.toBe('secure-session') await expect(AsyncStorage.getItem(key)).resolves.toBeNull() }) test('fails closed on a mismatched secure-storage identity', async () => { const key = 'sb-project-auth-token' const service = secureAuthStorageInternals.serviceForKey(key) mockKeychainStore.set(service, { username: 'other-key', password: 'foreign-session' }) await AsyncStorage.setItem(key, 'legacy-session') await expect(secureAuthStorage.getItem(key)) .rejects.toThrow('secure_auth_storage_identity_mismatch') expect(mockResetCalls).toContain(service) await expect(AsyncStorage.getItem(key)).resolves.toBe('legacy-session') }) test('fails closed when the native bridge returns a malformed credential response', async () => { const key = 'sb-project-auth-token' const keychain = jest.requireMock('react-native-keychain') as { getGenericPassword: jest.Mock } keychain.getGenericPassword.mockResolvedValueOnce(undefined) await expect(secureAuthStorage.getItem(key)) .rejects.toThrow('secure_auth_storage_invalid_response') }) test('does not fall back to plaintext when Keychain rejects a write', async () => { const key = 'sb-project-auth-token' await AsyncStorage.setItem(key, 'legacy-session') mockSetResult = false await expect(secureAuthStorage.getItem(key)) .rejects.toThrow('secure_auth_storage_write_failed') await expect(AsyncStorage.getItem(key)).resolves.toBe('legacy-session') }) test('removes both secure and legacy values on sign-out', async () => { const key = 'sb-project-auth-token' await secureAuthStorage.setItem(key, 'session') await AsyncStorage.setItem(key, 'stale-session') await secureAuthStorage.removeItem(key) expect(mockKeychainStore.size).toBe(0) await expect(AsyncStorage.getItem(key)).resolves.toBeNull() }) test('clears every auth service and legacy Supabase key without touching app preferences', async () => { const firstKey = 'sb-first-auth-token' const secondKey = 'sb-second-auth-token-code-verifier' await secureAuthStorage.setItem(firstKey, 'first') await secureAuthStorage.setItem(secondKey, 'second') mockKeychainStore.set('com.d3ro.voice.history-cache.v1.user', { username: 'user', password: 'history', }) await AsyncStorage.multiSet([ [firstKey, 'legacy-first'], [secondKey, 'legacy-second'], ['mobile-preferences', 'preserve-me'], ]) await clearAllSecureAuthStorage() expect([...mockKeychainStore.keys()]).toEqual(['com.d3ro.voice.history-cache.v1.user']) await expect(AsyncStorage.getItem(firstKey)).resolves.toBeNull() await expect(AsyncStorage.getItem(secondKey)).resolves.toBeNull() await expect(AsyncStorage.getItem('mobile-preferences')).resolves.toBe('preserve-me') }) test('rejects malformed storage keys', async () => { await expect(secureAuthStorage.getItem('')).rejects.toThrow('secure_auth_storage_invalid_key') await expect(secureAuthStorage.setItem(`bad\u0000key`, 'value')) .rejects.toThrow('secure_auth_storage_invalid_key') }) })