From 170bd1b48ed963822bd715878b61444f7a435db7 Mon Sep 17 00:00:00 2001 From: jameswilddev Date: Mon, 8 Jul 2024 09:46:04 +0100 Subject: [PATCH] Handle Android secure store corruption. --- jest.ts | 6 +- .../unit.tsx | 12 +- react-native/services/SessionStore/index.tsx | 13 +- react-native/services/SessionStore/readme.md | 14 +- react-native/services/SessionStore/unit.tsx | 153 +++++++++++++++--- 5 files changed, 164 insertions(+), 34 deletions(-) diff --git a/jest.ts b/jest.ts index 5ea637be..78d1e139 100644 --- a/jest.ts +++ b/jest.ts @@ -188,7 +188,11 @@ jest.mock('expo-secure-store', () => { async getItemAsync (key: string, options?: unknown): Promise { if (options === undefined) { await new Promise((resolve) => setTimeout(resolve, 50)) - return encryptedStorage.get(key) ?? null + if (key === 'Test Error-Throwing Key') { + throw new Error('Test Error') + } else { + return encryptedStorage.get(key) ?? null + } } else { throw new Error( 'expo-secure-store.getItemAsync\'s mock does not support options.' diff --git a/react-native/components/createSessionStoreManagerComponent/unit.tsx b/react-native/components/createSessionStoreManagerComponent/unit.tsx index 3f7937f3..8a01fbd8 100644 --- a/react-native/components/createSessionStoreManagerComponent/unit.tsx +++ b/react-native/components/createSessionStoreManagerComponent/unit.tsx @@ -8,7 +8,7 @@ import { createSessionStoreManagerComponent, SessionStore } from '../../..' type TestSession = { readonly value: number } test('displays the loading screen', async () => { - const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase()) + const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase(), { report: jest.fn() }) const SessionStoreManager = createSessionStoreManagerComponent(sessionStore) const renderer = TestRenderer.create( @@ -45,7 +45,7 @@ test('displays the loading screen', async () => { }) test('shows the ready screen once given time to load', async () => { - const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase()) + const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase(), { report: jest.fn() }) const SessionStoreManager = createSessionStoreManagerComponent(sessionStore) const renderer = TestRenderer.create( @@ -84,7 +84,7 @@ test('shows the ready screen once given time to load', async () => { }) test('re-renders when the session is changed externally once', async () => { - const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase()) + const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase(), { report: jest.fn() }) const SessionStoreManager = createSessionStoreManagerComponent(sessionStore) const renderer = TestRenderer.create( @@ -124,7 +124,7 @@ test('re-renders when the session is changed externally once', async () => { }) test('re-renders when the session is changed externally twice', async () => { - const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase()) + const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase(), { report: jest.fn() }) const SessionStoreManager = createSessionStoreManagerComponent(sessionStore) const renderer = TestRenderer.create( @@ -165,7 +165,7 @@ test('re-renders when the session is changed externally twice', async () => { }) test('re-renders when the session is changed internally once', async () => { - const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase()) + const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase(), { report: jest.fn() }) const SessionStoreManager = createSessionStoreManagerComponent(sessionStore) const renderer = TestRenderer.create( @@ -207,7 +207,7 @@ test('re-renders when the session is changed internally once', async () => { }) test('re-renders when the session is changed internally twice', async () => { - const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase()) + const sessionStore = new SessionStore({ value: 5 }, randomUUID().toLowerCase(), { report: jest.fn() }) const SessionStoreManager = createSessionStoreManagerComponent(sessionStore) const renderer = TestRenderer.create( diff --git a/react-native/services/SessionStore/index.tsx b/react-native/services/SessionStore/index.tsx index 8d7171be..7c7b476a 100644 --- a/react-native/services/SessionStore/index.tsx +++ b/react-native/services/SessionStore/index.tsx @@ -1,6 +1,7 @@ import * as SecureStore from 'expo-secure-store' import { EventEmitter } from 'events' import type { Json } from '../../types/Json' +import type { ErrorReporterInterface } from '../../types/ErrorReporterInterface' /** * A wrapper around expo-secure-store which adds: @@ -23,10 +24,12 @@ export class SessionStore { * store. * @param secureStorageKey The key of the record to read from/write to * expo-secure-store. + * @param errorReporter The error reporter to use. */ constructor ( private readonly initial: T, - private readonly secureStorageKey: string + private readonly secureStorageKey: string, + private readonly errorReporter: ErrorReporterInterface ) {} /** @@ -64,7 +67,13 @@ export class SessionStore { } else { this.unloaded = false - const raw = await SecureStore.getItemAsync(this.secureStorageKey) + let raw: null | string = null + + try { + raw = await SecureStore.getItemAsync(this.secureStorageKey) + } catch (e) { + this.errorReporter.report(e) + } if (raw === null) { this.value = this.initial diff --git a/react-native/services/SessionStore/readme.md b/react-native/services/SessionStore/readme.md index 95ab08c9..146c69b0 100644 --- a/react-native/services/SessionStore/readme.md +++ b/react-native/services/SessionStore/readme.md @@ -7,14 +7,24 @@ A wrapper around `expo-secure-store` which adds: - Change events. - A synchronous read/write API (with asynchronous write-back). +## Android decryption failure handling + +Expo on Android unfortunately has a tendency to lose the ability to decrypt the +secure store. It's not known why this is, but when it happens, the only +workaround is to catch the exception thrown by `expo-secure-store` and continue +as though the store is empty. + +For this reason, any exceptions thrown by `expo-secure-store` during the load +phase are ignored. + ## Usage ```tsx -import type { SessionStore } from "react-native-app-helpers"; +import type { SessionStore, errorReporter } from "react-native-app-helpers"; type Session = `Session A` | `Session B`; -const store = new SessionStore(`Session A`, `SecureStorage Key`); +const store = new SessionStore(`Session A`, `SecureStorage Key`, errorReporter); await store.load(); diff --git a/react-native/services/SessionStore/unit.tsx b/react-native/services/SessionStore/unit.tsx index 3b0b2798..3e032163 100644 --- a/react-native/services/SessionStore/unit.tsx +++ b/react-native/services/SessionStore/unit.tsx @@ -7,9 +7,11 @@ type TestSession = { } test('throws an error when getting from an unloaded store', () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -19,12 +21,15 @@ test('throws an error when getting from an unloaded store', () => { }).toThrowError('The session store is not loaded.') expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when setting a value in an unloaded store', () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -34,12 +39,15 @@ test('throws an error when setting a value in an unloaded store', () => { }).toThrowError('The session store is not loaded.') expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when unloading an unloaded store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -50,12 +58,15 @@ test('throws an error when unloading an unloaded store', async () => { new Error('The session store is not loaded.') ) expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('allows a store to be loaded and read from', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -65,12 +76,15 @@ test('allows a store to be loaded and read from', async () => { expect(output).toEqual({ testKey: 'Test Value A' }) expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('allows a store to be loaded, written to and read from', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn(() => store.get()) store.addListener('set', onSet) @@ -82,12 +96,15 @@ test('allows a store to be loaded, written to and read from', async () => { expect(output).toEqual({ testKey: 'Test Value B' }) expect(onSet).toBeCalledTimes(1) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('allows a store to be loaded, unloaded, loaded and read from', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -99,12 +116,15 @@ test('allows a store to be loaded, unloaded, loaded and read from', async () => expect(output).toEqual({ testKey: 'Test Value A' }) expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('allows a store to be loaded, written to, unloaded, loaded and read from', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn(() => store.get()) store.addListener('set', onSet) @@ -118,18 +138,22 @@ test('allows a store to be loaded, written to, unloaded, loaded and read from', expect(output).toEqual({ testKey: 'Test Value B' }) expect(onSet).toBeCalledTimes(1) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('treats two separate class instances as having their own state', async () => { + const errorReporterReport = jest.fn() const storeA = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSetA = jest.fn(() => storeA.get()) storeA.addListener('set', onSetA) const storeB = new SessionStore( { testKey: 'Test Value B' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSetB = jest.fn(() => storeB.get()) storeB.addListener('set', onSetB) @@ -146,12 +170,15 @@ test('treats two separate class instances as having their own state', async () = expect(outputB).toEqual({ testKey: 'Test Value D' }) expect(onSetB).toBeCalledTimes(1) expect(onSetB).toHaveReturnedWith({ testKey: 'Test Value D' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('allows a store to be loaded, written to twice in rapid succession and read from', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn(() => store.get()) store.addListener('set', onSet) @@ -165,12 +192,15 @@ test('allows a store to be loaded, written to twice in rapid succession and read expect(onSet).toBeCalledTimes(2) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value C' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('allows a store to be loaded, written to twice and unloaded in rapid succession, loaded and read from', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn(() => store.get()) store.addListener('set', onSet) @@ -186,12 +216,15 @@ test('allows a store to be loaded, written to twice and unloaded in rapid succes expect(onSet).toBeCalledTimes(2) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value C' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('works as expected without event listeners', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) await store.load() @@ -201,12 +234,15 @@ test('works as expected without event listeners', async () => { const output = store.get() expect(output).toEqual({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('works as expected with multiple event listeners', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSetA = jest.fn(() => store.get()) store.addListener('set', onSetA) @@ -228,12 +264,15 @@ test('works as expected with multiple event listeners', async () => { expect(onSetB).toHaveReturnedWith({ testKey: 'Test Value B' }) expect(onSetC).toBeCalledTimes(1) expect(onSetC).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('allows removal of event listeners', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSetA = jest.fn(() => store.get()) store.addListener('set', onSetA) @@ -255,12 +294,15 @@ test('allows removal of event listeners', async () => { expect(onSetB).not.toHaveBeenCalled() expect(onSetC).toBeCalledTimes(1) expect(onSetC).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when loading a loading store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -272,12 +314,15 @@ test('throws an error when loading a loading store', async () => { new Error('The session store is already loading.') ) expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when getting from a loading store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -288,12 +333,15 @@ test('throws an error when getting from a loading store', async () => { }).toThrowError('The session store is currently loading.') expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when setting a value in a loading store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -304,12 +352,15 @@ test('throws an error when setting a value in a loading store', async () => { }).toThrowError('The session store is currently loading.') expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when unloading a loading store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -321,12 +372,15 @@ test('throws an error when unloading a loading store', async () => { new Error('The session store is currently loading.') ) expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when loading a loaded store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn() store.addListener('set', onSet) @@ -338,12 +392,15 @@ test('throws an error when loading a loaded store', async () => { new Error('The session store is already loaded.') ) expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when loading an unloading store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn(() => store.get()) store.addListener('set', onSet) @@ -358,12 +415,15 @@ test('throws an error when loading an unloading store', async () => { ) expect(onSet).toBeCalledTimes(1) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when getting from an unloading store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn(() => store.get()) store.addListener('set', onSet) @@ -377,12 +437,15 @@ test('throws an error when getting from an unloading store', async () => { expect(onSet).toBeCalledTimes(1) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when setting a value in an unloading store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn(() => store.get()) store.addListener('set', onSet) @@ -396,12 +459,15 @@ test('throws an error when setting a value in an unloading store', async () => { expect(onSet).toBeCalledTimes(1) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() }) test('throws an error when unloading an unloading store', async () => { + const errorReporterReport = jest.fn() const store = new SessionStore( { testKey: 'Test Value A' }, - randomUUID().toLowerCase() + randomUUID().toLowerCase(), + { report: errorReporterReport } ) const onSet = jest.fn(() => store.get()) store.addListener('set', onSet) @@ -416,4 +482,45 @@ test('throws an error when unloading an unloading store', async () => { ) expect(onSet).toBeCalledTimes(1) expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).not.toHaveBeenCalled() +}) + +test('allows a corrupted store to be loaded and read from', async () => { + const errorReporterReport = jest.fn() + const store = new SessionStore( + { testKey: 'Test Value A' }, + 'Test Error-Throwing Key', + { report: errorReporterReport } + ) + const onSet = jest.fn() + store.addListener('set', onSet) + + await store.load() + const output = store.get() + + expect(output).toEqual({ testKey: 'Test Value A' }) + expect(onSet).not.toHaveBeenCalled() + expect(errorReporterReport).toHaveBeenCalledWith(new Error('Test Error')) + expect(errorReporterReport).toHaveBeenCalledTimes(1) +}) + +test('allows a corrupted store to be loaded, written to and read from', async () => { + const errorReporterReport = jest.fn() + const store = new SessionStore( + { testKey: 'Test Value A' }, + 'Test Error-Throwing Key', + { report: errorReporterReport } + ) + const onSet = jest.fn(() => store.get()) + store.addListener('set', onSet) + + await store.load() + store.set({ testKey: 'Test Value B' }) + const output = store.get() + + expect(output).toEqual({ testKey: 'Test Value B' }) + expect(onSet).toBeCalledTimes(1) + expect(onSet).toHaveReturnedWith({ testKey: 'Test Value B' }) + expect(errorReporterReport).toHaveBeenCalledWith(new Error('Test Error')) + expect(errorReporterReport).toHaveBeenCalledTimes(1) })