Skip to content
File

Blob: test/worker.test.ts

typescript412 lines
1/**
2 * Integration tests for the Worker terminal route.
3 *
4 * Covers:
5 * - auth validation (dev mode, Cloudflare Access)
6 * - ownership (sandbox ID derived from user identity)
7 * - terminal upgrade / proxy behavior
8 * - dimension clamping
9 * - session create / delete API
10 * - env variable sanitization
11 * - cwd validation
12 * - sandbox destruction
13 * - backup / restore
14 * - error responses
15 */
16import { describe, it, expect, vi, beforeEach } from "vitest";
17 
18// ---------------------------------------------------------------------------
19// Mocks
20// ---------------------------------------------------------------------------
21 
22const mockTerminalResponse = new Response(null, { status: 200 });
23 
24const mockSandbox = {
25 createSession: vi.fn().mockResolvedValue({}),
26 deleteSession: vi.fn().mockResolvedValue(undefined),
27 destroy: vi.fn().mockResolvedValue(undefined),
28 createBackup: vi.fn().mockResolvedValue({ id: "backup-123" }),
29 restoreBackup: vi.fn().mockResolvedValue({ success: true, dir: "/workspace", id: "backup-123" }),
30};
31 
32const mockGetSandbox = vi.fn(() => mockSandbox);
33const mockProxyTerminal = vi.fn().mockResolvedValue(mockTerminalResponse);
34 
35vi.mock("@cloudflare/sandbox", () => ({
36 getSandbox: (...args: unknown[]) => mockGetSandbox(...args),
37 proxyTerminal: (...args: unknown[]) => mockProxyTerminal(...args),
38 Sandbox: class MockSandbox {},
39}));
40 
41// Import after mock
42const workerModule = await import("../src/worker/index");
43const worker = workerModule.default;
44 
45// ---------------------------------------------------------------------------
46// Helpers
47// ---------------------------------------------------------------------------
48 
49/** Dev-mode env: no Access configured, all requests pass with dev-user identity. */
50function makeEnv(overrides: Record<string, unknown> = {}) {
51 return {
52 Sandbox: {} as DurableObjectNamespace,
53 SANDBOX_TRANSPORT: "websocket",
54 CF_ACCESS_AUD: "",
55 CF_ACCESS_TEAM: "",
56 ...overrides,
57 };
58}
59 
60/** Env with Access configured — requests without a valid JWT will be rejected. */
61function makeAccessEnv(overrides: Record<string, unknown> = {}) {
62 return makeEnv({
63 CF_ACCESS_AUD: "test-aud-tag",
64 CF_ACCESS_TEAM: "test-team",
65 ...overrides,
66 });
67}
68 
69function makeRequest(
70 path: string,
71 options: {
72 method?: string;
73 headers?: Record<string, string>;
74 body?: string;
75 host?: string;
76 } = {},
77): Request {
78 const host = options.host ?? "localhost";
79 return new Request(`https://${host}${path}`, {
80 method: options.method ?? "GET",
81 headers: {
82 ...(options.headers ?? {}),
83 },
84 ...(options.body ? { body: options.body } : {}),
85 });
86}
87 
88function wsRequest(path: string, headers: Record<string, string> = {}, host?: string): Request {
89 return makeRequest(path, {
90 host,
91 headers: { Upgrade: "websocket", ...headers },
92 });
93}
94 
95// ---------------------------------------------------------------------------
96// Tests
97// ---------------------------------------------------------------------------
98 
99beforeEach(() => {
100 vi.clearAllMocks();
101});
102 
103describe("Worker — terminal route", () => {
104 it("rejects non-WebSocket requests to /ws/terminal", async () => {
105 const res = await worker.fetch(makeRequest("/ws/terminal"), makeEnv());
106 expect(res.status).toBe(426);
107 });
108 
109 it("proxies via proxyTerminal for default session", async () => {
110 const res = await worker.fetch(wsRequest("/ws/terminal"), makeEnv());
111 expect(res.status).toBe(200);
112 // Sandbox ID derived from dev-user + default workspace
113 expect(mockGetSandbox).toHaveBeenCalledWith(expect.anything(), "dev-user-default");
114 expect(mockProxyTerminal).toHaveBeenCalledWith(
115 mockSandbox,
116 "", // empty string for default session
117 expect.any(Request),
118 { cols: 80, rows: 24, shell: "/usr/local/bin/ccccocc-shell" },
119 );
120 });
121 
122 it("proxies via proxyTerminal for named session", async () => {
123 const res = await worker.fetch(wsRequest("/ws/terminal?session=dev"), makeEnv());
124 expect(res.status).toBe(200);
125 expect(mockProxyTerminal).toHaveBeenCalledWith(mockSandbox, "dev", expect.any(Request), {
126 cols: 80,
127 rows: 24,
128 shell: "/usr/local/bin/ccccocc-shell",
129 });
130 });
131 
132 it("uses workspace param to derive sandbox ID", async () => {
133 await worker.fetch(wsRequest("/ws/terminal?workspace=myproject"), makeEnv());
134 expect(mockGetSandbox).toHaveBeenCalledWith(expect.anything(), "dev-user-myproject");
135 });
136 
137 it("passes cols/rows from query params", async () => {
138 await worker.fetch(wsRequest("/ws/terminal?cols=120&rows=40"), makeEnv());
139 expect(mockProxyTerminal).toHaveBeenCalledWith(mockSandbox, "", expect.any(Request), {
140 cols: 120,
141 rows: 40,
142 shell: "/usr/local/bin/ccccocc-shell",
143 });
144 });
145 
146 it("clamps extreme dimensions", async () => {
147 await worker.fetch(wsRequest("/ws/terminal?cols=9999&rows=-5"), makeEnv());
148 expect(mockProxyTerminal).toHaveBeenCalledWith(mockSandbox, "", expect.any(Request), {
149 cols: 500,
150 rows: 1,
151 shell: "/usr/local/bin/ccccocc-shell",
152 });
153 });
154 
155 it("falls back to defaults for non-numeric dimensions", async () => {
156 await worker.fetch(wsRequest("/ws/terminal?cols=abc&rows="), makeEnv());
157 expect(mockProxyTerminal).toHaveBeenCalledWith(mockSandbox, "", expect.any(Request), {
158 cols: 80,
159 rows: 24,
160 shell: "/usr/local/bin/ccccocc-shell",
161 });
162 });
163});
164 
165describe("Worker — auth", () => {
166 it("allows requests in dev mode (no Access configured)", async () => {
167 const res = await worker.fetch(wsRequest("/ws/terminal"), makeEnv());
168 expect(res.status).toBe(200);
169 });
170 
171 it("rejects requests without JWT when Access is configured", async () => {
172 const res = await worker.fetch(wsRequest("/ws/terminal", {}, "ccccocc.example.com"), makeAccessEnv());
173 expect(res.status).toBe(401);
174 const body = (await res.json()) as { code: string };
175 expect(body.code).toBe("AUTH_REQUIRED");
176 });
177 
178 it("derives sandbox ID from dev-user identity in dev mode", async () => {
179 await worker.fetch(wsRequest("/ws/terminal"), makeEnv());
180 expect(mockGetSandbox).toHaveBeenCalledWith(expect.anything(), "dev-user-default");
181 });
182 
183 it("rejects non-local hosts when Access is not configured", async () => {
184 const res = await worker.fetch(wsRequest("/ws/terminal", {}, "ccccocc.example.com"), makeEnv());
185 expect(res.status).toBe(403);
186 const body = (await res.json()) as { code: string };
187 expect(body.code).toBe("ACCESS_REQUIRED");
188 });
189 
190 it("allows non-local hosts when Access is configured", async () => {
191 const res = await worker.fetch(makeRequest("/api/health", { host: "ccccocc.example.com" }), makeAccessEnv());
192 expect(res.status).toBe(200);
193 });
194});
195 
196describe("Worker — ownership", () => {
197 it("scopes sandbox to authenticated user via workspace param", async () => {
198 await worker.fetch(wsRequest("/ws/terminal?workspace=project1"), makeEnv());
199 expect(mockGetSandbox).toHaveBeenCalledWith(expect.anything(), "dev-user-project1");
200 });
201 
202 it("ignores raw id param — derives sandbox from user identity", async () => {
203 await worker.fetch(wsRequest("/ws/terminal?id=someone-elses-sandbox"), makeEnv());
204 expect(mockGetSandbox).toHaveBeenCalledWith(expect.anything(), "dev-user-default");
205 });
206});
207 
208describe("Worker — session API", () => {
209 it("creates a session", async () => {
210 const res = await worker.fetch(
211 makeRequest("/api/sessions?workspace=default", {
212 method: "POST",
213 headers: { "Content-Type": "application/json" },
214 body: JSON.stringify({ id: "dev", cwd: "/workspace/app" }),
215 }),
216 makeEnv(),
217 );
218 expect(res.status).toBe(201);
219 const body = (await res.json()) as { id: string; cwd: string };
220 expect(body.id).toBe("dev");
221 expect(body.cwd).toBe("/workspace/app");
222 expect(mockSandbox.createSession).toHaveBeenCalledWith({
223 id: "dev",
224 cwd: "/workspace/app",
225 env: {},
226 });
227 });
228 
229 it("defaults cwd to /workspace", async () => {
230 await worker.fetch(
231 makeRequest("/api/sessions?workspace=default", {
232 method: "POST",
233 headers: { "Content-Type": "application/json" },
234 body: JSON.stringify({ id: "sess1" }),
235 }),
236 makeEnv(),
237 );
238 expect(mockSandbox.createSession).toHaveBeenCalledWith(expect.objectContaining({ cwd: "/workspace" }));
239 });
240 
241 it("rejects session creation without id", async () => {
242 const res = await worker.fetch(
243 makeRequest("/api/sessions?workspace=default", {
244 method: "POST",
245 headers: { "Content-Type": "application/json" },
246 body: JSON.stringify({ cwd: "/workspace/app" }),
247 }),
248 makeEnv(),
249 );
250 expect(res.status).toBe(400);
251 });
252 
253 it("rejects cwd outside /workspace", async () => {
254 const res = await worker.fetch(
255 makeRequest("/api/sessions?workspace=default", {
256 method: "POST",
257 headers: { "Content-Type": "application/json" },
258 body: JSON.stringify({ id: "sess1", cwd: "/etc/passwd" }),
259 }),
260 makeEnv(),
261 );
262 expect(res.status).toBe(400);
263 const body = (await res.json()) as { code: string };
264 expect(body.code).toBe("INVALID_CWD");
265 });
266 
267 it("rejects cwd with path traversal", async () => {
268 const res = await worker.fetch(
269 makeRequest("/api/sessions?workspace=default", {
270 method: "POST",
271 headers: { "Content-Type": "application/json" },
272 body: JSON.stringify({ id: "s", cwd: "/" }),
273 }),
274 makeEnv(),
275 );
276 expect(res.status).toBe(400);
277 });
278 
279 it("deletes a session", async () => {
280 const res = await worker.fetch(
281 makeRequest("/api/sessions?workspace=default&session=dev", {
282 method: "DELETE",
283 }),
284 makeEnv(),
285 );
286 expect(res.status).toBe(204);
287 expect(mockSandbox.deleteSession).toHaveBeenCalledWith("dev");
288 });
289 
290 it("rejects DELETE without session param", async () => {
291 const res = await worker.fetch(makeRequest("/api/sessions?workspace=default", { method: "DELETE" }), makeEnv());
292 expect(res.status).toBe(400);
293 });
294 
295 it("rejects unsupported methods", async () => {
296 const res = await worker.fetch(makeRequest("/api/sessions?workspace=default", { method: "PATCH" }), makeEnv());
297 expect(res.status).toBe(405);
298 });
299});
300 
301describe("Worker — env sanitization", () => {
302 it("strips dangerous env var keys", async () => {
303 await worker.fetch(
304 makeRequest("/api/sessions?workspace=default", {
305 method: "POST",
306 headers: { "Content-Type": "application/json" },
307 body: JSON.stringify({
308 id: "s1",
309 env: {
310 TERM: "xterm-256color",
311 AWS_SECRET_ACCESS_KEY: "hunter2",
312 CF_API_TOKEN: "tok",
313 MY_PASSWORD: "pass",
314 NODE_ENV: "development",
315 GITHUB_TOKEN: "ghp_xxx",
316 },
317 }),
318 }),
319 makeEnv(),
320 );
321 
322 const call = mockSandbox.createSession.mock.calls[0][0];
323 // Safe vars pass through
324 expect(call.env.TERM).toBe("xterm-256color");
325 expect(call.env.NODE_ENV).toBe("development");
326 // Dangerous vars stripped
327 expect(call.env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
328 expect(call.env.CF_API_TOKEN).toBeUndefined();
329 expect(call.env.MY_PASSWORD).toBeUndefined();
330 expect(call.env.GITHUB_TOKEN).toBeUndefined();
331 });
332});
333 
334describe("Worker — sandbox destruction", () => {
335 it("destroys sandbox via DELETE /api/sandbox", async () => {
336 const res = await worker.fetch(makeRequest("/api/sandbox?workspace=default", { method: "DELETE" }), makeEnv());
337 expect(res.status).toBe(204);
338 expect(mockSandbox.destroy).toHaveBeenCalled();
339 });
340 
341 it("requires auth for sandbox destruction when Access is configured", async () => {
342 const res = await worker.fetch(
343 makeRequest("/api/sandbox?workspace=default", { method: "DELETE" }),
344 makeAccessEnv(),
345 );
346 expect(res.status).toBe(401);
347 });
348});
349 
350describe("Worker — backup / restore", () => {
351 it("creates a backup", async () => {
352 const res = await worker.fetch(
353 makeRequest("/api/workspace/backup?workspace=default", {
354 method: "POST",
355 headers: { "Content-Type": "application/json" },
356 body: JSON.stringify({ dir: "/workspace", name: "my-backup" }),
357 }),
358 makeEnv(),
359 );
360 expect(res.status).toBe(201);
361 expect(mockSandbox.createBackup).toHaveBeenCalledWith({
362 dir: "/workspace",
363 name: "my-backup",
364 });
365 });
366 
367 it("restores a backup", async () => {
368 const res = await worker.fetch(
369 makeRequest("/api/workspace/restore?workspace=default", {
370 method: "POST",
371 headers: { "Content-Type": "application/json" },
372 body: JSON.stringify({ id: "backup-123", dir: "/workspace" }),
373 }),
374 makeEnv(),
375 );
376 expect(res.status).toBe(200);
377 expect(mockSandbox.restoreBackup).toHaveBeenCalledWith({
378 id: "backup-123",
379 dir: "/workspace",
380 });
381 });
382 
383 it("rejects restore without backup id", async () => {
384 const res = await worker.fetch(
385 makeRequest("/api/workspace/restore?workspace=default", {
386 method: "POST",
387 headers: { "Content-Type": "application/json" },
388 body: JSON.stringify({}),
389 }),
390 makeEnv(),
391 );
392 expect(res.status).toBe(400);
393 });
394});
395 
396describe("Worker — routing", () => {
397 it("serves health check without auth", async () => {
398 const res = await worker.fetch(
399 makeRequest("/api/health"),
400 makeAccessEnv(), // Access configured, but health needs no auth
401 );
402 expect(res.status).toBe(200);
403 const body = (await res.json()) as { ok: boolean };
404 expect(body.ok).toBe(true);
405 });
406 
407 it("returns 404 for unknown API routes", async () => {
408 const res = await worker.fetch(makeRequest("/api/unknown"), makeEnv());
409 expect(res.status).toBe(404);
410 });
411});