@cloudflare/sandbox 0.0.0-02ee8fe

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/Dockerfile +143 -0
  3. package/README.md +162 -0
  4. package/dist/chunk-BFVUNTP4.js +104 -0
  5. package/dist/chunk-BFVUNTP4.js.map +1 -0
  6. package/dist/chunk-EKSWCBCA.js +86 -0
  7. package/dist/chunk-EKSWCBCA.js.map +1 -0
  8. package/dist/chunk-JXZMAU2C.js +559 -0
  9. package/dist/chunk-JXZMAU2C.js.map +1 -0
  10. package/dist/chunk-UJ3TV4M6.js +7 -0
  11. package/dist/chunk-UJ3TV4M6.js.map +1 -0
  12. package/dist/chunk-YE265ASX.js +2484 -0
  13. package/dist/chunk-YE265ASX.js.map +1 -0
  14. package/dist/chunk-Z532A7QC.js +78 -0
  15. package/dist/chunk-Z532A7QC.js.map +1 -0
  16. package/dist/file-stream.d.ts +43 -0
  17. package/dist/file-stream.js +9 -0
  18. package/dist/file-stream.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.js +67 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/interpreter.d.ts +33 -0
  23. package/dist/interpreter.js +8 -0
  24. package/dist/interpreter.js.map +1 -0
  25. package/dist/request-handler.d.ts +18 -0
  26. package/dist/request-handler.js +13 -0
  27. package/dist/request-handler.js.map +1 -0
  28. package/dist/sandbox-CLZWpfGc.d.ts +613 -0
  29. package/dist/sandbox.d.ts +4 -0
  30. package/dist/sandbox.js +13 -0
  31. package/dist/sandbox.js.map +1 -0
  32. package/dist/security.d.ts +31 -0
  33. package/dist/security.js +13 -0
  34. package/dist/security.js.map +1 -0
  35. package/dist/sse-parser.d.ts +28 -0
  36. package/dist/sse-parser.js +11 -0
  37. package/dist/sse-parser.js.map +1 -0
  38. package/dist/version.d.ts +8 -0
  39. package/dist/version.js +7 -0
  40. package/dist/version.js.map +1 -0
  41. package/package.json +44 -0
  42. package/src/clients/base-client.ts +280 -0
  43. package/src/clients/command-client.ts +115 -0
  44. package/src/clients/file-client.ts +295 -0
  45. package/src/clients/git-client.ts +92 -0
  46. package/src/clients/index.ts +64 -0
  47. package/src/clients/interpreter-client.ts +329 -0
  48. package/src/clients/port-client.ts +105 -0
  49. package/src/clients/process-client.ts +177 -0
  50. package/src/clients/sandbox-client.ts +41 -0
  51. package/src/clients/types.ts +84 -0
  52. package/src/clients/utility-client.ts +119 -0
  53. package/src/errors/adapter.ts +180 -0
  54. package/src/errors/classes.ts +469 -0
  55. package/src/errors/index.ts +105 -0
  56. package/src/file-stream.ts +164 -0
  57. package/src/index.ts +93 -0
  58. package/src/interpreter.ts +159 -0
  59. package/src/request-handler.ts +180 -0
  60. package/src/sandbox.ts +1045 -0
  61. package/src/security.ts +104 -0
  62. package/src/sse-parser.ts +143 -0
  63. package/src/version.ts +6 -0
  64. package/startup.sh +3 -0
  65. package/tests/base-client.test.ts +328 -0
  66. package/tests/command-client.test.ts +407 -0
  67. package/tests/file-client.test.ts +719 -0
  68. package/tests/file-stream.test.ts +306 -0
  69. package/tests/get-sandbox.test.ts +149 -0
  70. package/tests/git-client.test.ts +328 -0
  71. package/tests/port-client.test.ts +301 -0
  72. package/tests/process-client.test.ts +658 -0
  73. package/tests/request-handler.test.ts +240 -0
  74. package/tests/sandbox.test.ts +554 -0
  75. package/tests/sse-parser.test.ts +290 -0
  76. package/tests/utility-client.test.ts +332 -0
  77. package/tests/version.test.ts +16 -0
  78. package/tests/wrangler.jsonc +35 -0
  79. package/tsconfig.json +11 -0
  80. package/vitest.config.ts +31 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Security utilities for URL construction and input validation
