@mjasano/devtunnel 1.2.0 → 1.4.0
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/.claude/settings.local.json +5 -1
- package/.prettierignore +2 -0
- package/.prettierrc +8 -0
- package/CHANGELOG.md +34 -0
- package/README.md +138 -0
- package/bin/cli.js +40 -11
- package/eslint.config.js +32 -0
- package/package.json +12 -3
- package/public/app.js +839 -0
- package/public/index.html +10 -1501
- package/public/login.html +242 -0
- package/public/styles.css +857 -0
- package/server.js +276 -7
- package/test/server.test.js +204 -0
package/server.js
CHANGED
|
@@ -10,9 +10,172 @@ const crypto = require('crypto');
|
|
|
10
10
|
|
|
11
11
|
const app = express();
|
|
12
12
|
const server = http.createServer(app);
|
|
13
|
-
const wss = new WebSocket.Server({ server });
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
// Authentication configuration
|
|
15
|
+
const PASSCODE = process.env.PASSCODE || null;
|
|
16
|
+
const AUTH_TOKEN_EXPIRY = 24 * 60 * 60 * 1000; // 24 hours
|
|
17
|
+
const authTokens = new Map(); // token -> { createdAt }
|
|
18
|
+
|
|
19
|
+
// Generate random token
|
|
20
|
+
function generateAuthToken() {
|
|
21
|
+
return crypto.randomBytes(32).toString('hex');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Validate auth token
|
|
25
|
+
function validateAuthToken(token) {
|
|
26
|
+
if (!token) return false;
|
|
27
|
+
const session = authTokens.get(token);
|
|
28
|
+
if (!session) return false;
|
|
29
|
+
if (Date.now() - session.createdAt > AUTH_TOKEN_EXPIRY) {
|
|
30
|
+
authTokens.delete(token);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Auth middleware
|
|
37
|
+
function authMiddleware(req, res, next) {
|
|
38
|
+
// If no passcode configured, allow all
|
|
39
|
+
if (!PASSCODE) return next();
|
|
40
|
+
|
|
41
|
+
// Check for auth token in cookie or header
|
|
42
|
+
const token = req.cookies?.authToken || req.headers['x-auth-token'];
|
|
43
|
+
|
|
44
|
+
if (validateAuthToken(token)) {
|
|
45
|
+
return next();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Allow access to login endpoint and static login page
|
|
49
|
+
if (req.path === '/api/auth/login' || req.path === '/api/auth/status') {
|
|
50
|
+
return next();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Cookie parser middleware
|
|
57
|
+
app.use((req, _res, next) => {
|
|
58
|
+
const cookieHeader = req.headers.cookie;
|
|
59
|
+
req.cookies = {};
|
|
60
|
+
if (cookieHeader) {
|
|
61
|
+
cookieHeader.split(';').forEach(cookie => {
|
|
62
|
+
const [name, ...rest] = cookie.trim().split('=');
|
|
63
|
+
req.cookies[name] = rest.join('=');
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
next();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// WebSocket security configuration
|
|
70
|
+
const WS_MAX_MESSAGE_SIZE = 1024 * 1024; // 1MB max message size
|
|
71
|
+
const WS_RATE_LIMIT_WINDOW = 1000; // 1 second
|
|
72
|
+
const WS_RATE_LIMIT_MAX = 100; // max 100 messages per second
|
|
73
|
+
|
|
74
|
+
const wss = new WebSocket.Server({
|
|
75
|
+
server,
|
|
76
|
+
maxPayload: WS_MAX_MESSAGE_SIZE
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Rate limiting for WebSocket connections
|
|
80
|
+
const wsRateLimits = new Map();
|
|
81
|
+
|
|
82
|
+
function checkRateLimit(ws) {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
let limit = wsRateLimits.get(ws);
|
|
85
|
+
|
|
86
|
+
if (!limit) {
|
|
87
|
+
limit = { count: 0, resetAt: now + WS_RATE_LIMIT_WINDOW };
|
|
88
|
+
wsRateLimits.set(ws, limit);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (now > limit.resetAt) {
|
|
92
|
+
limit.count = 0;
|
|
93
|
+
limit.resetAt = now + WS_RATE_LIMIT_WINDOW;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
limit.count++;
|
|
97
|
+
|
|
98
|
+
if (limit.count > WS_RATE_LIMIT_MAX) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
app.use(express.json({ limit: '5mb' }));
|
|
106
|
+
|
|
107
|
+
// Serve login page without auth
|
|
108
|
+
app.get('/login', (_req, res) => {
|
|
109
|
+
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Auth status endpoint (no auth required)
|
|
113
|
+
app.get('/api/auth/status', (req, res) => {
|
|
114
|
+
const requiresAuth = !!PASSCODE;
|
|
115
|
+
const token = req.cookies?.authToken || req.headers['x-auth-token'];
|
|
116
|
+
const authenticated = !requiresAuth || validateAuthToken(token);
|
|
117
|
+
res.json({ requiresAuth, authenticated });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Login endpoint (no auth required)
|
|
121
|
+
app.post('/api/auth/login', (req, res) => {
|
|
122
|
+
if (!PASSCODE) {
|
|
123
|
+
return res.json({ success: true, message: 'No passcode required' });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { passcode } = req.body;
|
|
127
|
+
|
|
128
|
+
if (passcode === PASSCODE) {
|
|
129
|
+
const token = generateAuthToken();
|
|
130
|
+
authTokens.set(token, { createdAt: Date.now() });
|
|
131
|
+
|
|
132
|
+
res.cookie('authToken', token, {
|
|
133
|
+
httpOnly: true,
|
|
134
|
+
secure: process.env.NODE_ENV === 'production',
|
|
135
|
+
maxAge: AUTH_TOKEN_EXPIRY,
|
|
136
|
+
sameSite: 'strict'
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
res.json({ success: true, token });
|
|
140
|
+
} else {
|
|
141
|
+
res.status(401).json({ success: false, error: 'Invalid passcode' });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Logout endpoint
|
|
146
|
+
app.post('/api/auth/logout', (req, res) => {
|
|
147
|
+
const token = req.cookies?.authToken || req.headers['x-auth-token'];
|
|
148
|
+
if (token) {
|
|
149
|
+
authTokens.delete(token);
|
|
150
|
+
}
|
|
151
|
+
res.clearCookie('authToken');
|
|
152
|
+
res.json({ success: true });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Apply auth middleware to all API routes
|
|
156
|
+
app.use('/api', authMiddleware);
|
|
157
|
+
|
|
158
|
+
// Serve static files (with auth check for main app)
|
|
159
|
+
app.use((req, res, next) => {
|
|
160
|
+
// Allow login page without auth
|
|
161
|
+
if (req.path === '/login.html' || req.path === '/login') {
|
|
162
|
+
return next();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If auth required and not authenticated, redirect to login
|
|
166
|
+
if (PASSCODE) {
|
|
167
|
+
const token = req.cookies?.authToken || req.headers['x-auth-token'];
|
|
168
|
+
if (!validateAuthToken(token)) {
|
|
169
|
+
// For HTML requests, redirect to login
|
|
170
|
+
if (req.accepts('html')) {
|
|
171
|
+
return res.redirect('/login');
|
|
172
|
+
}
|
|
173
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
next();
|
|
177
|
+
});
|
|
178
|
+
|
|
16
179
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
17
180
|
|
|
18
181
|
// Session configuration
|
|
@@ -328,7 +491,26 @@ function stopTunnel(id) {
|
|
|
328
491
|
}
|
|
329
492
|
|
|
330
493
|
// WebSocket handling
|
|
331
|
-
wss.on('connection', async (ws) => {
|
|
494
|
+
wss.on('connection', async (ws, req) => {
|
|
495
|
+
// Authenticate WebSocket connection
|
|
496
|
+
if (PASSCODE) {
|
|
497
|
+
// Parse cookies from upgrade request
|
|
498
|
+
const cookies = {};
|
|
499
|
+
const cookieHeader = req.headers.cookie;
|
|
500
|
+
if (cookieHeader) {
|
|
501
|
+
cookieHeader.split(';').forEach(cookie => {
|
|
502
|
+
const [name, ...rest] = cookie.trim().split('=');
|
|
503
|
+
cookies[name] = rest.join('=');
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const token = cookies.authToken;
|
|
508
|
+
if (!validateAuthToken(token)) {
|
|
509
|
+
ws.close(4001, 'Authentication required');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
332
514
|
console.log('Client connected');
|
|
333
515
|
|
|
334
516
|
let currentSession = null;
|
|
@@ -359,6 +541,12 @@ wss.on('connection', async (ws) => {
|
|
|
359
541
|
ws.send(JSON.stringify({ type: 'tmux-sessions', data: tmuxSessions }));
|
|
360
542
|
|
|
361
543
|
ws.on('message', (message) => {
|
|
544
|
+
// Rate limiting check
|
|
545
|
+
if (!checkRateLimit(ws)) {
|
|
546
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Rate limit exceeded' }));
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
362
550
|
try {
|
|
363
551
|
const msg = JSON.parse(message.toString());
|
|
364
552
|
|
|
@@ -502,6 +690,7 @@ wss.on('connection', async (ws) => {
|
|
|
502
690
|
|
|
503
691
|
ws.on('close', () => {
|
|
504
692
|
console.log('Client disconnected');
|
|
693
|
+
wsRateLimits.delete(ws);
|
|
505
694
|
if (currentSession) {
|
|
506
695
|
currentSession.clients.delete(ws);
|
|
507
696
|
currentSession.lastAccess = Date.now();
|
|
@@ -537,19 +726,19 @@ async function getMemoryInfo() {
|
|
|
537
726
|
const pageSize = 16384; // macOS default page size (16KB on Apple Silicon)
|
|
538
727
|
const lines = output.split('\n');
|
|
539
728
|
|
|
540
|
-
let
|
|
729
|
+
let _free = 0, active = 0, _inactive = 0, wired = 0, compressed = 0, _purgeable = 0;
|
|
541
730
|
|
|
542
731
|
lines.forEach(line => {
|
|
543
732
|
const match = line.match(/^(.+):\s+(\d+)/);
|
|
544
733
|
if (match) {
|
|
545
734
|
const value = parseInt(match[2]) * pageSize;
|
|
546
735
|
const key = match[1].toLowerCase();
|
|
547
|
-
if (key.includes('free'))
|
|
736
|
+
if (key.includes('free')) _free = value;
|
|
548
737
|
else if (key.includes('active')) active = value;
|
|
549
|
-
else if (key.includes('inactive'))
|
|
738
|
+
else if (key.includes('inactive')) _inactive = value;
|
|
550
739
|
else if (key.includes('wired')) wired = value;
|
|
551
740
|
else if (key.includes('compressed')) compressed = value;
|
|
552
|
-
else if (key.includes('purgeable'))
|
|
741
|
+
else if (key.includes('purgeable')) _purgeable = value;
|
|
553
742
|
}
|
|
554
743
|
});
|
|
555
744
|
|
|
@@ -769,6 +958,86 @@ app.post('/api/files/write', async (req, res) => {
|
|
|
769
958
|
}
|
|
770
959
|
});
|
|
771
960
|
|
|
961
|
+
// Delete file or directory
|
|
962
|
+
app.post('/api/files/delete', async (req, res) => {
|
|
963
|
+
try {
|
|
964
|
+
const { path: filePath } = req.body;
|
|
965
|
+
|
|
966
|
+
if (!filePath) {
|
|
967
|
+
return res.status(400).json({ error: 'Path required' });
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const fullPath = path.join(WORKSPACE_ROOT, filePath);
|
|
971
|
+
const normalizedPath = path.normalize(fullPath);
|
|
972
|
+
|
|
973
|
+
if (!normalizedPath.startsWith(WORKSPACE_ROOT)) {
|
|
974
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Don't allow deleting the workspace root
|
|
978
|
+
if (normalizedPath === WORKSPACE_ROOT) {
|
|
979
|
+
return res.status(403).json({ error: 'Cannot delete workspace root' });
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const stats = await fs.stat(normalizedPath);
|
|
983
|
+
|
|
984
|
+
if (stats.isDirectory()) {
|
|
985
|
+
await fs.rm(normalizedPath, { recursive: true });
|
|
986
|
+
} else {
|
|
987
|
+
await fs.unlink(normalizedPath);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
res.json({ success: true, path: filePath });
|
|
991
|
+
} catch (err) {
|
|
992
|
+
if (err.code === 'ENOENT') {
|
|
993
|
+
res.status(404).json({ error: 'File not found' });
|
|
994
|
+
} else {
|
|
995
|
+
res.status(500).json({ error: err.message });
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// Rename file or directory
|
|
1001
|
+
app.post('/api/files/rename', async (req, res) => {
|
|
1002
|
+
try {
|
|
1003
|
+
const { oldPath, newPath } = req.body;
|
|
1004
|
+
|
|
1005
|
+
if (!oldPath || !newPath) {
|
|
1006
|
+
return res.status(400).json({ error: 'Both oldPath and newPath required' });
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const fullOldPath = path.join(WORKSPACE_ROOT, oldPath);
|
|
1010
|
+
const fullNewPath = path.join(WORKSPACE_ROOT, newPath);
|
|
1011
|
+
const normalizedOldPath = path.normalize(fullOldPath);
|
|
1012
|
+
const normalizedNewPath = path.normalize(fullNewPath);
|
|
1013
|
+
|
|
1014
|
+
if (!normalizedOldPath.startsWith(WORKSPACE_ROOT) || !normalizedNewPath.startsWith(WORKSPACE_ROOT)) {
|
|
1015
|
+
return res.status(403).json({ error: 'Access denied' });
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Check if source exists
|
|
1019
|
+
await fs.stat(normalizedOldPath);
|
|
1020
|
+
|
|
1021
|
+
// Check if destination already exists
|
|
1022
|
+
try {
|
|
1023
|
+
await fs.stat(normalizedNewPath);
|
|
1024
|
+
return res.status(409).json({ error: 'Destination already exists' });
|
|
1025
|
+
} catch (err) {
|
|
1026
|
+
if (err.code !== 'ENOENT') throw err;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
await fs.rename(normalizedOldPath, normalizedNewPath);
|
|
1030
|
+
|
|
1031
|
+
res.json({ success: true, oldPath, newPath });
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
if (err.code === 'ENOENT') {
|
|
1034
|
+
res.status(404).json({ error: 'File not found' });
|
|
1035
|
+
} else {
|
|
1036
|
+
res.status(500).json({ error: err.message });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
|
|
772
1041
|
// REST API
|
|
773
1042
|
app.get('/api/sessions', (req, res) => {
|
|
774
1043
|
const sessionList = Array.from(sessions.values()).map(s => ({
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
const { describe, it, before, after } = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const { spawn } = require('node:child_process');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
let serverProcess;
|
|
8
|
+
const serverPort = 3099;
|
|
9
|
+
|
|
10
|
+
function makeRequest(method, path, body = null) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const options = {
|
|
13
|
+
hostname: 'localhost',
|
|
14
|
+
port: serverPort,
|
|
15
|
+
path,
|
|
16
|
+
method,
|
|
17
|
+
headers: {
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const req = http.request(options, (res) => {
|
|
23
|
+
let data = '';
|
|
24
|
+
res.on('data', (chunk) => (data += chunk));
|
|
25
|
+
res.on('end', () => {
|
|
26
|
+
try {
|
|
27
|
+
resolve({ status: res.statusCode, data: JSON.parse(data) });
|
|
28
|
+
} catch {
|
|
29
|
+
resolve({ status: res.statusCode, data });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
req.on('error', reject);
|
|
35
|
+
|
|
36
|
+
if (body) {
|
|
37
|
+
req.write(JSON.stringify(body));
|
|
38
|
+
}
|
|
39
|
+
req.end();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function waitForServer(port, timeout = 10000) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const start = Date.now();
|
|
46
|
+
|
|
47
|
+
const check = () => {
|
|
48
|
+
const req = http.get(`http://localhost:${port}/health`, (res) => {
|
|
49
|
+
if (res.statusCode === 200) {
|
|
50
|
+
resolve();
|
|
51
|
+
} else {
|
|
52
|
+
retry();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
req.on('error', retry);
|
|
57
|
+
req.setTimeout(1000);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const retry = () => {
|
|
61
|
+
if (Date.now() - start > timeout) {
|
|
62
|
+
reject(new Error('Server startup timeout'));
|
|
63
|
+
} else {
|
|
64
|
+
setTimeout(check, 200);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
check();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe('DevTunnel Server API', () => {
|
|
73
|
+
before(async () => {
|
|
74
|
+
const serverPath = path.join(__dirname, '..', 'server.js');
|
|
75
|
+
serverProcess = spawn('node', [serverPath], {
|
|
76
|
+
env: { ...process.env, PORT: serverPort },
|
|
77
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
serverProcess.stdout.on('data', (data) => {
|
|
81
|
+
console.log(`[server] ${data.toString().trim()}`);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
serverProcess.stderr.on('data', (data) => {
|
|
85
|
+
console.error(`[server] ${data.toString().trim()}`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await waitForServer(serverPort);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
after(() => {
|
|
92
|
+
if (serverProcess) {
|
|
93
|
+
serverProcess.kill('SIGTERM');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('GET /health', () => {
|
|
98
|
+
it('should return health status', async () => {
|
|
99
|
+
const { status, data } = await makeRequest('GET', '/health');
|
|
100
|
+
assert.strictEqual(status, 200);
|
|
101
|
+
assert.strictEqual(data.status, 'ok');
|
|
102
|
+
assert.strictEqual(typeof data.sessions, 'number');
|
|
103
|
+
assert.strictEqual(typeof data.tunnels, 'number');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('GET /api/system', () => {
|
|
108
|
+
it('should return system information', async () => {
|
|
109
|
+
const { status, data } = await makeRequest('GET', '/api/system');
|
|
110
|
+
assert.strictEqual(status, 200);
|
|
111
|
+
assert.ok(data.hostname);
|
|
112
|
+
assert.ok(data.platform);
|
|
113
|
+
assert.ok(data.arch);
|
|
114
|
+
assert.strictEqual(typeof data.uptime, 'number');
|
|
115
|
+
assert.ok(data.cpu);
|
|
116
|
+
assert.ok(data.memory);
|
|
117
|
+
assert.ok(Array.isArray(data.loadavg));
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('GET /api/sessions', () => {
|
|
122
|
+
it('should return empty session list initially', async () => {
|
|
123
|
+
const { status, data } = await makeRequest('GET', '/api/sessions');
|
|
124
|
+
assert.strictEqual(status, 200);
|
|
125
|
+
assert.ok(Array.isArray(data));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('GET /api/tunnels', () => {
|
|
130
|
+
it('should return empty tunnel list initially', async () => {
|
|
131
|
+
const { status, data } = await makeRequest('GET', '/api/tunnels');
|
|
132
|
+
assert.strictEqual(status, 200);
|
|
133
|
+
assert.ok(Array.isArray(data));
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('GET /api/files', () => {
|
|
138
|
+
it('should return file listing', async () => {
|
|
139
|
+
const { status, data } = await makeRequest('GET', '/api/files');
|
|
140
|
+
assert.strictEqual(status, 200);
|
|
141
|
+
assert.strictEqual(data.path, '');
|
|
142
|
+
assert.ok(Array.isArray(data.items));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('POST /api/files/write', () => {
|
|
147
|
+
it('should reject request without path', async () => {
|
|
148
|
+
const { status, data } = await makeRequest('POST', '/api/files/write', {
|
|
149
|
+
content: 'test',
|
|
150
|
+
});
|
|
151
|
+
assert.strictEqual(status, 400);
|
|
152
|
+
assert.strictEqual(data.error, 'Path required');
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('POST /api/files/delete', () => {
|
|
157
|
+
it('should reject request without path', async () => {
|
|
158
|
+
const { status, data } = await makeRequest('POST', '/api/files/delete', {});
|
|
159
|
+
assert.strictEqual(status, 400);
|
|
160
|
+
assert.strictEqual(data.error, 'Path required');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should return 404 for non-existent file', async () => {
|
|
164
|
+
const { status, data } = await makeRequest('POST', '/api/files/delete', {
|
|
165
|
+
path: 'non-existent-file-12345.txt',
|
|
166
|
+
});
|
|
167
|
+
assert.strictEqual(status, 404);
|
|
168
|
+
assert.strictEqual(data.error, 'File not found');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('POST /api/files/rename', () => {
|
|
173
|
+
it('should reject request without paths', async () => {
|
|
174
|
+
const { status, data } = await makeRequest('POST', '/api/files/rename', {});
|
|
175
|
+
assert.strictEqual(status, 400);
|
|
176
|
+
assert.strictEqual(data.error, 'Both oldPath and newPath required');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should return 404 for non-existent source file', async () => {
|
|
180
|
+
const { status, data } = await makeRequest('POST', '/api/files/rename', {
|
|
181
|
+
oldPath: 'non-existent-file-12345.txt',
|
|
182
|
+
newPath: 'new-name.txt',
|
|
183
|
+
});
|
|
184
|
+
assert.strictEqual(status, 404);
|
|
185
|
+
assert.strictEqual(data.error, 'File not found');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('POST /api/tunnels', () => {
|
|
190
|
+
it('should reject request without valid port', async () => {
|
|
191
|
+
const { status, data } = await makeRequest('POST', '/api/tunnels', {});
|
|
192
|
+
assert.strictEqual(status, 400);
|
|
193
|
+
assert.strictEqual(data.error, 'Valid port number required');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('should reject request with invalid port type', async () => {
|
|
197
|
+
const { status, data } = await makeRequest('POST', '/api/tunnels', {
|
|
198
|
+
port: 'invalid',
|
|
199
|
+
});
|
|
200
|
+
assert.strictEqual(status, 400);
|
|
201
|
+
assert.strictEqual(data.error, 'Valid port number required');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|