@apexnative/keychain/jest
The package for Jest: the same API over a store in memory, with the same checks and the same errors, on a simulated device that the tests control through keychainMock.
Jest runs without native code, so the package can't reach the iOS Keychain or the Android Keystore there. @apexnative/keychain/jest is the package's own API over a native module that lives in memory: the same JavaScript checks every key, value and option, merges a store's options with a call's, and turns every failure into the same KeychainError. Only the native side is simulated, as a device the tests set up and drive with keychainMock.
Usage
Replace the package with the mock, in a setup file or at the top of a test file:
jest.mock('@apexnative/keychain', () => require('@apexnative/keychain/jest'));
module.exports = {
// …the preset and options your app already has
setupFiles: ['./jest.setup.js'],
};
The app's code keeps importing @apexnative/keychain; the tests import keychainMock from @apexnative/keychain/jest. Both names give the same module, so the controls act on the items the app's code reads and writes:
import { keychainMock } from '@apexnative/keychain/jest';
import { loadSession, saveSession } from './session';
beforeEach(() => keychainMock.reset());
test('a saved session loads again', async () => {
await saveSession('token-1');
expect(await loadSession()).toBe('token-1');
});
The items live in the module, so they last as long as the test file's modules do. reset() before every test gives each one an empty keychain and the default device.
What it exports
Everything the package exports, with the same names and types, and three more names of its own:
| Export | What it is |
|---|---|
keychain, createKeychain | The stores, over the memory. Their items are kept per service. |
getBiometryTypes, canUseAccessControl, isPasscodeSet, getBiometryState, getHardwareSecurityLevel | The answers of the simulated device. |
Accessible, AccessControl, KeyStorage, BiometryType, HardwareSecurityLevel, KeychainErrorCode | The constants: the same objects as the package's. |
KeychainError, isKeychainError | The same class and check: an error of the mock is an instanceof KeychainError. |
| The types of the package | Keychain, KeychainOptions, SetOptions, ItemInfo and the rest, and KeychainErrorDetails. |
keychainMock | The controls of the device, the prompts and the old items. |
migrate | @apexnative/keychain/migrate over the same memory. |
compat | @apexnative/keychain/compat over the same memory. |
KeychainMock, MockDevice, MockPrompt, MockMigrationItem, MockMigrationSource | The types of the controls. |
/migrate
migrate holds migrateFromReactNativeKeychain, migrateFromExpoSecureStore and migrateItem, over the same memory as keychain, and KeychainError, KeychainErrorCode and isKeychainError, as the entry point exports them. Mock the entry point with it when the app migrates:
jest.mock('@apexnative/keychain', () => require('@apexnative/keychain/jest'));
jest.mock('@apexnative/keychain/migrate', () => require('@apexnative/keychain/jest').migrate);
The old items it moves are the ones a test plants with keychainMock.addMigrationSource; migrationSources() shows what is left of them after the run. Where nothing is named, the mock looks where the libraries keep their items on iOS: react-native-keychain's generic password under the device's defaultService, expo-secure-store's items under the keychainService app.
/compat
compat holds the functions and the constants of @apexnative/keychain/compat, with KeychainError, KeychainErrorCode and isKeychainError, and the same object as default, over the same memory:
jest.mock('@apexnative/keychain', () => require('@apexnative/keychain/jest'));
jest.mock('@apexnative/keychain/compat', () => require('@apexnative/keychain/jest').compat);
Both import * as Keychain from '@apexnative/keychain/compat' and import Keychain from '@apexnative/keychain/compat' work with it. A generic password the app writes through compat is the credentials of a store, so a test can check it with keychain.getCredentials() too.
The simulated device
Until a test changes it with setDevice, the mock is an iPhone with Face ID enrolled and a passcode:
Field of MockDevice | Default | What it changes |
|---|---|---|
defaultService | 'dev.apexnative.keychain.mock' | The service of keychain |
biometryTypes | ['faceID'] | What getBiometryTypes returns; [] is a device without biometric hardware |
biometryEnrolled | true | Whether biometry is enrolled |
passcodeSet | true | Whether the device has a passcode; biometry needs one too, as on a phone |
faceIDUsageDescription | true | Whether the app has NSFaceIDUsageDescription |
androidApi | null | The Android API level; null is a device that isn't an Android one |
hardwareSecurityLevel | null | What getHardwareSecurityLevel returns, and where an item's key lives |
What it simulates
- The checks. Keys, values and options go through the package's own code: a key with U+0000, an unknown option or
biometryCurrentSetwithauthValiditythrowINVALID_ARGUMENTexactly as they do in the app. - Protection at write time. A write the device can't protect throws, and nothing is stored:
BIOMETRY_NOT_AVAILABLEwithout biometric hardware,BIOMETRY_NOT_ENROLLEDwithout enrolled biometry,PASSCODE_NOT_SETfor a passcode option orwhenPasscodeSetThisDeviceOnlywithout a passcode.canUseAccessControlanswers with the reasons in the package's order. - Face ID without its description. With
faceIDUsageDescription: falseon a Face ID device, a write with a biometricaccessControlandcanUseAccessControlfor one reject withMISSING_USAGE_DESCRIPTION. - Old Android. With
androidApibelow 30, the passcode variants areunsupportedOS, and a write with one of them, or withauthValidityabove 0, throwsUNSUPPORTED. - The key's hardware. With a
hardwareSecurityLevel,getItemInforeports where each item's key lives, andstrongboxRequiredthrowsHARDWARE_UNAVAILABLEunless the level isstrongbox. - Prompts. A read of a protected item records a prompt and succeeds, or fails with the code a test queued. A sync read of it throws
AUTH_REQUIRED. - New biometry. After
changeBiometry(), items written withbiometryCurrentSetorbiometryCurrentSetOrPasscodethrowKEY_INVALIDATEDand are deleted. - Bytes and text. An item written with
setBytesthat isn't UTF-8 throwsINVALID_ARGUMENTwhen read withgetItem. - The items' info.
getItemInforeturns the label and the options of the write, andcreatedAtandmodifiedAtthat grow with every write.
What it doesn't
- The lock state. Reads never fail with
INTERACTION_NOT_ALLOWED, whateveraccessiblesays. - The reuse window. With
authValidity, every protected read still records a prompt. - Access groups. Items are kept per service:
accessGroupchanges nothing, andMISSING_ENTITLEMENTnever happens. - iCloud Keychain.
synchronizableis kept and reported bygetItemInfo, nothing more. - The prompt itself. Prompts don't wait for each other,
NO_ACTIVITYnever happens, and the system ends a prompt only when a test queues it withfailNextAuthentication. - Reinstalls.
clearOnReinstallis a setting of the app, not of the mock.
The Testing guide covers the end-to-end tests that check these on a simulator and an emulator.