d3ro-voice/apps/mobile-rn/__tests__/secure-auth-storage.test.ts
2026-08-29 18:33:45 +09:00

156 lines
6 KiB
TypeScript

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