diff --git a/__tests__/api/index.test.ts b/__tests__/api/index.test.ts index f6a31e3..45227c5 100644 --- a/__tests__/api/index.test.ts +++ b/__tests__/api/index.test.ts @@ -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', () => ({ diff --git a/__tests__/hass/index.test.ts b/__tests__/hass/index.test.ts index c5ab670..cc416cd 100644 --- a/__tests__/hass/index.test.ts +++ b/__tests__/hass/index.test.ts @@ -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; + climate: Record; + switch: Record; + media_player: Record; +} + +interface MockHassInstance { + services: MockHassServices; +} + +// Extend HassInstanceImpl for testing +interface TestHassInstance extends HassInstanceImpl { + _baseUrl: string; + _token: string; +} + type WebSocketMock = { on: jest.MockedFunction; send: jest.MockedFunction; @@ -37,6 +55,24 @@ jest.mock('ws', () => ({ const mockFetch = jest.fn() as jest.MockedFunction; global.fetch = mockFetch; +// Mock get_hass +jest.mock('../../src/hass/index.js', () => { + let instance: TestHassInstance | null = null; + const actual = jest.requireActual('../../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).mockImplementation(async () => { + const actual = jest.requireActual('../../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'); }); }); }); \ No newline at end of file diff --git a/__tests__/security/middleware.test.ts b/__tests__/security/middleware.test.ts index e05fc57..5a89127 100644 --- a/__tests__/security/middleware.test.ts +++ b/__tests__/security/middleware.test.ts @@ -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 { - headers: Record; - is: jest.Mock; -} +type MockRequest = { + headers: { + 'content-type'?: string; + authorization?: string; + }; + body?: any; + is: jest.MockInstance; +}; + +type MockResponse = { + status: jest.MockInstance; + json: jest.MockInstance; + setHeader: jest.MockInstance; +}; describe('Security Middleware', () => { let mockRequest: MockRequest; - let mockResponse: Partial; - 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().mockReturnValue('json') }; + mockResponse = { - status: jest.fn().mockReturnThis(), - json: jest.fn(), - setHeader: jest.fn(), - set: jest.fn() + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn().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: '', + text: 'Hello', nested: { - html: '' + html: 'World' } }; - - sanitizeInput( - mockRequest as Request, - mockResponse as Response, - mockNext - ); - - expect(mockRequest.body.text).not.toContain('Hello', + nested: { + html: 'World' + } + }; + 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 bodies', () => { + mockRequest.body = '

text

'; + sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction); + expect(mockRequest.body).toBe('text'); + expect(nextFunction).toHaveBeenCalled(); + }); + + it('should preserve non-string values', () => { + mockRequest.body = { + number: 42, + boolean: true, + null: null, + array: [1, 2, 3] + }; + 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(); + }); + }); +}); \ No newline at end of file