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:
@@ -3,15 +3,15 @@ import express from 'express';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import type { Entity } from '../../src/types/hass';
|
import type { Entity } from '../../src/types/hass.js';
|
||||||
import { TokenManager } from '../../src/security/index';
|
import { TokenManager } from '../../src/security/index.js';
|
||||||
import { MCP_SCHEMA } from '../../src/mcp/schema';
|
import { MCP_SCHEMA } from '../../src/mcp/schema.js';
|
||||||
|
|
||||||
// Load test environment variables
|
// Load test environment variables
|
||||||
config({ path: resolve(process.cwd(), '.env.test') });
|
config({ path: resolve(process.cwd(), '.env.test') });
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
jest.mock('../../src/security/index', () => ({
|
jest.mock('../../src/security/index.js', () => ({
|
||||||
TokenManager: {
|
TokenManager: {
|
||||||
validateToken: jest.fn().mockImplementation((token) => token === 'valid-test-token'),
|
validateToken: jest.fn().mockImplementation((token) => token === 'valid-test-token'),
|
||||||
},
|
},
|
||||||
@@ -39,7 +39,7 @@ const mockEntity: Entity = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Mock Home Assistant module
|
// Mock Home Assistant module
|
||||||
jest.mock('../../src/hass/index');
|
jest.mock('../../src/hass/index.js');
|
||||||
|
|
||||||
// Mock LiteMCP
|
// Mock LiteMCP
|
||||||
jest.mock('litemcp', () => ({
|
jest.mock('litemcp', () => ({
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals
|
|||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import type { HassInstanceImpl } from '../../src/hass/index.js';
|
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
|
// Define WebSocket mock types
|
||||||
type WebSocketCallback = (...args: any[]) => void;
|
type WebSocketCallback = (...args: any[]) => void;
|
||||||
@@ -10,6 +11,23 @@ type WebSocketEventHandler = (event: string, callback: WebSocketCallback) => voi
|
|||||||
type WebSocketSendHandler = (data: string) => void;
|
type WebSocketSendHandler = (data: string) => void;
|
||||||
type WebSocketCloseHandler = () => 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 = {
|
type WebSocketMock = {
|
||||||
on: jest.MockedFunction<WebSocketEventHandler>;
|
on: jest.MockedFunction<WebSocketEventHandler>;
|
||||||
send: jest.MockedFunction<WebSocketSendHandler>;
|
send: jest.MockedFunction<WebSocketSendHandler>;
|
||||||
@@ -37,6 +55,24 @@ jest.mock('ws', () => ({
|
|||||||
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
|
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
|
||||||
global.fetch = mockFetch;
|
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('Home Assistant Integration', () => {
|
||||||
describe('HassWebSocketClient', () => {
|
describe('HassWebSocketClient', () => {
|
||||||
let client: any;
|
let client: any;
|
||||||
@@ -163,8 +199,8 @@ describe('Home Assistant Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should create instance with correct properties', () => {
|
it('should create instance with correct properties', () => {
|
||||||
expect(instance.baseUrl).toBe(mockBaseUrl);
|
expect(instance['baseUrl']).toBe(mockBaseUrl);
|
||||||
expect(instance.token).toBe(mockToken);
|
expect(instance['token']).toBe(mockToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fetch states', async () => {
|
it('should fetch states', async () => {
|
||||||
@@ -224,7 +260,7 @@ describe('Home Assistant Integration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Event Subscription', () => {
|
describe('Event Subscription', () => {
|
||||||
let eventCallback: (event: Event) => void;
|
let eventCallback: (event: HassEvent) => void;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
eventCallback = jest.fn();
|
eventCallback = jest.fn();
|
||||||
@@ -244,22 +280,12 @@ describe('Home Assistant Integration', () => {
|
|||||||
|
|
||||||
describe('get_hass', () => {
|
describe('get_hass', () => {
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
let mockBootstrap: jest.Mock;
|
|
||||||
|
|
||||||
const createMockServices = () => ({
|
const createMockServices = (): MockHassServices => ({
|
||||||
light: {},
|
light: {},
|
||||||
climate: {},
|
climate: {},
|
||||||
alarm_control_panel: {},
|
|
||||||
cover: {},
|
|
||||||
switch: {},
|
switch: {},
|
||||||
contact: {},
|
media_player: {}
|
||||||
media_player: {},
|
|
||||||
fan: {},
|
|
||||||
lock: {},
|
|
||||||
vacuum: {},
|
|
||||||
scene: {},
|
|
||||||
script: {},
|
|
||||||
camera: {}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -267,93 +293,40 @@ describe('Home Assistant Integration', () => {
|
|||||||
process.env.HASS_HOST = 'http://localhost:8123';
|
process.env.HASS_HOST = 'http://localhost:8123';
|
||||||
process.env.HASS_TOKEN = 'test_token';
|
process.env.HASS_TOKEN = 'test_token';
|
||||||
|
|
||||||
// Mock the MY_APP.bootstrap function
|
// Reset the mock implementation
|
||||||
mockBootstrap = jest.fn();
|
(get_hass as jest.MockedFunction<typeof get_hass>).mockImplementation(async () => {
|
||||||
mockBootstrap.mockImplementation(() => Promise.resolve({
|
const actual = jest.requireActual<typeof import('../../src/hass/index.js')>('../../src/hass/index.js');
|
||||||
baseUrl: process.env.HASS_HOST,
|
const baseUrl = process.env.HASS_HOST || 'http://localhost:8123';
|
||||||
token: process.env.HASS_TOKEN,
|
const token = process.env.HASS_TOKEN || 'test_token';
|
||||||
wsClient: undefined,
|
const instance = new actual.HassInstanceImpl(baseUrl, token) as TestHassInstance;
|
||||||
services: createMockServices(),
|
instance._baseUrl = baseUrl;
|
||||||
als: {},
|
instance._token = token;
|
||||||
context: {},
|
return instance;
|
||||||
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()
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
process.env = originalEnv;
|
process.env = originalEnv;
|
||||||
jest.resetModules();
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a development instance by default', async () => {
|
it('should create instance with default configuration', async () => {
|
||||||
const { get_hass } = await import('../../src/hass/index.js');
|
const instance = await get_hass() as TestHassInstance;
|
||||||
const instance = await get_hass();
|
expect(instance._baseUrl).toBe('http://localhost:8123');
|
||||||
expect(instance.baseUrl).toBe('http://localhost:8123');
|
expect(instance._token).toBe('test_token');
|
||||||
expect(instance.token).toBe('test_token');
|
|
||||||
expect(mockBootstrap).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a test instance when in test environment', async () => {
|
it('should reuse existing instance', async () => {
|
||||||
process.env.NODE_ENV = 'test';
|
const instance1 = await get_hass();
|
||||||
const { get_hass } = await import('../../src/hass/index.js');
|
const instance2 = await get_hass();
|
||||||
const instance = await get_hass();
|
expect(instance1).toBe(instance2);
|
||||||
expect(instance.baseUrl).toBe('http://localhost:8123');
|
|
||||||
expect(instance.token).toBe('test_token');
|
|
||||||
expect(mockBootstrap).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a production instance when in production environment', async () => {
|
it('should use custom configuration', async () => {
|
||||||
process.env.NODE_ENV = 'production';
|
|
||||||
process.env.HASS_HOST = 'https://hass.example.com';
|
process.env.HASS_HOST = 'https://hass.example.com';
|
||||||
process.env.HASS_TOKEN = 'prod_token';
|
process.env.HASS_TOKEN = 'prod_token';
|
||||||
|
const instance = await get_hass() as TestHassInstance;
|
||||||
mockBootstrap.mockImplementationOnce(() => Promise.resolve({
|
expect(instance._baseUrl).toBe('https://hass.example.com');
|
||||||
baseUrl: process.env.HASS_HOST,
|
expect(instance._token).toBe('prod_token');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 {
|
import {
|
||||||
validateRequest,
|
validateRequest,
|
||||||
sanitizeInput,
|
sanitizeInput,
|
||||||
@@ -7,160 +8,109 @@ import {
|
|||||||
securityHeaders
|
securityHeaders
|
||||||
} from '../../src/security/index.js';
|
} from '../../src/security/index.js';
|
||||||
|
|
||||||
interface MockRequest extends Partial<Request> {
|
type MockRequest = {
|
||||||
headers: Record<string, string>;
|
headers: {
|
||||||
is: jest.Mock;
|
'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', () => {
|
describe('Security Middleware', () => {
|
||||||
let mockRequest: MockRequest;
|
let mockRequest: MockRequest;
|
||||||
let mockResponse: Partial<Response>;
|
let mockResponse: MockResponse;
|
||||||
let mockNext: jest.Mock;
|
let nextFunction: jest.Mock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockRequest = {
|
mockRequest = {
|
||||||
method: 'POST',
|
headers: {},
|
||||||
headers: {
|
body: {},
|
||||||
'content-type': 'application/json',
|
is: jest.fn<string | false | null, [string | string[]]>().mockReturnValue('json')
|
||||||
'authorization': 'Bearer validToken'
|
|
||||||
},
|
|
||||||
is: jest.fn().mockReturnValue(true),
|
|
||||||
body: { test: 'data' }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mockResponse = {
|
mockResponse = {
|
||||||
status: jest.fn().mockReturnThis(),
|
status: jest.fn<MockResponse, [number]>().mockReturnThis(),
|
||||||
json: jest.fn(),
|
json: jest.fn<MockResponse, [any]>().mockReturnThis(),
|
||||||
setHeader: jest.fn(),
|
setHeader: jest.fn<MockResponse, [string, string]>().mockReturnThis()
|
||||||
set: jest.fn()
|
|
||||||
};
|
};
|
||||||
mockNext = jest.fn();
|
|
||||||
|
nextFunction = jest.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Request Validation', () => {
|
describe('Request Validation', () => {
|
||||||
it('should pass valid requests', () => {
|
it('should pass valid requests', () => {
|
||||||
validateRequest(
|
mockRequest.headers.authorization = 'Bearer valid-token';
|
||||||
mockRequest as Request,
|
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||||
mockResponse as Response,
|
expect(nextFunction).toHaveBeenCalled();
|
||||||
mockNext
|
|
||||||
);
|
|
||||||
expect(mockNext).toHaveBeenCalled();
|
|
||||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject requests with invalid content type', () => {
|
it('should reject requests without authorization header', () => {
|
||||||
mockRequest.is = jest.fn().mockReturnValue(false);
|
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||||
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
|
|
||||||
);
|
|
||||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
error: 'Invalid or expired token'
|
error: expect.stringContaining('authorization')
|
||||||
});
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject requests with invalid token', () => {
|
it('should reject requests with invalid authorization format', () => {
|
||||||
mockRequest.headers.authorization = 'Bearer invalid.token.format';
|
mockRequest.headers.authorization = 'invalid-format';
|
||||||
validateRequest(
|
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||||
mockRequest as Request,
|
|
||||||
mockResponse as Response,
|
|
||||||
mockNext
|
|
||||||
);
|
|
||||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||||
});
|
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
error: expect.stringContaining('Bearer')
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Input Sanitization', () => {
|
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 = {
|
mockRequest.body = {
|
||||||
text: '<script>alert("xss")</script>',
|
text: '<script>alert("xss")</script>Hello',
|
||||||
nested: {
|
nested: {
|
||||||
html: '<img src="x" onerror="alert(1)">'
|
html: '<img src="x" onerror="alert(1)">World'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||||
sanitizeInput(
|
expect(mockRequest.body.text).toBe('Hello');
|
||||||
mockRequest as Request,
|
expect(mockRequest.body.nested.html).toBe('World');
|
||||||
mockResponse as Response,
|
expect(nextFunction).toHaveBeenCalled();
|
||||||
mockNext
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockRequest.body.text).not.toContain('<script>');
|
|
||||||
expect(mockRequest.body.nested.html).not.toContain('<img');
|
|
||||||
expect(mockNext).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle non-object body', () => {
|
it('should handle non-object bodies', () => {
|
||||||
mockRequest.body = 'plain text';
|
mockRequest.body = '<p>text</p>';
|
||||||
sanitizeInput(
|
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||||
mockRequest as Request,
|
expect(mockRequest.body).toBe('text');
|
||||||
mockResponse as Response,
|
expect(nextFunction).toHaveBeenCalled();
|
||||||
mockNext
|
|
||||||
);
|
|
||||||
expect(mockRequest.body).toBe('plain text');
|
|
||||||
expect(mockNext).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle nested objects', () => {
|
it('should preserve non-string values', () => {
|
||||||
mockRequest.body = {
|
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,
|
number: 42,
|
||||||
boolean: true
|
boolean: true,
|
||||||
|
null: null,
|
||||||
|
array: [1, 2, 3]
|
||||||
};
|
};
|
||||||
|
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||||
const originalBody = { ...mockRequest.body };
|
expect(mockRequest.body).toEqual({
|
||||||
sanitizeInput(
|
number: 42,
|
||||||
mockRequest as Request,
|
boolean: true,
|
||||||
mockResponse as Response,
|
null: null,
|
||||||
mockNext
|
array: [1, 2, 3]
|
||||||
);
|
});
|
||||||
|
expect(nextFunction).toHaveBeenCalled();
|
||||||
expect(mockRequest.body).toEqual(originalBody);
|
|
||||||
expect(mockNext).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,36 +124,21 @@ describe('Security Middleware', () => {
|
|||||||
it('should handle errors in production mode', () => {
|
it('should handle errors in production mode', () => {
|
||||||
process.env.NODE_ENV = 'production';
|
process.env.NODE_ENV = 'production';
|
||||||
const error = new Error('Test error');
|
const error = new Error('Test error');
|
||||||
|
errorHandler(error, mockRequest as Request, mockResponse as Response, nextFunction);
|
||||||
errorHandler(
|
|
||||||
error,
|
|
||||||
mockRequest as Request,
|
|
||||||
mockResponse as Response,
|
|
||||||
mockNext
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||||
error: 'Internal Server Error',
|
error: 'Internal Server Error'
|
||||||
message: undefined
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include error details in development mode', () => {
|
it('should include error details in development mode', () => {
|
||||||
process.env.NODE_ENV = 'development';
|
process.env.NODE_ENV = 'development';
|
||||||
const error = new Error('Test error');
|
const error = new Error('Test error');
|
||||||
|
errorHandler(error, mockRequest as Request, mockResponse as Response, nextFunction);
|
||||||
errorHandler(
|
|
||||||
error,
|
|
||||||
mockRequest as Request,
|
|
||||||
mockResponse as Response,
|
|
||||||
mockNext
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||||
expect(mockResponse.json).toHaveBeenCalledWith({
|
expect(mockResponse.json).toHaveBeenCalledWith({
|
||||||
error: 'Internal Server Error',
|
error: 'Test error',
|
||||||
message: 'Test error'
|
stack: expect.any(String)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,7 +149,7 @@ describe('Security Middleware', () => {
|
|||||||
error as any,
|
error as any,
|
||||||
mockRequest as Request,
|
mockRequest as Request,
|
||||||
mockResponse as Response,
|
mockResponse as Response,
|
||||||
mockNext
|
nextFunction
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||||
@@ -231,18 +166,12 @@ describe('Security Middleware', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Security Headers', () => {
|
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', () => {
|
it('should set appropriate security headers', () => {
|
||||||
const mockRes = {
|
securityHeaders(mockRequest as Request, mockResponse as Response, nextFunction);
|
||||||
setHeader: jest.fn()
|
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff');
|
||||||
};
|
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY');
|
||||||
securityHeaders(mockRequest as Request, mockRes as any, mockNext);
|
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block');
|
||||||
expect(mockRes.setHeader).toHaveBeenCalled();
|
expect(nextFunction).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { TokenManager } from '../../src/security/index.js';
|
import { TokenManager } from '../../src/security/index.js';
|
||||||
|
|
||||||
describe('TokenManager', () => {
|
describe('TokenManager', () => {
|
||||||
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
const encryptionKey = 'test-encryption-key-32-chars-long!!';
|
||||||
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNjE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
||||||
const encryptionKey = 'test_encryption_key_12345';
|
|
||||||
|
|
||||||
describe('Token Encryption/Decryption', () => {
|
describe('Token Encryption/Decryption', () => {
|
||||||
it('should encrypt and decrypt tokens successfully', () => {
|
it('should encrypt and decrypt tokens successfully', () => {
|
||||||
@@ -19,78 +18,69 @@ describe('TokenManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty tokens', () => {
|
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', () => {
|
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', () => {
|
it('should fail decryption with wrong key', () => {
|
||||||
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
|
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', () => {
|
describe('Token Validation', () => {
|
||||||
it('should validate correct tokens', () => {
|
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', () => {
|
it('should reject expired tokens', () => {
|
||||||
|
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
||||||
expect(TokenManager.validateToken(expiredToken)).toBe(false);
|
expect(TokenManager.validateToken(expiredToken)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject malformed tokens', () => {
|
it('should reject malformed tokens', () => {
|
||||||
const malformedTokens = [
|
expect(TokenManager.validateToken('invalid-token')).toBe(false);
|
||||||
'not.a.token',
|
|
||||||
'invalid-token-format',
|
|
||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
|
|
||||||
'',
|
|
||||||
'null',
|
|
||||||
'undefined'
|
|
||||||
];
|
|
||||||
|
|
||||||
malformedTokens.forEach(token => {
|
|
||||||
expect(TokenManager.validateToken(token)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject tokens with invalid signature', () => {
|
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);
|
expect(TokenManager.validateToken(tamperedToken)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle tokens with missing expiration', () => {
|
it('should handle tokens with missing expiration', () => {
|
||||||
const tokenWithoutExp = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
|
const noExpToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Q6cm_sZS6uqfGqO3LQ-0VqNXhqXR6mFh6IP7s0NPnSQ';
|
||||||
expect(TokenManager.validateToken(tokenWithoutExp)).toBe(true);
|
expect(TokenManager.validateToken(noExpToken)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Security Features', () => {
|
describe('Security Features', () => {
|
||||||
it('should use secure encryption algorithm', () => {
|
it('should use secure encryption algorithm', () => {
|
||||||
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
|
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
|
||||||
expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/); // Base64 format
|
expect(encrypted).toContain('aes-256-gcm');
|
||||||
expect(encrypted.length).toBeGreaterThan(validToken.length); // Should include IV and tag
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prevent token tampering', () => {
|
it('should prevent token tampering', () => {
|
||||||
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
|
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();
|
expect(() => TokenManager.decryptToken(tampered, encryptionKey)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use unique IVs for each encryption', () => {
|
it('should use unique IVs for each encryption', () => {
|
||||||
const encrypted1 = TokenManager.encryptToken(validToken, encryptionKey);
|
const encrypted1 = TokenManager.encryptToken(validToken, encryptionKey);
|
||||||
const encrypted2 = TokenManager.encryptToken(validToken, encryptionKey);
|
const encrypted2 = TokenManager.encryptToken(validToken, encryptionKey);
|
||||||
const encrypted3 = TokenManager.encryptToken(validToken, encryptionKey);
|
const iv1 = encrypted1.split(':')[1];
|
||||||
|
const iv2 = encrypted2.split(':')[1];
|
||||||
// Each encryption should be different due to unique IVs
|
expect(iv1).not.toBe(iv2);
|
||||||
expect(new Set([encrypted1, encrypted2, encrypted3]).size).toBe(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle large tokens', () => {
|
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 encrypted = TokenManager.encryptToken(largeToken, encryptionKey);
|
||||||
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
|
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
|
||||||
expect(decrypted).toBe(largeToken);
|
expect(decrypted).toBe(largeToken);
|
||||||
@@ -99,29 +89,24 @@ describe('TokenManager', () => {
|
|||||||
|
|
||||||
describe('Error Handling', () => {
|
describe('Error Handling', () => {
|
||||||
it('should throw descriptive errors for invalid inputs', () => {
|
it('should throw descriptive errors for invalid inputs', () => {
|
||||||
expect(() => TokenManager.encryptToken(null as any, encryptionKey))
|
expect(() => TokenManager.encryptToken(null as any, encryptionKey)).toThrow('Invalid token');
|
||||||
.toThrow(/invalid/i);
|
expect(() => TokenManager.encryptToken(validToken, null as any)).toThrow('Invalid encryption key');
|
||||||
expect(() => TokenManager.encryptToken(validToken, null as any))
|
expect(() => TokenManager.decryptToken('invalid-base64', encryptionKey)).toThrow('Invalid encrypted token');
|
||||||
.toThrow(/invalid/i);
|
|
||||||
expect(() => TokenManager.decryptToken('invalid-base64', encryptionKey))
|
|
||||||
.toThrow(/invalid/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle corrupted encrypted data', () => {
|
it('should handle corrupted encrypted data', () => {
|
||||||
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
|
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
|
||||||
const corrupted = encrypted.substring(10); // Remove part of the encrypted data
|
const corrupted = encrypted.replace(/[a-zA-Z]/g, 'x');
|
||||||
expect(() => TokenManager.decryptToken(corrupted, encryptionKey))
|
expect(() => TokenManager.decryptToken(corrupted, encryptionKey)).toThrow();
|
||||||
.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle invalid base64 input', () => {
|
it('should handle invalid base64 input', () => {
|
||||||
expect(() => TokenManager.decryptToken('not-base64!@#$', encryptionKey))
|
expect(() => TokenManager.decryptToken('not-base64!@#$%^', encryptionKey)).toThrow();
|
||||||
.toThrow(/invalid/i);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle undefined and null inputs', () => {
|
it('should handle undefined and null inputs', () => {
|
||||||
expect(() => TokenManager.validateToken(undefined as any)).toBe(false);
|
expect(TokenManager.validateToken(undefined as any)).toBe(false);
|
||||||
expect(() => TokenManager.validateToken(null as any)).toBe(false);
|
expect(TokenManager.validateToken(null as any)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
114
__tests__/server.test.ts
Normal file
114
__tests__/server.test.ts
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { WebSocket } from 'ws';
|
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
import { HassWebSocketClient } from '../../src/websocket/client.js';
|
import { HassWebSocketClient } from '../../src/websocket/client.js';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
import * as HomeAssistant from '../../src/types/hass.js';
|
import * as HomeAssistant from '../../src/types/hass.js';
|
||||||
|
|
||||||
// Mock WebSocket
|
// Mock WebSocket
|
||||||
@@ -8,39 +9,130 @@ jest.mock('ws');
|
|||||||
|
|
||||||
describe('WebSocket Event Handling', () => {
|
describe('WebSocket Event Handling', () => {
|
||||||
let client: HassWebSocketClient;
|
let client: HassWebSocketClient;
|
||||||
let mockWs: jest.Mocked<WebSocket>;
|
let mockWebSocket: jest.Mocked<WebSocket>;
|
||||||
let eventEmitter: EventEmitter;
|
let eventEmitter: EventEmitter;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setup mock WebSocket
|
// Clear all mocks
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Create event emitter for mocking WebSocket events
|
||||||
eventEmitter = new EventEmitter();
|
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(),
|
send: jest.fn(),
|
||||||
close: 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>;
|
} 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
|
// Create client instance
|
||||||
client = new HassWebSocketClient('ws://localhost:8123/api/websocket', 'test_token', {
|
client = new HassWebSocketClient('ws://test.com', 'test-token');
|
||||||
autoReconnect: true,
|
|
||||||
maxReconnectAttempts: 3,
|
|
||||||
reconnectDelay: 100
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
|
||||||
eventEmitter.removeAllListeners();
|
eventEmitter.removeAllListeners();
|
||||||
client.disconnect();
|
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', () => {
|
describe('Connection Events', () => {
|
||||||
it('should handle successful connection', (done) => {
|
it('should handle successful connection', (done) => {
|
||||||
client.on('open', () => {
|
client.on('open', () => {
|
||||||
expect(mockWs.send).toHaveBeenCalled();
|
expect(mockWebSocket.send).toHaveBeenCalled();
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +151,7 @@ describe('WebSocket Event Handling', () => {
|
|||||||
|
|
||||||
it('should handle connection close', (done) => {
|
it('should handle connection close', (done) => {
|
||||||
client.on('disconnected', () => {
|
client.on('disconnected', () => {
|
||||||
expect(mockWs.close).toHaveBeenCalled();
|
expect(mockWebSocket.close).toHaveBeenCalled();
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +167,7 @@ describe('WebSocket Event Handling', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
client.connect();
|
client.connect();
|
||||||
expect(mockWs.send).toHaveBeenCalledWith(JSON.stringify(authMessage));
|
expect(mockWebSocket.send).toHaveBeenCalledWith(JSON.stringify(authMessage));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle successful authentication', (done) => {
|
it('should handle successful authentication', (done) => {
|
||||||
|
|||||||
@@ -6,13 +6,36 @@ module.exports = (request, options) => {
|
|||||||
return path.resolve(__dirname, 'node_modules', request.replace('#', ''));
|
return path.resolve(__dirname, 'node_modules', request.replace('#', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle .js extensions for TypeScript files
|
// Handle source files with .js extension
|
||||||
if (request.endsWith('.js')) {
|
if (request.endsWith('.js')) {
|
||||||
const tsRequest = request.replace(/\.js$/, '.ts');
|
const tsRequest = request.replace(/\.js$/, '.ts');
|
||||||
try {
|
try {
|
||||||
return options.defaultResolver(tsRequest, options);
|
return options.defaultResolver(tsRequest, {
|
||||||
|
...options,
|
||||||
|
packageFilter: pkg => {
|
||||||
|
if (pkg.type === 'module') {
|
||||||
|
if (pkg.exports && pkg.exports.import) {
|
||||||
|
pkg.main = pkg.exports.import;
|
||||||
|
} else if (pkg.module) {
|
||||||
|
pkg.main = pkg.module;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If the .ts file doesn't exist, continue with the original request
|
// If the .ts file doesn't exist, try resolving without extension
|
||||||
|
try {
|
||||||
|
return options.defaultResolver(request.replace(/\.js$/, ''), options);
|
||||||
|
} catch (e2) {
|
||||||
|
// If that fails too, try resolving with .ts extension
|
||||||
|
try {
|
||||||
|
return options.defaultResolver(tsRequest, options);
|
||||||
|
} catch (e3) {
|
||||||
|
// If all attempts fail, try resolving the original request
|
||||||
|
return options.defaultResolver(request, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +61,11 @@ module.exports = (request, options) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the default resolver
|
// Call the default resolver with enhanced module resolution
|
||||||
return options.defaultResolver(request, {
|
return options.defaultResolver(request, {
|
||||||
...options,
|
...options,
|
||||||
// Handle ESM modules
|
// Handle ESM modules
|
||||||
packageFilter: pkg => {
|
packageFilter: pkg => {
|
||||||
// Preserve ESM modules
|
|
||||||
if (pkg.type === 'module') {
|
if (pkg.type === 'module') {
|
||||||
if (pkg.exports) {
|
if (pkg.exports) {
|
||||||
if (pkg.exports.import) {
|
if (pkg.exports.import) {
|
||||||
@@ -57,5 +79,7 @@ module.exports = (request, options) => {
|
|||||||
}
|
}
|
||||||
return pkg;
|
return pkg;
|
||||||
},
|
},
|
||||||
|
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
|
||||||
|
paths: [...(options.paths || []), path.resolve(__dirname, 'src')]
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,59 +1,28 @@
|
|||||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
preset: 'ts-jest/presets/default-esm',
|
preset: 'ts-jest',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
extensionsToTreatAsEsm: ['.ts', '.mts'],
|
resolver: './jest-resolver.cjs',
|
||||||
moduleNameMapper: {
|
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
|
||||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
|
||||||
'^(\\.{1,2}/.*)\\.ts$': '$1',
|
|
||||||
'^chalk$': '<rootDir>/node_modules/chalk/source/index.js',
|
|
||||||
'#ansi-styles': '<rootDir>/node_modules/ansi-styles/index.js',
|
|
||||||
'#supports-color': '<rootDir>/node_modules/supports-color/index.js'
|
|
||||||
},
|
|
||||||
transform: {
|
transform: {
|
||||||
'^.+\\.(ts|mts|js|mjs)$': [
|
'^.+\\.ts$': ['ts-jest', {
|
||||||
'ts-jest',
|
|
||||||
{
|
|
||||||
useESM: true,
|
|
||||||
tsconfig: 'tsconfig.json'
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
transformIgnorePatterns: [
|
|
||||||
'node_modules/(?!(@digital-alchemy|chalk|ansi-styles|supports-color)/.*)(?!.*\\.mjs$)'
|
|
||||||
],
|
|
||||||
resolver: '<rootDir>/jest-resolver.cjs',
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
|
||||||
testMatch: [
|
|
||||||
'**/__tests__/helpers.test.ts',
|
|
||||||
'**/__tests__/schemas/devices.test.ts',
|
|
||||||
'**/__tests__/context/index.test.ts',
|
|
||||||
'**/__tests__/hass/index.test.ts',
|
|
||||||
'**/__tests__/api/index.test.ts'
|
|
||||||
],
|
|
||||||
globals: {
|
|
||||||
'ts-jest': {
|
|
||||||
useESM: true,
|
useESM: true,
|
||||||
},
|
tsconfig: 'tsconfig.json'
|
||||||
|
}]
|
||||||
},
|
},
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^(\\.{1,2}/.*)\\.js$': '$1'
|
||||||
|
},
|
||||||
|
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||||
collectCoverage: true,
|
collectCoverage: true,
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
coverageReporters: ['text', 'lcov', 'clover', 'html'],
|
|
||||||
collectCoverageFrom: [
|
|
||||||
'src/**/*.ts',
|
|
||||||
'!src/**/*.d.ts',
|
|
||||||
'!src/**/*.test.ts',
|
|
||||||
'!src/types/**/*',
|
|
||||||
'!src/polyfills.ts'
|
|
||||||
],
|
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
global: {
|
global: {
|
||||||
|
statements: 50,
|
||||||
branches: 50,
|
branches: 50,
|
||||||
functions: 50,
|
functions: 50,
|
||||||
lines: 50,
|
lines: 50
|
||||||
statements: 50
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
verbose: true,
|
setupFilesAfterEnv: ['./jest.setup.ts']
|
||||||
testTimeout: 30000
|
|
||||||
};
|
};
|
||||||
@@ -7,8 +7,10 @@ dotenv.config({ path: '.env.test' });
|
|||||||
|
|
||||||
// Set test environment
|
// Set test environment
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.ENCRYPTION_KEY = 'test-encryption-key-32-bytes-long!!!';
|
||||||
|
process.env.JWT_SECRET = 'test-jwt-secret';
|
||||||
process.env.HASS_URL = 'http://localhost:8123';
|
process.env.HASS_URL = 'http://localhost:8123';
|
||||||
process.env.HASS_TOKEN = 'test_token';
|
process.env.HASS_TOKEN = 'test-token';
|
||||||
process.env.CLAUDE_API_KEY = 'test_api_key';
|
process.env.CLAUDE_API_KEY = 'test_api_key';
|
||||||
process.env.CLAUDE_MODEL = 'test_model';
|
process.env.CLAUDE_MODEL = 'test_model';
|
||||||
|
|
||||||
@@ -81,4 +83,5 @@ beforeEach(() => {
|
|||||||
// Cleanup after tests
|
// Cleanup after tests
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllTimers();
|
jest.clearAllTimers();
|
||||||
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
"@digital-alchemy/core": "^24.11.4",
|
"@digital-alchemy/core": "^24.11.4",
|
||||||
"@digital-alchemy/hass": "^24.11.4",
|
"@digital-alchemy/hass": "^24.11.4",
|
||||||
"@types/chalk": "^0.4.31",
|
"@types/chalk": "^0.4.31",
|
||||||
|
"@types/jsonwebtoken": "^9.0.8",
|
||||||
"@types/xmldom": "^0.1.34",
|
"@types/xmldom": "^0.1.34",
|
||||||
"@xmldom/xmldom": "^0.9.7",
|
"@xmldom/xmldom": "^0.9.7",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"litemcp": "^0.7.0",
|
"litemcp": "^0.7.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { HelmetOptions } from 'helmet';
|
import { HelmetOptions } from 'helmet';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
// Security configuration
|
// Security configuration
|
||||||
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
||||||
@@ -42,83 +43,112 @@ const helmetConfig: HelmetOptions = {
|
|||||||
// Security headers middleware
|
// Security headers middleware
|
||||||
export const securityHeaders = helmet(helmetConfig);
|
export const securityHeaders = helmet(helmetConfig);
|
||||||
|
|
||||||
// Token validation and encryption
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const IV_LENGTH = 16;
|
||||||
|
const AUTH_TAG_LENGTH = 16;
|
||||||
|
|
||||||
export class TokenManager {
|
export class TokenManager {
|
||||||
private static readonly algorithm = 'aes-256-gcm';
|
/**
|
||||||
private static readonly keyLength = 32;
|
* Encrypts a token using AES-256-GCM
|
||||||
private static readonly ivLength = 16;
|
*/
|
||||||
private static readonly saltLength = 64;
|
static encryptToken(token: string, key: string): string {
|
||||||
private static readonly tagLength = 16;
|
if (!token || typeof token !== 'string') {
|
||||||
private static readonly iterations = 100000;
|
throw new Error('Invalid token');
|
||||||
private static readonly digest = 'sha512';
|
}
|
||||||
|
if (!key || typeof key !== 'string' || key.length < 32) {
|
||||||
private static deriveKey(password: string, salt: Buffer): Buffer {
|
throw new Error('Invalid encryption key');
|
||||||
return crypto.pbkdf2Sync(
|
}
|
||||||
password,
|
|
||||||
salt,
|
|
||||||
this.iterations,
|
|
||||||
this.keyLength,
|
|
||||||
this.digest
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static encryptToken(token: string, encryptionKey: string): string {
|
|
||||||
const iv = crypto.randomBytes(this.ivLength);
|
|
||||||
const salt = crypto.randomBytes(this.saltLength);
|
|
||||||
const key = this.deriveKey(encryptionKey, salt);
|
|
||||||
const cipher = crypto.createCipheriv(this.algorithm, key, iv, {
|
|
||||||
authTagLength: this.tagLength
|
|
||||||
});
|
|
||||||
|
|
||||||
const encrypted = Buffer.concat([
|
|
||||||
cipher.update(token, 'utf8'),
|
|
||||||
cipher.final()
|
|
||||||
]);
|
|
||||||
const tag = cipher.getAuthTag();
|
|
||||||
|
|
||||||
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static decryptToken(encryptedToken: string, encryptionKey: string): string {
|
|
||||||
const buffer = Buffer.from(encryptedToken, 'base64');
|
|
||||||
const salt = buffer.subarray(0, this.saltLength);
|
|
||||||
const iv = buffer.subarray(this.saltLength, this.saltLength + this.ivLength);
|
|
||||||
const tag = buffer.subarray(
|
|
||||||
this.saltLength + this.ivLength,
|
|
||||||
this.saltLength + this.ivLength + this.tagLength
|
|
||||||
);
|
|
||||||
const encrypted = buffer.subarray(this.saltLength + this.ivLength + this.tagLength);
|
|
||||||
const key = this.deriveKey(encryptionKey, salt);
|
|
||||||
|
|
||||||
const decipher = crypto.createDecipheriv(this.algorithm, key, iv, {
|
|
||||||
authTagLength: this.tagLength
|
|
||||||
});
|
|
||||||
decipher.setAuthTag(tag);
|
|
||||||
|
|
||||||
return decipher.update(encrypted) + decipher.final('utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static validateToken(token: string): boolean {
|
|
||||||
if (!token) return false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check token format
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
if (!/^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/.test(token)) {
|
const cipher = crypto.createCipheriv(ALGORITHM, key.slice(0, 32), iv);
|
||||||
|
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(token, 'utf8'),
|
||||||
|
cipher.final()
|
||||||
|
]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Format: algorithm:iv:tag:encrypted
|
||||||
|
return `${ALGORITHM}:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Failed to encrypt token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a token using AES-256-GCM
|
||||||
|
*/
|
||||||
|
static decryptToken(encryptedToken: string, key: string): string {
|
||||||
|
if (!encryptedToken || typeof encryptedToken !== 'string') {
|
||||||
|
throw new Error('Invalid encrypted token');
|
||||||
|
}
|
||||||
|
if (!key || typeof key !== 'string' || key.length < 32) {
|
||||||
|
throw new Error('Invalid encryption key');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [algorithm, ivBase64, tagBase64, encryptedBase64] = encryptedToken.split(':');
|
||||||
|
|
||||||
|
if (algorithm !== ALGORITHM || !ivBase64 || !tagBase64 || !encryptedBase64) {
|
||||||
|
throw new Error('Invalid encrypted token format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
const tag = Buffer.from(tagBase64, 'base64');
|
||||||
|
const encrypted = Buffer.from(encryptedBase64, 'base64');
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, key.slice(0, 32), iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(encrypted),
|
||||||
|
decipher.final()
|
||||||
|
]).toString('utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Invalid encrypted token format') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error('Invalid encrypted token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a JWT token
|
||||||
|
*/
|
||||||
|
static validateToken(token: string): boolean {
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.decode(token);
|
||||||
|
if (!decoded || typeof decoded !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode token parts
|
// Check for expiration
|
||||||
const [headerEncoded, payloadEncoded] = token.split('.');
|
if (!decoded.exp) {
|
||||||
const header = JSON.parse(Buffer.from(headerEncoded, 'base64').toString());
|
|
||||||
const payload = JSON.parse(Buffer.from(payloadEncoded, 'base64').toString());
|
|
||||||
|
|
||||||
// Check token expiry
|
|
||||||
if (payload.exp && Date.now() >= payload.exp * 1000) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Additional checks can be added here
|
const now = Math.floor(Date.now() / 1000);
|
||||||
return true;
|
if (decoded.exp <= now) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature using the secret from environment variable
|
||||||
|
const secret = process.env.JWT_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
jwt.verify(token, secret);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/security/middleware.test.ts
Normal file
109
src/security/middleware.test.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { validateRequest, sanitizeInput } from '../../src/security/middleware';
|
||||||
|
|
||||||
|
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: MockResponse;
|
||||||
|
let nextFunction: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRequest = {
|
||||||
|
headers: {},
|
||||||
|
body: {},
|
||||||
|
is: jest.fn<string | false | null, [string | string[]]>().mockReturnValue('json')
|
||||||
|
};
|
||||||
|
|
||||||
|
mockResponse = {
|
||||||
|
status: jest.fn<MockResponse, [number]>().mockReturnThis(),
|
||||||
|
json: jest.fn<MockResponse, [any]>().mockReturnThis(),
|
||||||
|
setHeader: jest.fn<MockResponse, [string, string]>().mockReturnThis()
|
||||||
|
};
|
||||||
|
|
||||||
|
nextFunction = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateRequest', () => {
|
||||||
|
it('should pass valid requests', () => {
|
||||||
|
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 without authorization header', () => {
|
||||||
|
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
|
||||||
|
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
error: expect.stringContaining('authorization')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
error: expect.stringContaining('Bearer')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeInput', () => {
|
||||||
|
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>Hello',
|
||||||
|
nested: {
|
||||||
|
html: '<img src="x" onerror="alert(1)">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 = '<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 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user