@optimizely-opal/opal-tool-ocp-sdk 0.0.0-OCP-1487.1
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 +631 -0
- package/dist/auth/AuthUtils.d.ts +31 -0
- package/dist/auth/AuthUtils.d.ts.map +1 -0
- package/dist/auth/AuthUtils.js +64 -0
- package/dist/auth/AuthUtils.js.map +1 -0
- package/dist/auth/AuthUtils.test.d.ts +2 -0
- package/dist/auth/AuthUtils.test.d.ts.map +1 -0
- package/dist/auth/AuthUtils.test.js +469 -0
- package/dist/auth/AuthUtils.test.js.map +1 -0
- package/dist/auth/TokenVerifier.d.ts +31 -0
- package/dist/auth/TokenVerifier.d.ts.map +1 -0
- package/dist/auth/TokenVerifier.js +127 -0
- package/dist/auth/TokenVerifier.js.map +1 -0
- package/dist/auth/TokenVerifier.test.d.ts +2 -0
- package/dist/auth/TokenVerifier.test.d.ts.map +1 -0
- package/dist/auth/TokenVerifier.test.js +125 -0
- package/dist/auth/TokenVerifier.test.js.map +1 -0
- package/dist/decorator/Decorator.d.ts +48 -0
- package/dist/decorator/Decorator.d.ts.map +1 -0
- package/dist/decorator/Decorator.js +53 -0
- package/dist/decorator/Decorator.js.map +1 -0
- package/dist/decorator/Decorator.test.d.ts +2 -0
- package/dist/decorator/Decorator.test.d.ts.map +1 -0
- package/dist/decorator/Decorator.test.js +528 -0
- package/dist/decorator/Decorator.test.js.map +1 -0
- package/dist/function/GlobalToolFunction.d.ts +28 -0
- package/dist/function/GlobalToolFunction.d.ts.map +1 -0
- package/dist/function/GlobalToolFunction.js +56 -0
- package/dist/function/GlobalToolFunction.js.map +1 -0
- package/dist/function/GlobalToolFunction.test.d.ts +2 -0
- package/dist/function/GlobalToolFunction.test.d.ts.map +1 -0
- package/dist/function/GlobalToolFunction.test.js +425 -0
- package/dist/function/GlobalToolFunction.test.js.map +1 -0
- package/dist/function/ToolFunction.d.ts +28 -0
- package/dist/function/ToolFunction.d.ts.map +1 -0
- package/dist/function/ToolFunction.js +60 -0
- package/dist/function/ToolFunction.js.map +1 -0
- package/dist/function/ToolFunction.test.d.ts +2 -0
- package/dist/function/ToolFunction.test.d.ts.map +1 -0
- package/dist/function/ToolFunction.test.js +314 -0
- package/dist/function/ToolFunction.test.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/service/Service.d.ts +80 -0
- package/dist/service/Service.d.ts.map +1 -0
- package/dist/service/Service.js +210 -0
- package/dist/service/Service.js.map +1 -0
- package/dist/service/Service.test.d.ts +2 -0
- package/dist/service/Service.test.d.ts.map +1 -0
- package/dist/service/Service.test.js +427 -0
- package/dist/service/Service.test.js.map +1 -0
- package/dist/types/Models.d.ts +126 -0
- package/dist/types/Models.d.ts.map +1 -0
- package/dist/types/Models.js +181 -0
- package/dist/types/Models.js.map +1 -0
- package/package.json +64 -0
- package/src/auth/AuthUtils.test.ts +586 -0
- package/src/auth/AuthUtils.ts +66 -0
- package/src/auth/TokenVerifier.test.ts +165 -0
- package/src/auth/TokenVerifier.ts +145 -0
- package/src/decorator/Decorator.test.ts +649 -0
- package/src/decorator/Decorator.ts +111 -0
- package/src/function/GlobalToolFunction.test.ts +505 -0
- package/src/function/GlobalToolFunction.ts +61 -0
- package/src/function/ToolFunction.test.ts +374 -0
- package/src/function/ToolFunction.ts +64 -0
- package/src/index.ts +5 -0
- package/src/service/Service.test.ts +661 -0
- package/src/service/Service.ts +213 -0
- package/src/types/Models.ts +163 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { Parameter, AuthRequirement, ParameterType } from '../types/Models';
|
|
2
|
+
import { toolsService } from '../service/Service';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration for @tool decorator
|
|
6
|
+
*/
|
|
7
|
+
export interface ToolConfig {
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
parameters: ParameterConfig[];
|
|
11
|
+
authRequirements?: AuthRequirementConfig[];
|
|
12
|
+
endpoint: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parameter configuration for decorators
|
|
17
|
+
*/
|
|
18
|
+
export interface ParameterConfig {
|
|
19
|
+
name: string;
|
|
20
|
+
type: ParameterType;
|
|
21
|
+
description: string;
|
|
22
|
+
required: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* AuthRequirement configuration for decorators
|
|
27
|
+
*/
|
|
28
|
+
export interface AuthRequirementConfig {
|
|
29
|
+
provider: string;
|
|
30
|
+
scopeBundle: string;
|
|
31
|
+
required?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Configuration for @interaction decorator
|
|
36
|
+
*/
|
|
37
|
+
export interface InteractionConfig {
|
|
38
|
+
name: string;
|
|
39
|
+
endpoint: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decorator for registering tool functions
|
|
44
|
+
* Immediately registers the tool with the global ToolsService
|
|
45
|
+
* The handler will have access to 'this' context when called
|
|
46
|
+
*/
|
|
47
|
+
export function tool(config: ToolConfig) {
|
|
48
|
+
return function(target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
|
|
49
|
+
// Convert parameter configs to Parameter instances
|
|
50
|
+
const parameters = (config.parameters || []).map((p) =>
|
|
51
|
+
new Parameter(p.name, p.type, p.description, p.required)
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Convert auth requirement configs to AuthRequirement instances
|
|
55
|
+
const authRequirements = (config.authRequirements || []).map((a) =>
|
|
56
|
+
new AuthRequirement(a.provider, a.scopeBundle, a.required)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const originalMethod = descriptor.value;
|
|
60
|
+
|
|
61
|
+
const boundHandler = function(functionContext: any, params: any, authData: any) {
|
|
62
|
+
// Check if we're being called from within a ToolFunction instance context
|
|
63
|
+
// If so, use that instance; otherwise create a new one
|
|
64
|
+
const instance = (functionContext && functionContext instanceof target.constructor) ?
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
66
|
+
functionContext : new target.constructor();
|
|
67
|
+
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
69
|
+
return originalMethod.call(instance, params, authData);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Immediately register with global ToolsService
|
|
73
|
+
toolsService.registerTool(
|
|
74
|
+
config.name,
|
|
75
|
+
config.description,
|
|
76
|
+
boundHandler,
|
|
77
|
+
parameters,
|
|
78
|
+
config.endpoint,
|
|
79
|
+
authRequirements
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Decorator for registering interaction functions
|
|
86
|
+
* Immediately registers the interaction with the global ToolsService
|
|
87
|
+
* The handler will have access to 'this' context when called
|
|
88
|
+
*/
|
|
89
|
+
export function interaction(config: InteractionConfig) {
|
|
90
|
+
return function(target: any, _propertyKey: string, descriptor: PropertyDescriptor) {
|
|
91
|
+
const originalMethod = descriptor.value;
|
|
92
|
+
|
|
93
|
+
const boundHandler = function(functionContext: any, data: any, authData?: any) {
|
|
94
|
+
// Check if we're being called from within a ToolFunction instance context
|
|
95
|
+
// If so, use that instance; otherwise create a new one
|
|
96
|
+
const instance = (functionContext && functionContext instanceof target.constructor) ?
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
98
|
+
functionContext : new target.constructor();
|
|
99
|
+
|
|
100
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
101
|
+
return originalMethod.call(instance, data, authData);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Immediately register with global ToolsService
|
|
105
|
+
toolsService.registerInteraction(
|
|
106
|
+
config.name,
|
|
107
|
+
boundHandler,
|
|
108
|
+
config.endpoint
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
import { GlobalToolFunction } from './GlobalToolFunction';
|
|
2
|
+
import { toolsService } from '../service/Service';
|
|
3
|
+
import { Response, getAppContext } from '@zaiusinc/app-sdk';
|
|
4
|
+
import { getTokenVerifier } from '../auth/TokenVerifier';
|
|
5
|
+
|
|
6
|
+
// Mock the dependencies
|
|
7
|
+
jest.mock('../service/Service', () => ({
|
|
8
|
+
toolsService: {
|
|
9
|
+
processRequest: jest.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
jest.mock('../auth/TokenVerifier', () => ({
|
|
14
|
+
getTokenVerifier: jest.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
jest.mock('@zaiusinc/app-sdk', () => ({
|
|
18
|
+
GlobalFunction: class {
|
|
19
|
+
protected request: any;
|
|
20
|
+
public constructor(_name?: string) {
|
|
21
|
+
this.request = {};
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
Request: jest.fn().mockImplementation(() => ({})),
|
|
25
|
+
Response: jest.fn().mockImplementation((status, data) => ({
|
|
26
|
+
status,
|
|
27
|
+
data,
|
|
28
|
+
bodyJSON: data,
|
|
29
|
+
bodyAsU8Array: new Uint8Array()
|
|
30
|
+
})),
|
|
31
|
+
amendLogContext: jest.fn(),
|
|
32
|
+
getAppContext: jest.fn(),
|
|
33
|
+
logger: {
|
|
34
|
+
info: jest.fn(),
|
|
35
|
+
error: jest.fn(),
|
|
36
|
+
warn: jest.fn(),
|
|
37
|
+
debug: jest.fn(),
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Create a concrete implementation for testing
|
|
42
|
+
class TestGlobalToolFunction extends GlobalToolFunction {
|
|
43
|
+
private mockReady: jest.MockedFunction<() => Promise<boolean>>;
|
|
44
|
+
|
|
45
|
+
public constructor(request?: any) {
|
|
46
|
+
super(request || {});
|
|
47
|
+
(this as any).request = request;
|
|
48
|
+
this.mockReady = jest.fn().mockResolvedValue(true);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Override the ready method with mock implementation for testing
|
|
52
|
+
protected ready(): Promise<boolean> {
|
|
53
|
+
return this.mockReady();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
public getRequest() {
|
|
57
|
+
return (this as any).request;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public getMockReady() {
|
|
61
|
+
return this.mockReady;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('GlobalToolFunction', () => {
|
|
66
|
+
let mockRequest: any;
|
|
67
|
+
let mockResponse: Response;
|
|
68
|
+
let globalToolFunction: TestGlobalToolFunction;
|
|
69
|
+
let mockProcessRequest: jest.MockedFunction<typeof toolsService.processRequest>;
|
|
70
|
+
let mockGetTokenVerifier: jest.MockedFunction<typeof getTokenVerifier>;
|
|
71
|
+
let mockGetAppContext: jest.MockedFunction<typeof getAppContext>;
|
|
72
|
+
let mockTokenVerifier: jest.Mocked<{
|
|
73
|
+
verify: (token: string) => Promise<any>;
|
|
74
|
+
}>;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
jest.clearAllMocks();
|
|
78
|
+
|
|
79
|
+
// Create mock token verifier
|
|
80
|
+
mockTokenVerifier = {
|
|
81
|
+
verify: jest.fn(),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Setup the mocks
|
|
85
|
+
mockProcessRequest = jest.mocked(toolsService.processRequest);
|
|
86
|
+
mockGetTokenVerifier = jest.mocked(getTokenVerifier);
|
|
87
|
+
mockGetAppContext = jest.mocked(getAppContext);
|
|
88
|
+
|
|
89
|
+
mockGetTokenVerifier.mockResolvedValue(mockTokenVerifier as any);
|
|
90
|
+
mockGetAppContext.mockReturnValue({
|
|
91
|
+
account: {
|
|
92
|
+
organizationId: 'app-org-123'
|
|
93
|
+
}
|
|
94
|
+
} as any);
|
|
95
|
+
|
|
96
|
+
// Create mock request with bodyJSON structure
|
|
97
|
+
// Note: Global functions don't validate customer_id, so it can be different or missing
|
|
98
|
+
mockRequest = {
|
|
99
|
+
headers: new Map(),
|
|
100
|
+
method: 'POST',
|
|
101
|
+
path: '/test',
|
|
102
|
+
bodyJSON: {
|
|
103
|
+
parameters: {
|
|
104
|
+
task_id: 'task-123',
|
|
105
|
+
content_id: 'content-456'
|
|
106
|
+
},
|
|
107
|
+
auth: {
|
|
108
|
+
provider: 'OptiID',
|
|
109
|
+
credentials: {
|
|
110
|
+
token_type: 'Bearer',
|
|
111
|
+
access_token: 'valid-access-token',
|
|
112
|
+
org_sso_id: 'org-sso-123',
|
|
113
|
+
user_id: 'user-456',
|
|
114
|
+
instance_id: 'instance-789',
|
|
115
|
+
customer_id: 'any-org-123', // Can be different from app org for global functions
|
|
116
|
+
product_sku: 'OPAL'
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
environment: {
|
|
120
|
+
execution_mode: 'headless'
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
mockResponse = {} as Response;
|
|
126
|
+
globalToolFunction = new TestGlobalToolFunction(mockRequest);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Helper function to create a ready request with valid auth
|
|
130
|
+
const createReadyRequestWithAuth = () => ({
|
|
131
|
+
headers: new Map(),
|
|
132
|
+
method: 'GET',
|
|
133
|
+
path: '/ready',
|
|
134
|
+
bodyJSON: {
|
|
135
|
+
auth: {
|
|
136
|
+
provider: 'OptiID',
|
|
137
|
+
credentials: {
|
|
138
|
+
access_token: 'valid-token',
|
|
139
|
+
customer_id: 'any-org-123' // Can be any org for global functions
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Helper function to create a discovery request
|
|
146
|
+
const createDiscoveryRequest = () => ({
|
|
147
|
+
headers: new Map(),
|
|
148
|
+
method: 'GET',
|
|
149
|
+
path: '/discovery'
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Helper function to setup authorization mocks to pass
|
|
153
|
+
const setupAuthMocks = () => {
|
|
154
|
+
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
describe('/discovery endpoint', () => {
|
|
158
|
+
it('should allow discovery endpoint without authentication', async () => {
|
|
159
|
+
// Arrange
|
|
160
|
+
const discoveryRequest = createDiscoveryRequest();
|
|
161
|
+
globalToolFunction = new TestGlobalToolFunction(discoveryRequest);
|
|
162
|
+
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
163
|
+
|
|
164
|
+
// Act
|
|
165
|
+
const result = await globalToolFunction.perform();
|
|
166
|
+
|
|
167
|
+
// Assert
|
|
168
|
+
expect(result).toBe(mockResponse);
|
|
169
|
+
expect(mockGetTokenVerifier).not.toHaveBeenCalled(); // Should not verify token for discovery
|
|
170
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(discoveryRequest, globalToolFunction);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('/ready endpoint', () => {
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
setupAuthMocks();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should return ready: true when ready method returns true', async () => {
|
|
180
|
+
// Arrange
|
|
181
|
+
const readyRequest = createReadyRequestWithAuth();
|
|
182
|
+
globalToolFunction = new TestGlobalToolFunction(readyRequest);
|
|
183
|
+
globalToolFunction.getMockReady().mockResolvedValue(true);
|
|
184
|
+
|
|
185
|
+
// Act
|
|
186
|
+
const result = await globalToolFunction.perform();
|
|
187
|
+
|
|
188
|
+
// Assert
|
|
189
|
+
expect(globalToolFunction.getMockReady()).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(result).toEqual(new Response(200, { ready: true }));
|
|
191
|
+
expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should return ready: false when ready method returns false', async () => {
|
|
195
|
+
// Arrange
|
|
196
|
+
const readyRequest = createReadyRequestWithAuth();
|
|
197
|
+
globalToolFunction = new TestGlobalToolFunction(readyRequest);
|
|
198
|
+
globalToolFunction.getMockReady().mockResolvedValue(false);
|
|
199
|
+
|
|
200
|
+
// Act
|
|
201
|
+
const result = await globalToolFunction.perform();
|
|
202
|
+
|
|
203
|
+
// Assert
|
|
204
|
+
expect(globalToolFunction.getMockReady()).toHaveBeenCalledTimes(1);
|
|
205
|
+
expect(result).toEqual(new Response(200, { ready: false }));
|
|
206
|
+
expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should handle ready method throwing an error', async () => {
|
|
210
|
+
// Arrange
|
|
211
|
+
const readyRequest = createReadyRequestWithAuth();
|
|
212
|
+
globalToolFunction = new TestGlobalToolFunction(readyRequest);
|
|
213
|
+
globalToolFunction.getMockReady().mockRejectedValue(new Error('Ready check failed'));
|
|
214
|
+
|
|
215
|
+
// Act & Assert
|
|
216
|
+
await expect(globalToolFunction.perform()).rejects.toThrow('Ready check failed');
|
|
217
|
+
expect(globalToolFunction.getMockReady()).toHaveBeenCalledTimes(1);
|
|
218
|
+
expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should use default ready implementation when not overridden', async () => {
|
|
222
|
+
// Create a class that doesn't override ready method
|
|
223
|
+
class DefaultReadyGlobalToolFunction extends GlobalToolFunction {
|
|
224
|
+
public constructor(request?: any) {
|
|
225
|
+
super(request || {});
|
|
226
|
+
(this as any).request = request;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
public getRequest() {
|
|
230
|
+
return (this as any).request;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Arrange
|
|
235
|
+
const readyRequest = createReadyRequestWithAuth();
|
|
236
|
+
const defaultGlobalToolFunction = new DefaultReadyGlobalToolFunction(readyRequest);
|
|
237
|
+
|
|
238
|
+
// Act
|
|
239
|
+
const result = await defaultGlobalToolFunction.perform();
|
|
240
|
+
|
|
241
|
+
// Assert - Default implementation should return true
|
|
242
|
+
expect(result).toEqual(new Response(200, { ready: true }));
|
|
243
|
+
expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should allow ready endpoint without authentication', async () => {
|
|
247
|
+
// Arrange
|
|
248
|
+
const readyRequestWithoutAuth = {
|
|
249
|
+
headers: new Map(),
|
|
250
|
+
method: 'GET',
|
|
251
|
+
path: '/ready'
|
|
252
|
+
};
|
|
253
|
+
globalToolFunction = new TestGlobalToolFunction(readyRequestWithoutAuth);
|
|
254
|
+
globalToolFunction.getMockReady().mockResolvedValue(true);
|
|
255
|
+
|
|
256
|
+
// Act
|
|
257
|
+
const result = await globalToolFunction.perform();
|
|
258
|
+
|
|
259
|
+
// Assert
|
|
260
|
+
expect(result).toEqual(new Response(200, { ready: true }));
|
|
261
|
+
expect(mockGetTokenVerifier).not.toHaveBeenCalled(); // Should not verify token for ready
|
|
262
|
+
expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('perform', () => {
|
|
267
|
+
it('should execute successfully with valid token (no organization validation)', async () => {
|
|
268
|
+
// Setup mock token verifier to return true for valid token
|
|
269
|
+
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
270
|
+
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
271
|
+
|
|
272
|
+
const result = await globalToolFunction.perform();
|
|
273
|
+
|
|
274
|
+
expect(result).toBe(mockResponse);
|
|
275
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
276
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
277
|
+
// Note: getAppContext should NOT be called for global functions
|
|
278
|
+
expect(mockGetAppContext).not.toHaveBeenCalled();
|
|
279
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, globalToolFunction);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should execute successfully even with different organization ID', async () => {
|
|
283
|
+
// Update mock request with different customer_id (should still work for global functions)
|
|
284
|
+
const requestWithDifferentOrgId = {
|
|
285
|
+
...mockRequest,
|
|
286
|
+
bodyJSON: {
|
|
287
|
+
...mockRequest.bodyJSON,
|
|
288
|
+
auth: {
|
|
289
|
+
...mockRequest.bodyJSON.auth,
|
|
290
|
+
credentials: {
|
|
291
|
+
...mockRequest.bodyJSON.auth.credentials,
|
|
292
|
+
customer_id: 'completely-different-org-456'
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const globalToolFunctionWithDifferentOrgId = new TestGlobalToolFunction(requestWithDifferentOrgId);
|
|
299
|
+
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
300
|
+
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
301
|
+
|
|
302
|
+
const result = await globalToolFunctionWithDifferentOrgId.perform();
|
|
303
|
+
|
|
304
|
+
expect(result).toBe(mockResponse);
|
|
305
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
306
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
307
|
+
// Note: Should NOT validate organization ID for global functions
|
|
308
|
+
expect(mockGetAppContext).not.toHaveBeenCalled();
|
|
309
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(requestWithDifferentOrgId, globalToolFunctionWithDifferentOrgId);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should execute successfully even without customer_id', async () => {
|
|
313
|
+
// Create request without customer_id (should still work for global functions)
|
|
314
|
+
const requestWithoutCustomerId = {
|
|
315
|
+
...mockRequest,
|
|
316
|
+
bodyJSON: {
|
|
317
|
+
...mockRequest.bodyJSON,
|
|
318
|
+
auth: {
|
|
319
|
+
...mockRequest.bodyJSON.auth,
|
|
320
|
+
credentials: {
|
|
321
|
+
...mockRequest.bodyJSON.auth.credentials,
|
|
322
|
+
customer_id: undefined
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const globalToolFunctionWithoutCustomerId = new TestGlobalToolFunction(requestWithoutCustomerId);
|
|
329
|
+
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
330
|
+
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
331
|
+
|
|
332
|
+
const result = await globalToolFunctionWithoutCustomerId.perform();
|
|
333
|
+
|
|
334
|
+
expect(result).toBe(mockResponse);
|
|
335
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
336
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
337
|
+
expect(mockGetAppContext).not.toHaveBeenCalled();
|
|
338
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(requestWithoutCustomerId, globalToolFunctionWithoutCustomerId);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should return 403 response with invalid token', async () => {
|
|
342
|
+
// Setup mock token verifier to return false
|
|
343
|
+
mockTokenVerifier.verify.mockResolvedValue(false);
|
|
344
|
+
|
|
345
|
+
const result = await globalToolFunction.perform();
|
|
346
|
+
|
|
347
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
348
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
349
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
350
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should return 403 response when access token is missing', async () => {
|
|
354
|
+
// Create request without access token
|
|
355
|
+
const requestWithoutToken = {
|
|
356
|
+
...mockRequest,
|
|
357
|
+
bodyJSON: {
|
|
358
|
+
...mockRequest.bodyJSON,
|
|
359
|
+
auth: {
|
|
360
|
+
...mockRequest.bodyJSON.auth,
|
|
361
|
+
credentials: {
|
|
362
|
+
...mockRequest.bodyJSON.auth.credentials,
|
|
363
|
+
access_token: undefined
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const globalToolFunctionWithoutToken = new TestGlobalToolFunction(requestWithoutToken);
|
|
370
|
+
|
|
371
|
+
const result = await globalToolFunctionWithoutToken.perform();
|
|
372
|
+
|
|
373
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
374
|
+
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
375
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('should return 403 response when provider is not OptiID', async () => {
|
|
379
|
+
// Create request with different provider
|
|
380
|
+
const requestWithDifferentProvider = {
|
|
381
|
+
...mockRequest,
|
|
382
|
+
bodyJSON: {
|
|
383
|
+
...mockRequest.bodyJSON,
|
|
384
|
+
auth: {
|
|
385
|
+
...mockRequest.bodyJSON.auth,
|
|
386
|
+
provider: 'SomeOtherProvider'
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const globalToolFunctionWithDifferentProvider = new TestGlobalToolFunction(requestWithDifferentProvider);
|
|
392
|
+
|
|
393
|
+
const result = await globalToolFunctionWithDifferentProvider.perform();
|
|
394
|
+
|
|
395
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
396
|
+
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
397
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should return 403 response when auth structure is missing', async () => {
|
|
401
|
+
// Create request without auth structure
|
|
402
|
+
const requestWithoutAuth = {
|
|
403
|
+
...mockRequest,
|
|
404
|
+
bodyJSON: {
|
|
405
|
+
parameters: mockRequest.bodyJSON.parameters,
|
|
406
|
+
environment: mockRequest.bodyJSON.environment
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
const globalToolFunctionWithoutAuth = new TestGlobalToolFunction(requestWithoutAuth);
|
|
411
|
+
|
|
412
|
+
const result = await globalToolFunctionWithoutAuth.perform();
|
|
413
|
+
|
|
414
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
415
|
+
expect(mockGetTokenVerifier).not.toHaveBeenCalled();
|
|
416
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should return 403 response when token verifier initialization fails', async () => {
|
|
420
|
+
// Setup mock to fail during token verifier initialization
|
|
421
|
+
mockGetTokenVerifier.mockRejectedValue(new Error('Failed to initialize token verifier'));
|
|
422
|
+
|
|
423
|
+
const result = await globalToolFunction.perform();
|
|
424
|
+
|
|
425
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
426
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
427
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('should return 403 response when token validation throws an error', async () => {
|
|
431
|
+
// Setup mock token verifier to throw an error
|
|
432
|
+
mockTokenVerifier.verify.mockRejectedValue(new Error('Token validation failed'));
|
|
433
|
+
|
|
434
|
+
const result = await globalToolFunction.perform();
|
|
435
|
+
|
|
436
|
+
expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
|
|
437
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
438
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
439
|
+
expect(mockProcessRequest).not.toHaveBeenCalled();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe('inheritance', () => {
|
|
444
|
+
it('should be an instance of GlobalFunction', () => {
|
|
445
|
+
// Assert
|
|
446
|
+
expect(globalToolFunction).toBeInstanceOf(GlobalToolFunction);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should have access to the request property', () => {
|
|
450
|
+
// Assert
|
|
451
|
+
expect(globalToolFunction.getRequest()).toBe(mockRequest);
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
describe('authentication differences from ToolFunction', () => {
|
|
456
|
+
it('should NOT validate organization ID (unlike ToolFunction)', async () => {
|
|
457
|
+
// This test demonstrates the key difference between GlobalToolFunction and ToolFunction
|
|
458
|
+
// GlobalToolFunction should work with any customer_id, while ToolFunction requires matching org ID
|
|
459
|
+
|
|
460
|
+
const requestWithRandomOrgId = {
|
|
461
|
+
...mockRequest,
|
|
462
|
+
bodyJSON: {
|
|
463
|
+
...mockRequest.bodyJSON,
|
|
464
|
+
auth: {
|
|
465
|
+
...mockRequest.bodyJSON.auth,
|
|
466
|
+
credentials: {
|
|
467
|
+
...mockRequest.bodyJSON.auth.credentials,
|
|
468
|
+
customer_id: 'random-org-999' // This would fail in ToolFunction but should work here
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const globalToolFunctionWithRandomOrgId = new TestGlobalToolFunction(requestWithRandomOrgId);
|
|
475
|
+
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
476
|
+
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
477
|
+
|
|
478
|
+
const result = await globalToolFunctionWithRandomOrgId.perform();
|
|
479
|
+
|
|
480
|
+
// Should succeed even with different org ID
|
|
481
|
+
expect(result).toBe(mockResponse);
|
|
482
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
483
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
484
|
+
// Crucially: getAppContext should NOT be called (no org validation)
|
|
485
|
+
expect(mockGetAppContext).not.toHaveBeenCalled();
|
|
486
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(requestWithRandomOrgId, globalToolFunctionWithRandomOrgId);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should only require valid OptiID token (no organization constraints)', async () => {
|
|
490
|
+
// Test that only token validation is performed, no org validation
|
|
491
|
+
mockTokenVerifier.verify.mockResolvedValue(true);
|
|
492
|
+
mockProcessRequest.mockResolvedValue(mockResponse);
|
|
493
|
+
|
|
494
|
+
const result = await globalToolFunction.perform();
|
|
495
|
+
|
|
496
|
+
expect(result).toBe(mockResponse);
|
|
497
|
+
expect(mockGetTokenVerifier).toHaveBeenCalled();
|
|
498
|
+
expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
|
|
499
|
+
|
|
500
|
+
// Key assertion: getAppContext should never be called for global functions
|
|
501
|
+
expect(mockGetAppContext).not.toHaveBeenCalled();
|
|
502
|
+
expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, globalToolFunction);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { GlobalFunction, Response, amendLogContext } from '@zaiusinc/app-sdk';
|
|
2
|
+
import { AuthUtils } from '../auth/AuthUtils';
|
|
3
|
+
import { toolsService } from '../service/Service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Abstract base class for global tool-based function execution
|
|
7
|
+
* Provides a standard interface for processing requests through registered tools
|
|
8
|
+
*/
|
|
9
|
+
export abstract class GlobalToolFunction extends GlobalFunction {
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Override this method to implement any required credentials and/or other configuration
|
|
13
|
+
* exist and are valid. Reasonable caching should be utilized to prevent excessive requests to external resources.
|
|
14
|
+
* @async
|
|
15
|
+
* @returns true if the opal function is ready to use
|
|
16
|
+
*/
|
|
17
|
+
protected ready(): Promise<boolean> {
|
|
18
|
+
return Promise.resolve(true);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Process the incoming request using the tools service
|
|
23
|
+
*
|
|
24
|
+
* @returns Response as the HTTP response
|
|
25
|
+
*/
|
|
26
|
+
public async perform(): Promise<Response> {
|
|
27
|
+
amendLogContext({ opalThreadId: this.request.headers.get('x-opal-thread-id') || '' });
|
|
28
|
+
|
|
29
|
+
if (!(await this.authorizeRequest())) {
|
|
30
|
+
return new Response(403, { error: 'Forbidden' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (this.request.path === '/ready') {
|
|
34
|
+
const isReady = await this.ready();
|
|
35
|
+
return new Response(200, { ready: isReady });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return toolsService.processRequest(this.request, this);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Authenticate the incoming request by validating only the OptiID token
|
|
43
|
+
*
|
|
44
|
+
* @param request - The incoming request
|
|
45
|
+
* @returns true if authentication succeeds
|
|
46
|
+
*/
|
|
47
|
+
private async authorizeRequest(): Promise<boolean> {
|
|
48
|
+
if (this.request.path === '/discovery' || this.request.path === '/ready') {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const authInfo = AuthUtils.extractAuthData(this.request);
|
|
53
|
+
if (!authInfo) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { accessToken } = authInfo;
|
|
58
|
+
|
|
59
|
+
return await AuthUtils.validateAccessToken(accessToken);
|
|
60
|
+
}
|
|
61
|
+
}
|