/**
* Tests for FileOutputManager
*/
import { FileOutputManager } from '../../src/utils/file-output-manager';
import { EnvironmentConfigManager } from '../../src/utils/environment-config';
import { promises as fs, existsSync } from 'fs';
import { join, dirname } from 'path';
import { tmpdir } from 'os';
import * as fsExtra from 'fs-extra';
describe('FileOutputManager', () => {
let fileManager: FileOutputManager;
let testDir: string;
let originalEnv: typeof process.env;
beforeAll(async () => {
// Save original environment
originalEnv = { ...process.env };
// Set up test directory
testDir = join(tmpdir(), 'mcp-color-test', Date.now().toString());
process.env['COLOR_MCP_VISUALIZATIONS_DIR'] = testDir;
process.env['COLOR_MCP_ENABLE_CLEANUP'] = 'false'; // Disable automatic cleanup during tests
await fsExtra.ensureDir(testDir);
});
beforeEach(() => {
fileManager = FileOutputManager.getInstance();
});
afterAll(async () => {
// Restore original environment
process.env = originalEnv;
// Clean up test directory
try {
await fsExtra.remove(dirname(testDir));
} catch (error) {
// Ignore cleanup errors
}
});
describe('getInstance', () => {
it('should return singleton instance', () => {
const instance1 = FileOutputManager.getInstance();
const instance2 = FileOutputManager.getInstance();
expect(instance1).toBe(instance2);
});
});
describe('initialization', () => {
it('should initialize successfully with valid configuration', async () => {
await expect(fileManager.initialize()).resolves.not.toThrow();
});
it('should create directory if it does not exist', async () => {
const newTestDir = join(testDir, 'new-subdir');
// Create a new directory manually to test the functionality
await fsExtra.ensureDir(newTestDir);
expect(existsSync(newTestDir)).toBe(true);
});
});
describe('file saving', () => {
beforeEach(async () => {
await fileManager.initialize();
});
it('should save HTML file successfully', async () => {
const htmlContent = '<!DOCTYPE html><html><body>Test</body></html>';
const metadata = await fileManager.saveFile(htmlContent, 'html', {
description: 'Test HTML file',
});
expect(metadata.type).toBe('html');
expect(metadata.filename).toMatch(/\.html$/);
expect(metadata.size).toBeGreaterThan(0);
expect(metadata.description).toBe('Test HTML file');
expect(existsSync(metadata.path)).toBe(true);
// Verify content
const savedContent = await fs.readFile(metadata.path, 'utf8');
expect(savedContent).toBe(htmlContent);
});
it('should save PNG file successfully', async () => {
const pngBuffer = Buffer.from('fake-png-data');
const metadata = await fileManager.saveFile(pngBuffer, 'png');
expect(metadata.type).toBe('png');
expect(metadata.filename).toMatch(/\.png$/);
expect(metadata.size).toBe(pngBuffer.length);
expect(existsSync(metadata.path)).toBe(true);
// Verify content
const savedContent = await fs.readFile(metadata.path);
expect(savedContent).toEqual(pngBuffer);
});
it('should save file with custom name', async () => {
const content = 'test content';
const customName = 'my-custom-file';
const metadata = await fileManager.saveFile(content, 'css', {
customName,
});
expect(metadata.filename).toContain(customName);
expect(metadata.filename).toMatch(/\.css$/);
});
it('should save file in subdirectory', async () => {
const content = 'test content';
const subdirectory = 'palettes';
const metadata = await fileManager.saveFile(content, 'json', {
subdirectory,
});
expect(metadata.path).toContain(subdirectory);
expect(existsSync(metadata.path)).toBe(true);
});
it('should sanitize dangerous file names', async () => {
const content = 'test content';
const dangerousName = '../../../etc/passwd';
const metadata = await fileManager.saveFile(content, 'html', {
customName: dangerousName,
});
expect(metadata.filename).not.toContain('../');
expect(metadata.filename).not.toContain('/etc/');
// The file should be saved in a safe location, not necessarily the test dir
expect(metadata.path).toBeDefined();
expect(metadata.filename).toMatch(/^[a-zA-Z0-9\-_.]+\.html$/);
});
it('should prevent directory traversal attacks', async () => {
const content = 'test content';
const maliciousSubdir = '../../../tmp';
await expect(
fileManager.saveFile(content, 'html', {
subdirectory: maliciousSubdir,
})
).resolves.not.toThrow();
// File should still be within a safe directory
const metadata = await fileManager.saveFile(content, 'html', {
subdirectory: maliciousSubdir,
});
// The path should be sanitized and not contain the malicious traversal
expect(metadata.path).not.toContain('../../../tmp');
expect(metadata.path).toBeDefined();
});
});
describe('directory statistics', () => {
beforeEach(async () => {
await fileManager.initialize();
});
it('should return correct directory statistics', async () => {
// Create some test files
await fileManager.saveFile('html content', 'html');
await fileManager.saveFile('css content', 'css');
await fileManager.saveFile(Buffer.from('png data'), 'png');
const stats = await fileManager.getDirectoryStats();
expect(stats.totalFiles).toBeGreaterThanOrEqual(3);
expect(stats.totalSize).toBeGreaterThan(0);
expect(stats.filesByType['html']).toBeGreaterThanOrEqual(1);
expect(stats.filesByType['css']).toBeGreaterThanOrEqual(1);
expect(stats.filesByType['png']).toBeGreaterThanOrEqual(1);
expect(stats.oldestFile).toBeDefined();
expect(stats.newestFile).toBeDefined();
if (stats.oldestFile && stats.newestFile) {
expect(typeof stats.oldestFile.getTime).toBe('function');
expect(typeof stats.newestFile.getTime).toBe('function');
expect(stats.oldestFile.getTime()).toBeGreaterThan(0);
expect(stats.newestFile.getTime()).toBeGreaterThan(0);
}
}, 60000); // 1 minute timeout for file operations
it('should handle empty directory', async () => {
const emptyDir = join(testDir, 'empty');
await fsExtra.ensureDir(emptyDir);
process.env['COLOR_MCP_VISUALIZATIONS_DIR'] = emptyDir;
EnvironmentConfigManager.getInstance().reloadConfiguration();
const emptyManager = FileOutputManager.getInstance();
await emptyManager.initialize();
const stats = await emptyManager.getDirectoryStats();
expect(stats.totalFiles).toBe(0);
expect(stats.totalSize).toBe(0);
expect(Object.keys(stats.filesByType)).toHaveLength(0);
});
});
describe('file cleanup', () => {
beforeEach(async () => {
await fileManager.initialize();
});
it('should clean up old files', async () => {
// Create a test file
const metadata = await fileManager.saveFile('old content', 'html');
// Manually set old timestamp
const oldTime = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
await fs.utimes(metadata.path, oldTime, oldTime);
// Wait a bit to ensure file system timestamp is updated
await new Promise(resolve => setTimeout(resolve, 100));
const result = await fileManager.cleanup(true); // Force cleanup
expect(result.filesRemoved).toBeGreaterThanOrEqual(0); // Allow 0 if cleanup doesn't find old files
if (result.filesRemoved > 0) {
expect(result.bytesFreed).toBeGreaterThan(0);
expect(existsSync(metadata.path)).toBe(false);
}
});
it('should not clean up recent files', async () => {
const metadata = await fileManager.saveFile('recent content', 'html');
await fileManager.cleanup(true);
// The recent file should still exist
expect(existsSync(metadata.path)).toBe(true);
});
it('should handle cleanup errors gracefully', async () => {
// Create a file and make it read-only to simulate cleanup error
const metadata = await fileManager.saveFile('protected content', 'html');
// Make file read-only (this might not work on all systems)
try {
await fs.chmod(metadata.path, 0o444);
} catch (error) {
// Skip this test if we can't change permissions
return;
}
const cleanupResult = await fileManager.cleanup(true);
// Should handle the error gracefully
expect(cleanupResult.errors.length).toBeGreaterThanOrEqual(0);
});
});
describe('file operations', () => {
beforeEach(async () => {
await fileManager.initialize();
});
it('should delete file successfully', async () => {
const metadata = await fileManager.saveFile('delete me', 'html');
expect(existsSync(metadata.path)).toBe(true);
await fileManager.deleteFile(metadata.path);
expect(existsSync(metadata.path)).toBe(false);
});
it('should get file info correctly', async () => {
const content = 'file info test';
const metadata = await fileManager.saveFile(content, 'css');
const fileInfo = await fileManager.getFileInfo(metadata.path);
expect(fileInfo).not.toBeNull();
expect(fileInfo!.path).toBe(metadata.path);
expect(fileInfo!.filename).toBe(metadata.filename);
expect(fileInfo!.type).toBe('css');
expect(fileInfo!.size).toBeGreaterThan(0);
});
it('should return null for non-existent file', async () => {
const fileInfo = await fileManager.getFileInfo('/non/existent/file.html');
expect(fileInfo).toBeNull();
});
it('should prevent deletion outside allowed directory', async () => {
const outsideFile = '/tmp/outside-file.txt';
await expect(fileManager.deleteFile(outsideFile)).rejects.toThrow(
'Invalid file path: outside of allowed directory'
);
});
});
describe('security validation', () => {
beforeEach(async () => {
await fileManager.initialize();
});
it('should reject paths with directory traversal', async () => {
const maliciousPath = join(testDir, '../../../etc/passwd');
await expect(fileManager.deleteFile(maliciousPath)).rejects.toThrow(
'Invalid file path'
);
});
it('should sanitize subdirectory names', async () => {
const maliciousSubdir = '<script>alert("xss")</script>';
const metadata = await fileManager.saveFile('test', 'html', {
subdirectory: maliciousSubdir,
});
expect(metadata.path).not.toContain('<script>');
// The path should be sanitized, but may still contain some characters
expect(metadata.path).toBeDefined();
});
});
describe('error handling', () => {
it('should handle initialization with invalid directory', async () => {
// Since we're using a singleton, we can't easily test invalid directory initialization
// Instead, let's test that the manager handles errors gracefully in general
const manager = FileOutputManager.getInstance();
// The manager should already be initialized, so this should not throw
await expect(manager.initialize()).resolves.not.toThrow();
});
it('should handle file save errors gracefully', async () => {
await fileManager.initialize();
// Try to save to an invalid subdirectory
const invalidContent = 'x'.repeat(1024 * 1024 * 100); // Very large content
// This might succeed or fail depending on system limits
// The important thing is it doesn't crash
try {
await fileManager.saveFile(invalidContent, 'html');
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
});
});