diff --git a/app/lib/encryption/encryption.test.ts b/app/lib/encryption/encryption.test.ts new file mode 100644 index 00000000000..18a9d9f0a01 --- /dev/null +++ b/app/lib/encryption/encryption.test.ts @@ -0,0 +1,143 @@ +// Bypass the global mock of `app/lib/encryption` declared in jest.setup.js by +// importing directly from the file. We exercise the real `encryptMessage` here. +import encryption from './encryption'; + +jest.unmock('./encryption'); + +// Heavy native deps and helpers are not relevant to encryptMessage's branching; +// stub them so the module can be loaded in jsdom/node without a native runtime. +jest.mock('@rocket.chat/mobile-crypto', () => ({ + pbkdf2Hash: jest.fn(), + aesEncrypt: jest.fn(), + aesDecrypt: jest.fn(), + randomBytes: jest.fn(), + rsaGenerateKeys: jest.fn(), + rsaImportKey: jest.fn(), + rsaExportKey: jest.fn(), + calculateFileChecksum: jest.fn(), + aesGcmDecrypt: jest.fn(), + aesGcmEncrypt: jest.fn() +})); + +jest.mock('expo-file-system/legacy', () => ({ + deleteAsync: jest.fn() +})); + +jest.mock('../services/restApi', () => ({ + e2eSetUserPublicAndPrivateKeys: jest.fn(), + e2eRequestSubscriptionKeys: jest.fn(), + fetchUsersWaitingForGroupKey: jest.fn(), + provideUsersSuggestedGroupKeys: jest.fn() +})); + +jest.mock('../methods/userPreferences', () => ({ + __esModule: true, + default: { getString: jest.fn(), setString: jest.fn(), removeItem: jest.fn() } +})); + +jest.mock('../methods/helpers/protectedFunction', () => ({ + __esModule: true, + default: (fn: any) => fn +})); + +const mockGetState = jest.fn(); +jest.mock('../store/auxStore', () => ({ + store: { + getState: () => mockGetState() + } +})); + +const mockSubFind = jest.fn(); +jest.mock('../database', () => ({ + __esModule: true, + default: { + active: { + get: () => ({ find: (rid: string) => mockSubFind(rid) }) + } + } +})); + +const mockRoomEncrypt = jest.fn(); +const mockHasSessionKey = jest.fn(); +const mockHandshake = jest.fn().mockResolvedValue(undefined); +jest.mock('./room', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + handshake: mockHandshake, + hasSessionKey: () => mockHasSessionKey(), + encrypt: (m: any) => mockRoomEncrypt(m) + })) +})); + +const baseMessage = { _id: 'm1', rid: 'r1', msg: 'hello' } as any; + +describe('Encryption.encryptMessage', () => { + beforeEach(() => { + jest.clearAllMocks(); + // fresh handshake mock since clearAllMocks resets the implementation default + mockHandshake.mockResolvedValue(undefined); + // Force getRoomInstance to rebuild the EncryptionRoom on every test by + // clearing any cached instance from the singleton. + (encryption as any).roomInstances = {}; + }); + + it('returns the plain message when workspace E2E is disabled, even if the local subscription is flagged as encrypted', async () => { + // This is the regression case: web does not encrypt when E2E_Enable is off + // at the workspace level. Mobile must do the same to avoid sending an + // undefined message downstream when the local subscription has a stale + // `encrypted: true` flag and no session key is available. + mockGetState.mockReturnValue({ settings: { E2E_Enable: false } }); + mockSubFind.mockResolvedValue({ encrypted: true }); + mockHasSessionKey.mockReturnValue(false); + + const result = await encryption.encryptMessage(baseMessage); + + expect(result).toBe(baseMessage); + expect(mockSubFind).not.toHaveBeenCalled(); + expect(mockRoomEncrypt).not.toHaveBeenCalled(); + }); + + it('returns the plain message when the local subscription is not encrypted', async () => { + mockGetState.mockReturnValue({ settings: { E2E_Enable: true } }); + mockSubFind.mockResolvedValue({ encrypted: false }); + + const result = await encryption.encryptMessage(baseMessage); + + expect(result).toBe(baseMessage); + expect(mockRoomEncrypt).not.toHaveBeenCalled(); + }); + + it('returns undefined when the room is encrypted but no session key is available', async () => { + mockGetState.mockReturnValue({ settings: { E2E_Enable: true } }); + mockSubFind.mockResolvedValue({ encrypted: true }); + mockHasSessionKey.mockReturnValue(false); + + const result = await encryption.encryptMessage(baseMessage); + + expect(result).toBeUndefined(); + expect(mockRoomEncrypt).not.toHaveBeenCalled(); + }); + + it('encrypts the message when the room is encrypted and a session key is available', async () => { + mockGetState.mockReturnValue({ settings: { E2E_Enable: true } }); + mockSubFind.mockResolvedValue({ encrypted: true }); + mockHasSessionKey.mockReturnValue(true); + const encrypted = { ...baseMessage, t: 'e2e', msg: 'cipher' }; + mockRoomEncrypt.mockReturnValue(encrypted); + + const result = await encryption.encryptMessage(baseMessage); + + expect(result).toBe(encrypted); + expect(mockRoomEncrypt).toHaveBeenCalledWith(baseMessage); + }); + + it('falls back to the plain message when the subscription lookup throws', async () => { + mockGetState.mockReturnValue({ settings: { E2E_Enable: true } }); + mockSubFind.mockRejectedValue(new Error('not found')); + + const result = await encryption.encryptMessage(baseMessage); + + expect(result).toBe(baseMessage); + expect(mockRoomEncrypt).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/encryption/encryption.ts b/app/lib/encryption/encryption.ts index 3ba91014d58..ffe13c69655 100644 --- a/app/lib/encryption/encryption.ts +++ b/app/lib/encryption/encryption.ts @@ -527,6 +527,13 @@ class Encryption { const db = database.active; const subCollection = db.get('subscriptions'); + // If workspace-level E2E is disabled, send the message unencrypted regardless of + // any stale `encrypted: true` flag on the local subscription record. + const e2eeEnabled = store.getState().settings.E2E_Enable; + if (!e2eeEnabled) { + return message; + } + try { // Find the subscription const subRecord = await subCollection.find(rid);