spck 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for FilesystemService
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from 'fs/promises';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import * as os from 'os';
|
|
9
|
+
import * as crypto from 'crypto';
|
|
10
|
+
import * as fossilDelta from 'fossil-delta';
|
|
11
|
+
import { FilesystemService } from '../FilesystemService.js';
|
|
12
|
+
import { ErrorCode } from '../../types.js';
|
|
13
|
+
|
|
14
|
+
describe('FilesystemService', () => {
|
|
15
|
+
let service: FilesystemService;
|
|
16
|
+
let testRoot: string;
|
|
17
|
+
let mockSocket: any;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
// Create temporary test directory
|
|
21
|
+
testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'fs-test-'));
|
|
22
|
+
|
|
23
|
+
service = new FilesystemService(testRoot, {
|
|
24
|
+
maxFileSize: '10MB',
|
|
25
|
+
watchIgnorePatterns: ['.git', 'node_modules'],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
mockSocket = {
|
|
29
|
+
id: 'test-socket',
|
|
30
|
+
data: { uid: 'test-user', deviceId: 'test-device' },
|
|
31
|
+
emit: vi.fn(),
|
|
32
|
+
on: vi.fn(),
|
|
33
|
+
off: vi.fn(),
|
|
34
|
+
broadcast: {
|
|
35
|
+
emit: vi.fn(),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
// Clean up test directory
|
|
42
|
+
try {
|
|
43
|
+
await fs.rm(testRoot, { recursive: true, force: true });
|
|
44
|
+
} catch {}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('Path Validation & Security', () => {
|
|
48
|
+
it('should accept valid relative paths', async () => {
|
|
49
|
+
await fs.writeFile(path.join(testRoot, 'test.txt'), 'content');
|
|
50
|
+
|
|
51
|
+
const result = await service.handle('exists', { path: '/test.txt' }, mockSocket);
|
|
52
|
+
|
|
53
|
+
expect(result.exists).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should prevent directory traversal attacks', async () => {
|
|
57
|
+
await expect(
|
|
58
|
+
service.handle('exists', { path: '../../etc/passwd' }, mockSocket)
|
|
59
|
+
).rejects.toMatchObject({
|
|
60
|
+
code: ErrorCode.INVALID_PATH,
|
|
61
|
+
message: expect.stringContaining('directory traversal'),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should prevent access outside root directory', async () => {
|
|
66
|
+
// The path validation happens during normalization
|
|
67
|
+
// When path escapes root, it gets clamped to root, so file won't exist
|
|
68
|
+
const result = await service.handle(
|
|
69
|
+
'exists',
|
|
70
|
+
{ path: '/../../../../etc/passwd' },
|
|
71
|
+
mockSocket
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Should not find /etc/passwd since it's outside the test root
|
|
75
|
+
expect(result.exists).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should normalize paths correctly', async () => {
|
|
79
|
+
await fs.writeFile(path.join(testRoot, 'test.txt'), 'content');
|
|
80
|
+
|
|
81
|
+
const result = await service.handle('exists', { path: '//test.txt' }, mockSocket);
|
|
82
|
+
|
|
83
|
+
expect(result.exists).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('File Operations - exists', () => {
|
|
88
|
+
it('should return true for existing file', async () => {
|
|
89
|
+
await fs.writeFile(path.join(testRoot, 'exists.txt'), 'content');
|
|
90
|
+
|
|
91
|
+
const result = await service.handle('exists', { path: '/exists.txt' }, mockSocket);
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual({ exists: true });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should return false for non-existing file', async () => {
|
|
97
|
+
const result = await service.handle('exists', { path: '/missing.txt' }, mockSocket);
|
|
98
|
+
|
|
99
|
+
expect(result).toEqual({ exists: false });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should return true for existing directory', async () => {
|
|
103
|
+
await fs.mkdir(path.join(testRoot, 'testdir'));
|
|
104
|
+
|
|
105
|
+
const result = await service.handle('exists', { path: '/testdir' }, mockSocket);
|
|
106
|
+
|
|
107
|
+
expect(result).toEqual({ exists: true });
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('File Operations - readFile', () => {
|
|
112
|
+
it('should read text file with UTF-8 encoding', async () => {
|
|
113
|
+
const content = 'Hello, World! 你好';
|
|
114
|
+
await fs.writeFile(path.join(testRoot, 'hello.txt'), content, 'utf8');
|
|
115
|
+
|
|
116
|
+
const result = await service.handle(
|
|
117
|
+
'readFile',
|
|
118
|
+
{ path: '/hello.txt', encoding: 'utf8' },
|
|
119
|
+
mockSocket
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(result.contents).toBe(content);
|
|
123
|
+
expect(result.encoding).toBe('utf8');
|
|
124
|
+
expect(result.sha256).toBeTruthy();
|
|
125
|
+
expect(result.size).toBe(Buffer.from(content, 'utf8').length);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should compute correct SHA256 hash', async () => {
|
|
129
|
+
const content = 'test content';
|
|
130
|
+
await fs.writeFile(path.join(testRoot, 'test.txt'), content);
|
|
131
|
+
|
|
132
|
+
const result = await service.handle(
|
|
133
|
+
'readFile',
|
|
134
|
+
{ path: '/test.txt' },
|
|
135
|
+
mockSocket
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const expectedHash = crypto.createHash('sha256').update(content).digest('hex');
|
|
139
|
+
expect(result.sha256).toBe(expectedHash);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should default to UTF-8 encoding', async () => {
|
|
143
|
+
await fs.writeFile(path.join(testRoot, 'default.txt'), 'content');
|
|
144
|
+
|
|
145
|
+
const result = await service.handle(
|
|
146
|
+
'readFile',
|
|
147
|
+
{ path: '/default.txt' },
|
|
148
|
+
mockSocket
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(result.encoding).toBe('utf8');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should handle binary files', async () => {
|
|
155
|
+
const buffer = Buffer.from([0x00, 0x01, 0x02, 0xFF]);
|
|
156
|
+
await fs.writeFile(path.join(testRoot, 'binary.dat'), buffer);
|
|
157
|
+
|
|
158
|
+
const result = await service.handle(
|
|
159
|
+
'readFile',
|
|
160
|
+
{ path: '/binary.dat', encoding: 'binary', requestId: 123 },
|
|
161
|
+
mockSocket
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(result.encoding).toBe('binary');
|
|
165
|
+
expect(result.sha256).toBeTruthy();
|
|
166
|
+
expect(result.buffer).toBeInstanceOf(Buffer);
|
|
167
|
+
expect(result.buffer).toEqual(buffer);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should throw error for non-existing file', async () => {
|
|
171
|
+
await expect(
|
|
172
|
+
service.handle('readFile', { path: '/missing.txt' }, mockSocket)
|
|
173
|
+
).rejects.toMatchObject({
|
|
174
|
+
code: ErrorCode.FILE_NOT_FOUND,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should reject files exceeding size limit', async () => {
|
|
179
|
+
// Create a large file (11MB, exceeds 10MB limit)
|
|
180
|
+
const largeContent = 'x'.repeat(11 * 1024 * 1024);
|
|
181
|
+
await fs.writeFile(path.join(testRoot, 'large.txt'), largeContent);
|
|
182
|
+
|
|
183
|
+
await expect(
|
|
184
|
+
service.handle('readFile', { path: '/large.txt' }, mockSocket)
|
|
185
|
+
).rejects.toMatchObject({
|
|
186
|
+
code: ErrorCode.FILE_TOO_LARGE,
|
|
187
|
+
message: expect.stringContaining('too large'),
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('File Operations - writeFile', () => {
|
|
193
|
+
it('should write text file', async () => {
|
|
194
|
+
const content = 'test content';
|
|
195
|
+
|
|
196
|
+
const result = await service.handle(
|
|
197
|
+
'write',
|
|
198
|
+
{ path: '/new.txt', contents: content },
|
|
199
|
+
mockSocket
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
expect(result.success).toBe(true);
|
|
203
|
+
expect(result.sha256).toBeTruthy();
|
|
204
|
+
|
|
205
|
+
const written = await fs.readFile(path.join(testRoot, 'new.txt'), 'utf8');
|
|
206
|
+
expect(written).toBe(content);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should overwrite existing file', async () => {
|
|
210
|
+
await fs.writeFile(path.join(testRoot, 'overwrite.txt'), 'old content');
|
|
211
|
+
|
|
212
|
+
await service.handle(
|
|
213
|
+
'write',
|
|
214
|
+
{ path: '/overwrite.txt', contents: 'new content' },
|
|
215
|
+
mockSocket
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const content = await fs.readFile(path.join(testRoot, 'overwrite.txt'), 'utf8');
|
|
219
|
+
expect(content).toBe('new content');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should detect write conflicts with expectedHash', async () => {
|
|
223
|
+
await fs.writeFile(path.join(testRoot, 'conflict.txt'), 'original');
|
|
224
|
+
|
|
225
|
+
const wrongHash = 'wrong-hash-value';
|
|
226
|
+
|
|
227
|
+
await expect(
|
|
228
|
+
service.handle(
|
|
229
|
+
'write',
|
|
230
|
+
{
|
|
231
|
+
path: '/conflict.txt',
|
|
232
|
+
contents: 'modified',
|
|
233
|
+
expectedHash: wrongHash,
|
|
234
|
+
},
|
|
235
|
+
mockSocket
|
|
236
|
+
)
|
|
237
|
+
).rejects.toMatchObject({
|
|
238
|
+
code: ErrorCode.WRITE_CONFLICT,
|
|
239
|
+
message: expect.stringContaining('modified on server'),
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should allow write when expectedHash matches', async () => {
|
|
244
|
+
const original = 'original content';
|
|
245
|
+
await fs.writeFile(path.join(testRoot, 'match.txt'), original);
|
|
246
|
+
|
|
247
|
+
const correctHash = crypto.createHash('sha256').update(original).digest('hex');
|
|
248
|
+
|
|
249
|
+
const result = await service.handle(
|
|
250
|
+
'write',
|
|
251
|
+
{
|
|
252
|
+
path: '/match.txt',
|
|
253
|
+
contents: 'new content',
|
|
254
|
+
expectedHash: correctHash,
|
|
255
|
+
},
|
|
256
|
+
mockSocket
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(result.success).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should write binary file from base64-encoded string', async () => {
|
|
263
|
+
const bytes = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG header
|
|
264
|
+
const base64 = bytes.toString('base64');
|
|
265
|
+
|
|
266
|
+
const result = await service.handle(
|
|
267
|
+
'write',
|
|
268
|
+
{ path: '/image.png', contents: base64, encoding: 'binary' },
|
|
269
|
+
mockSocket
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
expect(result.success).toBe(true);
|
|
273
|
+
expect(result.sha256).toBeTruthy();
|
|
274
|
+
|
|
275
|
+
const written = await fs.readFile(path.join(testRoot, 'image.png'));
|
|
276
|
+
expect(written).toEqual(bytes);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should write binary file atomically from base64-encoded string', async () => {
|
|
280
|
+
const bytes = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD]);
|
|
281
|
+
const base64 = bytes.toString('base64');
|
|
282
|
+
|
|
283
|
+
const result = await service.handle(
|
|
284
|
+
'write',
|
|
285
|
+
{ path: '/data.bin', contents: base64, encoding: 'binary', atomic: true },
|
|
286
|
+
mockSocket
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
expect(result.success).toBe(true);
|
|
290
|
+
|
|
291
|
+
const written = await fs.readFile(path.join(testRoot, 'data.bin'));
|
|
292
|
+
expect(written).toEqual(bytes);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should write empty binary file', async () => {
|
|
296
|
+
const result = await service.handle(
|
|
297
|
+
'write',
|
|
298
|
+
{ path: '/empty.bin', contents: '', encoding: 'binary' },
|
|
299
|
+
mockSocket
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
expect(result.success).toBe(true);
|
|
303
|
+
|
|
304
|
+
const written = await fs.readFile(path.join(testRoot, 'empty.bin'));
|
|
305
|
+
expect(written.length).toBe(0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should preserve all byte values when writing binary via base64', async () => {
|
|
309
|
+
// Full range of byte values 0x00–0xFF
|
|
310
|
+
const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
311
|
+
const base64 = bytes.toString('base64');
|
|
312
|
+
|
|
313
|
+
await service.handle(
|
|
314
|
+
'write',
|
|
315
|
+
{ path: '/allbytes.bin', contents: base64, encoding: 'binary' },
|
|
316
|
+
mockSocket
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const written = await fs.readFile(path.join(testRoot, 'allbytes.bin'));
|
|
320
|
+
expect(written).toEqual(bytes);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should set executable permission when requested', async () => {
|
|
324
|
+
await service.handle(
|
|
325
|
+
'write',
|
|
326
|
+
{
|
|
327
|
+
path: '/script.sh',
|
|
328
|
+
contents: '#!/bin/bash\necho hello',
|
|
329
|
+
executable: true,
|
|
330
|
+
},
|
|
331
|
+
mockSocket
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
const stats = await fs.stat(path.join(testRoot, 'script.sh'));
|
|
335
|
+
// Check if executable bit is set (mode & 0o100)
|
|
336
|
+
expect(stats.mode & 0o100).toBeTruthy();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('File Operations - patchFile (Fossil Delta)', () => {
|
|
341
|
+
it('should apply fossil-delta patch successfully', async () => {
|
|
342
|
+
const original = Buffer.from('Hello, World!');
|
|
343
|
+
const modified = Buffer.from('Hello, Universe!');
|
|
344
|
+
await fs.writeFile(path.join(testRoot, 'patch.txt'), original);
|
|
345
|
+
|
|
346
|
+
const delta = fossilDelta.create(original, modified);
|
|
347
|
+
const baseHash = crypto.createHash('sha256').update(original).digest('hex');
|
|
348
|
+
const newHash = crypto.createHash('sha256').update(modified).digest('hex');
|
|
349
|
+
|
|
350
|
+
const result = await service.handle(
|
|
351
|
+
'patchFile',
|
|
352
|
+
{
|
|
353
|
+
path: '/patch.txt',
|
|
354
|
+
delta: delta, // Pass Buffer directly
|
|
355
|
+
baseHash,
|
|
356
|
+
newHash,
|
|
357
|
+
},
|
|
358
|
+
mockSocket
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
expect(result.success).toBe(true);
|
|
362
|
+
expect(result.finalHash).toBe(newHash);
|
|
363
|
+
|
|
364
|
+
const patched = await fs.readFile(path.join(testRoot, 'patch.txt'));
|
|
365
|
+
expect(patched.toString()).toBe('Hello, Universe!');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('should reject patch when base hash mismatches', async () => {
|
|
369
|
+
await fs.writeFile(path.join(testRoot, 'mismatch.txt'), 'content');
|
|
370
|
+
|
|
371
|
+
const delta = Buffer.from([0x00]);
|
|
372
|
+
const wrongBaseHash = 'wrong-hash';
|
|
373
|
+
|
|
374
|
+
await expect(
|
|
375
|
+
service.handle(
|
|
376
|
+
'patchFile',
|
|
377
|
+
{
|
|
378
|
+
path: '/mismatch.txt',
|
|
379
|
+
delta: Array.from(delta),
|
|
380
|
+
baseHash: wrongBaseHash,
|
|
381
|
+
},
|
|
382
|
+
mockSocket
|
|
383
|
+
)
|
|
384
|
+
).rejects.toMatchObject({
|
|
385
|
+
code: ErrorCode.WRITE_CONFLICT,
|
|
386
|
+
message: expect.stringContaining('Base hash mismatch'),
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should verify final hash after patching', async () => {
|
|
391
|
+
const original = Buffer.from('test');
|
|
392
|
+
const modified = Buffer.from('tested');
|
|
393
|
+
await fs.writeFile(path.join(testRoot, 'verify.txt'), original);
|
|
394
|
+
|
|
395
|
+
const delta = fossilDelta.create(original, modified);
|
|
396
|
+
const baseHash = crypto.createHash('sha256').update(original).digest('hex');
|
|
397
|
+
const wrongNewHash = 'wrong-final-hash';
|
|
398
|
+
|
|
399
|
+
await expect(
|
|
400
|
+
service.handle(
|
|
401
|
+
'patchFile',
|
|
402
|
+
{
|
|
403
|
+
path: '/verify.txt',
|
|
404
|
+
delta: delta, // Pass Buffer directly
|
|
405
|
+
baseHash,
|
|
406
|
+
newHash: wrongNewHash,
|
|
407
|
+
},
|
|
408
|
+
mockSocket
|
|
409
|
+
)
|
|
410
|
+
).rejects.toMatchObject({
|
|
411
|
+
code: ErrorCode.DELTA_PATCH_FAILED,
|
|
412
|
+
message: expect.stringContaining('Final hash mismatch'),
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe('File Operations - getFileHash', () => {
|
|
418
|
+
it('should return file hash and metadata', async () => {
|
|
419
|
+
const content = 'test content';
|
|
420
|
+
await fs.writeFile(path.join(testRoot, 'hash.txt'), content);
|
|
421
|
+
|
|
422
|
+
const result = await service.handle('getFileHash', { path: '/hash.txt' }, mockSocket);
|
|
423
|
+
|
|
424
|
+
const expectedHash = crypto.createHash('sha256').update(content).digest('hex');
|
|
425
|
+
expect(result.hash).toBe(expectedHash);
|
|
426
|
+
expect(result.size).toBe(Buffer.from(content).length);
|
|
427
|
+
expect(result.mtime).toBeGreaterThan(0);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
describe('File Operations - remove', () => {
|
|
432
|
+
it('should remove file', async () => {
|
|
433
|
+
await fs.writeFile(path.join(testRoot, 'remove.txt'), 'content');
|
|
434
|
+
|
|
435
|
+
const result = await service.handle('remove', { path: '/remove.txt' }, mockSocket);
|
|
436
|
+
|
|
437
|
+
expect(result.success).toBe(true);
|
|
438
|
+
|
|
439
|
+
await expect(fs.access(path.join(testRoot, 'remove.txt'))).rejects.toThrow();
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should remove directory recursively', async () => {
|
|
443
|
+
const dirPath = path.join(testRoot, 'removedir');
|
|
444
|
+
await fs.mkdir(dirPath);
|
|
445
|
+
await fs.writeFile(path.join(dirPath, 'file.txt'), 'content');
|
|
446
|
+
|
|
447
|
+
const result = await service.handle('remove', { path: '/removedir' }, mockSocket);
|
|
448
|
+
|
|
449
|
+
expect(result.success).toBe(true);
|
|
450
|
+
await expect(fs.access(dirPath)).rejects.toThrow();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should succeed for non-existing file (idempotent)', async () => {
|
|
454
|
+
const result = await service.handle('remove', { path: '/nonexistent.txt' }, mockSocket);
|
|
455
|
+
expect(result.success).toBe(true);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('Directory Operations - mkdir', () => {
|
|
460
|
+
it('should create directory', async () => {
|
|
461
|
+
const result = await service.handle('mkdir', { path: '/newdir' }, mockSocket);
|
|
462
|
+
|
|
463
|
+
expect(result.success).toBe(true);
|
|
464
|
+
|
|
465
|
+
const stats = await fs.stat(path.join(testRoot, 'newdir'));
|
|
466
|
+
expect(stats.isDirectory()).toBe(true);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it('should create nested directories with mkdirp', async () => {
|
|
470
|
+
const result = await service.handle('mkdirp', { path: '/a/b/c' }, mockSocket);
|
|
471
|
+
|
|
472
|
+
expect(result.success).toBe(true);
|
|
473
|
+
|
|
474
|
+
const stats = await fs.stat(path.join(testRoot, 'a', 'b', 'c'));
|
|
475
|
+
expect(stats.isDirectory()).toBe(true);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
describe('Directory Operations - readdir', () => {
|
|
480
|
+
beforeEach(async () => {
|
|
481
|
+
await fs.mkdir(path.join(testRoot, 'readdir-test'));
|
|
482
|
+
await fs.writeFile(path.join(testRoot, 'readdir-test', 'file1.txt'), '');
|
|
483
|
+
await fs.writeFile(path.join(testRoot, 'readdir-test', 'file2.txt'), '');
|
|
484
|
+
await fs.mkdir(path.join(testRoot, 'readdir-test', 'subdir'));
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should list directory contents', async () => {
|
|
488
|
+
const result = await service.handle('readdir', { path: '/readdir-test' }, mockSocket);
|
|
489
|
+
|
|
490
|
+
expect(result.entries).toHaveLength(3);
|
|
491
|
+
expect(result.entries).toEqual(
|
|
492
|
+
expect.arrayContaining(['file1.txt', 'file2.txt', 'subdir'])
|
|
493
|
+
);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('should skip files when skipFiles is true', async () => {
|
|
497
|
+
const result = await service.handle(
|
|
498
|
+
'readdir',
|
|
499
|
+
{ path: '/readdir-test', skipFiles: true },
|
|
500
|
+
mockSocket
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
expect(result.entries).toHaveLength(1);
|
|
504
|
+
expect(result.entries[0]).toBe('subdir');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('should skip folders when skipFolders is true', async () => {
|
|
508
|
+
const result = await service.handle(
|
|
509
|
+
'readdir',
|
|
510
|
+
{ path: '/readdir-test', skipFolders: true },
|
|
511
|
+
mockSocket
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
expect(result.entries).toHaveLength(2);
|
|
515
|
+
expect(result.entries).toEqual(
|
|
516
|
+
expect.arrayContaining(['file1.txt', 'file2.txt'])
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('should throw error for non-existing directory', async () => {
|
|
521
|
+
await expect(
|
|
522
|
+
service.handle('readdir', { path: '/nonexistent' }, mockSocket)
|
|
523
|
+
).rejects.toMatchObject({
|
|
524
|
+
code: ErrorCode.FILE_NOT_FOUND,
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
describe('Directory Operations - readdirDeep', () => {
|
|
530
|
+
beforeEach(async () => {
|
|
531
|
+
// Create nested directory structure
|
|
532
|
+
await fs.mkdir(path.join(testRoot, 'deep'));
|
|
533
|
+
await fs.writeFile(path.join(testRoot, 'deep', 'root.txt'), '');
|
|
534
|
+
await fs.mkdir(path.join(testRoot, 'deep', 'level1'));
|
|
535
|
+
await fs.writeFile(path.join(testRoot, 'deep', 'level1', 'file1.txt'), '');
|
|
536
|
+
await fs.mkdir(path.join(testRoot, 'deep', 'level1', 'level2'));
|
|
537
|
+
await fs.writeFile(path.join(testRoot, 'deep', 'level1', 'level2', 'file2.txt'), '');
|
|
538
|
+
await fs.mkdir(path.join(testRoot, 'deep', '.git'));
|
|
539
|
+
await fs.writeFile(path.join(testRoot, 'deep', '.git', 'config'), '');
|
|
540
|
+
await fs.mkdir(path.join(testRoot, 'deep', 'node_modules'));
|
|
541
|
+
await fs.writeFile(path.join(testRoot, 'deep', 'node_modules', 'package.json'), '');
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it('should recursively list all files and folders', async () => {
|
|
545
|
+
const result = await service.handle('readdirDeep', { path: '/deep' }, mockSocket);
|
|
546
|
+
|
|
547
|
+
expect(result).toContain('deep/root.txt');
|
|
548
|
+
expect(result).toContain('deep/level1/file1.txt');
|
|
549
|
+
expect(result).toContain('deep/level1/level2/file2.txt');
|
|
550
|
+
|
|
551
|
+
expect(result).toContain('deep/level1');
|
|
552
|
+
expect(result).toContain('deep/level1/level2');
|
|
553
|
+
|
|
554
|
+
// Should NOT include .git by default (auto-ignored)
|
|
555
|
+
// node_modules is included
|
|
556
|
+
expect(result).toContain('deep/node_modules');
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('should return only files when folders=false', async () => {
|
|
560
|
+
const result = await service.handle(
|
|
561
|
+
'readdirDeep',
|
|
562
|
+
{ path: '/deep', files: true, folders: false },
|
|
563
|
+
mockSocket
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
expect(result.length).toBeGreaterThan(0);
|
|
567
|
+
expect(result).toContain('deep/root.txt');
|
|
568
|
+
expect(result).toContain('deep/level1/file1.txt');
|
|
569
|
+
// Should not contain folders
|
|
570
|
+
expect(result).not.toContain('deep/level1');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('should return only folders when files=false', async () => {
|
|
574
|
+
const result = await service.handle(
|
|
575
|
+
'readdirDeep',
|
|
576
|
+
{ path: '/deep', files: false, folders: true },
|
|
577
|
+
mockSocket
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
expect(result.length).toBeGreaterThan(0);
|
|
581
|
+
expect(result).toContain('deep/level1');
|
|
582
|
+
expect(result).toContain('deep/level1/level2');
|
|
583
|
+
// Should not contain files
|
|
584
|
+
expect(result).not.toContain('deep/root.txt');
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('should filter ignored names', async () => {
|
|
588
|
+
const result = await service.handle(
|
|
589
|
+
'readdirDeep',
|
|
590
|
+
{ path: '/deep', ignoreName: '.git:node_modules' },
|
|
591
|
+
mockSocket
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
// Should NOT contain .git or node_modules
|
|
595
|
+
expect(result.some((f: string) => f.includes('.git'))).toBe(false);
|
|
596
|
+
expect(result.some((f: string) => f.includes('node_modules'))).toBe(false);
|
|
597
|
+
|
|
598
|
+
// Should still contain other files/folders
|
|
599
|
+
expect(result).toContain('deep/root.txt');
|
|
600
|
+
expect(result).toContain('deep/level1');
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('should handle empty ignoreName gracefully', async () => {
|
|
604
|
+
const result = await service.handle(
|
|
605
|
+
'readdirDeep',
|
|
606
|
+
{ path: '/deep', ignoreName: '' },
|
|
607
|
+
mockSocket
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
expect(result.length).toBeGreaterThan(0);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('should handle ignoreName with spaces', async () => {
|
|
614
|
+
const result = await service.handle(
|
|
615
|
+
'readdirDeep',
|
|
616
|
+
{ path: '/deep', ignoreName: ' .git : node_modules ' },
|
|
617
|
+
mockSocket
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
expect(result.some((f: string) => f.includes('.git'))).toBe(false);
|
|
621
|
+
expect(result.some((f: string) => f.includes('node_modules'))).toBe(false);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('should throw error for non-existing directory', async () => {
|
|
625
|
+
await expect(
|
|
626
|
+
service.handle('readdirDeep', { path: '/nonexistent' }, mockSocket)
|
|
627
|
+
).rejects.toMatchObject({
|
|
628
|
+
code: ErrorCode.FILE_NOT_FOUND,
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('should handle empty directory', async () => {
|
|
633
|
+
await fs.mkdir(path.join(testRoot, 'empty'));
|
|
634
|
+
|
|
635
|
+
const result = await service.handle('readdirDeep', { path: '/empty' }, mockSocket);
|
|
636
|
+
|
|
637
|
+
expect(result).toEqual([]);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('should filter results with matchPattern regex', async () => {
|
|
641
|
+
const result = await service.handle(
|
|
642
|
+
'readdirDeep',
|
|
643
|
+
{ path: '/deep', matchPattern: '\\.txt$' },
|
|
644
|
+
mockSocket
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
// Should only contain .txt files
|
|
648
|
+
expect(result).toContain('deep/root.txt');
|
|
649
|
+
expect(result).toContain('deep/level1/file1.txt');
|
|
650
|
+
expect(result).toContain('deep/level1/level2/file2.txt');
|
|
651
|
+
|
|
652
|
+
// Should NOT contain non-.txt files
|
|
653
|
+
expect(result.some((f: string) => f.endsWith('.json'))).toBe(false);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('should filter folders with matchPattern regex', async () => {
|
|
657
|
+
const result = await service.handle(
|
|
658
|
+
'readdirDeep',
|
|
659
|
+
{ path: '/deep', files: false, folders: true, matchPattern: 'level' },
|
|
660
|
+
mockSocket
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
// Should only contain folders with "level" in path
|
|
664
|
+
expect(result).toContain('deep/level1');
|
|
665
|
+
expect(result).toContain('deep/level1/level2');
|
|
666
|
+
|
|
667
|
+
// Should NOT contain node_modules
|
|
668
|
+
expect(result).not.toContain('deep/node_modules');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('should limit results when limit parameter is provided', async () => {
|
|
672
|
+
const result = await service.handle(
|
|
673
|
+
'readdirDeep',
|
|
674
|
+
{ path: '/deep', limit: 2 },
|
|
675
|
+
mockSocket
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
// Should return exactly 2 results (folders + files combined)
|
|
679
|
+
expect(result.length).toBe(2);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('should combine matchPattern and limit correctly', async () => {
|
|
683
|
+
const result = await service.handle(
|
|
684
|
+
'readdirDeep',
|
|
685
|
+
{ path: '/deep', matchPattern: '\\.txt$', limit: 2 },
|
|
686
|
+
mockSocket
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// Should return max 2 .txt files
|
|
690
|
+
expect(result.length).toBeLessThanOrEqual(2);
|
|
691
|
+
|
|
692
|
+
// All returned files should match the pattern
|
|
693
|
+
result.forEach((file: string) => {
|
|
694
|
+
expect(file).toMatch(/\.txt$/);
|
|
695
|
+
});
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it('should not truncate when limit is not reached', async () => {
|
|
699
|
+
const result = await service.handle(
|
|
700
|
+
'readdirDeep',
|
|
701
|
+
{ path: '/deep', limit: 1000 },
|
|
702
|
+
mockSocket
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
// Should return all results when limit is high
|
|
706
|
+
expect(result.length).toBeGreaterThan(0);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('should handle limit of 0', async () => {
|
|
710
|
+
const result = await service.handle(
|
|
711
|
+
'readdirDeep',
|
|
712
|
+
{ path: '/deep', limit: 0 },
|
|
713
|
+
mockSocket
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
expect(result).toEqual([]);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it('should handle limit of 1', async () => {
|
|
720
|
+
const result = await service.handle(
|
|
721
|
+
'readdirDeep',
|
|
722
|
+
{ path: '/deep', limit: 1 },
|
|
723
|
+
mockSocket
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
expect(result.length).toBe(1);
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it('should return all results when no limit or pattern', async () => {
|
|
730
|
+
const result = await service.handle(
|
|
731
|
+
'readdirDeep',
|
|
732
|
+
{ path: '/deep' },
|
|
733
|
+
mockSocket
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
expect(result.length).toBeGreaterThan(0);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('should handle invalid regex in matchPattern', async () => {
|
|
740
|
+
await expect(
|
|
741
|
+
service.handle(
|
|
742
|
+
'readdirDeep',
|
|
743
|
+
{ path: '/deep', matchPattern: '[invalid(' },
|
|
744
|
+
mockSocket
|
|
745
|
+
)
|
|
746
|
+
).rejects.toMatchObject({
|
|
747
|
+
code: ErrorCode.INVALID_PARAMS,
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it('should apply matchPattern to full relative path', async () => {
|
|
752
|
+
const result = await service.handle(
|
|
753
|
+
'readdirDeep',
|
|
754
|
+
{ path: '/deep', matchPattern: '^deep/level1/' },
|
|
755
|
+
mockSocket
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
// Should only match paths starting with "deep/level1/"
|
|
759
|
+
result.forEach((path: string) => {
|
|
760
|
+
expect(path).toMatch(/^deep\/level1\//);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// Should NOT contain root.txt or node_modules
|
|
764
|
+
expect(result).not.toContain('deep/root.txt');
|
|
765
|
+
expect(result).not.toContain('deep/node_modules');
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
describe('Directory Operations - rmdir', () => {
|
|
770
|
+
it('should remove empty directory', async () => {
|
|
771
|
+
await fs.mkdir(path.join(testRoot, 'emptydir'));
|
|
772
|
+
|
|
773
|
+
const result = await service.handle('rmdir', { path: '/emptydir' }, mockSocket);
|
|
774
|
+
|
|
775
|
+
expect(result.success).toBe(true);
|
|
776
|
+
await expect(fs.access(path.join(testRoot, 'emptydir'))).rejects.toThrow();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('should fail to remove non-empty directory', async () => {
|
|
780
|
+
await fs.mkdir(path.join(testRoot, 'nonempty'));
|
|
781
|
+
await fs.writeFile(path.join(testRoot, 'nonempty', 'file.txt'), '');
|
|
782
|
+
|
|
783
|
+
await expect(
|
|
784
|
+
service.handle('rmdir', { path: '/nonempty' }, mockSocket)
|
|
785
|
+
).rejects.toThrow();
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
describe('File Metadata - lstat', () => {
|
|
790
|
+
it('should return file metadata', async () => {
|
|
791
|
+
await fs.writeFile(path.join(testRoot, 'stat.txt'), 'content');
|
|
792
|
+
|
|
793
|
+
const result = await service.handle('lstat', { path: '/stat.txt' }, mockSocket);
|
|
794
|
+
|
|
795
|
+
expect(result).toMatchObject({
|
|
796
|
+
mode: expect.any(Number),
|
|
797
|
+
size: expect.any(Number),
|
|
798
|
+
mtimeMs: expect.any(Number),
|
|
799
|
+
ctimeMs: expect.any(Number),
|
|
800
|
+
atimeMs: expect.any(Number),
|
|
801
|
+
isFile: true,
|
|
802
|
+
isDirectory: false,
|
|
803
|
+
isSymbolicLink: false,
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('should return directory metadata', async () => {
|
|
808
|
+
await fs.mkdir(path.join(testRoot, 'statdir'));
|
|
809
|
+
|
|
810
|
+
const result = await service.handle('lstat', { path: '/statdir' }, mockSocket);
|
|
811
|
+
|
|
812
|
+
expect(result.isFile).toBe(false);
|
|
813
|
+
expect(result.isDirectory).toBe(true);
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
describe('File Manipulation - mv (move/rename)', () => {
|
|
818
|
+
it('should rename file', async () => {
|
|
819
|
+
await fs.writeFile(path.join(testRoot, 'old.txt'), 'content');
|
|
820
|
+
|
|
821
|
+
const result = await service.handle(
|
|
822
|
+
'mv',
|
|
823
|
+
{ src: '/old.txt', target: '/new.txt' },
|
|
824
|
+
mockSocket
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
expect(result).toBe('file');
|
|
828
|
+
|
|
829
|
+
await expect(fs.access(path.join(testRoot, 'old.txt'))).rejects.toThrow();
|
|
830
|
+
const content = await fs.readFile(path.join(testRoot, 'new.txt'), 'utf8');
|
|
831
|
+
expect(content).toBe('content');
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('should prevent overwrite when opts.overwrite is false', async () => {
|
|
835
|
+
await fs.writeFile(path.join(testRoot, 'src.txt'), 'source');
|
|
836
|
+
await fs.writeFile(path.join(testRoot, 'dst.txt'), 'destination');
|
|
837
|
+
|
|
838
|
+
await expect(
|
|
839
|
+
service.handle(
|
|
840
|
+
'mv',
|
|
841
|
+
{ src: '/src.txt', target: '/dst.txt', opts: { overwrite: false } },
|
|
842
|
+
mockSocket
|
|
843
|
+
)
|
|
844
|
+
).rejects.toMatchObject({
|
|
845
|
+
code: ErrorCode.INVALID_PATH,
|
|
846
|
+
message: expect.stringContaining('already exists'),
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('should allow overwrite when opts.overwrite is true', async () => {
|
|
851
|
+
await fs.writeFile(path.join(testRoot, 'src2.txt'), 'source');
|
|
852
|
+
await fs.writeFile(path.join(testRoot, 'dst2.txt'), 'destination');
|
|
853
|
+
|
|
854
|
+
const result = await service.handle(
|
|
855
|
+
'mv',
|
|
856
|
+
{ src: '/src2.txt', target: '/dst2.txt', opts: { overwrite: true } },
|
|
857
|
+
mockSocket
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
expect(result).toBe('file');
|
|
861
|
+
|
|
862
|
+
const content = await fs.readFile(path.join(testRoot, 'dst2.txt'), 'utf8');
|
|
863
|
+
expect(content).toBe('source');
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
describe('File Manipulation - copy', () => {
|
|
868
|
+
it('should copy file', async () => {
|
|
869
|
+
await fs.writeFile(path.join(testRoot, 'original.txt'), 'content');
|
|
870
|
+
|
|
871
|
+
const result = await service.handle(
|
|
872
|
+
'copy',
|
|
873
|
+
{ oldpath: '/original.txt', path: '/copy.txt' },
|
|
874
|
+
mockSocket
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
expect(result).toBe('file');
|
|
878
|
+
|
|
879
|
+
const original = await fs.readFile(path.join(testRoot, 'original.txt'), 'utf8');
|
|
880
|
+
const copied = await fs.readFile(path.join(testRoot, 'copy.txt'), 'utf8');
|
|
881
|
+
expect(copied).toBe(original);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it('should prevent overwrite when opts.overwrite is false', async () => {
|
|
885
|
+
await fs.writeFile(path.join(testRoot, 'src-copy.txt'), 'source');
|
|
886
|
+
await fs.writeFile(path.join(testRoot, 'dst-copy.txt'), 'destination');
|
|
887
|
+
|
|
888
|
+
await expect(
|
|
889
|
+
service.handle(
|
|
890
|
+
'copy',
|
|
891
|
+
{
|
|
892
|
+
oldpath: '/src-copy.txt',
|
|
893
|
+
path: '/dst-copy.txt',
|
|
894
|
+
opts: { overwrite: false },
|
|
895
|
+
},
|
|
896
|
+
mockSocket
|
|
897
|
+
)
|
|
898
|
+
).rejects.toMatchObject({
|
|
899
|
+
code: ErrorCode.INVALID_PATH,
|
|
900
|
+
message: expect.stringContaining('already exists'),
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
describe('Bulk Operations - bulkExists', () => {
|
|
906
|
+
it('should check existence of multiple files with relative base path', async () => {
|
|
907
|
+
// Create test files
|
|
908
|
+
await fs.writeFile(path.join(testRoot, 'file1.txt'), 'content1');
|
|
909
|
+
await fs.writeFile(path.join(testRoot, 'file2.txt'), 'content2');
|
|
910
|
+
await fs.mkdir(path.join(testRoot, 'subdir'));
|
|
911
|
+
await fs.writeFile(path.join(testRoot, 'subdir', 'file3.txt'), 'content3');
|
|
912
|
+
|
|
913
|
+
const result = await service.handle(
|
|
914
|
+
'bulkExists',
|
|
915
|
+
{
|
|
916
|
+
path: '/',
|
|
917
|
+
paths: ['file1.txt', 'file2.txt', 'missing.txt', 'subdir/file3.txt'],
|
|
918
|
+
},
|
|
919
|
+
mockSocket
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
expect(result).toEqual([1, 1, 0, 1]);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('should check existence with basePath /', async () => {
|
|
926
|
+
// Create nested directory structure
|
|
927
|
+
await fs.mkdir(path.join(testRoot, 'home'));
|
|
928
|
+
await fs.mkdir(path.join(testRoot, 'home', 'user'));
|
|
929
|
+
await fs.mkdir(path.join(testRoot, 'home', 'user', 'project'));
|
|
930
|
+
await fs.writeFile(path.join(testRoot, 'home', 'user', 'project', 'index.js'), 'console.log("hello")');
|
|
931
|
+
await fs.writeFile(path.join(testRoot, 'home', 'user', 'project', 'README.md'), '# Project');
|
|
932
|
+
|
|
933
|
+
const result = await service.handle(
|
|
934
|
+
'bulkExists',
|
|
935
|
+
{
|
|
936
|
+
path: '/',
|
|
937
|
+
paths: ['home/user/project/index.js', 'home/user/project/README.md', 'home/user/project/missing.js'],
|
|
938
|
+
},
|
|
939
|
+
mockSocket
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
expect(result).toEqual([1, 1, 0]);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it('should validate paths and prevent directory traversal in bulkExists', async () => {
|
|
946
|
+
await fs.writeFile(path.join(testRoot, 'safe.txt'), 'content');
|
|
947
|
+
|
|
948
|
+
const result = await service.handle(
|
|
949
|
+
'bulkExists',
|
|
950
|
+
{
|
|
951
|
+
path: '/',
|
|
952
|
+
paths: ['safe.txt', '../../etc/passwd'],
|
|
953
|
+
},
|
|
954
|
+
mockSocket
|
|
955
|
+
);
|
|
956
|
+
|
|
957
|
+
// safe.txt exists, but ../../etc/passwd should be clamped and return 0
|
|
958
|
+
expect(result[0]).toBe(1);
|
|
959
|
+
expect(result[1]).toBe(0);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it('should return empty array when paths is empty', async () => {
|
|
963
|
+
const result = await service.handle(
|
|
964
|
+
'bulkExists',
|
|
965
|
+
{
|
|
966
|
+
path: '/',
|
|
967
|
+
paths: [],
|
|
968
|
+
},
|
|
969
|
+
mockSocket
|
|
970
|
+
);
|
|
971
|
+
|
|
972
|
+
expect(result).toEqual([]);
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('should throw error when paths parameter is not an array', async () => {
|
|
976
|
+
await expect(
|
|
977
|
+
service.handle(
|
|
978
|
+
'bulkExists',
|
|
979
|
+
{
|
|
980
|
+
path: '/',
|
|
981
|
+
paths: 'not-an-array',
|
|
982
|
+
},
|
|
983
|
+
mockSocket
|
|
984
|
+
)
|
|
985
|
+
).rejects.toMatchObject({
|
|
986
|
+
code: ErrorCode.INVALID_PARAMS,
|
|
987
|
+
message: expect.stringContaining('paths must be an array'),
|
|
988
|
+
});
|
|
989
|
+
});
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
describe('Unknown Methods', () => {
|
|
993
|
+
it('should throw error for unknown method', async () => {
|
|
994
|
+
await expect(
|
|
995
|
+
service.handle('unknownMethod', {}, mockSocket)
|
|
996
|
+
).rejects.toMatchObject({
|
|
997
|
+
code: ErrorCode.METHOD_NOT_FOUND,
|
|
998
|
+
message: expect.stringContaining('Method not found'),
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
});
|