file-handler.test.tsā¢16.4 kB
import { FileHandler } from '../file-handler.js';
import { PathAccessControl } from '../path-access-control.js';
import { existsSync, rmSync, writeFileSync, mkdirSync, chmodSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
describe('FileHandler', () => {
let testDir: string;
let fileHandler: FileHandler;
beforeEach(() => {
testDir = join(tmpdir(), `test-files-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
fileHandler = new FileHandler();
});
afterEach(() => {
if (existsSync(testDir)) {
// Reset permissions before deleting
try {
chmodSync(testDir, 0o755);
} catch {}
rmSync(testDir, { recursive: true, force: true });
}
});
describe('Read Lines - RED', () => {
it('should read specific line range from file', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\nline 4\nline 5\n');
const result = fileHandler.readLines(filePath, 2, 4);
expect(result.content).toBe('line 2\nline 3\nline 4');
expect(result.lines).toEqual(['line 2', 'line 3', 'line 4']);
});
it('should handle 1-indexed line numbers correctly', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'first\nsecond\nthird\n');
const result = fileHandler.readLines(filePath, 1, 1);
expect(result.content).toBe('first');
expect(result.lines).toEqual(['first']);
});
it('should throw error for invalid file path', () => {
expect(() => {
fileHandler.readLines('/non/existent/file.txt', 1, 1);
}).toThrow('File not found');
});
it('should throw error for invalid line range (start > end)', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
fileHandler.readLines(filePath, 5, 2);
}).toThrow('Invalid line range');
});
it('should throw error for negative line numbers', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
fileHandler.readLines(filePath, -1, 2);
}).toThrow('Line numbers must be positive');
});
it('should throw error for non-integer line numbers', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
fileHandler.readLines(filePath, 1.5, 2);
}).toThrow('Line numbers must be integers');
});
it('should throw error for zero line numbers', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
fileHandler.readLines(filePath, 0, 2);
}).toThrow('Line numbers must be positive');
});
it('should enforce file size limit before reading content', () => {
const filePath = join(testDir, 'large.txt');
const oversized = Buffer.alloc(10 * 1024 * 1024 + 1, 'a');
writeFileSync(filePath, oversized);
expect(() => {
fileHandler.readLines(filePath, 1, 1);
}).toThrow('File size exceeds');
});
it('should throw error for line numbers beyond file length', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
fileHandler.readLines(filePath, 1, 10);
}).toThrow('Line range exceeds file length');
});
it('should handle empty files', () => {
const filePath = join(testDir, 'empty.txt');
writeFileSync(filePath, '');
expect(() => {
fileHandler.readLines(filePath, 1, 1);
}).toThrow('File is empty or line range exceeds file length');
});
it('should preserve line ending information', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\r\nline 2\r\nline 3\r\n');
const result = fileHandler.readLines(filePath, 1, 2);
expect(result.lineEnding).toBe('\r\n');
});
it('should detect Unix line endings', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
const result = fileHandler.readLines(filePath, 1, 2);
expect(result.lineEnding).toBe('\n');
});
it('should reject binary files', () => {
const filePath = join(testDir, 'binary.bin');
// Write some binary data (null bytes)
writeFileSync(filePath, Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]));
expect(() => {
fileHandler.readLines(filePath, 1, 1);
}).toThrow('Binary files are not supported');
});
it('should handle files without trailing newline', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3');
const result = fileHandler.readLines(filePath, 2, 3);
expect(result.lines).toEqual(['line 2', 'line 3']);
});
});
describe('Insert Lines - RED', () => {
it('should insert lines at specified position', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
fileHandler.insertLines(filePath, 2, ['inserted 1', 'inserted 2']);
const content = fileHandler.readLines(filePath, 1, 5);
expect(content.lines).toEqual(['line 1', 'inserted 1', 'inserted 2', 'line 2', 'line 3']);
});
it('should handle inserting at start of file', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
fileHandler.insertLines(filePath, 1, ['new first']);
const content = fileHandler.readLines(filePath, 1, 3);
expect(content.lines).toEqual(['new first', 'line 1', 'line 2']);
});
it('should handle inserting at end of file', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
fileHandler.insertLines(filePath, 3, ['line 3', 'line 4']);
const content = fileHandler.readLines(filePath, 1, 4);
expect(content.lines).toEqual(['line 1', 'line 2', 'line 3', 'line 4']);
});
it('should preserve line endings when inserting', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\r\nline 2\r\n');
fileHandler.insertLines(filePath, 2, ['inserted']);
const result = fileHandler.readLines(filePath, 1, 3);
expect(result.content).toContain('\r\n');
});
it('should throw error for read-only files', () => {
const filePath = join(testDir, 'readonly.txt');
writeFileSync(filePath, 'line 1\n');
chmodSync(filePath, 0o444); // Read-only
expect(() => {
fileHandler.insertLines(filePath, 1, ['new line']);
}).toThrow(/Permission denied|EACCES/);
});
it('should throw error for invalid insert position', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
fileHandler.insertLines(filePath, 10, ['new line']);
}).toThrow('Insert position exceeds file length');
});
it('should require integer insert positions', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
fileHandler.insertLines(filePath, 1.2, ['new line']);
}).toThrow('Insert position must be an integer');
});
it('should handle empty line array', () => {
const filePath = join(testDir, 'test.txt');
const originalContent = 'line 1\nline 2\n';
writeFileSync(filePath, originalContent);
fileHandler.insertLines(filePath, 2, []);
const result = fileHandler.readLines(filePath, 1, 2);
expect(result.lines).toEqual(['line 1', 'line 2']);
});
});
describe('Delete Lines - RED', () => {
it('should delete specified line range', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\nline 4\nline 5\n');
fileHandler.deleteLines(filePath, 2, 4);
const result = fileHandler.readLines(filePath, 1, 2);
expect(result.lines).toEqual(['line 1', 'line 5']);
});
it('should handle deleting first lines', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
fileHandler.deleteLines(filePath, 1, 1);
const result = fileHandler.readLines(filePath, 1, 2);
expect(result.lines).toEqual(['line 2', 'line 3']);
});
it('should handle deleting last lines', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
fileHandler.deleteLines(filePath, 3, 3);
const result = fileHandler.readLines(filePath, 1, 2);
expect(result.lines).toEqual(['line 1', 'line 2']);
});
it('should handle deleting all content', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
fileHandler.deleteLines(filePath, 1, 3);
expect(() => {
fileHandler.readLines(filePath, 1, 1);
}).toThrow('File is empty');
});
it('should preserve line endings after deletion', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\r\nline 2\r\nline 3\r\n');
fileHandler.deleteLines(filePath, 2, 2);
const result = fileHandler.readLines(filePath, 1, 2);
expect(result.lineEnding).toBe('\r\n');
});
it('should throw error for invalid delete range', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
fileHandler.deleteLines(filePath, 1, 10);
}).toThrow('Line range exceeds file length');
});
it('should require integer delete positions', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
expect(() => {
fileHandler.deleteLines(filePath, 1, 2.5);
}).toThrow('Line numbers must be integers');
});
});
describe('Get File Snapshot - RED', () => {
it('should capture entire file content', () => {
const filePath = join(testDir, 'test.txt');
const content = 'line 1\nline 2\nline 3\n';
writeFileSync(filePath, content);
const snapshot = fileHandler.getFileSnapshot(filePath);
expect(snapshot.content).toBe(content);
});
it('should include file metadata', () => {
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'line 1\nline 2\nline 3\n');
const snapshot = fileHandler.getFileSnapshot(filePath);
expect(snapshot.lineCount).toBe(3);
expect(snapshot.filePath).toBe(filePath);
expect(snapshot.timestamp).toBeGreaterThan(0);
});
it('should handle empty files', () => {
const filePath = join(testDir, 'empty.txt');
writeFileSync(filePath, '');
const snapshot = fileHandler.getFileSnapshot(filePath);
expect(snapshot.content).toBe('');
expect(snapshot.lineCount).toBe(0);
});
it('should throw error for non-existent files', () => {
expect(() => {
fileHandler.getFileSnapshot('/non/existent/file.txt');
}).toThrow('File not found');
});
});
describe('Is Text File - RED', () => {
it('should identify text files correctly', () => {
const filePath = join(testDir, 'text.txt');
writeFileSync(filePath, 'This is plain text\n');
expect(fileHandler.isTextFile(filePath)).toBe(true);
});
it('should identify binary files correctly', () => {
const filePath = join(testDir, 'binary.bin');
writeFileSync(filePath, Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]));
expect(fileHandler.isTextFile(filePath)).toBe(false);
});
it('should handle common text file extensions', () => {
const extensions = ['.txt', '.js', '.ts', '.py', '.java', '.md', '.json', '.yml'];
extensions.forEach((ext) => {
const filePath = join(testDir, `test${ext}`);
writeFileSync(filePath, 'text content');
expect(fileHandler.isTextFile(filePath)).toBe(true);
});
});
});
describe('Path Access Control', () => {
let allowFilePath: string;
let restrictedHandler: FileHandler;
beforeEach(() => {
allowFilePath = join(testDir, 'paths.allow');
});
it('should allow all paths when no access control is provided', () => {
const handler = new FileHandler();
const filePath = join(testDir, 'test.txt');
writeFileSync(filePath, 'content');
expect(() => handler.readLines(filePath, 1, 1)).not.toThrow();
});
it('should block read access to paths not in allowlist', () => {
// Create a restricted allowlist
writeFileSync(allowFilePath, '/allowed/path/**\n');
const pathControl = new PathAccessControl(allowFilePath);
restrictedHandler = new FileHandler(pathControl);
const filePath = join(testDir, 'blocked.txt');
writeFileSync(filePath, 'content');
expect(() => {
restrictedHandler.readLines(filePath, 1, 1);
}).toThrow('Access denied: path not in allowlist');
});
it('should allow read access to paths in allowlist', () => {
// Allow testDir
writeFileSync(allowFilePath, `${testDir}/**\n`);
const pathControl = new PathAccessControl(allowFilePath);
restrictedHandler = new FileHandler(pathControl);
const filePath = join(testDir, 'allowed.txt');
writeFileSync(filePath, 'line 1\nline 2');
const result = restrictedHandler.readLines(filePath, 1, 2);
expect(result.lines).toEqual(['line 1', 'line 2']);
});
it('should block write operations to paths not in allowlist', () => {
writeFileSync(allowFilePath, '/allowed/path/**\n');
const pathControl = new PathAccessControl(allowFilePath);
restrictedHandler = new FileHandler(pathControl);
const filePath = join(testDir, 'blocked.txt');
writeFileSync(filePath, 'content');
expect(() => {
restrictedHandler.insertLines(filePath, 1, ['new line']);
}).toThrow('Access denied: path not in allowlist');
expect(() => {
restrictedHandler.deleteLines(filePath, 1, 1);
}).toThrow('Access denied: path not in allowlist');
});
it('should allow write operations to paths in allowlist', () => {
writeFileSync(allowFilePath, `${testDir}/**\n`);
const pathControl = new PathAccessControl(allowFilePath);
restrictedHandler = new FileHandler(pathControl);
const filePath = join(testDir, 'allowed.txt');
writeFileSync(filePath, 'line 1\nline 2\n');
expect(() => {
restrictedHandler.insertLines(filePath, 1, ['new line']);
}).not.toThrow();
expect(() => {
restrictedHandler.deleteLines(filePath, 1, 1);
}).not.toThrow();
});
it('should respect negation patterns in allowlist', () => {
writeFileSync(allowFilePath, `${testDir}/**\n!${testDir}/blocked/**\n`);
const pathControl = new PathAccessControl(allowFilePath);
restrictedHandler = new FileHandler(pathControl);
// Create blocked subdirectory
const blockedDir = join(testDir, 'blocked');
mkdirSync(blockedDir, { recursive: true });
const allowedFile = join(testDir, 'allowed.txt');
const blockedFile = join(blockedDir, 'blocked.txt');
writeFileSync(allowedFile, 'content');
writeFileSync(blockedFile, 'content');
expect(() => {
restrictedHandler.readLines(allowedFile, 1, 1);
}).not.toThrow();
expect(() => {
restrictedHandler.readLines(blockedFile, 1, 1);
}).toThrow('Access denied: path not in allowlist');
});
it('should block getFileSnapshot for paths not in allowlist', () => {
writeFileSync(allowFilePath, '/allowed/path/**\n');
const pathControl = new PathAccessControl(allowFilePath);
restrictedHandler = new FileHandler(pathControl);
const filePath = join(testDir, 'blocked.txt');
writeFileSync(filePath, 'content');
expect(() => {
restrictedHandler.getFileSnapshot(filePath);
}).toThrow('Access denied: path not in allowlist');
});
});
});