From 111e21a94de0cedfb9ef535105d2885b976095c4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:45:54 +0200 Subject: [PATCH] feat: offline db encryption (#3780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal Let integrators encrypt the offline database at rest. The offline cache stores channels, messages, members, drafts and reminders, and right now we write all of it as plaintext `SQLite`. It's opt-in. Apps that don't pass the new prop behave exactly as they do today. One thing to know up front, since it shapes the rest of the PR: `op-sqlite` accepts an `encryptionKey` on a build without `SQLCipher` and then ignores it. You get a plaintext database and no error at any layer. So part of this change is detecting that and refusing to open the database, instead of passing the key along and assuming it was used. Accompanying docs PR: https://github.com/GetStream/docs-content/pull/1521 ## ๐Ÿ›  Implementation details ### API `Chat` takes one new prop: ```tsx ``` `getEncryptionKey?: () => Promise` runs once per database open, so once per launch and again after a sign-out. Its result is passed to `SQLCipher` through `op-sqlite`. `SqliteClientError` and `SqliteClientErrorCode` are exported too. ### We throw instead of recovering When the database can't be opened with the encryption that was asked for, `Chat` throws a `SqliteClientError` from render and the integrator's error boundary handles it. We don't fall back to plaintext, we don't switch offline support off, and we don't delete anything. The reason is that all of those recoveries have a security consequence and there's no default that's right for everyone. Falling back to plaintext defeats the point of the feature and nothing tells you it happened. Dropping the cache decides a compliance question for the integrator. Deleting the file throws away offline actions that are still queued. We also can't tell "the Keystore isn't unlocked yet, try again shortly" from "something is wrong here, sign this device out". So we detect the failure and classify it, and the app decides what to do about it. ### Scenarios | Scenario | What it means | What the SDK does | Recommended recovery | | ------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------ | | No `getEncryptionKey` passed | Encryption not requested | Opens plaintext, same as today | n/a | | Key supplied, fresh install | Nothing on disk yet | Creates the database encrypted with that key | n/a | | Key supplied, plaintext database already on disk | Integrator is turning encryption on for an existing install | Throws `OFFLINE_DB_UNREADABLE` | Delete the database, remount `Chat` | | Key differs from the one the database was written with | Key rotated, or read from the wrong place | Throws `OFFLINE_DB_UNREADABLE` | Delete the database, remount `Chat` | | `getEncryptionKey` removed, encrypted database on disk | Integrator is turning encryption off again | Throws `OFFLINE_DB_UNREADABLE` | Delete the database, remount `Chat` | | `getEncryptionKey` throws | Key isn't available yet, e.g. Keystore still locked | Throws `ENCRYPTION_KEY_UNAVAILABLE` | Remount to retry, e.g. on next app foreground | | `getEncryptionKey` resolves `undefined` | Same as above | Throws `ENCRYPTION_KEY_UNAVAILABLE` | Remount to retry, e.g. on next app foreground | | Key supplied, native build has no `SQLCipher` | The key would be ignored and the database left plaintext | Throws `SQLCIPHER_BUILD_MISSING`, doesn't open | Not fixable at runtime, remount with `enableOfflineSupport={false}` | | Database file corrupted | Nothing to do with encryption | Throws `OFFLINE_DB_UNREADABLE` | Delete the database, remount `Chat` | Two of those rows need a closer look in review. `OFFLINE_DB_UNREADABLE` is not gated on `getEncryptionKey` being set, and that's on purpose, because of the "turning encryption off again" row. If we only threw it when a key was supplied, an integrator removing the prop would get a blank screen instead of an error they can recover from. I hit that on device. The last row is why the boundary is useful even for apps that never use encryption. A corrupted database gives you the same code, so anything using `enableOfflineSupport` can end up there. ### Where the error comes from `AbstractOfflineDB.init` in the LLC catches whatever `initializeDB` throws and doesn't re-throw it, so a caller can't find out why initialisation failed. I left that alone, because changing it would tie this PR to an LLC release. `OfflineDB` stores the reason on the instance on the way out instead, and the new hook reads it back once `init` has settled. No LLC changes needed for this. - `SqliteClient` resolves the key, opens through `SQLCipher` and maps failures onto the codes above. It also gets `preflightEncryption()`, which runs before `setOfflineDBApi`. Without that ordering the client attaches a database that's already dead, and the unguarded `await this.offlineDb.upsertChannels(...)` inside `queryChannels` rejects. You end up on a loading screen that never resolves. - `useInitializeOfflineDb()` is new and does preflight, attach, init and raise, with the init options behind an `options` param. It's pulled out of `Chat`, which loses 72 lines. - `OfflineDB` records `initializationError` and re-throws, so `init` still marks the database uninitialised. ## ๐ŸŽจ UI Changes ## ๐Ÿงช Testing ## โ˜‘๏ธ Checklist - [x] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [x] PR targets the `develop` branch - [x] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- examples/SampleApp/App.tsx | 40 ++- examples/SampleApp/ios/Podfile.lock | 10 +- .../src/components/OfflineDbBoundary.tsx | 72 +++++ package/src/components/Chat/Chat.tsx | 70 +++-- .../components/Chat/__tests__/Chat.test.tsx | 243 ++++++++++++++++- .../Chat/hooks/useInitializeOfflineDb.ts | 117 ++++++++ package/src/index.ts | 2 +- package/src/mock-builders/DB/mock.ts | 13 + package/src/store/OfflineDB.ts | 35 ++- package/src/store/SqliteClient.ts | 186 ++++++++++++- .../src/store/__tests__/SqliteClient.test.ts | 258 ++++++++++++++++++ 11 files changed, 1006 insertions(+), 40 deletions(-) create mode 100644 examples/SampleApp/src/components/OfflineDbBoundary.tsx create mode 100644 package/src/components/Chat/hooks/useInitializeOfflineDb.ts create mode 100644 package/src/store/__tests__/SqliteClient.test.ts diff --git a/examples/SampleApp/App.tsx b/examples/SampleApp/App.tsx index 00fc913a3a..14dc99a866 100644 --- a/examples/SampleApp/App.tsx +++ b/examples/SampleApp/App.tsx @@ -27,6 +27,7 @@ import { } from 'stream-chat-react-native'; import { MenuDrawer } from './src/components/MenuDrawer'; +import { OfflineDbBoundary } from './src/components/OfflineDbBoundary'; import { useSampleAppComponentOverrides } from './src/components/SampleAppComponentOverrides'; import { MessageInputFloatingConfigItem, @@ -334,20 +335,35 @@ const DrawerNavigatorWrapper: React.FC<{ chatClient: StreamChat; i18nInstance: Streami18n; }> = ({ chatClient, i18nInstance }) => { + // `attempt` re-mounts after the offline database has been deleted; + // `offlineSupport` is switched off once there is no usable encryption key. + const [attempt, setAttempt] = useState(0); + const [offlineSupport, setOfflineSupport] = useState(true); + + // The boundary stops rendering its children once it has caught (see its render), and + // nothing else clears that. Keying it on both recovery levers re-mounts it when one is + // pulled - without that it would sit on a blank screen forever, having already deleted + // the database. return ( - setOfflineSupport(false)} + onRetry={() => setAttempt((value) => value + 1)} > - - - - - - + + + + + + + + ); }; diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 34584cc346..e86287ae53 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -298,7 +298,7 @@ PODS: - React-utils - ReactNativeDependencies - Yoga - - React-Core-prebuilt (0.86.0): + - React-Core-prebuilt (0.86.2): - ReactNativeDependencies - React-Core/CoreModulesHeaders (0.86.2): - hermes-engine @@ -2857,7 +2857,7 @@ PODS: - SDWebImageWebPCoder (0.15.0): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - stream-chat-react-native (9.7.2): + - stream-chat-react-native (9.7.6): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3291,7 +3291,7 @@ SPEC CHECKSUMS: GoogleAppMeasurement: 57270ccc2b77472d7e85c4cbe45972564eff78bb GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850 - hermes-engine: 188393eb43a0cce2dfbf912e6d22c7bb6469957d + hermes-engine: 3730f5b467f988fa954ded67cbea8a9ba32d854c libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 @@ -3309,7 +3309,7 @@ SPEC CHECKSUMS: React: 4b2532a459d15e1adf6c22d3e399e5c85a94220f React-callinvoker: 0b8ce4057e02a0bd15cf0532596e8eb8c0392e92 React-Core: 5af045531a540ba3f65f07de1e3f585ddfb27948 - React-Core-prebuilt: 13924a267683b3d6fa4bde9c80380becf83a9c5c + React-Core-prebuilt: 405cf395d66cf694faf9aed3483a21b5515cec85 React-CoreModules: 99b194a721de84ccfc1be149a0de52647dc38c0e React-cxxreact: b7e8e254074fd8111d147202b391ccf7816946a6 React-debug: 3281bfefe5ece9a9d8b28bec3f871db229f9d8d8 @@ -3399,7 +3399,7 @@ SPEC CHECKSUMS: SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 - stream-chat-react-native: e97f6d3ed0c2828b20610ffc0023ad7f9c90738d + stream-chat-react-native: ea3499916019c499ecb745be61e7ecf82f0fd999 Teleport: c56b30b08bd20d10da1efb0f21c6bc50c269ee6a Yoga: 542a30dafe5b0f5f1d9f185ea7b2a3811ac54801 diff --git a/examples/SampleApp/src/components/OfflineDbBoundary.tsx b/examples/SampleApp/src/components/OfflineDbBoundary.tsx new file mode 100644 index 0000000000..d58806ddbb --- /dev/null +++ b/examples/SampleApp/src/components/OfflineDbBoundary.tsx @@ -0,0 +1,72 @@ +import React from 'react'; + +import { + SqliteClient, + SqliteClientError, + type SqliteClientErrorCode, +} from 'stream-chat-react-native'; + +/** + * `` throws a {@link SqliteClientError} from render when it cannot open the + * offline database - most often `OFFLINE_DB_UNREADABLE`, meaning the file on disk + * cannot be read (corruption, or a database left behind from a different encryption + * mode). It never silently continues without the cache; recovery is the application's + * decision. + * + * The recommended recovery, shown here: the contents are a cache, so delete the + * database and let it rebuild from the server. The only real loss is actions that were + * queued while offline, so a real app may want to confirm with the user first. + * + * The `onGiveUp` path covers the codes that mean "no usable encryption key" + * (`SQLCIPHER_BUILD_MISSING`, `ENCRYPTION_KEY_UNAVAILABLE`). Those only occur when + * `` is given a `getOfflineDbEncryptionKey` prop, which this sample does not do - + * a new database would then be written in plaintext, so running online-only is the safe + * response. + */ +type BoundaryProps = React.PropsWithChildren<{ + onGiveUp: () => void; + onRetry: () => void; +}>; + +type BoundaryState = { code?: SqliteClientErrorCode }; + +export class OfflineDbBoundary extends React.Component { + state: BoundaryState = {}; + + // Must return state, and render() must stop rendering the failing subtree. Returning + // null here would re-render the same children, they would throw again, and React + // would give up and unmount the whole app. + static getDerivedStateFromError(error: unknown) { + if (!(error instanceof SqliteClientError)) { + // Not one of ours - re-throw so it reaches whatever boundary owns it. + throw error; + } + return { code: error.code }; + } + + componentDidCatch(error: unknown) { + if (!(error instanceof SqliteClientError)) { + return; + } + + if (error.code === 'OFFLINE_DB_UNREADABLE') { + // The recommended recovery: the contents are a cache, so drop the database and + // let it rebuild. Only actions queued while offline are lost. + try { + SqliteClient.deleteDatabase(); + } catch (deleteError) { + console.warn('[SampleApp] could not delete the offline database', deleteError); + } + this.props.onRetry(); + return; + } + + // No usable key, so a new database would be plaintext. Run online-only instead. + console.warn(`[SampleApp] offline encryption unavailable (${error.code}); going online-only`); + this.props.onGiveUp(); + } + + render() { + return this.state.code ? null : this.props.children; + } +} diff --git a/package/src/components/Chat/Chat.tsx b/package/src/components/Chat/Chat.tsx index 7eb6228cf3..17c221054c 100644 --- a/package/src/components/Chat/Chat.tsx +++ b/package/src/components/Chat/Chat.tsx @@ -6,6 +6,7 @@ import { Channel, OfflineDBState } from 'stream-chat'; import { useClientMutedUsers } from './hooks'; import { useAppSettings } from './hooks/useAppSettings'; import { useCreateChatContext } from './hooks/useCreateChatContext'; +import { useInitializeOfflineDb } from './hooks/useInitializeOfflineDb'; import { useIsOnline } from './hooks/useIsOnline'; import { ChannelsStateProvider } from '../../contexts/channelsStateContext/ChannelsStateContext'; @@ -24,7 +25,6 @@ import init from '../../init'; import { NativeHandlers } from '../../native'; import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../store/constants'; -import { OfflineDB } from '../../store/OfflineDB'; import type { Streami18n } from '../../utils/i18n/Streami18n'; import { installNativeMultipartAdapter } from '../../utils/installNativeMultipartAdapter'; @@ -45,6 +45,50 @@ export type ChatProps = Pick & * Enables offline storage and loading for chat data. */ enableOfflineSupport?: boolean; + /** + * Encrypts the offline database at rest with SQLCipher, using the key this + * resolves to. Only relevant when `enableOfflineSupport` is enabled. Leaving it + * unset keeps the offline database unencrypted, which is the default. + * + * Requires a native build of `@op-engineering/op-sqlite` that includes SQLCipher. + * Add the following to your application's `package.json` and rebuild the native + * app - without the flag the key is accepted and then silently ignored: + * + * ```json + * { "op-sqlite": { "sqlcipher": true } } + * ``` + * + * **Wrap `` in an error boundary.** If the database cannot be opened with + * the encryption you asked for, `` throws a {@link SqliteClientError} + * from render instead of continuing without it. The SDK deliberately takes no + * recovery action of its own - it never deletes data, and never silently falls + * back to an unencrypted or absent cache. Discriminate on `code`: + * + * - `OFFLINE_DB_UNREADABLE` - the file exists but this key cannot read it (the + * key changed, or the database predates encryption). **Recommended recovery: + * `SqliteClient.deleteDatabase()`, then re-mount ``.** The contents are a + * cache and are refetched from the server; the exception is actions queued while + * offline, which are lost - prompt the user first if that matters to you. + * - `ENCRYPTION_KEY_UNAVAILABLE` - the key could not be read (a locked keychain, a + * launch before first unlock). The database is untouched. **Recommended + * recovery: re-mount to retry** once the key is readable - for example when the + * app next returns to the foreground. + * - `SQLCIPHER_BUILD_MISSING` - the native build has no SQLCipher, so the key + * would be ignored and the database written in plaintext. Not recoverable at + * runtime; it needs the build flag above and a new binary. **Recommended + * recovery: re-mount with `enableOfflineSupport={false}`** so nothing is + * persisted unencrypted. + * + * The key must be **stable for the lifetime of the database file**. There is no + * rekey path, so a key that changes costs one `OFFLINE_DB_UNREADABLE` and a + * rebuild. To rotate without paying that, rotate a key-encryption key and keep the + * database key it protects unchanged (envelope encryption). + * + * Switching encryption on, or back off, leaves a database from the other mode on + * disk and so raises `OFFLINE_DB_UNREADABLE` once in each direction. Deleting it + * from your boundary is all that is needed. + */ + getOfflineDbEncryptionKey?: () => Promise; /** * Optional positive cap on the number of events a single `/sync` response may * contain before the offline sync manager skips replaying those events into @@ -172,6 +216,7 @@ const ChatWithContext = (props: PropsWithChildren) => { client, closeConnectionOnBackground = true, enableOfflineSupport = false, + getOfflineDbEncryptionKey, i18nInstance, isMessageAIGenerated, maxSyncEventsLimit = DEFAULT_MAX_SYNC_EVENTS_LIMIT, @@ -241,23 +286,12 @@ const ChatWithContext = (props: PropsWithChildren) => { const setActiveChannel = (newChannel?: Channel) => setChannel(newChannel); - useEffect(() => { - if (!(userID && enableOfflineSupport)) { - return; - } - - const initializeDatabase = async () => { - if (!client.offlineDb) { - client.setOfflineDBApi(new OfflineDB({ client, maxSyncEventsLimit })); - } - - if (client.offlineDb) { - await client.offlineDb.init(userID); - } - }; - - initializeDatabase(); - }, [userID, enableOfflineSupport, client, maxSyncEventsLimit]); + useInitializeOfflineDb({ + client, + enabled: enableOfflineSupport, + options: { getEncryptionKey: getOfflineDbEncryptionKey, maxSyncEventsLimit }, + userID, + }); useEffect(() => { if (!client) { diff --git a/package/src/components/Chat/__tests__/Chat.test.tsx b/package/src/components/Chat/__tests__/Chat.test.tsx index 4d6a43ad29..8862b49657 100644 --- a/package/src/components/Chat/__tests__/Chat.test.tsx +++ b/package/src/components/Chat/__tests__/Chat.test.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { View } from 'react-native'; import NetInfo from '@react-native-community/netinfo'; @@ -9,10 +9,12 @@ import { useChatContext } from '../../../contexts/chatContext/ChatContext'; import type { TranslationContextValue } from '../../../contexts/translationContext/TranslationContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; +import { sqliteMock } from '../../../mock-builders/DB/mock'; import dispatchConnectionChangedEvent from '../../../mock-builders/event/connectionChanged'; import dispatchConnectionRecoveredEvent from '../../../mock-builders/event/connectionRecovered'; import { getTestClient, getTestClientWithUser, setUser } from '../../../mock-builders/mock'; import { DEFAULT_MAX_SYNC_EVENTS_LIMIT } from '../../../store/constants'; +import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient'; import { Streami18n } from '../../../utils/i18n/Streami18n'; import { Chat } from '../Chat'; @@ -368,3 +370,242 @@ describe('TranslationContext', () => { expect(chatClientWithUser.offlineDb!.syncManager.syncMaxEventCount).toBeUndefined(); }); }); + +describe('Chat offline DB encryption', () => { + const installedSpies: jest.SpyInstance[] = []; + + /** + * Registers a spy for teardown. Deliberately not jest.restoreAllMocks(): that also + * restores the connection privates mockClient() stubs out on every client created + * by earlier tests in this file, after which those clients reconnect for real and + * the failed websocket handshake resurfaces as an unhandled error somewhere else. + */ + const track = (spy: T): T => { + installedSpies.push(spy); + return spy; + }; + + /** + * Chat mounts useIsOnline, which opens the websocket whenever the app comes to the + * foreground. Left real, that connection attempt outlives the test and rejects + * asynchronously. Nothing in this block needs a connection. + */ + const createClient = async () => { + const client = await getTestClientWithUser({ id: 'testID' }); + track(jest.spyOn(client, 'openConnection').mockResolvedValue(undefined)); + track(jest.spyOn(client, 'closeConnection').mockResolvedValue(undefined)); + return client; + }; + + /** Minimal error boundary, since `` reports encryption failures by throwing. */ + class Boundary extends React.Component< + PropsWithChildren<{ onCatch: (error: Error) => void }>, + { caught: boolean } + > { + state = { caught: false }; + + static getDerivedStateFromError() { + return { caught: true }; + } + + componentDidCatch(error: Error) { + this.props.onCatch(error); + } + + render() { + return this.state.caught ? : this.props.children; + } + } + + afterEach(() => { + cleanup(); + installedSpies.splice(0).forEach((spy) => spy.mockRestore()); + SqliteClient.getEncryptionKey = undefined; + }); + + it('does not configure an encryption key when the prop is omitted', async () => { + const chatClientWithUser = await createClient(); + + render(); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + expect(SqliteClient.getEncryptionKey).toBeUndefined(); + }); + + it('forwards getOfflineDbEncryptionKey to the sqlite client', async () => { + const chatClientWithUser = await createClient(); + const getOfflineDbEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + render( + , + ); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + await waitFor(() => expect(getOfflineDbEncryptionKey).toHaveBeenCalled()); + }); + + it('does not re-initialize when getOfflineDbEncryptionKey is a new function every render', async () => { + const chatClientWithUser = await createClient(); + const resolveKey = jest.fn().mockResolvedValue('a-stable-key'); + + // An inline arrow is the shape integrators reach for first, so a changing + // identity must not restart initialization on every render. + const { rerender } = render( + resolveKey()} + />, + ); + + await waitFor(() => expect(chatClientWithUser.offlineDb).toBeDefined()); + const initSpy = track(jest.spyOn(chatClientWithUser.offlineDb!, 'init')); + + rerender( + resolveKey()} + />, + ); + rerender( + resolveKey()} + />, + ); + + await waitFor(() => expect(initSpy).not.toHaveBeenCalled()); + }); + + it.each<[string, () => Promise, string]>([ + ['the key cannot be read', () => Promise.resolve(undefined), 'ENCRYPTION_KEY_UNAVAILABLE'], + [ + 'the key getter throws', + () => Promise.reject(new Error('keychain is locked')), + 'ENCRYPTION_KEY_UNAVAILABLE', + ], + ])('throws %s so an error boundary can decide', async (_label, getKey, code) => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + + + + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect(onCatch).toHaveBeenCalledWith(expect.any(SqliteClientError)); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe(code); + // Never silently downgraded to online-only. + expect(() => getByTestId('children')).toThrow(); + }); + + it('throws when the native build has no SQLCipher', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + track(jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false)); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('SQLCIPHER_BUILD_MISSING'); + }); + + it('throws OFFLINE_DB_UNREADABLE without deleting the database', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + // Preflight passes, then the first read of the file fails to decrypt. + track( + jest + .spyOn(SqliteClient, 'getUserPragmaVersion') + .mockRejectedValue(new Error('Querying for user_version failed: file is not a database')), + ); + const deleteSpy = track(jest.spyOn(SqliteClient, 'deleteDatabase')); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + expect((onCatch.mock.calls[0][0] as SqliteClientError).code).toBe('OFFLINE_DB_UNREADABLE'); + // Wiping is the integrator's decision, made from the boundary. + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + it('never attaches an offline DB it cannot open', async () => { + const chatClientWithUser = await createClient(); + track(jest.spyOn(console, 'warn').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'error').mockImplementation(() => undefined)); + track(jest.spyOn(console, 'log').mockImplementation(() => undefined)); + const setOfflineDBApiSpy = track(jest.spyOn(chatClientWithUser, 'setOfflineDBApi')); + + const { getByTestId } = render( + undefined}> + Promise.resolve(undefined)} + /> + , + ); + + await waitFor(() => expect(getByTestId('boundary')).toBeTruthy()); + + // Parts of the client write through `client.offlineDb` without checking that it + // initialized - queryChannels upserts into it - so an instance we cannot open + // would turn those writes into rejections. + expect(setOfflineDBApiSpy).not.toHaveBeenCalled(); + expect(chatClientWithUser.offlineDb).toBeUndefined(); + }); + + it('renders normally when nothing is wrong with encryption', async () => { + const chatClientWithUser = await createClient(); + const onCatch = jest.fn(); + + const { getByTestId } = render( + + Promise.resolve('a-stable-key')} + > + + + , + ); + + await waitFor(() => expect(getByTestId('children')).toBeTruthy()); + expect(onCatch).not.toHaveBeenCalled(); + }); +}); diff --git a/package/src/components/Chat/hooks/useInitializeOfflineDb.ts b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts new file mode 100644 index 0000000000..04860a28e2 --- /dev/null +++ b/package/src/components/Chat/hooks/useInitializeOfflineDb.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useState } from 'react'; + +import type { StreamChat } from 'stream-chat'; + +import { useStableCallback } from '../../../hooks/useStableCallback'; +import { OfflineDB } from '../../../store/OfflineDB'; +import { SqliteClient, SqliteClientError } from '../../../store/SqliteClient'; + +export type InitializeOfflineDbOptions = { + /** + * Encrypts the offline database at rest with SQLCipher, using the key this resolves + * to. Leaving it unset opens the database unencrypted, which is the default. See + * `ChatProps.getOfflineDbEncryptionKey` for the build flag it requires, the stability + * requirement, and how failures are surfaced. + */ + getEncryptionKey?: () => Promise; + /** + * Optional positive cap on the number of events a single `/sync` response may + * contain before the offline sync manager skips replaying those events into local + * storage. `false` opts out entirely. + */ + maxSyncEventsLimit?: number | false; +}; + +export type UseInitializeOfflineDbParams = { + client: StreamChat; + /** Whether offline support is enabled at all. */ + enabled: boolean; + options?: InitializeOfflineDbOptions; + userID?: string; +}; + +/** + * Attaches an offline database to the client and initializes it for a user. + * + * **Raises** whatever prevented the database from opening, from render, so an error + * boundary above the caller can decide what to do. The offline database is never + * silently downgraded, because an integration that asked for encryption must not end + * up with an unencrypted cache. + */ +export const useInitializeOfflineDb = ({ + client, + enabled, + options, + userID, +}: UseInitializeOfflineDbParams) => { + /** + * Why this attempt could not open the offline database. + * + * Held per attempt rather than read from a longer-lived source: a value that outlived + * the attempt would be seen during the first render after a re-mount and raised + * before that mount's own attempt could run, so an error boundary that re-mounts to + * retry would loop forever. + */ + const [initializationError, setInitializationError] = useState(); + + const { getEncryptionKey, maxSyncEventsLimit } = options ?? {}; + + // `getEncryptionKey` is overwhelmingly likely to be an inline arrow. Stabilising it + // keeps a new identity per render out of the dependencies below, while still calling + // whatever the latest prop is. + const resolveEncryptionKey = useStableCallback( + () => getEncryptionKey?.() ?? Promise.resolve(undefined), + ); + const isEncryptionEnabled = !!getEncryptionKey; + + const initialize = useCallback(async () => { + if (!(userID && enabled)) { + return; + } + + if (!client.offlineDb) { + const keyGetter = isEncryptionEnabled ? resolveEncryptionKey : undefined; + + // Confirm the database can be opened before attaching it: the client writes + // through `client.offlineDb` without checking that it initialized, so one we + // cannot open turns those writes into rejections (and UI is affected directly). + if (keyGetter) { + SqliteClient.getEncryptionKey = keyGetter; + try { + await SqliteClient.preflightEncryption(); + } catch (error) { + if (error instanceof SqliteClientError) { + setInitializationError(error); + return; + } + throw error; + } + } + + client.setOfflineDBApi( + new OfflineDB({ client, getEncryptionKey: keyGetter, maxSyncEventsLimit }), + ); + } + + const { offlineDb } = client; + if (offlineDb) { + await offlineDb.init(userID); + // Note: Since `init()` currently swallows errors by design, we have to rely + // on consuming the error later in order to be able to still rethrow without + // introducing a breaking change. + // TODO: The DB API should be changed in the next major to always throw upwards + // and let integrators handle it if necessary. + setInitializationError( + offlineDb instanceof OfflineDB ? offlineDb.initializationError : undefined, + ); + } + }, [client, enabled, isEncryptionEnabled, maxSyncEventsLimit, resolveEncryptionKey, userID]); + + useEffect(() => { + initialize(); + }, [initialize]); + + if (initializationError) { + throw initializationError; + } +}; diff --git a/package/src/index.ts b/package/src/index.ts index 6c8f8c1f45..cb8584481d 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -40,7 +40,7 @@ export { default as ruTranslations } from './i18n/ru.json'; export { default as trTranslations } from './i18n/tr.json'; export * from './state-store'; -export { SqliteClient } from './store/SqliteClient'; +export { SqliteClient, SqliteClientError, type SqliteClientErrorCode } from './store/SqliteClient'; export { OfflineDB } from './store/OfflineDB'; export { version } from './version.json'; diff --git a/package/src/mock-builders/DB/mock.ts b/package/src/mock-builders/DB/mock.ts index 7fa94cfc65..8d03370c98 100644 --- a/package/src/mock-builders/DB/mock.ts +++ b/package/src/mock-builders/DB/mock.ts @@ -1,3 +1,5 @@ +import { rmSync } from 'fs'; + import Sqlite3 from 'better-sqlite3'; import type { PreparedQueries } from '../../store/types'; @@ -6,6 +8,11 @@ let db: Sqlite3.Database; const testDbName = `foobar-${process.env.JEST_WORKER_ID ?? '0'}.db`; export const sqliteMock = { + // better-sqlite3 has no SQLCipher, so an `encryptionKey` passed to open() is + // simply ignored. Reporting a SQLCipher build keeps the encrypted path + // exercisable in tests; whether the bytes on disk are actually encrypted can + // only be verified on a device. Spy on this to test the build-missing guard. + isSQLCipher: () => true, open: () => { db = new Sqlite3(testDbName); db.pragma('journal_mode = MEMORY'); @@ -18,6 +25,12 @@ export const sqliteMock = { status: 0, }; }, + // Mirrors op-sqlite's delete(): closes the handle and unlinks the file, so a + // subsequent open() starts from an empty database. + delete: () => { + db.close(); + rmSync(testDbName, { force: true }); + }, execute: async (queryInput: string, params: unknown[]) => { const query = queryInput.trim().toLowerCase(); diff --git a/package/src/store/OfflineDB.ts b/package/src/store/OfflineDB.ts index d0d6a408fc..09b565a75e 100644 --- a/package/src/store/OfflineDB.ts +++ b/package/src/store/OfflineDB.ts @@ -9,20 +9,30 @@ import type { } from 'stream-chat'; import * as api from './apis'; -import { SqliteClient } from './SqliteClient'; +import { SqliteClient, SqliteClientError } from './SqliteClient'; export class OfflineDB extends AbstractOfflineDB { constructor({ client, + getEncryptionKey, maxSyncEventsLimit, }: { client: StreamChat; + /** + * Supplies the SQLCipher key the offline database is opened with. See + * {@link SqliteClient.getEncryptionKey} for the stability requirement. + */ + getEncryptionKey?: () => Promise; maxSyncEventsLimit?: number | false; }) { super({ client, syncMaxEventCount: maxSyncEventsLimit === false ? undefined : maxSyncEventsLimit, }); + // Assigned unconditionally: SqliteClient holds this statically, so leaving a + // previous instance's getter in place would keep encrypting after the caller + // stopped asking for it. + SqliteClient.getEncryptionKey = getEncryptionKey; } upsertCidsForQuery = api.upsertCidsForQuery; @@ -106,5 +116,26 @@ export class OfflineDB extends AbstractOfflineDB { executeSqlBatch = SqliteClient.executeSqlBatch; - initializeDB = SqliteClient.initializeDatabase; + /** + * Why the most recent {@link initializeDB} failed, if it did. + * + * `AbstractOfflineDB.init` catches whatever `initializeDB` throws and does not + * re-throw it, so a caller has no way to see the reason. Recording it here on the + * way out gives the caller something to read back once `init` has settled. Kept on + * the instance rather than a static so two clients cannot overwrite each other. + */ + initializationError: SqliteClientError | undefined; + + initializeDB = async () => { + this.initializationError = undefined; + try { + return await SqliteClient.initializeDatabase(); + } catch (error) { + if (error instanceof SqliteClientError) { + this.initializationError = error; + } + // Re-thrown so `AbstractOfflineDB.init` still marks the database uninitialized. + throw error; + } + }; } diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 733fc95dd8..22b532e971 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -23,6 +23,29 @@ import { tables } from './schema'; import { createCreateTableQuery } from './sqlite-utils/createCreateTableQuery'; import type { PreparedBatchQueries, PreparedQueries, Scalar, Table } from './types'; +/** + * Why the offline database could not be opened. The first two only arise when + * {@link SqliteClient.getEncryptionKey} is set; `OFFLINE_DB_UNREADABLE` can also mean + * plain corruption, or a database left behind from the other encryption mode. + */ +export type SqliteClientErrorCode = + | 'SQLCIPHER_BUILD_MISSING' + | 'ENCRYPTION_KEY_UNAVAILABLE' + | 'OFFLINE_DB_UNREADABLE'; + +export class SqliteClientError extends Error { + public readonly code: SqliteClientErrorCode; + + constructor(code: SqliteClientErrorCode, message: string, options?: { cause?: unknown }) { + super(message); + this.name = 'SqliteClientError'; + this.code = code; + // Assigned here rather than passed through `super(message, { cause })` because + // Hermes does not reliably honour the ErrorOptions overload. + this.cause = options?.cause; + } +} + /** * SqliteClient takes care of any direct interaction with sqlite. * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. @@ -35,10 +58,126 @@ export class SqliteClient { static logger: Logger | undefined; static db: _InternalDB | undefined; + /** + * Supplies the SQLCipher key the offline database is opened with; `undefined` + * opens it unencrypted, which is the default. The key must be stable for the + * lifetime of the database file - there is no rekey path, so a database this key + * cannot read raises `OFFLINE_DB_UNREADABLE` on the first page read. The file is + * left untouched; recovery is `SqliteClient.deleteDatabase()` and a re-mount. + */ + static getEncryptionKey: (() => Promise) | undefined; + + /** Key resolved by {@link preflightEncryption}, consumed by the next {@link openDB}. */ + private static preflightedKey: string | undefined; + + /** Busy/disk/memory failures. Checked first: wiping over these destroys a good db. */ + private static TRANSIENT_ERROR = + /database is locked|SQLITE_BUSY|SQLITE_LOCKED|disk i\/o|SQLITE_IOERR|unable to open|SQLITE_CANTOPEN|out of memory|readonly/i; + + /** + * The bytes on disk cannot be read with the key we have: wrong/rotated key, + * plaintext-encrypted mismatch or corruption. SQLCipher has no decrypt specific + * code and overloads NOTADB (26), occasionally CORRUPT (11). + */ + private static UNREADABLE_ERROR = + /not a database|file is encrypted|malformed|disk image is malformed|SQLite (?:error )?code:?\s*(?:26|11)\b|NOTADB|SQLITE_CORRUPT/i; + static getDbVersion = () => SqliteClient.dbVersion; // Force a specific db version. This is mainly useful for testsuit. static setDbVersion = (version: number) => (SqliteClient.dbVersion = version); + /** + * Records and re-throws. Deliberately does not write to the console: the error is + * thrown, so logging it here would duplicate whatever the caller's error boundary + * reports - and in dev React already logs every boundary-caught error, which is what + * LogBox turns red. + */ + private static recordError = (e: SqliteClientError) => { + this.logger?.('error', e.message, { tag: e.code }); + + throw e; + }; + + /** + * Resolves the encryption key without opening the database, so callers can decide + * whether to attach an `OfflineDB` at all. Parts of the client write through + * `client.offlineDb` without checking that it initialized (`queryChannels` upserts + * into it), so attaching one we cannot open turns those writes into rejections. + * + * Throws {@link SqliteClientError}. The key is handed to the next + * {@link openDB} rather than read from `getEncryptionKey` twice. + */ + static preflightEncryption = async () => { + try { + this.preflightedKey = await this.resolveEncryptionKey(); + } catch (e) { + if (e instanceof SqliteClientError) { + this.recordError(e); + } + throw e; + } + }; + + /** + * The key to open with, or `undefined` when the database is meant to be + * unencrypted. Throws rather than silently falling back to an unencrypted + * database, which would hand an integration that asked for encryption a plaintext + * cache of its users' messages. + */ + private static resolveEncryptionKey = async () => { + const { getEncryptionKey } = this; + + if (!getEncryptionKey) { + return undefined; + } + + // A non-SQLCipher build accepts `encryptionKey` at the JSI boundary and then + // drops it - plaintext database, no error anywhere. `isSQLCipher` has existed + // since op-sqlite 9, well below the peer floor, so the typeof check is not really + // necessary but we'll keep it in case something changes in the future so that + // we at least have a clearer error. + if (sqlite === undefined) { + throw new SqliteClientError( + 'SQLCIPHER_BUILD_MISSING', + 'An offline database encryption key was provided but "@op-engineering/op-sqlite" ' + + 'is not installed.', + ); + } + if (typeof sqlite.isSQLCipher !== 'function' || !sqlite.isSQLCipher()) { + throw new SqliteClientError( + 'SQLCIPHER_BUILD_MISSING', + 'An offline database encryption key was provided but @op-engineering/op-sqlite was ' + + 'not built with SQLCipher, so the key would be silently ignored and the offline ' + + 'database written in plaintext. Add { "op-sqlite": { "sqlcipher": true } } to your ' + + "application's package.json and rebuild, or stop providing a key.", + ); + } + + let encryptionKey: string | undefined; + + try { + encryptionKey = await getEncryptionKey(); + } catch (error) { + throw new SqliteClientError( + 'ENCRYPTION_KEY_UNAVAILABLE', + 'The offline database encryption key getter threw, so the database cannot be opened.', + { cause: error }, + ); + } + + // Not being handed a key is not the same as being handed the wrong one, so a locked + // keychain must not cost us a database we can still read later. + if (!encryptionKey) { + throw new SqliteClientError( + 'ENCRYPTION_KEY_UNAVAILABLE', + 'The offline database encryption key getter resolved without a key, so the database ' + + 'cannot be opened.', + ); + } + + return encryptionKey; + }; + static openDB = async () => { try { if (sqlite === undefined) { @@ -46,13 +185,24 @@ export class SqliteClient { 'Please install "@op-engineering/op-sqlite" package to enable offline support', ); } + const encryptionKey = this.preflightedKey ?? (await this.resolveEncryptionKey()); + this.preflightedKey = undefined; + this.db = sqlite.open({ location: SqliteClient.dbLocation, name: SqliteClient.dbName, + ...(encryptionKey ? { encryptionKey } : {}), }); + // Note: this will not fail on an encryption key mismatch, as we do not read + // any pages, but rather look at a connection level flag. The first failure + // is going to be whatever actually reads something, which is going to be + // the user_version read in initializeDatabase. await this.db?.execute('PRAGMA foreign_keys = ON', []); } catch (e) { + if (e instanceof SqliteClientError) { + throw e; + } this.logger?.('error', `Error opening database ${SqliteClient.dbName}`, { error: e, }); @@ -154,7 +304,23 @@ export class SqliteClient { return true; }; - static initializeDatabase = async () => { + /** + * Whether the file cannot be read with the key we have, as opposed to being + * temporarily unavailable (busy, locked, disk). Works off message text because + * op-sqlite rejects with a plain Error and this class re-wraps those messages, so + * no numeric code survives. Drives `OFFLINE_DB_UNREADABLE`. + */ + static isUnreadableDbError = (e: unknown) => { + const message = String((e as Error)?.message ?? e); + + if (this.TRANSIENT_ERROR.test(message)) { + return false; + } + + return this.UNREADABLE_ERROR.test(message); + }; + + static initializeDatabase = async (): Promise => { try { await SqliteClient.openDB(); const version = await SqliteClient.getUserPragmaVersion(); @@ -180,6 +346,24 @@ export class SqliteClient { return true; } catch (e) { + if (e instanceof SqliteClientError) { + this.recordError(e); + } + + if (this.isUnreadableDbError(e)) { + this.recordError( + new SqliteClientError( + 'OFFLINE_DB_UNREADABLE', + 'The offline database exists but could not be read. Usually the encryption ' + + 'key changed, or encryption was turned on or off while a database from ' + + 'the other mode was still on disk. Delete it with ' + + 'SqliteClient.deleteDatabase() and re-mount to rebuild from the server - ' + + 'everything in it is a cache, except queued offline actions, which are lost.', + { cause: e }, + ), + ); + } + console.log('Error initializing DB', e); this.logger?.('error', 'Error initializing DB', { dbLocation: SqliteClient.dbLocation, diff --git a/package/src/store/__tests__/SqliteClient.test.ts b/package/src/store/__tests__/SqliteClient.test.ts new file mode 100644 index 0000000000..0ee0f2e406 --- /dev/null +++ b/package/src/store/__tests__/SqliteClient.test.ts @@ -0,0 +1,258 @@ +import { sqliteMock } from '../../mock-builders/DB/mock'; +import { SqliteClient, SqliteClientError } from '../SqliteClient'; + +// Captured before any spy is installed so the spy can call through to a real +// better-sqlite3 handle while still observing the arguments open() was given. +const openDatabase = sqliteMock.open; + +/** Runs `initializeDatabase` once and returns the error it threw. */ +const captureInitError = async () => { + try { + await SqliteClient.initializeDatabase(); + } catch (error) { + return error as SqliteClientError; + } + throw new Error('expected initializeDatabase to reject, but it resolved'); +}; + +describe('SqliteClient encryption', () => { + let openSpy: jest.SpyInstance>; + let deleteMocks: jest.Mock[]; + + beforeEach(() => { + SqliteClient.getEncryptionKey = undefined; + SqliteClient.db = undefined; + SqliteClient.logger = jest.fn(); + + deleteMocks = []; + openSpy = jest.spyOn(sqliteMock, 'open').mockImplementation(() => { + const db = openDatabase(); + const originalDelete = db.delete; + const deleteMock = jest.fn(() => originalDelete()); + deleteMocks.push(deleteMock); + return { ...db, delete: deleteMock }; + }); + + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + SqliteClient.getEncryptionKey = undefined; + SqliteClient.logger = undefined; + SqliteClient.db = undefined; + }); + + describe('opening without encryption', () => { + it('does not pass an encryption key when no getter is configured', async () => { + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + + expect(openSpy).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).not.toHaveProperty('encryptionKey'); + }); + + it('never consults isSQLCipher when no getter is configured', async () => { + const isSQLCipherSpy = jest.spyOn(sqliteMock, 'isSQLCipher'); + + await SqliteClient.initializeDatabase(); + + expect(isSQLCipherSpy).not.toHaveBeenCalled(); + }); + }); + + describe('opening with encryption', () => { + it('passes the resolved key to open()', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + + expect(SqliteClient.getEncryptionKey).toHaveBeenCalledTimes(1); + expect(openSpy.mock.calls[0][0]).toMatchObject({ encryptionKey: 'a-stable-key' }); + }); + + it('refuses to open at all when op-sqlite has no SQLCipher build', async () => { + jest.spyOn(sqliteMock, 'isSQLCipher').mockReturnValue(false); + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('SQLCIPHER_BUILD_MISSING'); + // The whole point: no database is created, so nothing is written in plaintext. + expect(openSpy).not.toHaveBeenCalled(); + // Nor is the key ever requested - the build is unusable regardless of it. + expect(SqliteClient.getEncryptionKey).not.toHaveBeenCalled(); + expect(deleteMocks).toHaveLength(0); + }); + + it('refuses to open when isSQLCipher is missing from the installed op-sqlite', async () => { + // An op-sqlite too old to expose the check cannot be verified, so it is + // treated exactly like a build without SQLCipher. + const { isSQLCipher } = sqliteMock; + // @ts-expect-error deliberately simulating an older op-sqlite + delete sqliteMock.isSQLCipher; + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + + try { + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + expect(error.code).toBe('SQLCIPHER_BUILD_MISSING'); + expect(openSpy).not.toHaveBeenCalled(); + } finally { + sqliteMock.isSQLCipher = isSQLCipher; + } + }); + }); + + describe('when the encryption key cannot be obtained', () => { + it('gives up without wiping when the getter throws', async () => { + const cause = new Error('keychain is locked'); + SqliteClient.getEncryptionKey = jest.fn().mockRejectedValue(cause); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(error.cause).toBe(cause); + expect(openSpy).not.toHaveBeenCalled(); + // Not being handed a key says nothing about the database on disk, so it stays. + expect(deleteMocks).toHaveLength(0); + }); + + it('gives up without wiping when the getter resolves without a key', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(undefined); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(openSpy).not.toHaveBeenCalled(); + expect(deleteMocks).toHaveLength(0); + }); + + it('treats an empty string as no key', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(''); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('ENCRYPTION_KEY_UNAVAILABLE'); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('clears a recorded encryption error once initialization succeeds', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue(undefined); + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + expect(error).toBeDefined(); + + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + await expect(SqliteClient.initializeDatabase()).resolves.toBe(true); + }); + }); + + describe('when the database cannot be read', () => { + const notADatabase = () => + new Error('Querying for user_version failed: Error: file is not a database'); + + it('throws OFFLINE_DB_UNREADABLE instead of wiping it', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(notADatabase()); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('OFFLINE_DB_UNREADABLE'); + // Deleting it is the caller's decision - the pending-task queue lives in there. + expect(deleteMocks.every((m) => m.mock.calls.length === 0)).toBe(true); + }); + + it('keeps the original cause on the thrown error', async () => { + const cause = notADatabase(); + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(cause); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.cause).toBe(cause); + }); + + it('throws even with no encryption configured', async () => { + // Turning encryption off leaves an encrypted file and no key to read it. Simply + // reporting failure would leave offline support uninitialized forever, so the + // caller is told and can delete it. + jest.spyOn(SqliteClient, 'getUserPragmaVersion').mockRejectedValue(notADatabase()); + + const error = await captureInitError(); + + expect(error).toBeInstanceOf(SqliteClientError); + + expect(error.code).toBe('OFFLINE_DB_UNREADABLE'); + }); + + it('does not throw on a transient failure', async () => { + SqliteClient.getEncryptionKey = jest.fn().mockResolvedValue('a-stable-key'); + jest + .spyOn(SqliteClient, 'getUserPragmaVersion') + .mockRejectedValue(new Error('Query failed: Error: database is locked')); + + await expect(SqliteClient.initializeDatabase()).resolves.toBe(false); + }); + }); + + describe('isUnreadableDbError', () => { + it.each([ + 'file is not a database', + 'SQLite error code: 26', + 'SQLite code:11', + 'NOTADB', + 'SQLITE_CORRUPT', + 'database disk image is malformed', + 'file is encrypted or is not a database', + ])('treats %p as unreadable', (message) => { + expect(SqliteClient.isUnreadableDbError(new Error(message))).toBe(true); + }); + + it.each([ + 'database is locked', + 'SQLITE_BUSY', + 'SQLITE_LOCKED', + 'disk I/O error', + 'SQLITE_IOERR', + 'unable to open database file', + 'SQLITE_CANTOPEN', + 'out of memory', + 'attempt to write a readonly database', + 'DB is not open or initialized.', + 'Please install "@op-engineering/op-sqlite" package to enable offline support', + ])('does not treat %p as unreadable', (message) => { + expect(SqliteClient.isUnreadableDbError(new Error(message))).toBe(false); + }); + + it('lets a transient reason win when both are present in one message', () => { + // Pins the precedence rule: a message that could be read either way must not + // trigger a wipe. Guessing wrong in this direction destroys a good database. + expect( + SqliteClient.isUnreadableDbError( + new Error('unable to open database file: file is not a database'), + ), + ).toBe(false); + }); + + it('handles non-Error throwables', () => { + expect(SqliteClient.isUnreadableDbError('file is not a database')).toBe(true); + expect(SqliteClient.isUnreadableDbError(undefined)).toBe(false); + }); + }); +});