matterbridge-roborock-vacuum-plugin
Version:
Matterbridge Roborock Vacuum Plugin
323 lines • 15 kB
JavaScript
import crypto from 'node:crypto';
import * as dgram from 'node:dgram';
import { EventEmitter } from 'node:events';
import CRC32 from 'crc-32';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalNetworkUDPClient } from '../../../../roborockCommunication/local/udpClient.js';
import { asType, createMockLogger } from '../../../helpers/testUtils.js';
// Mock dgram module
vi.mock('node:dgram', () => {
return {
createSocket: vi.fn(),
};
});
class MockSocket extends EventEmitter {
bind = vi.fn();
close = vi.fn((callback) => {
if (callback)
callback();
});
send = vi.fn();
}
/**
* Create a valid V10 protocol buffer with encrypted payload
*/
function createV10Buffer(networkInfo) {
const plaintext = JSON.stringify(networkInfo);
// Pad to 16-byte boundary
const paddingLength = 16 - (plaintext.length % 16);
const paddedPlaintext = plaintext + String.fromCharCode(paddingLength).repeat(paddingLength);
// Encrypt using AES-128-ECB
const key = Buffer.from('qWKYcdQWrbm9hPqe', 'utf8');
const cipher = crypto.createCipheriv('aes-128-ecb', key, null);
cipher.setAutoPadding(false);
const encrypted = Buffer.concat([cipher.update(paddedPlaintext, 'utf8'), cipher.final()]);
// Build the buffer
const version = Buffer.from('1.0', 'latin1');
const seq = Buffer.alloc(4);
seq.writeUInt32BE(1, 0);
const protocol = Buffer.alloc(2);
protocol.writeUInt16BE(102, 0);
const payloadLen = Buffer.alloc(2);
payloadLen.writeUInt16BE(encrypted.length, 0);
const headerAndPayload = Buffer.concat([version, seq, protocol, payloadLen, encrypted]);
// Calculate CRC32
const crc = CRC32.buf(headerAndPayload) >>> 0;
const crcBuffer = Buffer.alloc(4);
crcBuffer.writeUInt32BE(crc, 0);
return Buffer.concat([headerAndPayload, crcBuffer]);
}
/**
* Create a valid L01 protocol buffer with encrypted payload
*/
function createL01Buffer(networkInfo) {
const plaintext = JSON.stringify(networkInfo);
// Build header first (needed for IV generation)
const version = Buffer.from('L01', 'latin1');
const field1 = Buffer.from('0000', 'latin1');
const field2 = Buffer.from('00', 'latin1');
// Calculate key and IV
const key = crypto.createHash('sha256').update(Buffer.from('qWKYcdQWrbm9hPqe', 'utf8')).digest();
const digestInput = Buffer.concat([version, field1, field2]);
const digest = crypto.createHash('sha256').update(digestInput).digest();
const iv = digest.subarray(0, 12);
// Encrypt using AES-256-GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
// Payload is ciphertext + auth tag
const payload = Buffer.concat([encrypted, tag]);
const payloadLen = Buffer.alloc(2);
payloadLen.writeUInt16BE(payload.length, 0);
const headerAndPayload = Buffer.concat([version, field1, field2, payloadLen, payload]);
// Calculate CRC32
const crc = CRC32.buf(headerAndPayload) >>> 0;
const crcBuffer = Buffer.alloc(4);
crcBuffer.writeUInt32BE(crc, 0);
return Buffer.concat([headerAndPayload, crcBuffer]);
}
describe('LocalNetworkUDPClient', () => {
let client;
let mockLogger;
let mockSocket;
const testNetworkInfo = {
duid: 'test-duid-123',
ssid: 'TestNetwork',
ip: '192.168.1.100',
mac: 'AA:BB:CC:DD:EE:FF',
bssid: '11:22:33:44:55:66',
rssi: -50,
};
beforeEach(() => {
mockLogger = createMockLogger();
mockSocket = new MockSocket();
vi.mocked(dgram.createSocket).mockReturnValue(asType(mockSocket));
client = new LocalNetworkUDPClient(mockLogger);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should create instance with logger', () => {
expect(client).toBeDefined();
});
});
describe('registerListener', () => {
it('should register a listener', () => {
const mockListener = {
onMessage: vi.fn().mockResolvedValue(undefined),
};
client.registerListener(mockListener);
// Verify by triggering a message and checking if listener is called
expect(() => {
client.registerListener(mockListener);
}).not.toThrow();
});
it('should register multiple listeners', () => {
const mockListener1 = {
onMessage: vi.fn().mockResolvedValue(undefined),
};
const mockListener2 = {
onMessage: vi.fn().mockResolvedValue(undefined),
};
client.registerListener(mockListener1);
client.registerListener(mockListener2);
expect(() => {
client.registerListener(mockListener1);
}).not.toThrow();
});
});
describe('connect', () => {
it('should create UDP socket and bind to port', () => {
client.connect();
expect(dgram.createSocket).toHaveBeenCalledWith('udp4');
expect(mockSocket.bind).toHaveBeenCalledWith(58866);
});
it('should set up message and error event handlers', () => {
client.connect();
expect(mockSocket.listenerCount('message')).toBe(1);
expect(mockSocket.listenerCount('error')).toBe(1);
});
it('should handle error when creating socket fails', () => {
vi.mocked(dgram.createSocket).mockImplementation(() => {
throw new Error('Socket creation failed');
});
client.connect();
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('Failed to create UDP server'));
});
});
describe('disconnect', () => {
it('should close server when connected', async () => {
client.connect();
await client.disconnect();
expect(mockSocket.close).toHaveBeenCalled();
});
it('should resolve immediately when not connected', async () => {
// Don't call connect
await expect(client.disconnect()).resolves.toBeUndefined();
});
it('should handle disconnect after connect', async () => {
client.connect();
await client.disconnect();
// Should be able to disconnect again without error
await expect(client.disconnect()).resolves.toBeUndefined();
});
});
describe('onError', () => {
it('should log error and close socket', async () => {
client.connect();
// Emit error event
mockSocket.emit('error', new Error('Test error'));
// Wait for async handler
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('UDP socket error'));
expect(mockSocket.close).toHaveBeenCalled();
});
});
describe('onMessage with V10 protocol', () => {
it('should deserialize V10 message and notify listeners', async () => {
const mockListener = {
onMessage: vi.fn().mockResolvedValue(undefined),
};
client.registerListener(mockListener);
client.connect();
const buffer = createV10Buffer(testNetworkInfo);
mockSocket.emit('message', buffer);
// Wait for async handler
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockListener.onMessage).toHaveBeenCalledWith(testNetworkInfo.duid, testNetworkInfo.ip);
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Received message'));
});
it('should notify multiple listeners', async () => {
const mockListener1 = {
onMessage: vi.fn().mockResolvedValue(undefined),
};
const mockListener2 = {
onMessage: vi.fn().mockResolvedValue(undefined),
};
client.registerListener(mockListener1);
client.registerListener(mockListener2);
client.connect();
const buffer = createV10Buffer(testNetworkInfo);
mockSocket.emit('message', buffer);
// Wait for async handler
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockListener1.onMessage).toHaveBeenCalledWith(testNetworkInfo.duid, testNetworkInfo.ip);
expect(mockListener2.onMessage).toHaveBeenCalledWith(testNetworkInfo.duid, testNetworkInfo.ip);
});
});
describe('onMessage with L01 protocol', () => {
it('should deserialize L01 message and notify listeners', async () => {
const mockListener = {
onMessage: vi.fn().mockResolvedValue(undefined),
};
client.registerListener(mockListener);
client.connect();
const buffer = createL01Buffer(testNetworkInfo);
mockSocket.emit('message', buffer);
// Wait for async handler
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockListener.onMessage).toHaveBeenCalledWith(testNetworkInfo.duid, testNetworkInfo.ip);
});
});
describe('deserializeMessage error handling', () => {
it('should throw error for unsupported protocol version', async () => {
client.connect();
// Create a buffer with unsupported version
const unsupportedBuffer = Buffer.from('XYZ0000000000000', 'latin1');
// We need to catch the error from the async handler
const originalEmit = mockSocket.emit.bind(mockSocket);
let caughtError;
mockSocket.emit = (event, ...args) => {
if (event === 'message') {
// Wrap the call to catch async errors
client['onMessage'](args[0]).catch((e) => {
caughtError = e instanceof Error ? e : new Error(String(e));
});
return true;
}
return originalEmit(event, ...args);
};
mockSocket.emit('message', unsupportedBuffer);
// Wait for async handler
await new Promise((resolve) => setTimeout(resolve, 50));
expect(caughtError).toBeDefined();
expect(caughtError?.message).toContain('Unsupported protocol version');
});
it('should throw error for invalid V10 CRC32', async () => {
// Create a valid V10 buffer but corrupt the CRC
const buffer = createV10Buffer(testNetworkInfo);
// Corrupt the last byte (part of CRC32)
buffer[buffer.length - 1] = buffer[buffer.length - 1] ^ 0xff;
// Test the private deserializeMessage method directly
await expect(client['deserializeMessage'](buffer)).rejects.toThrow('wrong CRC32');
});
it('should throw error for invalid L01 CRC32', async () => {
// Create a valid L01 buffer but corrupt the CRC
const buffer = createL01Buffer(testNetworkInfo);
// Corrupt the last byte (part of CRC32)
buffer[buffer.length - 1] = buffer[buffer.length - 1] ^ 0xff;
// Test the private deserializeMessage method directly
await expect(client['deserializeMessage'](buffer)).rejects.toThrow('wrong CRC32');
});
});
describe('deserializeV10Message', () => {
it('should correctly decrypt V10 message payload', async () => {
const buffer = createV10Buffer(testNetworkInfo);
// Access private method through any
const result = await client['deserializeV10Message'](buffer);
const parsed = JSON.parse(result);
expect(parsed.duid).toBe(testNetworkInfo.duid);
expect(parsed.ip).toBe(testNetworkInfo.ip);
});
});
describe('deserializeL01Message', () => {
it('should correctly decrypt L01 message payload', async () => {
const buffer = createL01Buffer(testNetworkInfo);
// Access private method through any
const result = await client['deserializeL01Message'](buffer);
const parsed = JSON.parse(result);
expect(parsed.duid).toBe(testNetworkInfo.duid);
expect(parsed.ip).toBe(testNetworkInfo.ip);
});
it('should throw error when decryption fails due to invalid auth tag', async () => {
// Create a buffer with corrupted auth tag
const buffer = createL01Buffer(testNetworkInfo);
// Find payload start and corrupt the auth tag (last 16 bytes of payload)
// Header: version(3) + field1(4) + field2(2) + payloadLen(2) = 11 bytes
// Then payload, then CRC32(4)
const payloadLen = buffer.readUInt16BE(9);
const payloadStart = 11;
const authTagStart = payloadStart + payloadLen - 16;
// Corrupt auth tag
buffer[authTagStart] = buffer[authTagStart] ^ 0xff;
// Recalculate CRC32 for the corrupted buffer
const headerAndPayload = buffer.subarray(0, buffer.length - 4);
const crc = CRC32.buf(headerAndPayload) >>> 0;
buffer.writeUInt32BE(crc, buffer.length - 4);
await expect(client['deserializeL01Message'](buffer)).rejects.toThrow('failed to decrypt');
});
});
describe('integration scenarios', () => {
it('should handle rapid successive messages', async () => {
const mockListener = {
onMessage: vi.fn().mockResolvedValue(undefined),
};
client.registerListener(mockListener);
client.connect();
const networkInfo1 = { ...testNetworkInfo, duid: 'duid-1', ip: '192.168.1.1' };
const networkInfo2 = { ...testNetworkInfo, duid: 'duid-2', ip: '192.168.1.2' };
const networkInfo3 = { ...testNetworkInfo, duid: 'duid-3', ip: '192.168.1.3' };
mockSocket.emit('message', createV10Buffer(networkInfo1));
mockSocket.emit('message', createL01Buffer(networkInfo2));
mockSocket.emit('message', createV10Buffer(networkInfo3));
// Wait for async handlers
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockListener.onMessage).toHaveBeenCalledTimes(3);
expect(mockListener.onMessage).toHaveBeenCalledWith('duid-1', '192.168.1.1');
expect(mockListener.onMessage).toHaveBeenCalledWith('duid-2', '192.168.1.2');
expect(mockListener.onMessage).toHaveBeenCalledWith('duid-3', '192.168.1.3');
});
});
});
//# sourceMappingURL=LocalNetworkUDPClient.test.js.map