Major improvements based on comprehensive test suite review: Test Fixes: - Fix all 78 failing tests across logger, MSW, and validator tests - Fix console spy management in logger tests with proper DEBUG env handling - Fix MSW test environment restoration in session-management.test.ts - Fix workflow validator tests by adding proper node connections - Fix mock setup issues in edge case tests Test Organization: - Split large config-validator.test.ts (1,075 lines) into 4 focused files - Rename 63+ tests to follow "should X when Y" naming convention - Add comprehensive edge case test files for all major validators - Create tests/README.md with testing guidelines and best practices New Features: - Add ConfigValidator.validateBatch() method for bulk validation - Add edge case coverage for null/undefined, boundaries, invalid data - Add CI-aware performance test timeouts - Add JSDoc comments to test utilities and factories - Add workflow duplicate node name validation tests Results: - All tests passing: 1,356 passed, 19 skipped - Test coverage: 85.34% statements, 85.3% branches - From 78 failures to 0 failures 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
259 lines
7.7 KiB
TypeScript
259 lines
7.7 KiB
TypeScript
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import type { MockedFunction } from 'vitest';
|
|
|
|
// Import the actual functions we'll be testing
|
|
import { loadAuthToken, startFixedHTTPServer } from '../src/http-server';
|
|
|
|
// Mock dependencies
|
|
vi.mock('../src/utils/logger', () => ({
|
|
logger: {
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn()
|
|
},
|
|
Logger: vi.fn().mockImplementation(() => ({
|
|
info: vi.fn(),
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
debug: vi.fn()
|
|
})),
|
|
LogLevel: {
|
|
ERROR: 0,
|
|
WARN: 1,
|
|
INFO: 2,
|
|
DEBUG: 3
|
|
}
|
|
}));
|
|
|
|
vi.mock('dotenv');
|
|
|
|
// Mock other dependencies to prevent side effects
|
|
vi.mock('../src/mcp/server', () => ({
|
|
N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({
|
|
executeTool: vi.fn()
|
|
}))
|
|
}));
|
|
|
|
vi.mock('../src/mcp/tools', () => ({
|
|
n8nDocumentationToolsFinal: []
|
|
}));
|
|
|
|
vi.mock('../src/mcp/tools-n8n-manager', () => ({
|
|
n8nManagementTools: []
|
|
}));
|
|
|
|
vi.mock('../src/utils/version', () => ({
|
|
PROJECT_VERSION: '2.7.4'
|
|
}));
|
|
|
|
vi.mock('../src/config/n8n-api', () => ({
|
|
isN8nApiConfigured: vi.fn().mockReturnValue(false)
|
|
}));
|
|
|
|
vi.mock('../src/utils/url-detector', () => ({
|
|
getStartupBaseUrl: vi.fn().mockReturnValue('http://localhost:3000'),
|
|
formatEndpointUrls: vi.fn().mockReturnValue({
|
|
health: 'http://localhost:3000/health',
|
|
mcp: 'http://localhost:3000/mcp'
|
|
}),
|
|
detectBaseUrl: vi.fn().mockReturnValue('http://localhost:3000')
|
|
}));
|
|
|
|
// Create mock server instance
|
|
const mockServer = {
|
|
on: vi.fn(),
|
|
close: vi.fn((callback) => callback())
|
|
};
|
|
|
|
// Mock Express to prevent server from starting
|
|
const mockExpressApp = {
|
|
use: vi.fn(),
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
listen: vi.fn((port: any, host: any, callback: any) => {
|
|
// Call the callback immediately to simulate server start
|
|
if (callback) callback();
|
|
return mockServer;
|
|
}),
|
|
set: vi.fn()
|
|
};
|
|
|
|
vi.mock('express', () => {
|
|
const express: any = vi.fn(() => mockExpressApp);
|
|
express.json = vi.fn();
|
|
express.urlencoded = vi.fn();
|
|
express.static = vi.fn();
|
|
express.Request = {};
|
|
express.Response = {};
|
|
express.NextFunction = {};
|
|
return { default: express };
|
|
});
|
|
|
|
describe('HTTP Server Authentication', () => {
|
|
const originalEnv = process.env;
|
|
let tempDir: string;
|
|
let authTokenFile: string;
|
|
|
|
beforeEach(() => {
|
|
// Reset modules and environment
|
|
vi.clearAllMocks();
|
|
vi.resetModules();
|
|
process.env = { ...originalEnv };
|
|
|
|
// Create temporary directory for test files
|
|
tempDir = join(tmpdir(), `http-server-auth-test-${Date.now()}`);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
authTokenFile = join(tempDir, 'auth-token');
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore original environment
|
|
process.env = originalEnv;
|
|
|
|
// Clean up temporary directory
|
|
try {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
describe('loadAuthToken', () => {
|
|
it('should load token when AUTH_TOKEN environment variable is set', () => {
|
|
process.env.AUTH_TOKEN = 'test-token-from-env';
|
|
delete process.env.AUTH_TOKEN_FILE;
|
|
|
|
const token = loadAuthToken();
|
|
expect(token).toBe('test-token-from-env');
|
|
});
|
|
|
|
it('should load token from file when only AUTH_TOKEN_FILE is set', () => {
|
|
delete process.env.AUTH_TOKEN;
|
|
process.env.AUTH_TOKEN_FILE = authTokenFile;
|
|
|
|
// Write test token to file
|
|
writeFileSync(authTokenFile, 'test-token-from-file\n');
|
|
|
|
const token = loadAuthToken();
|
|
expect(token).toBe('test-token-from-file');
|
|
});
|
|
|
|
it('should trim whitespace when reading token from file', () => {
|
|
delete process.env.AUTH_TOKEN;
|
|
process.env.AUTH_TOKEN_FILE = authTokenFile;
|
|
|
|
// Write token with whitespace
|
|
writeFileSync(authTokenFile, ' test-token-with-spaces \n\n');
|
|
|
|
const token = loadAuthToken();
|
|
expect(token).toBe('test-token-with-spaces');
|
|
});
|
|
|
|
it('should prefer AUTH_TOKEN when both variables are set', () => {
|
|
process.env.AUTH_TOKEN = 'env-token';
|
|
process.env.AUTH_TOKEN_FILE = authTokenFile;
|
|
writeFileSync(authTokenFile, 'file-token');
|
|
|
|
const token = loadAuthToken();
|
|
expect(token).toBe('env-token');
|
|
});
|
|
|
|
it('should return null when AUTH_TOKEN_FILE points to non-existent file', async () => {
|
|
delete process.env.AUTH_TOKEN;
|
|
process.env.AUTH_TOKEN_FILE = join(tempDir, 'non-existent-file');
|
|
|
|
// Import logger to check calls
|
|
const { logger } = await import('../src/utils/logger');
|
|
|
|
// Clear any previous mock calls
|
|
vi.clearAllMocks();
|
|
|
|
const token = loadAuthToken();
|
|
expect(token).toBeNull();
|
|
expect(logger.error).toHaveBeenCalled();
|
|
const errorCall = (logger.error as MockedFunction<any>).mock.calls[0];
|
|
expect(errorCall[0]).toContain('Failed to read AUTH_TOKEN_FILE');
|
|
// Check that the second argument exists and is truthy (the error object)
|
|
expect(errorCall[1]).toBeTruthy();
|
|
});
|
|
|
|
it('should return null when no auth variables are set', () => {
|
|
delete process.env.AUTH_TOKEN;
|
|
delete process.env.AUTH_TOKEN_FILE;
|
|
|
|
const token = loadAuthToken();
|
|
expect(token).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('validateEnvironment', () => {
|
|
it('should exit process when no auth token is available', async () => {
|
|
delete process.env.AUTH_TOKEN;
|
|
delete process.env.AUTH_TOKEN_FILE;
|
|
|
|
const mockExit = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => {
|
|
throw new Error('Process exited');
|
|
});
|
|
|
|
// validateEnvironment is called when starting the server
|
|
await expect(async () => {
|
|
await startFixedHTTPServer();
|
|
}).rejects.toThrow('Process exited');
|
|
|
|
expect(mockExit).toHaveBeenCalledWith(1);
|
|
mockExit.mockRestore();
|
|
});
|
|
|
|
it('should warn when token length is less than 32 characters', async () => {
|
|
process.env.AUTH_TOKEN = 'short-token';
|
|
|
|
// Import logger to check calls
|
|
const { logger } = await import('../src/utils/logger');
|
|
|
|
// Clear any previous mock calls
|
|
vi.clearAllMocks();
|
|
|
|
// Ensure the mock server is properly configured
|
|
mockExpressApp.listen.mockReturnValue(mockServer);
|
|
mockServer.on.mockReturnValue(undefined);
|
|
|
|
// Start the server which will trigger validateEnvironment
|
|
await startFixedHTTPServer();
|
|
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'AUTH_TOKEN should be at least 32 characters for security'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Integration test scenarios', () => {
|
|
it('should authenticate successfully when token is loaded from file', () => {
|
|
// This is more of an integration test placeholder
|
|
// In a real scenario, you'd start the server and make HTTP requests
|
|
|
|
writeFileSync(authTokenFile, 'very-secure-token-with-more-than-32-characters');
|
|
process.env.AUTH_TOKEN_FILE = authTokenFile;
|
|
delete process.env.AUTH_TOKEN;
|
|
|
|
const token = loadAuthToken();
|
|
expect(token).toBe('very-secure-token-with-more-than-32-characters');
|
|
});
|
|
|
|
it('should load token when using Docker secrets pattern', () => {
|
|
// Docker secrets are typically mounted at /run/secrets/
|
|
const dockerSecretPath = join(tempDir, 'run', 'secrets', 'auth_token');
|
|
mkdirSync(join(tempDir, 'run', 'secrets'), { recursive: true });
|
|
writeFileSync(dockerSecretPath, 'docker-secret-token');
|
|
|
|
process.env.AUTH_TOKEN_FILE = dockerSecretPath;
|
|
delete process.env.AUTH_TOKEN;
|
|
|
|
const token = loadAuthToken();
|
|
expect(token).toBe('docker-secret-token');
|
|
});
|
|
});
|
|
}); |