@optimizely-opal/opal-tool-ocp-sdk 1.0.0 → 1.1.0-beta.2
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/README.md +51 -13
- package/dist/auth/AuthUtils.d.ts +2 -5
- package/dist/auth/AuthUtils.d.ts.map +1 -1
- package/dist/auth/AuthUtils.js +5 -5
- package/dist/auth/AuthUtils.js.map +1 -1
- package/dist/decorator/Decorator.test.js +4 -4
- package/dist/decorator/Decorator.test.js.map +1 -1
- package/dist/function/GlobalToolFunction.d.ts +4 -1
- package/dist/function/GlobalToolFunction.d.ts.map +1 -1
- package/dist/function/GlobalToolFunction.js +27 -21
- package/dist/function/GlobalToolFunction.js.map +1 -1
- package/dist/function/GlobalToolFunction.test.js +114 -193
- package/dist/function/GlobalToolFunction.test.js.map +1 -1
- package/dist/function/ToolFunction.d.ts +4 -1
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +20 -21
- package/dist/function/ToolFunction.js.map +1 -1
- package/dist/function/ToolFunction.test.js +73 -263
- package/dist/function/ToolFunction.test.js.map +1 -1
- package/dist/service/Service.d.ts +20 -19
- package/dist/service/Service.d.ts.map +1 -1
- package/dist/service/Service.js +47 -72
- package/dist/service/Service.js.map +1 -1
- package/dist/service/Service.test.js +229 -133
- package/dist/service/Service.test.js.map +1 -1
- package/dist/types/Models.d.ts +18 -7
- package/dist/types/Models.d.ts.map +1 -1
- package/dist/types/Models.js +1 -29
- package/dist/types/Models.js.map +1 -1
- package/dist/utils/ErrorFormatter.d.ts +9 -0
- package/dist/utils/ErrorFormatter.d.ts.map +1 -0
- package/dist/utils/ErrorFormatter.js +25 -0
- package/dist/utils/ErrorFormatter.js.map +1 -0
- package/package.json +1 -1
- package/src/auth/AuthUtils.ts +10 -10
- package/src/decorator/Decorator.test.ts +4 -4
- package/src/function/GlobalToolFunction.test.ts +113 -213
- package/src/function/GlobalToolFunction.ts +31 -31
- package/src/function/ToolFunction.test.ts +78 -285
- package/src/function/ToolFunction.ts +24 -30
- package/src/service/Service.test.ts +238 -174
- package/src/service/Service.ts +68 -92
- package/src/types/Models.ts +24 -15
- package/src/utils/ErrorFormatter.ts +31 -0
|
@@ -2,11 +2,14 @@ import { ToolFunction } from './ToolFunction';
|
|
|
2
2
|
import { toolsService } from '../service/Service';
|
|
3
3
|
import { Response, getAppContext } from '@zaiusinc/app-sdk';
|
|
4
4
|
import { getTokenVerifier } from '../auth/TokenVerifier';
|
|
5
|
+
import { ToolError } from '../types/ToolError';
|
|
6
|
+
import { authenticateRegularRequest } from '../auth/AuthUtils';
|
|
5
7
|
|
|
6
8
|
// Mock the dependencies
|
|
7
9
|
jest.mock('../service/Service', () => ({
|
|
8
10
|
toolsService: {
|
|
9
11
|
processRequest: jest.fn(),
|
|
12
|
+
requiresOptiIdAuth: jest.fn(),
|
|
10
13
|
},
|
|
11
14
|
}));
|
|
12
15
|
|
|
@@ -14,6 +17,11 @@ jest.mock('../auth/TokenVerifier', () => ({
|
|
|
14
17
|
getTokenVerifier: jest.fn(),
|
|
15
18
|
}));
|
|
16
19
|
|
|
20
|
+
jest.mock('../auth/AuthUtils', () => ({
|
|
21
|
+
authenticateRegularRequest: jest.fn().mockResolvedValue(undefined),
|
|
22
|
+
authenticateInternalRequest: jest.fn().mockResolvedValue(undefined),
|
|
23
|
+
}));
|
|
24
|
+
|
|
17
25
|
jest.mock('@zaiusinc/app-sdk', () => ({
|
|
18
26
|
Function: class {
|
|
19
27
|
protected request: any;
|
|
@@ -257,172 +265,16 @@ describe('ToolFunction', () => {
|
|
|
257
265
|
});
|
|
258
266
|
|
|
259
267
|
describe('perform', () => {
|
|
260
|
-
it('should
|
|
261
|
-
// Setup mock
|
|
262
|
-
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
268
|
+
it('should call processRequest and return its result', async () => {
|
|
269
|
+
// Setup mock to return a response
|
|
263
270
|
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
264
271
|
|
|
265
272
|
const result = await toolFunction.perform();
|
|
266
273
|
|
|
267
274
|
expect(result).toBe(mockResponse);
|
|
268
|
-
|
|
269
|
-
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
270
|
-
expect(mockGetAppContext).toHaveBeenCalled();
|
|
275
|
+
// processRequest is called with request and context (no authValidator)
|
|
271
276
|
expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, toolFunction);
|
|
272
277
|
});
|
|
273
|
-
|
|
274
|
-
it('should return 403 response with invalid token', async () => {
|
|
275
|
-
// Setup mock token verifier to return false
|
|
276
|
-
mockTokenVerifier.verify.mockResolvedValue(false);
|
|
277
|
-
|
|
278
|
-
const result = await toolFunction.perform();
|
|
279
|
-
|
|
280
|
-
expect(result.status).toBe(403);
|
|
281
|
-
expect(result.bodyJSON).toEqual({
|
|
282
|
-
title: 'Forbidden',
|
|
283
|
-
status: 403,
|
|
284
|
-
detail: 'Invalid OptiID access token',
|
|
285
|
-
instance: '/test'
|
|
286
|
-
});
|
|
287
|
-
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
288
|
-
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
289
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('should return 403 response when organization ID does not match', async () => {
|
|
293
|
-
// Update mock request with different customer_id
|
|
294
|
-
const requestWithDifferentOrgId = {
|
|
295
|
-
...mockRequest,
|
|
296
|
-
bodyJSON: {
|
|
297
|
-
...mockRequest.bodyJSON,
|
|
298
|
-
auth: {
|
|
299
|
-
...mockRequest.bodyJSON.auth,
|
|
300
|
-
credentials: {
|
|
301
|
-
...mockRequest.bodyJSON.auth.credentials,
|
|
302
|
-
customer_id: 'different-org-123'
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
const toolFunctionWithDifferentOrgId = new TestToolFunction(requestWithDifferentOrgId);
|
|
309
|
-
|
|
310
|
-
const result = await toolFunctionWithDifferentOrgId.perform();
|
|
311
|
-
|
|
312
|
-
expect(result.status).toBe(403);
|
|
313
|
-
expect(result.bodyJSON).toEqual({
|
|
314
|
-
title: 'Forbidden',
|
|
315
|
-
status: 403,
|
|
316
|
-
detail: 'Organization ID does not match',
|
|
317
|
-
instance: '/test'
|
|
318
|
-
});
|
|
319
|
-
expect(mockGetAppContext).toHaveBeenCalled();
|
|
320
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('should return 403 response when access token is missing', async () => {
|
|
324
|
-
// Create request without access token
|
|
325
|
-
const requestWithoutToken = {
|
|
326
|
-
...mockRequest,
|
|
327
|
-
bodyJSON: {
|
|
328
|
-
...mockRequest.bodyJSON,
|
|
329
|
-
auth: {
|
|
330
|
-
...mockRequest.bodyJSON.auth,
|
|
331
|
-
credentials: {
|
|
332
|
-
...mockRequest.bodyJSON.auth.credentials,
|
|
333
|
-
access_token: undefined
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
const toolFunctionWithoutToken = new TestToolFunction(requestWithoutToken);
|
|
340
|
-
|
|
341
|
-
const result = await toolFunctionWithoutToken.perform();
|
|
342
|
-
|
|
343
|
-
expect(result.status).toBe(403);
|
|
344
|
-
expect(result.bodyJSON).toEqual({
|
|
345
|
-
title: 'Forbidden',
|
|
346
|
-
status: 403,
|
|
347
|
-
detail: 'OptiID access token is required',
|
|
348
|
-
instance: '/test'
|
|
349
|
-
});
|
|
350
|
-
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
351
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
it('should return 403 response when organisation id is missing', async () => {
|
|
355
|
-
// Create request without customer_id
|
|
356
|
-
const requestWithoutCustomerId = {
|
|
357
|
-
...mockRequest,
|
|
358
|
-
bodyJSON: {
|
|
359
|
-
...mockRequest.bodyJSON,
|
|
360
|
-
auth: {
|
|
361
|
-
...mockRequest.bodyJSON.auth,
|
|
362
|
-
credentials: {
|
|
363
|
-
...mockRequest.bodyJSON.auth.credentials,
|
|
364
|
-
customer_id: undefined
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
const toolFunctionWithoutCustomerId = new TestToolFunction(requestWithoutCustomerId);
|
|
371
|
-
|
|
372
|
-
const result = await toolFunctionWithoutCustomerId.perform();
|
|
373
|
-
|
|
374
|
-
expect(result.status).toBe(403);
|
|
375
|
-
expect(result.bodyJSON).toEqual({
|
|
376
|
-
title: 'Forbidden',
|
|
377
|
-
status: 403,
|
|
378
|
-
detail: 'Organization ID is required',
|
|
379
|
-
instance: '/test'
|
|
380
|
-
});
|
|
381
|
-
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
382
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
383
|
-
});
|
|
384
|
-
|
|
385
|
-
it('should return 403 response when auth structure is missing', async () => {
|
|
386
|
-
// Create request without auth structure
|
|
387
|
-
const requestWithoutAuth = {
|
|
388
|
-
...mockRequest,
|
|
389
|
-
bodyJSON: {
|
|
390
|
-
parameters: mockRequest.bodyJSON.parameters,
|
|
391
|
-
environment: mockRequest.bodyJSON.environment
|
|
392
|
-
}
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
const toolFunctionWithoutAuth = new TestToolFunction(requestWithoutAuth);
|
|
396
|
-
|
|
397
|
-
const result = await toolFunctionWithoutAuth.perform();
|
|
398
|
-
|
|
399
|
-
expect(result.status).toBe(403);
|
|
400
|
-
expect(result.bodyJSON).toEqual({
|
|
401
|
-
title: 'Forbidden',
|
|
402
|
-
status: 403,
|
|
403
|
-
detail: 'Authentication data is required',
|
|
404
|
-
instance: '/test'
|
|
405
|
-
});
|
|
406
|
-
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
407
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
it('should return 403 response when token verifier initialization fails', async () => {
|
|
411
|
-
// Setup mock to fail during token verifier initialization
|
|
412
|
-
mockGetTokenVerifier.mockRejectedValue(new Error('Failed to initialize token verifier'));
|
|
413
|
-
|
|
414
|
-
const result = await toolFunction.perform();
|
|
415
|
-
|
|
416
|
-
expect(result.status).toBe(403);
|
|
417
|
-
expect(result.bodyJSON).toEqual({
|
|
418
|
-
title: 'Forbidden',
|
|
419
|
-
status: 403,
|
|
420
|
-
detail: 'Token verification failed',
|
|
421
|
-
instance: '/test'
|
|
422
|
-
});
|
|
423
|
-
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
424
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
425
|
-
});
|
|
426
278
|
});
|
|
427
279
|
|
|
428
280
|
describe('inheritance', () => {
|
|
@@ -437,21 +289,18 @@ describe('ToolFunction', () => {
|
|
|
437
289
|
});
|
|
438
290
|
});
|
|
439
291
|
|
|
440
|
-
describe('
|
|
292
|
+
describe('system paths routing', () => {
|
|
441
293
|
beforeEach(() => {
|
|
442
|
-
// Reset mocks before each test
|
|
443
294
|
jest.clearAllMocks();
|
|
444
|
-
setupAuthMocks();
|
|
445
295
|
});
|
|
446
296
|
|
|
447
|
-
it('should
|
|
297
|
+
it('should forward /overrides to processRequest directly', async () => {
|
|
448
298
|
const overridesRequest = {
|
|
449
299
|
path: '/overrides',
|
|
450
300
|
method: 'DELETE',
|
|
451
301
|
bodyJSON: {},
|
|
452
302
|
headers: {
|
|
453
303
|
get: jest.fn().mockImplementation((name: string) => {
|
|
454
|
-
if (name === 'Authorization' || name === 'authorization') return 'internal-token';
|
|
455
304
|
if (name === 'x-opal-thread-id') return 'test-thread-id';
|
|
456
305
|
return null;
|
|
457
306
|
})
|
|
@@ -463,39 +312,14 @@ describe('ToolFunction', () => {
|
|
|
463
312
|
const result = await toolFunctionOverrides.perform();
|
|
464
313
|
|
|
465
314
|
expect(result).toBe(mockResponse);
|
|
315
|
+
// /overrides is forwarded directly to processRequest (auth handled there)
|
|
466
316
|
expect(mockProcessRequest).toHaveBeenCalledWith(overridesRequest, toolFunctionOverrides);
|
|
467
317
|
});
|
|
468
318
|
|
|
469
|
-
it('should
|
|
470
|
-
const
|
|
471
|
-
path: '/
|
|
472
|
-
method: '
|
|
473
|
-
bodyJSON: {},
|
|
474
|
-
headers: {
|
|
475
|
-
get: jest.fn().mockImplementation((name: string) => {
|
|
476
|
-
if (name === 'x-opal-thread-id') return 'test-thread-id';
|
|
477
|
-
return null; // No Authorization header
|
|
478
|
-
})
|
|
479
|
-
}
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
const toolFunctionOverrides = new TestToolFunction(overridesRequest);
|
|
483
|
-
const result = await toolFunctionOverrides.perform();
|
|
484
|
-
|
|
485
|
-
expect(result.status).toBe(401);
|
|
486
|
-
expect(result.bodyJSON).toEqual({
|
|
487
|
-
title: 'Unauthorized',
|
|
488
|
-
status: 401,
|
|
489
|
-
detail: 'Internal request authentication failed',
|
|
490
|
-
instance: '/overrides'
|
|
491
|
-
});
|
|
492
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it('should use regular authentication for non-overrides endpoints', async () => {
|
|
496
|
-
const regularRequest = {
|
|
497
|
-
...mockRequest,
|
|
498
|
-
path: '/regular-tool',
|
|
319
|
+
it('should forward /discovery to processRequest directly', async () => {
|
|
320
|
+
const discoveryRequest = {
|
|
321
|
+
path: '/discovery',
|
|
322
|
+
method: 'GET',
|
|
499
323
|
headers: {
|
|
500
324
|
get: jest.fn().mockImplementation((name: string) => {
|
|
501
325
|
if (name === 'x-opal-thread-id') return 'test-thread-id';
|
|
@@ -505,136 +329,101 @@ describe('ToolFunction', () => {
|
|
|
505
329
|
};
|
|
506
330
|
|
|
507
331
|
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
508
|
-
const
|
|
509
|
-
const result = await
|
|
332
|
+
const toolFunctionDiscovery = new TestToolFunction(discoveryRequest);
|
|
333
|
+
const result = await toolFunctionDiscovery.perform();
|
|
510
334
|
|
|
511
335
|
expect(result).toBe(mockResponse);
|
|
512
|
-
expect(mockProcessRequest).toHaveBeenCalledWith(
|
|
336
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(discoveryRequest, toolFunctionDiscovery);
|
|
513
337
|
});
|
|
514
338
|
|
|
515
|
-
it('should
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
bodyJSON: {
|
|
520
|
-
functions: [
|
|
521
|
-
{
|
|
522
|
-
name: 'test_tool',
|
|
523
|
-
description: 'Updated description'
|
|
524
|
-
}
|
|
525
|
-
]
|
|
526
|
-
},
|
|
339
|
+
it('should forward tool endpoints to processRequest', async () => {
|
|
340
|
+
const toolRequest = {
|
|
341
|
+
...mockRequest,
|
|
342
|
+
path: '/some-tool',
|
|
527
343
|
headers: {
|
|
528
344
|
get: jest.fn().mockImplementation((name: string) => {
|
|
529
|
-
if (name === 'Authorization') return 'valid-internal-token';
|
|
530
345
|
if (name === 'x-opal-thread-id') return 'test-thread-id';
|
|
531
346
|
return null;
|
|
532
347
|
})
|
|
533
348
|
}
|
|
534
349
|
};
|
|
535
350
|
|
|
351
|
+
// Tool does not require OptiID auth
|
|
352
|
+
(toolsService.requiresOptiIdAuth as jest.Mock).mockReturnValue(false);
|
|
536
353
|
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
537
|
-
const toolFunctionOverrides = new TestToolFunction(overridesRequest);
|
|
538
|
-
const result = await toolFunctionOverrides.perform();
|
|
539
354
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
it('should handle internal authentication with lowercase authorization header', async () => {
|
|
545
|
-
const overridesRequest = {
|
|
546
|
-
path: '/overrides',
|
|
547
|
-
method: 'PATCH',
|
|
548
|
-
bodyJSON: {
|
|
549
|
-
functions: [
|
|
550
|
-
{
|
|
551
|
-
name: 'test_tool',
|
|
552
|
-
description: 'Updated description'
|
|
553
|
-
}
|
|
554
|
-
]
|
|
555
|
-
},
|
|
556
|
-
headers: {
|
|
557
|
-
get: jest.fn().mockImplementation((name: string) => {
|
|
558
|
-
if (name === 'authorization') return 'valid-internal-token';
|
|
559
|
-
if (name === 'x-opal-thread-id') return 'test-thread-id';
|
|
560
|
-
return null;
|
|
561
|
-
})
|
|
562
|
-
}
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
566
|
-
const toolFunctionOverrides = new TestToolFunction(overridesRequest);
|
|
567
|
-
const result = await toolFunctionOverrides.perform();
|
|
355
|
+
const toolFunctionTest = new TestToolFunction(toolRequest);
|
|
356
|
+
const result = await toolFunctionTest.perform();
|
|
568
357
|
|
|
569
358
|
expect(result).toBe(mockResponse);
|
|
570
|
-
expect(mockProcessRequest).toHaveBeenCalledWith(
|
|
359
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(toolRequest, toolFunctionTest);
|
|
571
360
|
});
|
|
361
|
+
});
|
|
572
362
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
363
|
+
describe('error formatting', () => {
|
|
364
|
+
beforeEach(() => {
|
|
365
|
+
jest.clearAllMocks();
|
|
366
|
+
});
|
|
576
367
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
368
|
+
it('should format ToolError as RFC 9457 response when processRequest throws ToolError', async () => {
|
|
369
|
+
const toolRequest = {
|
|
370
|
+
...mockRequest,
|
|
371
|
+
path: '/some-tool',
|
|
581
372
|
headers: {
|
|
582
373
|
get: jest.fn().mockImplementation((name: string) => {
|
|
583
|
-
if (name === 'Authorization') return 'invalid-token';
|
|
584
374
|
if (name === 'x-opal-thread-id') return 'test-thread-id';
|
|
585
375
|
return null;
|
|
586
376
|
})
|
|
587
377
|
}
|
|
588
378
|
};
|
|
589
379
|
|
|
590
|
-
const
|
|
591
|
-
|
|
380
|
+
const toolError = new ToolError('Resource not found', 404, 'The requested resource does not exist');
|
|
381
|
+
mockProcessRequest.mockRejectedValue(toolError);
|
|
382
|
+
(toolsService.requiresOptiIdAuth as jest.Mock).mockReturnValue(false);
|
|
592
383
|
|
|
593
|
-
|
|
384
|
+
const toolFunctionTest = new TestToolFunction(toolRequest);
|
|
385
|
+
const result = await toolFunctionTest.perform();
|
|
386
|
+
|
|
387
|
+
expect(result.status).toBe(404);
|
|
594
388
|
expect(result.bodyJSON).toEqual({
|
|
595
|
-
title: '
|
|
596
|
-
status:
|
|
597
|
-
detail: '
|
|
598
|
-
instance: '/
|
|
389
|
+
title: 'Resource not found',
|
|
390
|
+
status: 404,
|
|
391
|
+
detail: 'The requested resource does not exist',
|
|
392
|
+
instance: '/some-tool'
|
|
599
393
|
});
|
|
600
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
601
394
|
});
|
|
602
395
|
|
|
603
|
-
it('should
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const overridesRequest = {
|
|
608
|
-
path: '/overrides',
|
|
609
|
-
method: 'DELETE',
|
|
610
|
-
bodyJSON: {},
|
|
396
|
+
it('should format generic Error as 500 RFC 9457 response when processRequest throws', async () => {
|
|
397
|
+
const toolRequest = {
|
|
398
|
+
...mockRequest,
|
|
399
|
+
path: '/some-tool',
|
|
611
400
|
headers: {
|
|
612
401
|
get: jest.fn().mockImplementation((name: string) => {
|
|
613
|
-
if (name === 'Authorization') return 'some-token';
|
|
614
402
|
if (name === 'x-opal-thread-id') return 'test-thread-id';
|
|
615
403
|
return null;
|
|
616
404
|
})
|
|
617
405
|
}
|
|
618
406
|
};
|
|
619
407
|
|
|
620
|
-
|
|
621
|
-
|
|
408
|
+
mockProcessRequest.mockRejectedValue(new Error('Database connection failed'));
|
|
409
|
+
(toolsService.requiresOptiIdAuth as jest.Mock).mockReturnValue(false);
|
|
410
|
+
|
|
411
|
+
const toolFunctionTest = new TestToolFunction(toolRequest);
|
|
412
|
+
const result = await toolFunctionTest.perform();
|
|
622
413
|
|
|
623
|
-
expect(result.status).toBe(
|
|
414
|
+
expect(result.status).toBe(500);
|
|
624
415
|
expect(result.bodyJSON).toEqual({
|
|
625
|
-
title: '
|
|
626
|
-
status:
|
|
627
|
-
detail: '
|
|
628
|
-
instance: '/
|
|
416
|
+
title: 'Internal Server Error',
|
|
417
|
+
status: 500,
|
|
418
|
+
detail: 'Database connection failed',
|
|
419
|
+
instance: '/some-tool'
|
|
629
420
|
});
|
|
630
|
-
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
631
421
|
});
|
|
632
422
|
|
|
633
|
-
it('should
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
bodyJSON: {},
|
|
423
|
+
it('should format ToolError from authorization failure as RFC 9457 response', async () => {
|
|
424
|
+
const toolRequest = {
|
|
425
|
+
...mockRequest,
|
|
426
|
+
path: '/some-tool',
|
|
638
427
|
headers: {
|
|
639
428
|
get: jest.fn().mockImplementation((name: string) => {
|
|
640
429
|
if (name === 'x-opal-thread-id') return 'test-thread-id';
|
|
@@ -643,15 +432,19 @@ describe('ToolFunction', () => {
|
|
|
643
432
|
}
|
|
644
433
|
};
|
|
645
434
|
|
|
646
|
-
const
|
|
647
|
-
|
|
435
|
+
const authError = new ToolError('Unauthorized', 403, 'Invalid access token');
|
|
436
|
+
(toolsService.requiresOptiIdAuth as jest.Mock).mockReturnValue(true);
|
|
437
|
+
jest.mocked(authenticateRegularRequest).mockRejectedValue(authError);
|
|
438
|
+
|
|
439
|
+
const toolFunctionTest = new TestToolFunction(toolRequest);
|
|
440
|
+
const result = await toolFunctionTest.perform();
|
|
648
441
|
|
|
649
|
-
expect(result.status).toBe(
|
|
442
|
+
expect(result.status).toBe(403);
|
|
650
443
|
expect(result.bodyJSON).toEqual({
|
|
651
444
|
title: 'Unauthorized',
|
|
652
|
-
status:
|
|
653
|
-
detail: '
|
|
654
|
-
instance: '/
|
|
445
|
+
status: 403,
|
|
446
|
+
detail: 'Invalid access token',
|
|
447
|
+
instance: '/some-tool'
|
|
655
448
|
});
|
|
656
449
|
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
657
450
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { Function, Response,
|
|
1
|
+
import { Function, Response, amendLogContext } from '@zaiusinc/app-sdk';
|
|
2
2
|
import { authenticateRegularRequest, authenticateInternalRequest } from '../auth/AuthUtils';
|
|
3
3
|
import { toolsService } from '../service/Service';
|
|
4
4
|
import { ToolLogger } from '../logging/ToolLogger';
|
|
5
|
-
import { ToolError } from '../types/ToolError';
|
|
6
5
|
import { ReadyResponse } from '../types/Models';
|
|
6
|
+
import { formatErrorResponse } from '../utils/ErrorFormatter';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Abstract base class for tool-based function execution
|
|
@@ -44,29 +44,6 @@ export abstract class ToolFunction extends Function {
|
|
|
44
44
|
* @returns Response as the HTTP response
|
|
45
45
|
*/
|
|
46
46
|
private async handleRequest(): Promise<Response> {
|
|
47
|
-
try {
|
|
48
|
-
await this.authorizeRequest();
|
|
49
|
-
} catch (error) {
|
|
50
|
-
if (error instanceof ToolError) {
|
|
51
|
-
return new Response(
|
|
52
|
-
error.status,
|
|
53
|
-
error.toProblemDetails(this.request.path),
|
|
54
|
-
new Headers([['content-type', 'application/problem+json']])
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
// Fallback for unexpected errors
|
|
58
|
-
return new Response(
|
|
59
|
-
500,
|
|
60
|
-
{
|
|
61
|
-
title: 'Internal Server Error',
|
|
62
|
-
status: 500,
|
|
63
|
-
detail: 'An unexpected error occurred during authentication',
|
|
64
|
-
instance: this.request.path
|
|
65
|
-
},
|
|
66
|
-
new Headers([['content-type', 'application/problem+json']])
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
47
|
if (this.request.path === '/ready') {
|
|
71
48
|
const readyResult = await this.ready();
|
|
72
49
|
const readyResponse = typeof readyResult === 'boolean'
|
|
@@ -75,21 +52,38 @@ export abstract class ToolFunction extends Function {
|
|
|
75
52
|
return new Response(200, readyResponse);
|
|
76
53
|
}
|
|
77
54
|
|
|
78
|
-
|
|
79
|
-
|
|
55
|
+
try {
|
|
56
|
+
await this.authorizeRequest();
|
|
57
|
+
|
|
58
|
+
// Pass 'this' as context so decorated methods can use the existing instance
|
|
59
|
+
return await toolsService.processRequest(this.request, this);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return formatErrorResponse(error, this.request.path);
|
|
62
|
+
}
|
|
80
63
|
}
|
|
81
64
|
|
|
82
65
|
/**
|
|
83
|
-
* Authenticate the incoming request
|
|
66
|
+
* Authenticate the incoming request based on the endpoint
|
|
67
|
+
* - /discovery: No auth required
|
|
68
|
+
* - /overrides: Internal auth (header-based token)
|
|
69
|
+
* - Tools/interactions: Regular auth if OptiID is required
|
|
84
70
|
*
|
|
85
71
|
* @throws {ToolError} If authentication fails
|
|
86
72
|
*/
|
|
87
73
|
private async authorizeRequest(): Promise<void> {
|
|
74
|
+
// Discovery endpoint doesn't require auth
|
|
75
|
+
if (this.request.path === '/discovery') {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
88
79
|
// Use internal authentication for overrides endpoint (header-based token)
|
|
89
80
|
if (this.request.path === '/overrides') {
|
|
90
81
|
await authenticateInternalRequest(this.request);
|
|
91
|
-
|
|
92
|
-
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Tool/interaction endpoints - authenticate only if OptiID is required
|
|
86
|
+
if (toolsService.requiresOptiIdAuth(this.request.path)) {
|
|
93
87
|
await authenticateRegularRequest(this.request);
|
|
94
88
|
}
|
|
95
89
|
}
|