Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/__tests__/hooks/useBiometricAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';

import { useBiometricAuth } from '../../hooks/useBiometricAuth';
import { isBiometricEnabled } from '../../services/secureStorage';
import { useDeviceStore } from '../../store/deviceStore';

Check warning on line 5 in src/__tests__/hooks/useBiometricAuth.test.ts

View workflow job for this annotation

GitHub Actions / ci

'useDeviceStore' is defined but never used

// ─── Mocks ────────────────────────────────────────────────────────────────────

jest.mock('../../services/secureStorage', () => ({
isBiometricEnabled: jest.fn(),
}));

const mockSetBiometricEnabled = jest.fn();
const mockStore = {
isDeviceCompromised: false,
biometricEnabled: false,
setBiometricEnabled: mockSetBiometricEnabled,
};

jest.mock('../../store/deviceStore', () => ({
useDeviceStore: jest.fn((selector: (s: typeof mockStore) => unknown) => selector(mockStore)),
}));

let capturedAppStateCallback: ((state: string) => void) | null = null;

jest.mock('react-native', () => ({
AppState: {
currentState: 'active',
addEventListener: jest.fn().mockImplementation((_, cb) => {
capturedAppStateCallback = cb;
return { remove: jest.fn() };
}),
},
}));

jest.mock('../../services/mobileAuth', () => ({}));

const mockIsBiometricEnabled = isBiometricEnabled as jest.Mock;

// ─── Tests ────────────────────────────────────────────────────────────────────

describe('useBiometricAuth', () => {
beforeEach(() => {
jest.clearAllMocks();
capturedAppStateCallback = null;
mockStore.isDeviceCompromised = false;
mockStore.biometricEnabled = false;
mockIsBiometricEnabled.mockResolvedValue(false);
});

it('syncs biometricEnabled from SecureStore on mount', async () => {
mockIsBiometricEnabled.mockResolvedValue(true);

renderHook(() => useBiometricAuth());

await waitFor(() => {
expect(mockSetBiometricEnabled).toHaveBeenCalledWith(true);
});
});

it('isLoading is true until the initial sync completes', async () => {
let resolveEnabled!: (v: boolean) => void;
mockIsBiometricEnabled.mockReturnValue(new Promise(r => (resolveEnabled = r)));

const { result } = renderHook(() => useBiometricAuth());

expect(result.current.isLoading).toBe(true);

await act(async () => resolveEnabled(false));

expect(result.current.isLoading).toBe(false);
});

it('re-syncs from SecureStore when app returns to foreground', async () => {
mockIsBiometricEnabled.mockResolvedValue(false);
renderHook(() => useBiometricAuth());
await waitFor(() => expect(mockSetBiometricEnabled).toHaveBeenCalledTimes(1));

mockIsBiometricEnabled.mockResolvedValue(true);

await act(async () => {
// Simulate background → foreground transition
capturedAppStateCallback!('background');
capturedAppStateCallback!('active');
});

await waitFor(() => expect(mockSetBiometricEnabled).toHaveBeenCalledWith(true));
});

it('divergence: SecureStore cleared while Zustand cached true → resets to false', async () => {
// Zustand has stale true, SecureStore no longer has the entry
mockStore.biometricEnabled = true;
mockIsBiometricEnabled.mockResolvedValue(false);

renderHook(() => useBiometricAuth());

// Foreground event triggers re-sync
await act(async () => {
capturedAppStateCallback?.('background');
capturedAppStateCallback?.('active');
});

await waitFor(() => {
expect(mockSetBiometricEnabled).toHaveBeenCalledWith(false);
});
});

it('isEnabled is false while syncing is in progress', async () => {
mockStore.biometricEnabled = true;
let resolveEnabled!: (v: boolean) => void;
mockIsBiometricEnabled.mockReturnValue(new Promise(r => (resolveEnabled = r)));

const { result } = renderHook(() => useBiometricAuth());

expect(result.current.isLoading).toBe(true);

await act(async () => resolveEnabled(true));
});

it('isEnabled is false when device is compromised regardless of SecureStore', async () => {
mockStore.isDeviceCompromised = true;
mockStore.biometricEnabled = true;
mockIsBiometricEnabled.mockResolvedValue(true);

const { result } = renderHook(() => useBiometricAuth());

await waitFor(() => expect(mockSetBiometricEnabled).toHaveBeenCalled());

expect(result.current.isEnabled).toBe(false);
expect(result.current.error).toBeTruthy();
});

it('does not re-sync on active → background transition', async () => {
mockIsBiometricEnabled.mockResolvedValue(false);
renderHook(() => useBiometricAuth());
await waitFor(() => expect(mockSetBiometricEnabled).toHaveBeenCalledTimes(1));

await act(async () => {
capturedAppStateCallback!('background');
});

// No additional sync — only foreground triggers re-sync
expect(mockSetBiometricEnabled).toHaveBeenCalledTimes(1);
});
});
43 changes: 40 additions & 3 deletions src/hooks/useBiometricAuth.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,56 @@
import { useCallback } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, AppStateStatus } from 'react-native';

