@optimizely-opal/opal-tool-ocp-sdk 0.0.0-devmg.13 → 1.0.0-beta.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 +108 -15
- package/dist/auth/AuthUtils.d.ts +26 -0
- package/dist/auth/AuthUtils.d.ts.map +1 -0
- package/dist/auth/AuthUtils.js +109 -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 +601 -0
- package/dist/auth/AuthUtils.test.js.map +1 -0
- package/dist/auth/TokenVerifier.d.ts.map +1 -1
- package/dist/auth/TokenVerifier.js +0 -1
- package/dist/auth/TokenVerifier.js.map +1 -1
- package/dist/auth/TokenVerifier.test.js +9 -0
- package/dist/auth/TokenVerifier.test.js.map +1 -1
- package/dist/function/GlobalToolFunction.d.ts +27 -0
- package/dist/function/GlobalToolFunction.d.ts.map +1 -0
- package/dist/function/GlobalToolFunction.js +53 -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 +1 -2
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +3 -35
- package/dist/function/ToolFunction.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 +8 -7
- package/dist/service/Service.d.ts.map +1 -1
- package/dist/service/Service.js.map +1 -1
- package/package.json +3 -4
- package/src/auth/AuthUtils.test.ts +729 -0
- package/src/auth/AuthUtils.ts +117 -0
- package/src/auth/TokenVerifier.test.ts +11 -0
- package/src/auth/TokenVerifier.ts +0 -1
- package/src/function/GlobalToolFunction.test.ts +505 -0
- package/src/function/GlobalToolFunction.ts +56 -0
- package/src/function/ToolFunction.ts +4 -41
- package/src/index.ts +1 -0
- package/src/service/Service.ts +33 -9
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { getAppContext, logger } from '@zaiusinc/app-sdk';
|
|
2
|
+
import { getTokenVerifier } from './TokenVerifier';
|
|
3
|
+
import { OptiIdAuthData } from '../types/Models';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validate the OptiID access token
|
|
7
|
+
*
|
|
8
|
+
* @param accessToken - The access token to validate
|
|
9
|
+
* @returns true if the token is valid
|
|
10
|
+
*/
|
|
11
|
+
async function validateAccessToken(accessToken: string | undefined): Promise<boolean> {
|
|
12
|
+
try {
|
|
13
|
+
if (!accessToken) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const tokenVerifier = await getTokenVerifier();
|
|
17
|
+
return await tokenVerifier.verify(accessToken);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
logger.error('OptiID token validation failed:', error);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract and validate basic OptiID authentication data from request
|
|
26
|
+
*
|
|
27
|
+
* @param request - The incoming request
|
|
28
|
+
* @returns object with authData and accessToken, or null if invalid
|
|
29
|
+
*/
|
|
30
|
+
export function extractAuthData(request: any): { authData: OptiIdAuthData; accessToken: string } | null {
|
|
31
|
+
const authData = request?.bodyJSON?.auth as OptiIdAuthData;
|
|
32
|
+
const accessToken = authData?.credentials?.access_token;
|
|
33
|
+
if (!accessToken || authData?.provider?.toLowerCase() !== 'optiid') {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { authData, accessToken };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate organization ID matches the app context
|
|
42
|
+
*
|
|
43
|
+
* @param customerId - The customer ID from the auth data
|
|
44
|
+
* @returns true if the organization ID is valid
|
|
45
|
+
*/
|
|
46
|
+
function validateOrganizationId(customerId: string | undefined): boolean {
|
|
47
|
+
if (!customerId) {
|
|
48
|
+
logger.error('Organisation ID is required but not provided');
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const appOrganisationId = getAppContext()?.account?.organizationId;
|
|
53
|
+
if (customerId !== appOrganisationId) {
|
|
54
|
+
logger.error(`Invalid organisation ID: expected ${appOrganisationId}, received ${customerId}`);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if a request should skip authentication (discovery/ready endpoints)
|
|
63
|
+
*
|
|
64
|
+
* @param request - The incoming request
|
|
65
|
+
* @returns true if auth should be skipped
|
|
66
|
+
*/
|
|
67
|
+
function shouldSkipAuth(request: any): boolean {
|
|
68
|
+
return request.path === '/discovery' || request.path === '/ready';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Core authentication flow - extracts auth data and validates token
|
|
73
|
+
*
|
|
74
|
+
* @param request - The incoming request
|
|
75
|
+
* @param validateOrg - Whether to validate organization ID
|
|
76
|
+
* @returns true if authentication succeeds
|
|
77
|
+
*/
|
|
78
|
+
async function authenticateRequest(request: any, validateOrg: boolean): Promise<boolean> {
|
|
79
|
+
if (shouldSkipAuth(request)) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const authInfo = extractAuthData(request);
|
|
84
|
+
if (!authInfo) {
|
|
85
|
+
logger.error('OptiID token is required but not provided');
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const { authData, accessToken } = authInfo;
|
|
90
|
+
|
|
91
|
+
// Validate organization ID if required
|
|
92
|
+
if (validateOrg && !validateOrganizationId(authData.credentials?.customer_id)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return await validateAccessToken(accessToken);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Authenticate a request for regular functions (with organization validation)
|
|
101
|
+
*
|
|
102
|
+
* @param request - The incoming request
|
|
103
|
+
* @returns true if authentication and authorization succeed
|
|
104
|
+
*/
|
|
105
|
+
export async function authenticateRegularRequest(request: any): Promise<boolean> {
|
|
106
|
+
return await authenticateRequest(request, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Authenticate a request for global functions (without organization validation)
|
|
111
|
+
*
|
|
112
|
+
* @param request - The incoming request
|
|
113
|
+
* @returns true if authentication succeeds
|
|
114
|
+
*/
|
|
115
|
+
export async function authenticateGlobalRequest(request: any): Promise<boolean> {
|
|
116
|
+
return await authenticateRequest(request, false);
|
|
117
|
+
}
|
|
@@ -92,7 +92,6 @@ export class TokenVerifier {
|
|
|
92
92
|
cooldownDuration: DEFAULT_JWKS_EXPIRES_IN
|
|
93
93
|
});
|
|
94
94
|
this.initialized = true;
|
|
95
|
-
logger.info('TokenVerifier environment ' + environment);
|
|
96
95
|
logger.info(`TokenVerifier initialized with issuer: ${this.issuer} (environment: ${environment})`);
|
|
97
96
|
} catch (error) {
|
|
98
97
|
logger.error('Failed to initialize TokenVerifier', error);
|
|
@@ -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,56 @@
|
|
|
1
|
+
import { GlobalFunction, Response, amendLogContext } from '@zaiusinc/app-sdk';
|
|
2
|
+
import { authenticateGlobalRequest, extractAuthData } 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
|
+
// Extract customer_id from auth data for global context attribution
|
|
28
|
+
const authInfo = extractAuthData(this.request);
|
|
29
|
+
const customerId = authInfo?.authData?.credentials?.customer_id;
|
|
30
|
+
|
|
31
|
+
amendLogContext({
|
|
32
|
+
opalThreadId: this.request.headers.get('x-opal-thread-id') || '',
|
|
33
|
+
customerId: customerId || ''
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!(await this.authorizeRequest())) {
|
|
37
|
+
return new Response(403, { error: 'Forbidden' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (this.request.path === '/ready') {
|
|
41
|
+
const isReady = await this.ready();
|
|
42
|
+
return new Response(200, { ready: isReady });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return toolsService.processRequest(this.request, this);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Authenticate the incoming request by validating only the OptiID token
|
|
50
|
+
*
|
|
51
|
+
* @returns true if authentication succeeds
|
|
52
|
+
*/
|
|
53
|
+
private async authorizeRequest(): Promise<boolean> {
|
|
54
|
+
return await authenticateGlobalRequest(this.request);
|
|
55
|
+
}
|
|
56
|
+
}
|