623 lines
No EOL
19 KiB
JavaScript
623 lines
No EOL
19 KiB
JavaScript
import { jest } from '@jest/globals';
|
|
|
|
// Mock all external dependencies before importing the module
|
|
const mockMkdir = jest.fn();
|
|
const mockReadFile = jest.fn();
|
|
const mockWriteFileSync = jest.fn();
|
|
const mockReadFileSync = jest.fn();
|
|
const mockUnlinkSync = jest.fn();
|
|
const mockExistsSync = jest.fn();
|
|
const mockReaddirSync = jest.fn();
|
|
|
|
jest.unstable_mockModule('fs/promises', () => ({
|
|
mkdir: mockMkdir,
|
|
readFile: mockReadFile
|
|
}));
|
|
|
|
jest.unstable_mockModule('fs', () => ({
|
|
writeFileSync: mockWriteFileSync,
|
|
readFileSync: mockReadFileSync,
|
|
unlinkSync: mockUnlinkSync,
|
|
existsSync: mockExistsSync,
|
|
readdirSync: mockReaddirSync
|
|
}));
|
|
|
|
const mockJoin = jest.fn();
|
|
const mockDirname = jest.fn();
|
|
const mockBasename = jest.fn();
|
|
|
|
jest.unstable_mockModule('path', () => ({
|
|
join: mockJoin,
|
|
dirname: mockDirname,
|
|
basename: mockBasename
|
|
}));
|
|
|
|
const mockFileURLToPath = jest.fn();
|
|
jest.unstable_mockModule('url', () => ({
|
|
fileURLToPath: mockFileURLToPath
|
|
}));
|
|
|
|
const mockSecureImportModule = jest.fn();
|
|
jest.unstable_mockModule('../dist/utils/plugins.js', () => ({
|
|
secureImportModule: mockSecureImportModule
|
|
}));
|
|
|
|
const mockLogs = {
|
|
section: jest.fn(),
|
|
init: jest.fn(),
|
|
config: jest.fn(),
|
|
error: jest.fn(),
|
|
msg: jest.fn(),
|
|
server: jest.fn()
|
|
};
|
|
jest.unstable_mockModule('../dist/utils/logs.js', () => mockLogs);
|
|
|
|
const mockApp = {
|
|
set: jest.fn(),
|
|
use: jest.fn(),
|
|
listen: jest.fn()
|
|
};
|
|
|
|
const mockExpress = jest.fn(() => mockApp);
|
|
mockExpress.json = jest.fn(() => (req, res, next) => next());
|
|
mockExpress.urlencoded = jest.fn(() => (req, res, next) => next());
|
|
mockExpress.static = jest.fn(() => (req, res, next) => next());
|
|
mockExpress.Router = jest.fn(() => ({
|
|
use: jest.fn()
|
|
}));
|
|
|
|
jest.unstable_mockModule('express', () => ({
|
|
default: mockExpress
|
|
}));
|
|
|
|
const mockServer = {
|
|
on: jest.fn(),
|
|
close: jest.fn(),
|
|
listen: jest.fn()
|
|
};
|
|
|
|
const mockCreateServer = jest.fn(() => mockServer);
|
|
jest.unstable_mockModule('http', () => ({
|
|
createServer: mockCreateServer
|
|
}));
|
|
|
|
const mockSpawn = jest.fn();
|
|
jest.unstable_mockModule('child_process', () => ({
|
|
spawn: mockSpawn
|
|
}));
|
|
|
|
jest.unstable_mockModule('dotenv', () => ({
|
|
config: jest.fn()
|
|
}));
|
|
|
|
// Mock process for command line argument testing
|
|
const originalArgv = process.argv;
|
|
const originalEnv = process.env;
|
|
const originalExit = process.exit;
|
|
const originalKill = process.kill;
|
|
const originalOn = process.on;
|
|
|
|
// Mock imports properly for ES modules
|
|
const mockTomlModule = {
|
|
parse: jest.fn(() => ({}))
|
|
};
|
|
|
|
jest.unstable_mockModule('@iarna/toml', () => ({
|
|
default: mockTomlModule
|
|
}));
|
|
|
|
// Import the module after all mocking is set up
|
|
let indexModule;
|
|
|
|
describe('Index (Main Application)', () => {
|
|
let mockProcess;
|
|
|
|
beforeAll(async () => {
|
|
// Import the module once with proper mocking setup
|
|
indexModule = await import('../dist/index.js');
|
|
});
|
|
|
|
beforeEach(() => {
|
|
// Reset all mocks
|
|
jest.clearAllMocks();
|
|
|
|
// Setup default mock returns
|
|
mockFileURLToPath.mockReturnValue('/app/src/index.js');
|
|
mockDirname.mockReturnValue('/app/src');
|
|
mockJoin.mockImplementation((...parts) => parts.join('/'));
|
|
mockBasename.mockImplementation((path, ext) => {
|
|
const name = path.split('/').pop();
|
|
return ext ? name.replace(ext, '') : name;
|
|
});
|
|
mockExistsSync.mockReturnValue(false);
|
|
mockReaddirSync.mockReturnValue([]);
|
|
mockReadFile.mockResolvedValue('');
|
|
mockTomlModule.parse.mockReturnValue({});
|
|
|
|
// Setup process mocking
|
|
mockProcess = {
|
|
argv: ['node', 'index.js'],
|
|
env: { ...originalEnv },
|
|
exit: jest.fn(),
|
|
kill: jest.fn(),
|
|
on: jest.fn()
|
|
};
|
|
|
|
// Note: Plugin registry persists across tests in ES modules
|
|
// This is expected behavior in the test environment
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.argv = originalArgv;
|
|
process.env = originalEnv;
|
|
process.exit = originalExit;
|
|
process.kill = originalKill;
|
|
process.on = originalOn;
|
|
});
|
|
|
|
describe('Plugin Registration System', () => {
|
|
test('should register plugin successfully', () => {
|
|
const pluginName = 'test-plugin';
|
|
const handler = { middleware: jest.fn() };
|
|
|
|
indexModule.registerPlugin(pluginName, handler);
|
|
|
|
const registeredNames = indexModule.getRegisteredPluginNames();
|
|
expect(registeredNames).toContain(pluginName);
|
|
});
|
|
|
|
test('should throw error for invalid plugin name', () => {
|
|
expect(() => {
|
|
indexModule.registerPlugin('', { middleware: jest.fn() });
|
|
}).toThrow('Plugin name must be a non-empty string');
|
|
|
|
expect(() => {
|
|
indexModule.registerPlugin(null, { middleware: jest.fn() });
|
|
}).toThrow('Plugin name must be a non-empty string');
|
|
});
|
|
|
|
test('should throw error for invalid handler', () => {
|
|
expect(() => {
|
|
indexModule.registerPlugin('test', null);
|
|
}).toThrow('Plugin handler must be an object');
|
|
|
|
expect(() => {
|
|
indexModule.registerPlugin('test', 'invalid');
|
|
}).toThrow('Plugin handler must be an object');
|
|
});
|
|
|
|
test('should prevent duplicate plugin registration', () => {
|
|
const pluginName = 'duplicate-plugin';
|
|
const handler = { middleware: jest.fn() };
|
|
|
|
indexModule.registerPlugin(pluginName, handler);
|
|
|
|
expect(() => {
|
|
indexModule.registerPlugin(pluginName, handler);
|
|
}).toThrow(`Plugin 'duplicate-plugin' is already registered`);
|
|
});
|
|
|
|
test('should load plugins in registration order', () => {
|
|
const handler1 = { middleware: jest.fn() };
|
|
const handler2 = { middleware: jest.fn() };
|
|
|
|
indexModule.registerPlugin('order-test-1', handler1);
|
|
indexModule.registerPlugin('order-test-2', handler2);
|
|
|
|
const handlers = indexModule.loadPlugins();
|
|
const registeredNames = indexModule.getRegisteredPluginNames();
|
|
|
|
// Check that our test plugins are in the registry
|
|
expect(registeredNames).toContain('order-test-1');
|
|
expect(registeredNames).toContain('order-test-2');
|
|
|
|
// Check that our handlers are in the handlers array
|
|
expect(handlers).toContain(handler1);
|
|
expect(handlers).toContain(handler2);
|
|
|
|
// Find the indices of our test handlers
|
|
const handler1Index = handlers.indexOf(handler1);
|
|
const handler2Index = handlers.indexOf(handler2);
|
|
|
|
// Verify order (handler1 should come before handler2)
|
|
expect(handler1Index).toBeLessThan(handler2Index);
|
|
});
|
|
|
|
test('should freeze plugin registry', () => {
|
|
const handler = { middleware: jest.fn() };
|
|
indexModule.registerPlugin('before-freeze', handler);
|
|
|
|
indexModule.freezePlugins();
|
|
|
|
expect(mockLogs.msg).toHaveBeenCalledWith('Plugin registration frozen');
|
|
});
|
|
});
|
|
|
|
describe('Configuration Loading', () => {
|
|
test('should load config successfully', async () => {
|
|
const configName = 'test-config';
|
|
const target = {};
|
|
const configData = { setting1: 'value1' };
|
|
|
|
mockReadFile.mockResolvedValue('setting1 = "value1"');
|
|
mockJoin.mockReturnValue('/app/src/config/test-config.toml');
|
|
|
|
// Mock the TOML parser
|
|
const mockTomlModule = {
|
|
default: {
|
|
parse: jest.fn(() => configData)
|
|
}
|
|
};
|
|
jest.unstable_mockModule('@iarna/toml', () => mockTomlModule);
|
|
|
|
await indexModule.loadConfig(configName, target);
|
|
|
|
expect(mockReadFile).toHaveBeenCalledWith('/app/src/config/test-config.toml', 'utf8');
|
|
expect(target).toEqual(configData);
|
|
});
|
|
|
|
test('should throw error for invalid config name', async () => {
|
|
await expect(indexModule.loadConfig('', {})).rejects.toThrow('Config name must be a non-empty string');
|
|
await expect(indexModule.loadConfig(null, {})).rejects.toThrow('Config name must be a non-empty string');
|
|
});
|
|
|
|
test('should throw error for invalid target', async () => {
|
|
await expect(indexModule.loadConfig('test', null)).rejects.toThrow('Config target must be an object');
|
|
await expect(indexModule.loadConfig('test', 'invalid')).rejects.toThrow('Config target must be an object');
|
|
});
|
|
|
|
test('should handle config loading errors', async () => {
|
|
const configName = 'failing-config';
|
|
const target = {};
|
|
|
|
mockReadFile.mockRejectedValue(new Error('File not found'));
|
|
|
|
await expect(indexModule.loadConfig(configName, target)).rejects.toThrow(
|
|
"Failed to load config 'failing-config': File not found"
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Command Line Argument Handling', () => {
|
|
test('should validate PID file operations for kill mode', () => {
|
|
// Test the logic for handling kill mode operations
|
|
mockExistsSync.mockReturnValue(true);
|
|
mockReadFileSync.mockReturnValue('1234');
|
|
mockJoin.mockReturnValue('/app/src/checkpoint.pid');
|
|
|
|
const pidContent = mockReadFileSync('checkpoint.pid', 'utf8').trim();
|
|
const pid = parseInt(pidContent, 10);
|
|
|
|
expect(pid).toBe(1234);
|
|
expect(isNaN(pid)).toBe(false);
|
|
expect(pid > 0).toBe(true);
|
|
});
|
|
|
|
test('should validate invalid PID handling', () => {
|
|
mockReadFileSync.mockReturnValue('invalid');
|
|
|
|
const pidContent = mockReadFileSync('checkpoint.pid', 'utf8').trim();
|
|
const pid = parseInt(pidContent, 10);
|
|
|
|
expect(isNaN(pid)).toBe(true);
|
|
});
|
|
|
|
test('should validate daemon spawn parameters', () => {
|
|
const mockChildProcess = {
|
|
pid: 5678,
|
|
unref: jest.fn()
|
|
};
|
|
mockSpawn.mockReturnValue(mockChildProcess);
|
|
|
|
const args = ['index.js'];
|
|
const nodeExecutable = 'node';
|
|
|
|
mockSpawn(nodeExecutable, args, {
|
|
detached: true,
|
|
stdio: 'ignore'
|
|
});
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'node',
|
|
['index.js'],
|
|
expect.objectContaining({
|
|
detached: true,
|
|
stdio: 'ignore'
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Directory Initialization', () => {
|
|
test('should validate directory creation logic', async () => {
|
|
mockMkdir.mockResolvedValue(undefined);
|
|
|
|
const expectedDirs = [
|
|
'/app/src/data',
|
|
'/app/src/db',
|
|
'/app/src/config'
|
|
];
|
|
|
|
// Test the directory creation logic
|
|
for (const dirPath of expectedDirs) {
|
|
mockJoin.mockReturnValueOnce(dirPath);
|
|
await mockMkdir(dirPath, { recursive: true });
|
|
}
|
|
|
|
expect(mockMkdir).toHaveBeenCalledTimes(3);
|
|
expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
|
|
});
|
|
});
|
|
|
|
describe('Plugin Discovery', () => {
|
|
test('should discover plugins in correct order', () => {
|
|
mockExistsSync.mockImplementation(path => {
|
|
return path.includes('plugins');
|
|
});
|
|
|
|
mockReaddirSync.mockReturnValue([
|
|
'waf.js',
|
|
'basic-auth.js',
|
|
'ipfilter.js',
|
|
'proxy.js',
|
|
'rate-limit.js'
|
|
]);
|
|
|
|
mockBasename.mockImplementation((file, ext) =>
|
|
file.replace(ext || '', '')
|
|
);
|
|
|
|
// Plugin load order should be: ipfilter, waf, proxy, then alphabetical
|
|
const expectedOrder = ['ipfilter', 'waf', 'proxy', 'basic-auth', 'rate-limit'];
|
|
|
|
expect(mockReaddirSync).toHaveBeenCalled;
|
|
});
|
|
});
|
|
|
|
describe('Express Server Setup', () => {
|
|
test('should validate Express app creation', () => {
|
|
// Test Express app creation
|
|
const app = mockExpress();
|
|
expect(mockExpress).toHaveBeenCalled();
|
|
expect(app).toBeDefined();
|
|
expect(app.set).toBeDefined();
|
|
expect(app.use).toBeDefined();
|
|
});
|
|
|
|
test('should validate middleware configuration', () => {
|
|
// Test middleware setup logic
|
|
const app = mockExpress();
|
|
|
|
// Test trust proxy setting
|
|
app.set('trust proxy', true);
|
|
expect(app.set).toHaveBeenCalledWith('trust proxy', true);
|
|
|
|
// Test body parsing middleware
|
|
const jsonMiddleware = mockExpress.json({ limit: '10mb' });
|
|
const urlencodedMiddleware = mockExpress.urlencoded({ extended: true, limit: '10mb' });
|
|
|
|
expect(mockExpress.json).toHaveBeenCalledWith({ limit: '10mb' });
|
|
expect(mockExpress.urlencoded).toHaveBeenCalledWith({ extended: true, limit: '10mb' });
|
|
});
|
|
|
|
test('should handle WebSocket upgrade detection logic', () => {
|
|
const mockReq = {
|
|
headers: {
|
|
upgrade: 'websocket',
|
|
connection: 'Upgrade'
|
|
}
|
|
};
|
|
const mockRes = {};
|
|
const mockNext = jest.fn();
|
|
|
|
// Test WebSocket detection logic
|
|
const upgradeHeader = mockReq.headers.upgrade;
|
|
const connectionHeader = mockReq.headers.connection;
|
|
|
|
const isWebSocket = upgradeHeader === 'websocket' ||
|
|
(connectionHeader && connectionHeader.toLowerCase().includes('upgrade'));
|
|
|
|
if (isWebSocket) {
|
|
mockReq.isWebSocketRequest = true;
|
|
mockNext();
|
|
}
|
|
|
|
expect(mockReq.isWebSocketRequest).toBe(true);
|
|
expect(mockNext).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Environment Configuration', () => {
|
|
test('should handle production environment', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
|
|
// In production, console.log should be disabled
|
|
// This is handled at module load time
|
|
expect(process.env.NODE_ENV).toBe('production');
|
|
});
|
|
|
|
test('should handle custom port configuration', () => {
|
|
process.env.PORT = '8080';
|
|
|
|
// Port validation would happen during server startup
|
|
expect(process.env.PORT).toBe('8080');
|
|
});
|
|
|
|
test('should use default port when not specified', () => {
|
|
delete process.env.PORT;
|
|
|
|
// Default port should be 3000
|
|
const expectedPort = 3000;
|
|
expect(expectedPort).toBe(3000);
|
|
});
|
|
});
|
|
|
|
describe('Error Handling', () => {
|
|
test('should validate plugin loading error handling', async () => {
|
|
const error = new Error('Plugin load failed');
|
|
mockSecureImportModule.mockRejectedValue(error);
|
|
|
|
// Test error handling logic
|
|
try {
|
|
await mockSecureImportModule('test-plugin.js');
|
|
} catch (e) {
|
|
expect(e.message).toBe('Plugin load failed');
|
|
}
|
|
|
|
expect(mockSecureImportModule).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should validate config loading error handling', async () => {
|
|
const error = new Error('Config read failed');
|
|
mockReadFile.mockRejectedValue(error);
|
|
|
|
// Test config loading error handling
|
|
try {
|
|
await indexModule.loadConfig('test-config', {});
|
|
} catch (e) {
|
|
expect(e.message).toContain('Failed to load config');
|
|
expect(e.message).toContain('Config read failed');
|
|
}
|
|
});
|
|
|
|
test('should validate port number range', () => {
|
|
const invalidPorts = [-1, 0, 65536, 70000];
|
|
const validPorts = [80, 443, 3000, 8080, 65535];
|
|
|
|
invalidPorts.forEach(port => {
|
|
expect(isNaN(Number(port)) || Number(port) < 1 || Number(port) > 65535).toBe(true);
|
|
});
|
|
|
|
validPorts.forEach(port => {
|
|
expect(Number(port) >= 1 && Number(port) <= 65535).toBe(true);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Exclusion Rules Processing', () => {
|
|
test('should compile exclusion patterns correctly', () => {
|
|
const exclusionRules = [
|
|
{
|
|
Path: '/api/health',
|
|
Hosts: ['localhost', '127.0.0.1'],
|
|
UserAgents: ['healthcheck.*']
|
|
}
|
|
];
|
|
|
|
// Test exclusion rule compilation logic
|
|
const compiledRule = {
|
|
...exclusionRules[0],
|
|
pathStartsWith: exclusionRules[0].Path,
|
|
hostsSet: new Set(exclusionRules[0].Hosts),
|
|
userAgentPatterns: exclusionRules[0].UserAgents.map(pattern => new RegExp(pattern, 'i'))
|
|
};
|
|
|
|
expect(compiledRule.hostsSet.has('localhost')).toBe(true);
|
|
expect(compiledRule.userAgentPatterns[0].test('healthcheck-bot')).toBe(true);
|
|
});
|
|
|
|
test('should handle invalid regex patterns in UserAgents', () => {
|
|
const invalidPattern = '[invalid regex';
|
|
|
|
try {
|
|
new RegExp(invalidPattern, 'i');
|
|
} catch (e) {
|
|
// Should handle invalid regex gracefully
|
|
const fallbackPattern = /(?!)/; // Never matches
|
|
expect(fallbackPattern.test('anything')).toBe(false);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Static File Middleware', () => {
|
|
test('should validate static file setup for existing directories', () => {
|
|
const webfontPath = '/app/src/pages/interstitial/webfont';
|
|
const jsPath = '/app/src/pages/interstitial/js';
|
|
|
|
mockExistsSync.mockImplementation(path => {
|
|
return path === webfontPath || path === jsPath;
|
|
});
|
|
|
|
const router = mockExpress.Router();
|
|
|
|
// Test static directory setup logic
|
|
if (mockExistsSync(webfontPath)) {
|
|
router.use('/webfont', mockExpress.static(webfontPath, { maxAge: '7d' }));
|
|
}
|
|
|
|
if (mockExistsSync(jsPath)) {
|
|
router.use('/js', mockExpress.static(jsPath, { maxAge: '7d' }));
|
|
}
|
|
|
|
expect(mockExistsSync).toHaveBeenCalledWith(webfontPath);
|
|
expect(mockExistsSync).toHaveBeenCalledWith(jsPath);
|
|
expect(mockExpress.static).toHaveBeenCalledWith(webfontPath, { maxAge: '7d' });
|
|
expect(mockExpress.static).toHaveBeenCalledWith(jsPath, { maxAge: '7d' });
|
|
});
|
|
|
|
test('should skip static setup for non-existent directories', () => {
|
|
mockExistsSync.mockReturnValue(false);
|
|
|
|
const webfontPath = '/app/src/pages/interstitial/webfont';
|
|
const jsPath = '/app/src/pages/interstitial/js';
|
|
|
|
// Test logic when directories don't exist
|
|
const webfontExists = mockExistsSync(webfontPath);
|
|
const jsExists = mockExistsSync(jsPath);
|
|
|
|
expect(webfontExists).toBe(false);
|
|
expect(jsExists).toBe(false);
|
|
expect(mockExistsSync).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('Graceful Shutdown', () => {
|
|
test('should validate shutdown handler logic', () => {
|
|
const mockShutdownHandler = jest.fn();
|
|
const activeSockets = new Set();
|
|
const mockSocketInstance = {
|
|
destroy: jest.fn(),
|
|
setTimeout: jest.fn(),
|
|
setKeepAlive: jest.fn(),
|
|
on: jest.fn()
|
|
};
|
|
|
|
activeSockets.add(mockSocketInstance);
|
|
|
|
// Test shutdown logic
|
|
const isShuttingDown = false;
|
|
if (!isShuttingDown) {
|
|
activeSockets.forEach(sock => sock.destroy());
|
|
mockShutdownHandler();
|
|
}
|
|
|
|
expect(mockSocketInstance.destroy).toHaveBeenCalled();
|
|
expect(mockShutdownHandler).toHaveBeenCalled();
|
|
});
|
|
|
|
test('should validate socket management', () => {
|
|
const activeSockets = new Set();
|
|
const mockSocket = {
|
|
destroy: jest.fn(),
|
|
setTimeout: jest.fn(),
|
|
setKeepAlive: jest.fn(),
|
|
on: jest.fn()
|
|
};
|
|
|
|
// Test socket lifecycle
|
|
activeSockets.add(mockSocket);
|
|
expect(activeSockets.has(mockSocket)).toBe(true);
|
|
|
|
// Test socket configuration
|
|
mockSocket.setTimeout(120000);
|
|
mockSocket.setKeepAlive(true, 60000);
|
|
|
|
expect(mockSocket.setTimeout).toHaveBeenCalledWith(120000);
|
|
expect(mockSocket.setKeepAlive).toHaveBeenCalledWith(true, 60000);
|
|
|
|
// Test cleanup
|
|
activeSockets.delete(mockSocket);
|
|
expect(activeSockets.has(mockSocket)).toBe(false);
|
|
});
|
|
});
|
|
});
|