@optimizely-opal/opal-tool-ocp-sdk 1.0.0-OCP-1441.4 → 1.0.0-OCP-1442.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.
@@ -1,6 +1,7 @@
1
1
  import { GlobalFunction, Response, amendLogContext } from '@zaiusinc/app-sdk';
2
2
  import { authenticateGlobalRequest, extractAuthData } from '../auth/AuthUtils';
3
3
  import { toolsService } from '../service/Service';
4
+ import { ToolLogger } from '../logging/ToolLogger';
4
5
 
5
6
  /**
6
7
  * Abstract base class for global tool-based function execution
@@ -24,6 +25,8 @@ export abstract class GlobalToolFunction extends GlobalFunction {
24
25
  * @returns Response as the HTTP response
25
26
  */
26
27
  public async perform(): Promise<Response> {
28
+ const startTime = Date.now();
29
+
27
30
  // Extract customer_id from auth data for global context attribution
28
31
  const authInfo = extractAuthData(this.request);
29
32
  const customerId = authInfo?.authData?.credentials?.customer_id;
@@ -33,6 +36,14 @@ export abstract class GlobalToolFunction extends GlobalFunction {
33
36
  customerId: customerId || ''
34
37
  });
35
38
 
39
+ ToolLogger.logRequest(this.request);
40
+
41
+ const response = await this.handleRequest();
42
+ ToolLogger.logResponse(this.request, response, startTime - Date.now());
43
+ return response;
44
+ }
45
+
46
+ private async handleRequest(): Promise<Response> {
36
47
  if (!(await this.authorizeRequest())) {
37
48
  return new Response(403, { error: 'Forbidden' });
38
49
  }
@@ -0,0 +1,377 @@
1
+ import { ToolFunction } from './ToolFunction';
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
+ Function: 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
+ LogVisibility: {
40
+ Zaius: 'zaius'
41
+ },
42
+ }));
43
+
44
+ // Create a concrete implementation for testing
45
+ class TestToolFunction extends ToolFunction {
46
+ private mockReady: jest.MockedFunction<() => Promise<boolean>>;
47
+
48
+ public constructor(request?: any) {
49
+ super(request || {});
50
+ (this as any).request = request;
51
+ this.mockReady = jest.fn().mockResolvedValue(true);
52
+ }
53
+
54
+ // Override the ready method with mock implementation for testing
55
+ protected ready(): Promise<boolean> {
56
+ return this.mockReady();
57
+ }
58
+
59
+ public getRequest() {
60
+ return (this as any).request;
61
+ }
62
+
63
+ public getMockReady() {
64
+ return this.mockReady;
65
+ }
66
+ }
67
+
68
+ describe('ToolFunction', () => {
69
+ let mockRequest: any;
70
+ let mockResponse: Response;
71
+ let toolFunction: TestToolFunction;
72
+ let mockProcessRequest: jest.MockedFunction<typeof toolsService.processRequest>;
73
+ let mockGetTokenVerifier: jest.MockedFunction<typeof getTokenVerifier>;
74
+ let mockGetAppContext: jest.MockedFunction<typeof getAppContext>;
75
+ let mockTokenVerifier: jest.Mocked<{
76
+ verify: (token: string) => Promise<any>;
77
+ }>;
78
+
79
+ beforeEach(() => {
80
+ jest.clearAllMocks();
81
+
82
+ // Create mock token verifier
83
+ mockTokenVerifier = {
84
+ verify: jest.fn(),
85
+ };
86
+
87
+ // Setup the mocks
88
+ mockProcessRequest = jest.mocked(toolsService.processRequest);
89
+ mockGetTokenVerifier = jest.mocked(getTokenVerifier);
90
+ mockGetAppContext = jest.mocked(getAppContext);
91
+
92
+ mockGetTokenVerifier.mockResolvedValue(mockTokenVerifier as any);
93
+ mockGetAppContext.mockReturnValue({
94
+ account: {
95
+ organizationId: 'app-org-123'
96
+ }
97
+ } as any);
98
+
99
+ // Create mock request with bodyJSON structure
100
+ mockRequest = {
101
+ headers: new Map(),
102
+ method: 'POST',
103
+ path: '/test',
104
+ bodyJSON: {
105
+ parameters: {
106
+ task_id: 'task-123',
107
+ content_id: 'content-456'
108
+ },
109
+ auth: {
110
+ provider: 'OptiID',
111
+ credentials: {
112
+ token_type: 'Bearer',
113
+ access_token: 'valid-access-token',
114
+ org_sso_id: 'org-sso-123',
115
+ user_id: 'user-456',
116
+ instance_id: 'instance-789',
117
+ customer_id: 'app-org-123',
118
+ product_sku: 'OPAL'
119
+ }
120
+ },
121
+ environment: {
122
+ execution_mode: 'headless'
123
+ }
124
+ }
125
+ };
126
+
127
+ mockResponse = {} as Response;
128
+ toolFunction = new TestToolFunction(mockRequest);
129
+ });
130
+
131
+ // Helper function to create a ready request with valid auth
132
+ const createReadyRequestWithAuth = () => ({
133
+ headers: new Map(),
134
+ method: 'GET',
135
+ path: '/ready',
136
+ bodyJSON: {
137
+ auth: {
138
+ provider: 'OptiID',
139
+ credentials: {
140
+ access_token: 'valid-token',
141
+ customer_id: 'app-org-123'
142
+ }
143
+ }
144
+ }
145
+ });
146
+
147
+ // Helper function to setup authorization mocks to pass
148
+ const setupAuthMocks = () => {
149
+ mockTokenVerifier.verify.mockResolvedValue(true);
150
+ mockGetAppContext.mockReturnValue({
151
+ account: {
152
+ organizationId: 'app-org-123'
153
+ }
154
+ } as any);
155
+ };
156
+
157
+ describe('/ready endpoint', () => {
158
+ beforeEach(() => {
159
+ setupAuthMocks();
160
+ });
161
+
162
+ it('should return ready: true when ready method returns true', async () => {
163
+ // Arrange
164
+ const readyRequest = createReadyRequestWithAuth();
165
+
166
+ toolFunction = new TestToolFunction(readyRequest);
167
+ toolFunction.getMockReady().mockResolvedValue(true);
168
+
169
+ // Act
170
+ const result = await toolFunction.perform();
171
+
172
+ // Assert
173
+ expect(toolFunction.getMockReady()).toHaveBeenCalledTimes(1);
174
+ expect(result).toEqual(new Response(200, { ready: true }));
175
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
176
+ });
177
+
178
+ it('should return ready: false when ready method returns false', async () => {
179
+ // Arrange
180
+ const readyRequest = createReadyRequestWithAuth();
181
+
182
+ toolFunction = new TestToolFunction(readyRequest);
183
+ toolFunction.getMockReady().mockResolvedValue(false);
184
+
185
+ // Act
186
+ const result = await toolFunction.perform();
187
+
188
+ // Assert
189
+ expect(toolFunction.getMockReady()).toHaveBeenCalledTimes(1);
190
+ expect(result).toEqual(new Response(200, { ready: false }));
191
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
192
+ });
193
+
194
+ it('should handle ready method throwing an error', async () => {
195
+ // Arrange
196
+ const readyRequest = createReadyRequestWithAuth();
197
+
198
+ toolFunction = new TestToolFunction(readyRequest);
199
+ toolFunction.getMockReady().mockRejectedValue(new Error('Ready check failed'));
200
+
201
+ // Act & Assert
202
+ await expect(toolFunction.perform()).rejects.toThrow('Ready check failed');
203
+ expect(toolFunction.getMockReady()).toHaveBeenCalledTimes(1);
204
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
205
+ });
206
+
207
+ it('should use default ready implementation when not overridden', async () => {
208
+ // Create a class that doesn't override ready method
209
+ class DefaultReadyToolFunction extends ToolFunction {
210
+ public constructor(request?: any) {
211
+ super(request || {});
212
+ (this as any).request = request;
213
+ }
214
+
215
+ public getRequest() {
216
+ return (this as any).request;
217
+ }
218
+ }
219
+
220
+ // Arrange
221
+ const readyRequest = createReadyRequestWithAuth();
222
+ const defaultToolFunction = new DefaultReadyToolFunction(readyRequest);
223
+
224
+ // Act
225
+ const result = await defaultToolFunction.perform();
226
+
227
+ // Assert - Default implementation should return true
228
+ expect(result).toEqual(new Response(200, { ready: true }));
229
+ expect(mockProcessRequest).not.toHaveBeenCalled(); // Should not call service
230
+ });
231
+ });
232
+
233
+ describe('perform', () => {
234
+ it('should execute successfully with valid token and matching organization', async () => {
235
+ // Setup mock token verifier to return true for valid token
236
+ mockTokenVerifier.verify.mockResolvedValue(true);
237
+ mockProcessRequest.mockResolvedValue(mockResponse);
238
+
239
+ const result = await toolFunction.perform();
240
+
241
+ expect(result).toBe(mockResponse);
242
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
243
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
244
+ expect(mockGetAppContext).toHaveBeenCalled();
245
+ expect(mockProcessRequest).toHaveBeenCalledWith(mockRequest, toolFunction);
246
+ });
247
+
248
+ it('should return 403 response with invalid token', async () => {
249
+ // Setup mock token verifier to return false
250
+ mockTokenVerifier.verify.mockResolvedValue(false);
251
+
252
+ const result = await toolFunction.perform();
253
+
254
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
255
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
256
+ expect(mockTokenVerifier.verify).toHaveBeenCalledWith('valid-access-token');
257
+ expect(mockProcessRequest).not.toHaveBeenCalled();
258
+ });
259
+
260
+ it('should return 403 response when organization ID does not match', async () => {
261
+ // Update mock request with different customer_id
262
+ const requestWithDifferentOrgId = {
263
+ ...mockRequest,
264
+ bodyJSON: {
265
+ ...mockRequest.bodyJSON,
266
+ auth: {
267
+ ...mockRequest.bodyJSON.auth,
268
+ credentials: {
269
+ ...mockRequest.bodyJSON.auth.credentials,
270
+ customer_id: 'different-org-123'
271
+ }
272
+ }
273
+ }
274
+ };
275
+
276
+ const toolFunctionWithDifferentOrgId = new TestToolFunction(requestWithDifferentOrgId);
277
+
278
+ const result = await toolFunctionWithDifferentOrgId.perform();
279
+
280
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
281
+ expect(mockGetAppContext).toHaveBeenCalled();
282
+ expect(mockProcessRequest).not.toHaveBeenCalled();
283
+ });
284
+
285
+ it('should return 403 response when access token is missing', async () => {
286
+ // Create request without access token
287
+ const requestWithoutToken = {
288
+ ...mockRequest,
289
+ bodyJSON: {
290
+ ...mockRequest.bodyJSON,
291
+ auth: {
292
+ ...mockRequest.bodyJSON.auth,
293
+ credentials: {
294
+ ...mockRequest.bodyJSON.auth.credentials,
295
+ access_token: undefined
296
+ }
297
+ }
298
+ }
299
+ };
300
+
301
+ const toolFunctionWithoutToken = new TestToolFunction(requestWithoutToken);
302
+
303
+ const result = await toolFunctionWithoutToken.perform();
304
+
305
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
306
+ expect(mockGetTokenVerifier).not.toHaveBeenCalled();
307
+ expect(mockProcessRequest).not.toHaveBeenCalled();
308
+ });
309
+
310
+ it('should return 403 response when organisation id is missing', async () => {
311
+ // Create request without customer_id
312
+ const requestWithoutCustomerId = {
313
+ ...mockRequest,
314
+ bodyJSON: {
315
+ ...mockRequest.bodyJSON,
316
+ auth: {
317
+ ...mockRequest.bodyJSON.auth,
318
+ credentials: {
319
+ ...mockRequest.bodyJSON.auth.credentials,
320
+ customer_id: undefined
321
+ }
322
+ }
323
+ }
324
+ };
325
+
326
+ const toolFunctionWithoutCustomerId = new TestToolFunction(requestWithoutCustomerId);
327
+
328
+ const result = await toolFunctionWithoutCustomerId.perform();
329
+
330
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
331
+ expect(mockGetTokenVerifier).not.toHaveBeenCalled();
332
+ expect(mockProcessRequest).not.toHaveBeenCalled();
333
+ });
334
+
335
+ it('should return 403 response when auth structure is missing', async () => {
336
+ // Create request without auth structure
337
+ const requestWithoutAuth = {
338
+ ...mockRequest,
339
+ bodyJSON: {
340
+ parameters: mockRequest.bodyJSON.parameters,
341
+ environment: mockRequest.bodyJSON.environment
342
+ }
343
+ };
344
+
345
+ const toolFunctionWithoutAuth = new TestToolFunction(requestWithoutAuth);
346
+
347
+ const result = await toolFunctionWithoutAuth.perform();
348
+
349
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
350
+ expect(mockGetTokenVerifier).not.toHaveBeenCalled();
351
+ expect(mockProcessRequest).not.toHaveBeenCalled();
352
+ });
353
+
354
+ it('should return 403 response when token verifier initialization fails', async () => {
355
+ // Setup mock to fail during token verifier initialization
356
+ mockGetTokenVerifier.mockRejectedValue(new Error('Failed to initialize token verifier'));
357
+
358
+ const result = await toolFunction.perform();
359
+
360
+ expect(result).toEqual(new Response(403, { error: 'Forbidden' }));
361
+ expect(mockGetTokenVerifier).toHaveBeenCalled();
362
+ expect(mockProcessRequest).not.toHaveBeenCalled();
363
+ });
364
+ });
365
+
366
+ describe('inheritance', () => {
367
+ it('should be an instance of Function', () => {
368
+ // Assert
369
+ expect(toolFunction).toBeInstanceOf(ToolFunction);
370
+ });
371
+
372
+ it('should have access to the request property', () => {
373
+ // Assert
374
+ expect(toolFunction.getRequest()).toBe(mockRequest);
375
+ });
376
+ });
377
+ });
@@ -1,5 +1,7 @@
1
1
  import { Function, Response, amendLogContext } from '@zaiusinc/app-sdk';
