UNPKG

@apistudio/apim-cli

Version:

CLI for API Management Products

743 lines (639 loc) 22.4 kB
/** * Copyright IBM Corp. 2024, 2025 */ import axios from 'axios'; import { RestHandler } from '../../../src/engine/protocol/rest-handler.js'; import { AxiosClient } from '../../../src/engine/protocol/axios-client.js'; import { VCM } from '../../../src/engine/variable-context-manager/context-manager.js'; import { Request } from '../../../src/schemas/test.schema.js'; import { VariableContext } from '../../../src/engine/variable-context-manager/variable-context.js'; import fs from 'fs'; // Mock the LogWrapper jest.mock('../../../src/service/log-wrapper.js', () => ({ LogWrapper: { logWarn: jest.fn(), logInfo: jest.fn(), logError: jest.fn(), logDebug: jest.fn(), }, })); jest.mock('axios'); const mockSetVariable = jest.fn(); const mockedAxios = axios as any as jest.Mock; jest.mock('fs'); jest.spyOn(VCM, 'getContext').mockReturnValue({ setVariable: mockSetVariable, set: mockSetVariable, } as unknown as VariableContext); describe('RestHandler with AxiosClient', () => { const sessionId = 'restHandlerContext'; beforeAll(() => { VCM.createContext('restHandlerContext').setVariable('user', { id: '42', name: 'Bob', }); VCM.createContext('restHandlerContext')?.setVariable( 'token', 'secrettoken', ); VCM.createContext('restHandlerContext')?.setVariable('account', 'alice'); }); beforeEach(() => { jest.clearAllMocks(); }); it('should interpolate and make a GET request', async () => { mockedAxios.mockResolvedValueOnce({ data: { ok: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', resource: '${user.id}', endpoint: 'https://example.com/user/${user.id}', headers: [{ key: 'Authorization', value: 'Bearer ${token}' }], }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://example.com/user/42', headers: expect.objectContaining({ Authorization: 'Bearer secrettoken', }), method: 'GET', }), ); expect(result.data).toEqual({ ok: true }); }); it('should throw error for missing variable when throwOnMissing is true', async () => { mockedAxios.mockResolvedValueOnce({ data: { success: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', resource: '${notfound}', endpoint: 'https://api.com/${notfound}', }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ method: 'GET', url: 'https://api.com/', }), ); expect(result.data).toEqual({ success: true }); }); it('should handle POST request with JSON body', async () => { mockedAxios.mockResolvedValueOnce({ data: { success: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', resource: 'create', endpoint: 'https://api.com/create', payload: { raw: { json: '{ name: "${user.name}" }' } }, headers: [{ key: 'ContentType', value: 'application/json' }], }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', url: 'https://api.com/create', data: expect.stringContaining('{ name: "Bob" }'), headers: expect.objectContaining({ ContentType: 'application/json', }), }), ); expect(result.data).toEqual({ success: true }); }); it('should handle POST request with xml body', async () => { mockedAxios.mockResolvedValueOnce({ data: { success: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', resource: 'create', endpoint: 'https://api.com/create', payload: { raw: { xml: '{ name: "${user.name}" }' } }, headers: [{ key: 'ContentType', value: 'application/xml' }], }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', url: 'https://api.com/create', data: expect.stringContaining('{ name: "Bob" }'), headers: expect.objectContaining({ ContentType: 'application/xml', }), }), ); expect(result.data).toEqual({ success: true }); }); it('should handle POST request with js body', async () => { mockedAxios.mockResolvedValueOnce({ data: { success: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', resource: 'create', endpoint: 'https://api.com/create', payload: { raw: { js: 'console.log("${user.name}")' } }, headers: [{ key: 'ContentType', value: 'text/plain' }], }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', url: 'https://api.com/create', data: expect.stringContaining('console.log("Bob")'), headers: expect.objectContaining({ ContentType: 'text/plain', }), }), ); expect(result.data).toEqual({ success: true }); }); it('should handle POST request with html body', async () => { mockedAxios.mockResolvedValueOnce({ data: { success: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', resource: 'create', endpoint: 'https://api.com/create', payload: { raw: { js: '<html><body>${user.name}</body></html>' } }, headers: [{ key: 'ContentType', value: 'text/plain' }], }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', url: 'https://api.com/create', data: expect.stringContaining('<html><body>Bob</body></html>'), headers: expect.objectContaining({ ContentType: 'text/plain', }), }), ); expect(result.data).toEqual({ success: true }); }); it('should send form-urlencoded data correctly', async () => { mockedAxios.mockResolvedValueOnce({ data: { status: 'ok' } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', resource: 'login', endpoint: 'https://api.com/login', payload: { urlEncodedFormData: [ { key: 'username', value: 'test' }, { key: 'password', value: '123' }, ], }, headers: [ { key: 'ContentType', value: 'application/x-www-form-urlencoded' }, ], }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ ContentType: 'application/x-www-form-urlencoded', }), data: expect.stringContaining('username=test&password=123'), // pragma: allowlist secret }), ); expect(result.data).toEqual({ status: 'ok' }); }); it('should handle XML response parsing', async () => { const xmlResponse = '<root><user>John</user><id>123</id></root>'; mockedAxios.mockResolvedValueOnce({ data: xmlResponse, headers: { 'content-type': 'application/xml' }, status: 200, statusText: 'OK', }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', endpoint: 'https://api.com/xml', resource: 'xml', }; await handler.execute(step, sessionId); // Verify XML was parsed expect(mockSetVariable).toHaveBeenCalledWith( 'xml()', expect.objectContaining({ root: expect.objectContaining({ user: 'John', id: '123', }), }), ); expect(mockSetVariable).toHaveBeenCalledWith( 'responseBody', expect.objectContaining({ root: expect.objectContaining({ user: 'John', id: '123', }), }), ); }); it('should handle file not found error in formData', async () => { // Mock fs.existsSync to simulate file not found (fs.existsSync as jest.Mock).mockReturnValue(false); // Create a custom error to be thrown when file is not found const fileNotFoundError = new Error( 'File not found: /non/existent/path.txt', ); // Mock fs.readFileSync to throw the error (fs.readFileSync as jest.Mock).mockImplementation(() => { throw fileNotFoundError; }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', endpoint: 'https://api.com/upload', resource: 'upload', payload: { formData: [ { key: 'file', value: '/non/existent/path.txt', type: 'file' }, ], }, }; // Clear previous mock calls mockSetVariable.mockClear(); // Execute and expect it to throw await expect(handler.execute(step, sessionId)).rejects.toThrow(); // Check that the error response was set in the context expect(mockSetVariable).toHaveBeenCalledWith('request', expect.anything()); // The error response should be set at some point const calls = mockSetVariable.mock.calls; const responseCall = calls.find((call) => call[0] === 'response'); expect(responseCall).toBeDefined(); if (responseCall) { const responseArg = responseCall[1]; expect(responseArg).toEqual(undefined); } }); it('should handle variable resolution errors', async () => { // Mock VCM.resolve to throw an error jest.spyOn(VCM, 'resolve').mockImplementationOnce(() => { throw new Error('Variable not found: ${missing}'); }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', endpoint: 'https://api.com/${missing}', resource: 'error', }; await expect(handler.execute(step, sessionId)).rejects.toThrow( 'Variable not found: ${missing}', ); // Verify error response is set in context expect(mockSetVariable).toHaveBeenCalledWith( 'response', expect.objectContaining({ status: 0, statusText: 'Variable Resolution Error', data: expect.objectContaining({ error: 'Variable not found: ${missing}', }), }), ); }); it('should handle HTTP request failures', async () => { // Mock axios to throw an error with a response const errorResponse = { status: 500, statusText: 'Internal Server Error', headers: { 'content-type': 'application/json' }, data: { error: 'Server error' }, }; const error = new Error('Request failed'); (error as any).response = errorResponse; mockedAxios.mockRejectedValueOnce(error); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', endpoint: 'https://api.com/error', resource: 'error', }; await expect(handler.execute(step, sessionId)).rejects.toThrow( 'Request failed', ); // Verify error response is set in context expect(mockSetVariable).toHaveBeenCalledWith('response', errorResponse); expect(mockSetVariable).toHaveBeenCalledWith('responseStatus', 500); }); it('should handle HTTP request failures without response object', async () => { // Mock axios to throw an error without a response const error = new Error('Network error'); mockedAxios.mockRejectedValueOnce(error); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', endpoint: 'https://api.com/network-error', resource: 'error', }; await expect(handler.execute(step, sessionId)).rejects.toThrow( 'Network error', ); // Verify error itself is set as response in context expect(mockSetVariable).toHaveBeenCalledWith('response', error); }); it('should handle SSL verification settings', async () => { mockedAxios.mockResolvedValueOnce({ data: { success: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', endpoint: 'https://api.com/secure', resource: 'secure', settings: { sslVerification: false, }, }; await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ httpsAgent: expect.objectContaining({ options: expect.objectContaining({ rejectUnauthorized: false, }), }), }), ); }); it('should attach basic auth headers', async () => { mockedAxios.mockResolvedValueOnce({ data: { auth: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', resource: 'secure', endpoint: 'https://api.com/secure', auth: { basicAuth: { username: 'foo', password: '<PASSWORD>', // pragma: allowlist secret }, }, }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Basic Zm9vOjxQQVNTV09SRD4=', }), }), ); expect(result.data).toEqual({ auth: true }); }); it('should interpolate nested headers and query params', async () => { mockedAxios.mockResolvedValueOnce({ data: { query: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', resource: 'search', endpoint: 'https://api.com/search', headers: [{ key: 'X-User', value: '${user.name}' }], parameters: [ { key: 'id', value: '${user.id}' }, { key: 'name', value: '${user.name}' }, ], }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ params: { id: '42', name: 'Bob' }, headers: expect.objectContaining({ 'X-User': 'Bob' }), }), ); expect(result.data).toEqual({ query: true }); }); it('should override default headers with interpolated values', async () => { mockedAxios.mockResolvedValueOnce({ data: { overridden: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', resource: 'data', endpoint: 'https://api.com/data', auth: { bearerToken: '${token}', }, headers: [{ key: 'Content-Type', value: 'application/json' }], }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer secrettoken', 'Content-Type': 'application/json', }), }), ); expect(result.data).toEqual({ overridden: true }); }); it('should interpolate each item in an array', () => { const arr = ['User ID: ${user.id}', 'Token: ${token}']; const result = VCM.resolve('restHandlerContext', arr); expect(result).toEqual(['User ID: 42', 'Token: secrettoken']); }); it('should throw error if HTTP url is missing', async () => { const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', resource: 'nomethod', // enpoint is missing here }; await expect(handler.execute(step, sessionId)).rejects.toThrow( 'Endpoint is required', ); }); it('should handle empty payload without throwing', async () => { mockedAxios.mockResolvedValueOnce({ data: { ok: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', resource: 'empty', endpoint: 'https://api.com', payload: undefined, }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', }), ); expect(result.data).toEqual({ ok: true }); }); it('should send multipart/form-data using FormData payload', async () => { mockedAxios.mockResolvedValueOnce({ data: { uploaded: true } }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', endpoint: 'https://api.com', resource: 'upload', headers: [{ key: 'ContentType', value: 'application/json' }], payload: { formData: [ { key: 'file', value: 'mock-file-content' }, { key: 'description', value: 'test upload' }, ], }, }; const result = await handler.execute(step, sessionId); expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ data: expect.any(FormData), }), ); expect(result.data).toEqual({ uploaded: true }); }); it('should handle file uploads in FormData', async () => { // Clear previous mock calls mockSetVariable.mockClear(); mockedAxios.mockClear(); // Mock file existence check (fs.existsSync as jest.Mock).mockReturnValue(true); // Mock file reading (fs.readFileSync as jest.Mock).mockReturnValue( Buffer.from('test file content'), ); // Mock axios response mockedAxios.mockResolvedValueOnce({ data: { fileUploaded: true }, status: 200, statusText: 'OK', headers: {}, }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'POST', endpoint: 'https://api.com/upload', resource: 'upload', payload: { formData: [ { key: 'file', value: '/path/to/test.txt', type: 'file' }, { key: 'description', value: 'File upload test' }, ], }, }; const result = await handler.execute(step, sessionId); // Verify the response expect(result.data).toEqual({ fileUploaded: true }); // Verify that the request was made with FormData expect(mockedAxios).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', url: 'https://api.com/upload', }), ); // Verify that response variables were set expect(mockSetVariable).toHaveBeenCalledWith('responseStatus', 200); expect(mockSetVariable).toHaveBeenCalledWith('responseBody', { fileUploaded: true, }); }); it('should store selected keys in context when step.var is an array', async () => { const userId = 123; const address = { street: '123 Main St', city: 'New York', state: 'NY', }; const address1 = { street: '456 Elm St', city: 'Florida', state: 'US', }; const address2 = { street: '789 Oak St', city: 'Florida', state: 'FL', }; const user = { userId, name: 'Bob', address: [address, address1], }; const response = [ user, { userId: 456, name: 'Alice', address: [address2], }, ]; mockedAxios.mockResolvedValueOnce({ data: response, }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', endpoint: 'https://api.com', resource: 'users', var: [ { id: 'userId' }, { userId: '[0].userId' }, { address: '[1].address' }, { firstAddress: '[0].address[0]' }, { response1: '[0]' }, { response1_name: '[0].name' }, { response1_address2: '[0].address[1]' }, ], }; const result = await handler.execute(step, sessionId); expect(mockSetVariable).toHaveBeenCalledWith('id', undefined); expect(mockSetVariable).toHaveBeenCalledWith('userId', userId); expect(mockSetVariable).toHaveBeenCalledWith('address', [address2]); expect(mockSetVariable).toHaveBeenCalledWith('firstAddress', address); expect(mockSetVariable).toHaveBeenCalledWith('response1', user); expect(mockSetVariable).toHaveBeenCalledWith('response1_name', user.name); expect(mockSetVariable).toHaveBeenCalledWith( 'response1_address2', address1, ); expect(result).toMatchObject(expect.objectContaining({ data: response })); }); it('should store full response in context when step.var is a string', async () => { mockedAxios.mockResolvedValueOnce({ data: { token: 'secrettoken', user: 'alice' }, }); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', endpoint: 'https://api.com', resource: 'login', var: 'loginResponse', }; const result = await handler.execute(step, sessionId); expect(mockSetVariable).toHaveBeenCalledWith( 'loginResponse', expect.objectContaining({ token: 'secrettoken', user: 'alice' }), ); expect(result.data).toEqual({ token: 'secrettoken', user: 'alice' }); }); it('should handle system variables with undefined values', async () => { mockedAxios.mockResolvedValueOnce({ data: { success: true } }); // Mock VCM.getContext to return a context with specific behavior for system variables const mockContext = { set: mockSetVariable, get: jest.fn().mockImplementation((key) => { if (key === 'responseBody') { return undefined; // Known system var with undefined value } return null; }), }; jest.spyOn(VCM, 'getContext').mockReturnValue(mockContext as any); const handler = new RestHandler(new AxiosClient()); const step: Request = { method: 'GET', endpoint: 'https://api.com/test', resource: 'test', var: [ { key: 'responseValue', value: 'responseBody.data' }, { key: 'unknownValue', value: '__unknown_var__.data' }, ], }; await handler.execute(step, sessionId); // Verify that the logger was used for both cases expect(mockSetVariable).toHaveBeenCalledWith('responseValue', undefined); expect(mockSetVariable).toHaveBeenCalledWith('unknownValue', undefined); }); });