test: enhance security middleware and token management tests

- Added comprehensive test coverage for TokenManager encryption and validation methods
- Implemented detailed test scenarios for security middleware functions
- Updated test cases to handle edge cases and improve input validation
- Refactored test mocks to provide more robust and realistic testing environment
- Improved error handling and input validation in security-related components
This commit is contained in:
jango-blockchained
2025-02-03 18:44:38 +01:00
parent 4e89e5458c
commit d17d881e7b
12 changed files with 658 additions and 428 deletions

View File

@@ -3,15 +3,15 @@ import express from 'express';
import request from 'supertest';
import { config } from 'dotenv';
import { resolve } from 'path';
import type { Entity } from '../../src/types/hass';
import { TokenManager } from '../../src/security/index';
import { MCP_SCHEMA } from '../../src/mcp/schema';
import type { Entity } from '../../src/types/hass.js';
import { TokenManager } from '../../src/security/index.js';
import { MCP_SCHEMA } from '../../src/mcp/schema.js';
// Load test environment variables
config({ path: resolve(process.cwd(), '.env.test') });
// Mock dependencies
jest.mock('../../src/security/index', () => ({
jest.mock('../../src/security/index.js', () => ({
TokenManager: {
validateToken: jest.fn().mockImplementation((token) => token === 'valid-test-token'),
},
@@ -39,7 +39,7 @@ const mockEntity: Entity = {
};
// Mock Home Assistant module
jest.mock('../../src/hass/index');
jest.mock('../../src/hass/index.js');
// Mock LiteMCP
jest.mock('litemcp', () => ({

View File

@@ -2,7 +2,8 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals
import { WebSocket } from 'ws';
import { EventEmitter } from 'events';
import type { HassInstanceImpl } from '../../src/hass/index.js';
import type { Entity, Event } from '../../src/types/hass.js';
import type { Entity, HassEvent } from '../../src/types/hass.js';
import { get_hass } from '../../src/hass/index.js';
// Define WebSocket mock types
type WebSocketCallback = (...args: any[]) => void;
@@ -10,6 +11,23 @@ type WebSocketEventHandler = (event: string, callback: WebSocketCallback) => voi
type WebSocketSendHandler = (data: string) => void;
type WebSocketCloseHandler = () => void;
interface MockHassServices {
light: Record<string, unknown>;
climate: Record<string, unknown>;
switch: Record<string, unknown>;
media_player: Record<string, unknown>;
}
interface MockHassInstance {
services: MockHassServices;
}
// Extend HassInstanceImpl for testing
interface TestHassInstance extends HassInstanceImpl {
_baseUrl: string;
_token: string;
}
type WebSocketMock = {
on: jest.MockedFunction<WebSocketEventHandler>;
send: jest.MockedFunction<WebSocketSendHandler>;
@@ -37,6 +55,24 @@ jest.mock('ws', () => ({
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
global.fetch = mockFetch;
// Mock get_hass
jest.mock('../../src/hass/index.js', () => {
let instance: TestHassInstance | null = null;
const actual = jest.requireActual<typeof import('../../src/hass/index.js')>('../../src/hass/index.js');
return {
get_hass: jest.fn(async () => {
if (!instance) {
const baseUrl = process.env.HASS_HOST || 'http://localhost:8123';
const token = process.env.HASS_TOKEN || 'test_token';
instance = new actual.HassInstanceImpl(baseUrl, token) as TestHassInstance;
instance._baseUrl = baseUrl;
instance._token = token;
}
return instance;
})
};
});
describe('Home Assistant Integration', () => {
describe('HassWebSocketClient', () => {
let client: any;
@@ -163,8 +199,8 @@ describe('Home Assistant Integration', () => {
});
it('should create instance with correct properties', () => {
expect(instance.baseUrl).toBe(mockBaseUrl);
expect(instance.token).toBe(mockToken);
expect(instance['baseUrl']).toBe(mockBaseUrl);
expect(instance['token']).toBe(mockToken);
});
it('should fetch states', async () => {
@@ -224,7 +260,7 @@ describe('Home Assistant Integration', () => {
});
describe('Event Subscription', () => {
let eventCallback: (event: Event) => void;
let eventCallback: (event: HassEvent) => void;
beforeEach(() => {
eventCallback = jest.fn();
@@ -244,22 +280,12 @@ describe('Home Assistant Integration', () => {
describe('get_hass', () => {
const originalEnv = process.env;
let mockBootstrap: jest.Mock;
const createMockServices = () => ({
const createMockServices = (): MockHassServices => ({
light: {},
climate: {},
alarm_control_panel: {},
cover: {},
switch: {},
contact: {},
media_player: {},
fan: {},
lock: {},
vacuum: {},
scene: {},
script: {},
camera: {}
media_player: {}
});
beforeEach(() => {
@@ -267,93 +293,40 @@ describe('Home Assistant Integration', () => {
process.env.HASS_HOST = 'http://localhost:8123';
process.env.HASS_TOKEN = 'test_token';
// Mock the MY_APP.bootstrap function
mockBootstrap = jest.fn();
mockBootstrap.mockImplementation(() => Promise.resolve({
baseUrl: process.env.HASS_HOST,
token: process.env.HASS_TOKEN,
wsClient: undefined,
services: createMockServices(),
als: {},
context: {},
event: new EventEmitter(),
internal: {},
lifecycle: {},
logger: {},
scheduler: {},
config: {},
params: {},
hass: {},
fetchStates: jest.fn(),
fetchState: jest.fn(),
callService: jest.fn(),
subscribeEvents: jest.fn(),
unsubscribeEvents: jest.fn()
}));
jest.mock('../../src/hass/index.js', () => ({
MY_APP: {
configuration: {},
bootstrap: () => mockBootstrap()
}
}));
// Reset the mock implementation
(get_hass as jest.MockedFunction<typeof get_hass>).mockImplementation(async () => {
const actual = jest.requireActual<typeof import('../../src/hass/index.js')>('../../src/hass/index.js');
const baseUrl = process.env.HASS_HOST || 'http://localhost:8123';
const token = process.env.HASS_TOKEN || 'test_token';
const instance = new actual.HassInstanceImpl(baseUrl, token) as TestHassInstance;
instance._baseUrl = baseUrl;
instance._token = token;
return instance;
});
});
afterEach(() => {
process.env = originalEnv;
jest.resetModules();
jest.clearAllMocks();
});
it('should return a development instance by default', async () => {
const { get_hass } = await import('../../src/hass/index.js');
const instance = await get_hass();
expect(instance.baseUrl).toBe('http://localhost:8123');
expect(instance.token).toBe('test_token');
expect(mockBootstrap).toHaveBeenCalledTimes(1);
it('should create instance with default configuration', async () => {
const instance = await get_hass() as TestHassInstance;
expect(instance._baseUrl).toBe('http://localhost:8123');
expect(instance._token).toBe('test_token');
});
it('should return a test instance when in test environment', async () => {
process.env.NODE_ENV = 'test';
const { get_hass } = await import('../../src/hass/index.js');
const instance = await get_hass();
expect(instance.baseUrl).toBe('http://localhost:8123');
expect(instance.token).toBe('test_token');
expect(mockBootstrap).toHaveBeenCalledTimes(1);
it('should reuse existing instance', async () => {
const instance1 = await get_hass();
const instance2 = await get_hass();
expect(instance1).toBe(instance2);
});
it('should return a production instance when in production environment', async () => {
process.env.NODE_ENV = 'production';
it('should use custom configuration', async () => {
process.env.HASS_HOST = 'https://hass.example.com';
process.env.HASS_TOKEN = 'prod_token';
mockBootstrap.mockImplementationOnce(() => Promise.resolve({
baseUrl: process.env.HASS_HOST,
token: process.env.HASS_TOKEN,
wsClient: undefined,
services: createMockServices(),
als: {},
context: {},
event: new EventEmitter(),
internal: {},
lifecycle: {},
logger: {},
scheduler: {},
config: {},
params: {},
hass: {},
fetchStates: jest.fn(),
fetchState: jest.fn(),
callService: jest.fn(),
subscribeEvents: jest.fn(),
unsubscribeEvents: jest.fn()
}));
const { get_hass } = await import('../../src/hass/index.js');
const instance = await get_hass();
expect(instance.baseUrl).toBe('https://hass.example.com');
expect(instance.token).toBe('prod_token');
expect(mockBootstrap).toHaveBeenCalledTimes(1);
const instance = await get_hass() as TestHassInstance;
expect(instance._baseUrl).toBe('https://hass.example.com');
expect(instance._token).toBe('prod_token');
});
});
});

View File

@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { Request, Response, NextFunction } from 'express';
import {
validateRequest,
sanitizeInput,
@@ -7,160 +8,109 @@ import {
securityHeaders
} from '../../src/security/index.js';
interface MockRequest extends Partial<Request> {
headers: Record<string, string>;
is: jest.Mock;
}
type MockRequest = {
headers: {
'content-type'?: string;
authorization?: string;
};
body?: any;
is: jest.MockInstance<string | false | null, [type: string | string[]]>;
};
type MockResponse = {
status: jest.MockInstance<MockResponse, [code: number]>;
json: jest.MockInstance<MockResponse, [body: any]>;
setHeader: jest.MockInstance<MockResponse, [name: string, value: string]>;
};
describe('Security Middleware', () => {
let mockRequest: MockRequest;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
let mockResponse: MockResponse;
let nextFunction: jest.Mock;
beforeEach(() => {
mockRequest = {
method: 'POST',
headers: {
'content-type': 'application/json',
'authorization': 'Bearer validToken'
},
is: jest.fn().mockReturnValue(true),
body: { test: 'data' }
headers: {},
body: {},
is: jest.fn<string | false | null, [string | string[]]>().mockReturnValue('json')
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
set: jest.fn()
status: jest.fn<MockResponse, [number]>().mockReturnThis(),
json: jest.fn<MockResponse, [any]>().mockReturnThis(),
setHeader: jest.fn<MockResponse, [string, string]>().mockReturnThis()
};
mockNext = jest.fn();
nextFunction = jest.fn();
});
describe('Request Validation', () => {
it('should pass valid requests', () => {
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
mockRequest.headers.authorization = 'Bearer valid-token';
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(nextFunction).toHaveBeenCalled();
});
it('should reject requests with invalid content type', () => {
mockRequest.is = jest.fn().mockReturnValue(false);
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(415);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Unsupported Media Type - Content-Type must be application/json'
});
});
it('should reject requests without authorization', () => {
mockRequest.headers = {};
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
it('should reject requests without authorization header', () => {
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Invalid or expired token'
});
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
error: expect.stringContaining('authorization')
}));
});
it('should reject requests with invalid token', () => {
mockRequest.headers.authorization = 'Bearer invalid.token.format';
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
it('should reject requests with invalid authorization format', () => {
mockRequest.headers.authorization = 'invalid-format';
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('should handle GET requests without body validation', () => {
mockRequest.method = 'GET';
mockRequest.body = undefined;
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
error: expect.stringContaining('Bearer')
}));
});
});
describe('Input Sanitization', () => {
it('should remove HTML tags from request body', () => {
it('should pass requests without body', () => {
delete mockRequest.body;
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(nextFunction).toHaveBeenCalled();
});
it('should sanitize HTML in request body', () => {
mockRequest.body = {
text: '<script>alert("xss")</script>',
text: '<script>alert("xss")</script>Hello',
nested: {
html: '<img src="x" onerror="alert(1)">'
html: '<img src="x" onerror="alert(1)">World'
}
};
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body.text).not.toContain('<script>');
expect(mockRequest.body.nested.html).not.toContain('<img');
expect(mockNext).toHaveBeenCalled();
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockRequest.body.text).toBe('Hello');
expect(mockRequest.body.nested.html).toBe('World');
expect(nextFunction).toHaveBeenCalled();
});
it('should handle non-object body', () => {
mockRequest.body = 'plain text';
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body).toBe('plain text');
expect(mockNext).toHaveBeenCalled();
it('should handle non-object bodies', () => {
mockRequest.body = '<p>text</p>';
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockRequest.body).toBe('text');
expect(nextFunction).toHaveBeenCalled();
});
it('should handle nested objects', () => {
it('should preserve non-string values', () => {
mockRequest.body = {
level1: {
level2: {
text: '<p>test</p>'
}
}
};
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body.level1.level2.text).not.toContain('<p>');
expect(mockNext).toHaveBeenCalled();
});
it('should preserve safe content', () => {
mockRequest.body = {
text: 'Safe text without HTML',
number: 42,
boolean: true
boolean: true,
null: null,
array: [1, 2, 3]
};
const originalBody = { ...mockRequest.body };
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body).toEqual(originalBody);
expect(mockNext).toHaveBeenCalled();
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockRequest.body).toEqual({
number: 42,
boolean: true,
null: null,
array: [1, 2, 3]
});
expect(nextFunction).toHaveBeenCalled();
});
});
@@ -174,36 +124,21 @@ describe('Security Middleware', () => {
it('should handle errors in production mode', () => {
process.env.NODE_ENV = 'production';
const error = new Error('Test error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
errorHandler(error, mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: undefined
error: 'Internal Server Error'
});
});
it('should include error details in development mode', () => {
process.env.NODE_ENV = 'development';
const error = new Error('Test error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
errorHandler(error, mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Test error'
error: 'Test error',
stack: expect.any(String)
});
});
@@ -214,7 +149,7 @@ describe('Security Middleware', () => {
error as any,
mockRequest as Request,
mockResponse as Response,
mockNext
nextFunction
);
expect(mockResponse.status).toHaveBeenCalledWith(500);
@@ -231,18 +166,12 @@ describe('Security Middleware', () => {
});
describe('Security Headers', () => {
it('should be configured with secure defaults', () => {
expect(securityHeaders).toBeDefined();
const middleware = securityHeaders as any;
expect(middleware.getDefaultDirectives).toBeDefined();
});
it('should set appropriate security headers', () => {
const mockRes = {
setHeader: jest.fn()
};
securityHeaders(mockRequest as Request, mockRes as any, mockNext);
expect(mockRes.setHeader).toHaveBeenCalled();
securityHeaders(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff');
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY');
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block');
expect(nextFunction).toHaveBeenCalled();
});
});
});

View File

@@ -1,9 +1,8 @@
import { TokenManager } from '../../src/security/index.js';
describe('TokenManager', () => {
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const encryptionKey = 'test_encryption_key_12345';
const encryptionKey = 'test-encryption-key-32-chars-long!!';
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNjE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
describe('Token Encryption/Decryption', () => {
it('should encrypt and decrypt tokens successfully', () => {
@@ -19,78 +18,69 @@ describe('TokenManager', () => {
});
it('should handle empty tokens', () => {
expect(() => TokenManager.encryptToken('', encryptionKey)).toThrow();
expect(() => TokenManager.encryptToken('', encryptionKey)).toThrow('Invalid token');
expect(() => TokenManager.decryptToken('', encryptionKey)).toThrow('Invalid encrypted token');
});
it('should handle empty encryption keys', () => {
expect(() => TokenManager.encryptToken(validToken, '')).toThrow();
expect(() => TokenManager.encryptToken(validToken, '')).toThrow('Invalid encryption key');
expect(() => TokenManager.decryptToken(validToken, '')).toThrow('Invalid encryption key');
});
it('should fail decryption with wrong key', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
expect(() => TokenManager.decryptToken(encrypted, 'wrong_key')).toThrow();
expect(() => TokenManager.decryptToken(encrypted, 'wrong-key-32-chars-long!!!!!!!!')).toThrow();
});
});
describe('Token Validation', () => {
it('should validate correct tokens', () => {
expect(TokenManager.validateToken(validToken)).toBe(true);
const validJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNjcyNTI3OTk5fQ.Q6cm_sZS6uqfGqO3LQ-0VqNXhqXR6mFh6IP7s0NPnSQ';
expect(TokenManager.validateToken(validJwt)).toBe(true);
});
it('should reject expired tokens', () => {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(TokenManager.validateToken(expiredToken)).toBe(false);
});
it('should reject malformed tokens', () => {
const malformedTokens = [
'not.a.token',
'invalid-token-format',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
'',
'null',
'undefined'
];
malformedTokens.forEach(token => {
expect(TokenManager.validateToken(token)).toBe(false);
});
expect(TokenManager.validateToken('invalid-token')).toBe(false);
});
it('should reject tokens with invalid signature', () => {
const tamperedToken = validToken.slice(0, -1) + 'X';
const tamperedToken = validToken.slice(0, -5) + 'xxxxx';
expect(TokenManager.validateToken(tamperedToken)).toBe(false);
});
it('should handle tokens with missing expiration', () => {
const tokenWithoutExp = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(TokenManager.validateToken(tokenWithoutExp)).toBe(true);
const noExpToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Q6cm_sZS6uqfGqO3LQ-0VqNXhqXR6mFh6IP7s0NPnSQ';
expect(TokenManager.validateToken(noExpToken)).toBe(false);
});
});
describe('Security Features', () => {
it('should use secure encryption algorithm', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/); // Base64 format
expect(encrypted.length).toBeGreaterThan(validToken.length); // Should include IV and tag
expect(encrypted).toContain('aes-256-gcm');
});
it('should prevent token tampering', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
const tampered = encrypted.slice(0, -1) + 'X';
const tampered = encrypted.slice(0, -5) + 'xxxxx';
expect(() => TokenManager.decryptToken(tampered, encryptionKey)).toThrow();
});
it('should use unique IVs for each encryption', () => {
const encrypted1 = TokenManager.encryptToken(validToken, encryptionKey);
const encrypted2 = TokenManager.encryptToken(validToken, encryptionKey);
const encrypted3 = TokenManager.encryptToken(validToken, encryptionKey);
// Each encryption should be different due to unique IVs
expect(new Set([encrypted1, encrypted2, encrypted3]).size).toBe(3);
const iv1 = encrypted1.split(':')[1];
const iv2 = encrypted2.split(':')[1];
expect(iv1).not.toBe(iv2);
});
it('should handle large tokens', () => {
const largeToken = validToken.repeat(10); // Create a much larger token
const largeToken = 'x'.repeat(10000);
const encrypted = TokenManager.encryptToken(largeToken, encryptionKey);
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
expect(decrypted).toBe(largeToken);
@@ -99,29 +89,24 @@ describe('TokenManager', () => {
describe('Error Handling', () => {
it('should throw descriptive errors for invalid inputs', () => {
expect(() => TokenManager.encryptToken(null as any, encryptionKey))
.toThrow(/invalid/i);
expect(() => TokenManager.encryptToken(validToken, null as any))
.toThrow(/invalid/i);
expect(() => TokenManager.decryptToken('invalid-base64', encryptionKey))
.toThrow(/invalid/i);
expect(() => TokenManager.encryptToken(null as any, encryptionKey)).toThrow('Invalid token');
expect(() => TokenManager.encryptToken(validToken, null as any)).toThrow('Invalid encryption key');
expect(() => TokenManager.decryptToken('invalid-base64', encryptionKey)).toThrow('Invalid encrypted token');
});
it('should handle corrupted encrypted data', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
const corrupted = encrypted.substring(10); // Remove part of the encrypted data
expect(() => TokenManager.decryptToken(corrupted, encryptionKey))
.toThrow();
const corrupted = encrypted.replace(/[a-zA-Z]/g, 'x');
expect(() => TokenManager.decryptToken(corrupted, encryptionKey)).toThrow();
});
it('should handle invalid base64 input', () => {
expect(() => TokenManager.decryptToken('not-base64!@#$', encryptionKey))
.toThrow(/invalid/i);
expect(() => TokenManager.decryptToken('not-base64!@#$%^', encryptionKey)).toThrow();
});
it('should handle undefined and null inputs', () => {
expect(() => TokenManager.validateToken(undefined as any)).toBe(false);
expect(() => TokenManager.validateToken(null as any)).toBe(false);
expect(TokenManager.validateToken(undefined as any)).toBe(false);
expect(TokenManager.validateToken(null as any)).toBe(false);
});
});
});

114
__tests__/server.test.ts Normal file
View File

@@ -0,0 +1,114 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import express from 'express';
import { LiteMCP } from 'litemcp';
import { logger } from '../src/utils/logger.js';
// Mock express
jest.mock('express', () => {
const mockApp = {
use: jest.fn(),
listen: jest.fn((port: number, callback: () => void) => {
callback();
return { close: jest.fn() };
})
};
return jest.fn(() => mockApp);
});
// Mock LiteMCP
jest.mock('litemcp', () => ({
LiteMCP: jest.fn(() => ({
addTool: jest.fn(),
start: jest.fn().mockImplementation(async () => { })
}))
}));
// Mock logger
jest.mock('../src/utils/logger.js', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn()
}
}));
describe('Server Initialization', () => {
let originalEnv: NodeJS.ProcessEnv;
let mockApp: ReturnType<typeof express>;
beforeEach(() => {
// Store original environment
originalEnv = { ...process.env };
// Reset all mocks
jest.clearAllMocks();
// Get the mock express app
mockApp = express();
});
afterEach(() => {
// Restore original environment
process.env = originalEnv;
// Clear module cache to ensure fresh imports
jest.resetModules();
});
it('should start Express server when not in Claude mode', async () => {
// Set OpenAI mode
process.env.PROCESSOR_TYPE = 'openai';
// Import the main module
await import('../src/index.js');
// Verify Express server was initialized
expect(express).toHaveBeenCalled();
expect(mockApp.use).toHaveBeenCalled();
expect(mockApp.listen).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Server is running on port'));
});
it('should not start Express server in Claude mode', async () => {
// Set Claude mode
process.env.PROCESSOR_TYPE = 'claude';
// Import the main module
await import('../src/index.js');
// Verify Express server was not initialized
expect(express).not.toHaveBeenCalled();
expect(mockApp.use).not.toHaveBeenCalled();
expect(mockApp.listen).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Running in Claude mode - Express server disabled');
});
it('should initialize LiteMCP in both modes', async () => {
// Test OpenAI mode
process.env.PROCESSOR_TYPE = 'openai';
await import('../src/index.js');
expect(LiteMCP).toHaveBeenCalledWith('home-assistant', expect.any(String));
// Reset modules
jest.resetModules();
// Test Claude mode
process.env.PROCESSOR_TYPE = 'claude';
await import('../src/index.js');
expect(LiteMCP).toHaveBeenCalledWith('home-assistant', expect.any(String));
});
it('should handle missing PROCESSOR_TYPE (default to Express server)', async () => {
// Remove PROCESSOR_TYPE
delete process.env.PROCESSOR_TYPE;
// Import the main module
await import('../src/index.js');
// Verify Express server was initialized (default behavior)
expect(express).toHaveBeenCalled();
expect(mockApp.use).toHaveBeenCalled();
expect(mockApp.listen).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Server is running on port'));
});
});

View File

@@ -1,6 +1,7 @@
import { WebSocket } from 'ws';
import { EventEmitter } from 'events';
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
import { HassWebSocketClient } from '../../src/websocket/client.js';
import WebSocket from 'ws';
import { EventEmitter } from 'events';
import * as HomeAssistant from '../../src/types/hass.js';
// Mock WebSocket
@@ -8,39 +9,130 @@ jest.mock('ws');
describe('WebSocket Event Handling', () => {
let client: HassWebSocketClient;
let mockWs: jest.Mocked<WebSocket>;
let mockWebSocket: jest.Mocked<WebSocket>;
let eventEmitter: EventEmitter;
beforeEach(() => {
// Setup mock WebSocket
// Clear all mocks
jest.clearAllMocks();
// Create event emitter for mocking WebSocket events
eventEmitter = new EventEmitter();
mockWs = {
on: jest.fn((event, callback) => eventEmitter.on(event, callback)),
// Create mock WebSocket instance
mockWebSocket = {
on: jest.fn((event: string, listener: (...args: any[]) => void) => {
eventEmitter.on(event, listener);
return mockWebSocket;
}),
send: jest.fn(),
close: jest.fn(),
readyState: WebSocket.OPEN
readyState: WebSocket.OPEN,
removeAllListeners: jest.fn(),
// Add required WebSocket properties
binaryType: 'arraybuffer',
bufferedAmount: 0,
extensions: '',
protocol: '',
url: 'ws://test.com',
isPaused: () => false,
ping: jest.fn(),
pong: jest.fn(),
terminate: jest.fn()
} as unknown as jest.Mocked<WebSocket>;
(WebSocket as jest.MockedClass<typeof WebSocket>).mockImplementation(() => mockWs);
// Mock WebSocket constructor
(WebSocket as unknown as jest.Mock).mockImplementation(() => mockWebSocket);
// Create client instance with required options
client = new HassWebSocketClient('ws://localhost:8123/api/websocket', 'test_token', {
autoReconnect: true,
maxReconnectAttempts: 3,
reconnectDelay: 100
});
// Create client instance
client = new HassWebSocketClient('ws://test.com', 'test-token');
});
afterEach(() => {
jest.clearAllMocks();
eventEmitter.removeAllListeners();
client.disconnect();
});
it('should handle connection events', () => {
// Simulate open event
eventEmitter.emit('open');
// Verify authentication message was sent
expect(mockWebSocket.send).toHaveBeenCalledWith(
expect.stringContaining('"type":"auth"')
);
});
it('should handle authentication response', () => {
// Simulate auth_ok message
eventEmitter.emit('message', JSON.stringify({ type: 'auth_ok' }));
// Verify client is ready for commands
expect(mockWebSocket.readyState).toBe(WebSocket.OPEN);
});
it('should handle auth failure', () => {
// Simulate auth_invalid message
eventEmitter.emit('message', JSON.stringify({
type: 'auth_invalid',
message: 'Invalid token'
}));
// Verify client attempts to close connection
expect(mockWebSocket.close).toHaveBeenCalled();
});
it('should handle connection errors', () => {
// Create error spy
const errorSpy = jest.fn();
client.on('error', errorSpy);
// Simulate error
const testError = new Error('Test error');
eventEmitter.emit('error', testError);
// Verify error was handled
expect(errorSpy).toHaveBeenCalledWith(testError);
});
it('should handle disconnection', () => {
// Create close spy
const closeSpy = jest.fn();
client.on('close', closeSpy);
// Simulate close
eventEmitter.emit('close');
// Verify close was handled
expect(closeSpy).toHaveBeenCalled();
});
it('should handle event messages', () => {
// Create event spy
const eventSpy = jest.fn();
client.on('event', eventSpy);
// Simulate event message
const eventData = {
type: 'event',
event: {
event_type: 'state_changed',
data: {
entity_id: 'light.test',
new_state: { state: 'on' }
}
}
};
eventEmitter.emit('message', JSON.stringify(eventData));
// Verify event was handled
expect(eventSpy).toHaveBeenCalledWith(eventData.event);
});
describe('Connection Events', () => {
it('should handle successful connection', (done) => {
client.on('open', () => {
expect(mockWs.send).toHaveBeenCalled();
expect(mockWebSocket.send).toHaveBeenCalled();
done();
});
@@ -59,7 +151,7 @@ describe('WebSocket Event Handling', () => {
it('should handle connection close', (done) => {
client.on('disconnected', () => {
expect(mockWs.close).toHaveBeenCalled();
expect(mockWebSocket.close).toHaveBeenCalled();
done();
});
@@ -75,7 +167,7 @@ describe('WebSocket Event Handling', () => {
};
client.connect();
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(authMessage));
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(authMessage));
});
it('should handle successful authentication', (done) => {