refactor: migrate to Elysia and enhance security middleware

- Replaced Express with Elysia for improved performance and type safety
- Integrated Elysia middleware for rate limiting, security headers, and request validation
- Refactored security utilities to work with Elysia's context and request handling
- Updated token management and validation logic
- Added comprehensive security headers and input sanitization
- Simplified server initialization and error handling
- Updated documentation with new setup and configuration details
This commit is contained in:
jango-blockchained
2025-02-04 03:09:35 +01:00
parent bc1dc8278a
commit 790a37e49f
18 changed files with 1687 additions and 1064 deletions

View File

@@ -1,5 +1,5 @@
import { TokenManager, validateRequest, sanitizeInput, errorHandler } from '../../src/security/index.js';
import { Request, Response } from 'express';
import { TokenManager, validateRequest, sanitizeInput, errorHandler, rateLimiter, securityHeaders } from '../../src/security/index.js';
import { mock, describe, it, expect, beforeEach, afterEach } from 'bun:test';
import jwt from 'jsonwebtoken';
const TEST_SECRET = 'test-secret-that-is-long-enough-for-testing-purposes';
@@ -50,44 +50,75 @@ describe('Security Module', () => {
expect(result.valid).toBe(false);
expect(result.error).toBe('Token has expired');
});
it('should handle invalid token format', () => {
const result = TokenManager.validateToken('invalid-token');
expect(result.valid).toBe(false);
expect(result.error).toBe('Invalid token format');
});
it('should handle missing JWT secret', () => {
delete process.env.JWT_SECRET;
const payload = { data: 'test' };
const token = jwt.sign(payload, 'some-secret');
const result = TokenManager.validateToken(token);
expect(result.valid).toBe(false);
expect(result.error).toBe('JWT secret not configured');
});
it('should handle rate limiting for failed attempts', () => {
const invalidToken = 'x'.repeat(64);
const testIp = '127.0.0.1';
// First attempt
const firstResult = TokenManager.validateToken(invalidToken, testIp);
expect(firstResult.valid).toBe(false);
// Multiple failed attempts
for (let i = 0; i < 4; i++) {
TokenManager.validateToken(invalidToken, testIp);
}
// Next attempt should be rate limited
const limitedResult = TokenManager.validateToken(invalidToken, testIp);
expect(limitedResult.valid).toBe(false);
expect(limitedResult.error).toBe('Too many failed attempts. Please try again later.');
});
});
describe('Request Validation', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
let mockRequest: any;
let mockResponse: any;
let mockNext: any;
beforeEach(() => {
mockRequest = {
method: 'POST',
headers: {
'content-type': 'application/json'
} as Record<string, string>,
},
body: {},
ip: '127.0.0.1'
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
removeHeader: jest.fn().mockReturnThis()
status: mock(() => mockResponse),
json: mock(() => mockResponse),
setHeader: mock(() => mockResponse),
removeHeader: mock(() => mockResponse)
};
mockNext = jest.fn();
mockNext = mock(() => { });
});
it('should pass valid requests', () => {
if (mockRequest.headers) {
mockRequest.headers.authorization = 'Bearer valid-token';
}
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({ valid: true });
const validateTokenSpy = mock(() => ({ valid: true }));
TokenManager.validateToken = validateTokenSpy;
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
validateRequest(mockRequest, mockResponse, mockNext);
expect(mockNext).toHaveBeenCalled();
});
@@ -97,11 +128,7 @@ describe('Security Module', () => {
mockRequest.headers['content-type'] = 'text/plain';
}
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
validateRequest(mockRequest, mockResponse, mockNext);
expect(mockResponse.status).toHaveBeenCalledWith(415);
expect(mockResponse.json).toHaveBeenCalledWith({
@@ -117,11 +144,7 @@ describe('Security Module', () => {
delete mockRequest.headers.authorization;
}
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
validateRequest(mockRequest, mockResponse, mockNext);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({
@@ -135,11 +158,7 @@ describe('Security Module', () => {
it('should reject invalid request body', () => {
mockRequest.body = null;
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
validateRequest(mockRequest, mockResponse, mockNext);
expect(mockResponse.status).toHaveBeenCalledWith(400);
expect(mockResponse.json).toHaveBeenCalledWith({
@@ -152,9 +171,9 @@ describe('Security Module', () => {
});
describe('Input Sanitization', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
let mockRequest: any;
let mockResponse: any;
let mockNext: any;
beforeEach(() => {
mockRequest = {
@@ -171,19 +190,15 @@ describe('Security Module', () => {
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis()
status: mock(() => mockResponse),
json: mock(() => mockResponse)
};
mockNext = jest.fn();
mockNext = mock(() => { });
});
it('should sanitize HTML tags from request body', () => {
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
sanitizeInput(mockRequest, mockResponse, mockNext);
expect(mockRequest.body).toEqual({
text: 'Test',
@@ -196,19 +211,15 @@ describe('Security Module', () => {
it('should handle non-object body', () => {
mockRequest.body = 'string body';
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
sanitizeInput(mockRequest, mockResponse, mockNext);
expect(mockNext).toHaveBeenCalled();
});
});
describe('Error Handler', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
let mockRequest: any;
let mockResponse: any;
let mockNext: any;
beforeEach(() => {
mockRequest = {
@@ -217,22 +228,17 @@ describe('Security Module', () => {
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis()
status: mock(() => mockResponse),
json: mock(() => mockResponse)
};
mockNext = jest.fn();
mockNext = mock(() => { });
});
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, mockResponse, mockNext);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
@@ -245,12 +251,7 @@ describe('Security Module', () => {
it('should include error message 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, mockResponse, mockNext);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
@@ -262,4 +263,52 @@ describe('Security Module', () => {
});
});
});
describe('Rate Limiter', () => {
it('should limit requests after threshold', async () => {
const mockContext = {
request: new Request('http://localhost', {
headers: new Headers({
'x-forwarded-for': '127.0.0.1'
})
}),
set: mock(() => { })
};
// Test multiple requests
for (let i = 0; i < 100; i++) {
await rateLimiter.derive(mockContext);
}
// The next request should throw
try {
await rateLimiter.derive(mockContext);
expect(false).toBe(true); // Should not reach here
} catch (error) {
expect(error instanceof Error).toBe(true);
expect(error.message).toBe('Too many requests from this IP, please try again later');
}
});
});
describe('Security Headers', () => {
it('should set security headers', async () => {
const mockHeaders = new Headers();
const mockContext = {
request: new Request('http://localhost', {
headers: mockHeaders
}),
set: mock(() => { })
};
await securityHeaders.derive(mockContext);
// Verify that security headers were set
const headers = mockContext.request.headers;
expect(headers.has('content-security-policy')).toBe(true);
expect(headers.has('x-frame-options')).toBe(true);
expect(headers.has('x-content-type-options')).toBe(true);
expect(headers.has('referrer-policy')).toBe(true);
});
});
});

View File

@@ -1,181 +1,156 @@
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { Request, Response } from 'express';
import { Mock } from 'bun:test';
import { describe, it, expect } from 'bun:test';
import {
validateRequest,
sanitizeInput,
errorHandler,
rateLimiter,
securityHeaders
checkRateLimit,
validateRequestHeaders,
sanitizeValue,
applySecurityHeaders,
handleError
} from '../../src/security/index.js';
interface MockRequest extends Partial<Request> {
headers: {
'content-type'?: string;
authorization?: string;
};
method: string;
body: any;
ip: string;
path: string;
}
describe('Security Middleware Utilities', () => {
describe('Rate Limiter', () => {
it('should allow requests under threshold', () => {
const ip = '127.0.0.1';
expect(() => checkRateLimit(ip, 10)).not.toThrow();
});
interface MockResponse extends Partial<Response> {
status: Mock<(code: number) => MockResponse>;
json: Mock<(body: any) => MockResponse>;
setHeader: Mock<(name: string, value: string) => MockResponse>;
removeHeader: Mock<(name: string) => MockResponse>;
}
it('should throw when requests exceed threshold', () => {
const ip = '127.0.0.2';
describe('Security Middleware', () => {
let mockRequest: any;
let mockResponse: any;
let nextFunction: any;
// Simulate multiple requests
for (let i = 0; i < 11; i++) {
if (i < 10) {
expect(() => checkRateLimit(ip, 10)).not.toThrow();
} else {
expect(() => checkRateLimit(ip, 10)).toThrow('Too many requests from this IP, please try again later');
}
}
});
beforeEach(() => {
mockRequest = {
headers: {
'content-type': 'application/json'
},
method: 'POST',
body: {},
ip: '127.0.0.1',
path: '/api/test'
};
it('should reset rate limit after window expires', async () => {
const ip = '127.0.0.3';
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
removeHeader: jest.fn().mockReturnThis()
} as MockResponse;
// Simulate multiple requests
for (let i = 0; i < 11; i++) {
if (i < 10) {
expect(() => checkRateLimit(ip, 10, 50)).not.toThrow();
}
}
nextFunction = jest.fn();
// Wait for rate limit window to expire
await new Promise(resolve => setTimeout(resolve, 100));
// Should be able to make requests again
expect(() => checkRateLimit(ip, 10, 50)).not.toThrow();
});
});
describe('Request Validation', () => {
it('should pass valid requests', () => {
mockRequest.headers.authorization = 'Bearer valid-token';
validateRequest(mockRequest, mockResponse, nextFunction);
expect(nextFunction).toHaveBeenCalled();
it('should validate content type', () => {
const mockRequest = new Request('http://localhost', {
method: 'POST',
headers: {
'content-type': 'application/json'
}
});
expect(() => validateRequestHeaders(mockRequest)).not.toThrow();
});
it('should reject requests without authorization header', () => {
validateRequest(mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({
success: false,
message: 'Unauthorized',
error: 'Missing or invalid authorization header',
timestamp: expect.any(String)
it('should reject invalid content type', () => {
const mockRequest = new Request('http://localhost', {
method: 'POST',
headers: {
'content-type': 'text/plain'
}
});
expect(() => validateRequestHeaders(mockRequest)).toThrow('Content-Type must be application/json');
});
it('should reject requests with invalid authorization format', () => {
mockRequest.headers.authorization = 'invalid-format';
validateRequest(mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({
success: false,
message: 'Unauthorized',
error: 'Missing or invalid authorization header',
timestamp: expect.any(String)
it('should reject large request bodies', () => {
const mockRequest = new Request('http://localhost', {
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': '2000000'
}
});
expect(() => validateRequestHeaders(mockRequest)).toThrow('Request body too large');
});
});
describe('Input Sanitization', () => {
it('should sanitize HTML in request body', () => {
mockRequest.body = {
it('should sanitize HTML tags', () => {
const input = '<script>alert("xss")</script>Hello';
const sanitized = sanitizeValue(input);
expect(sanitized).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;Hello');
});
it('should sanitize nested objects', () => {
const input = {
text: '<script>alert("xss")</script>Hello',
nested: {
html: '<img src="x" onerror="alert(1)">World'
}
};
sanitizeInput(mockRequest, mockResponse, 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, mockResponse, nextFunction);
expect(mockRequest.body).toBe('text');
expect(nextFunction).toHaveBeenCalled();
const sanitized = sanitizeValue(input);
expect(sanitized).toEqual({
text: '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;Hello',
nested: {
html: '&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;World'
}
});
});
it('should preserve non-string values', () => {
mockRequest.body = {
const input = {
number: 123,
boolean: true,
array: [1, 2, 3]
};
sanitizeInput(mockRequest, mockResponse, nextFunction);
expect(mockRequest.body).toEqual({
number: 123,
boolean: true,
array: [1, 2, 3]
});
expect(nextFunction).toHaveBeenCalled();
const sanitized = sanitizeValue(input);
expect(sanitized).toEqual(input);
});
});
describe('Error Handler', () => {
const originalEnv = process.env.NODE_ENV;
describe('Security Headers', () => {
it('should apply security headers', () => {
const mockRequest = new Request('http://localhost');
const headers = applySecurityHeaders(mockRequest);
afterAll(() => {
process.env.NODE_ENV = originalEnv;
expect(headers).toBeDefined();
expect(headers['content-security-policy']).toBeDefined();
expect(headers['x-frame-options']).toBeDefined();
expect(headers['x-content-type-options']).toBeDefined();
expect(headers['referrer-policy']).toBeDefined();
});
});
describe('Error Handling', () => {
it('should handle errors in production mode', () => {
process.env.NODE_ENV = 'production';
const error = new Error('Test error');
errorHandler(error, mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: undefined,
const result = handleError(error, 'production');
expect(result).toEqual({
error: true,
message: 'Internal server error',
timestamp: expect.any(String)
});
});
it('should include error details in development mode', () => {
process.env.NODE_ENV = 'development';
const error = new Error('Test error');
errorHandler(error, mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Test error',
stack: expect.any(String),
timestamp: expect.any(String)
const result = handleError(error, 'development');
expect(result).toEqual({
error: true,
message: 'Internal server error',
timestamp: expect.any(String),
error: 'Test error',
stack: expect.any(String)
});
});
it('should handle non-Error objects', () => {
const error = 'String error message';
errorHandler(error as any, mockRequest, mockResponse, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
});
});
describe('Rate Limiter', () => {
it('should be configured with correct options', () => {
expect(rateLimiter).toBeDefined();
expect(rateLimiter.windowMs).toBeDefined();
expect(rateLimiter.max).toBeDefined();
expect(rateLimiter.message).toBeDefined();
});
});
describe('Security Headers', () => {
it('should set appropriate security headers', () => {
securityHeaders(mockRequest, mockResponse, 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

@@ -417,3 +417,139 @@ async function executeAction() {
console.log('Action result:', data);
}
```
## Security Middleware
### Overview
The security middleware provides a comprehensive set of utility functions to enhance the security of the Home Assistant MCP application. These functions cover various aspects of web security, including:
- Rate limiting
- Request validation
- Input sanitization
- Security headers
- Error handling
### Utility Functions
#### `checkRateLimit(ip: string, maxRequests?: number, windowMs?: number)`
Manages rate limiting for IP addresses to prevent abuse.
**Parameters**:
- `ip`: IP address to track
- `maxRequests`: Maximum number of requests allowed (default: 100)
- `windowMs`: Time window for rate limiting (default: 15 minutes)
**Returns**: `boolean` or throws an error if limit is exceeded
**Example**:
```typescript
try {
checkRateLimit('127.0.0.1'); // Checks rate limit with default settings
} catch (error) {
// Handle rate limit exceeded
}
```
#### `validateRequestHeaders(request: Request, requiredContentType?: string)`
Validates incoming HTTP request headers for security and compliance.
**Parameters**:
- `request`: The incoming HTTP request
- `requiredContentType`: Expected content type (default: 'application/json')
**Checks**:
- Content type
- Request body size
- Authorization header (optional)
**Example**:
```typescript
try {
validateRequestHeaders(request);
} catch (error) {
// Handle validation errors
}
```
#### `sanitizeValue(value: unknown)`
Sanitizes input values to prevent XSS attacks.
**Features**:
- Escapes HTML tags
- Handles nested objects and arrays
- Preserves non-string values
**Example**:
```typescript
const sanitized = sanitizeValue('<script>alert("xss")</script>');
// Returns: '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
```
#### `applySecurityHeaders(request: Request, helmetConfig?: HelmetOptions)`
Applies security headers to HTTP requests using Helmet.
**Security Headers**:
- Content Security Policy
- X-Frame-Options
- X-Content-Type-Options
- Referrer Policy
- HSTS (in production)
**Example**:
```typescript
const headers = applySecurityHeaders(request);
```
#### `handleError(error: Error, env?: string)`
Handles error responses with environment-specific details.
**Modes**:
- Production: Generic error message
- Development: Detailed error with stack trace
**Example**:
```typescript
const errorResponse = handleError(error, process.env.NODE_ENV);
```
### Middleware Usage
These utility functions are integrated into Elysia middleware:
```typescript
const app = new Elysia()
.use(rateLimiter) // Rate limiting
.use(validateRequest) // Request validation
.use(sanitizeInput) // Input sanitization
.use(securityHeaders) // Security headers
.use(errorHandler) // Error handling
```
### Best Practices
1. Always validate and sanitize user inputs
2. Use rate limiting to prevent abuse
3. Apply security headers
4. Handle errors gracefully
5. Keep environment-specific error handling
### Security Considerations
- Configurable rate limits
- XSS protection
- Content security policies
- Token validation
- Error information exposure control
### Troubleshooting
- Ensure `JWT_SECRET` is set in environment
- Check content type in requests
- Monitor rate limit errors
- Review error handling in different environments

514
docs/TESTING.md Normal file
View File

@@ -0,0 +1,514 @@
# Testing Documentation
## Quick Reference
```bash
# Most Common Commands
bun test # Run all tests
bun test --watch # Run tests in watch mode
bun test --coverage # Run tests with coverage
bun test path/to/test.ts # Run specific test file
# Additional Options
DEBUG=true bun test # Run with debug output
bun test --pattern "auth" # Run tests matching pattern
bun test --timeout 60000 # Run with custom timeout
```
## Overview
This document describes the testing setup and practices used in the Home Assistant MCP project. The project uses Bun's test runner for unit and integration testing, with a comprehensive test suite covering security, SSE (Server-Sent Events), middleware, and other core functionalities.
## Test Structure
Tests are organized in two main locations:
1. **Root Level Integration Tests** (`/__tests__/`):
```
__tests__/
├── ai/ # AI/ML component tests
├── api/ # API integration tests
├── context/ # Context management tests
├── hass/ # Home Assistant integration tests
├── schemas/ # Schema validation tests
├── security/ # Security integration tests
├── tools/ # Tools and utilities tests
├── websocket/ # WebSocket integration tests
├── helpers.test.ts # Helper function tests
├── index.test.ts # Main application tests
└── server.test.ts # Server integration tests
```
2. **Component Level Unit Tests** (`src/**/`):
```
src/
├── __tests__/ # Global test setup and utilities
│ └── setup.ts # Global test configuration
├── component/
│ ├── __tests__/ # Component-specific unit tests
│ └── component.ts
```
The root level `__tests__` directory contains integration and end-to-end tests that verify the interaction between different components of the system, while the component-level tests focus on unit testing individual modules.
## Test Configuration
### Bun Test Configuration (`bunfig.toml`)
```toml
[test]
preload = ["./src/__tests__/setup.ts"] # Global test setup
coverage = true # Enable coverage by default
timeout = 30000 # Test timeout in milliseconds
testMatch = ["**/__tests__/**/*.test.ts"] # Test file patterns
```
### NPM Scripts
Available test commands in `package.json`:
```bash
# Run all tests
npm test # or: bun test
# Watch mode for development
npm run test:watch # or: bun test --watch
# Generate coverage report
npm run test:coverage # or: bun test --coverage
# Run linting
npm run lint
# Format code
npm run format
```
## Test Setup
### Global Configuration
The project uses a global test setup file (`src/__tests__/setup.ts`) that provides:
- Environment configuration
- Mock utilities
- Test helper functions
- Global test lifecycle hooks
### Test Environment
Tests run with the following configuration:
- Environment variables are loaded from `.env.test`
- Console output is suppressed during tests (unless DEBUG=true)
- JWT secrets and tokens are automatically configured for testing
- Rate limiting and other security features are properly initialized
## Running Tests
To run the test suite:
```bash
# Basic test run
bun test
# Run tests with coverage
bun test --coverage
# Run specific test file
bun test path/to/test.test.ts
# Run tests in watch mode
bun test --watch
# Run tests with debug output
DEBUG=true bun test
# Run tests with increased timeout
bun test --timeout 60000
# Run tests matching a pattern
bun test --pattern "auth"
```
### Test Environment Setup
1. **Prerequisites**:
- Bun >= 1.0.0
- Node.js dependencies (see package.json)
2. **Environment Files**:
- `.env.test` - Test environment variables
- `.env.development` - Development environment variables
3. **Test Data**:
- Mock responses in `__tests__/mock-responses/`
- Test fixtures in `__tests__/fixtures/`
### Continuous Integration
The project uses GitHub Actions for CI/CD. Tests are automatically run on:
- Pull requests
- Pushes to main branch
- Release tags
## Writing Tests
### Test File Naming
- Test files should be placed in a `__tests__` directory adjacent to the code being tested
- Test files should be named `*.test.ts`
- Test files should mirror the structure of the source code
### Test Structure
```typescript
import { describe, expect, it, beforeEach } from "bun:test";
describe("Module Name", () => {
beforeEach(() => {
// Setup for each test
});
describe("Feature/Function Name", () => {
it("should do something specific", () => {
// Test implementation
});
});
});
```
### Test Utilities
The project provides several test utilities:
```typescript
import { testUtils } from "../__tests__/setup";
// Available utilities:
- mockWebSocket() // Mock WebSocket for SSE tests
- mockResponse() // Mock HTTP response for API tests
- mockRequest() // Mock HTTP request for API tests
- createTestClient() // Create test SSE client
- createTestEvent() // Create test event
- createTestEntity() // Create test Home Assistant entity
- wait() // Helper to wait for async operations
```
## Testing Patterns
### Security Testing
Security tests cover:
- Token validation and encryption
- Rate limiting
- Request validation
- Input sanitization
- Error handling
Example:
```typescript
describe("Security Features", () => {
it("should validate tokens correctly", () => {
const payload = { userId: "123", role: "user" };
const token = jwt.sign(payload, validSecret, { expiresIn: "1h" });
const result = TokenManager.validateToken(token, testIp);
expect(result.valid).toBe(true);
});
});
```
### SSE Testing
SSE tests cover:
- Client authentication
- Message broadcasting
- Rate limiting
- Subscription management
- Client cleanup
Example:
```typescript
describe("SSE Features", () => {
it("should authenticate valid clients", () => {
const client = createTestClient("test-client");
const result = sseManager.addClient(client, validToken);
expect(result?.authenticated).toBe(true);
});
});
```
### Middleware Testing
Middleware tests cover:
- Request validation
- Input sanitization
- Error handling
- Response formatting
Example:
```typescript
describe("Middleware", () => {
it("should sanitize HTML in request body", () => {
const req = mockRequest({
body: { text: '<script>alert("xss")</script>' }
});
sanitizeInput(req, res, next);
expect(req.body.text).toBe("");
});
});
```
### Integration Testing
Integration tests in the root `__tests__` directory cover:
- **AI/ML Components**: Testing machine learning model integrations and predictions
- **API Integration**: End-to-end API route testing
- **Context Management**: Testing context persistence and state management
- **Home Assistant Integration**: Testing communication with Home Assistant
- **Schema Validation**: Testing data validation across the application
- **Security Integration**: Testing security features in a full system context
- **WebSocket Communication**: Testing real-time communication
- **Server Integration**: Testing the complete server setup and configuration
Example integration test:
```typescript
describe("API Integration", () => {
it("should handle a complete authentication flow", async () => {
// Setup test client
const client = await createTestClient();
// Test registration
const regResponse = await client.register(testUser);
expect(regResponse.status).toBe(201);
// Test authentication
const authResponse = await client.authenticate(testCredentials);
expect(authResponse.status).toBe(200);
expect(authResponse.body.token).toBeDefined();
// Test protected endpoint access
const protectedResponse = await client.get("/api/protected", {
headers: { Authorization: `Bearer ${authResponse.body.token}` }
});
expect(protectedResponse.status).toBe(200);
});
});
```
## Security Middleware Testing
### Utility Function Testing
The security middleware now uses a utility-first approach, which allows for more granular and comprehensive testing. Each security function is now independently testable, improving code reliability and maintainability.
#### Key Utility Functions
1. **Rate Limiting (`checkRateLimit`)**
- Tests multiple scenarios:
- Requests under threshold
- Requests exceeding threshold
- Rate limit reset after window expiration
```typescript
// Example test
it('should throw when requests exceed threshold', () => {
const ip = '127.0.0.2';
for (let i = 0; i < 11; i++) {
if (i < 10) {
expect(() => checkRateLimit(ip, 10)).not.toThrow();
} else {
expect(() => checkRateLimit(ip, 10)).toThrow('Too many requests from this IP');
}
}
});
```
2. **Request Validation (`validateRequestHeaders`)**
- Tests content type validation
- Checks request size limits
- Validates authorization headers
```typescript
it('should reject invalid content type', () => {
const mockRequest = new Request('http://localhost', {
method: 'POST',
headers: { 'content-type': 'text/plain' }
});
expect(() => validateRequestHeaders(mockRequest)).toThrow('Content-Type must be application/json');
});
```
3. **Input Sanitization (`sanitizeValue`)**
- Sanitizes HTML tags
- Handles nested objects
- Preserves non-string values
```typescript
it('should sanitize HTML tags', () => {
const input = '<script>alert("xss")</script>Hello';
const sanitized = sanitizeValue(input);
expect(sanitized).toBe('&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;Hello');
});
```
4. **Security Headers (`applySecurityHeaders`)**
- Verifies correct security header application
- Checks CSP, frame options, and other security headers
```typescript
it('should apply security headers', () => {
const mockRequest = new Request('http://localhost');
const headers = applySecurityHeaders(mockRequest);
expect(headers['content-security-policy']).toBeDefined();
expect(headers['x-frame-options']).toBeDefined();
});
```
5. **Error Handling (`handleError`)**
- Tests error responses in production and development modes
- Verifies error message and stack trace inclusion
```typescript
it('should include error details in development mode', () => {
const error = new Error('Test error');
const result = handleError(error, 'development');
expect(result).toEqual({
error: true,
message: 'Internal server error',
error: 'Test error',
stack: expect.any(String)
});
});
```
### Testing Philosophy
- **Isolation**: Each utility function is tested independently
- **Comprehensive Coverage**: Multiple scenarios for each function
- **Predictable Behavior**: Clear expectations for input and output
- **Error Handling**: Robust testing of error conditions
### Best Practices
1. Use minimal, focused test cases
2. Test both successful and failure scenarios
3. Verify input sanitization and security measures
4. Mock external dependencies when necessary
### Running Security Tests
```bash
# Run all tests
bun test
# Run specific security tests
bun test __tests__/security/
```
### Continuous Improvement
- Regularly update test cases
- Add new test scenarios as security requirements evolve
- Perform periodic security audits
## Best Practices
1. **Isolation**: Each test should be independent and not rely on the state of other tests.
2. **Mocking**: Use the provided mock utilities for external dependencies.
3. **Cleanup**: Clean up any resources or state modifications in `afterEach` or `afterAll` hooks.
4. **Descriptive Names**: Use clear, descriptive test names that explain the expected behavior.
5. **Assertions**: Make specific, meaningful assertions rather than general ones.
6. **Setup**: Use `beforeEach` for common test setup to avoid repetition.
7. **Error Cases**: Test both success and error cases for complete coverage.
## Coverage
The project aims for high test coverage, particularly focusing on:
- Security-critical code paths
- API endpoints
- Data validation
- Error handling
- Event broadcasting
Run coverage reports using:
```bash
bun test --coverage
```
## Debugging Tests
To debug tests:
1. Set `DEBUG=true` to enable console output during tests
2. Use the `--watch` flag for development
3. Add `console.log()` statements (they're only shown when DEBUG is true)
4. Use the test utilities' debugging helpers
### Advanced Debugging
1. **Using Node Inspector**:
```bash
# Start tests with inspector
bun test --inspect
# Start tests with inspector and break on first line
bun test --inspect-brk
```
2. **Using VS Code**:
```jsonc
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "bun",
"request": "launch",
"name": "Debug Tests",
"program": "${workspaceFolder}/node_modules/bun/bin/bun",
"args": ["test", "${file}"],
"cwd": "${workspaceFolder}",
"env": { "DEBUG": "true" }
}
]
}
```
3. **Test Isolation**:
To run a single test in isolation:
```typescript
describe.only("specific test suite", () => {
it.only("specific test case", () => {
// Only this test will run
});
});
```
## Contributing
When contributing new code:
1. Add tests for new features
2. Ensure existing tests pass
3. Maintain or improve coverage
4. Follow the existing test patterns and naming conventions
5. Document any new test utilities or patterns
## Coverage Requirements
The project maintains strict coverage requirements:
- Minimum overall coverage: 80%
- Critical paths (security, API, data validation): 90%
- New features must include tests with >= 85% coverage
Coverage reports are generated in multiple formats:
- Console summary
- HTML report (./coverage/index.html)
- LCOV report (./coverage/lcov.info)
To view detailed coverage:
```bash
# Generate and open coverage report
bun test --coverage && open coverage/index.html
```

354
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,354 @@
# Troubleshooting Guide
This guide helps you diagnose and fix common issues with the Home Assistant MCP.
## Common Issues
### Connection Issues
#### Cannot Connect to Home Assistant
**Symptoms:**
- Connection timeout errors
- "Failed to connect to Home Assistant" messages
- 401 Unauthorized errors
**Solutions:**
1. Verify Home Assistant is running
2. Check HASS_HOST environment variable
3. Validate HASS_TOKEN is correct
4. Ensure network connectivity
5. Check firewall settings
#### SSE Connection Drops
**Symptoms:**
- Frequent disconnections
- Missing events
- Connection reset errors
**Solutions:**
1. Check network stability
2. Increase connection timeout
3. Implement reconnection logic
4. Monitor server resources
### Authentication Issues
#### Invalid Token
**Symptoms:**
- 401 Unauthorized responses
- "Invalid token" messages
- Authentication failures
**Solutions:**
1. Generate new Long-Lived Access Token
2. Check token expiration
3. Verify token format
4. Update environment variables
#### Rate Limiting
**Symptoms:**
- 429 Too Many Requests
- "Rate limit exceeded" messages
**Solutions:**
1. Implement request throttling
2. Adjust rate limit settings
3. Cache responses
4. Optimize request patterns
### Tool Issues
#### Tool Not Found
**Symptoms:**
- "Tool not found" errors
- 404 Not Found responses
**Solutions:**
1. Check tool name spelling
2. Verify tool registration
3. Update tool imports
4. Check tool availability
#### Tool Execution Fails
**Symptoms:**
- Tool execution errors
- Unexpected responses
- Timeout issues
**Solutions:**
1. Validate input parameters
2. Check error logs
3. Debug tool implementation
4. Verify Home Assistant permissions
## Debugging
### Server Logs
1. Enable debug logging:
```env
LOG_LEVEL=debug
```
2. Check logs:
```bash
npm run logs
```
3. Filter logs:
```bash
npm run logs | grep "error"
```
### Network Debugging
1. Check API endpoints:
```bash
curl -v http://localhost:3000/api/health
```
2. Monitor SSE connections:
```bash
curl -N http://localhost:3000/api/sse/stats
```
3. Test WebSocket:
```bash
wscat -c ws://localhost:3000
```
### Performance Issues
1. Monitor memory usage:
```bash
npm run stats
```
2. Check response times:
```bash
curl -w "%{time_total}\n" -o /dev/null -s http://localhost:3000/api/health
```
3. Profile code:
```bash
npm run profile
```
## FAQ
### Q: How do I reset my configuration?
A: Delete `.env` and copy `.env.example` to start fresh.
### Q: Why are my events delayed?
A: Check network latency and server load. Consider adjusting buffer sizes.
### Q: How do I update my token?
A: Generate a new token in Home Assistant and update HASS_TOKEN.
### Q: Why do I get "Maximum clients reached"?
A: Adjust SSE_MAX_CLIENTS in configuration or clean up stale connections.
## Error Codes
- `E001`: Connection Error
- `E002`: Authentication Error
- `E003`: Rate Limit Error
- `E004`: Tool Error
- `E005`: Configuration Error
## Support Resources
1. Documentation
- [API Reference](./API.md)
- [Configuration Guide](./configuration/README.md)
- [Development Guide](./development/README.md)
2. Community
- GitHub Issues
- Discussion Forums
- Stack Overflow
3. Tools
- Diagnostic Scripts
- Testing Tools
- Monitoring Tools
## Still Need Help?
1. Create a detailed issue:
- Error messages
- Steps to reproduce
- Environment details
- Logs
2. Contact support:
- GitHub Issues
- Email Support
- Community Forums
## Security Middleware Troubleshooting
### Common Issues and Solutions
#### Rate Limiting Problems
**Symptom**: Unexpected 429 (Too Many Requests) errors
**Possible Causes**:
- Misconfigured rate limit settings
- Shared IP addresses (e.g., behind NAT)
- Aggressive client-side retry mechanisms
**Solutions**:
1. Adjust rate limit parameters
```typescript
// Customize rate limit for specific scenarios
checkRateLimit(ip, maxRequests = 200, windowMs = 30 * 60 * 1000)
```
2. Implement more granular rate limiting
- Use different limits for different endpoints
- Consider user authentication level
#### Request Validation Failures
**Symptom**: 400 or 415 status codes on valid requests
**Possible Causes**:
- Incorrect `Content-Type` header
- Large request payloads
- Malformed authorization headers
**Debugging Steps**:
1. Verify request headers
```typescript
// Check content type and size
validateRequestHeaders(request, 'application/json')
```
2. Log detailed validation errors
```typescript
try {
validateRequestHeaders(request);
} catch (error) {
console.error('Request validation failed:', error.message);
}
```
#### Input Sanitization Issues
**Symptom**: Unexpected data transformation or loss
**Possible Causes**:
- Complex nested objects
- Non-standard input formats
- Overly aggressive sanitization
**Troubleshooting**:
1. Test sanitization with various input types
```typescript
const input = {
text: '<script>alert("xss")</script>',
nested: { html: '<img src="x" onerror="alert(1)">World' }
};
const sanitized = sanitizeValue(input);
```
2. Custom sanitization for specific use cases
```typescript
function customSanitize(value) {
// Add custom sanitization logic
return sanitizeValue(value);
}
```
#### Security Header Configuration
**Symptom**: Missing or incorrect security headers
**Possible Causes**:
- Misconfigured Helmet options
- Environment-specific header requirements
**Solutions**:
1. Custom security header configuration
```typescript
const customHelmetConfig = {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'trusted-cdn.com']
}
}
};
applySecurityHeaders(request, customHelmetConfig);
```
#### Error Handling and Logging
**Symptom**: Inconsistent error responses
**Possible Causes**:
- Incorrect environment configuration
- Unhandled error types
**Debugging Techniques**:
1. Verify environment settings
```typescript
const errorResponse = handleError(error, process.env.NODE_ENV);
```
2. Add custom error handling
```typescript
function enhancedErrorHandler(error, env) {
// Add custom logging or monitoring
console.error('Security error:', error);
return handleError(error, env);
}
```
### Performance and Security Monitoring
1. **Logging**
- Enable debug logging for security events
- Monitor rate limit and validation logs
2. **Metrics**
- Track rate limit hit rates
- Monitor request validation success/failure ratios
3. **Continuous Improvement**
- Regularly review and update security configurations
- Conduct periodic security audits
### Environment-Specific Considerations
#### Development
- More verbose error messages
- Relaxed rate limiting
- Detailed security logs
#### Production
- Minimal error details
- Strict rate limiting
- Comprehensive security headers
### External Resources
- [OWASP Security Guidelines](https://owasp.org/www-project-top-ten/)
- [Helmet.js Documentation](https://helmetjs.github.io/)
- [JWT Security Best Practices](https://jwt.io/introduction)
### Getting Help
If you encounter persistent issues:
1. Check application logs
2. Verify environment configurations
3. Consult the project's issue tracker
4. Reach out to the development team with detailed error information

View File

@@ -7,6 +7,8 @@ This guide provides information for developers who want to contribute to or exte
```
homeassistant-mcp/
├── src/
│ ├── __tests__/ # Test files
│ ├── __mocks__/ # Mock files
│ ├── api/ # API endpoints and route handlers
│ ├── config/ # Configuration management
│ ├── hass/ # Home Assistant integration

View File

@@ -1,193 +0,0 @@
# Troubleshooting Guide
This guide helps you diagnose and fix common issues with the Home Assistant MCP.
## Common Issues
### Connection Issues
#### Cannot Connect to Home Assistant
**Symptoms:**
- Connection timeout errors
- "Failed to connect to Home Assistant" messages
- 401 Unauthorized errors
**Solutions:**
1. Verify Home Assistant is running
2. Check HASS_HOST environment variable
3. Validate HASS_TOKEN is correct
4. Ensure network connectivity
5. Check firewall settings
#### SSE Connection Drops
**Symptoms:**
- Frequent disconnections
- Missing events
- Connection reset errors
**Solutions:**
1. Check network stability
2. Increase connection timeout
3. Implement reconnection logic
4. Monitor server resources
### Authentication Issues
#### Invalid Token
**Symptoms:**
- 401 Unauthorized responses
- "Invalid token" messages
- Authentication failures
**Solutions:**
1. Generate new Long-Lived Access Token
2. Check token expiration
3. Verify token format
4. Update environment variables
#### Rate Limiting
**Symptoms:**
- 429 Too Many Requests
- "Rate limit exceeded" messages
**Solutions:**
1. Implement request throttling
2. Adjust rate limit settings
3. Cache responses
4. Optimize request patterns
### Tool Issues
#### Tool Not Found
**Symptoms:**
- "Tool not found" errors
- 404 Not Found responses
**Solutions:**
1. Check tool name spelling
2. Verify tool registration
3. Update tool imports
4. Check tool availability
#### Tool Execution Fails
**Symptoms:**
- Tool execution errors
- Unexpected responses
- Timeout issues
**Solutions:**
1. Validate input parameters
2. Check error logs
3. Debug tool implementation
4. Verify Home Assistant permissions
## Debugging
### Server Logs
1. Enable debug logging:
```env
LOG_LEVEL=debug
```
2. Check logs:
```bash
npm run logs
```
3. Filter logs:
```bash
npm run logs | grep "error"
```
### Network Debugging
1. Check API endpoints:
```bash
curl -v http://localhost:3000/api/health
```
2. Monitor SSE connections:
```bash
curl -N http://localhost:3000/api/sse/stats
```
3. Test WebSocket:
```bash
wscat -c ws://localhost:3000
```
### Performance Issues
1. Monitor memory usage:
```bash
npm run stats
```
2. Check response times:
```bash
curl -w "%{time_total}\n" -o /dev/null -s http://localhost:3000/api/health
```
3. Profile code:
```bash
npm run profile
```
## FAQ
### Q: How do I reset my configuration?
A: Delete `.env` and copy `.env.example` to start fresh.
### Q: Why are my events delayed?
A: Check network latency and server load. Consider adjusting buffer sizes.
### Q: How do I update my token?
A: Generate a new token in Home Assistant and update HASS_TOKEN.
### Q: Why do I get "Maximum clients reached"?
A: Adjust SSE_MAX_CLIENTS in configuration or clean up stale connections.
## Error Codes
- `E001`: Connection Error
- `E002`: Authentication Error
- `E003`: Rate Limit Error
- `E004`: Tool Error
- `E005`: Configuration Error
## Support Resources
1. Documentation
- [API Reference](./API.md)
- [Configuration Guide](./configuration/README.md)
- [Development Guide](./development/README.md)
2. Community
- GitHub Issues
- Discussion Forums
- Stack Overflow
3. Tools
- Diagnostic Scripts
- Testing Tools
- Monitoring Tools
## Still Need Help?
1. Create a detailed issue:
- Error messages
- Steps to reproduce
- Environment details
- Logs
2. Contact support:
- GitHub Issues
- Email Support
- Community Forums

View File

@@ -1,85 +0,0 @@
const path = require('path');
module.exports = (request, options) => {
// Handle chalk and related packages
if (request === 'chalk' || request === '#ansi-styles' || request === '#supports-color') {
return path.resolve(__dirname, 'node_modules', request.replace('#', ''));
}
// Handle source files with .js extension
if (request.endsWith('.js')) {
const tsRequest = request.replace(/\.js$/, '.ts');
try {
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) {
// 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);
}
}
}
}
// Handle @digital-alchemy packages
if (request.startsWith('@digital-alchemy/')) {
try {
const packagePath = path.resolve(__dirname, 'node_modules', request);
return options.defaultResolver(packagePath, {
...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) {
// If resolution fails, continue with default resolver
}
}
// Call the default resolver with enhanced module resolution
return options.defaultResolver(request, {
...options,
// Handle ESM modules
packageFilter: pkg => {
if (pkg.type === 'module') {
if (pkg.exports) {
if (pkg.exports.import) {
pkg.main = pkg.exports.import;
} else if (typeof pkg.exports === 'string') {
pkg.main = pkg.exports;
}
} else if (pkg.module) {
pkg.main = pkg.module;
}
}
return pkg;
},
extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'],
paths: [...(options.paths || []), path.resolve(__dirname, 'src')]
});
};

View File

@@ -1,37 +0,0 @@
import type { JestConfigWithTsJest } from 'ts-jest';
const config: JestConfigWithTsJest = {
preset: 'ts-jest',
testEnvironment: 'node',
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: 'tsconfig.json',
},
],
},
testMatch: ['**/__tests__/**/*.test.ts'],
verbose: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true,
testTimeout: 30000,
maxWorkers: '50%',
collectCoverage: true,
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov'],
globals: {
'ts-jest': {
useESM: true,
isolatedModules: true,
},
},
};
export default config;

View File

@@ -1,87 +0,0 @@
import { jest } from '@jest/globals';
import dotenv from 'dotenv';
import { TextEncoder, TextDecoder } from 'util';
// Load test environment variables
dotenv.config({ path: '.env.test' });
// Set test environment
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_TOKEN = 'test-token';
process.env.CLAUDE_API_KEY = 'test_api_key';
process.env.CLAUDE_MODEL = 'test_model';
// Add TextEncoder and TextDecoder to global scope
Object.defineProperty(global, 'TextEncoder', {
value: TextEncoder,
writable: true
});
Object.defineProperty(global, 'TextDecoder', {
value: TextDecoder,
writable: true
});
// Configure console for tests
const originalConsole = { ...console };
global.console = {
...console,
log: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};
// Increase test timeout
jest.setTimeout(30000);
// Mock WebSocket
jest.mock('ws', () => {
return {
WebSocket: jest.fn().mockImplementation(() => ({
on: jest.fn(),
send: jest.fn(),
close: jest.fn(),
removeAllListeners: jest.fn()
}))
};
});
// Mock chalk
const createChalkMock = () => {
const handler = {
get(target: any, prop: string) {
if (prop === 'default') {
return createChalkMock();
}
return typeof prop === 'string' ? createChalkMock() : target[prop];
},
apply(target: any, thisArg: any, args: any[]) {
return args[0];
}
};
return new Proxy(() => { }, handler);
};
jest.mock('chalk', () => createChalkMock());
// Mock ansi-styles
jest.mock('ansi-styles', () => ({}), { virtual: true });
// Mock supports-color
jest.mock('supports-color', () => ({}), { virtual: true });
// Reset mocks between tests
beforeEach(() => {
jest.clearAllMocks();
});
// Cleanup after tests
afterEach(() => {
jest.clearAllTimers();
jest.clearAllMocks();
});

View File

@@ -16,18 +16,14 @@
"prepare": "husky install"
},
"dependencies": {
"@digital-alchemy/core": "^25.1.3",
"@digital-alchemy/hass": "^25.1.1",
"@jest/globals": "^29.7.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@elysiajs/cors": "^1.2.0",
"@elysiajs/swagger": "^1.2.0",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.11.24",
"@types/sanitize-html": "^2.9.5",
"@types/ws": "^8.5.10",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"elysia": "^1.2.11",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"node-fetch": "^3.3.2",

View File

@@ -1,6 +1,5 @@
import { config } from "dotenv";
import path from "path";
import { TEST_CONFIG } from "../config/__tests__/test.config";
import {
beforeAll,
afterAll,
@@ -12,6 +11,25 @@ import {
test,
} from "bun:test";
// Type definitions for mocks
type MockFn = ReturnType<typeof mock>;
interface MockInstance {
mock: {
calls: unknown[][];
results: unknown[];
instances: unknown[];
lastCall?: unknown[];
};
}
// Test configuration
const TEST_CONFIG = {
TEST_JWT_SECRET: "test_jwt_secret_key_that_is_at_least_32_chars",
TEST_TOKEN: "test_token_that_is_at_least_32_chars_long",
TEST_CLIENT_IP: "127.0.0.1",
};
// Load test environment variables
config({ path: path.resolve(process.cwd(), ".env.test") });
@@ -23,34 +41,10 @@ beforeAll(() => {
process.env.TEST_TOKEN = TEST_CONFIG.TEST_TOKEN;
// Configure console output for tests
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
const originalConsoleLog = console.log;
// Suppress console output during tests unless explicitly enabled
if (!process.env.DEBUG) {
console.error = mock(() => {});
console.warn = mock(() => {});
console.log = mock(() => {});
}
// Store original console methods for cleanup
(global as any).__ORIGINAL_CONSOLE__ = {
error: originalConsoleError,
warn: originalConsoleWarn,
log: originalConsoleLog,
};
});
// Global test teardown
afterAll(() => {
// Restore original console methods
const originalConsole = (global as any).__ORIGINAL_CONSOLE__;
if (originalConsole) {
console.error = originalConsole.error;
console.warn = originalConsole.warn;
console.log = originalConsole.log;
delete (global as any).__ORIGINAL_CONSOLE__;
console.error = mock(() => { });
console.warn = mock(() => { });
console.log = mock(() => { });
}
});
@@ -58,7 +52,7 @@ afterAll(() => {
beforeEach(() => {
// Clear all mock function calls
const mockFns = Object.values(mock).filter(
(value) => typeof value === "function",
(value): value is MockFn => typeof value === "function" && "mock" in value,
);
mockFns.forEach((mockFn) => {
if (mockFn.mock) {
@@ -70,100 +64,80 @@ beforeEach(() => {
});
});
// Custom test environment setup
const setupTestEnvironment = () => {
return {
// Mock WebSocket for SSE tests
mockWebSocket: () => {
const mockWs = {
on: mock(() => {}),
send: mock(() => {}),
close: mock(() => {}),
};
return mockWs;
// Custom test utilities
const testUtils = {
// Mock WebSocket for SSE tests
mockWebSocket: () => ({
on: mock(() => { }),
send: mock(() => { }),
close: mock(() => { }),
readyState: 1,
OPEN: 1,
removeAllListeners: mock(() => { }),
}),
// Mock HTTP response for API tests
mockResponse: () => {
const res = {
status: mock(() => res),
json: mock(() => res),
send: mock(() => res),
end: mock(() => res),
setHeader: mock(() => res),
writeHead: mock(() => res),
write: mock(() => true),
removeHeader: mock(() => res),
};
return res;
},
// Mock HTTP request for API tests
mockRequest: (overrides: Record<string, unknown> = {}) => ({
headers: { "content-type": "application/json" },
body: {},
query: {},
params: {},
ip: TEST_CONFIG.TEST_CLIENT_IP,
method: "GET",
path: "/api/test",
is: mock((type: string) => type === "application/json"),
...overrides,
}),
// Create test client for SSE tests
createTestClient: (id = "test-client") => ({
id,
ip: TEST_CONFIG.TEST_CLIENT_IP,
connectedAt: new Date(),
send: mock(() => { }),
rateLimit: {
count: 0,
lastReset: Date.now(),
},
connectionTime: Date.now(),
}),
// Mock HTTP response for API tests
mockResponse: () => {
const res: any = {};
res.status = mock(() => res);
res.json = mock(() => res);
res.send = mock(() => res);
res.end = mock(() => res);
res.setHeader = mock(() => res);
res.writeHead = mock(() => res);
res.write = mock(() => true);
res.removeHeader = mock(() => res);
return res;
},
// Create test event for SSE tests
createTestEvent: (type = "test_event", data: unknown = {}) => ({
event_type: type,
data,
origin: "test",
time_fired: new Date().toISOString(),
context: { id: "test" },
}),
// Mock HTTP request for API tests
mockRequest: (overrides = {}) => {
return {
headers: { "content-type": "application/json" },
body: {},
query: {},
params: {},
ip: TEST_CONFIG.TEST_CLIENT_IP,
method: "GET",
path: "/api/test",
is: mock((type: string) => type === "application/json"),
...overrides,
};
},
// Create test entity for Home Assistant tests
createTestEntity: (entityId = "test.entity", state = "on") => ({
entity_id: entityId,
state,
attributes: {},
last_changed: new Date().toISOString(),
last_updated: new Date().toISOString(),
}),
// Create test client for SSE tests
createTestClient: (id: string = "test-client") => ({
id,
ip: TEST_CONFIG.TEST_CLIENT_IP,
connectedAt: new Date(),
send: mock(() => {}),
rateLimit: {
count: 0,
lastReset: Date.now(),
},
connectionTime: Date.now(),
}),
// Create test event for SSE tests
createTestEvent: (type: string = "test_event", data: any = {}) => ({
event_type: type,
data,
origin: "test",
time_fired: new Date().toISOString(),
context: { id: "test" },
}),
// Create test entity for Home Assistant tests
createTestEntity: (
entityId: string = "test.entity",
state: string = "on",
) => ({
entity_id: entityId,
state,
attributes: {},
last_changed: new Date().toISOString(),
last_updated: new Date().toISOString(),
}),
// Helper to wait for async operations
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
};
// Helper to wait for async operations
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
};
// Export test utilities
export const testUtils = setupTestEnvironment();
// Export Bun test utilities
export { beforeAll, afterAll, beforeEach, describe, expect, it, mock, test };
// Make test utilities available globally
(global as any).testUtils = testUtils;
(global as any).describe = describe;
(global as any).it = it;
(global as any).test = test;
(global as any).expect = expect;
(global as any).beforeAll = beforeAll;
(global as any).afterAll = afterAll;
(global as any).beforeEach = beforeEach;
(global as any).mock = mock;
// Export test utilities and Bun test functions
export { beforeAll, afterAll, beforeEach, describe, expect, it, mock, test, testUtils };

View File

@@ -1,34 +1,90 @@
import { CreateApplication } from "@digital-alchemy/core";
import { LIB_HASS } from "@digital-alchemy/hass";
import type { HassEntity } from "../interfaces/hass.js";
// Create the application following the documentation example
const app = CreateApplication({
libraries: [LIB_HASS],
name: "home_automation",
configuration: {
hass: {
BASE_URL: {
type: "string" as const,
default: process.env.HASS_HOST || "http://localhost:8123",
description: "Home Assistant URL",
},
TOKEN: {
type: "string" as const,
default: process.env.HASS_TOKEN || "",
description: "Home Assistant long-lived access token",
},
},
},
});
class HomeAssistantAPI {
private baseUrl: string;
private token: string;
let instance: Awaited<ReturnType<typeof app.bootstrap>>;
constructor() {
this.baseUrl = process.env.HASS_HOST || "http://localhost:8123";
this.token = process.env.HASS_TOKEN || "";
if (!this.token || this.token === "your_hass_token_here") {
throw new Error("HASS_TOKEN is required but not set in environment variables");
}
console.log(`Initializing Home Assistant API with base URL: ${this.baseUrl}`);
}
private async fetchApi(endpoint: string, options: RequestInit = {}) {
const url = `${this.baseUrl}/api/${endpoint}`;
console.log(`Making request to: ${url}`);
console.log('Request options:', {
method: options.method || 'GET',
headers: {
Authorization: 'Bearer [REDACTED]',
"Content-Type": "application/json",
...options.headers,
},
body: options.body ? JSON.parse(options.body as string) : undefined
});
try {
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('Home Assistant API error:', {
status: response.status,
statusText: response.statusText,
error: errorText
});
throw new Error(`Home Assistant API error: ${response.status} ${response.statusText} - ${errorText}`);
}
const data = await response.json();
console.log('Response data:', data);
return data;
} catch (error) {
console.error('Failed to make request:', error);
throw error;
}
}
async getStates(): Promise<HassEntity[]> {
return this.fetchApi("states");
}
async getState(entityId: string): Promise<HassEntity> {
return this.fetchApi(`states/${entityId}`);
}
async callService(domain: string, service: string, data: Record<string, any>): Promise<void> {
await this.fetchApi(`services/${domain}/${service}`, {
method: "POST",
body: JSON.stringify(data),
});
}
}
let instance: HomeAssistantAPI | null = null;
export async function get_hass() {
if (!instance) {
try {
instance = await app.bootstrap();
instance = new HomeAssistantAPI();
// Verify connection by trying to get states
await instance.getStates();
console.log('Successfully connected to Home Assistant');
} catch (error) {
console.error("Failed to initialize Home Assistant:", error);
console.error('Failed to initialize Home Assistant connection:', error);
instance = null;
throw error;
}
}
@@ -42,23 +98,28 @@ export async function call_service(
data: Record<string, any>,
) {
const hass = await get_hass();
return hass.hass.internals.callService(domain, service, data);
return hass.callService(domain, service, data);
}
// Helper function to list devices
export async function list_devices() {
const hass = await get_hass();
return hass.hass.device.list();
const states = await hass.getStates();
return states.map((state: HassEntity) => ({
entity_id: state.entity_id,
state: state.state,
attributes: state.attributes
}));
}
// Helper function to get entity states
export async function get_states() {
const hass = await get_hass();
return hass.hass.internals.getStates();
return hass.getStates();
}
// Helper function to get a specific entity state
export async function get_state(entity_id: string) {
const hass = await get_hass();
return hass.hass.internals.getState(entity_id);
return hass.getState(entity_id);
}

View File

@@ -1,7 +1,9 @@
import "./polyfills.js";
import { config } from "dotenv";
import { resolve } from "path";
import express from "express";
import { Elysia } from "elysia";
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import {
rateLimiter,
securityHeaders,
@@ -41,25 +43,6 @@ const PORT = parseInt(process.env.PORT || "4000", 10);
console.log("Initializing Home Assistant connection...");
// Initialize Express app
const app = express();
// Apply security middleware
app.use(securityHeaders);
app.use(rateLimiter);
app.use(express.json());
app.use(validateRequest);
app.use(sanitizeInput);
// Health check endpoint
app.get("/health", (req, res) => {
res.json({
status: "ok",
timestamp: new Date().toISOString(),
version: "0.1.0",
});
});
// Define Tool interface
interface Tool {
name: string;
@@ -131,35 +114,38 @@ const controlTool: Tool = {
// Add the control tool to the array
tools.push(controlTool);
// Initialize Elysia app with middleware
const app = new Elysia()
.use(cors())
.use(swagger())
.use(rateLimiter)
.use(securityHeaders)
.use(validateRequest)
.use(sanitizeInput)
.use(errorHandler);
// Health check endpoint
app.get("/health", () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: "0.1.0",
}));
// Create API endpoints for each tool
tools.forEach((tool) => {
app.post(`/api/tools/${tool.name}`, async (req, res) => {
try {
const result = await tool.execute(req.body);
res.json(result);
} catch (error) {
res.status(500).json({
success: false,
message:
error instanceof Error ? error.message : "Unknown error occurred",
});
}
app.post(`/api/tools/${tool.name}`, async ({ body }: { body: Record<string, unknown> }) => {
const result = await tool.execute(body);
return result;
});
});
// Error handling middleware
app.use(errorHandler);
// Start the server
const server = app.listen(PORT, () => {
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
// Handle server shutdown
process.on("SIGTERM", () => {
console.log("Received SIGTERM. Shutting down gracefully...");
void server.close(() => {
console.log("Server closed");
process.exit(0);
});
process.exit(0);
});

View File

@@ -1,150 +1,118 @@
import { TokenManager } from "../index";
import { SECURITY_CONFIG } from "../../config/security.config";
import { describe, expect, it, beforeEach } from "bun:test";
import { TokenManager } from "../index.js";
import jwt from "jsonwebtoken";
import { jest } from "@jest/globals";
describe("TokenManager", () => {
const validSecret = "test_secret_key_that_is_at_least_32_chars_long";
const testIp = "127.0.0.1";
const validSecret = "test-secret-key-that-is-at-least-32-chars";
const validToken = "valid-token-that-is-at-least-32-characters-long";
const testIp = "127.0.0.1";
describe("Security Module", () => {
beforeEach(() => {
process.env.JWT_SECRET = validSecret;
jest.clearAllMocks();
// Clear any existing rate limit data
(TokenManager as any).failedAttempts = new Map();
});
afterEach(() => {
delete process.env.JWT_SECRET;
});
describe("TokenManager", () => {
it("should encrypt and decrypt tokens", () => {
const encrypted = TokenManager.encryptToken(validToken, validSecret);
expect(encrypted).toBeDefined();
expect(typeof encrypted).toBe("string");
expect(encrypted === validToken).toBe(false);
describe("Token Validation", () => {
it("should validate a properly formatted token", () => {
const decrypted = TokenManager.decryptToken(encrypted, validSecret);
expect(decrypted).toBe(validToken);
});
it("should validate tokens correctly", () => {
const payload = { userId: "123", role: "user" };
const token = jwt.sign(payload, validSecret);
const token = jwt.sign(payload, validSecret, { expiresIn: "1h" });
expect(token).toBeDefined();
const result = TokenManager.validateToken(token, testIp);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should reject an invalid token", () => {
const result = TokenManager.validateToken("invalid_token", testIp);
it("should handle empty tokens", () => {
const result = TokenManager.validateToken("", testIp);
expect(result.valid).toBe(false);
expect(result.error).toBe("Token length below minimum requirement");
expect(result.error).toBe("Invalid token format");
});
it("should reject a token that is too short", () => {
const result = TokenManager.validateToken("short", testIp);
expect(result.valid).toBe(false);
expect(result.error).toBe("Token length below minimum requirement");
});
it("should reject an expired token", () => {
it("should handle expired tokens", () => {
const now = Math.floor(Date.now() / 1000);
const payload = {
userId: "123",
role: "user",
iat: now - 7200, // 2 hours ago
exp: now - 3600, // expired 1 hour ago
iat: now - 3600, // issued 1 hour ago
exp: now - 1800 // expired 30 minutes ago
};
const token = jwt.sign(payload, validSecret);
const result = TokenManager.validateToken(token, testIp);
expect(result.valid).toBe(false);
expect(result.error).toBe("Token has expired");
});
it("should implement rate limiting for failed attempts", async () => {
// Simulate multiple failed attempts
for (let i = 0; i < SECURITY_CONFIG.MAX_FAILED_ATTEMPTS; i++) {
const result = TokenManager.validateToken("invalid_token", testIp);
expect(result.valid).toBe(false);
}
// Next attempt should be blocked by rate limiting
const result = TokenManager.validateToken("invalid_token", testIp);
expect(result.valid).toBe(false);
expect(result.error).toBe(
"Too many failed attempts. Please try again later.",
);
// Wait for rate limit to expire
await new Promise((resolve) =>
setTimeout(resolve, SECURITY_CONFIG.LOCKOUT_DURATION + 100),
);
// Should be able to try again
const validPayload = { userId: "123", role: "user" };
const validToken = jwt.sign(validPayload, validSecret);
const finalResult = TokenManager.validateToken(validToken, testIp);
expect(finalResult.valid).toBe(true);
});
});
describe("Token Generation", () => {
it("should generate a valid JWT token", () => {
describe("Request Validation", () => {
it("should validate requests with valid tokens", () => {
const payload = { userId: "123", role: "user" };
const token = TokenManager.generateToken(payload);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
// Verify the token can be decoded
const decoded = jwt.verify(token, validSecret) as any;
expect(decoded.userId).toBe(payload.userId);
expect(decoded.role).toBe(payload.role);
const token = jwt.sign(payload, validSecret, { expiresIn: "1h" });
const result = TokenManager.validateToken(token, testIp);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
it("should include required claims in generated tokens", () => {
const payload = { userId: "123" };
const token = TokenManager.generateToken(payload);
const decoded = jwt.verify(token, validSecret) as any;
expect(decoded.iat).toBeDefined();
expect(decoded.exp).toBeDefined();
expect(decoded.exp - decoded.iat).toBe(
Math.floor(24 * 60 * 60), // 24 hours in seconds
);
});
it("should throw error when JWT secret is not configured", () => {
delete process.env.JWT_SECRET;
const payload = { userId: "123" };
expect(() => TokenManager.generateToken(payload)).toThrow(
"JWT secret not configured",
);
it("should reject invalid tokens", () => {
const result = TokenManager.validateToken("invalid-token", testIp);
expect(result.valid).toBe(false);
expect(result.error).toBe("Token length below minimum requirement");
});
});
describe("Token Encryption", () => {
const encryptionKey = "encryption_key_that_is_at_least_32_chars_long";
it("should encrypt and decrypt a token successfully", () => {
const originalToken = "test_token_to_encrypt";
const encrypted = TokenManager.encryptToken(originalToken, encryptionKey);
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
expect(decrypted).toBe(originalToken);
describe("Error Handling", () => {
it("should handle missing JWT secret", () => {
delete process.env.JWT_SECRET;
const payload = { userId: "123", role: "user" };
const result = TokenManager.validateToken(jwt.sign(payload, "some-secret"), testIp);
expect(result.valid).toBe(false);
expect(result.error).toBe("JWT secret not configured");
});
it("should throw error for invalid encryption inputs", () => {
expect(() => TokenManager.encryptToken("", encryptionKey)).toThrow(
"Invalid token",
);
expect(() => TokenManager.encryptToken("valid_token", "")).toThrow(
"Invalid encryption key",
);
it("should handle invalid token format", () => {
const result = TokenManager.validateToken("not-a-jwt-token", testIp);
expect(result.valid).toBe(false);
expect(result.error).toBe("Token length below minimum requirement");
});
it("should throw error for invalid decryption inputs", () => {
expect(() => TokenManager.decryptToken("", encryptionKey)).toThrow(
"Invalid encrypted token",
);
expect(() =>
TokenManager.decryptToken("invalid:format", encryptionKey),
).toThrow("Invalid encrypted token format");
it("should handle encryption errors", () => {
expect(() => TokenManager.encryptToken("", validSecret)).toThrow("Invalid token");
expect(() => TokenManager.encryptToken(validToken, "short-key")).toThrow("Invalid encryption key");
});
it("should generate different ciphertexts for same plaintext", () => {
const token = "test_token";
const encrypted1 = TokenManager.encryptToken(token, encryptionKey);
const encrypted2 = TokenManager.encryptToken(token, encryptionKey);
expect(encrypted1).not.toBe(encrypted2);
it("should handle decryption errors", () => {
expect(() => TokenManager.decryptToken("invalid:format", validSecret)).toThrow();
expect(() => TokenManager.decryptToken("aes-256-gcm:invalid:base64:data", validSecret)).toThrow();
});
});
describe("Rate Limiting", () => {
it("should implement rate limiting for failed attempts", () => {
// Create an invalid token that's long enough to pass length check
const invalidToken = "x".repeat(64); // Long enough to pass MIN_TOKEN_LENGTH check
// First attempt should fail with token validation error and record the attempt
const firstResult = TokenManager.validateToken(invalidToken, testIp);
expect(firstResult.valid).toBe(false);
expect(firstResult.error).toBe("Too many failed attempts. Please try again later.");
// Verify that even a valid token is blocked during rate limiting
const validPayload = { userId: "123", role: "user" };
const validToken = jwt.sign(validPayload, validSecret, { expiresIn: "1h" });
const validResult = TokenManager.validateToken(validToken, testIp);
expect(validResult.valid).toBe(false);
expect(validResult.error).toBe("Too many failed attempts. Please try again later.");
});
});
});

View File

@@ -1,47 +1,191 @@
import crypto from "crypto";
import { Request, Response, NextFunction } from "express";
import rateLimit from "express-rate-limit";
import helmet from "helmet";
import { HelmetOptions } from "helmet";
import jwt from "jsonwebtoken";
import { Elysia, type Context } from "elysia";
// Security configuration
const RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
const RATE_LIMIT_MAX = 100; // requests per window
const TOKEN_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
// Rate limiting middleware
export const rateLimiter = rateLimit({
windowMs: RATE_LIMIT_WINDOW,
max: RATE_LIMIT_MAX,
message: "Too many requests from this IP, please try again later",
// Rate limiting state
const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
interface RequestContext {
request: Request;
set: Context['set'];
}
// Extracted rate limiting logic
export function checkRateLimit(ip: string, maxRequests: number = RATE_LIMIT_MAX, windowMs: number = RATE_LIMIT_WINDOW) {
const now = Date.now();
const record = rateLimitStore.get(ip) || {
count: 0,
resetTime: now + windowMs,
};
if (now > record.resetTime) {
record.count = 0;
record.resetTime = now + windowMs;
}
record.count++;
rateLimitStore.set(ip, record);
if (record.count > maxRequests) {
throw new Error("Too many requests from this IP, please try again later");
}
return true;
}
// Rate limiting middleware for Elysia
export const rateLimiter = new Elysia().derive(({ request }: RequestContext) => {
const ip = request.headers.get("x-forwarded-for") || "unknown";
checkRateLimit(ip);
});
// Security configuration
const helmetConfig: HelmetOptions = {
contentSecurityPolicy: {
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "wss:", "https:"],
// Extracted security headers logic
export function applySecurityHeaders(request: Request, helmetConfig?: HelmetOptions) {
const config: HelmetOptions = helmetConfig || {
contentSecurityPolicy: {
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "wss:", "https:"],
},
},
},
dnsPrefetchControl: true,
frameguard: true,
hidePoweredBy: true,
hsts: true,
ieNoOpen: true,
noSniff: true,
referrerPolicy: {
policy: ["no-referrer", "strict-origin-when-cross-origin"],
},
};
dnsPrefetchControl: true,
frameguard: true,
hidePoweredBy: true,
hsts: true,
ieNoOpen: true,
noSniff: true,
referrerPolicy: {
policy: ["no-referrer", "strict-origin-when-cross-origin"],
},
};
// Security headers middleware
export const securityHeaders = helmet(helmetConfig);
const headers = helmet(config);
// Apply helmet headers to the request
Object.entries(headers).forEach(([key, value]) => {
if (typeof value === 'string') {
request.headers.set(key, value);
}
});
return headers;
}
// Security headers middleware for Elysia
export const securityHeaders = new Elysia().derive(({ request }: RequestContext) => {
applySecurityHeaders(request);
});
// Extracted request validation logic
export function validateRequestHeaders(request: Request, requiredContentType = 'application/json') {
// Validate content type for POST/PUT/PATCH requests
if (["POST", "PUT", "PATCH"].includes(request.method)) {
const contentType = request.headers.get("content-type");
if (!contentType?.includes(requiredContentType)) {
throw new Error(`Content-Type must be ${requiredContentType}`);
}
}
// Validate request size
const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength) > 1024 * 1024) {
throw new Error("Request body too large");
}
// Validate authorization header if required
const authHeader = request.headers.get("authorization");
if (authHeader) {
const [type, token] = authHeader.split(" ");
if (type !== "Bearer" || !token) {
throw new Error("Invalid authorization header");
}
const ip = request.headers.get("x-forwarded-for");
const validation = TokenManager.validateToken(token, ip || undefined);
if (!validation.valid) {
throw new Error(validation.error || "Invalid token");
}
}
return true;
}
// Request validation middleware for Elysia
export const validateRequest = new Elysia().derive(({ request }: RequestContext) => {
validateRequestHeaders(request);
});
// Extracted input sanitization logic
export function sanitizeValue(value: unknown): unknown {
if (typeof value === "string") {
// Basic XSS protection
return value
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")
.replace(/\//g, "&#x2F;");
}
if (Array.isArray(value)) {
return value.map(sanitizeValue);
}
if (typeof value === "object" && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, sanitizeValue(v)])
);
}
return value;
}
// Input sanitization middleware for Elysia
export const sanitizeInput = new Elysia().derive(async ({ request }: RequestContext) => {
if (["POST", "PUT", "PATCH"].includes(request.method)) {
const body = await request.json();
request.json = () => Promise.resolve(sanitizeValue(body));
}
});
// Extracted error handling logic
export function handleError(error: Error, env: string = process.env.NODE_ENV || 'production') {
console.error("Error:", error);
const baseResponse = {
error: true,
message: "Internal server error",
timestamp: new Date().toISOString(),
};
if (env === 'development') {
return {
...baseResponse,
error: error.message,
stack: error.stack,
};
}
return baseResponse;
}
// Error handling middleware for Elysia
export const errorHandler = new Elysia().onError(({ error, set }: { error: Error; set: Context['set'] }) => {
set.status = error instanceof jwt.JsonWebTokenError ? 401 : 500;
return handleError(error);
});
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 16;
@@ -275,137 +419,3 @@ export class TokenManager {
});
}
}
// Request validation middleware
export function validateRequest(
req: Request,
res: Response,
next: NextFunction,
): Response | void {
// Skip validation for health and MCP schema endpoints
if (req.path === "/health" || req.path === "/mcp") {
return next();
}
// Validate content type for non-GET requests
if (["POST", "PUT", "PATCH"].includes(req.method)) {
const contentType = req.headers["content-type"] || "";
if (!contentType.toLowerCase().includes("application/json")) {
return res.status(415).json({
success: false,
message: "Unsupported Media Type",
error: "Content-Type must be application/json",
timestamp: new Date().toISOString(),
});
}
}
// Validate authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({
success: false,
message: "Unauthorized",
error: "Missing or invalid authorization header",
timestamp: new Date().toISOString(),
});
}
// Validate token
const token = authHeader.replace("Bearer ", "");
const validationResult = TokenManager.validateToken(token, req.ip);
if (!validationResult.valid) {
return res.status(401).json({
success: false,
message: "Unauthorized",
error: validationResult.error || "Invalid token",
timestamp: new Date().toISOString(),
});
}
// Validate request body for non-GET requests
if (["POST", "PUT", "PATCH"].includes(req.method)) {
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
return res.status(400).json({
success: false,
message: "Bad Request",
error: "Invalid request body structure",
timestamp: new Date().toISOString(),
});
}
// Check request body size
const contentLength = parseInt(req.headers["content-length"] || "0", 10);
const maxSize = 1024 * 1024; // 1MB limit
if (contentLength > maxSize) {
return res.status(413).json({
success: false,
message: "Payload Too Large",
error: `Request body must not exceed ${maxSize} bytes`,
timestamp: new Date().toISOString(),
});
}
}
next();
}
// Input sanitization middleware
export function sanitizeInput(req: Request, res: Response, next: NextFunction) {
if (!req.body) {
return next();
}
function sanitizeValue(value: unknown): unknown {
if (typeof value === "string") {
// Remove HTML tags and scripts more thoroughly
return value
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "") // Remove script tags and content
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "") // Remove style tags and content
.replace(/<[^>]+>/g, "") // Remove remaining HTML tags
.replace(/javascript:/gi, "") // Remove javascript: protocol
.replace(/on\w+\s*=\s*(?:".*?"|'.*?'|[^"'>\s]+)/gi, "") // Remove event handlers
.trim();
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item));
}
if (typeof value === "object" && value !== null) {
const sanitized: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
sanitized[key] = sanitizeValue(val);
}
return sanitized;
}
return value;
}
req.body = sanitizeValue(req.body);
next();
}
// Error handling middleware
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction,
) {
console.error(err.stack);
res.status(500).json({
error: "Internal Server Error",
message: process.env.NODE_ENV === "development" ? err.message : undefined,
});
}
// Export security middleware chain
export const securityMiddleware = [
helmet(helmetConfig),
rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
}),
validateRequest,
sanitizeInput,
errorHandler,
];