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