chore: migrate project to Bun testing framework and update configuration
- Replace Jest with Bun's native testing framework - Update test configuration and utilities to support Bun test environment - Add mock implementations for SSE and security testing - Refactor test setup to use Bun's testing utilities - Update package dependencies and scripts to align with Bun testing - Enhance type definitions for Bun test mocking
This commit is contained in:
23
bunfig.toml
Normal file
23
bunfig.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[test]
|
||||||
|
preload = ["./src/__tests__/setup.ts"]
|
||||||
|
coverage = true
|
||||||
|
timeout = 30000
|
||||||
|
testMatch = ["**/__tests__/**/*.test.ts"]
|
||||||
|
|
||||||
|
[build]
|
||||||
|
target = "node"
|
||||||
|
outdir = "./dist"
|
||||||
|
minify = true
|
||||||
|
sourcemap = "external"
|
||||||
|
|
||||||
|
[install]
|
||||||
|
production = false
|
||||||
|
frozen = true
|
||||||
|
peer = false
|
||||||
|
|
||||||
|
[install.cache]
|
||||||
|
dir = ".bun"
|
||||||
|
disable = false
|
||||||
|
|
||||||
|
[debug]
|
||||||
|
port = 9229
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/** @type {import('bun:test').BunTestConfig} */
|
|
||||||
module.exports = {
|
|
||||||
testEnvironment: 'node',
|
|
||||||
moduleFileExtensions: ['ts', 'js', 'json', 'node'],
|
|
||||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
|
||||||
collectCoverage: true,
|
|
||||||
coverageDirectory: 'coverage',
|
|
||||||
coverageThreshold: {
|
|
||||||
global: {
|
|
||||||
statements: 50,
|
|
||||||
branches: 50,
|
|
||||||
functions: 50,
|
|
||||||
lines: 50
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setupFilesAfterEnv: ['./jest.setup.ts']
|
|
||||||
};
|
|
||||||
@@ -1,76 +1,37 @@
|
|||||||
import type { Config } from '@jest/types';
|
import type { JestConfigWithTsJest } from 'ts-jest';
|
||||||
|
|
||||||
const config: Config.InitialOptions = {
|
const config: JestConfigWithTsJest = {
|
||||||
preset: 'ts-jest',
|
preset: 'ts-jest',
|
||||||
testEnvironment: 'node',
|
testEnvironment: 'node',
|
||||||
roots: ['<rootDir>/src'],
|
extensionsToTreatAsEsm: ['.ts'],
|
||||||
testMatch: [
|
|
||||||
'**/__tests__/**/*.+(ts|tsx|js)',
|
|
||||||
'**/?(*.)+(spec|test).+(ts|tsx|js)'
|
|
||||||
],
|
|
||||||
transform: {
|
|
||||||
'^.+\\.(ts|tsx)$': 'ts-jest'
|
|
||||||
},
|
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/src/$1'
|
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||||
},
|
},
|
||||||
setupFilesAfterEnv: [
|
transform: {
|
||||||
'<rootDir>/src/__tests__/setup.ts'
|
'^.+\\.tsx?$': [
|
||||||
],
|
'ts-jest',
|
||||||
globals: {
|
{
|
||||||
'ts-jest': {
|
useESM: true,
|
||||||
tsconfig: 'tsconfig.json',
|
tsconfig: 'tsconfig.json',
|
||||||
isolatedModules: true
|
},
|
||||||
}
|
],
|
||||||
},
|
|
||||||
collectCoverage: true,
|
|
||||||
collectCoverageFrom: [
|
|
||||||
'src/**/*.{ts,tsx}',
|
|
||||||
'!src/**/*.d.ts',
|
|
||||||
'!src/**/__tests__/**',
|
|
||||||
'!src/**/__mocks__/**',
|
|
||||||
'!src/**/types/**'
|
|
||||||
],
|
|
||||||
coverageReporters: ['text', 'lcov', 'html'],
|
|
||||||
coverageDirectory: 'coverage',
|
|
||||||
coverageThreshold: {
|
|
||||||
global: {
|
|
||||||
branches: 80,
|
|
||||||
functions: 80,
|
|
||||||
lines: 80,
|
|
||||||
statements: 80
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||||
verbose: true,
|
verbose: true,
|
||||||
testTimeout: 10000,
|
|
||||||
maxWorkers: '50%',
|
|
||||||
errorOnDeprecated: true,
|
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
resetMocks: true,
|
resetMocks: true,
|
||||||
restoreMocks: true,
|
restoreMocks: true,
|
||||||
testPathIgnorePatterns: [
|
testTimeout: 30000,
|
||||||
'/node_modules/',
|
maxWorkers: '50%',
|
||||||
'/dist/',
|
collectCoverage: true,
|
||||||
'/.cursor/'
|
coverageDirectory: 'coverage',
|
||||||
],
|
coverageReporters: ['text', 'lcov'],
|
||||||
watchPathIgnorePatterns: [
|
globals: {
|
||||||
'/node_modules/',
|
'ts-jest': {
|
||||||
'/dist/',
|
useESM: true,
|
||||||
'/.cursor/',
|
isolatedModules: true,
|
||||||
'/coverage/'
|
},
|
||||||
],
|
},
|
||||||
modulePathIgnorePatterns: [
|
|
||||||
'/dist/',
|
|
||||||
'/.cursor/'
|
|
||||||
],
|
|
||||||
moduleFileExtensions: [
|
|
||||||
'ts',
|
|
||||||
'tsx',
|
|
||||||
'js',
|
|
||||||
'jsx',
|
|
||||||
'json',
|
|
||||||
'node'
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
83
package.json
83
package.json
@@ -1,66 +1,53 @@
|
|||||||
{
|
{
|
||||||
"name": "homeassistant-mcp",
|
"name": "homeassistant-mcp",
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"description": "Model Context Protocol Server for Home Assistant",
|
"description": "Home Assistant Master Control Program",
|
||||||
"type": "module",
|
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run tsc",
|
"start": "bun run dist/index.js",
|
||||||
"start": "bun run dist/src/index.js",
|
"dev": "bun run --watch src/index.ts",
|
||||||
"dev": "bun --watch src/index.ts",
|
"build": "bun build ./src/index.ts --outdir ./dist --target node",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:coverage": "bun test --coverage",
|
|
||||||
"test:watch": "bun test --watch",
|
"test:watch": "bun test --watch",
|
||||||
"test:openai": "bun run openai_test.ts",
|
"test:coverage": "bun test --coverage",
|
||||||
"lint": "eslint src --ext .ts",
|
"lint": "eslint . --ext .ts",
|
||||||
"lint:fix": "eslint src --ext .ts --fix",
|
"format": "prettier --write \"src/**/*.ts\"",
|
||||||
"prepare": "bun run build",
|
"prepare": "husky install"
|
||||||
"clean": "rimraf dist",
|
|
||||||
"types:check": "tsc --noEmit",
|
|
||||||
"types:install": "bun add -d @types/node @types/jest"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@digital-alchemy/core": "^24.11.4",
|
"@digital-alchemy/core": "^0.1.0",
|
||||||
"@digital-alchemy/hass": "^24.11.4",
|
"@jest/globals": "^29.7.0",
|
||||||
"@types/chalk": "^0.4.31",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsonwebtoken": "^9.0.8",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/xmldom": "^0.1.34",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
"@xmldom/xmldom": "^0.9.7",
|
"@types/node": "^20.11.24",
|
||||||
"ajv": "^8.12.0",
|
"@types/sanitize-html": "^2.9.5",
|
||||||
"chalk": "^5.4.1",
|
"@types/ws": "^8.5.10",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.4.5",
|
||||||
"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",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"litemcp": "^0.7.0",
|
"node-fetch": "^3.3.2",
|
||||||
"uuid": "^9.0.1",
|
"sanitize-html": "^2.11.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0",
|
"typescript": "^5.3.3",
|
||||||
"ws": "^8.16.0",
|
"ws": "^8.16.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ajv": "^1.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@types/express": "^4.17.21",
|
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||||
"@types/express-rate-limit": "^6.0.0",
|
"@typescript-eslint/parser": "^7.1.0",
|
||||||
"@types/glob": "^8.1.0",
|
"eslint": "^8.57.0",
|
||||||
"@types/helmet": "^4.0.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"@types/jest": "^29.5.14",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"@types/node": "^20.17.16",
|
"husky": "^9.0.11",
|
||||||
"@types/supertest": "^6.0.2",
|
"prettier": "^3.2.5",
|
||||||
"@types/uuid": "^9.0.8",
|
"supertest": "^6.3.3",
|
||||||
"@types/winston": "^2.4.4",
|
"uuid": "^11.0.5"
|
||||||
"@types/ws": "^8.5.10",
|
|
||||||
"jest": "^29.7.0",
|
|
||||||
"node-fetch": "^3.3.2",
|
|
||||||
"openai": "^4.82.0",
|
|
||||||
"rimraf": "^5.0.10",
|
|
||||||
"supertest": "^6.3.4",
|
|
||||||
"ts-jest": "^29.1.2",
|
|
||||||
"tsx": "^4.7.0",
|
|
||||||
"typescript": "^5.3.3"
|
|
||||||
},
|
},
|
||||||
"author": "Jango Blockchained",
|
"engines": {
|
||||||
"license": "MIT",
|
"bun": ">=1.0.0"
|
||||||
"packageManager": "bun@1.0.26"
|
}
|
||||||
}
|
}
|
||||||
77
src/__mocks__/@digital-alchemy/hass.ts
Normal file
77
src/__mocks__/@digital-alchemy/hass.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { mock } from 'bun:test';
|
||||||
|
|
||||||
|
export const LIB_HASS = {
|
||||||
|
configuration: {
|
||||||
|
name: 'Home Assistant',
|
||||||
|
version: '2024.2.0',
|
||||||
|
location_name: 'Home',
|
||||||
|
time_zone: 'UTC',
|
||||||
|
components: ['automation', 'script', 'light', 'switch'],
|
||||||
|
unit_system: {
|
||||||
|
temperature: '°C',
|
||||||
|
length: 'm',
|
||||||
|
mass: 'kg',
|
||||||
|
pressure: 'hPa',
|
||||||
|
volume: 'L'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
light: {
|
||||||
|
turn_on: mock(() => Promise.resolve()),
|
||||||
|
turn_off: mock(() => Promise.resolve()),
|
||||||
|
toggle: mock(() => Promise.resolve())
|
||||||
|
},
|
||||||
|
switch: {
|
||||||
|
turn_on: mock(() => Promise.resolve()),
|
||||||
|
turn_off: mock(() => Promise.resolve()),
|
||||||
|
toggle: mock(() => Promise.resolve())
|
||||||
|
},
|
||||||
|
automation: {
|
||||||
|
trigger: mock(() => Promise.resolve()),
|
||||||
|
turn_on: mock(() => Promise.resolve()),
|
||||||
|
turn_off: mock(() => Promise.resolve())
|
||||||
|
},
|
||||||
|
script: {
|
||||||
|
turn_on: mock(() => Promise.resolve()),
|
||||||
|
turn_off: mock(() => Promise.resolve()),
|
||||||
|
toggle: mock(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
states: {
|
||||||
|
light: {
|
||||||
|
'light.living_room': {
|
||||||
|
state: 'on',
|
||||||
|
attributes: {
|
||||||
|
brightness: 255,
|
||||||
|
color_temp: 300,
|
||||||
|
friendly_name: 'Living Room Light'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'light.bedroom': {
|
||||||
|
state: 'off',
|
||||||
|
attributes: {
|
||||||
|
friendly_name: 'Bedroom Light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
switch: {
|
||||||
|
'switch.tv': {
|
||||||
|
state: 'off',
|
||||||
|
attributes: {
|
||||||
|
friendly_name: 'TV'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
subscribe: mock(() => Promise.resolve()),
|
||||||
|
unsubscribe: mock(() => Promise.resolve()),
|
||||||
|
fire: mock(() => Promise.resolve())
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
subscribeEvents: mock(() => Promise.resolve()),
|
||||||
|
subscribeMessage: mock(() => Promise.resolve()),
|
||||||
|
sendMessage: mock(() => Promise.resolve()),
|
||||||
|
close: mock(() => Promise.resolve())
|
||||||
|
}
|
||||||
|
};
|
||||||
61
src/__mocks__/litemcp.ts
Normal file
61
src/__mocks__/litemcp.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export class LiteMCP {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
config: any;
|
||||||
|
|
||||||
|
constructor(config: any = {}) {
|
||||||
|
this.name = 'home-assistant';
|
||||||
|
this.version = '1.0.0';
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async callService(domain: string, service: string, data: any = {}) {
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStates() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getState(entityId: string) {
|
||||||
|
return Promise.resolve({
|
||||||
|
entity_id: entityId,
|
||||||
|
state: 'unknown',
|
||||||
|
attributes: {},
|
||||||
|
last_changed: new Date().toISOString(),
|
||||||
|
last_updated: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setState(entityId: string, state: string, attributes: any = {}) {
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onStateChanged(callback: (event: any) => void) {
|
||||||
|
// Mock implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(eventType: string, callback: (event: any) => void) {
|
||||||
|
// Mock implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createMCP = (config: any = {}) => {
|
||||||
|
return new LiteMCP(config);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { TEST_CONFIG } from '../config/__tests__/test.config';
|
import { TEST_CONFIG } from '../config/__tests__/test.config';
|
||||||
|
import { beforeAll, afterAll, beforeEach, describe, expect, it, mock, test } from 'bun:test';
|
||||||
|
|
||||||
// Load test environment variables
|
// Load test environment variables
|
||||||
config({ path: path.resolve(process.cwd(), '.env.test') });
|
config({ path: path.resolve(process.cwd(), '.env.test') });
|
||||||
@@ -19,9 +20,9 @@ beforeAll(() => {
|
|||||||
|
|
||||||
// Suppress console output during tests unless explicitly enabled
|
// Suppress console output during tests unless explicitly enabled
|
||||||
if (!process.env.DEBUG) {
|
if (!process.env.DEBUG) {
|
||||||
console.error = jest.fn();
|
console.error = mock(() => { });
|
||||||
console.warn = jest.fn();
|
console.warn = mock(() => { });
|
||||||
console.log = jest.fn();
|
console.log = mock(() => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store original console methods for cleanup
|
// Store original console methods for cleanup
|
||||||
@@ -46,8 +47,16 @@ afterAll(() => {
|
|||||||
|
|
||||||
// Reset mocks between tests
|
// Reset mocks between tests
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.resetModules();
|
// Clear all mock function calls
|
||||||
jest.clearAllMocks();
|
const mockFns = Object.values(mock).filter(value => typeof value === 'function');
|
||||||
|
mockFns.forEach(mockFn => {
|
||||||
|
if (mockFn.mock) {
|
||||||
|
mockFn.mock.calls = [];
|
||||||
|
mockFn.mock.results = [];
|
||||||
|
mockFn.mock.instances = [];
|
||||||
|
mockFn.mock.lastCall = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom test environment setup
|
// Custom test environment setup
|
||||||
@@ -56,9 +65,9 @@ const setupTestEnvironment = () => {
|
|||||||
// Mock WebSocket for SSE tests
|
// Mock WebSocket for SSE tests
|
||||||
mockWebSocket: () => {
|
mockWebSocket: () => {
|
||||||
const mockWs = {
|
const mockWs = {
|
||||||
on: jest.fn(),
|
on: mock(() => { }),
|
||||||
send: jest.fn(),
|
send: mock(() => { }),
|
||||||
close: jest.fn()
|
close: mock(() => { })
|
||||||
};
|
};
|
||||||
return mockWs;
|
return mockWs;
|
||||||
},
|
},
|
||||||
@@ -66,13 +75,14 @@ const setupTestEnvironment = () => {
|
|||||||
// Mock HTTP response for API tests
|
// Mock HTTP response for API tests
|
||||||
mockResponse: () => {
|
mockResponse: () => {
|
||||||
const res: any = {};
|
const res: any = {};
|
||||||
res.status = jest.fn().mockReturnValue(res);
|
res.status = mock(() => res);
|
||||||
res.json = jest.fn().mockReturnValue(res);
|
res.json = mock(() => res);
|
||||||
res.send = jest.fn().mockReturnValue(res);
|
res.send = mock(() => res);
|
||||||
res.end = jest.fn().mockReturnValue(res);
|
res.end = mock(() => res);
|
||||||
res.setHeader = jest.fn().mockReturnValue(res);
|
res.setHeader = mock(() => res);
|
||||||
res.writeHead = jest.fn().mockReturnValue(res);
|
res.writeHead = mock(() => res);
|
||||||
res.write = jest.fn().mockReturnValue(true);
|
res.write = mock(() => true);
|
||||||
|
res.removeHeader = mock(() => res);
|
||||||
return res;
|
return res;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -86,7 +96,7 @@ const setupTestEnvironment = () => {
|
|||||||
ip: TEST_CONFIG.TEST_CLIENT_IP,
|
ip: TEST_CONFIG.TEST_CLIENT_IP,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/api/test',
|
path: '/api/test',
|
||||||
is: jest.fn(type => type === 'application/json'),
|
is: mock((type: string) => type === 'application/json'),
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -96,7 +106,7 @@ const setupTestEnvironment = () => {
|
|||||||
id,
|
id,
|
||||||
ip: TEST_CONFIG.TEST_CLIENT_IP,
|
ip: TEST_CONFIG.TEST_CLIENT_IP,
|
||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
send: jest.fn(),
|
send: mock(() => { }),
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
count: 0,
|
count: 0,
|
||||||
lastReset: Date.now()
|
lastReset: Date.now()
|
||||||
@@ -130,5 +140,16 @@ const setupTestEnvironment = () => {
|
|||||||
// Export test utilities
|
// Export test utilities
|
||||||
export const testUtils = setupTestEnvironment();
|
export const testUtils = setupTestEnvironment();
|
||||||
|
|
||||||
|
// Export Bun test utilities
|
||||||
|
export { beforeAll, afterAll, beforeEach, describe, expect, it, mock, test };
|
||||||
|
|
||||||
// Make test utilities available globally
|
// Make test utilities available globally
|
||||||
(global as any).testUtils = testUtils;
|
(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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TokenManager } from '../index';
|
import { TokenManager } from '../index';
|
||||||
import { SECURITY_CONFIG } from '../../config/security.config';
|
import { SECURITY_CONFIG } from '../../config/security.config';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
describe('TokenManager', () => {
|
describe('TokenManager', () => {
|
||||||
const validSecret = 'test_secret_key_that_is_at_least_32_chars_long';
|
const validSecret = 'test_secret_key_that_is_at_least_32_chars_long';
|
||||||
@@ -9,8 +10,7 @@ describe('TokenManager', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.JWT_SECRET = validSecret;
|
process.env.JWT_SECRET = validSecret;
|
||||||
// Reset rate limiting
|
jest.clearAllMocks();
|
||||||
jest.resetModules();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Token Validation', () => {
|
describe('Token Validation', () => {
|
||||||
@@ -41,7 +41,7 @@ describe('TokenManager', () => {
|
|||||||
expect(result.error).toContain('expired');
|
expect(result.error).toContain('expired');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should implement rate limiting for failed attempts', () => {
|
it('should implement rate limiting for failed attempts', async () => {
|
||||||
// Simulate multiple failed attempts
|
// Simulate multiple failed attempts
|
||||||
for (let i = 0; i < SECURITY_CONFIG.MAX_FAILED_ATTEMPTS; i++) {
|
for (let i = 0; i < SECURITY_CONFIG.MAX_FAILED_ATTEMPTS; i++) {
|
||||||
TokenManager.validateToken('invalid_token', testIp);
|
TokenManager.validateToken('invalid_token', testIp);
|
||||||
@@ -51,6 +51,9 @@ describe('TokenManager', () => {
|
|||||||
const result = TokenManager.validateToken('invalid_token', testIp);
|
const result = TokenManager.validateToken('invalid_token', testIp);
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.error).toContain('Too many failed attempts');
|
expect(result.error).toContain('Too many failed attempts');
|
||||||
|
|
||||||
|
// Wait for rate limit to expire
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { SSEManager } from '../index';
|
import { SSEManager } from '../index';
|
||||||
import { TokenManager } from '../../security/index';
|
import { TokenManager } from '../../security/index';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
|
||||||
|
import type { SSEClient, SSEEvent, MockSend, MockSendFn } from '../types';
|
||||||
|
|
||||||
describe('SSE Security Features', () => {
|
describe('SSE Security Features', () => {
|
||||||
let sseManager: SSEManager;
|
let sseManager: SSEManager;
|
||||||
const validToken = 'valid_token';
|
const validToken = 'valid_token';
|
||||||
const testIp = '127.0.0.1';
|
const testIp = '127.0.0.1';
|
||||||
|
|
||||||
const createTestClient = (id: string) => ({
|
const createTestClient = (id: string): SSEClient => ({
|
||||||
id,
|
id,
|
||||||
ip: testIp,
|
ip: testIp,
|
||||||
connectedAt: new Date(),
|
connectedAt: new Date(),
|
||||||
send: jest.fn(),
|
send: mock<MockSendFn>((data: string) => { }),
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
count: 0,
|
count: 0,
|
||||||
lastReset: Date.now()
|
lastReset: Date.now()
|
||||||
@@ -26,11 +28,20 @@ describe('SSE Security Features', () => {
|
|||||||
cleanupInterval: 200,
|
cleanupInterval: 200,
|
||||||
maxConnectionAge: 1000
|
maxConnectionAge: 1000
|
||||||
});
|
});
|
||||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({ valid: true });
|
TokenManager.validateToken = mock(() => ({ valid: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.clearAllMocks();
|
// Clear all mock function calls
|
||||||
|
const mocks = Object.values(mock).filter(
|
||||||
|
(value): value is MockSend => typeof value === 'function' && 'mock' in value
|
||||||
|
);
|
||||||
|
mocks.forEach(mockFn => {
|
||||||
|
mockFn.mock.calls = [];
|
||||||
|
mockFn.mock.results = [];
|
||||||
|
mockFn.mock.instances = [];
|
||||||
|
mockFn.mock.lastCall = undefined;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Client Authentication', () => {
|
describe('Client Authentication', () => {
|
||||||
@@ -39,21 +50,21 @@ describe('SSE Security Features', () => {
|
|||||||
const result = sseManager.addClient(client, validToken);
|
const result = sseManager.addClient(client, validToken);
|
||||||
|
|
||||||
expect(result).toBeTruthy();
|
expect(result).toBeTruthy();
|
||||||
expect(result?.authenticated).toBe(true);
|
|
||||||
expect(TokenManager.validateToken).toHaveBeenCalledWith(validToken, testIp);
|
expect(TokenManager.validateToken).toHaveBeenCalledWith(validToken, testIp);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject invalid tokens', () => {
|
it('should reject invalid tokens', () => {
|
||||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({
|
const validateTokenMock = mock(() => ({
|
||||||
valid: false,
|
valid: false,
|
||||||
error: 'Invalid token'
|
error: 'Invalid token'
|
||||||
});
|
}));
|
||||||
|
TokenManager.validateToken = validateTokenMock;
|
||||||
|
|
||||||
const client = createTestClient('test-client-2');
|
const client = createTestClient('test-client-2');
|
||||||
const result = sseManager.addClient(client, 'invalid_token');
|
const result = sseManager.addClient(client, 'invalid_token');
|
||||||
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
expect(TokenManager.validateToken).toHaveBeenCalledWith('invalid_token', testIp);
|
expect(validateTokenMock).toHaveBeenCalledWith('invalid_token', testIp);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enforce maximum client limit', () => {
|
it('should enforce maximum client limit', () => {
|
||||||
@@ -121,15 +132,16 @@ describe('SSE Security Features', () => {
|
|||||||
expect(sseClient).toBeTruthy();
|
expect(sseClient).toBeTruthy();
|
||||||
|
|
||||||
// Send messages up to rate limit
|
// Send messages up to rate limit
|
||||||
for (let i = 0; i < 1000; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
sseManager['sendToClient'](sseClient!, { type: 'test', data: i });
|
sseManager['sendToClient'](sseClient!, { type: 'test', data: i });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Next message should trigger rate limit
|
// Next message should trigger rate limit
|
||||||
sseManager['sendToClient'](sseClient!, { type: 'test', data: 'overflow' });
|
sseManager['sendToClient'](sseClient!, { type: 'test', data: 'overflow' });
|
||||||
|
|
||||||
const lastCall = client.send.mock.calls[client.send.mock.calls.length - 1][0];
|
const lastCall = client.send.mock.calls[client.send.mock.calls.length - 1];
|
||||||
expect(JSON.parse(lastCall)).toMatchObject({
|
const lastMessage = JSON.parse(lastCall.args[0] as string);
|
||||||
|
expect(lastMessage).toEqual({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
error: 'rate_limit_exceeded'
|
error: 'rate_limit_exceeded'
|
||||||
});
|
});
|
||||||
@@ -141,7 +153,7 @@ describe('SSE Security Features', () => {
|
|||||||
expect(sseClient).toBeTruthy();
|
expect(sseClient).toBeTruthy();
|
||||||
|
|
||||||
// Send messages up to rate limit
|
// Send messages up to rate limit
|
||||||
for (let i = 0; i < 1000; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
sseManager['sendToClient'](sseClient!, { type: 'test', data: i });
|
sseManager['sendToClient'](sseClient!, { type: 'test', data: i });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +162,9 @@ describe('SSE Security Features', () => {
|
|||||||
|
|
||||||
// Should be able to send messages again
|
// Should be able to send messages again
|
||||||
sseManager['sendToClient'](sseClient!, { type: 'test', data: 'new message' });
|
sseManager['sendToClient'](sseClient!, { type: 'test', data: 'new message' });
|
||||||
const lastCall = client.send.mock.calls[client.send.mock.calls.length - 1][0];
|
const lastCall = client.send.mock.calls[client.send.mock.calls.length - 1];
|
||||||
expect(JSON.parse(lastCall)).toMatchObject({
|
const lastMessage = JSON.parse(lastCall.args[0] as string);
|
||||||
|
expect(lastMessage).toEqual({
|
||||||
type: 'test',
|
type: 'test',
|
||||||
data: 'new message'
|
data: 'new message'
|
||||||
});
|
});
|
||||||
@@ -164,22 +177,28 @@ describe('SSE Security Features', () => {
|
|||||||
const client2 = createTestClient('client2');
|
const client2 = createTestClient('client2');
|
||||||
|
|
||||||
const sseClient1 = sseManager.addClient(client1, validToken);
|
const sseClient1 = sseManager.addClient(client1, validToken);
|
||||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({ valid: false });
|
TokenManager.validateToken = mock(() => ({ valid: false }));
|
||||||
const sseClient2 = sseManager.addClient(client2, 'invalid_token');
|
const sseClient2 = sseManager.addClient(client2, 'invalid_token');
|
||||||
|
|
||||||
expect(sseClient1).toBeTruthy();
|
expect(sseClient1).toBeTruthy();
|
||||||
expect(sseClient2).toBeNull();
|
expect(sseClient2).toBeNull();
|
||||||
|
|
||||||
sseManager.broadcastEvent({
|
const event: SSEEvent = {
|
||||||
event_type: 'test_event',
|
event_type: 'test_event',
|
||||||
data: { test: true },
|
data: { test: true },
|
||||||
origin: 'test',
|
origin: 'test',
|
||||||
time_fired: new Date().toISOString(),
|
time_fired: new Date().toISOString(),
|
||||||
context: { id: 'test' }
|
context: { id: 'test' }
|
||||||
});
|
};
|
||||||
|
|
||||||
expect(client1.send).toHaveBeenCalled();
|
sseManager.broadcastEvent(event);
|
||||||
expect(client2.send).not.toHaveBeenCalled();
|
|
||||||
|
expect(client1.send.mock.calls.length).toBe(1);
|
||||||
|
const sentCall = client1.send.mock.calls[0];
|
||||||
|
const sentEvent = JSON.parse(sentCall.args[0] as string);
|
||||||
|
expect(sentEvent.type).toBe('test_event');
|
||||||
|
|
||||||
|
expect(client2.send.mock.calls.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect subscription filters', () => {
|
it('should respect subscription filters', () => {
|
||||||
@@ -207,8 +226,9 @@ describe('SSE Security Features', () => {
|
|||||||
context: { id: 'test' }
|
context: { id: 'test' }
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(client.send).toHaveBeenCalledTimes(1);
|
expect(client.send.mock.calls.length).toBe(1);
|
||||||
const sentMessage = JSON.parse(client.send.mock.calls[0][0]);
|
const sentCall = client.send.mock.calls[0];
|
||||||
|
const sentMessage = JSON.parse(sentCall.args[0] as string);
|
||||||
expect(sentMessage.type).toBe('test_event');
|
expect(sentMessage.type).toBe('test_event');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
42
src/sse/types.ts
Normal file
42
src/sse/types.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Mock } from 'bun:test';
|
||||||
|
|
||||||
|
export interface SSEClient {
|
||||||
|
id: string;
|
||||||
|
ip: string;
|
||||||
|
connectedAt: Date;
|
||||||
|
send: Mock<(data: string) => void>;
|
||||||
|
rateLimit: {
|
||||||
|
count: number;
|
||||||
|
lastReset: number;
|
||||||
|
};
|
||||||
|
connectionTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEEvent {
|
||||||
|
event_type: string;
|
||||||
|
data: unknown;
|
||||||
|
origin: string;
|
||||||
|
time_fired: string;
|
||||||
|
context: {
|
||||||
|
id: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEMessage {
|
||||||
|
type: string;
|
||||||
|
data?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SSEManagerConfig {
|
||||||
|
maxClients?: number;
|
||||||
|
pingInterval?: number;
|
||||||
|
cleanupInterval?: number;
|
||||||
|
maxConnectionAge?: number;
|
||||||
|
rateLimitWindow?: number;
|
||||||
|
maxRequestsPerWindow?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MockSendFn = (data: string) => void;
|
||||||
|
export type MockSend = Mock<MockSendFn>;
|
||||||
50
src/types/bun.d.ts
vendored
Normal file
50
src/types/bun.d.ts
vendored
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
declare module 'bun:test' {
|
||||||
|
export interface Mock<T extends (...args: any[]) => any> {
|
||||||
|
(...args: Parameters<T>): ReturnType<T>;
|
||||||
|
mock: {
|
||||||
|
calls: Array<{ args: Parameters<T>; returned: ReturnType<T> }>;
|
||||||
|
results: Array<{ type: 'return' | 'throw'; value: any }>;
|
||||||
|
instances: any[];
|
||||||
|
lastCall: { args: Parameters<T>; returned: ReturnType<T> } | undefined;
|
||||||
|
};
|
||||||
|
mockImplementation(fn: T): this;
|
||||||
|
mockReturnValue(value: ReturnType<T>): this;
|
||||||
|
mockResolvedValue<U>(value: U): Mock<() => Promise<U>>;
|
||||||
|
mockRejectedValue(value: any): Mock<() => Promise<never>>;
|
||||||
|
mockReset(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mock<T extends (...args: any[]) => any>(
|
||||||
|
implementation?: T
|
||||||
|
): Mock<T>;
|
||||||
|
|
||||||
|
export function describe(name: string, fn: () => void): void;
|
||||||
|
export function it(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
export function test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
export function expect(actual: any): {
|
||||||
|
toBe(expected: any): void;
|
||||||
|
toEqual(expected: any): void;
|
||||||
|
toBeDefined(): void;
|
||||||
|
toBeUndefined(): void;
|
||||||
|
toBeNull(): void;
|
||||||
|
toBeTruthy(): void;
|
||||||
|
toBeFalsy(): void;
|
||||||
|
toBeGreaterThan(expected: number): void;
|
||||||
|
toBeLessThan(expected: number): void;
|
||||||
|
toContain(expected: any): void;
|
||||||
|
toHaveLength(expected: number): void;
|
||||||
|
toHaveBeenCalled(): void;
|
||||||
|
toHaveBeenCalledTimes(expected: number): void;
|
||||||
|
toHaveBeenCalledWith(...args: any[]): void;
|
||||||
|
toThrow(expected?: string | RegExp): void;
|
||||||
|
resolves: any;
|
||||||
|
rejects: any;
|
||||||
|
};
|
||||||
|
export function beforeAll(fn: () => void | Promise<void>): void;
|
||||||
|
export function afterAll(fn: () => void | Promise<void>): void;
|
||||||
|
export function beforeEach(fn: () => void | Promise<void>): void;
|
||||||
|
export function afterEach(fn: () => void | Promise<void>): void;
|
||||||
|
export const mock: {
|
||||||
|
resetAll(): void;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "node",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -37,11 +37,11 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*",
|
"src/**/*",
|
||||||
"__tests__/**/*"
|
"jest.config.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"**/__tests__/**/*.ts",
|
"dist",
|
||||||
"**/*.test.ts"
|
"coverage"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user