librechat-data-provider 0.8.402 → 0.8.404
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/dist/index.es.js +1 -1
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react-query/index.es.js +1 -1
- package/dist/react-query/index.es.js.map +1 -1
- package/dist/types/accessPermissions.d.ts +744 -0
- package/dist/types/actions.d.ts +118 -0
- package/dist/types/api-endpoints.d.ts +150 -0
- package/dist/types/artifacts.d.ts +97 -0
- package/dist/types/azure.d.ts +22 -0
- package/dist/types/bedrock.d.ts +1220 -0
- package/dist/types/config.d.ts +14849 -0
- package/dist/types/config.spec.d.ts +1 -0
- package/dist/types/createPayload.d.ts +5 -0
- package/dist/types/data-service.d.ts +287 -0
- package/dist/types/feedback.d.ts +36 -0
- package/dist/types/file-config.d.ts +263 -0
- package/dist/types/file-config.spec.d.ts +1 -0
- package/dist/types/generate.d.ts +597 -0
- package/dist/types/headers-helpers.d.ts +2 -0
- package/{src/index.ts → dist/types/index.d.ts} +0 -15
- package/dist/types/keys.d.ts +92 -0
- package/dist/types/mcp.d.ts +2760 -0
- package/dist/types/messages.d.ts +10 -0
- package/dist/types/models.d.ts +1547 -0
- package/dist/types/parameterSettings.d.ts +69 -0
- package/dist/types/parsers.d.ts +110 -0
- package/dist/types/permissions.d.ts +522 -0
- package/dist/types/react-query/react-query-service.d.ts +85 -0
- package/dist/types/request.d.ts +25 -0
- package/dist/types/roles.d.ts +554 -0
- package/dist/types/roles.spec.d.ts +1 -0
- package/dist/types/schemas.d.ts +5110 -0
- package/dist/types/schemas.spec.d.ts +1 -0
- package/dist/types/types/agents.d.ts +433 -0
- package/dist/types/types/assistants.d.ts +547 -0
- package/dist/types/types/files.d.ts +172 -0
- package/dist/types/types/graph.d.ts +135 -0
- package/{src/types/mcpServers.ts → dist/types/types/mcpServers.d.ts} +12 -18
- package/dist/types/types/mutations.d.ts +209 -0
- package/dist/types/types/queries.d.ts +169 -0
- package/dist/types/types/runs.d.ts +36 -0
- package/dist/types/types/web.d.ts +520 -0
- package/dist/types/types.d.ts +503 -0
- package/dist/types/utils.d.ts +12 -0
- package/package.json +5 -1
- package/babel.config.js +0 -4
- package/check_updates.sh +0 -52
- package/jest.config.js +0 -19
- package/react-query/package-lock.json +0 -292
- package/react-query/package.json +0 -10
- package/rollup.config.js +0 -74
- package/server-rollup.config.js +0 -40
- package/specs/actions.spec.ts +0 -2533
- package/specs/api-endpoints-subdir.spec.ts +0 -140
- package/specs/api-endpoints.spec.ts +0 -74
- package/specs/azure.spec.ts +0 -844
- package/specs/bedrock.spec.ts +0 -862
- package/specs/filetypes.spec.ts +0 -175
- package/specs/generate.spec.ts +0 -770
- package/specs/headers-helpers.spec.ts +0 -24
- package/specs/mcp.spec.ts +0 -147
- package/specs/openapiSpecs.ts +0 -524
- package/specs/parsers.spec.ts +0 -601
- package/specs/request-interceptor.spec.ts +0 -304
- package/specs/utils.spec.ts +0 -196
- package/src/accessPermissions.ts +0 -346
- package/src/actions.ts +0 -813
- package/src/api-endpoints.ts +0 -440
- package/src/artifacts.ts +0 -3104
- package/src/azure.ts +0 -328
- package/src/bedrock.ts +0 -425
- package/src/config.spec.ts +0 -315
- package/src/config.ts +0 -2006
- package/src/createPayload.ts +0 -46
- package/src/data-service.ts +0 -1087
- package/src/feedback.ts +0 -141
- package/src/file-config.spec.ts +0 -1248
- package/src/file-config.ts +0 -764
- package/src/generate.ts +0 -634
- package/src/headers-helpers.ts +0 -13
- package/src/keys.ts +0 -99
- package/src/mcp.ts +0 -271
- package/src/messages.ts +0 -50
- package/src/models.ts +0 -69
- package/src/parameterSettings.ts +0 -1111
- package/src/parsers.ts +0 -563
- package/src/permissions.ts +0 -188
- package/src/react-query/react-query-service.ts +0 -566
- package/src/request.ts +0 -171
- package/src/roles.spec.ts +0 -132
- package/src/roles.ts +0 -225
- package/src/schemas.spec.ts +0 -355
- package/src/schemas.ts +0 -1234
- package/src/types/agents.ts +0 -470
- package/src/types/assistants.ts +0 -654
- package/src/types/files.ts +0 -191
- package/src/types/graph.ts +0 -145
- package/src/types/mutations.ts +0 -422
- package/src/types/queries.ts +0 -208
- package/src/types/runs.ts +0 -40
- package/src/types/web.ts +0 -588
- package/src/types.ts +0 -676
- package/src/utils.ts +0 -85
- package/tsconfig.json +0 -28
- package/tsconfig.spec.json +0 -10
- /package/{src/react-query/index.ts → dist/types/react-query/index.d.ts} +0 -0
- /package/{src/types/index.ts → dist/types/types/index.d.ts} +0 -0
package/specs/actions.spec.ts
DELETED
|
@@ -1,2533 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
2
|
-
import axios from 'axios';
|
|
3
|
-
import type { OpenAPIV3 } from 'openapi-types';
|
|
4
|
-
import type { ParametersSchema } from '../src/actions';
|
|
5
|
-
import type { FlowchartSchema } from './openapiSpecs';
|
|
6
|
-
import {
|
|
7
|
-
createURL,
|
|
8
|
-
resolveRef,
|
|
9
|
-
ActionRequest,
|
|
10
|
-
openapiToFunction,
|
|
11
|
-
FunctionSignature,
|
|
12
|
-
extractDomainFromUrl,
|
|
13
|
-
validateActionDomain,
|
|
14
|
-
validateAndParseOpenAPISpec,
|
|
15
|
-
} from '../src/actions';
|
|
16
|
-
import {
|
|
17
|
-
getWeatherOpenapiSpec,
|
|
18
|
-
whimsicalOpenapiSpec,
|
|
19
|
-
scholarAIOpenapiSpec,
|
|
20
|
-
formOpenAPISpec,
|
|
21
|
-
swapidev,
|
|
22
|
-
} from './openapiSpecs';
|
|
23
|
-
import { AuthorizationTypeEnum, AuthTypeEnum } from '../src/types/agents';
|
|
24
|
-
|
|
25
|
-
jest.mock('axios');
|
|
26
|
-
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
27
|
-
mockedAxios.create.mockReturnValue(mockedAxios);
|
|
28
|
-
|
|
29
|
-
describe('FunctionSignature', () => {
|
|
30
|
-
it('creates a function signature and converts to JSON tool', () => {
|
|
31
|
-
const signature = new FunctionSignature('testFunction', 'A test function', {
|
|
32
|
-
param1: { type: 'string' },
|
|
33
|
-
} as unknown as ParametersSchema);
|
|
34
|
-
expect(signature.name).toBe('testFunction');
|
|
35
|
-
expect(signature.description).toBe('A test function');
|
|
36
|
-
expect(signature.toObjectTool()).toEqual({
|
|
37
|
-
type: 'function',
|
|
38
|
-
function: {
|
|
39
|
-
name: 'testFunction',
|
|
40
|
-
description: 'A test function',
|
|
41
|
-
parameters: {
|
|
42
|
-
param1: { type: 'string' },
|
|
43
|
-
},
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
describe('ActionRequest', () => {
|
|
50
|
-
// Mocking responses for each method
|
|
51
|
-
beforeEach(() => {
|
|
52
|
-
mockedAxios.get.mockResolvedValue({ data: { success: true, method: 'GET' } });
|
|
53
|
-
mockedAxios.post.mockResolvedValue({ data: { success: true, method: 'POST' } });
|
|
54
|
-
mockedAxios.put.mockResolvedValue({ data: { success: true, method: 'PUT' } });
|
|
55
|
-
mockedAxios.delete.mockResolvedValue({ data: { success: true, method: 'DELETE' } });
|
|
56
|
-
mockedAxios.patch.mockResolvedValue({ data: { success: true, method: 'PATCH' } });
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
afterEach(() => {
|
|
60
|
-
jest.clearAllMocks();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should make a GET request', async () => {
|
|
64
|
-
const actionRequest = new ActionRequest(
|
|
65
|
-
'https://example.com',
|
|
66
|
-
'/test',
|
|
67
|
-
'GET',
|
|
68
|
-
'testOp',
|
|
69
|
-
false,
|
|
70
|
-
'application/json',
|
|
71
|
-
);
|
|
72
|
-
actionRequest.setParams({ param1: 'value1' });
|
|
73
|
-
const response = await actionRequest.execute();
|
|
74
|
-
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', expect.anything());
|
|
75
|
-
expect(response.data).toEqual({ success: true, method: 'GET' });
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
describe('ActionRequest', () => {
|
|
79
|
-
beforeEach(() => {
|
|
80
|
-
mockedAxios.get.mockClear();
|
|
81
|
-
mockedAxios.post.mockClear();
|
|
82
|
-
mockedAxios.put.mockClear();
|
|
83
|
-
mockedAxios.delete.mockClear();
|
|
84
|
-
mockedAxios.patch.mockClear();
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('handles GET requests', async () => {
|
|
88
|
-
mockedAxios.get.mockResolvedValue({ data: { success: true } });
|
|
89
|
-
const actionRequest = new ActionRequest(
|
|
90
|
-
'https://example.com',
|
|
91
|
-
'/get',
|
|
92
|
-
'GET',
|
|
93
|
-
'testGet',
|
|
94
|
-
false,
|
|
95
|
-
'application/json',
|
|
96
|
-
);
|
|
97
|
-
actionRequest.setParams({ param: 'test' });
|
|
98
|
-
const response = await actionRequest.execute();
|
|
99
|
-
expect(mockedAxios.get).toHaveBeenCalled();
|
|
100
|
-
expect(response.data.success).toBe(true);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('handles POST requests', async () => {
|
|
104
|
-
mockedAxios.post.mockResolvedValue({ data: { success: true } });
|
|
105
|
-
const actionRequest = new ActionRequest(
|
|
106
|
-
'https://example.com',
|
|
107
|
-
'/post',
|
|
108
|
-
'POST',
|
|
109
|
-
'testPost',
|
|
110
|
-
false,
|
|
111
|
-
'application/json',
|
|
112
|
-
);
|
|
113
|
-
actionRequest.setParams({ param: 'test' });
|
|
114
|
-
const response = await actionRequest.execute();
|
|
115
|
-
expect(mockedAxios.post).toHaveBeenCalled();
|
|
116
|
-
expect(response.data.success).toBe(true);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('handles PUT requests', async () => {
|
|
120
|
-
mockedAxios.put.mockResolvedValue({ data: { success: true } });
|
|
121
|
-
const actionRequest = new ActionRequest(
|
|
122
|
-
'https://example.com',
|
|
123
|
-
'/put',
|
|
124
|
-
'PUT',
|
|
125
|
-
'testPut',
|
|
126
|
-
false,
|
|
127
|
-
'application/json',
|
|
128
|
-
);
|
|
129
|
-
actionRequest.setParams({ param: 'test' });
|
|
130
|
-
const response = await actionRequest.execute();
|
|
131
|
-
expect(mockedAxios.put).toHaveBeenCalled();
|
|
132
|
-
expect(response.data.success).toBe(true);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('handles DELETE requests', async () => {
|
|
136
|
-
mockedAxios.delete.mockResolvedValue({ data: { success: true } });
|
|
137
|
-
const actionRequest = new ActionRequest(
|
|
138
|
-
'https://example.com',
|
|
139
|
-
'/delete',
|
|
140
|
-
'DELETE',
|
|
141
|
-
'testDelete',
|
|
142
|
-
false,
|
|
143
|
-
'application/json',
|
|
144
|
-
);
|
|
145
|
-
actionRequest.setParams({ param: 'test' });
|
|
146
|
-
const response = await actionRequest.execute();
|
|
147
|
-
expect(mockedAxios.delete).toHaveBeenCalled();
|
|
148
|
-
expect(response.data.success).toBe(true);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('handles PATCH requests', async () => {
|
|
152
|
-
mockedAxios.patch.mockResolvedValue({ data: { success: true } });
|
|
153
|
-
const actionRequest = new ActionRequest(
|
|
154
|
-
'https://example.com',
|
|
155
|
-
'/patch',
|
|
156
|
-
'PATCH',
|
|
157
|
-
'testPatch',
|
|
158
|
-
false,
|
|
159
|
-
'application/json',
|
|
160
|
-
);
|
|
161
|
-
actionRequest.setParams({ param: 'test' });
|
|
162
|
-
const response = await actionRequest.execute();
|
|
163
|
-
expect(mockedAxios.patch).toHaveBeenCalled();
|
|
164
|
-
expect(response.data.success).toBe(true);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('throws an error for unsupported HTTP methods', async () => {
|
|
168
|
-
const actionRequest = new ActionRequest(
|
|
169
|
-
'https://example.com',
|
|
170
|
-
'/invalid',
|
|
171
|
-
'INVALID',
|
|
172
|
-
'testInvalid',
|
|
173
|
-
false,
|
|
174
|
-
'application/json',
|
|
175
|
-
);
|
|
176
|
-
await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: invalid');
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('replaces path parameters with values from toolInput', async () => {
|
|
180
|
-
const actionRequest = new ActionRequest(
|
|
181
|
-
'https://example.com',
|
|
182
|
-
'/stocks/{stocksTicker}/bars/{multiplier}',
|
|
183
|
-
'GET',
|
|
184
|
-
'getAggregateBars',
|
|
185
|
-
false,
|
|
186
|
-
'application/json',
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
const executor = actionRequest.createExecutor();
|
|
190
|
-
executor.setParams({
|
|
191
|
-
stocksTicker: 'AAPL',
|
|
192
|
-
multiplier: 5,
|
|
193
|
-
startDate: '2023-01-01',
|
|
194
|
-
endDate: '2023-12-31',
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
expect(executor.path).toBe('/stocks/AAPL/bars/5');
|
|
198
|
-
expect(executor.params).toEqual({
|
|
199
|
-
startDate: '2023-01-01',
|
|
200
|
-
endDate: '2023-12-31',
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
await executor.execute();
|
|
204
|
-
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/stocks/AAPL/bars/5', {
|
|
205
|
-
headers: expect.anything(),
|
|
206
|
-
params: {
|
|
207
|
-
startDate: '2023-01-01',
|
|
208
|
-
endDate: '2023-12-31',
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('handles GET requests with header and query parameters', async () => {
|
|
214
|
-
mockedAxios.get.mockResolvedValue({ data: { success: true } });
|
|
215
|
-
|
|
216
|
-
const data: Record<string, unknown> = {
|
|
217
|
-
'api-version': '2025-01-01',
|
|
218
|
-
'some-header': 'header-var',
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
|
222
|
-
'api-version': 'query',
|
|
223
|
-
'some-header': 'header',
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
const actionRequest = new ActionRequest(
|
|
227
|
-
'https://example.com',
|
|
228
|
-
'/get',
|
|
229
|
-
'GET',
|
|
230
|
-
'testGET',
|
|
231
|
-
false,
|
|
232
|
-
'',
|
|
233
|
-
loc,
|
|
234
|
-
);
|
|
235
|
-
const executer = actionRequest.setParams(data);
|
|
236
|
-
const response = await executer.execute();
|
|
237
|
-
expect(mockedAxios.get).toHaveBeenCalled();
|
|
238
|
-
|
|
239
|
-
const [url, config] = mockedAxios.get.mock.calls[0];
|
|
240
|
-
expect(url).toBe('https://example.com/get');
|
|
241
|
-
expect(config?.headers).toEqual({
|
|
242
|
-
'some-header': 'header-var',
|
|
243
|
-
});
|
|
244
|
-
expect(config?.params).toEqual({
|
|
245
|
-
'api-version': '2025-01-01',
|
|
246
|
-
});
|
|
247
|
-
expect(response.data.success).toBe(true);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it('handles GET requests with header and path parameters', async () => {
|
|
251
|
-
mockedAxios.get.mockResolvedValue({ data: { success: true } });
|
|
252
|
-
|
|
253
|
-
const data: Record<string, unknown> = {
|
|
254
|
-
'user-id': '1',
|
|
255
|
-
'some-header': 'header-var',
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
|
259
|
-
'user-id': 'path',
|
|
260
|
-
'some-header': 'header',
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const actionRequest = new ActionRequest(
|
|
264
|
-
'https://example.com',
|
|
265
|
-
'/getwithpath/{user-id}',
|
|
266
|
-
'GET',
|
|
267
|
-
'testGETwithpath',
|
|
268
|
-
false,
|
|
269
|
-
'',
|
|
270
|
-
loc,
|
|
271
|
-
);
|
|
272
|
-
const executer = actionRequest.setParams(data);
|
|
273
|
-
const response = await executer.execute();
|
|
274
|
-
expect(mockedAxios.get).toHaveBeenCalled();
|
|
275
|
-
|
|
276
|
-
const [url, config] = mockedAxios.get.mock.calls[0];
|
|
277
|
-
expect(url).toBe('https://example.com/getwithpath/1');
|
|
278
|
-
expect(config?.headers).toEqual({
|
|
279
|
-
'some-header': 'header-var',
|
|
280
|
-
});
|
|
281
|
-
expect(config?.params).toEqual({});
|
|
282
|
-
expect(response.data.success).toBe(true);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
it('handles POST requests with body, header and query parameters', async () => {
|
|
286
|
-
mockedAxios.post.mockResolvedValue({ data: { success: true } });
|
|
287
|
-
|
|
288
|
-
const data: Record<string, unknown> = {
|
|
289
|
-
'api-version': '2025-01-01',
|
|
290
|
-
message: 'a body parameter',
|
|
291
|
-
'some-header': 'header-var',
|
|
292
|
-
};
|
|
293
|
-
|
|
294
|
-
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
|
295
|
-
'api-version': 'query',
|
|
296
|
-
message: 'body',
|
|
297
|
-
'some-header': 'header',
|
|
298
|
-
};
|
|
299
|
-
|
|
300
|
-
const actionRequest = new ActionRequest(
|
|
301
|
-
'https://example.com',
|
|
302
|
-
'/post',
|
|
303
|
-
'POST',
|
|
304
|
-
'testPost',
|
|
305
|
-
false,
|
|
306
|
-
'application/json',
|
|
307
|
-
loc,
|
|
308
|
-
);
|
|
309
|
-
const executer = actionRequest.setParams(data);
|
|
310
|
-
const response = await executer.execute();
|
|
311
|
-
expect(mockedAxios.post).toHaveBeenCalled();
|
|
312
|
-
|
|
313
|
-
const [url, body, config] = mockedAxios.post.mock.calls[0];
|
|
314
|
-
expect(url).toBe('https://example.com/post');
|
|
315
|
-
expect(body).toEqual({ message: 'a body parameter' });
|
|
316
|
-
expect(config?.headers).toEqual({
|
|
317
|
-
'some-header': 'header-var',
|
|
318
|
-
'Content-Type': 'application/json',
|
|
319
|
-
});
|
|
320
|
-
expect(config?.params).toEqual({
|
|
321
|
-
'api-version': '2025-01-01',
|
|
322
|
-
});
|
|
323
|
-
expect(response.data.success).toBe(true);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it('handles PUT requests with body, header and query parameters', async () => {
|
|
327
|
-
mockedAxios.put.mockResolvedValue({ data: { success: true } });
|
|
328
|
-
|
|
329
|
-
const data: Record<string, unknown> = {
|
|
330
|
-
'api-version': '2025-01-01',
|
|
331
|
-
message: 'a body parameter',
|
|
332
|
-
'some-header': 'header-var',
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
|
336
|
-
'api-version': 'query',
|
|
337
|
-
message: 'body',
|
|
338
|
-
'some-header': 'header',
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
const actionRequest = new ActionRequest(
|
|
342
|
-
'https://example.com',
|
|
343
|
-
'/put',
|
|
344
|
-
'PUT',
|
|
345
|
-
'testPut',
|
|
346
|
-
false,
|
|
347
|
-
'application/json',
|
|
348
|
-
loc,
|
|
349
|
-
);
|
|
350
|
-
const executer = actionRequest.setParams(data);
|
|
351
|
-
const response = await executer.execute();
|
|
352
|
-
expect(mockedAxios.put).toHaveBeenCalled();
|
|
353
|
-
|
|
354
|
-
const [url, body, config] = mockedAxios.put.mock.calls[0];
|
|
355
|
-
expect(url).toBe('https://example.com/put');
|
|
356
|
-
expect(body).toEqual({ message: 'a body parameter' });
|
|
357
|
-
expect(config?.headers).toEqual({
|
|
358
|
-
'some-header': 'header-var',
|
|
359
|
-
'Content-Type': 'application/json',
|
|
360
|
-
});
|
|
361
|
-
expect(config?.params).toEqual({
|
|
362
|
-
'api-version': '2025-01-01',
|
|
363
|
-
});
|
|
364
|
-
expect(response.data.success).toBe(true);
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it('handles PATCH requests with body, header and query parameters', async () => {
|
|
368
|
-
mockedAxios.patch.mockResolvedValue({ data: { success: true } });
|
|
369
|
-
|
|
370
|
-
const data: Record<string, unknown> = {
|
|
371
|
-
'api-version': '2025-01-01',
|
|
372
|
-
message: 'a body parameter',
|
|
373
|
-
'some-header': 'header-var',
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
|
377
|
-
'api-version': 'query',
|
|
378
|
-
message: 'body',
|
|
379
|
-
'some-header': 'header',
|
|
380
|
-
};
|
|
381
|
-
|
|
382
|
-
const actionRequest = new ActionRequest(
|
|
383
|
-
'https://example.com',
|
|
384
|
-
'/patch',
|
|
385
|
-
'PATCH',
|
|
386
|
-
'testPatch',
|
|
387
|
-
false,
|
|
388
|
-
'application/json',
|
|
389
|
-
loc,
|
|
390
|
-
);
|
|
391
|
-
const executer = actionRequest.setParams(data);
|
|
392
|
-
const response = await executer.execute();
|
|
393
|
-
expect(mockedAxios.patch).toHaveBeenCalled();
|
|
394
|
-
|
|
395
|
-
const [url, body, config] = mockedAxios.patch.mock.calls[0];
|
|
396
|
-
expect(url).toBe('https://example.com/patch');
|
|
397
|
-
expect(body).toEqual({ message: 'a body parameter' });
|
|
398
|
-
expect(config?.headers).toEqual({
|
|
399
|
-
'some-header': 'header-var',
|
|
400
|
-
'Content-Type': 'application/json',
|
|
401
|
-
});
|
|
402
|
-
expect(config?.params).toEqual({
|
|
403
|
-
'api-version': '2025-01-01',
|
|
404
|
-
});
|
|
405
|
-
expect(response.data.success).toBe(true);
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
it('handles DELETE requests with body, header and query parameters', async () => {
|
|
409
|
-
mockedAxios.delete.mockResolvedValue({ data: { success: true } });
|
|
410
|
-
|
|
411
|
-
const data: Record<string, unknown> = {
|
|
412
|
-
'api-version': '2025-01-01',
|
|
413
|
-
'message-id': '1',
|
|
414
|
-
'some-header': 'header-var',
|
|
415
|
-
};
|
|
416
|
-
|
|
417
|
-
const loc: Record<string, 'query' | 'path' | 'header' | 'body'> = {
|
|
418
|
-
'api-version': 'query',
|
|
419
|
-
'message-id': 'body',
|
|
420
|
-
'some-header': 'header',
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
const actionRequest = new ActionRequest(
|
|
424
|
-
'https://example.com',
|
|
425
|
-
'/delete',
|
|
426
|
-
'DELETE',
|
|
427
|
-
'testDelete',
|
|
428
|
-
false,
|
|
429
|
-
'application/json',
|
|
430
|
-
loc,
|
|
431
|
-
);
|
|
432
|
-
const executer = actionRequest.setParams(data);
|
|
433
|
-
const response = await executer.execute();
|
|
434
|
-
expect(mockedAxios.delete).toHaveBeenCalled();
|
|
435
|
-
|
|
436
|
-
const [url, config] = mockedAxios.delete.mock.calls[0];
|
|
437
|
-
expect(url).toBe('https://example.com/delete');
|
|
438
|
-
expect(config?.data).toEqual({ 'message-id': '1' });
|
|
439
|
-
expect(config?.headers).toEqual({
|
|
440
|
-
'some-header': 'header-var',
|
|
441
|
-
'Content-Type': 'application/json',
|
|
442
|
-
});
|
|
443
|
-
expect(config?.params).toEqual({
|
|
444
|
-
'api-version': '2025-01-01',
|
|
445
|
-
});
|
|
446
|
-
expect(response.data.success).toBe(true);
|
|
447
|
-
});
|
|
448
|
-
});
|
|
449
|
-
|
|
450
|
-
it('throws an error for unsupported HTTP method', async () => {
|
|
451
|
-
const actionRequest = new ActionRequest(
|
|
452
|
-
'https://example.com',
|
|
453
|
-
'/test',
|
|
454
|
-
'INVALID',
|
|
455
|
-
'testOp',
|
|
456
|
-
false,
|
|
457
|
-
'application/json',
|
|
458
|
-
);
|
|
459
|
-
await expect(actionRequest.execute()).rejects.toThrow('Unsupported HTTP method: invalid');
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
describe('SSRF-safe agent passthrough', () => {
|
|
463
|
-
beforeEach(() => {
|
|
464
|
-
mockedAxios.get.mockResolvedValue({ data: { success: true } });
|
|
465
|
-
mockedAxios.post.mockResolvedValue({ data: { success: true } });
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
it('should pass httpAgent and httpsAgent to axios.create when provided', async () => {
|
|
469
|
-
const mockHttpAgent = { keepAlive: true };
|
|
470
|
-
const mockHttpsAgent = { keepAlive: true };
|
|
471
|
-
|
|
472
|
-
const actionRequest = new ActionRequest(
|
|
473
|
-
'https://example.com',
|
|
474
|
-
'/test',
|
|
475
|
-
'GET',
|
|
476
|
-
'testOp',
|
|
477
|
-
false,
|
|
478
|
-
'application/json',
|
|
479
|
-
);
|
|
480
|
-
const executor = actionRequest.createExecutor();
|
|
481
|
-
executor.setParams({ key: 'value' });
|
|
482
|
-
await executor.execute({ httpAgent: mockHttpAgent, httpsAgent: mockHttpsAgent });
|
|
483
|
-
|
|
484
|
-
expect(mockedAxios.create).toHaveBeenCalledWith(
|
|
485
|
-
expect.objectContaining({
|
|
486
|
-
httpAgent: mockHttpAgent,
|
|
487
|
-
httpsAgent: mockHttpsAgent,
|
|
488
|
-
maxRedirects: 0,
|
|
489
|
-
}),
|
|
490
|
-
);
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
it('should not include agent keys when no options are provided', async () => {
|
|
494
|
-
const actionRequest = new ActionRequest(
|
|
495
|
-
'https://example.com',
|
|
496
|
-
'/test',
|
|
497
|
-
'GET',
|
|
498
|
-
'testOp',
|
|
499
|
-
false,
|
|
500
|
-
'application/json',
|
|
501
|
-
);
|
|
502
|
-
const executor = actionRequest.createExecutor();
|
|
503
|
-
executor.setParams({ key: 'value' });
|
|
504
|
-
await executor.execute();
|
|
505
|
-
|
|
506
|
-
const createArg = mockedAxios.create.mock.calls[
|
|
507
|
-
mockedAxios.create.mock.calls.length - 1
|
|
508
|
-
][0] as Record<string, unknown>;
|
|
509
|
-
expect(createArg).not.toHaveProperty('httpAgent');
|
|
510
|
-
expect(createArg).not.toHaveProperty('httpsAgent');
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it('should pass agents through for POST requests', async () => {
|
|
514
|
-
const mockAgent = { ssrf: true };
|
|
515
|
-
|
|
516
|
-
const actionRequest = new ActionRequest(
|
|
517
|
-
'https://example.com',
|
|
518
|
-
'/test',
|
|
519
|
-
'POST',
|
|
520
|
-
'testOp',
|
|
521
|
-
false,
|
|
522
|
-
'application/json',
|
|
523
|
-
);
|
|
524
|
-
const executor = actionRequest.createExecutor();
|
|
525
|
-
executor.setParams({ body: 'data' });
|
|
526
|
-
await executor.execute({ httpAgent: mockAgent, httpsAgent: mockAgent });
|
|
527
|
-
|
|
528
|
-
expect(mockedAxios.create).toHaveBeenCalledWith(
|
|
529
|
-
expect.objectContaining({
|
|
530
|
-
httpAgent: mockAgent,
|
|
531
|
-
httpsAgent: mockAgent,
|
|
532
|
-
}),
|
|
533
|
-
);
|
|
534
|
-
expect(mockedAxios.post).toHaveBeenCalled();
|
|
535
|
-
});
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
describe('ActionRequest Concurrent Execution', () => {
|
|
539
|
-
beforeEach(() => {
|
|
540
|
-
jest.clearAllMocks();
|
|
541
|
-
mockedAxios.get.mockImplementation(async (url, config) => ({
|
|
542
|
-
data: { url, params: config?.params, headers: config?.headers },
|
|
543
|
-
}));
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
it('maintains isolated state between concurrent executions with different parameters', async () => {
|
|
547
|
-
const actionRequest = new ActionRequest(
|
|
548
|
-
'https://example.com',
|
|
549
|
-
'/math/sqrt/{number}',
|
|
550
|
-
'GET',
|
|
551
|
-
'getSqrt',
|
|
552
|
-
false,
|
|
553
|
-
'application/json',
|
|
554
|
-
);
|
|
555
|
-
|
|
556
|
-
// Simulate concurrent requests with different numbers
|
|
557
|
-
const numbers = [20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30];
|
|
558
|
-
const requests = numbers.map((num) => ({
|
|
559
|
-
number: num.toString(),
|
|
560
|
-
precision: '2',
|
|
561
|
-
}));
|
|
562
|
-
|
|
563
|
-
const responses = await Promise.all(
|
|
564
|
-
requests.map((params) => {
|
|
565
|
-
const executor = actionRequest.createExecutor();
|
|
566
|
-
return executor.setParams(params).execute();
|
|
567
|
-
}),
|
|
568
|
-
);
|
|
569
|
-
|
|
570
|
-
// Verify each response used the correct path parameter
|
|
571
|
-
responses.forEach((response, index) => {
|
|
572
|
-
const expectedUrl = `https://example.com/math/sqrt/${numbers[index]}`;
|
|
573
|
-
expect(response.data.url).toBe(expectedUrl);
|
|
574
|
-
expect(response.data.params).toEqual({ precision: '2' });
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
// Verify the correct number of calls were made
|
|
578
|
-
expect(mockedAxios.get).toHaveBeenCalledTimes(numbers.length);
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it('maintains isolated authentication state between concurrent executions', async () => {
|
|
582
|
-
const actionRequest = new ActionRequest(
|
|
583
|
-
'https://example.com',
|
|
584
|
-
'/secure/resource/{id}',
|
|
585
|
-
'GET',
|
|
586
|
-
'getResource',
|
|
587
|
-
false,
|
|
588
|
-
'application/json',
|
|
589
|
-
);
|
|
590
|
-
|
|
591
|
-
const requests = [
|
|
592
|
-
{
|
|
593
|
-
params: { id: '1' },
|
|
594
|
-
auth: {
|
|
595
|
-
auth: {
|
|
596
|
-
type: AuthTypeEnum.ServiceHttp,
|
|
597
|
-
authorization_type: AuthorizationTypeEnum.Bearer,
|
|
598
|
-
},
|
|
599
|
-
api_key: 'token1',
|
|
600
|
-
},
|
|
601
|
-
},
|
|
602
|
-
{
|
|
603
|
-
params: { id: '2' },
|
|
604
|
-
auth: {
|
|
605
|
-
auth: {
|
|
606
|
-
type: AuthTypeEnum.ServiceHttp,
|
|
607
|
-
authorization_type: AuthorizationTypeEnum.Bearer,
|
|
608
|
-
},
|
|
609
|
-
api_key: 'token2',
|
|
610
|
-
},
|
|
611
|
-
},
|
|
612
|
-
];
|
|
613
|
-
|
|
614
|
-
const responses = await Promise.all(
|
|
615
|
-
requests.map(async ({ params, auth }) => {
|
|
616
|
-
const executor = actionRequest.createExecutor();
|
|
617
|
-
return (await executor.setParams(params).setAuth(auth)).execute();
|
|
618
|
-
}),
|
|
619
|
-
);
|
|
620
|
-
|
|
621
|
-
// Verify each response had its own auth token
|
|
622
|
-
responses.forEach((response, index) => {
|
|
623
|
-
const expectedUrl = `https://example.com/secure/resource/${index + 1}`;
|
|
624
|
-
expect(response.data.url).toBe(expectedUrl);
|
|
625
|
-
expect(response.data.headers).toMatchObject({
|
|
626
|
-
Authorization: `Bearer token${index + 1}`,
|
|
627
|
-
});
|
|
628
|
-
});
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
it('handles mixed authentication types concurrently', async () => {
|
|
632
|
-
const actionRequest = new ActionRequest(
|
|
633
|
-
'https://example.com',
|
|
634
|
-
'/api/{version}/data',
|
|
635
|
-
'GET',
|
|
636
|
-
'getData',
|
|
637
|
-
false,
|
|
638
|
-
'application/json',
|
|
639
|
-
);
|
|
640
|
-
|
|
641
|
-
const requests = [
|
|
642
|
-
{
|
|
643
|
-
params: { version: 'v1' },
|
|
644
|
-
auth: {
|
|
645
|
-
auth: {
|
|
646
|
-
type: AuthTypeEnum.ServiceHttp,
|
|
647
|
-
authorization_type: AuthorizationTypeEnum.Bearer,
|
|
648
|
-
},
|
|
649
|
-
api_key: 'bearer_token',
|
|
650
|
-
},
|
|
651
|
-
},
|
|
652
|
-
{
|
|
653
|
-
params: { version: 'v2' },
|
|
654
|
-
auth: {
|
|
655
|
-
auth: {
|
|
656
|
-
type: AuthTypeEnum.ServiceHttp,
|
|
657
|
-
authorization_type: AuthorizationTypeEnum.Basic,
|
|
658
|
-
},
|
|
659
|
-
api_key: 'basic:auth',
|
|
660
|
-
},
|
|
661
|
-
},
|
|
662
|
-
{
|
|
663
|
-
params: { version: 'v3' },
|
|
664
|
-
auth: {
|
|
665
|
-
auth: {
|
|
666
|
-
type: AuthTypeEnum.ServiceHttp,
|
|
667
|
-
authorization_type: AuthorizationTypeEnum.Custom,
|
|
668
|
-
custom_auth_header: 'X-API-Key',
|
|
669
|
-
},
|
|
670
|
-
api_key: 'custom_key',
|
|
671
|
-
},
|
|
672
|
-
},
|
|
673
|
-
];
|
|
674
|
-
|
|
675
|
-
const responses = await Promise.all(
|
|
676
|
-
requests.map(async ({ params, auth }) => {
|
|
677
|
-
const executor = actionRequest.createExecutor();
|
|
678
|
-
return (await executor.setParams(params).setAuth(auth)).execute();
|
|
679
|
-
}),
|
|
680
|
-
);
|
|
681
|
-
|
|
682
|
-
// Verify each response had the correct auth type and headers
|
|
683
|
-
expect(responses[0].data.headers).toMatchObject({
|
|
684
|
-
Authorization: 'Bearer bearer_token',
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
expect(responses[1].data.headers).toMatchObject({
|
|
688
|
-
Authorization: `Basic ${Buffer.from('basic:auth').toString('base64')}`,
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
expect(responses[2].data.headers).toMatchObject({
|
|
692
|
-
'X-API-Key': 'custom_key',
|
|
693
|
-
});
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
it('maintains parameter integrity during concurrent path parameter replacement', async () => {
|
|
697
|
-
const actionRequest = new ActionRequest(
|
|
698
|
-
'https://example.com',
|
|
699
|
-
'/users/{userId}/posts/{postId}',
|
|
700
|
-
'GET',
|
|
701
|
-
'getUserPost',
|
|
702
|
-
false,
|
|
703
|
-
'application/json',
|
|
704
|
-
);
|
|
705
|
-
|
|
706
|
-
const requests = [
|
|
707
|
-
{ userId: '1', postId: 'a', filter: 'recent' },
|
|
708
|
-
{ userId: '2', postId: 'b', filter: 'popular' },
|
|
709
|
-
{ userId: '3', postId: 'c', filter: 'trending' },
|
|
710
|
-
];
|
|
711
|
-
|
|
712
|
-
const responses = await Promise.all(
|
|
713
|
-
requests.map((params) => {
|
|
714
|
-
const executor = actionRequest.createExecutor();
|
|
715
|
-
return executor.setParams(params).execute();
|
|
716
|
-
}),
|
|
717
|
-
);
|
|
718
|
-
|
|
719
|
-
responses.forEach((response, index) => {
|
|
720
|
-
const expectedUrl = `https://example.com/users/${requests[index].userId}/posts/${requests[index].postId}`;
|
|
721
|
-
expect(response.data.url).toBe(expectedUrl);
|
|
722
|
-
expect(response.data.params).toEqual({ filter: requests[index].filter });
|
|
723
|
-
});
|
|
724
|
-
});
|
|
725
|
-
|
|
726
|
-
it('preserves original ActionRequest state after multiple executions', async () => {
|
|
727
|
-
const actionRequest = new ActionRequest(
|
|
728
|
-
'https://example.com',
|
|
729
|
-
'/original/{param}',
|
|
730
|
-
'GET',
|
|
731
|
-
'testOp',
|
|
732
|
-
false,
|
|
733
|
-
'application/json',
|
|
734
|
-
);
|
|
735
|
-
|
|
736
|
-
// Store original values
|
|
737
|
-
const originalPath = actionRequest.path;
|
|
738
|
-
const originalDomain = actionRequest.domain;
|
|
739
|
-
const originalMethod = actionRequest.method;
|
|
740
|
-
|
|
741
|
-
// Perform multiple concurrent executions
|
|
742
|
-
await Promise.all([
|
|
743
|
-
actionRequest.createExecutor().setParams({ param: '1' }).execute(),
|
|
744
|
-
actionRequest.createExecutor().setParams({ param: '2' }).execute(),
|
|
745
|
-
actionRequest.createExecutor().setParams({ param: '3' }).execute(),
|
|
746
|
-
]);
|
|
747
|
-
|
|
748
|
-
// Verify original ActionRequest remains unchanged
|
|
749
|
-
expect(actionRequest.path).toBe(originalPath);
|
|
750
|
-
expect(actionRequest.domain).toBe(originalDomain);
|
|
751
|
-
expect(actionRequest.method).toBe(originalMethod);
|
|
752
|
-
});
|
|
753
|
-
|
|
754
|
-
it('shares immutable configuration between executors from the same ActionRequest', () => {
|
|
755
|
-
const actionRequest = new ActionRequest(
|
|
756
|
-
'https://example.com',
|
|
757
|
-
'/api/{version}/data',
|
|
758
|
-
'GET',
|
|
759
|
-
'getData',
|
|
760
|
-
false,
|
|
761
|
-
'application/json',
|
|
762
|
-
);
|
|
763
|
-
|
|
764
|
-
// Create multiple executors
|
|
765
|
-
const executor1 = actionRequest.createExecutor();
|
|
766
|
-
const executor2 = actionRequest.createExecutor();
|
|
767
|
-
const executor3 = actionRequest.createExecutor();
|
|
768
|
-
|
|
769
|
-
// Test that the configuration properties are shared
|
|
770
|
-
[executor1, executor2, executor3].forEach((executor) => {
|
|
771
|
-
expect(executor.getConfig()).toBeDefined();
|
|
772
|
-
expect(executor.getConfig()).toEqual({
|
|
773
|
-
domain: 'https://example.com',
|
|
774
|
-
basePath: '/api/{version}/data',
|
|
775
|
-
method: 'GET',
|
|
776
|
-
operation: 'getData',
|
|
777
|
-
isConsequential: false,
|
|
778
|
-
contentType: 'application/json',
|
|
779
|
-
});
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
// Verify that config objects are the exact same instance (shared reference)
|
|
783
|
-
expect(executor1.getConfig()).toBe(executor2.getConfig());
|
|
784
|
-
expect(executor2.getConfig()).toBe(executor3.getConfig());
|
|
785
|
-
|
|
786
|
-
// Verify that modifying mutable state doesn't affect other executors
|
|
787
|
-
executor1.setParams({ version: 'v1' });
|
|
788
|
-
executor2.setParams({ version: 'v2' });
|
|
789
|
-
executor3.setParams({ version: 'v3' });
|
|
790
|
-
|
|
791
|
-
expect(executor1.path).toBe('/api/v1/data');
|
|
792
|
-
expect(executor2.path).toBe('/api/v2/data');
|
|
793
|
-
expect(executor3.path).toBe('/api/v3/data');
|
|
794
|
-
|
|
795
|
-
// Verify that the original config remains unchanged
|
|
796
|
-
expect(executor1.getConfig().basePath).toBe('/api/{version}/data');
|
|
797
|
-
expect(executor2.getConfig().basePath).toBe('/api/{version}/data');
|
|
798
|
-
expect(executor3.getConfig().basePath).toBe('/api/{version}/data');
|
|
799
|
-
});
|
|
800
|
-
});
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
describe('Authentication Handling', () => {
|
|
804
|
-
it('correctly sets Basic Auth header', async () => {
|
|
805
|
-
const actionRequest = new ActionRequest(
|
|
806
|
-
'https://example.com',
|
|
807
|
-
'/test',
|
|
808
|
-
'GET',
|
|
809
|
-
'testOp',
|
|
810
|
-
false,
|
|
811
|
-
'application/json',
|
|
812
|
-
);
|
|
813
|
-
|
|
814
|
-
const api_key = 'user:pass';
|
|
815
|
-
const encodedCredentials = Buffer.from('user:pass').toString('base64');
|
|
816
|
-
|
|
817
|
-
const executor = actionRequest.createExecutor();
|
|
818
|
-
await executor.setParams({ param1: 'value1' }).setAuth({
|
|
819
|
-
auth: {
|
|
820
|
-
type: AuthTypeEnum.ServiceHttp,
|
|
821
|
-
authorization_type: AuthorizationTypeEnum.Basic,
|
|
822
|
-
},
|
|
823
|
-
api_key,
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
await executor.execute();
|
|
827
|
-
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
|
|
828
|
-
headers: expect.objectContaining({
|
|
829
|
-
Authorization: `Basic ${encodedCredentials}`,
|
|
830
|
-
'Content-Type': 'application/json',
|
|
831
|
-
}),
|
|
832
|
-
params: { param1: 'value1' },
|
|
833
|
-
});
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
it('correctly sets Bearer token', async () => {
|
|
837
|
-
const actionRequest = new ActionRequest(
|
|
838
|
-
'https://example.com',
|
|
839
|
-
'/test',
|
|
840
|
-
'GET',
|
|
841
|
-
'testOp',
|
|
842
|
-
false,
|
|
843
|
-
'application/json',
|
|
844
|
-
);
|
|
845
|
-
|
|
846
|
-
const executor = actionRequest.createExecutor();
|
|
847
|
-
await executor.setParams({ param1: 'value1' }).setAuth({
|
|
848
|
-
auth: {
|
|
849
|
-
type: AuthTypeEnum.ServiceHttp,
|
|
850
|
-
authorization_type: AuthorizationTypeEnum.Bearer,
|
|
851
|
-
},
|
|
852
|
-
api_key: 'token123',
|
|
853
|
-
});
|
|
854
|
-
|
|
855
|
-
await executor.execute();
|
|
856
|
-
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
|
|
857
|
-
headers: expect.objectContaining({
|
|
858
|
-
Authorization: 'Bearer token123',
|
|
859
|
-
'Content-Type': 'application/json',
|
|
860
|
-
}),
|
|
861
|
-
params: { param1: 'value1' },
|
|
862
|
-
});
|
|
863
|
-
});
|
|
864
|
-
|
|
865
|
-
it('correctly sets API Key', async () => {
|
|
866
|
-
const actionRequest = new ActionRequest(
|
|
867
|
-
'https://example.com',
|
|
868
|
-
'/test',
|
|
869
|
-
'GET',
|
|
870
|
-
'testOp',
|
|
871
|
-
false,
|
|
872
|
-
'application/json',
|
|
873
|
-
);
|
|
874
|
-
|
|
875
|
-
const executor = actionRequest.createExecutor();
|
|
876
|
-
await executor.setParams({ param1: 'value1' }).setAuth({
|
|
877
|
-
auth: {
|
|
878
|
-
type: AuthTypeEnum.ServiceHttp,
|
|
879
|
-
authorization_type: AuthorizationTypeEnum.Custom,
|
|
880
|
-
custom_auth_header: 'X-API-KEY',
|
|
881
|
-
},
|
|
882
|
-
api_key: 'abc123',
|
|
883
|
-
});
|
|
884
|
-
|
|
885
|
-
await executor.execute();
|
|
886
|
-
expect(mockedAxios.get).toHaveBeenCalledWith('https://example.com/test', {
|
|
887
|
-
headers: expect.objectContaining({
|
|
888
|
-
'X-API-KEY': 'abc123',
|
|
889
|
-
'Content-Type': 'application/json',
|
|
890
|
-
}),
|
|
891
|
-
params: { param1: 'value1' },
|
|
892
|
-
});
|
|
893
|
-
});
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
describe('resolveRef', () => {
|
|
897
|
-
it('correctly resolves $ref references in the OpenAPI spec', () => {
|
|
898
|
-
const openapiSpec = whimsicalOpenapiSpec;
|
|
899
|
-
const flowchartRequestRef = (
|
|
900
|
-
openapiSpec.paths['/ai.chatgpt.render-flowchart']?.post
|
|
901
|
-
?.requestBody as OpenAPIV3.RequestBodyObject
|
|
902
|
-
).content['application/json'].schema;
|
|
903
|
-
|
|
904
|
-
expect(flowchartRequestRef).toBeDefined();
|
|
905
|
-
|
|
906
|
-
const resolvedSchemaObject = resolveRef(
|
|
907
|
-
flowchartRequestRef as OpenAPIV3.ReferenceObject,
|
|
908
|
-
openapiSpec.components,
|
|
909
|
-
) as OpenAPIV3.SchemaObject;
|
|
910
|
-
|
|
911
|
-
expect(resolvedSchemaObject).toBeDefined();
|
|
912
|
-
expect(resolvedSchemaObject.type).toBe('object');
|
|
913
|
-
expect(resolvedSchemaObject.properties).toBeDefined();
|
|
914
|
-
|
|
915
|
-
const properties = resolvedSchemaObject.properties as FlowchartSchema;
|
|
916
|
-
expect(properties.mermaid).toBeDefined();
|
|
917
|
-
expect(properties.mermaid.type).toBe('string');
|
|
918
|
-
});
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
describe('resolveRef general cases', () => {
|
|
922
|
-
const spec = {
|
|
923
|
-
openapi: '3.0.0',
|
|
924
|
-
info: { title: 'TestSpec', version: '1.0.0' },
|
|
925
|
-
paths: {},
|
|
926
|
-
components: {
|
|
927
|
-
schemas: {
|
|
928
|
-
TestSchema: { type: 'string' },
|
|
929
|
-
},
|
|
930
|
-
parameters: {
|
|
931
|
-
TestParam: {
|
|
932
|
-
name: 'myParam',
|
|
933
|
-
in: 'query',
|
|
934
|
-
required: false,
|
|
935
|
-
schema: { $ref: '#/components/schemas/TestSchema' },
|
|
936
|
-
},
|
|
937
|
-
},
|
|
938
|
-
requestBodies: {
|
|
939
|
-
TestRequestBody: {
|
|
940
|
-
content: {
|
|
941
|
-
'application/json': {
|
|
942
|
-
schema: { $ref: '#/components/schemas/TestSchema' },
|
|
943
|
-
},
|
|
944
|
-
},
|
|
945
|
-
},
|
|
946
|
-
},
|
|
947
|
-
},
|
|
948
|
-
} satisfies OpenAPIV3.Document;
|
|
949
|
-
|
|
950
|
-
it('resolves schema refs correctly', () => {
|
|
951
|
-
const schemaRef: OpenAPIV3.ReferenceObject = { $ref: '#/components/schemas/TestSchema' };
|
|
952
|
-
const resolvedSchema = resolveRef<OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject>(
|
|
953
|
-
schemaRef,
|
|
954
|
-
spec.components,
|
|
955
|
-
);
|
|
956
|
-
expect(resolvedSchema.type).toEqual('string');
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
it('resolves parameter refs correctly, then schema within parameter', () => {
|
|
960
|
-
const paramRef: OpenAPIV3.ReferenceObject = { $ref: '#/components/parameters/TestParam' };
|
|
961
|
-
const resolvedParam = resolveRef<OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject>(
|
|
962
|
-
paramRef,
|
|
963
|
-
spec.components,
|
|
964
|
-
);
|
|
965
|
-
expect(resolvedParam.name).toEqual('myParam');
|
|
966
|
-
expect(resolvedParam.in).toEqual('query');
|
|
967
|
-
expect(resolvedParam.required).toBe(false);
|
|
968
|
-
|
|
969
|
-
const paramSchema = resolveRef<OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject>(
|
|
970
|
-
resolvedParam.schema as OpenAPIV3.ReferenceObject,
|
|
971
|
-
spec.components,
|
|
972
|
-
);
|
|
973
|
-
expect(paramSchema.type).toEqual('string');
|
|
974
|
-
});
|
|
975
|
-
|
|
976
|
-
it('resolves requestBody refs correctly, then schema within requestBody', () => {
|
|
977
|
-
const requestBodyRef: OpenAPIV3.ReferenceObject = {
|
|
978
|
-
$ref: '#/components/requestBodies/TestRequestBody',
|
|
979
|
-
};
|
|
980
|
-
const resolvedRequestBody = resolveRef<OpenAPIV3.ReferenceObject | OpenAPIV3.RequestBodyObject>(
|
|
981
|
-
requestBodyRef,
|
|
982
|
-
spec.components,
|
|
983
|
-
);
|
|
984
|
-
|
|
985
|
-
expect(resolvedRequestBody.content['application/json']).toBeDefined();
|
|
986
|
-
|
|
987
|
-
const schemaInRequestBody = resolveRef<OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject>(
|
|
988
|
-
resolvedRequestBody.content['application/json'].schema as OpenAPIV3.ReferenceObject,
|
|
989
|
-
spec.components,
|
|
990
|
-
);
|
|
991
|
-
|
|
992
|
-
expect(schemaInRequestBody.type).toEqual('string');
|
|
993
|
-
});
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
describe('openapiToFunction', () => {
|
|
997
|
-
it('converts OpenAPI spec to function signatures and request builders', () => {
|
|
998
|
-
const { functionSignatures, requestBuilders } = openapiToFunction(getWeatherOpenapiSpec);
|
|
999
|
-
expect(functionSignatures.length).toBe(1);
|
|
1000
|
-
expect(functionSignatures[0].name).toBe('GetCurrentWeather');
|
|
1001
|
-
|
|
1002
|
-
const parameters = functionSignatures[0].parameters as ParametersSchema & {
|
|
1003
|
-
properties: {
|
|
1004
|
-
location: {
|
|
1005
|
-
type: 'string';
|
|
1006
|
-
};
|
|
1007
|
-
locations: {
|
|
1008
|
-
type: 'array';
|
|
1009
|
-
items: {
|
|
1010
|
-
type: 'object';
|
|
1011
|
-
properties: {
|
|
1012
|
-
city: {
|
|
1013
|
-
type: 'string';
|
|
1014
|
-
};
|
|
1015
|
-
state: {
|
|
1016
|
-
type: 'string';
|
|
1017
|
-
};
|
|
1018
|
-
countryCode: {
|
|
1019
|
-
type: 'string';
|
|
1020
|
-
};
|
|
1021
|
-
time: {
|
|
1022
|
-
type: 'string';
|
|
1023
|
-
};
|
|
1024
|
-
};
|
|
1025
|
-
};
|
|
1026
|
-
};
|
|
1027
|
-
};
|
|
1028
|
-
};
|
|
1029
|
-
|
|
1030
|
-
expect(parameters).toBeDefined();
|
|
1031
|
-
expect(parameters.properties.locations).toBeDefined();
|
|
1032
|
-
expect(parameters.properties.locations.type).toBe('array');
|
|
1033
|
-
expect(parameters.properties.locations.items.type).toBe('object');
|
|
1034
|
-
|
|
1035
|
-
expect(parameters.properties.locations.items.properties.city.type).toBe('string');
|
|
1036
|
-
expect(parameters.properties.locations.items.properties.state.type).toBe('string');
|
|
1037
|
-
expect(parameters.properties.locations.items.properties.countryCode.type).toBe('string');
|
|
1038
|
-
expect(parameters.properties.locations.items.properties.time.type).toBe('string');
|
|
1039
|
-
|
|
1040
|
-
expect(requestBuilders).toHaveProperty('GetCurrentWeather');
|
|
1041
|
-
expect(requestBuilders.GetCurrentWeather).toBeInstanceOf(ActionRequest);
|
|
1042
|
-
expect(requestBuilders.GetCurrentWeather.contentType).toBe('application/json');
|
|
1043
|
-
});
|
|
1044
|
-
|
|
1045
|
-
it('preserves OpenAPI spec content-type', () => {
|
|
1046
|
-
const { functionSignatures, requestBuilders } = openapiToFunction(formOpenAPISpec);
|
|
1047
|
-
expect(functionSignatures.length).toBe(1);
|
|
1048
|
-
expect(functionSignatures[0].name).toBe('SubmitForm');
|
|
1049
|
-
|
|
1050
|
-
const parameters = functionSignatures[0].parameters as ParametersSchema & {
|
|
1051
|
-
properties: {
|
|
1052
|
-
'entry.123': {
|
|
1053
|
-
type: 'string';
|
|
1054
|
-
};
|
|
1055
|
-
'entry.456': {
|
|
1056
|
-
type: 'string';
|
|
1057
|
-
};
|
|
1058
|
-
};
|
|
1059
|
-
};
|
|
1060
|
-
|
|
1061
|
-
expect(parameters).toBeDefined();
|
|
1062
|
-
expect(parameters.properties['entry.123']).toBeDefined();
|
|
1063
|
-
expect(parameters.properties['entry.123'].type).toBe('string');
|
|
1064
|
-
expect(parameters.properties['entry.456']).toBeDefined();
|
|
1065
|
-
expect(parameters.properties['entry.456'].type).toBe('string');
|
|
1066
|
-
|
|
1067
|
-
expect(requestBuilders).toHaveProperty('SubmitForm');
|
|
1068
|
-
expect(requestBuilders.SubmitForm).toBeInstanceOf(ActionRequest);
|
|
1069
|
-
expect(requestBuilders.SubmitForm.contentType).toBe('application/x-www-form-urlencoded');
|
|
1070
|
-
});
|
|
1071
|
-
|
|
1072
|
-
describe('openapiToFunction with $ref resolution', () => {
|
|
1073
|
-
it('correctly converts OpenAPI spec to function signatures and request builders, resolving $ref references', () => {
|
|
1074
|
-
const { functionSignatures, requestBuilders } = openapiToFunction(whimsicalOpenapiSpec);
|
|
1075
|
-
|
|
1076
|
-
expect(functionSignatures.length).toBeGreaterThan(0);
|
|
1077
|
-
|
|
1078
|
-
const postRenderFlowchartSignature = functionSignatures.find(
|
|
1079
|
-
(sig) => sig.name === 'postRenderFlowchart',
|
|
1080
|
-
);
|
|
1081
|
-
expect(postRenderFlowchartSignature).toBeDefined();
|
|
1082
|
-
expect(postRenderFlowchartSignature?.name).toBe('postRenderFlowchart');
|
|
1083
|
-
expect(postRenderFlowchartSignature?.parameters).toBeDefined();
|
|
1084
|
-
|
|
1085
|
-
expect(requestBuilders).toHaveProperty('postRenderFlowchart');
|
|
1086
|
-
const postRenderFlowchartRequestBuilder = requestBuilders['postRenderFlowchart'];
|
|
1087
|
-
expect(postRenderFlowchartRequestBuilder).toBeDefined();
|
|
1088
|
-
expect(postRenderFlowchartRequestBuilder.method).toBe('post');
|
|
1089
|
-
expect(postRenderFlowchartRequestBuilder.path).toBe('/ai.chatgpt.render-flowchart');
|
|
1090
|
-
});
|
|
1091
|
-
});
|
|
1092
|
-
});
|
|
1093
|
-
|
|
1094
|
-
const invalidServerURL = 'Could not find a valid URL in `servers`';
|
|
1095
|
-
|
|
1096
|
-
describe('validateAndParseOpenAPISpec', () => {
|
|
1097
|
-
it('validates a correct OpenAPI spec successfully', () => {
|
|
1098
|
-
const validSpec = JSON.stringify({
|
|
1099
|
-
openapi: '3.0.0',
|
|
1100
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1101
|
-
servers: [{ url: 'https://test.api' }],
|
|
1102
|
-
paths: { '/test': {} },
|
|
1103
|
-
components: { schemas: {} },
|
|
1104
|
-
});
|
|
1105
|
-
|
|
1106
|
-
const result = validateAndParseOpenAPISpec(validSpec);
|
|
1107
|
-
expect(result.status).toBe(true);
|
|
1108
|
-
expect(result.message).toBe('OpenAPI spec is valid.');
|
|
1109
|
-
});
|
|
1110
|
-
|
|
1111
|
-
it('returns an error for spec with no servers', () => {
|
|
1112
|
-
const noServerSpec = JSON.stringify({
|
|
1113
|
-
openapi: '3.0.0',
|
|
1114
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1115
|
-
paths: { '/test': {} },
|
|
1116
|
-
components: { schemas: {} },
|
|
1117
|
-
});
|
|
1118
|
-
|
|
1119
|
-
const result = validateAndParseOpenAPISpec(noServerSpec);
|
|
1120
|
-
expect(result.status).toBe(false);
|
|
1121
|
-
expect(result.message).toBe(invalidServerURL);
|
|
1122
|
-
});
|
|
1123
|
-
|
|
1124
|
-
it('returns an error for spec with empty server URL', () => {
|
|
1125
|
-
const emptyURLSpec = `{
|
|
1126
|
-
"openapi": "3.1.0",
|
|
1127
|
-
"info": {
|
|
1128
|
-
"title": "Untitled",
|
|
1129
|
-
"description": "Your OpenAPI specification",
|
|
1130
|
-
"version": "v1.0.0"
|
|
1131
|
-
},
|
|
1132
|
-
"servers": [
|
|
1133
|
-
{
|
|
1134
|
-
"url": ""
|
|
1135
|
-
}
|
|
1136
|
-
],
|
|
1137
|
-
"paths": {},
|
|
1138
|
-
"components": {
|
|
1139
|
-
"schemas": {}
|
|
1140
|
-
}
|
|
1141
|
-
}`;
|
|
1142
|
-
|
|
1143
|
-
const result = validateAndParseOpenAPISpec(emptyURLSpec);
|
|
1144
|
-
expect(result.status).toBe(false);
|
|
1145
|
-
expect(result.message).toBe(invalidServerURL);
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
it('returns an error for spec with no paths', () => {
|
|
1149
|
-
const noPathsSpec = JSON.stringify({
|
|
1150
|
-
openapi: '3.0.0',
|
|
1151
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1152
|
-
servers: [{ url: 'https://test.api' }],
|
|
1153
|
-
components: { schemas: {} },
|
|
1154
|
-
});
|
|
1155
|
-
|
|
1156
|
-
const result = validateAndParseOpenAPISpec(noPathsSpec);
|
|
1157
|
-
expect(result.status).toBe(false);
|
|
1158
|
-
expect(result.message).toBe('No paths found in the OpenAPI spec.');
|
|
1159
|
-
});
|
|
1160
|
-
|
|
1161
|
-
it('detects missing components in spec', () => {
|
|
1162
|
-
const missingComponentSpec = JSON.stringify({
|
|
1163
|
-
openapi: '3.0.0',
|
|
1164
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1165
|
-
servers: [{ url: 'https://test.api' }],
|
|
1166
|
-
paths: {
|
|
1167
|
-
'/test': {
|
|
1168
|
-
get: {
|
|
1169
|
-
responses: {
|
|
1170
|
-
'200': {
|
|
1171
|
-
content: {
|
|
1172
|
-
'application/json': { schema: { $ref: '#/components/schemas/Missing' } },
|
|
1173
|
-
},
|
|
1174
|
-
},
|
|
1175
|
-
},
|
|
1176
|
-
},
|
|
1177
|
-
},
|
|
1178
|
-
},
|
|
1179
|
-
});
|
|
1180
|
-
|
|
1181
|
-
const result = validateAndParseOpenAPISpec(missingComponentSpec);
|
|
1182
|
-
expect(result.status).toBe(true);
|
|
1183
|
-
expect(result.message).toContain('reference to unknown component Missing');
|
|
1184
|
-
expect(result.spec).toBeDefined();
|
|
1185
|
-
});
|
|
1186
|
-
|
|
1187
|
-
it('handles invalid spec formats', () => {
|
|
1188
|
-
const invalidSpec = 'not a valid spec';
|
|
1189
|
-
|
|
1190
|
-
const result = validateAndParseOpenAPISpec(invalidSpec);
|
|
1191
|
-
expect(result.status).toBe(false);
|
|
1192
|
-
expect(result.message).toBe(invalidServerURL);
|
|
1193
|
-
});
|
|
1194
|
-
|
|
1195
|
-
it('handles YAML spec and correctly converts to Function Signatures', () => {
|
|
1196
|
-
const result = validateAndParseOpenAPISpec(scholarAIOpenapiSpec);
|
|
1197
|
-
expect(result.status).toBe(true);
|
|
1198
|
-
|
|
1199
|
-
const spec = result.spec;
|
|
1200
|
-
expect(spec).toBeDefined();
|
|
1201
|
-
|
|
1202
|
-
const { functionSignatures, requestBuilders } = openapiToFunction(spec as OpenAPIV3.Document);
|
|
1203
|
-
expect(functionSignatures.length).toBe(3);
|
|
1204
|
-
expect(requestBuilders).toHaveProperty('searchAbstracts');
|
|
1205
|
-
expect(requestBuilders).toHaveProperty('getFullText');
|
|
1206
|
-
expect(requestBuilders).toHaveProperty('saveCitation');
|
|
1207
|
-
});
|
|
1208
|
-
});
|
|
1209
|
-
|
|
1210
|
-
describe('createURL', () => {
|
|
1211
|
-
it('correctly combines domain and path', () => {
|
|
1212
|
-
expect(createURL('https://example.com', '/api/v1/users')).toBe(
|
|
1213
|
-
'https://example.com/api/v1/users',
|
|
1214
|
-
);
|
|
1215
|
-
});
|
|
1216
|
-
|
|
1217
|
-
it('handles domain with trailing slash', () => {
|
|
1218
|
-
expect(createURL('https://example.com/', '/api/v1/users')).toBe(
|
|
1219
|
-
'https://example.com/api/v1/users',
|
|
1220
|
-
);
|
|
1221
|
-
});
|
|
1222
|
-
|
|
1223
|
-
it('handles path with leading slash', () => {
|
|
1224
|
-
expect(createURL('https://example.com', 'api/v1/users')).toBe(
|
|
1225
|
-
'https://example.com/api/v1/users',
|
|
1226
|
-
);
|
|
1227
|
-
});
|
|
1228
|
-
|
|
1229
|
-
it('handles domain with trailing slash and path with leading slash', () => {
|
|
1230
|
-
expect(createURL('https://example.com/', '/api/v1/users')).toBe(
|
|
1231
|
-
'https://example.com/api/v1/users',
|
|
1232
|
-
);
|
|
1233
|
-
});
|
|
1234
|
-
|
|
1235
|
-
it('handles domain without trailing slash and path without leading slash', () => {
|
|
1236
|
-
expect(createURL('https://example.com', 'api/v1/users')).toBe(
|
|
1237
|
-
'https://example.com/api/v1/users',
|
|
1238
|
-
);
|
|
1239
|
-
});
|
|
1240
|
-
|
|
1241
|
-
it('handles empty path', () => {
|
|
1242
|
-
expect(createURL('https://example.com', '')).toBe('https://example.com/');
|
|
1243
|
-
});
|
|
1244
|
-
|
|
1245
|
-
it('handles domain with subdirectory', () => {
|
|
1246
|
-
expect(createURL('https://example.com/subdirectory', '/api/v1/users')).toBe(
|
|
1247
|
-
'https://example.com/subdirectory/api/v1/users',
|
|
1248
|
-
);
|
|
1249
|
-
});
|
|
1250
|
-
|
|
1251
|
-
describe('openapiToFunction zodSchemas', () => {
|
|
1252
|
-
describe('getWeatherOpenapiSpec', () => {
|
|
1253
|
-
const { zodSchemas } = openapiToFunction(getWeatherOpenapiSpec, true);
|
|
1254
|
-
|
|
1255
|
-
it('generates correct Zod schema for GetCurrentWeather', () => {
|
|
1256
|
-
expect(zodSchemas).toBeDefined();
|
|
1257
|
-
expect(zodSchemas?.GetCurrentWeather).toBeDefined();
|
|
1258
|
-
|
|
1259
|
-
const GetCurrentWeatherSchema = zodSchemas?.GetCurrentWeather;
|
|
1260
|
-
|
|
1261
|
-
expect(GetCurrentWeatherSchema instanceof z.ZodObject).toBe(true);
|
|
1262
|
-
|
|
1263
|
-
if (!(GetCurrentWeatherSchema instanceof z.ZodObject)) {
|
|
1264
|
-
throw new Error('GetCurrentWeatherSchema is not a ZodObject');
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
const shape = GetCurrentWeatherSchema.shape;
|
|
1268
|
-
expect(shape.location instanceof z.ZodString).toBe(true);
|
|
1269
|
-
|
|
1270
|
-
// Check locations property
|
|
1271
|
-
expect(shape.locations).toBeDefined();
|
|
1272
|
-
expect(shape.locations instanceof z.ZodOptional).toBe(true);
|
|
1273
|
-
|
|
1274
|
-
if (!(shape.locations instanceof z.ZodOptional)) {
|
|
1275
|
-
throw new Error('locations is not a ZodOptional');
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
const locationsInnerType = shape.locations._def.innerType;
|
|
1279
|
-
expect(locationsInnerType instanceof z.ZodArray).toBe(true);
|
|
1280
|
-
|
|
1281
|
-
if (!(locationsInnerType instanceof z.ZodArray)) {
|
|
1282
|
-
throw new Error('locationsInnerType is not a ZodArray');
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
const locationsItemSchema = locationsInnerType.element;
|
|
1286
|
-
expect(locationsItemSchema instanceof z.ZodObject).toBe(true);
|
|
1287
|
-
|
|
1288
|
-
if (!(locationsItemSchema instanceof z.ZodObject)) {
|
|
1289
|
-
throw new Error('locationsItemSchema is not a ZodObject');
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// Validate the structure of locationsItemSchema
|
|
1293
|
-
expect(locationsItemSchema.shape.city instanceof z.ZodString).toBe(true);
|
|
1294
|
-
expect(locationsItemSchema.shape.state instanceof z.ZodString).toBe(true);
|
|
1295
|
-
expect(locationsItemSchema.shape.countryCode instanceof z.ZodString).toBe(true);
|
|
1296
|
-
|
|
1297
|
-
// Check if time is optional
|
|
1298
|
-
const timeSchema = locationsItemSchema.shape.time;
|
|
1299
|
-
expect(timeSchema instanceof z.ZodOptional).toBe(true);
|
|
1300
|
-
|
|
1301
|
-
if (!(timeSchema instanceof z.ZodOptional)) {
|
|
1302
|
-
throw new Error('timeSchema is not a ZodOptional');
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
expect(timeSchema._def.innerType instanceof z.ZodString).toBe(true);
|
|
1306
|
-
|
|
1307
|
-
// Check the description
|
|
1308
|
-
expect(shape.locations._def.description).toBe(
|
|
1309
|
-
'A list of locations to retrieve the weather for.',
|
|
1310
|
-
);
|
|
1311
|
-
});
|
|
1312
|
-
|
|
1313
|
-
it('validates correct data for GetCurrentWeather', () => {
|
|
1314
|
-
const GetCurrentWeatherSchema = zodSchemas?.GetCurrentWeather as z.ZodTypeAny;
|
|
1315
|
-
const validData = {
|
|
1316
|
-
location: 'New York',
|
|
1317
|
-
locations: [
|
|
1318
|
-
{ city: 'New York', state: 'NY', countryCode: 'US', time: '2023-12-04T14:00:00Z' },
|
|
1319
|
-
],
|
|
1320
|
-
};
|
|
1321
|
-
expect(() => GetCurrentWeatherSchema.parse(validData)).not.toThrow();
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
|
-
it('throws error for invalid data for GetCurrentWeather', () => {
|
|
1325
|
-
const GetCurrentWeatherSchema = zodSchemas?.GetCurrentWeather as z.ZodTypeAny;
|
|
1326
|
-
const invalidData = {
|
|
1327
|
-
location: 123,
|
|
1328
|
-
locations: [{ city: 'New York', state: 'NY', countryCode: 'US', time: 'invalid-time' }],
|
|
1329
|
-
};
|
|
1330
|
-
expect(() => GetCurrentWeatherSchema.parse(invalidData)).toThrow();
|
|
1331
|
-
});
|
|
1332
|
-
});
|
|
1333
|
-
|
|
1334
|
-
describe('whimsicalOpenapiSpec', () => {
|
|
1335
|
-
const { zodSchemas } = openapiToFunction(whimsicalOpenapiSpec, true);
|
|
1336
|
-
|
|
1337
|
-
it('generates correct Zod schema for postRenderFlowchart', () => {
|
|
1338
|
-
expect(zodSchemas).toBeDefined();
|
|
1339
|
-
expect(zodSchemas?.postRenderFlowchart).toBeDefined();
|
|
1340
|
-
|
|
1341
|
-
const PostRenderFlowchartSchema = zodSchemas?.postRenderFlowchart;
|
|
1342
|
-
expect(PostRenderFlowchartSchema).toBeInstanceOf(z.ZodObject);
|
|
1343
|
-
|
|
1344
|
-
if (!(PostRenderFlowchartSchema instanceof z.ZodObject)) {
|
|
1345
|
-
return;
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
const shape = PostRenderFlowchartSchema.shape;
|
|
1349
|
-
expect(shape.mermaid).toBeInstanceOf(z.ZodString);
|
|
1350
|
-
expect(shape.title).toBeInstanceOf(z.ZodOptional);
|
|
1351
|
-
expect((shape.title as z.ZodOptional<z.ZodString>)._def.innerType).toBeInstanceOf(
|
|
1352
|
-
z.ZodString,
|
|
1353
|
-
);
|
|
1354
|
-
});
|
|
1355
|
-
|
|
1356
|
-
it('validates correct data for postRenderFlowchart', () => {
|
|
1357
|
-
const PostRenderFlowchartSchema = zodSchemas?.postRenderFlowchart;
|
|
1358
|
-
const validData = {
|
|
1359
|
-
mermaid: 'graph TD; A-->B; B-->C; C-->D;',
|
|
1360
|
-
title: 'Test Flowchart',
|
|
1361
|
-
};
|
|
1362
|
-
expect(() => PostRenderFlowchartSchema?.parse(validData)).not.toThrow();
|
|
1363
|
-
});
|
|
1364
|
-
|
|
1365
|
-
it('throws error for invalid data for postRenderFlowchart', () => {
|
|
1366
|
-
const PostRenderFlowchartSchema = zodSchemas?.postRenderFlowchart;
|
|
1367
|
-
const invalidData = {
|
|
1368
|
-
mermaid: 123,
|
|
1369
|
-
title: 42,
|
|
1370
|
-
};
|
|
1371
|
-
expect(() => PostRenderFlowchartSchema?.parse(invalidData)).toThrow();
|
|
1372
|
-
});
|
|
1373
|
-
});
|
|
1374
|
-
|
|
1375
|
-
describe('scholarAIOpenapiSpec', () => {
|
|
1376
|
-
const result = validateAndParseOpenAPISpec(scholarAIOpenapiSpec);
|
|
1377
|
-
const spec = result.spec as OpenAPIV3.Document;
|
|
1378
|
-
const { zodSchemas } = openapiToFunction(spec, true);
|
|
1379
|
-
|
|
1380
|
-
it('generates correct Zod schema for searchAbstracts', () => {
|
|
1381
|
-
expect(zodSchemas).toBeDefined();
|
|
1382
|
-
expect(zodSchemas?.searchAbstracts).toBeDefined();
|
|
1383
|
-
|
|
1384
|
-
const SearchAbstractsSchema = zodSchemas?.searchAbstracts;
|
|
1385
|
-
expect(SearchAbstractsSchema).toBeInstanceOf(z.ZodObject);
|
|
1386
|
-
|
|
1387
|
-
if (!(SearchAbstractsSchema instanceof z.ZodObject)) {
|
|
1388
|
-
return;
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
const shape = SearchAbstractsSchema.shape;
|
|
1392
|
-
expect(shape.keywords).toBeInstanceOf(z.ZodString);
|
|
1393
|
-
expect(shape.sort).toBeInstanceOf(z.ZodOptional);
|
|
1394
|
-
expect(
|
|
1395
|
-
(shape.sort as z.ZodOptional<z.ZodEnum<[string, ...string[]]>>)._def.innerType,
|
|
1396
|
-
).toBeInstanceOf(z.ZodEnum);
|
|
1397
|
-
expect(shape.query).toBeInstanceOf(z.ZodString);
|
|
1398
|
-
expect(shape.peer_reviewed_only).toBeInstanceOf(z.ZodOptional);
|
|
1399
|
-
expect(shape.start_year).toBeInstanceOf(z.ZodOptional);
|
|
1400
|
-
expect(shape.end_year).toBeInstanceOf(z.ZodOptional);
|
|
1401
|
-
expect(shape.offset).toBeInstanceOf(z.ZodOptional);
|
|
1402
|
-
});
|
|
1403
|
-
|
|
1404
|
-
it('validates correct data for searchAbstracts', () => {
|
|
1405
|
-
const SearchAbstractsSchema = zodSchemas?.searchAbstracts;
|
|
1406
|
-
const validData = {
|
|
1407
|
-
keywords: 'machine learning',
|
|
1408
|
-
sort: 'cited_by_count',
|
|
1409
|
-
query: 'AI applications',
|
|
1410
|
-
peer_reviewed_only: 'true',
|
|
1411
|
-
start_year: '2020',
|
|
1412
|
-
end_year: '2023',
|
|
1413
|
-
offset: '0',
|
|
1414
|
-
};
|
|
1415
|
-
expect(() => SearchAbstractsSchema?.parse(validData)).not.toThrow();
|
|
1416
|
-
});
|
|
1417
|
-
|
|
1418
|
-
it('throws error for invalid data for searchAbstracts', () => {
|
|
1419
|
-
const SearchAbstractsSchema = zodSchemas?.searchAbstracts;
|
|
1420
|
-
const invalidData = {
|
|
1421
|
-
keywords: 123,
|
|
1422
|
-
sort: 'invalid_sort',
|
|
1423
|
-
query: 42,
|
|
1424
|
-
peer_reviewed_only: 'maybe',
|
|
1425
|
-
start_year: 2020,
|
|
1426
|
-
end_year: 2023,
|
|
1427
|
-
offset: 0,
|
|
1428
|
-
};
|
|
1429
|
-
expect(() => SearchAbstractsSchema?.parse(invalidData)).toThrow();
|
|
1430
|
-
});
|
|
1431
|
-
|
|
1432
|
-
it('generates correct Zod schema for getFullText', () => {
|
|
1433
|
-
expect(zodSchemas?.getFullText).toBeDefined();
|
|
1434
|
-
|
|
1435
|
-
const GetFullTextSchema = zodSchemas?.getFullText;
|
|
1436
|
-
expect(GetFullTextSchema).toBeInstanceOf(z.ZodObject);
|
|
1437
|
-
|
|
1438
|
-
if (!(GetFullTextSchema instanceof z.ZodObject)) {
|
|
1439
|
-
return;
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
const shape = GetFullTextSchema.shape;
|
|
1443
|
-
expect(shape.pdf_url).toBeInstanceOf(z.ZodString);
|
|
1444
|
-
expect(shape.chunk).toBeInstanceOf(z.ZodOptional);
|
|
1445
|
-
expect((shape.chunk as z.ZodOptional<z.ZodNumber>)._def.innerType).toBeInstanceOf(
|
|
1446
|
-
z.ZodNumber,
|
|
1447
|
-
);
|
|
1448
|
-
});
|
|
1449
|
-
|
|
1450
|
-
it('generates correct Zod schema for saveCitation', () => {
|
|
1451
|
-
expect(zodSchemas?.saveCitation).toBeDefined();
|
|
1452
|
-
|
|
1453
|
-
const SaveCitationSchema = zodSchemas?.saveCitation;
|
|
1454
|
-
expect(SaveCitationSchema).toBeInstanceOf(z.ZodObject);
|
|
1455
|
-
|
|
1456
|
-
if (!(SaveCitationSchema instanceof z.ZodObject)) {
|
|
1457
|
-
return;
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
const shape = SaveCitationSchema.shape;
|
|
1461
|
-
expect(shape.doi).toBeInstanceOf(z.ZodString);
|
|
1462
|
-
expect(shape.zotero_user_id).toBeInstanceOf(z.ZodString);
|
|
1463
|
-
expect(shape.zotero_api_key).toBeInstanceOf(z.ZodString);
|
|
1464
|
-
});
|
|
1465
|
-
});
|
|
1466
|
-
});
|
|
1467
|
-
|
|
1468
|
-
describe('openapiToFunction zodSchemas for SWAPI', () => {
|
|
1469
|
-
const result = validateAndParseOpenAPISpec(swapidev);
|
|
1470
|
-
const spec = result.spec as OpenAPIV3.Document;
|
|
1471
|
-
const { zodSchemas } = openapiToFunction(spec, true);
|
|
1472
|
-
|
|
1473
|
-
describe('getPeople schema', () => {
|
|
1474
|
-
it('does not generate Zod schema for getPeople (no parameters)', () => {
|
|
1475
|
-
expect(zodSchemas).toBeDefined();
|
|
1476
|
-
expect(zodSchemas?.getPeople).toBeUndefined();
|
|
1477
|
-
});
|
|
1478
|
-
|
|
1479
|
-
it('validates correct data for getPeople', () => {
|
|
1480
|
-
const GetPeopleSchema = zodSchemas?.getPeople;
|
|
1481
|
-
expect(GetPeopleSchema).toBeUndefined();
|
|
1482
|
-
});
|
|
1483
|
-
|
|
1484
|
-
it('does not throw for invalid data for getPeople', () => {
|
|
1485
|
-
const GetPeopleSchema = zodSchemas?.getPeople;
|
|
1486
|
-
expect(GetPeopleSchema).toBeUndefined();
|
|
1487
|
-
});
|
|
1488
|
-
});
|
|
1489
|
-
|
|
1490
|
-
describe('getPersonById schema', () => {
|
|
1491
|
-
it('generates correct Zod schema for getPersonById', () => {
|
|
1492
|
-
expect(zodSchemas).toBeDefined();
|
|
1493
|
-
expect(zodSchemas?.getPersonById).toBeDefined();
|
|
1494
|
-
|
|
1495
|
-
const GetPersonByIdSchema = zodSchemas?.getPersonById;
|
|
1496
|
-
expect(GetPersonByIdSchema).toBeInstanceOf(z.ZodObject);
|
|
1497
|
-
|
|
1498
|
-
if (!(GetPersonByIdSchema instanceof z.ZodObject)) {
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
const shape = GetPersonByIdSchema.shape;
|
|
1503
|
-
expect(shape.id).toBeInstanceOf(z.ZodString);
|
|
1504
|
-
});
|
|
1505
|
-
|
|
1506
|
-
it('validates correct data for getPersonById', () => {
|
|
1507
|
-
const GetPersonByIdSchema = zodSchemas?.getPersonById;
|
|
1508
|
-
const validData = { id: '1' };
|
|
1509
|
-
expect(() => GetPersonByIdSchema?.parse(validData)).not.toThrow();
|
|
1510
|
-
});
|
|
1511
|
-
|
|
1512
|
-
it('throws error for invalid data for getPersonById', () => {
|
|
1513
|
-
const GetPersonByIdSchema = zodSchemas?.getPersonById;
|
|
1514
|
-
const invalidData = { id: 1 }; // should be string
|
|
1515
|
-
expect(() => GetPersonByIdSchema?.parse(invalidData)).toThrow();
|
|
1516
|
-
});
|
|
1517
|
-
});
|
|
1518
|
-
});
|
|
1519
|
-
|
|
1520
|
-
describe('openapiToFunction parameter refs resolution', () => {
|
|
1521
|
-
const weatherSpec = {
|
|
1522
|
-
openapi: '3.0.0',
|
|
1523
|
-
info: { title: 'Weather', version: '1.0.0' },
|
|
1524
|
-
servers: [{ url: 'https://api.weather.gov' }],
|
|
1525
|
-
paths: {
|
|
1526
|
-
'/points/{point}': {
|
|
1527
|
-
get: {
|
|
1528
|
-
operationId: 'getPoint',
|
|
1529
|
-
parameters: [{ $ref: '#/components/parameters/PathPoint' }],
|
|
1530
|
-
responses: { '200': { description: 'ok' } },
|
|
1531
|
-
},
|
|
1532
|
-
},
|
|
1533
|
-
},
|
|
1534
|
-
components: {
|
|
1535
|
-
parameters: {
|
|
1536
|
-
PathPoint: {
|
|
1537
|
-
name: 'point',
|
|
1538
|
-
in: 'path',
|
|
1539
|
-
required: true,
|
|
1540
|
-
schema: { type: 'string', pattern: '^(-?\\d+(?:\\.\\d+)?),(-?\\d+(?:\\.\\d+)?)$' },
|
|
1541
|
-
},
|
|
1542
|
-
},
|
|
1543
|
-
},
|
|
1544
|
-
} satisfies OpenAPIV3.Document;
|
|
1545
|
-
|
|
1546
|
-
it('correctly resolves $ref for parameters', () => {
|
|
1547
|
-
const { functionSignatures } = openapiToFunction(weatherSpec, true);
|
|
1548
|
-
const func = functionSignatures.find((sig) => sig.name === 'getPoint');
|
|
1549
|
-
expect(func).toBeDefined();
|
|
1550
|
-
expect(func?.parameters.properties).toHaveProperty('point');
|
|
1551
|
-
expect(func?.parameters.required).toContain('point');
|
|
1552
|
-
|
|
1553
|
-
const paramSchema = func?.parameters.properties['point'] as OpenAPIV3.SchemaObject;
|
|
1554
|
-
expect(paramSchema.type).toEqual('string');
|
|
1555
|
-
expect(paramSchema.pattern).toEqual('^(-?\\d+(?:\\.\\d+)?),(-?\\d+(?:\\.\\d+)?)$');
|
|
1556
|
-
});
|
|
1557
|
-
});
|
|
1558
|
-
});
|
|
1559
|
-
|
|
1560
|
-
describe('SSRF Protection', () => {
|
|
1561
|
-
describe('extractDomainFromUrl', () => {
|
|
1562
|
-
it('extracts domain from valid HTTPS URL', () => {
|
|
1563
|
-
expect(extractDomainFromUrl('https://example.com')).toBe('https://example.com');
|
|
1564
|
-
expect(extractDomainFromUrl('https://example.com/path')).toBe('https://example.com');
|
|
1565
|
-
expect(extractDomainFromUrl('https://example.com:8080')).toBe('https://example.com');
|
|
1566
|
-
expect(extractDomainFromUrl('https://example.com:8080/path?query=value')).toBe(
|
|
1567
|
-
'https://example.com',
|
|
1568
|
-
);
|
|
1569
|
-
});
|
|
1570
|
-
|
|
1571
|
-
it('extracts domain from valid HTTP URL', () => {
|
|
1572
|
-
expect(extractDomainFromUrl('http://example.com')).toBe('http://example.com');
|
|
1573
|
-
expect(extractDomainFromUrl('http://example.com/api')).toBe('http://example.com');
|
|
1574
|
-
});
|
|
1575
|
-
|
|
1576
|
-
it('handles subdomains correctly', () => {
|
|
1577
|
-
expect(extractDomainFromUrl('https://api.example.com')).toBe('https://api.example.com');
|
|
1578
|
-
expect(extractDomainFromUrl('https://subdomain.api.example.com/path')).toBe(
|
|
1579
|
-
'https://subdomain.api.example.com',
|
|
1580
|
-
);
|
|
1581
|
-
});
|
|
1582
|
-
|
|
1583
|
-
it('throws error for invalid URLs', () => {
|
|
1584
|
-
expect(() => extractDomainFromUrl('not-a-url')).toThrow('Invalid URL format');
|
|
1585
|
-
expect(() => extractDomainFromUrl('')).toThrow('Invalid URL format');
|
|
1586
|
-
expect(() => extractDomainFromUrl('example.com')).toThrow('Invalid URL format');
|
|
1587
|
-
});
|
|
1588
|
-
|
|
1589
|
-
it('preserves protocol to prevent HTTP/HTTPS confusion', () => {
|
|
1590
|
-
const httpsDomain = extractDomainFromUrl('https://example.com/path');
|
|
1591
|
-
const httpDomain = extractDomainFromUrl('http://example.com/path');
|
|
1592
|
-
expect(httpsDomain).not.toBe(httpDomain);
|
|
1593
|
-
expect(httpsDomain).toBe('https://example.com');
|
|
1594
|
-
expect(httpDomain).toBe('http://example.com');
|
|
1595
|
-
});
|
|
1596
|
-
|
|
1597
|
-
it('handles internal/private IP addresses', () => {
|
|
1598
|
-
expect(extractDomainFromUrl('http://192.168.1.1')).toBe('http://192.168.1.1');
|
|
1599
|
-
expect(extractDomainFromUrl('http://10.0.0.1/admin')).toBe('http://10.0.0.1');
|
|
1600
|
-
expect(extractDomainFromUrl('http://172.16.0.1')).toBe('http://172.16.0.1');
|
|
1601
|
-
expect(extractDomainFromUrl('http://127.0.0.1:8080')).toBe('http://127.0.0.1');
|
|
1602
|
-
});
|
|
1603
|
-
|
|
1604
|
-
it('handles cloud metadata service URLs', () => {
|
|
1605
|
-
// AWS EC2 metadata
|
|
1606
|
-
expect(extractDomainFromUrl('http://169.254.169.254/latest/meta-data/')).toBe(
|
|
1607
|
-
'http://169.254.169.254',
|
|
1608
|
-
);
|
|
1609
|
-
// Google Cloud metadata
|
|
1610
|
-
expect(extractDomainFromUrl('http://metadata.google.internal/computeMetadata/v1/')).toBe(
|
|
1611
|
-
'http://metadata.google.internal',
|
|
1612
|
-
);
|
|
1613
|
-
// Azure metadata
|
|
1614
|
-
expect(extractDomainFromUrl('http://169.254.169.254/metadata/instance')).toBe(
|
|
1615
|
-
'http://169.254.169.254',
|
|
1616
|
-
);
|
|
1617
|
-
});
|
|
1618
|
-
|
|
1619
|
-
it('handles IPv6 URLs with brackets correctly', () => {
|
|
1620
|
-
expect(extractDomainFromUrl('http://[::1]/')).toBe('http://[::1]');
|
|
1621
|
-
expect(extractDomainFromUrl('http://[::1]:8080')).toBe('http://[::1]');
|
|
1622
|
-
expect(extractDomainFromUrl('https://[2001:db8::1]/api')).toBe('https://[2001:db8::1]');
|
|
1623
|
-
expect(extractDomainFromUrl('http://[fe80::1]/path')).toBe('http://[fe80::1]');
|
|
1624
|
-
});
|
|
1625
|
-
|
|
1626
|
-
it('handles complex IPv6 addresses', () => {
|
|
1627
|
-
expect(extractDomainFromUrl('http://[2001:db8:85a3::8a2e:370:7334]/api')).toBe(
|
|
1628
|
-
'http://[2001:db8:85a3::8a2e:370:7334]',
|
|
1629
|
-
);
|
|
1630
|
-
// Node.js normalizes IPv4-mapped IPv6 to hex form
|
|
1631
|
-
expect(extractDomainFromUrl('https://[::ffff:192.168.1.1]:8080')).toBe(
|
|
1632
|
-
'https://[::ffff:c0a8:101]',
|
|
1633
|
-
);
|
|
1634
|
-
});
|
|
1635
|
-
|
|
1636
|
-
it('handles URLs with authentication credentials', () => {
|
|
1637
|
-
expect(extractDomainFromUrl('https://user:pass@example.com/api')).toBe('https://example.com');
|
|
1638
|
-
expect(extractDomainFromUrl('http://admin@192.168.1.1:8080')).toBe('http://192.168.1.1');
|
|
1639
|
-
});
|
|
1640
|
-
|
|
1641
|
-
it('handles URLs with special characters in path', () => {
|
|
1642
|
-
expect(extractDomainFromUrl('https://example.com/path%20with%20spaces')).toBe(
|
|
1643
|
-
'https://example.com',
|
|
1644
|
-
);
|
|
1645
|
-
expect(extractDomainFromUrl('https://example.com/path#fragment')).toBe('https://example.com');
|
|
1646
|
-
expect(extractDomainFromUrl('https://example.com/?query=value&other=123')).toBe(
|
|
1647
|
-
'https://example.com',
|
|
1648
|
-
);
|
|
1649
|
-
});
|
|
1650
|
-
|
|
1651
|
-
it('handles localhost variations', () => {
|
|
1652
|
-
expect(extractDomainFromUrl('http://localhost/')).toBe('http://localhost');
|
|
1653
|
-
expect(extractDomainFromUrl('https://localhost:3000')).toBe('https://localhost');
|
|
1654
|
-
expect(extractDomainFromUrl('http://localhost.localdomain')).toBe(
|
|
1655
|
-
'http://localhost.localdomain',
|
|
1656
|
-
);
|
|
1657
|
-
});
|
|
1658
|
-
|
|
1659
|
-
it('handles internationalized domain names', () => {
|
|
1660
|
-
expect(extractDomainFromUrl('https://xn--e1afmkfd.xn--p1ai/api')).toBe(
|
|
1661
|
-
'https://xn--e1afmkfd.xn--p1ai',
|
|
1662
|
-
);
|
|
1663
|
-
// Node.js URL parser converts IDN to punycode
|
|
1664
|
-
expect(extractDomainFromUrl('https://münchen.de')).toBe('https://xn--mnchen-3ya.de');
|
|
1665
|
-
});
|
|
1666
|
-
|
|
1667
|
-
it('throws error for non-HTTP/HTTPS protocols in extractDomainFromUrl', () => {
|
|
1668
|
-
expect(() => extractDomainFromUrl('ftp://example.com')).not.toThrow();
|
|
1669
|
-
expect(extractDomainFromUrl('ftp://example.com')).toBe('ftp://example.com');
|
|
1670
|
-
// Note: The function doesn't validate protocol, just extracts domain
|
|
1671
|
-
});
|
|
1672
|
-
});
|
|
1673
|
-
|
|
1674
|
-
describe('validateAndParseOpenAPISpec - SSRF Prevention', () => {
|
|
1675
|
-
it('returns serverUrl for valid spec', () => {
|
|
1676
|
-
const validSpec = JSON.stringify({
|
|
1677
|
-
openapi: '3.0.0',
|
|
1678
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1679
|
-
servers: [{ url: 'https://example.com' }],
|
|
1680
|
-
paths: { '/test': {} },
|
|
1681
|
-
});
|
|
1682
|
-
|
|
1683
|
-
const result = validateAndParseOpenAPISpec(validSpec);
|
|
1684
|
-
expect(result.status).toBe(true);
|
|
1685
|
-
expect(result.serverUrl).toBe('https://example.com');
|
|
1686
|
-
});
|
|
1687
|
-
|
|
1688
|
-
it('extracts serverUrl even with path in server URL', () => {
|
|
1689
|
-
const specWithPath = JSON.stringify({
|
|
1690
|
-
openapi: '3.0.0',
|
|
1691
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1692
|
-
servers: [{ url: 'https://example.com/api/v1' }],
|
|
1693
|
-
paths: { '/test': {} },
|
|
1694
|
-
});
|
|
1695
|
-
|
|
1696
|
-
const result = validateAndParseOpenAPISpec(specWithPath);
|
|
1697
|
-
expect(result.status).toBe(true);
|
|
1698
|
-
expect(result.serverUrl).toBe('https://example.com/api/v1');
|
|
1699
|
-
});
|
|
1700
|
-
|
|
1701
|
-
it('detects potential SSRF attempts with internal IPs', () => {
|
|
1702
|
-
const internalIPSpec = JSON.stringify({
|
|
1703
|
-
openapi: '3.0.0',
|
|
1704
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1705
|
-
servers: [{ url: 'http://192.168.1.1' }],
|
|
1706
|
-
paths: { '/test': {} },
|
|
1707
|
-
});
|
|
1708
|
-
|
|
1709
|
-
const result = validateAndParseOpenAPISpec(internalIPSpec);
|
|
1710
|
-
expect(result.status).toBe(true);
|
|
1711
|
-
expect(result.serverUrl).toBe('http://192.168.1.1');
|
|
1712
|
-
});
|
|
1713
|
-
|
|
1714
|
-
it('detects potential SSRF attempts with localhost', () => {
|
|
1715
|
-
const localhostSpec = JSON.stringify({
|
|
1716
|
-
openapi: '3.0.0',
|
|
1717
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1718
|
-
servers: [{ url: 'http://localhost:8080' }],
|
|
1719
|
-
paths: { '/test': {} },
|
|
1720
|
-
});
|
|
1721
|
-
|
|
1722
|
-
const result = validateAndParseOpenAPISpec(localhostSpec);
|
|
1723
|
-
expect(result.status).toBe(true);
|
|
1724
|
-
expect(result.serverUrl).toBe('http://localhost:8080');
|
|
1725
|
-
});
|
|
1726
|
-
|
|
1727
|
-
it('detects potential SSRF attempts with cloud metadata services', () => {
|
|
1728
|
-
const awsMetadataSpec = JSON.stringify({
|
|
1729
|
-
openapi: '3.0.0',
|
|
1730
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1731
|
-
servers: [{ url: 'http://169.254.169.254/latest/meta-data/' }],
|
|
1732
|
-
paths: { '/test': {} },
|
|
1733
|
-
});
|
|
1734
|
-
|
|
1735
|
-
const result = validateAndParseOpenAPISpec(awsMetadataSpec);
|
|
1736
|
-
expect(result.status).toBe(true);
|
|
1737
|
-
expect(result.serverUrl).toBe('http://169.254.169.254/latest/meta-data/');
|
|
1738
|
-
});
|
|
1739
|
-
|
|
1740
|
-
it('handles multiple servers and returns the first one', () => {
|
|
1741
|
-
const multiServerSpec = JSON.stringify({
|
|
1742
|
-
openapi: '3.0.0',
|
|
1743
|
-
info: { title: 'Test API', version: '1.0.0' },
|
|
1744
|
-
servers: [{ url: 'https://api.example.com' }, { url: 'https://backup.example.com' }],
|
|
1745
|
-
paths: { '/test': {} },
|
|
1746
|
-
});
|
|
1747
|
-
|
|
1748
|
-
const result = validateAndParseOpenAPISpec(multiServerSpec);
|
|
1749
|
-
expect(result.status).toBe(true);
|
|
1750
|
-
expect(result.serverUrl).toBe('https://api.example.com');
|
|
1751
|
-
});
|
|
1752
|
-
});
|
|
1753
|
-
|
|
1754
|
-
describe('SSRF Attack Scenarios', () => {
|
|
1755
|
-
it('scenario: attacker tries to use whitelisted domain but different spec URL', () => {
|
|
1756
|
-
const maliciousSpec = JSON.stringify({
|
|
1757
|
-
openapi: '3.0.0',
|
|
1758
|
-
info: { title: 'Malicious API', version: '1.0.0' },
|
|
1759
|
-
servers: [{ url: 'http://169.254.169.254/latest/meta-data/' }], // AWS metadata service
|
|
1760
|
-
paths: { '/': { get: { summary: 'Get metadata', operationId: 'getMetadata' } } },
|
|
1761
|
-
});
|
|
1762
|
-
|
|
1763
|
-
const result = validateAndParseOpenAPISpec(maliciousSpec);
|
|
1764
|
-
expect(result.status).toBe(true);
|
|
1765
|
-
expect(result.serverUrl).toBe('http://169.254.169.254/latest/meta-data/');
|
|
1766
|
-
|
|
1767
|
-
// The fix ensures this serverUrl would be validated against the domain whitelist
|
|
1768
|
-
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
|
1769
|
-
expect(extractedDomain).toBe('http://169.254.169.254');
|
|
1770
|
-
|
|
1771
|
-
// In the actual validation, this would not match a whitelisted 'example.com'
|
|
1772
|
-
expect(extractedDomain).not.toContain('example.com');
|
|
1773
|
-
});
|
|
1774
|
-
|
|
1775
|
-
it('scenario: attacker tries to use internal network IP', () => {
|
|
1776
|
-
const internalNetworkSpec = JSON.stringify({
|
|
1777
|
-
openapi: '3.0.0',
|
|
1778
|
-
info: { title: 'Internal API', version: '1.0.0' },
|
|
1779
|
-
servers: [{ url: 'http://10.0.0.1:8080/admin' }],
|
|
1780
|
-
paths: { '/': { get: { summary: 'Admin endpoint', operationId: 'getAdmin' } } },
|
|
1781
|
-
});
|
|
1782
|
-
|
|
1783
|
-
const result = validateAndParseOpenAPISpec(internalNetworkSpec);
|
|
1784
|
-
expect(result.status).toBe(true);
|
|
1785
|
-
expect(result.serverUrl).toBe('http://10.0.0.1:8080/admin');
|
|
1786
|
-
|
|
1787
|
-
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
|
1788
|
-
expect(extractedDomain).toBe('http://10.0.0.1');
|
|
1789
|
-
expect(extractedDomain).not.toContain('example.com');
|
|
1790
|
-
});
|
|
1791
|
-
|
|
1792
|
-
it('scenario: attacker tries to access Google Cloud metadata', () => {
|
|
1793
|
-
const gcpMetadataSpec = JSON.stringify({
|
|
1794
|
-
openapi: '3.0.0',
|
|
1795
|
-
info: { title: 'GCP Metadata', version: '1.0.0' },
|
|
1796
|
-
servers: [{ url: 'http://metadata.google.internal/computeMetadata/v1/' }],
|
|
1797
|
-
paths: { '/': { get: { summary: 'Get GCP metadata', operationId: 'getGCPMetadata' } } },
|
|
1798
|
-
});
|
|
1799
|
-
|
|
1800
|
-
const result = validateAndParseOpenAPISpec(gcpMetadataSpec);
|
|
1801
|
-
expect(result.status).toBe(true);
|
|
1802
|
-
expect(result.serverUrl).toBe('http://metadata.google.internal/computeMetadata/v1/');
|
|
1803
|
-
|
|
1804
|
-
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
|
1805
|
-
expect(extractedDomain).toBe('http://metadata.google.internal');
|
|
1806
|
-
expect(extractedDomain).not.toContain('example.com');
|
|
1807
|
-
});
|
|
1808
|
-
|
|
1809
|
-
it('scenario: legitimate use case with correct domain matching', () => {
|
|
1810
|
-
const legitimateSpec = JSON.stringify({
|
|
1811
|
-
openapi: '3.0.0',
|
|
1812
|
-
info: { title: 'Legitimate API', version: '1.0.0' },
|
|
1813
|
-
servers: [{ url: 'https://api.example.com/v1' }],
|
|
1814
|
-
paths: { '/data': { get: { summary: 'Get data', operationId: 'getData' } } },
|
|
1815
|
-
});
|
|
1816
|
-
|
|
1817
|
-
const result = validateAndParseOpenAPISpec(legitimateSpec);
|
|
1818
|
-
expect(result.status).toBe(true);
|
|
1819
|
-
expect(result.serverUrl).toBe('https://api.example.com/v1');
|
|
1820
|
-
|
|
1821
|
-
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
|
1822
|
-
expect(extractedDomain).toBe('https://api.example.com');
|
|
1823
|
-
|
|
1824
|
-
// This should match when client provides 'api.example.com' or 'https://api.example.com'
|
|
1825
|
-
const clientProvidedDomain = 'api.example.com';
|
|
1826
|
-
const normalizedClientDomain = `https://${clientProvidedDomain}`;
|
|
1827
|
-
expect(extractedDomain).toBe(normalizedClientDomain);
|
|
1828
|
-
});
|
|
1829
|
-
|
|
1830
|
-
it('scenario: protocol mismatch should be detected', () => {
|
|
1831
|
-
const httpSpec = JSON.stringify({
|
|
1832
|
-
openapi: '3.0.0',
|
|
1833
|
-
info: { title: 'HTTP API', version: '1.0.0' },
|
|
1834
|
-
servers: [{ url: 'http://example.com' }],
|
|
1835
|
-
paths: { '/': { get: { summary: 'Get data', operationId: 'getData' } } },
|
|
1836
|
-
});
|
|
1837
|
-
|
|
1838
|
-
const result = validateAndParseOpenAPISpec(httpSpec);
|
|
1839
|
-
expect(result.status).toBe(true);
|
|
1840
|
-
expect(result.serverUrl).toBe('http://example.com');
|
|
1841
|
-
|
|
1842
|
-
const extractedDomain = extractDomainFromUrl(result.serverUrl!);
|
|
1843
|
-
expect(extractedDomain).toBe('http://example.com');
|
|
1844
|
-
|
|
1845
|
-
// If client provided 'https://example.com', there would be a mismatch
|
|
1846
|
-
const clientProvidedHttps = 'https://example.com';
|
|
1847
|
-
expect(extractedDomain).not.toBe(clientProvidedHttps);
|
|
1848
|
-
});
|
|
1849
|
-
});
|
|
1850
|
-
|
|
1851
|
-
describe('validateActionDomain', () => {
|
|
1852
|
-
it('validates matching domains with HTTPS protocol', () => {
|
|
1853
|
-
const result = validateActionDomain('example.com', 'https://example.com/api/v1');
|
|
1854
|
-
expect(result.isValid).toBe(true);
|
|
1855
|
-
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
|
1856
|
-
expect(result.normalizedClientDomain).toBe('https://example.com');
|
|
1857
|
-
});
|
|
1858
|
-
|
|
1859
|
-
it('validates matching domains when client provides full URL', () => {
|
|
1860
|
-
const result = validateActionDomain('https://example.com', 'https://example.com/api');
|
|
1861
|
-
expect(result.isValid).toBe(true);
|
|
1862
|
-
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
|
1863
|
-
expect(result.normalizedClientDomain).toBe('https://example.com');
|
|
1864
|
-
});
|
|
1865
|
-
|
|
1866
|
-
it('rejects mismatched domains', () => {
|
|
1867
|
-
const result = validateActionDomain('example.com', 'https://malicious.com/api');
|
|
1868
|
-
expect(result.isValid).toBe(false);
|
|
1869
|
-
expect(result.message).toContain('Domain mismatch');
|
|
1870
|
-
expect(result.message).toContain('example.com');
|
|
1871
|
-
expect(result.message).toContain('malicious.com');
|
|
1872
|
-
});
|
|
1873
|
-
|
|
1874
|
-
it('detects SSRF attempt with internal IP', () => {
|
|
1875
|
-
const result = validateActionDomain('example.com', 'http://192.168.1.1/admin');
|
|
1876
|
-
expect(result.isValid).toBe(false);
|
|
1877
|
-
expect(result.message).toContain('Domain mismatch');
|
|
1878
|
-
expect(result.normalizedSpecDomain).toBe('http://192.168.1.1');
|
|
1879
|
-
});
|
|
1880
|
-
|
|
1881
|
-
it('detects SSRF attempt with AWS metadata service', () => {
|
|
1882
|
-
const result = validateActionDomain(
|
|
1883
|
-
'api.example.com',
|
|
1884
|
-
'http://169.254.169.254/latest/meta-data/',
|
|
1885
|
-
);
|
|
1886
|
-
expect(result.isValid).toBe(false);
|
|
1887
|
-
expect(result.message).toContain('Domain mismatch');
|
|
1888
|
-
expect(result.normalizedSpecDomain).toBe('http://169.254.169.254');
|
|
1889
|
-
});
|
|
1890
|
-
|
|
1891
|
-
it('detects SSRF attempt with localhost', () => {
|
|
1892
|
-
const result = validateActionDomain('example.com', 'http://localhost:8080/api');
|
|
1893
|
-
expect(result.isValid).toBe(false);
|
|
1894
|
-
expect(result.message).toContain('Domain mismatch');
|
|
1895
|
-
expect(result.normalizedSpecDomain).toBe('http://localhost');
|
|
1896
|
-
});
|
|
1897
|
-
|
|
1898
|
-
it('detects protocol mismatch (HTTP vs HTTPS)', () => {
|
|
1899
|
-
const result = validateActionDomain('https://example.com', 'http://example.com/api');
|
|
1900
|
-
expect(result.isValid).toBe(false);
|
|
1901
|
-
expect(result.message).toContain('Domain mismatch');
|
|
1902
|
-
expect(result.normalizedSpecDomain).toBe('http://example.com');
|
|
1903
|
-
expect(result.normalizedClientDomain).toBe('https://example.com');
|
|
1904
|
-
});
|
|
1905
|
-
|
|
1906
|
-
it('validates matching subdomains', () => {
|
|
1907
|
-
const result = validateActionDomain('api.example.com', 'https://api.example.com/v1');
|
|
1908
|
-
expect(result.isValid).toBe(true);
|
|
1909
|
-
expect(result.normalizedSpecDomain).toBe('https://api.example.com');
|
|
1910
|
-
});
|
|
1911
|
-
|
|
1912
|
-
it('rejects different subdomains', () => {
|
|
1913
|
-
const result = validateActionDomain('api.example.com', 'https://admin.example.com/v1');
|
|
1914
|
-
expect(result.isValid).toBe(false);
|
|
1915
|
-
expect(result.message).toContain('Domain mismatch');
|
|
1916
|
-
});
|
|
1917
|
-
|
|
1918
|
-
it('handles invalid server URL gracefully', () => {
|
|
1919
|
-
const result = validateActionDomain('example.com', 'not-a-valid-url');
|
|
1920
|
-
expect(result.isValid).toBe(false);
|
|
1921
|
-
expect(result.message).toContain('Failed to validate domain');
|
|
1922
|
-
});
|
|
1923
|
-
|
|
1924
|
-
it('validates with port numbers', () => {
|
|
1925
|
-
const result = validateActionDomain('example.com', 'https://example.com:8443/api');
|
|
1926
|
-
expect(result.isValid).toBe(true);
|
|
1927
|
-
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
|
1928
|
-
});
|
|
1929
|
-
|
|
1930
|
-
it('detects port-based SSRF attempt', () => {
|
|
1931
|
-
const result = validateActionDomain('example.com', 'http://example.com:6379/');
|
|
1932
|
-
expect(result.isValid).toBe(false);
|
|
1933
|
-
expect(result.normalizedSpecDomain).toBe('http://example.com');
|
|
1934
|
-
expect(result.normalizedClientDomain).toBe('https://example.com');
|
|
1935
|
-
});
|
|
1936
|
-
|
|
1937
|
-
it('validates Google Cloud metadata service detection', () => {
|
|
1938
|
-
const result = validateActionDomain(
|
|
1939
|
-
'example.com',
|
|
1940
|
-
'http://metadata.google.internal/computeMetadata/v1/',
|
|
1941
|
-
);
|
|
1942
|
-
expect(result.isValid).toBe(false);
|
|
1943
|
-
expect(result.normalizedSpecDomain).toBe('http://metadata.google.internal');
|
|
1944
|
-
});
|
|
1945
|
-
|
|
1946
|
-
it('validates Azure metadata service detection', () => {
|
|
1947
|
-
const result = validateActionDomain(
|
|
1948
|
-
'example.com',
|
|
1949
|
-
'http://169.254.169.254/metadata/instance',
|
|
1950
|
-
);
|
|
1951
|
-
expect(result.isValid).toBe(false);
|
|
1952
|
-
expect(result.normalizedSpecDomain).toBe('http://169.254.169.254');
|
|
1953
|
-
});
|
|
1954
|
-
|
|
1955
|
-
it('handles edge case: client provides domain with protocol matching spec', () => {
|
|
1956
|
-
const result = validateActionDomain('http://example.com', 'http://example.com/api');
|
|
1957
|
-
expect(result.isValid).toBe(true);
|
|
1958
|
-
expect(result.normalizedSpecDomain).toBe('http://example.com');
|
|
1959
|
-
expect(result.normalizedClientDomain).toBe('http://example.com');
|
|
1960
|
-
});
|
|
1961
|
-
|
|
1962
|
-
it('validates real-world case: legitimate API with versioned path', () => {
|
|
1963
|
-
const result = validateActionDomain(
|
|
1964
|
-
'api.openai.com',
|
|
1965
|
-
'https://api.openai.com/v1/chat/completions',
|
|
1966
|
-
);
|
|
1967
|
-
expect(result.isValid).toBe(true);
|
|
1968
|
-
expect(result.normalizedSpecDomain).toBe('https://api.openai.com');
|
|
1969
|
-
});
|
|
1970
|
-
|
|
1971
|
-
// Tests for IP address validation (fix for the reported issue)
|
|
1972
|
-
it('validates matching IP addresses when client provides just IP (no protocol)', () => {
|
|
1973
|
-
const result = validateActionDomain('10.225.26.25', 'http://10.225.26.25:7894/api');
|
|
1974
|
-
expect(result.isValid).toBe(true);
|
|
1975
|
-
expect(result.normalizedSpecDomain).toBe('http://10.225.26.25');
|
|
1976
|
-
expect(result.normalizedClientDomain).toBe('http://10.225.26.25');
|
|
1977
|
-
});
|
|
1978
|
-
|
|
1979
|
-
it('validates matching localhost IP when client provides just IP', () => {
|
|
1980
|
-
const result = validateActionDomain('127.0.0.1', 'http://127.0.0.1:8080/api');
|
|
1981
|
-
expect(result.isValid).toBe(true);
|
|
1982
|
-
expect(result.normalizedSpecDomain).toBe('http://127.0.0.1');
|
|
1983
|
-
expect(result.normalizedClientDomain).toBe('http://127.0.0.1');
|
|
1984
|
-
});
|
|
1985
|
-
|
|
1986
|
-
it('validates matching private network IP when client provides just IP', () => {
|
|
1987
|
-
const result = validateActionDomain('192.168.1.100', 'https://192.168.1.100:443/api');
|
|
1988
|
-
expect(result.isValid).toBe(true);
|
|
1989
|
-
expect(result.normalizedSpecDomain).toBe('https://192.168.1.100');
|
|
1990
|
-
expect(result.normalizedClientDomain).toBe('https://192.168.1.100');
|
|
1991
|
-
});
|
|
1992
|
-
|
|
1993
|
-
it('validates matching IP when client provides full URL with IP', () => {
|
|
1994
|
-
const result = validateActionDomain('http://10.225.26.25', 'http://10.225.26.25:7894');
|
|
1995
|
-
expect(result.isValid).toBe(true);
|
|
1996
|
-
expect(result.normalizedSpecDomain).toBe('http://10.225.26.25');
|
|
1997
|
-
expect(result.normalizedClientDomain).toBe('http://10.225.26.25');
|
|
1998
|
-
});
|
|
1999
|
-
|
|
2000
|
-
it('rejects mismatched IP addresses', () => {
|
|
2001
|
-
const result = validateActionDomain('10.225.26.25', 'http://10.225.26.26:7894/api');
|
|
2002
|
-
expect(result.isValid).toBe(false);
|
|
2003
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2004
|
-
expect(result.message).toContain('10.225.26.25');
|
|
2005
|
-
expect(result.message).toContain('10.225.26.26');
|
|
2006
|
-
});
|
|
2007
|
-
|
|
2008
|
-
it('rejects IP when domain expected', () => {
|
|
2009
|
-
const result = validateActionDomain('example.com', 'http://192.168.1.1/api');
|
|
2010
|
-
expect(result.isValid).toBe(false);
|
|
2011
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2012
|
-
expect(result.normalizedSpecDomain).toBe('http://192.168.1.1');
|
|
2013
|
-
});
|
|
2014
|
-
|
|
2015
|
-
it('rejects domain when IP expected', () => {
|
|
2016
|
-
const result = validateActionDomain('192.168.1.1', 'http://malicious.com/api');
|
|
2017
|
-
expect(result.isValid).toBe(false);
|
|
2018
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2019
|
-
expect(result.message).toContain('192.168.1.1');
|
|
2020
|
-
expect(result.message).toContain('malicious.com');
|
|
2021
|
-
});
|
|
2022
|
-
|
|
2023
|
-
it('handles IPv6 addresses when client provides just IP', () => {
|
|
2024
|
-
const result = validateActionDomain('[::1]', 'http://[::1]:8080/api');
|
|
2025
|
-
expect(result.isValid).toBe(true);
|
|
2026
|
-
expect(result.normalizedSpecDomain).toBe('http://[::1]');
|
|
2027
|
-
expect(result.normalizedClientDomain).toBe('http://[::1]');
|
|
2028
|
-
});
|
|
2029
|
-
|
|
2030
|
-
// Additional IP-based SSRF tests for comprehensive security coverage
|
|
2031
|
-
it('prevents using whitelisted IP to access different IP', () => {
|
|
2032
|
-
const result = validateActionDomain('192.168.1.100', 'http://192.168.1.101/api');
|
|
2033
|
-
expect(result.isValid).toBe(false);
|
|
2034
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2035
|
-
expect(result.message).toContain('192.168.1.100');
|
|
2036
|
-
expect(result.message).toContain('192.168.1.101');
|
|
2037
|
-
});
|
|
2038
|
-
|
|
2039
|
-
it('prevents using external IP to access localhost', () => {
|
|
2040
|
-
const result = validateActionDomain('8.8.8.8', 'http://127.0.0.1/admin');
|
|
2041
|
-
expect(result.isValid).toBe(false);
|
|
2042
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2043
|
-
});
|
|
2044
|
-
|
|
2045
|
-
it('prevents using localhost to access private network', () => {
|
|
2046
|
-
const result = validateActionDomain('127.0.0.1', 'http://192.168.1.1/admin');
|
|
2047
|
-
expect(result.isValid).toBe(false);
|
|
2048
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2049
|
-
});
|
|
2050
|
-
|
|
2051
|
-
it('detects SSRF with 0.0.0.0 binding address', () => {
|
|
2052
|
-
const result = validateActionDomain('example.com', 'http://0.0.0.0:8080');
|
|
2053
|
-
expect(result.isValid).toBe(false);
|
|
2054
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2055
|
-
expect(result.normalizedSpecDomain).toBe('http://0.0.0.0');
|
|
2056
|
-
});
|
|
2057
|
-
|
|
2058
|
-
it('validates matching 0.0.0.0 when legitimately used', () => {
|
|
2059
|
-
const result = validateActionDomain('0.0.0.0', 'http://0.0.0.0:8080');
|
|
2060
|
-
expect(result.isValid).toBe(true);
|
|
2061
|
-
expect(result.normalizedSpecDomain).toBe('http://0.0.0.0');
|
|
2062
|
-
});
|
|
2063
|
-
|
|
2064
|
-
it('prevents link-local address SSRF (169.254.x.x)', () => {
|
|
2065
|
-
const result = validateActionDomain('api.example.com', 'http://169.254.10.10/');
|
|
2066
|
-
expect(result.isValid).toBe(false);
|
|
2067
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2068
|
-
expect(result.normalizedSpecDomain).toBe('http://169.254.10.10');
|
|
2069
|
-
});
|
|
2070
|
-
|
|
2071
|
-
it('validates matching link-local when explicitly allowed', () => {
|
|
2072
|
-
const result = validateActionDomain('169.254.10.10', 'http://169.254.10.10/api');
|
|
2073
|
-
expect(result.isValid).toBe(true);
|
|
2074
|
-
expect(result.normalizedSpecDomain).toBe('http://169.254.10.10');
|
|
2075
|
-
});
|
|
2076
|
-
|
|
2077
|
-
it('prevents Docker internal network access via SSRF', () => {
|
|
2078
|
-
const result = validateActionDomain('public-api.com', 'http://172.17.0.1/');
|
|
2079
|
-
expect(result.isValid).toBe(false);
|
|
2080
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2081
|
-
expect(result.normalizedSpecDomain).toBe('http://172.17.0.1');
|
|
2082
|
-
});
|
|
2083
|
-
|
|
2084
|
-
it('prevents Kubernetes service network SSRF', () => {
|
|
2085
|
-
const result = validateActionDomain('api.company.com', 'http://10.96.0.1/');
|
|
2086
|
-
expect(result.isValid).toBe(false);
|
|
2087
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2088
|
-
});
|
|
2089
|
-
|
|
2090
|
-
it('detects protocol mismatch for IP addresses', () => {
|
|
2091
|
-
const result = validateActionDomain('https://192.168.1.1', 'http://192.168.1.1/api');
|
|
2092
|
-
expect(result.isValid).toBe(false);
|
|
2093
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2094
|
-
expect(result.normalizedSpecDomain).toBe('http://192.168.1.1');
|
|
2095
|
-
expect(result.normalizedClientDomain).toBe('https://192.168.1.1');
|
|
2096
|
-
});
|
|
2097
|
-
|
|
2098
|
-
it('prevents IPv6 localhost bypass attempts', () => {
|
|
2099
|
-
const result = validateActionDomain('example.com', 'http://[::1]/admin');
|
|
2100
|
-
expect(result.isValid).toBe(false);
|
|
2101
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2102
|
-
expect(result.normalizedSpecDomain).toBe('http://[::1]');
|
|
2103
|
-
});
|
|
2104
|
-
|
|
2105
|
-
it('prevents IPv6 link-local SSRF (fe80::)', () => {
|
|
2106
|
-
const result = validateActionDomain('api.example.com', 'http://[fe80::1]/');
|
|
2107
|
-
expect(result.isValid).toBe(false);
|
|
2108
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2109
|
-
});
|
|
2110
|
-
|
|
2111
|
-
it('validates matching IPv6 link-local when explicitly allowed', () => {
|
|
2112
|
-
const result = validateActionDomain('[fe80::1]', 'http://[fe80::1]/api');
|
|
2113
|
-
expect(result.isValid).toBe(true);
|
|
2114
|
-
expect(result.normalizedSpecDomain).toBe('http://[fe80::1]');
|
|
2115
|
-
});
|
|
2116
|
-
|
|
2117
|
-
it('prevents multicast address SSRF', () => {
|
|
2118
|
-
const result = validateActionDomain('api.example.com', 'http://224.0.0.1/');
|
|
2119
|
-
expect(result.isValid).toBe(false);
|
|
2120
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2121
|
-
});
|
|
2122
|
-
|
|
2123
|
-
it('prevents broadcast address SSRF', () => {
|
|
2124
|
-
const result = validateActionDomain('api.example.com', 'http://255.255.255.255/');
|
|
2125
|
-
expect(result.isValid).toBe(false);
|
|
2126
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2127
|
-
});
|
|
2128
|
-
|
|
2129
|
-
// Cloud Provider Metadata Service Tests
|
|
2130
|
-
it('prevents AWS IMDSv1 metadata access', () => {
|
|
2131
|
-
const result = validateActionDomain(
|
|
2132
|
-
'trusted-api.com',
|
|
2133
|
-
'http://169.254.169.254/latest/meta-data/',
|
|
2134
|
-
);
|
|
2135
|
-
expect(result.isValid).toBe(false);
|
|
2136
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2137
|
-
});
|
|
2138
|
-
|
|
2139
|
-
it('prevents AWS IMDSv2 token endpoint access', () => {
|
|
2140
|
-
const result = validateActionDomain(
|
|
2141
|
-
'api.example.com',
|
|
2142
|
-
'http://169.254.169.254/latest/api/token',
|
|
2143
|
-
);
|
|
2144
|
-
expect(result.isValid).toBe(false);
|
|
2145
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2146
|
-
});
|
|
2147
|
-
|
|
2148
|
-
it('prevents GCP metadata access via metadata.google.internal', () => {
|
|
2149
|
-
const result = validateActionDomain(
|
|
2150
|
-
'api.example.com',
|
|
2151
|
-
'http://metadata.google.internal/computeMetadata/v1/',
|
|
2152
|
-
);
|
|
2153
|
-
expect(result.isValid).toBe(false);
|
|
2154
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2155
|
-
});
|
|
2156
|
-
|
|
2157
|
-
it('prevents Azure IMDS access', () => {
|
|
2158
|
-
const result = validateActionDomain(
|
|
2159
|
-
'api.example.com',
|
|
2160
|
-
'http://169.254.169.254/metadata/instance?api-version=2021-02-01',
|
|
2161
|
-
);
|
|
2162
|
-
expect(result.isValid).toBe(false);
|
|
2163
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2164
|
-
});
|
|
2165
|
-
|
|
2166
|
-
it('prevents DigitalOcean metadata access', () => {
|
|
2167
|
-
const result = validateActionDomain('api.example.com', 'http://169.254.169.254/metadata/v1/');
|
|
2168
|
-
expect(result.isValid).toBe(false);
|
|
2169
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2170
|
-
});
|
|
2171
|
-
|
|
2172
|
-
it('prevents Oracle Cloud metadata access', () => {
|
|
2173
|
-
const result = validateActionDomain(
|
|
2174
|
-
'api.example.com',
|
|
2175
|
-
'http://169.254.169.254/opc/v1/instance/',
|
|
2176
|
-
);
|
|
2177
|
-
expect(result.isValid).toBe(false);
|
|
2178
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2179
|
-
});
|
|
2180
|
-
|
|
2181
|
-
it('prevents Alibaba Cloud metadata access', () => {
|
|
2182
|
-
const result = validateActionDomain(
|
|
2183
|
-
'api.example.com',
|
|
2184
|
-
'http://100.100.100.200/latest/meta-data/',
|
|
2185
|
-
);
|
|
2186
|
-
expect(result.isValid).toBe(false);
|
|
2187
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2188
|
-
});
|
|
2189
|
-
|
|
2190
|
-
// Container & Orchestration Internal Services
|
|
2191
|
-
it('prevents Kubernetes API server access', () => {
|
|
2192
|
-
const result = validateActionDomain(
|
|
2193
|
-
'api.example.com',
|
|
2194
|
-
'https://kubernetes.default.svc.cluster.local/',
|
|
2195
|
-
);
|
|
2196
|
-
expect(result.isValid).toBe(false);
|
|
2197
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2198
|
-
});
|
|
2199
|
-
|
|
2200
|
-
it('prevents Docker host access from container', () => {
|
|
2201
|
-
const result = validateActionDomain('api.example.com', 'http://host.docker.internal/');
|
|
2202
|
-
expect(result.isValid).toBe(false);
|
|
2203
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2204
|
-
});
|
|
2205
|
-
|
|
2206
|
-
it('prevents Rancher metadata service access', () => {
|
|
2207
|
-
const result = validateActionDomain('api.example.com', 'http://rancher-metadata/');
|
|
2208
|
-
expect(result.isValid).toBe(false);
|
|
2209
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2210
|
-
});
|
|
2211
|
-
|
|
2212
|
-
// Common Internal Service Ports
|
|
2213
|
-
it('prevents Redis default port access', () => {
|
|
2214
|
-
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:6379/');
|
|
2215
|
-
expect(result.isValid).toBe(false);
|
|
2216
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2217
|
-
});
|
|
2218
|
-
|
|
2219
|
-
it('prevents Elasticsearch default port access', () => {
|
|
2220
|
-
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:9200/');
|
|
2221
|
-
expect(result.isValid).toBe(false);
|
|
2222
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2223
|
-
});
|
|
2224
|
-
|
|
2225
|
-
it('prevents MongoDB default port access', () => {
|
|
2226
|
-
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:27017/');
|
|
2227
|
-
expect(result.isValid).toBe(false);
|
|
2228
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2229
|
-
});
|
|
2230
|
-
|
|
2231
|
-
it('prevents PostgreSQL default port access', () => {
|
|
2232
|
-
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:5432/');
|
|
2233
|
-
expect(result.isValid).toBe(false);
|
|
2234
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2235
|
-
});
|
|
2236
|
-
|
|
2237
|
-
it('prevents MySQL default port access', () => {
|
|
2238
|
-
const result = validateActionDomain('api.example.com', 'http://10.0.0.5:3306/');
|
|
2239
|
-
expect(result.isValid).toBe(false);
|
|
2240
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2241
|
-
});
|
|
2242
|
-
|
|
2243
|
-
// Alternative localhost representations
|
|
2244
|
-
it('prevents localhost.localdomain SSRF', () => {
|
|
2245
|
-
const result = validateActionDomain('api.example.com', 'http://localhost.localdomain/admin');
|
|
2246
|
-
expect(result.isValid).toBe(false);
|
|
2247
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2248
|
-
});
|
|
2249
|
-
|
|
2250
|
-
it('validates matching localhost.localdomain when explicitly allowed', () => {
|
|
2251
|
-
const result = validateActionDomain(
|
|
2252
|
-
'localhost.localdomain',
|
|
2253
|
-
'https://localhost.localdomain/api',
|
|
2254
|
-
);
|
|
2255
|
-
expect(result.isValid).toBe(true);
|
|
2256
|
-
});
|
|
2257
|
-
|
|
2258
|
-
// Edge cases with special IPs
|
|
2259
|
-
it('prevents class E reserved IP range access', () => {
|
|
2260
|
-
const result = validateActionDomain('api.example.com', 'http://240.0.0.1/');
|
|
2261
|
-
expect(result.isValid).toBe(false);
|
|
2262
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2263
|
-
});
|
|
2264
|
-
|
|
2265
|
-
it('prevents TEST-NET-1 range access when not matching', () => {
|
|
2266
|
-
const result = validateActionDomain('api.example.com', 'http://192.0.2.1/');
|
|
2267
|
-
expect(result.isValid).toBe(false);
|
|
2268
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2269
|
-
});
|
|
2270
|
-
|
|
2271
|
-
it('validates TEST-NET-1 when explicitly matching', () => {
|
|
2272
|
-
const result = validateActionDomain('192.0.2.1', 'http://192.0.2.1/api');
|
|
2273
|
-
expect(result.isValid).toBe(true);
|
|
2274
|
-
});
|
|
2275
|
-
|
|
2276
|
-
// Mixed protocol and IP scenarios (unsupported protocols)
|
|
2277
|
-
it('rejects unsupported WebSocket protocol', () => {
|
|
2278
|
-
const result = validateActionDomain('api.example.com', 'ws://api.example.com:8080/');
|
|
2279
|
-
expect(result.isValid).toBe(false);
|
|
2280
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2281
|
-
expect(result.message).toContain('ws:');
|
|
2282
|
-
});
|
|
2283
|
-
|
|
2284
|
-
it('rejects unsupported FTP protocol', () => {
|
|
2285
|
-
const result = validateActionDomain('ftp.example.com', 'ftp://ftp.example.com/');
|
|
2286
|
-
expect(result.isValid).toBe(false);
|
|
2287
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2288
|
-
expect(result.message).toContain('ftp:');
|
|
2289
|
-
});
|
|
2290
|
-
|
|
2291
|
-
it('rejects WSS (secure WebSocket) protocol', () => {
|
|
2292
|
-
const result = validateActionDomain('api.example.com', 'wss://api.example.com:8080/');
|
|
2293
|
-
expect(result.isValid).toBe(false);
|
|
2294
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2295
|
-
expect(result.message).toContain('wss:');
|
|
2296
|
-
});
|
|
2297
|
-
|
|
2298
|
-
it('rejects file:// protocol for local file access', () => {
|
|
2299
|
-
const result = validateActionDomain('localhost', 'file:///etc/passwd');
|
|
2300
|
-
expect(result.isValid).toBe(false);
|
|
2301
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2302
|
-
expect(result.message).toContain('file:');
|
|
2303
|
-
});
|
|
2304
|
-
|
|
2305
|
-
it('rejects gopher:// protocol', () => {
|
|
2306
|
-
const result = validateActionDomain('example.com', 'gopher://example.com/');
|
|
2307
|
-
expect(result.isValid).toBe(false);
|
|
2308
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2309
|
-
expect(result.message).toContain('gopher:');
|
|
2310
|
-
});
|
|
2311
|
-
|
|
2312
|
-
it('rejects data: URL protocol', () => {
|
|
2313
|
-
const result = validateActionDomain('example.com', 'data:text/plain,Hello');
|
|
2314
|
-
expect(result.isValid).toBe(false);
|
|
2315
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2316
|
-
expect(result.message).toContain('data:');
|
|
2317
|
-
});
|
|
2318
|
-
|
|
2319
|
-
// Tests for Copilot second review catches
|
|
2320
|
-
it('rejects unsupported protocol in client domain', () => {
|
|
2321
|
-
const result = validateActionDomain('ftp://evil.com', 'https://trusted.com/api');
|
|
2322
|
-
expect(result.isValid).toBe(false);
|
|
2323
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2324
|
-
expect(result.message).toContain('client domain');
|
|
2325
|
-
});
|
|
2326
|
-
|
|
2327
|
-
it('rejects WebSocket protocol in client domain', () => {
|
|
2328
|
-
const result = validateActionDomain('ws://evil.com', 'https://trusted.com/api');
|
|
2329
|
-
expect(result.isValid).toBe(false);
|
|
2330
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2331
|
-
expect(result.message).toContain('client domain');
|
|
2332
|
-
});
|
|
2333
|
-
|
|
2334
|
-
it('rejects file protocol in client domain', () => {
|
|
2335
|
-
const result = validateActionDomain('file:///etc/passwd', 'https://trusted.com/api');
|
|
2336
|
-
expect(result.isValid).toBe(false);
|
|
2337
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2338
|
-
expect(result.message).toContain('client domain');
|
|
2339
|
-
});
|
|
2340
|
-
|
|
2341
|
-
it('handles IPv6 address without brackets from client', () => {
|
|
2342
|
-
const result = validateActionDomain('2001:db8::1', 'http://[2001:db8::1]/api');
|
|
2343
|
-
expect(result.isValid).toBe(true);
|
|
2344
|
-
expect(result.normalizedClientDomain).toBe('http://[2001:db8::1]');
|
|
2345
|
-
expect(result.normalizedSpecDomain).toBe('http://[2001:db8::1]');
|
|
2346
|
-
});
|
|
2347
|
-
|
|
2348
|
-
it('handles IPv6 address with brackets from client', () => {
|
|
2349
|
-
const result = validateActionDomain('[2001:db8::1]', 'http://[2001:db8::1]/api');
|
|
2350
|
-
expect(result.isValid).toBe(true);
|
|
2351
|
-
expect(result.normalizedClientDomain).toBe('http://[2001:db8::1]');
|
|
2352
|
-
expect(result.normalizedSpecDomain).toBe('http://[2001:db8::1]');
|
|
2353
|
-
});
|
|
2354
|
-
|
|
2355
|
-
// Ensure legitimate internal use cases still work
|
|
2356
|
-
it('allows legitimate internal API with matching IP', () => {
|
|
2357
|
-
const result = validateActionDomain('10.0.0.5', 'http://10.0.0.5:8080/api');
|
|
2358
|
-
expect(result.isValid).toBe(true);
|
|
2359
|
-
});
|
|
2360
|
-
|
|
2361
|
-
it('allows legitimate Docker internal when explicitly specified', () => {
|
|
2362
|
-
const result = validateActionDomain(
|
|
2363
|
-
'host.docker.internal',
|
|
2364
|
-
'https://host.docker.internal:3000/api',
|
|
2365
|
-
);
|
|
2366
|
-
expect(result.isValid).toBe(true);
|
|
2367
|
-
});
|
|
2368
|
-
|
|
2369
|
-
it('allows legitimate Kubernetes service when explicitly specified', () => {
|
|
2370
|
-
const result = validateActionDomain(
|
|
2371
|
-
'myservice.default.svc.cluster.local',
|
|
2372
|
-
'https://myservice.default.svc.cluster.local/api',
|
|
2373
|
-
);
|
|
2374
|
-
expect(result.isValid).toBe(true);
|
|
2375
|
-
});
|
|
2376
|
-
|
|
2377
|
-
// Additional coverage tests for error paths and edge cases
|
|
2378
|
-
it('handles malformed URL in client domain gracefully', () => {
|
|
2379
|
-
const result = validateActionDomain('http://[invalid', 'https://example.com/api');
|
|
2380
|
-
expect(result.isValid).toBe(false);
|
|
2381
|
-
});
|
|
2382
|
-
|
|
2383
|
-
it('handles error in spec URL parsing', () => {
|
|
2384
|
-
const result = validateActionDomain('example.com', 'not-a-valid-url');
|
|
2385
|
-
expect(result.isValid).toBe(false);
|
|
2386
|
-
expect(result.message).toContain('Failed to validate domain');
|
|
2387
|
-
});
|
|
2388
|
-
|
|
2389
|
-
it('validates when client provides HTTP and spec uses HTTP', () => {
|
|
2390
|
-
const result = validateActionDomain('http://example.com', 'http://example.com/api');
|
|
2391
|
-
expect(result.isValid).toBe(true);
|
|
2392
|
-
expect(result.normalizedClientDomain).toBe('http://example.com');
|
|
2393
|
-
expect(result.normalizedSpecDomain).toBe('http://example.com');
|
|
2394
|
-
});
|
|
2395
|
-
|
|
2396
|
-
it('validates when client provides HTTPS and spec uses HTTPS', () => {
|
|
2397
|
-
const result = validateActionDomain('https://example.com', 'https://example.com/api');
|
|
2398
|
-
expect(result.isValid).toBe(true);
|
|
2399
|
-
expect(result.normalizedClientDomain).toBe('https://example.com');
|
|
2400
|
-
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
|
2401
|
-
});
|
|
2402
|
-
|
|
2403
|
-
it('handles IPv4 with explicit protocol from client', () => {
|
|
2404
|
-
const result = validateActionDomain('http://192.168.1.1', 'http://192.168.1.1:8080');
|
|
2405
|
-
expect(result.isValid).toBe(true);
|
|
2406
|
-
expect(result.normalizedClientDomain).toBe('http://192.168.1.1');
|
|
2407
|
-
});
|
|
2408
|
-
|
|
2409
|
-
it('handles localhost as a domain', () => {
|
|
2410
|
-
const result = validateActionDomain('localhost', 'https://localhost:3000/api');
|
|
2411
|
-
expect(result.isValid).toBe(true);
|
|
2412
|
-
expect(result.normalizedClientDomain).toBe('https://localhost');
|
|
2413
|
-
expect(result.normalizedSpecDomain).toBe('https://localhost');
|
|
2414
|
-
});
|
|
2415
|
-
|
|
2416
|
-
it('rejects javascript: protocol in client domain', () => {
|
|
2417
|
-
const result = validateActionDomain('javascript:alert(1)', 'https://example.com/api');
|
|
2418
|
-
expect(result.isValid).toBe(false);
|
|
2419
|
-
// javascript: doesn't have :// so it's treated as a hostname mismatch
|
|
2420
|
-
expect(result.message).toContain('Domain mismatch');
|
|
2421
|
-
});
|
|
2422
|
-
|
|
2423
|
-
it('handles empty string as client domain', () => {
|
|
2424
|
-
const result = validateActionDomain('', 'https://example.com/api');
|
|
2425
|
-
expect(result.isValid).toBe(false);
|
|
2426
|
-
});
|
|
2427
|
-
|
|
2428
|
-
it('handles spec URL without path', () => {
|
|
2429
|
-
const result = validateActionDomain('example.com', 'https://example.com');
|
|
2430
|
-
expect(result.isValid).toBe(true);
|
|
2431
|
-
});
|
|
2432
|
-
|
|
2433
|
-
it('handles spec URL with query parameters', () => {
|
|
2434
|
-
const result = validateActionDomain(
|
|
2435
|
-
'api.example.com',
|
|
2436
|
-
'https://api.example.com/v1?key=value',
|
|
2437
|
-
);
|
|
2438
|
-
expect(result.isValid).toBe(true);
|
|
2439
|
-
expect(result.normalizedSpecDomain).toBe('https://api.example.com');
|
|
2440
|
-
});
|
|
2441
|
-
|
|
2442
|
-
it('handles subdomain matching correctly', () => {
|
|
2443
|
-
const result = validateActionDomain(
|
|
2444
|
-
'api.v2.example.com',
|
|
2445
|
-
'https://api.v2.example.com/endpoint',
|
|
2446
|
-
);
|
|
2447
|
-
expect(result.isValid).toBe(true);
|
|
2448
|
-
});
|
|
2449
|
-
|
|
2450
|
-
it('rejects SSH protocol in client domain', () => {
|
|
2451
|
-
const result = validateActionDomain('ssh://git@github.com', 'https://github.com/api');
|
|
2452
|
-
expect(result.isValid).toBe(false);
|
|
2453
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2454
|
-
});
|
|
2455
|
-
|
|
2456
|
-
it('handles punycode/internationalized domains', () => {
|
|
2457
|
-
const result = validateActionDomain(
|
|
2458
|
-
'xn--e1afmkfd.xn--p1ai',
|
|
2459
|
-
'https://xn--e1afmkfd.xn--p1ai/api',
|
|
2460
|
-
);
|
|
2461
|
-
expect(result.isValid).toBe(true);
|
|
2462
|
-
});
|
|
2463
|
-
|
|
2464
|
-
it('validates IPv6 localhost variations', () => {
|
|
2465
|
-
const result = validateActionDomain('::1', 'http://[::1]:8080');
|
|
2466
|
-
expect(result.isValid).toBe(true);
|
|
2467
|
-
expect(result.normalizedClientDomain).toBe('http://[::1]');
|
|
2468
|
-
});
|
|
2469
|
-
|
|
2470
|
-
it('handles spec URL with username in URL', () => {
|
|
2471
|
-
const result = validateActionDomain('example.com', 'https://user@example.com/api');
|
|
2472
|
-
expect(result.isValid).toBe(true);
|
|
2473
|
-
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
|
2474
|
-
});
|
|
2475
|
-
|
|
2476
|
-
it('handles spec URL with username and password', () => {
|
|
2477
|
-
const result = validateActionDomain('example.com', 'https://user:pass@example.com/api');
|
|
2478
|
-
expect(result.isValid).toBe(true);
|
|
2479
|
-
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
|
2480
|
-
});
|
|
2481
|
-
|
|
2482
|
-
it('handles complex IPv6 addresses', () => {
|
|
2483
|
-
const result = validateActionDomain(
|
|
2484
|
-
'2001:db8:85a3::8a2e:370:7334',
|
|
2485
|
-
'http://[2001:db8:85a3::8a2e:370:7334]/api',
|
|
2486
|
-
);
|
|
2487
|
-
expect(result.isValid).toBe(true);
|
|
2488
|
-
expect(result.normalizedClientDomain).toBe('http://[2001:db8:85a3::8a2e:370:7334]');
|
|
2489
|
-
});
|
|
2490
|
-
|
|
2491
|
-
it('handles IPv4-mapped IPv6 addresses', () => {
|
|
2492
|
-
// Node.js normalizes IPv4-mapped IPv6 differently in URL parsing
|
|
2493
|
-
const result = validateActionDomain('::ffff:c0a8:101', 'http://[::ffff:c0a8:101]/api');
|
|
2494
|
-
expect(result.isValid).toBe(true);
|
|
2495
|
-
expect(result.normalizedClientDomain).toBe('http://[::ffff:c0a8:101]');
|
|
2496
|
-
});
|
|
2497
|
-
|
|
2498
|
-
it('rejects telnet protocol in client domain', () => {
|
|
2499
|
-
const result = validateActionDomain('telnet://example.com', 'https://example.com/api');
|
|
2500
|
-
expect(result.isValid).toBe(false);
|
|
2501
|
-
expect(result.message).toContain('Invalid protocol');
|
|
2502
|
-
});
|
|
2503
|
-
|
|
2504
|
-
it('handles client domain with port and no protocol', () => {
|
|
2505
|
-
const result = validateActionDomain('example.com:443', 'https://example.com:443/api');
|
|
2506
|
-
// Port is included in hostname comparison, causing mismatch
|
|
2507
|
-
expect(result.isValid).toBe(false);
|
|
2508
|
-
expect(result.normalizedClientDomain).toBe('https://example.com:443');
|
|
2509
|
-
expect(result.normalizedSpecDomain).toBe('https://example.com');
|
|
2510
|
-
});
|
|
2511
|
-
|
|
2512
|
-
it('handles TLD-only domains', () => {
|
|
2513
|
-
const result = validateActionDomain('localhost', 'http://localhost/api');
|
|
2514
|
-
expect(result.isValid).toBe(false); // HTTP vs HTTPS mismatch
|
|
2515
|
-
expect(result.normalizedClientDomain).toBe('https://localhost');
|
|
2516
|
-
expect(result.normalizedSpecDomain).toBe('http://localhost');
|
|
2517
|
-
});
|
|
2518
|
-
|
|
2519
|
-
it('validates when both URLs have ports', () => {
|
|
2520
|
-
const result = validateActionDomain(
|
|
2521
|
-
'https://api.example.com:8443',
|
|
2522
|
-
'https://api.example.com:8443/v1',
|
|
2523
|
-
);
|
|
2524
|
-
expect(result.isValid).toBe(true);
|
|
2525
|
-
});
|
|
2526
|
-
|
|
2527
|
-
it('handles client domain that looks like URL but missing protocol separator', () => {
|
|
2528
|
-
const result = validateActionDomain('httpexample.com', 'https://httpexample.com/api');
|
|
2529
|
-
expect(result.isValid).toBe(true);
|
|
2530
|
-
expect(result.normalizedClientDomain).toBe('https://httpexample.com');
|
|
2531
|
-
});
|
|
2532
|
-
});
|
|
2533
|
-
});
|