3
+ *
4
+ * This module contains critical security functions to prevent:
5
+ * - URL injection attacks
6
+ * - SSRF (Server-Side Request Forgery) attacks
7
+ * - DNS rebinding attacks
8
+ * - Host header injection
9
+ * - Open redirect vulnerabilities
10
+ */
11
+
12
+ export class SecurityError extends Error {
13
+ constructor(message: string, public readonly code?: string) {
14
+ super(message);
15
+ this.name = 'SecurityError';
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Validates port numbers for sandbox services
21
+ * Only allows non-system ports to prevent conflicts and security issues
22
+ */
23
+ export function validatePort(port: number): boolean {
24
+ // Must be a valid integer
25
+ if (!Number.isInteger(port)) {
26
+ return false;
27
+ }
28
+
29
+ // Only allow non-system ports (1024-65535)
30
+ if (port < 1024 || port > 65535) {
31
+ return false;
32
+ }
33
+
34
+ // Exclude ports reserved by our system
35
+ const reservedPorts = [
36
+ 3000, // Control plane port
37
+ 8787, // Common wrangler dev port
38
+ ];
39
+
40
+ if (reservedPorts.includes(port)) {
41
+ return false;
42
+ }
43
+
44
+ return true;
45
+ }
46
+
47
+ /**
48
+ * Sanitizes and validates sandbox IDs for DNS compliance and security
49
+ * Only enforces critical requirements - allows maximum developer flexibility
50
+ */
51
+ export function sanitizeSandboxId(id: string): string {
52
+ // Basic validation: not empty, reasonable length limit (DNS subdomain limit is 63 chars)
53
+ if (!id || id.length > 63) {
54
+ throw new SecurityError(
55
+ 'Sandbox ID must be 1-63 characters long.',
56
+ 'INVALID_SANDBOX_ID_LENGTH'
57
+ );
58
+ }
59
+
60
+ // DNS compliance: cannot start or end with hyphens (RFC requirement)
61
+ if (id.startsWith('-') || id.endsWith('-')) {
62
+ throw new SecurityError(
63
+ 'Sandbox ID cannot start or end with hyphens (DNS requirement).',
64
+ 'INVALID_SANDBOX_ID_HYPHENS'
65
+ );
66
+ }
67
+
68
+ // Prevent reserved names that cause technical conflicts
69
+ const reservedNames = [
70
+ 'www', 'api', 'admin', 'root', 'system',
71
+ 'cloudflare', 'workers'
72
+ ];
73
+
74
+ const lowerCaseId = id.toLowerCase();
75
+ if (reservedNames.includes(lowerCaseId)) {
76
+ throw new SecurityError(
77
+ `Reserved sandbox ID '${id}' is not allowed.`,
78
+ 'RESERVED_SANDBOX_ID'
79
+ );
80
+ }
81
+
82
+ return id;
83
+ }
84
+
85
+
86
+ /**
87
+ * Validates language for code interpreter
88
+ * Only allows supported languages
89
+ */
90
+ export function validateLanguage(language: string | undefined): void {
91
+ if (!language) {
92
+ return; // undefined is valid, will default to python
93
+ }
94
+
95
+ const supportedLanguages = ['python', 'python3', 'javascript', 'js', 'node', 'typescript', 'ts'];
96
+ const normalized = language.toLowerCase();
97
+
98
+ if (!supportedLanguages.includes(normalized)) {
99
+ throw new SecurityError(
100
+ `Unsupported language '${language}'. Supported languages: python, javascript, typescript`,
101
+ 'INVALID_LANGUAGE'
102
+ );
103
+ }
104
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Server-Sent Events (SSE) parser for streaming responses
3
+ * Converts ReadableStream<Uint8Array> to typed AsyncIterable<T>
4
+ */
5
+
6
+ /**
7
+ * Parse a ReadableStream of SSE events into typed AsyncIterable
8
+ * @param stream - The ReadableStream from fetch response
9
+ * @param signal - Optional AbortSignal for cancellation
10
+ */
11
+ export async function* parseSSEStream<T>(
12
+ stream: ReadableStream<Uint8Array>,
13
+ signal?: AbortSignal
14
+ ): AsyncIterable<T> {
15
+ const reader = stream.getReader();
16
+ const decoder = new TextDecoder();
17
+ let buffer = '';
18
+
19
+ try {
20
+ while (true) {
21
+ // Check for cancellation
22
+ if (signal?.aborted) {
23
+ throw new Error('Operation was aborted');
24
+ }
25
+
26
+ const { done, value } = await reader.read();
27
+ if (done) break;
28
+
29
+ // Decode chunk and add to buffer
30
+ buffer += decoder.decode(value, { stream: true });
31
+
32
+ // Process complete SSE events in buffer
33
+ const lines = buffer.split('\n');
34
+
35
+ // Keep the last incomplete line in buffer
36
+ buffer = lines.pop() || '';
37
+
38
+ for (const line of lines) {
39
+ // Skip empty lines
40
+ if (line.trim() === '') continue;
41
+
42
+ // Process SSE data lines
43
+ if (line.startsWith('data: ')) {
44
+ const data = line.substring(6);
45
+
46
+ // Skip [DONE] markers or empty data
47
+ if (data === '[DONE]' || data.trim() === '') continue;
48
+
49
+ try {
50
+ const event = JSON.parse(data) as T;
51
+ yield event;
52
+ } catch {
53
+ // Skip invalid JSON events and continue processing
54
+ }
55
+ }
56
+ // Handle other SSE fields if needed (event:, id:, retry:)
57
+ // For now, we only care about data: lines
58
+ }
59
+ }
60
+
61
+ // Process any remaining data in buffer
62
+ if (buffer.trim() && buffer.startsWith('data: ')) {
63
+ const data = buffer.substring(6);
64
+ if (data !== '[DONE]' && data.trim()) {
65
+ try {
66
+ const event = JSON.parse(data) as T;
67
+ yield event;
68
+ } catch {
69
+ // Skip invalid JSON in final event
70
+ }
71
+ }
72
+ }
73
+ } finally {
74
+ // Clean up resources
75
+ reader.releaseLock();
76
+ }
77
+ }
78
+
79
+
80
+ /**
81
+ * Helper to convert a Response with SSE stream directly to AsyncIterable
82
+ * @param response - Response object with SSE stream
83
+ * @param signal - Optional AbortSignal for cancellation
84
+ */
85
+ export async function* responseToAsyncIterable<T>(
86
+ response: Response,
87
+ signal?: AbortSignal
88
+ ): AsyncIterable<T> {
89
+ if (!response.ok) {
90
+ throw new Error(`Response not ok: ${response.status} ${response.statusText}`);
91
+ }
92
+
93
+ if (!response.body) {
94
+ throw new Error('No response body');
95
+ }
96
+
97
+ yield* parseSSEStream<T>(response.body, signal);
98
+ }
99
+
100
+ /**
101
+ * Create an SSE-formatted ReadableStream from an AsyncIterable
102
+ * (Useful for Worker endpoints that need to forward AsyncIterable as SSE)
103
+ * @param events - AsyncIterable of events
104
+ * @param options - Stream options
105
+ */
106
+ export function asyncIterableToSSEStream<T>(
107
+ events: AsyncIterable<T>,
108
+ options?: {
109
+ signal?: AbortSignal;
110
+ serialize?: (event: T) => string;
111
+ }
112
+ ): ReadableStream<Uint8Array> {
113
+ const encoder = new TextEncoder();
114
+ const serialize = options?.serialize || JSON.stringify;
115
+
116
+ return new ReadableStream({
117
+ async start(controller) {
118
+ try {
119
+ for await (const event of events) {
120
+ if (options?.signal?.aborted) {
121
+ controller.error(new Error('Operation was aborted'));
122
+ break;
123
+ }
124
+
125
+ const data = serialize(event);
126
+ const sseEvent = `data: ${data}\n\n`;
127
+ controller.enqueue(encoder.encode(sseEvent));
128
+ }
129
+
130
+ // Send completion marker
131
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'));
132
+ } catch (error) {
133
+ controller.error(error);
134
+ } finally {
135
+ controller.close();
136
+ }
137
+ },
138
+
139
+ cancel() {
140
+ // Handle stream cancellation
141
+ }
142
+ });
143
+ }
package/src/version.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * SDK version - automatically synchronized with package.json by Changesets
3
+ * This file is auto-updated by .github/changeset-version.ts during releases
4
+ * DO NOT EDIT MANUALLY - Changes will be overwritten on the next version bump
5
+ */
6
+ export const SDK_VERSION = '0.4.12';
package/startup.sh ADDED
@@ -0,0 +1,3 @@
1
+ #!/bin/bash
2
+
3
+ exec bun dist/index.js
@@ -0,0 +1,328 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { BaseApiResponse, HttpClientOptions } from '../src/clients';
3
+ import { BaseHttpClient } from '../src/clients/base-client';
4
+ import type { ErrorResponse } from '../src/errors';
5
+ import {
6
+ CommandError,
7
+ FileNotFoundError,
8
+ FileSystemError,
9
+ PermissionDeniedError,
10
+ SandboxError
11
+ } from '../src/errors';
12
+
13
+ interface TestDataResponse extends BaseApiResponse {
14
+ data: string;
15
+ }
16
+
17
+ interface TestResourceResponse extends BaseApiResponse {
18
+ id: string;
19
+ }
20
+
21
+ interface TestSourceResponse extends BaseApiResponse {
22
+ source: string;
23
+ }
24
+
25
+ interface TestStatusResponse extends BaseApiResponse {
26
+ status: string;
27
+ }
28
+
29
+ class TestHttpClient extends BaseHttpClient {
30
+ constructor(options: HttpClientOptions = {}) {
31
+ super({
32
+ baseUrl: 'http://test.com',
33
+ port: 3000,
34
+ ...options,
35
+ });
36
+ }
37
+
38
+ public async testRequest<T = BaseApiResponse>(endpoint: string, data?: Record<string, unknown>): Promise<T> {
39
+ if (data) {
40
+ return this.post<T>(endpoint, data);
41
+ }
42
+ return this.get<T>(endpoint);
43
+ }
44
+
45
+ public async testStreamRequest(endpoint: string): Promise<ReadableStream> {
46
+ const response = await this.doFetch(endpoint);
47
+ return this.handleStreamResponse(response);
48
+ }
49
+
50
+ public async testErrorHandling(errorResponse: ErrorResponse) {
51
+ const response = new Response(
52
+ JSON.stringify(errorResponse),
53
+ { status: errorResponse.httpStatus || 400 }
54
+ );
55
+ return this.handleErrorResponse(response);
56
+ }
57
+ }
58
+
59
+ describe('BaseHttpClient', () => {
60
+ let client: TestHttpClient;
61
+ let mockFetch: ReturnType<typeof vi.fn>;
62
+ let onError: ReturnType<typeof vi.fn>;
63
+
64
+ beforeEach(() => {
65
+ vi.clearAllMocks();
66
+
67
+ mockFetch = vi.fn();
68
+ global.fetch = mockFetch as unknown as typeof fetch;
69
+ onError = vi.fn();
70
+
71
+ client = new TestHttpClient({
72
+ baseUrl: 'http://test.com',
73
+ port: 3000,
74
+ onError,
75
+ });
76
+ });
77
+
78
+ afterEach(() => {
79
+ vi.restoreAllMocks();
80
+ });
81
+
82
+ describe('core request functionality', () => {
83
+ it('should handle successful API requests', async () => {
84
+ mockFetch.mockResolvedValue(new Response(
85
+ JSON.stringify({ success: true, data: 'operation completed' }),
86
+ { status: 200 }
87
+ ));
88
+
89
+ const result = await client.testRequest<TestDataResponse>('/api/test');
90
+
91
+ expect(result.success).toBe(true);
92
+ expect(result.data).toBe('operation completed');
93
+ });
94
+
95
+ it('should handle POST requests with data', async () => {
96
+ const requestData = { action: 'create', name: 'test-resource' };
97
+ mockFetch.mockResolvedValue(new Response(
98
+ JSON.stringify({ success: true, id: 'resource-123' }),
99
+ { status: 201 }
100
+ ));
101
+
102
+ const result = await client.testRequest<TestResourceResponse>('/api/create', requestData);
103
+
104
+ expect(result.success).toBe(true);
105
+ expect(result.id).toBe('resource-123');
106
+
107
+ const [url, options] = mockFetch.mock.calls[0];
108
+ expect(url).toBe('http://test.com/api/create');
109
+ expect(options.method).toBe('POST');
110
+ expect(options.headers['Content-Type']).toBe('application/json');
111
+ expect(JSON.parse(options.body)).toEqual(requestData);
112
+ });
113
+ });
114
+
115
+ describe('error handling and mapping', () => {
116
+ it('should map container errors to client errors', async () => {
117
+ const errorMappingTests = [
118
+ {
119
+ containerError: {
120
+ code: 'FILE_NOT_FOUND',
121
+ message: 'File not found: /test.txt',
122
+ context: { path: '/test.txt' },
123
+ httpStatus: 404,
124
+ timestamp: new Date().toISOString()
125
+ },
126
+ expectedError: FileNotFoundError,
127
+ },
128
+ {
129
+ containerError: {
130
+ code: 'PERMISSION_DENIED',
131
+ message: 'Permission denied',
132
+ context: { path: '/secure.txt' },
133
+ httpStatus: 403,
134
+ timestamp: new Date().toISOString()
135
+ },
136
+ expectedError: PermissionDeniedError,
137
+ },
138
+ {
139
+ containerError: {
140
+ code: 'COMMAND_EXECUTION_ERROR',
141
+ message: 'Command failed: badcmd',
142
+ context: { command: 'badcmd' },
143
+ httpStatus: 400,
144
+ timestamp: new Date().toISOString()
145
+ },
146
+ expectedError: CommandError,
147
+ },
148
+ {
149
+ containerError: {
150
+ code: 'FILESYSTEM_ERROR',
151
+ message: 'Filesystem error',
152
+ context: { path: '/test' },
153
+ httpStatus: 500,
154
+ timestamp: new Date().toISOString()
155
+ },
156
+ expectedError: FileSystemError,
157
+ },
158
+ {
159
+ containerError: {
160
+ code: 'UNKNOWN_ERROR',
161
+ message: 'Unknown error',
162
+ context: {},
163
+ httpStatus: 500,
164
+ timestamp: new Date().toISOString()
165
+ },
166
+ expectedError: SandboxError,
167
+ }
168
+ ];
169
+
170
+ for (const test of errorMappingTests) {
171
+ await expect(client.testErrorHandling(test.containerError as ErrorResponse))
172
+ .rejects.toThrow(test.expectedError);
173
+
174
+ expect(onError).toHaveBeenCalledWith(test.containerError.message, undefined);
175
+ }
176
+ });
177
+
178
+ it('should handle malformed error responses', async () => {
179
+ mockFetch.mockResolvedValue(new Response(
180
+ 'invalid json {',
181
+ { status: 500 }
182
+ ));
183
+
184
+ await expect(client.testRequest('/api/test'))
185
+ .rejects.toThrow(SandboxError);
186
+ });
187
+
188
+ it('should handle network failures', async () => {
189
+ mockFetch.mockRejectedValue(new Error('Network connection timeout'));
190
+
191
+ await expect(client.testRequest('/api/test'))
192
+ .rejects.toThrow('Network connection timeout');
193
+ });
194
+
195
+ it('should handle server unavailable scenarios', async () => {
196
+ mockFetch.mockResolvedValue(new Response(
197
+ 'Service Unavailable',
198
+ { status: 503 }
199
+ ));
200
+
201
+ await expect(client.testRequest('/api/test'))
202
+ .rejects.toThrow(SandboxError);
203
+
204
+ expect(onError).toHaveBeenCalledWith('HTTP error! status: 503', undefined);
205
+ });
206
+ });
207
+
208
+
209
+ describe('streaming functionality', () => {
210
+ it('should handle streaming responses', async () => {
211
+ const streamData = 'data: {"type":"output","content":"stream data"}\n\n';
212
+ const mockStream = new ReadableStream({
213
+ start(controller) {
214
+ controller.enqueue(new TextEncoder().encode(streamData));
215
+ controller.close();
216
+ }
217
+ });
218
+
219
+ mockFetch.mockResolvedValue(new Response(mockStream, {
220
+ status: 200,
221
+ headers: { 'Content-Type': 'text/event-stream' }
222
+ }));
223
+
224
+ const stream = await client.testStreamRequest('/api/stream');
225
+
226
+ expect(stream).toBeInstanceOf(ReadableStream);
227
+
228
+ const reader = stream.getReader();
229
+ const { done, value } = await reader.read();
230
+ const content = new TextDecoder().decode(value);
231
+
232
+ expect(done).toBe(false);
233
+ expect(content).toContain('stream data');
234
+
235
+ reader.releaseLock();
236
+ });
237
+
238
+ it('should handle streaming errors', async () => {
239
+ mockFetch.mockResolvedValue(new Response(
240
+ JSON.stringify({ error: 'Stream initialization failed', code: 'STREAM_ERROR' }),
241
+ { status: 400 }
242
+ ));
243
+
244
+ await expect(client.testStreamRequest('/api/bad-stream'))
245
+ .rejects.toThrow(SandboxError);
246
+ });
247
+
248
+ it('should handle missing stream body', async () => {
249
+ mockFetch.mockResolvedValue(new Response(null, {
250
+ status: 200,
251
+ headers: { 'Content-Type': 'text/event-stream' }
252
+ }));
253
+
254
+ await expect(client.testStreamRequest('/api/empty-stream'))
255
+ .rejects.toThrow('No response body for streaming');
256
+ });
257
+ });
258
+
259
+ describe('stub integration', () => {
260
+ it('should use stub when provided instead of fetch', async () => {
261
+ const stubFetch = vi.fn().mockResolvedValue(new Response(
262
+ JSON.stringify({ success: true, source: 'stub' }),
263
+ { status: 200 }
264
+ ));
265
+
266
+ const stub = { containerFetch: stubFetch };
267
+ const stubClient = new TestHttpClient({
268
+ baseUrl: 'http://test.com',
269
+ port: 3000,
270
+ stub,
271
+ });
272
+
273
+ const result = await stubClient.testRequest<TestSourceResponse>('/api/stub-test');
274
+
275
+ expect(result.success).toBe(true);
276
+ expect(result.source).toBe('stub');
277
+ expect(stubFetch).toHaveBeenCalledWith(
278
+ 'http://localhost:3000/api/stub-test',
279
+ { method: 'GET' },
280
+ 3000
281
+ );
282
+ expect(mockFetch).not.toHaveBeenCalled();
283
+ });
284
+
285
+ it('should handle stub errors', async () => {
286
+ const stubFetch = vi.fn().mockRejectedValue(new Error('Stub connection failed'));
287
+ const stub = { containerFetch: stubFetch };
288
+ const stubClient = new TestHttpClient({
289
+ baseUrl: 'http://test.com',
290
+ port: 3000,
291
+ stub,
292
+ });
293
+
294
+ await expect(stubClient.testRequest('/api/stub-error'))
295
+ .rejects.toThrow('Stub connection failed');
296
+ });
297
+ });
298
+
299
+ describe('edge cases and resilience', () => {
300
+ it('should handle responses with unusual status codes', async () => {
301
+ const unusualStatusTests = [
302
+ { status: 201, shouldSucceed: true },
303
+ { status: 202, shouldSucceed: true },
304
+ { status: 409, shouldSucceed: false },
305
+ { status: 422, shouldSucceed: false },
306
+ { status: 429, shouldSucceed: false },
307
+ ];
308
+
309
+ for (const test of unusualStatusTests) {
310
+ mockFetch.mockResolvedValueOnce(new Response(
311
+ test.shouldSucceed
312
+ ? JSON.stringify({ success: true, status: test.status })
313
+ : JSON.stringify({ error: `Status ${test.status}` }),
314
+ { status: test.status }
315
+ ));
316
+
317
+ if (test.shouldSucceed) {
318
+ const result = await client.testRequest<TestStatusResponse>('/api/unusual-status');
319
+ expect(result.success).toBe(true);
320
+ expect(result.status).toBe(test.status);
321
+ } else {
322
+ await expect(client.testRequest('/api/unusual-status'))
323
+ .rejects.toThrow();
324
+ }
325
+ }
326
+ });
327
+ });
328
+ });