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
156
apps/mobile-rn/__tests__/secure-auth-storage.test.ts
Normal file
156
apps/mobile-rn/__tests__/secure-auth-storage.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
|
||||
const mockKeychainStore = new Map<string, { username: string; password: string }>()
|
||||
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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue