@toolbox-sdk/core
Version:
JavaScript Base SDK for interacting with the Toolbox service
622 lines (551 loc) • 20.4 kB
text/typescript
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {ToolboxTool} from '../src/toolbox_core/tool';
import {z, ZodObject, ZodRawShape} from 'zod';
import {AxiosInstance, AxiosResponse} from 'axios';
import * as utils from '../src/toolbox_core/utils';
// Global mocks
const mockAxiosPost = jest.fn();
const mockSession = {
post: mockAxiosPost,
} as unknown as AxiosInstance;
// Mock the utils module
jest.mock('../src/toolbox_core/utils', () => ({
...jest.requireActual('../src/toolbox_core/utils'),
resolveValue: jest.fn(async (v: unknown) =>
typeof v === 'function' ? await v() : v,
),
identifyAuthRequirements: jest.fn(),
}));
describe('ToolboxTool', () => {
// Common constants for the tool
const baseURL = 'https://api.example.com';
const toolName = 'myTestTool';
const toolDescription = 'This is a description for the test tool.';
// Variables to be initialized in beforeEach
let basicParamSchema: ZodObject<ZodRawShape>;
let consoleWarnSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
let tool: ReturnType<typeof ToolboxTool>;
beforeEach(() => {
// Reset mocks before each test
mockAxiosPost.mockReset();
(utils.resolveValue as jest.Mock).mockClear();
(utils.identifyAuthRequirements as jest.Mock).mockClear();
// Initialize a basic schema used by many tests
basicParamSchema = z.object({
query: z.string().min(1, 'Query cannot be empty'),
limit: z.number().optional(),
});
// Spy on console to prevent logging and allow assertions
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
// Restore the original console methods
consoleWarnSpy.mockRestore();
consoleErrorSpy.mockRestore();
jest.clearAllMocks();
});
describe('Factory Properties and Getters', () => {
beforeEach(() => {
tool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
{},
);
});
it('should correctly assign toolName, description, and params to the callable function', () => {
expect(tool.toolName).toBe(toolName);
expect(tool.description).toBe(toolDescription);
expect(tool.params).toBe(basicParamSchema);
expect(tool.boundParams).toEqual({});
expect(tool.authTokenGetters).toEqual({});
expect(tool.requiredAuthnParams).toEqual({});
expect(tool.requiredAuthzTokens).toEqual([]);
});
it('getName() should return the tool name', () => {
expect(tool.getName()).toBe(toolName);
});
it('getDescription() should return the tool description', () => {
expect(tool.getDescription()).toBe(toolDescription);
});
it('getParamSchema() should return the parameter schema', () => {
expect(tool.getParamSchema()).toBe(basicParamSchema);
});
it('should warn when using an HTTP URL with authTokenGetters', () => {
const httpBaseURL = 'http://api.insecure.com';
ToolboxTool(
mockSession,
httpBaseURL,
toolName,
toolDescription,
basicParamSchema,
{service1: () => 'token'},
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Sending ID token over HTTP. User data may be exposed. Use HTTPS for secure communication.',
);
});
it('should warn when using an HTTP URL with clientHeaders', () => {
const httpBaseURL = 'http://api.insecure.com';
ToolboxTool(
mockSession,
httpBaseURL,
toolName,
toolDescription,
basicParamSchema,
{},
{},
[],
{},
{'x-api-key': 'key'},
);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Sending ID token over HTTP. User data may be exposed. Use HTTPS for secure communication.',
);
});
it('should throw an error if client headers and auth tokens have conflicting names', () => {
const authTokenGetters = {service1: () => 'token'};
const clientHeaders = {service1_token: 'some-other-token'}; // Conflicts with service1
expect(() => {
ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
authTokenGetters,
{},
[],
{},
clientHeaders,
);
}).toThrow(
'Client header(s) `service1_token` already registered in client. Cannot register the same headers in the client as well as tool.',
);
});
});
describe('Callable Function - Argument Validation', () => {
it('should call paramSchema.parse with the provided arguments', async () => {
const omitSpy = jest
.spyOn(basicParamSchema, 'omit')
.mockImplementation(() => basicParamSchema);
const currentTool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
);
const parseSpy = jest.spyOn(basicParamSchema, 'parse');
const callArgs = {query: 'test query'};
mockAxiosPost.mockResolvedValueOnce({data: 'success'} as AxiosResponse);
await currentTool(callArgs);
expect(parseSpy).toHaveBeenCalledWith(callArgs);
parseSpy.mockRestore();
omitSpy.mockRestore();
});
it('should throw a formatted ZodError if argument validation fails', async () => {
const currentTool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
);
const invalidArgs = {query: ''}; // Fails because of empty string
await expect(currentTool(invalidArgs)).rejects.toThrow(
`Argument validation failed for tool "${toolName}":\n - query: Query cannot be empty`,
);
expect(mockAxiosPost).not.toHaveBeenCalled();
});
it('should handle multiple ZodError issues in the validation error message', async () => {
const complexSchema = z.object({
name: z.string().min(1, 'Name is required'),
age: z.number().positive('Age must be positive'),
});
const currentTool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
complexSchema,
);
const invalidArgs = {name: '', age: -5};
await expect(currentTool(invalidArgs)).rejects.toThrow(
new RegExp(
`Argument validation failed for tool "${toolName}":\\s*-\\s*name: Name is required\\s*-\\s*age: Age must be positive`,
),
);
expect(mockAxiosPost).not.toHaveBeenCalled();
});
it('should throw a generic error if paramSchema.parse throws a non-ZodError', async () => {
const customError = new Error('A non-Zod parsing error occurred!');
const failingSchema = {
shape: {},
parse: jest.fn().mockImplementation(() => {
throw customError;
}),
omit: jest.fn().mockReturnThis(),
} as unknown as ZodObject<ZodRawShape>;
const currentTool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
failingSchema,
);
const callArgs = {query: 'some query'};
await expect(currentTool(callArgs)).rejects.toThrow(
`Argument validation failed: ${String(customError)}`,
);
expect(mockAxiosPost).not.toHaveBeenCalled();
});
it('should use an empty object as default if no arguments are provided and schema allows it', async () => {
const emptySchema = z.object({});
const omitSpy = jest
.spyOn(emptySchema, 'omit')
.mockImplementation(() => emptySchema);
const parseSpy = jest.spyOn(emptySchema, 'parse');
const currentTool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
emptySchema,
);
mockAxiosPost.mockResolvedValueOnce({data: 'success'});
await currentTool();
expect(parseSpy).toHaveBeenCalledWith({});
expect(mockAxiosPost).toHaveBeenCalled();
parseSpy.mockRestore();
omitSpy.mockRestore();
});
it('should fail validation if no arguments are given and schema requires them', async () => {
const currentTool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
);
await expect(currentTool()).rejects.toThrow(
'Argument validation failed for tool "myTestTool":\n - query: Invalid input: expected string, received undefined',
);
expect(mockAxiosPost).not.toHaveBeenCalled();
});
});
describe('Callable Function - API Call Execution', () => {
const validArgs = {query: 'search term', limit: 10};
const expectedUrl = `${baseURL}/api/tool/${toolName}/invoke`;
const mockApiResponseData = {result: 'Data from API'};
beforeEach(() => {
tool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
);
});
it('should make a POST request to the correct URL with the validated payload', async () => {
mockAxiosPost.mockResolvedValueOnce({
data: mockApiResponseData,
} as AxiosResponse);
const result = await tool(validArgs);
expect(mockAxiosPost).toHaveBeenCalledTimes(1);
expect(mockAxiosPost).toHaveBeenCalledWith(expectedUrl, validArgs, {
headers: {},
});
expect(result).toEqual(mockApiResponseData['result']);
});
it('should re-throw the error and log to console.error if API call fails', async () => {
const apiError = new Error('API request failed');
mockAxiosPost.mockRejectedValueOnce(apiError);
await expect(tool(validArgs)).rejects.toThrow(apiError);
expect(mockAxiosPost).toHaveBeenCalledWith(expectedUrl, validArgs, {
headers: {},
});
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Error posting data to ${expectedUrl}:`,
apiError.message,
);
});
it('should omit null and undefined values from the final payload', async () => {
const paramSchemaWithOptional = z.object({
required_param: z.string(),
optional_param1: z.string().nullish(),
optional_param2: z.string().nullish(),
});
const toolWithOptionalParams = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
paramSchemaWithOptional,
);
mockAxiosPost.mockResolvedValueOnce({data: 'success'} as AxiosResponse);
const callArgs = {
required_param: 'value',
optional_param1: null,
optional_param2: undefined,
};
await toolWithOptionalParams(callArgs);
expect(mockAxiosPost).toHaveBeenCalledWith(
expectedUrl,
{required_param: 'value'},
{headers: {}},
);
});
});
describe('Bound Parameters Functionality', () => {
const expectedUrl = `${baseURL}/api/tool/${toolName}/invoke`;
beforeEach(() => {
tool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
);
});
it('should create a new tool with bound parameters using bindParams', () => {
const boundTool = tool.bindParams({limit: 10});
expect(boundTool).not.toBe(tool);
expect(boundTool.boundParams).toEqual({limit: 10});
expect(tool.boundParams).toEqual({});
});
it('should create a new tool with a single bound parameter using bindParam and use it in the call', async () => {
const boundTool = tool.bindParam('limit', 20);
expect(boundTool.boundParams).toEqual({limit: 20});
// Also test execution
mockAxiosPost.mockResolvedValueOnce({data: 'success'});
await boundTool({query: 'single bind test'});
expect(mockAxiosPost).toHaveBeenCalledWith(
expectedUrl,
{query: 'single bind test', limit: 20},
{headers: {}},
);
});
it('should merge bound parameters with call arguments in the final payload', async () => {
const boundTool = tool.bindParams({limit: 5});
mockAxiosPost.mockResolvedValueOnce({data: 'success'});
await boundTool({query: 'specific query'});
expect(mockAxiosPost).toHaveBeenCalledWith(
expectedUrl,
{
query: 'specific query',
limit: 5,
},
{headers: {}},
);
});
it('should not require bound parameters to be provided at call time', async () => {
const boundTool = tool.bindParams({query: 'default query'});
mockAxiosPost.mockResolvedValueOnce({data: 'success'});
await boundTool({limit: 15});
expect(mockAxiosPost).toHaveBeenCalledWith(
expectedUrl,
{
query: 'default query',
limit: 15,
},
{headers: {}},
);
});
it('should validate only the user-provided arguments, not the bound ones', async () => {
const boundTool = tool.bindParams({query: 'a valid query'});
mockAxiosPost.mockResolvedValueOnce({data: {result: 'success'}});
// This call is valid because 'query' is bound, and no invalid args are passed
await expect(boundTool()).resolves.toBe('success');
});
it('should throw an error when trying to re-bind an already bound parameter', () => {
const boundTool = tool.bindParams({limit: 10});
const expectedError = `Cannot re-bind parameter: parameter 'limit' is already bound in tool '${toolName}'.`;
expect(() => boundTool.bindParams({limit: 20})).toThrow(expectedError);
});
it('should throw an error when trying to bind a parameter that does not exist', () => {
const expectedError = `Unable to bind parameter: no parameter named 'nonExistent' in tool '${toolName}'.`;
expect(() => tool.bindParams({nonExistent: 'value'})).toThrow(
expectedError,
);
});
it('should resolve function values in bound parameters before making the API call', async () => {
const dynamicQuery = async () => 'resolved-query';
const boundTool = tool.bindParams({query: dynamicQuery});
mockAxiosPost.mockResolvedValueOnce({data: 'success'});
await boundTool({limit: 5});
expect(utils.resolveValue).toHaveBeenCalledWith(dynamicQuery);
expect(mockAxiosPost).toHaveBeenCalledWith(
expectedUrl,
{
query: 'resolved-query',
limit: 5,
},
{headers: {}},
);
});
});
describe('Authentication Functionality', () => {
const expectedUrl = `${baseURL}/api/tool/${toolName}/invoke`;
const initialRequiredAuthn = {paramA: ['service1', 'service2']};
const initialRequiredAuthz = ['service3'];
beforeEach(() => {
tool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
{}, // authTokenGetters
initialRequiredAuthn,
initialRequiredAuthz,
{}, // boundParams
{}, // clientHeaders
);
});
it('should throw an error if called with unmet authentication requirements', async () => {
await expect(tool({query: 'test'})).rejects.toThrow(
'One or more of the following authn services are required to invoke this tool: service1,service2,service3',
);
});
it('should add a single auth token getter and create a new tool', () => {
(utils.identifyAuthRequirements as jest.Mock).mockReturnValue([
{paramA: ['service2']},
['service3'],
new Set(['service1']),
]);
const newTool = tool.addAuthTokenGetter('service1', () => 'token1');
expect(newTool).not.toBe(tool);
expect(Object.keys(newTool.authTokenGetters)).toContain('service1');
expect(newTool.requiredAuthnParams).toEqual({paramA: ['service2']});
expect(newTool.requiredAuthzTokens).toEqual(['service3']);
});
it('should add multiple auth token getters', () => {
(utils.identifyAuthRequirements as jest.Mock).mockReturnValue([
{},
[],
new Set(['service1', 'service2', 'service3']),
]);
const newTool = tool.addAuthTokenGetters({
service1: () => 'token1',
service2: () => 'token2',
service3: () => 'token3',
});
expect(Object.keys(newTool.authTokenGetters)).toEqual([
'service1',
'service2',
'service3',
]);
expect(newTool.requiredAuthnParams).toEqual({});
expect(newTool.requiredAuthzTokens).toEqual([]);
});
it('should call the API with the correct auth headers', async () => {
const readyTool = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
);
(utils.identifyAuthRequirements as jest.Mock).mockReturnValue([
{},
[],
new Set(['service1', 'service3']),
]);
const authedTool = readyTool.addAuthTokenGetters({
service1: () => 'token-one',
service3: async () => 'token-three',
});
mockAxiosPost.mockResolvedValue({data: 'success'});
await authedTool({query: 'a query'});
expect(mockAxiosPost).toHaveBeenCalledWith(
expectedUrl,
{query: 'a query'},
{
headers: {
service1_token: 'token-one',
service3_token: 'token-three',
},
},
);
});
it('should throw an error if an auth token getter does not return a string', async () => {
(utils.identifyAuthRequirements as jest.Mock).mockReturnValue([
{},
[],
new Set(['service1']),
]);
const badTokenGetter = () => 12345;
const authedTool = tool.addAuthTokenGetter(
'service1',
badTokenGetter as unknown as () => string,
);
// Manually clear requirements to bypass the initial check
authedTool.requiredAuthnParams = {};
authedTool.requiredAuthzTokens = [];
await expect(authedTool({query: 'a query'})).rejects.toThrow(
"Auth token getter for 'service1' did not return a string.",
);
});
it('should throw an error when registering a duplicate auth source', () => {
(utils.identifyAuthRequirements as jest.Mock).mockReturnValue([
{},
[],
new Set(['service1']),
]);
const newTool = tool.addAuthTokenGetter('service1', () => 'token1');
expect(() =>
newTool.addAuthTokenGetter('service1', () => 'token1-new'),
).toThrow(
`Authentication source(s) \`service1\` already registered in tool \`${toolName}\`.`,
);
});
it('should throw an error if an unused auth source is provided', () => {
(utils.identifyAuthRequirements as jest.Mock).mockReturnValue([
initialRequiredAuthn,
initialRequiredAuthz,
new Set(), // No services from the getter were used
]);
expect(() =>
tool.addAuthTokenGetter('unusedService', () => 'token'),
).toThrow(
`Authentication source(s) \`unusedService\` unused by tool \`${toolName}\`.`,
);
});
it('should throw an error if adding an auth token conflicts with a client header', () => {
const toolWithClientHeader = ToolboxTool(
mockSession,
baseURL,
toolName,
toolDescription,
basicParamSchema,
{},
{},
[],
{},
{service1_token: 'api-key'}, // This will conflict
);
expect(() =>
toolWithClientHeader.addAuthTokenGetter('service1', () => 'token'),
).toThrow(
'Client header(s) `service1_token` already registered in client. Cannot register the same headers in the client as well as tool.',
);
});
});
});