diff --git a/jest.config.js b/jest.config.js index 3ca54b0..744926d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,4 +8,6 @@ module.exports = { '^.+\\.(ts|tsx)?$': 'ts-jest', }, testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(tsx?)$', -}; + coveragePathIgnorePatterns: ['/node_modules/', '/client/'], + testPathIgnorePatterns: ['/node_modules/', '/e2e/'], +}; \ No newline at end of file diff --git a/service/src/sdk.test.ts b/service/src/sdk.test.ts new file mode 100644 index 0000000..f1ec00f --- /dev/null +++ b/service/src/sdk.test.ts @@ -0,0 +1,362 @@ +/// +/// + +/** + * Unit tests for the ChatE2EE class (issue #319) + * + * ChatE2EE is the main SDK class exposed via createChatInstance(). + * All I/O dependencies (socket, HTTP, crypto) are mocked so the tests run + * in Node without a real network or browser runtime. + */ + +// ─── Polyfills ──────────────────────────────────────────────────────────────── +import { webcrypto } from 'crypto'; +if (!globalThis.crypto) { + (globalThis as any).crypto = webcrypto; +} +if (typeof window === 'undefined') { + (globalThis as any).window = globalThis; +} + +// ─── Mock configContext ─────────────────────────────────────────────────────── +jest.mock('./configContext', () => ({ + configContext: () => ({ + baseUrl: 'http://localhost:3001', + settings: { disableLog: true }, + }), + setConfig: jest.fn(), +})); + +// ─── Mock SocketInstance ────────────────────────────────────────────────────── +const mockJoinChat = jest.fn(); +const mockSocketDispose = jest.fn(); + +jest.mock('./socket/socket', () => ({ + SocketInstance: jest.fn().mockImplementation(() => ({ + joinChat: mockJoinChat, + dispose: mockSocketDispose, + })), +})); + +// ─── Mock network layer ─────────────────────────────────────────────────────── +jest.mock('./getLink', () => jest.fn().mockResolvedValue({ + hash: 'abc123', + link: '/chat/abc123', + absoluteLink: 'http://localhost:3001/chat/abc123', + expired: false, + deleted: false, + pin: '1234', + pinCreatedAt: 0, +})); + +jest.mock('./deleteLink', () => jest.fn().mockResolvedValue(undefined)); + +jest.mock('./getUsersInChannel', () => + jest.fn().mockResolvedValue([{ uuid: 'user-1' }, { uuid: 'user-2' }]) +); + +jest.mock('./sendMessage', () => + jest.fn().mockResolvedValue({ id: 'msg-1', timestamp: '2024-01-01T00:00:00Z' }) +); + +jest.mock('./publicKey', () => ({ + getPublicKey: jest.fn().mockResolvedValue({ publicKey: 'receiver-pub-key', aesKey: null }), + sharePublicKey: jest.fn().mockResolvedValue(undefined), +})); + +// ─── Mock WebRTC ────────────────────────────────────────────────────────────── +jest.mock('./webrtc', () => ({ + WebRTCCall: class MockWebRTCCall { + static isSupported = jest.fn().mockReturnValue(false); + on = jest.fn(); + startCall = jest.fn().mockResolvedValue(undefined); + endCall = jest.fn(); + signal = jest.fn(); + }, + E2ECall: class MockE2ECall { + constructor(public inner: any) {} + }, + peerConnectionEvents: ['call-added', 'call-removed', 'state-changed'], +})); + +// ─── Mock EncryptionFactory (inject stub strategies) ───────────────────────── +const mockSymEncryption = { + init: jest.fn().mockResolvedValue(undefined), + exportKey: jest.fn().mockResolvedValue('exported-aes-key'), + importRemoteKey: jest.fn().mockResolvedValue(undefined), + encryptMessage: jest.fn().mockImplementation(async (msg: string) => `enc:${msg}`), + decryptMessage: jest.fn().mockImplementation(async (msg: string) => msg.replace('enc:', '')), + generateKey: jest.fn().mockResolvedValue(undefined), +}; + +const mockAsymEncryption = { + generateKeypairs: jest.fn().mockResolvedValue({ privateKey: 'priv-key', publicKey: 'pub-key' }), + encryptMessage: jest.fn().mockImplementation(async (msg: string) => `rsa:${msg}`), + decryptMessage: jest.fn().mockImplementation(async (msg: string) => msg.replace('rsa:', '')), +}; + +jest.mock('./encryptionFactory', () => ({ + EncryptionFactory: { + create: jest.fn().mockReturnValue({ + symmetric: mockSymEncryption, + asymmetric: mockAsymEncryption, + }), + }, + EncryptionStrategyConfig: {}, +})); + +// ─── Import under test ──────────────────────────────────────────────────────── +import { createChatInstance } from './sdk'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── +async function makeInitializedInstance() { + const instance = createChatInstance(); + await instance.init(); + return instance; +} + +async function makeChannelInstance(channelId = 'ch-1', userId = 'usr-1') { + const instance = await makeInitializedInstance(); + await instance.setChannel(channelId, userId); + return instance; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── +describe('ChatE2EE (createChatInstance)', () => { + + beforeEach(() => { + jest.clearAllMocks(); + // Restore default mock return values after clearAllMocks + mockAsymEncryption.generateKeypairs.mockResolvedValue({ privateKey: 'priv-key', publicKey: 'pub-key' }); + mockSymEncryption.init.mockResolvedValue(undefined); + + const { getPublicKey, sharePublicKey } = require('./publicKey'); + getPublicKey.mockResolvedValue({ publicKey: 'receiver-pub-key', aesKey: null }); + sharePublicKey.mockResolvedValue(undefined); + + const { SocketInstance } = require('./socket/socket'); + SocketInstance.mockImplementation(() => ({ + joinChat: mockJoinChat, + dispose: mockSocketDispose, + })); + }); + + // ── init() ──────────────────────────────────────────────────────────────── + + describe('init()', () => { + it('initialises without throwing', async () => { + const instance = createChatInstance(); + await expect(instance.init()).resolves.not.toThrow(); + }); + + it('generates RSA key pairs', async () => { + await makeInitializedInstance(); + expect(mockAsymEncryption.generateKeypairs).toHaveBeenCalledTimes(1); + }); + + it('initialises the symmetric encryption layer', async () => { + await makeInitializedInstance(); + expect(mockSymEncryption.init).toHaveBeenCalledTimes(1); + }); + + it('exposes the generated key pair via getKeyPair()', async () => { + const instance = await makeInitializedInstance(); + expect(instance.getKeyPair()).toEqual({ + privateKey: 'priv-key', + publicKey: 'pub-key', + }); + }); + }); + + // ── Guards before init() ────────────────────────────────────────────────── + + describe('method guards (not initialised)', () => { + const guardedMethods: Array<(i: ReturnType) => any> = [ + (i) => i.setChannel('ch', 'usr'), + (i) => i.isEncrypted(), + (i) => i.delete(), + (i) => i.getUsersInChannel(), + (i) => i.sendMessage({ image: '', text: 'hi' }), + (i) => i.dispose(), + (i) => i.getKeyPair(), + (i) => i.encrypt({ image: '', text: 'hi' }), + ]; + + guardedMethods.forEach((method, idx) => { + it(`method #${idx + 1} throws when called before init()`, async () => { + const instance = createChatInstance(); + await expect(async () => method(instance)).rejects.toThrow( + 'ChatE2EE is not initialized' + ); + }); + }); + }); + + // ── getLink() ───────────────────────────────────────────────────────────── + + describe('getLink()', () => { + it('returns a link object', async () => { + const instance = await makeInitializedInstance(); + const link = await instance.getLink(); + expect(link).toHaveProperty('hash'); + expect(link).toHaveProperty('link'); + }); + }); + + // ── setChannel() ────────────────────────────────────────────────────────── + + describe('setChannel()', () => { + it('emits a chat-join via the socket', async () => { + await makeChannelInstance('ch-1', 'usr-1'); + expect(mockJoinChat).toHaveBeenCalledWith( + expect.objectContaining({ channelID: 'ch-1', userID: 'usr-1' }) + ); + }); + + it('shares the RSA public key on joining', async () => { + const { sharePublicKey } = require('./publicKey'); + await makeChannelInstance(); + expect(sharePublicKey).toHaveBeenCalled(); + }); + }); + + // ── isEncrypted() ───────────────────────────────────────────────────────── + + describe('isEncrypted()', () => { + it('returns true when the receiver public key is known', async () => { + const instance = await makeChannelInstance(); + expect(instance.isEncrypted()).toBe(true); + }); + + it('returns false when getPublicKey resolves with no key', async () => { + const { getPublicKey } = require('./publicKey'); + getPublicKey.mockResolvedValue({ publicKey: undefined, aesKey: null }); + + const instance = await makeChannelInstance(); + expect(instance.isEncrypted()).toBe(false); + }); + }); + + // ── on() ────────────────────────────────────────────────────────────────── + + describe('on()', () => { + it('registers a callback without throwing', async () => { + const instance = await makeInitializedInstance(); + const cb = jest.fn(); + expect(() => instance.on('chat-message', cb)).not.toThrow(); + }); + + it('does not register the same callback twice for the same listener', async () => { + const instance = await makeInitializedInstance(); + const cb = jest.fn(); + instance.on('delivered', cb); + instance.on('delivered', cb); + + // Trigger via socket subscription – subscription context holds the Set + // Internal verification: calling init() again would reset subs, so + // we just assert the method did not throw and cb remains once only. + expect(cb).not.toHaveBeenCalled(); // no event fired yet + }); + }); + + // ── sendMessage() ───────────────────────────────────────────────────────── + + describe('sendMessage()', () => { + it('delegates to the sendMessage module and returns its result', async () => { + const instance = await makeChannelInstance(); + const result = await instance.sendMessage({ image: '', text: 'hello' }); + expect(result).toEqual({ id: 'msg-1', timestamp: '2024-01-01T00:00:00Z' }); + }); + }); + + // ── encrypt() ──────────────────────────────────────────────────────────── + + describe('encrypt()', () => { + it('returns an object with a send() method', async () => { + const instance = await makeChannelInstance(); + const encryptable = instance.encrypt({ image: '', text: 'secret' }); + expect(typeof encryptable.send).toBe('function'); + }); + + it('send() encrypts the text and calls sendMessage', async () => { + const instance = await makeChannelInstance(); + const encryptable = instance.encrypt({ image: '', text: 'secret' }); + const result = await encryptable.send(); + + expect(mockAsymEncryption.encryptMessage).toHaveBeenCalledWith( + 'secret', + 'receiver-pub-key' + ); + expect(result).toHaveProperty('id'); + }); + }); + + // ── getUsersInChannel() ─────────────────────────────────────────────────── + + describe('getUsersInChannel()', () => { + it('returns the list of users', async () => { + const instance = await makeChannelInstance(); + const users = await instance.getUsersInChannel(); + expect(users).toEqual([{ uuid: 'user-1' }, { uuid: 'user-2' }]); + }); + }); + + // ── delete() ───────────────────────────────────────────────────────────── + + describe('delete()', () => { + it('calls deleteLink without throwing', async () => { + const instance = await makeChannelInstance(); + await expect(instance.delete()).resolves.not.toThrow(); + }); + }); + + // ── dispose() ──────────────────────────────────────────────────────────── + + describe('dispose()', () => { + it('disconnects the socket', async () => { + const instance = await makeChannelInstance(); + instance.dispose(); + expect(mockSocketDispose).toHaveBeenCalledTimes(1); + }); + + it('clears subscriptions so subsequent calls to guarded methods throw', async () => { + const instance = await makeChannelInstance(); + instance.dispose(); + await expect(async () => instance.isEncrypted()).rejects.toThrow( + 'ChatE2EE is not initialized' + ); + }); + }); + + // ── startCall() ─────────────────────────────────────────────────────────── + + describe('startCall()', () => { + it('throws when WebRTC is not supported', async () => { + const { WebRTCCall } = require('./webrtc'); + WebRTCCall.isSupported.mockReturnValue(false); + + const instance = await makeChannelInstance(); + await expect(instance.startCall()).rejects.toThrow( + 'createEncodedStreams not supported' + ); + }); + }); + + // ── endCall() ───────────────────────────────────────────────────────────── + + describe('endCall()', () => { + it('resolves without throwing when no call is active', async () => { + const instance = await makeChannelInstance(); + await expect(instance.endCall()).resolves.not.toThrow(); + }); + }); + + // ── activeCall getter ───────────────────────────────────────────────────── + + describe('activeCall', () => { + it('returns null when no call is in progress', async () => { + const instance = await makeChannelInstance(); + expect(instance.activeCall).toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/service/src/socket/socket.test.ts b/service/src/socket/socket.test.ts new file mode 100644 index 0000000..ad40872 --- /dev/null +++ b/service/src/socket/socket.test.ts @@ -0,0 +1,259 @@ +/** + * Unit tests for SocketInstance (issue #311) + * + * SocketInstance wraps socket.io-client and bridges socket events to the + * subscription map owned by ChatE2EE. All socket.io-client calls are mocked + * so no real network is required. + */ + +// ─── Polyfills (mirrors crypto.test.ts setup) ──────────────────────────────── +import { webcrypto } from 'node:crypto'; +if (!globalThis.crypto) { + (globalThis as any).crypto = webcrypto; +} +if (typeof globalThis.window === 'undefined') { + (globalThis as any).window = globalThis; +} + +// ─── Mock socket.io-client ──────────────────────────────────────────────────── +// We build a fake socket object whose .on() and .emit() methods we can inspect, +// then return it from the mocked socketIOClient factory. + +type FakeHandler = (...args: any[]) => void; + +const mockSocketOn = jest.fn(); +const mockSocketEmit = jest.fn(); +const mockSocketDisconnect = jest.fn(); + +const fakeSocket = { + on: mockSocketOn, + emit: mockSocketEmit, + disconnect: mockSocketDisconnect, + // Helpers used in tests to fire server-side events into the instance + _handlers: new Map(), + fire(event: string, ...args: any[]) { + const handler = this._handlers.get(event); + if (handler) handler(...args); + }, +}; + +// Capture handlers registered via socket.on() so tests can trigger them +mockSocketOn.mockImplementation((event: string, handler: FakeHandler) => { + fakeSocket._handlers.set(event, handler); +}); + +jest.mock('socket.io-client', () => ({ + __esModule: true, + default: jest.fn(() => fakeSocket), + Socket: jest.fn(), +})); + +// ─── Mock configContext ─────────────────────────────────────────────────────── +jest.mock('../configContext', () => ({ + configContext: () => ({ + baseUrl: 'http://localhost:3001', + settings: { disableLog: true }, + }), +})); + +// ─── Imports (after mocks) ──────────────────────────────────────────────────── +import { SocketInstance, SubscriptionType, SocketListenerType } from './socket'; +import { Logger } from '../utils/logger'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── +function makeSubscriptions( + listeners: SocketListenerType[] = [] +): SubscriptionType { + const map: SubscriptionType = new Map(); + listeners.forEach((l) => map.set(l, new Set())); + return map; +} + +function makeLogger(): Logger { + const logger = new Logger('test'); + return logger; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── +describe('SocketInstance', () => { + beforeEach(() => { + jest.clearAllMocks(); + fakeSocket._handlers.clear(); + // Re-attach handler capture after clearAllMocks resets mockSocketOn + mockSocketOn.mockImplementation((event: string, handler: FakeHandler) => { + fakeSocket._handlers.set(event, handler); + }); + }); + + // ── Construction ───────────────────────────────────────────────────────── + + describe('constructor', () => { + it('registers handlers for all six socket events', () => { + const subs = makeSubscriptions(); + const _instance = new SocketInstance(() => subs, makeLogger()); + + const registeredEvents = [...fakeSocket._handlers.keys()]; + expect(registeredEvents).toEqual( + expect.arrayContaining([ + 'limit-reached', + 'delivered', + 'on-alice-join', + 'on-alice-disconnect', + 'chat-message', + 'webrtc-session-description', + ]) + ); + expect(registeredEvents).toHaveLength(6); + }); + + it('connects to the URL returned by configContext', () => { + const socketIOClient = require('socket.io-client').default; + const subs = makeSubscriptions(); + const _instance = new SocketInstance(() => subs, makeLogger()); + + expect(socketIOClient).toHaveBeenCalledWith('http://localhost:3001/'); + }); + }); + + // ── joinChat ────────────────────────────────────────────────────────────── + + describe('joinChat()', () => { + it('emits a "chat-join" event with the full payload', () => { + const subs = makeSubscriptions(); + const instance = new SocketInstance(() => subs, makeLogger()); + + const payload = { + channelID: 'channel-abc', + userID: 'user-123', + publicKey: 'rsa-public-key', + }; + instance.joinChat(payload); + + expect(mockSocketEmit).toHaveBeenCalledWith('chat-join', payload); + }); + }); + + // ── dispose ─────────────────────────────────────────────────────────────── + + describe('dispose()', () => { + it('calls socket.disconnect()', () => { + const subs = makeSubscriptions(); + const instance = new SocketInstance(() => subs, makeLogger()); + + instance.dispose(); + + expect(mockSocketDisconnect).toHaveBeenCalledTimes(1); + }); + }); + + // ── Event routing (handler) ─────────────────────────────────────────────── + + describe('event routing', () => { + const routedEvents: SocketListenerType[] = [ + 'limit-reached', + 'delivered', + 'on-alice-join', + 'on-alice-disconnect', + 'webrtc-session-description', + ]; + + routedEvents.forEach((event) => { + it(`routes "${event}" to registered subscription callbacks`, () => { + const callback = jest.fn(); + const subs = makeSubscriptions([event]); + subs.get(event)?.add(callback); + + const _instance = new SocketInstance(() => subs, makeLogger()); + fakeSocket.fire(event, { data: 'payload' }); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ data: 'payload' }); + }); + }); + + it('calls all callbacks when multiple subscribers exist for an event', () => { + const cb1 = jest.fn(); + const cb2 = jest.fn(); + const subs = makeSubscriptions(['on-alice-join']); + subs.get('on-alice-join')?.add(cb1); + subs.get('on-alice-join')?.add(cb2); + + const _instance = new SocketInstance(() => subs, makeLogger()); + fakeSocket.fire('on-alice-join'); + + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + it('does not throw when no subscribers exist for a fired event', () => { + const subs = makeSubscriptions(); // empty map + const _instance = new SocketInstance(() => subs, makeLogger()); + + expect(() => fakeSocket.fire('limit-reached')).not.toThrow(); + }); + }); + + // ── "chat-message" special handling ────────────────────────────────────── + + describe('"chat-message" event', () => { + it('routes the message to subscription callbacks', () => { + const callback = jest.fn(); + const subs = makeSubscriptions(['chat-message']); + subs.get('chat-message')?.add(callback); + + const _instance = new SocketInstance(() => subs, makeLogger()); + + const msg = { channel: 'ch-1', sender: 'user-1', id: 'msg-42', text: 'hi' }; + fakeSocket.fire('chat-message', msg); + + expect(callback).toHaveBeenCalledWith(msg); + }); + + it('emits a "received" acknowledgement after delivering the message', () => { + const subs = makeSubscriptions(['chat-message']); + const _instance = new SocketInstance(() => subs, makeLogger()); + + const msg = { channel: 'ch-1', sender: 'user-1', id: 'msg-42' }; + fakeSocket.fire('chat-message', msg); + + expect(mockSocketEmit).toHaveBeenCalledWith('received', { + channel: 'ch-1', + sender: 'user-1', + id: 'msg-42', + }); + }); + + it('delivers message before emitting "received"', () => { + const order: string[] = []; + const callback = jest.fn(() => order.push('callback')); + mockSocketEmit.mockImplementation((event: string) => { + if (event === 'received') order.push('received'); + }); + + const subs = makeSubscriptions(['chat-message']); + subs.get('chat-message')?.add(callback); + const _instance = new SocketInstance(() => subs, makeLogger()); + + fakeSocket.fire('chat-message', { channel: 'c', sender: 's', id: '1' }); + + expect(order).toEqual(['callback', 'received']); + }); + }); + + // ── subscriptionContext reactivity ──────────────────────────────────────── + + describe('subscriptionContext reactivity', () => { + it('reads the subscription map lazily on each event', () => { + const subs: SubscriptionType = new Map(); + const _instance = new SocketInstance(() => subs, makeLogger()); + + // Register a callback AFTER construction + const lateCallback = jest.fn(); + subs.set('delivered', new Set([lateCallback])); + + fakeSocket.fire('delivered', 'some-data'); + + expect(lateCallback).toHaveBeenCalledWith('some-data'); + }); + }); +}); \ No newline at end of file