UNPKG

stream-chat-react-native-core

Version:

The official React Native and Expo components for Stream Chat, a service for building chat applications

259 lines (202 loc) 9.75 kB
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<ReturnType<typeof sqliteMock.open>>; 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); }); }); });