Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions app/lib/encryption/encryption.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
7 changes: 7 additions & 0 deletions app/lib/encryption/encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading