keychainMock
The controls of the mock: the simulated device, how the next prompt ends, a new finger or face, the prompts shown so far, and the items of react-native-keychain and expo-secure-store that a migration finds.
import { keychainMock } from '@apexnative/keychain/jest';
keychainMock drives the device that @apexnative/keychain/jest simulates. The app's code runs against the mock unchanged; a test uses these controls to put the device in a state, play how a prompt ends, and check what the code showed the user.
Usage
import { KeychainErrorCode, keychainMock } from '@apexnative/keychain/jest';
import { vault } from './storage';
import { unlockRefreshToken } from './restore';
beforeEach(() => keychainMock.reset());
test('a cancel goes to the password form', async () => {
await vault.setItem('refreshToken', 'r-1');
keychainMock.failNextAuthentication(KeychainErrorCode.UserCanceled);
expect(await unlockRefreshToken()).toBe('password');
expect(keychainMock.prompts()).toHaveLength(1);
});
Signature
const keychainMock: KeychainMock;
An object with the controls below; its type is KeychainMock. There is one per module, shared by every store, migrate and compat of the mock.
reset
reset(): void;
Forgets every item of every store, every recorded prompt, every queued failure and every planted old item, and restores the default device. The enrolled biometry is back to its first state too, so getBiometryState() returns the same token as at the start.
beforeEach(() => keychainMock.reset());
Call it before every test: the mock is a module, and what one test stored is otherwise still there in the next.
setDevice
setDevice(device: Partial<MockDevice>): void;
Changes the fields of the simulated device it is given and keeps the others (MockDevice). The calls after it see the new device: what getBiometryTypes, canUseAccessControl, isPasscodeSet, getBiometryState and getHardwareSecurityLevel answer, and whether a protected write succeeds. Items written before stay as they are.
import {
AccessControl,
BiometryType,
HardwareSecurityLevel,
canUseAccessControl,
keychainMock,
} from '@apexnative/keychain/jest';
// A phone without enrolled biometry.
keychainMock.setDevice({ biometryEnrolled: false });
await canUseAccessControl(AccessControl.BiometryCurrentSet);
// { available: false, reason: 'notEnrolled' }
// An Android 10 phone with a fingerprint sensor and StrongBox.
keychainMock.setDevice({
biometryTypes: [BiometryType.Fingerprint],
androidApi: 29,
hardwareSecurityLevel: HardwareSecurityLevel.Strongbox,
});
await canUseAccessControl(AccessControl.BiometryAnyOrPasscode);
// { available: false, reason: 'unsupportedOS' }
A field of the device decides several answers at once, as on a phone:
biometryTypes: []— no sensor:getBiometryTypes()is[]andgetBiometryState()isnull;biometryAny,biometryCurrentSetandbiometryCurrentSetOrPasscodearenoHardwareand their writes throwBIOMETRY_NOT_AVAILABLE.biometryAnyOrPasscodeandpasscodestay available.biometryEnrolled: false—getBiometryState()isnull;biometryAny,biometryCurrentSetandbiometryCurrentSetOrPasscodearenotEnrolledand their writes throwBIOMETRY_NOT_ENROLLED.biometryAnyOrPasscodestays available: the passcode opens it.passcodeSet: false— everyaccessControlbutnoneispasscodeNotSet, and a write with one of them, or withwhenPasscodeSetThisDeviceOnly, throwsPASSCODE_NOT_SET.faceIDUsageDescription: false— on a device withfaceID, a biometric write andcanUseAccessControlfor a biometricaccessControlreject withMISSING_USAGE_DESCRIPTION.androidApibelow 30 — the passcode variants areunsupportedOS, and a write with one of them, or withauthValidityabove 0, throwsUNSUPPORTED.hardwareSecurityLevel—getItemInforeportsandroid.securityLevelfor new items, andstrongboxRequiredthrowsHARDWARE_UNAVAILABLEunless the level isstrongbox.
failNextAuthentication
failNextAuthentication(code: KeychainErrorCode): void;
Makes the next prompt fail with code instead of succeeding. The prompt is still recorded by prompts(), and the read rejects with a KeychainError of that code. Calls queue up, one failure per prompt, so a test can play a cancel, then a lockout, then a success:
keychainMock.failNextAuthentication(KeychainErrorCode.UserCanceled);
keychainMock.failNextAuthentication(KeychainErrorCode.Lockout);
await vault.getItem('refreshToken'); // rejects: USER_CANCELED
await vault.getItem('refreshToken'); // rejects: LOCKOUT
await vault.getItem('refreshToken'); // 'r-1'
- The code is any
KeychainErrorCode, as a constant or a string; anything else throws aTypeErrorat once. The ones a prompt ends with on a device areUSER_CANCELED,USER_FALLBACK,SYSTEM_CANCELED,AUTH_FAILED,LOCKOUT,LOCKOUT_PERMANENTandKEY_INVALIDATED. KEY_INVALIDATEDalso deletes the item, as the device does.- Only a prompt takes a failure from the queue: writes, reads of items without protection and the device functions leave it for the next prompt. That includes the prompt of
migrateItemfor a protected old item.
changeBiometry
changeBiometry(): void;
A new finger or face on the device. Items written before it with biometryCurrentSet or biometryCurrentSetOrPasscode throw KEY_INVALIDATED at their next read and are deleted; the read shows no prompt. Items of biometryAny and biometryAnyOrPasscode keep working, and getBiometryState() returns a new token.
test('new biometry signs the user out of Face ID', async () => {
const before = await getBiometryState();
await vault.setItem('refreshToken', 'r-1');
keychainMock.changeBiometry();
expect(await getBiometryState()).not.toBe(before);
await expect(vault.getItem('refreshToken')).rejects.toMatchObject({
code: KeychainErrorCode.KeyInvalidated,
});
expect(await vault.hasItem('refreshToken')).toBe(false);
});
An item written after the change belongs to the new biometry and reads as usual.
prompts
prompts(): MockPrompt[];
The prompts shown since the last reset, oldest first. Each is a MockPrompt: the key of the item it was for, and the texts it had, the store's and the call's merged field by field, with confirmationRequired (true unless one of them set it).
const vault = createKeychain({
service: 'vault',
accessControl: AccessControl.BiometryAny,
prompt: { title: 'Unlock the vault', cancel: 'Use password' },
});
await vault.setItem('pin', '1234');
await vault.getItem('pin', { prompt: { title: 'Confirm the payment' } });
keychainMock.prompts();
// [{ key: 'pin', title: 'Confirm the payment', cancel: 'Use password', confirmationRequired: true }]
What never shows a prompt on a device records nothing: writes, hasItem, getItemInfo, getAllKeys, clear, reads of items without protection, and a read that throws KEY_INVALIDATED after changeBiometry. The prompt of migrateItem for a protected old item is recorded with the old item's account as key: the user name of react-native-keychain, the key of expo-secure-store.
The array is a copy: changing it changes nothing in the mock.
addMigrationSource
addMigrationSource(item: MockMigrationItem): void;
Plants an item of react-native-keychain or expo-secure-store, as that library would have left it on the device, for migrate to find. The item is a MockMigrationItem: a generic password (service), internet credentials (server) or an item of expo-secure-store (key, value, keychainService that defaults to app). A planted item replaces one of the same kind, service and account.
With @apexnative/keychain/migrate mocked as the overview shows:
import { keychain, keychainMock } from '@apexnative/keychain/jest';
import { migrateFromReactNativeKeychain } from '@apexnative/keychain/migrate';
beforeEach(() => keychainMock.reset());
test('the old sign-in moves to the new storage', async () => {
keychainMock.addMigrationSource({
library: 'reactNativeKeychain',
service: 'dev.apexnative.keychain.mock', // the default service of the mock
username: 'ivan@example.com',
password: 'secret',
});
const report = await migrateFromReactNativeKeychain({ removeSource: true });
expect(report.migrated).toEqual(['dev.apexnative.keychain.mock']);
expect(await keychain.getCredentials()).toEqual({
username: 'ivan@example.com',
password: 'secret',
});
expect(keychainMock.migrationSources()).toEqual([]);
});
Three fields shape how the old item behaves:
requiresAuth: true— the old item is protected.migrateFrom…skips it asrequiresAuth;migrateItemshows a prompt, which is recorded and takes a queued failure.corrupted: true— reading it fails withDATA_CORRUPTED, as bytes that aren't text do; the report has it underskippedwith the reasonerror.accessGroup— the group the old item is in; a migration that names anaccessGroupfinds only the items of that group.
migrationSources
migrationSources(): MockMigrationSource[];
The planted items that are still there, in the order they were planted, as MockMigrationSource: the library, the kind, the service, the account, whether it needs authentication, and migrated once a run moved it and kept it. A run with removeSource deletes the old item, so it is no longer in the list.
keychainMock.addMigrationSource({ library: 'expoSecureStore', key: 'token', value: 't-1' });
await migrateFromExpoSecureStore();
keychainMock.migrationSources();
// [{ library: 'expoSecureStore', kind: 'secureStore', service: 'app', account: 'token',
// requiresAuth: false, migrated: true }]
await migrateFromExpoSecureStore({ removeSource: true });
keychainMock.migrationSources(); // []