2
+ import { authenticateRegularRequest } from '../auth/AuthUtils';
2
3
  import { toolsService } from '../service/Service';
4
+ import { ToolLogger } from '../logging/ToolLogger';
3
5
 
4
6
  /**
5
7
  * Abstract base class for tool-based function execution
@@ -18,12 +20,28 @@ export abstract class ToolFunction extends Function {
18
20
  }
19
21
 
20
22
  /**
21
- * Process the incoming request using the tools service
23
+ * Process the incoming request with logging
22
24
  *
23
25
  * @returns Response as the HTTP response
24
26
  */
25
27
  public async perform(): Promise<Response> {
28
+ const startTime = Date.now();
26
29
  amendLogContext({ opalThreadId: this.request.headers.get('x-opal-thread-id') || '' });
30
+
31
+ ToolLogger.logRequest(this.request);
32
+
33
+ const response = await this.handleRequest();
34
+
35
+ ToolLogger.logResponse(this.request, response, startTime - Date.now());
36
+ return response;
37
+ }
38
+
39
+ /**
40
+ * Handle the core request processing logic
41
+ *
42
+ * @returns Response as the HTTP response
43
+ */
44
+ private async handleRequest(): Promise<Response> {
27
45
  if (!(await this.authorizeRequest())) {
28
46
  return new Response(403, { error: 'Forbidden' });
29
47
  }
@@ -32,6 +50,7 @@ export abstract class ToolFunction extends Function {
32
50
  const isReady = await this.ready();
33
51
  return new Response(200, { ready: isReady });
34
52
  }
53
+
35
54
  // Pass 'this' as context so decorated methods can use the existing instance
36
55
  return toolsService.processRequest(this.request, this);
37
56
  }
@@ -42,6 +61,6 @@ export abstract class ToolFunction extends Function {
42
61
  * @returns true if authentication succeeds
43
62
  */
44
63
  private async authorizeRequest(): Promise<boolean> {
45
- return true;
64
+ return await authenticateRegularRequest(this.request);
46
65
  }
47
66
  }