import { BiometricType, AuthResult } from '../services/mobileAuth';
import { isBiometricEnabled } from '../services/secureStorage';
import { useDeviceStore } from '../store/deviceStore';

export function useBiometricAuth() {
const isDeviceCompromised = useDeviceStore(state => state.isDeviceCompromised);
const biometricEnabled = useDeviceStore(state => state.biometricEnabled);
const setBiometricEnabled = useDeviceStore(state => state.setBiometricEnabled);

const [isSyncing, setIsSyncing] = useState(true);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);

const syncWithSecureStore = useCallback(async () => {
setIsSyncing(true);
try {
const enabled = await isBiometricEnabled();
// If secure store no longer has the entry, reset to the safe default.
// This covers: OS update wiping the keychain, app reinstall, or any
// external process clearing the key while the app was backgrounded.
setBiometricEnabled(enabled);
} finally {
setIsSyncing(false);
}
}, [setBiometricEnabled]);

// Sync once on mount so the initial render reflects SecureStore truth.
useEffect(() => {
syncWithSecureStore();
}, [syncWithSecureStore]);

// Re-sync on every foreground transition so a stale Zustand value is
// corrected before any biometric UI can appear.
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
if (appStateRef.current !== 'active' && nextState === 'active') {
syncWithSecureStore();
}
appStateRef.current = nextState;
});
return () => subscription.remove();
}, [syncWithSecureStore]);

return {
isAvailable: false,
isEnabled: false,
isEnabled: !isDeviceCompromised && biometricEnabled,
biometricType: 'none' as BiometricType,
authenticate: useCallback(async (): Promise<AuthResult | null> => null, []),
enable: useCallback(async () => false, []),
disable: useCallback(async () => {}, []),
isLoading: false,
isLoading: isSyncing,
error: isDeviceCompromised ? 'Biometric authentication is unavailable on this device.' : null,
clearError: useCallback(() => {}, []),
};
Expand Down
6 changes: 6 additions & 0 deletions src/store/deviceStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ interface DeviceState {
/** True when the device is jailbroken (iOS) or rooted (Android) */
isDeviceCompromised: boolean;
lastBiometricAuth: number | null;
biometricEnabled: boolean;

// Actions
updateBatteryInfo: (level: number, state: Battery.BatteryState, lowPowerMode: boolean) => void;
setIsInBackground: (isBg: boolean) => void;
setDeviceCompromised: (compromised: boolean) => void;
setLastBiometricAuth: (timestamp: number | null) => void;
setBiometricEnabled: (enabled: boolean) => void;
/** Runs jailbreak/root detection and updates state */
runDeviceCompromisedCheck: () => Promise<boolean>;
}
Expand All @@ -31,6 +33,7 @@ export const useDeviceStore = create<DeviceState>(set => ({
isInBackground: false,
isDeviceCompromised: false,
lastBiometricAuth: null,
biometricEnabled: false,

updateBatteryInfo: (level, state, lowPowerMode) => {
const isLowBattery = level > 0 && level < 0.2;
Expand All @@ -50,6 +53,9 @@ export const useDeviceStore = create<DeviceState>(set => ({
setLastBiometricAuth: (timestamp) => {
set({ lastBiometricAuth: timestamp });
},
setBiometricEnabled: (enabled) => {
set({ biometricEnabled: enabled });
},
runDeviceCompromisedCheck: async () => {
const compromised = await checkDeviceCompromised();
set({ isDeviceCompromised: compromised });
Expand Down
Loading