import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import fs from 'fs'; import { Readable } from 'stream'; import nock from 'nock'; import https from 'https'; import { Provider } from '../../src/resources/provider.js'; import * as HttpErrors from '../../src/httpErrors.js'; import Logger from '../../src/resources/logger.js'; import { RequestMetrics } from '../../src/resources/requestMetrics.js'; // There is currently an issue with node 20.12 and fetch mocking. A quick fix is to first call fetch so it's getter // get properly instantiated, which allow it to be mocked properly. // Issue: https://github.com/nodejs/node/issues/52015 // PR fix: https://github.com/nodejs/node/pull/52275 globalThis.fetch = fetch; describe('Provider', () => { const provider = new Provider({ prepareRequest: requestOptions => { return { url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }; }, rateLimiter: undefined, }); const logger = new Logger(); // Helper to spy on https.request and capture options const spyOnHttpsRequest = (): { getCapturedOptions: () => any; restore: () => void } => { let capturedOptions: any; const originalRequest = https.request; (https as any).request = function (options: any, callback: any) { capturedOptions = options; return originalRequest.call(https, options, callback); }; return { getCapturedOptions: () => capturedOptions, restore: () => { (https as any).request = originalRequest; }, }; }; it('get', async context => { const response = new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1' }, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint', { method: 'GET', body: null, signal: new AbortController().signal, headers: { Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 200, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('accepts text/html type response', async context => { const response = new Response('', { status: 200, headers: new Headers({ 'Content-Type': 'text/html; charset=UTF-8' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'text/html; charset=UTF-8' }, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint', { method: 'GET', body: null, signal: new AbortController().signal, headers: { Accept: 'text/html; charset=UTF-8', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 200, headers: Object.fromEntries(response.headers.entries()), body: '', }); }); it('accepts application/schema+json type response', async context => { const response = new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/schema+json; charset=UTF-8' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/schema+json; charset=UTF-8' }, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint', { method: 'GET', body: null, signal: new AbortController().signal, headers: { Accept: 'application/schema+json; charset=UTF-8', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 200, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('accepts application/swagger+json type response', async context => { const response = new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/swagger+json; charset=UTF-8' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1', Accept: 'application/swagger+json; charset=UTF-8' }, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint', { method: 'GET', body: null, signal: new AbortController().signal, headers: { Accept: 'application/swagger+json; charset=UTF-8', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 200, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('accepts application/vnd.oracle.resource+json type response', async context => { const response = new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/vnd.oracle.resource+json; type=collection; charset=UTF-8' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1' }, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint', { method: 'GET', body: null, signal: new AbortController().signal, headers: { Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 200, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('returns the raw response body if specified', async context => { const response = new Response(`IMAGINE A HUGE PAYLOAD`, { status: 200, headers: new Headers({ 'Content-Type': 'image/png' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const providerResponse = await provider.streamingGet('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { Accept: 'application/json', }, rawBody: true, }); assert.ok(providerResponse); // What matters: still returns a stream assert.ok(providerResponse.body instanceof ReadableStream); }); it('gets an endpoint which is an absolute url', async context => { const response = new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.get('https://my-cdn.my-domain.com/file.png', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'https://my-cdn.my-domain.com/file.png', { method: 'GET', body: null, signal: new AbortController().signal, headers: { Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', }, }, ]); assert.deepEqual(actualResponse, { status: 200, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('gets on provider url', async context => { const response = new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.get('', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com', { method: 'GET', body: null, signal: new AbortController().signal, headers: { Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', }, }, ]); assert.deepEqual(actualResponse, { status: 200, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('post with url encoded body', async context => { const response = new Response('{"data": "value"}', { status: 201, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.post( '/endpoint', { data: 'createdItemInfo', }, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Additional-Header': 'value1' }, }, ); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint', { method: 'POST', body: 'data=createdItemInfo', signal: new AbortController().signal, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 201, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('accepts an array as body for post request', async context => { const response = new Response('{"data": "value"}', { status: 201, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.post( '/endpoint', [ { data: '1', data2: '2' }, { data: '3', data2: '4' }, ], { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'Content-Type': 'application/json-patch+json', 'X-Additional-Header': 'value1' }, }, ); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint', { method: 'POST', body: '[{"data":"1","data2":"2"},{"data":"3","data2":"4"}]', signal: new AbortController().signal, headers: { 'Content-Type': 'application/json-patch+json', Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 201, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('put with json body', async context => { const response = new Response('{"data": "value"}', { status: 201, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); // Removing leading '/' on endpoint to make sure we support both cases const actualResponse = await provider.put( 'endpoint/123', { data: 'updatedItemInfo', }, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/json' }, }, ); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint/123', { method: 'PUT', body: JSON.stringify({ data: 'updatedItemInfo' }), signal: new AbortController().signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 201, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('putBuffer with Buffer body', async context => { const response = new Response('{"data": "value"}', { status: 201, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const buffer = Buffer.from('binary data content'); // What matters is that the body of put is a buffer const actualResponse = await provider.putBuffer('endpoint/123', buffer, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1', 'Content-Type': 'application/octet-stream' }, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint/123', { method: 'PUT', body: buffer, signal: new AbortController().signal, headers: { 'Content-Type': 'application/octet-stream', Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 201, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('patch with query params', async context => { const response = new Response('{"data": "value"}', { status: 201, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.patch( '/endpoint/123', { data: 'updatedItemInfo', }, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, queryParams: { param1: 'value1', param2: 'value2' }, additionnalheaders: { 'X-Additional-Header': 'value1' }, }, ); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint/123?param1=value1¶m2=value2', { method: 'PATCH', body: JSON.stringify({ data: 'updatedItemInfo' }), signal: new AbortController().signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 201, headers: Object.fromEntries(response.headers.entries()), body: { data: 'value' }, }); }); it('delete', async context => { const response = new Response(undefined, { status: 204, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const actualResponse = await provider.delete('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1' }, }); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint/123', { method: 'DELETE', body: null, signal: new AbortController().signal, headers: { Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 204, headers: Object.fromEntries(response.headers.entries()), body: undefined, }); }); it('deleteWithBody', async context => { const response = new Response('{"success": true}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const requestBody = { webhookIds: [1, 2, 3] }; const actualResponse = await provider.delete( '/webhook', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1' }, }, requestBody, ); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/webhook', { method: 'DELETE', body: JSON.stringify(requestBody), signal: new AbortController().signal, headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 200, headers: Object.fromEntries(response.headers.entries()), body: { success: true }, }); }); it('uses rate limiter if provided', async context => { const mockRateLimiter = context.mock.fn((_context, request) => Promise.resolve(request())); const rateLimitedProvider = new Provider({ prepareRequest: requestOptions => { return { url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }; }, rateLimiter: mockRateLimiter, }); const response = new Response(undefined, { status: 204, headers: new Headers({ 'Content-Type': 'application/json' }), }); const fetchMock = context.mock.method(global, 'fetch', () => Promise.resolve(response)); const options = { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1' }, }; const actualResponse = await rateLimitedProvider.delete('/endpoint/123', options); assert.equal(mockRateLimiter.mock.calls.length, 1); assert.deepEqual(mockRateLimiter.mock.calls[0]?.arguments[0]?.credentials, options.credentials); assert.equal(fetchMock.mock.calls.length, 1); assert.deepEqual(fetchMock.mock.calls[0]?.arguments, [ 'www.myApi.com/endpoint/123', { method: 'DELETE', body: null, signal: new AbortController().signal, headers: { Accept: 'application/json', 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': 'apikey#1111', 'X-Additional-Header': 'value1', }, }, ]); assert.deepEqual(actualResponse, { status: 204, headers: Object.fromEntries(response.headers.entries()), body: undefined, }); }); it('uses custom error handler if provided', async context => { const rateLimitedProvider = new Provider({ prepareRequest: requestOptions => { return { url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }; }, rateLimiter: undefined, // Change from normal behavior 400 -> 429 customErrorHandler: (responseStatus: number) => responseStatus === 400 ? new HttpErrors.RateLimitExceededError('Weird provider behavior') : undefined, }); const response = new Response(undefined, { status: 400, headers: new Headers({ 'Content-Type': 'application/json' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const options = { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1' }, }; let error; try { await rateLimitedProvider.delete('/endpoint/123', options); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.HttpError); assert.equal(error.message, 'Weird provider behavior'); }); it('contains the credential in the custom error handler', async context => { const provider = new Provider({ prepareRequest: requestOptions => { return { url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }; }, rateLimiter: undefined, customErrorHandler: (responseStatus: number, _message: string, options) => { if (responseStatus === 400) { // What matter is that we have access to the context in the error handler throw new HttpErrors.BadRequestError(`Error with API key ${options?.credentials.apiKey}`); } return undefined; }, }); const response = new Response(undefined, { status: 400, headers: new Headers({ 'Content-Type': 'application/json' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const options = { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1' }, }; let error; try { await provider.delete('/endpoint/123', options); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.HttpError); assert.equal(error.message, 'Error with API key apikey#1111'); }); it('uses default behavior if custom error handler returns undefined', async context => { const rateLimitedProvider = new Provider({ prepareRequest: requestOptions => { return { url: `www.${requestOptions.credentials.domain ?? 'myApi.com'}`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }; }, rateLimiter: undefined, // Custom Error Handler returning undefined (default behavior should apply) customErrorHandler: () => undefined, }); const response = new Response(undefined, { status: 404, headers: new Headers({ 'Content-Type': 'application/json' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const options = { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, additionnalheaders: { 'X-Additional-Header': 'value1' }, }; let error; try { await rateLimitedProvider.delete('/endpoint/123', options); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.HttpError); assert.equal(error.message, 'Not found'); }); it('returns valid json response', async context => { const response = new Response(`{ "validJson": true }`, { status: 200, headers: new Headers({ 'Content-Type': 'application/json;charset=utf-8' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const providerResponse = await provider.get<{ validJson: boolean }>('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, }); assert.ok(providerResponse); assert.ok(providerResponse.body); assert.equal(providerResponse.body.validJson, true); }); it('returns successfully on missing Content-Type header', async context => { const response = new Response(undefined, { status: 201, }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const providerResponse = await provider.get<{ validJson: boolean }>('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, }); assert.ok(providerResponse); assert.equal(providerResponse.body, undefined); }); it('returns streamable response on streaming get calls', async context => { const response = new Response(`IMAGINE A HUGE PAYLOAD`, { status: 200, headers: new Headers({ 'Content-Type': 'video/mp4' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const providerResponse = await provider.streamingGet('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, }); assert.ok(providerResponse); assert.ok(providerResponse.body instanceof ReadableStream); }); it('returns successfully on unexpected content-type response with no body', async context => { const response = new Response(null, { status: 201, headers: new Headers({ 'Content-Type': 'html/text' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const providerResponse = await provider.post( '/endpoint/123', {}, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, }, ); assert.ok(providerResponse); assert.strictEqual(providerResponse.status, response.status); assert.deepEqual(providerResponse.headers, Object.fromEntries(response.headers.entries())); assert.strictEqual(providerResponse.body, undefined); }); it('throws on invalid json response', async context => { const response = new Response('{invalidJSON}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); let error; try { await provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.HttpError); assert.equal(error.message, 'Invalid JSON response'); }); it('throws on unexpected content-type response', async context => { const response = new Response('text', { status: 200, headers: new Headers({ 'Content-Type': 'application/text' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); let error; try { await provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: new AbortController().signal, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.HttpError); assert.equal(error.status, 500); }); it('throws on status 400', async context => { const response = new Response('response body', { status: 400, }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); let error; try { await provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, signal: new AbortController().signal, logger, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.BadRequestError); assert.equal(error.message, 'response body'); }); it('throws on timeout', async context => { context.mock.method(global, 'fetch', () => { const error = new Error(); error.name = 'TimeoutError'; throw error; }); let error; try { await provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, signal: new AbortController().signal, logger, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.TimeoutError); assert.equal(error.message, 'Request timeout'); }); it('throws on abort', async context => { context.mock.method(global, 'fetch', () => { const error = new Error(); error.name = 'AbortError'; throw error; }); let error; try { await provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, signal: new AbortController().signal, logger, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.TimeoutError); assert.equal(error.message, 'Request aborted'); }); it('throws on unknown errors', async context => { context.mock.method(global, 'fetch', () => { throw new TypeError('foo', { cause: new Error('bar') }); }); let error; try { await provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, signal: new AbortController().signal, logger, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.HttpError); assert.ok(error.message.startsWith('Unexpected error while calling the provider.')); assert.ok(error.message.includes('ErrorName: "TypeError"')); assert.ok(error.message.includes('message: "foo"')); assert.ok(error.message.includes('stack:')); assert.ok(error.message.includes('cause:')); assert.ok(error.message.includes('causeStack:')); }); it('throws on status 429', async context => { const response = new Response('response body', { status: 429, }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); let error; try { await provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, signal: new AbortController().signal, logger, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.RateLimitExceededError); assert.equal(error.message, 'response body'); }); it('logs provider requests', async context => { const response = new Response(undefined, { status: 201 }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const loggerStub = context.mock.method(logger, 'log'); await provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, signal: new AbortController().signal, logger, }); assert.equal(loggerStub.mock.callCount(), 1); assert.equal(loggerStub.mock.calls[0]?.arguments[0], 'info'); assert.match( String(loggerStub.mock.calls[0]?.arguments[1]), /Connector API Request GET www.myApi.com\/endpoint\/123 201 - \d+ ms/, ); }); it('logs failed provider requests at error level', async context => { const response = new Response('something went wrong', { status: 500 }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); const loggerStub = context.mock.method(logger, 'log'); await assert.rejects( provider.get('/endpoint/123', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, signal: new AbortController().signal, logger, }), ); assert.equal(loggerStub.mock.callCount(), 1); assert.equal(loggerStub.mock.calls[0]?.arguments[0], 'error'); }); // Stream upload will not load the data in memory and sending a binary in the body it('postStream streams data without buffering', async () => { const streamProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.${requestOptions.credentials.domain ?? 'myApi.com'}`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const testData = 'test binary data for streaming'; const stream = Readable.from([testData]); const scope = nock('https://www.myApi.com') .post('/upload', testData) .matchHeader('content-type', 'application/octet-stream') .matchHeader('accept', 'application/json') .matchHeader('x-custom-provider-header', 'value') .matchHeader('x-provider-credential-header', 'apikey#1111') .reply(201, { success: true, id: '12345' }); const response = await streamProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, }); assert.ok(scope.isDone(), 'HTTPS request should have been made'); assert.equal(response.status, 201); assert.deepEqual(response.body, { success: true, id: '12345' }); }); it('postStream with query params', async () => { const streamProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const testData = 'data with params'; const stream = Readable.from([testData]); const scope = nock('https://www.myApi.com') .post('/upload?key=value&format=json', testData) .reply(200, { uploaded: true }); const response = await streamProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, queryParams: { key: 'value', format: 'json' }, }); assert.ok(scope.isDone()); assert.equal(response.status, 200); assert.deepEqual(response.body, { uploaded: true }); }); it('postStream handles error responses', async () => { const streamProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const stream = Readable.from(['error test data']); nock('https://www.myApi.com').post('/upload').reply(400, 'Bad request error'); let error; try { await streamProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.BadRequestError); assert.equal(error.message, 'Bad request error'); }); it('postStream with rate limiter', async () => { let rateLimiterCalled = false; let rateLimiterCredentials; const mockRateLimiter = async (options: any, request: () => Promise) => { rateLimiterCalled = true; rateLimiterCredentials = options.credentials; return request(); }; const rateLimitedProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: mockRateLimiter, }); const testData = 'rate limited data'; const stream = Readable.from([testData]); nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true }); await rateLimitedProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, }); assert.ok(rateLimiterCalled, 'Rate limiter should have been called'); assert.deepEqual(rateLimiterCredentials, { apiKey: 'apikey#1111', unitoCredentialId: '123', }); }); it('postStream sets timeout to 0 (no timeout)', async () => { const streamProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const testData = 'timeout test data'; const stream = Readable.from([testData]); const spy = spyOnHttpsRequest(); const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true }); try { await streamProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, }); assert.ok(scope.isDone()); assert.equal(spy.getCapturedOptions().timeout, 0, 'Timeout should be set to 0 (no timeout)'); } finally { spy.restore(); } }); it('postStream handles AbortSignal for request cancellation', async () => { const streamProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const stream = Readable.from(['abort signal test']); const abortController = new AbortController(); // Simulate aborting the request immediately abortController.abort(); let error; try { await streamProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: abortController.signal, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.TimeoutError); assert.equal(error.message, 'Timeout'); }); it('postStream handles AbortSignal timeout during request', async () => { const streamProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const stream = Readable.from(['timeout during request test']); const abortController = new AbortController(); // Delay response to simulate a slow server nock('https://www.myApi.com').post('/upload').delayConnection(100).reply(201, { success: true }); // Abort after 50ms setTimeout(() => abortController.abort(), 50); let error; try { await streamProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: abortController.signal, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.TimeoutError); assert.equal(error.message, 'Timeout'); }); it('postStream cleans up AbortSignal listener on success', async () => { const streamProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const testData = 'cleanup test'; const stream = Readable.from([testData]); const abortController = new AbortController(); const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true }); const response = await streamProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: abortController.signal, }); assert.ok(scope.isDone()); assert.equal(response.status, 201); // Verify the listener was removed by checking that aborting after completion // doesn't cause any side effects (if listener wasn't removed, this could cause issues) assert.doesNotThrow(() => { abortController.abort(); }, 'Aborting after completion should not throw or cause issues'); // Verify the signal is indeed aborted assert.ok(abortController.signal.aborted, 'Signal should be aborted'); }); it('postForm handles AbortSignal for request cancellation', async () => { const FormData = (await import('form-data')).default; const formProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const form = new FormData(); form.append('field1', 'value1'); form.append('field2', 'value2'); const abortController = new AbortController(); // Simulate aborting the request immediately abortController.abort(); let error; try { await formProvider.postForm('/upload-form', form, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: abortController.signal, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.TimeoutError); assert.equal(error.message, 'Timeout'); }); it('postForm handles AbortSignal timeout during request', async () => { const FormData = (await import('form-data')).default; const formProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const form = new FormData(); form.append('field1', 'value1'); const abortController = new AbortController(); // Mock a delayed response nock('https://www.myApi.com').post('/upload-form').delayConnection(100).reply(201, { success: true, id: '12345' }); // Abort after 50ms setTimeout(() => abortController.abort(), 50); let error; try { await formProvider.postForm('/upload-form', form, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: abortController.signal, }); } catch (e) { error = e; } assert.ok(error instanceof HttpErrors.TimeoutError); assert.equal(error.message, 'Timeout'); }); it('postForm successfully completes with AbortSignal provided', async () => { const FormData = (await import('form-data')).default; const formProvider = new Provider({ prepareRequest: requestOptions => ({ url: `https://www.myApi.com`, headers: { 'X-Custom-Provider-Header': 'value', 'X-Provider-Credential-Header': requestOptions.credentials.apiKey as string, }, }), rateLimiter: undefined, }); const form = new FormData(); form.append('field1', 'value1'); form.append('field2', 'value2'); const abortController = new AbortController(); const scope = nock('https://www.myApi.com').post('/upload-form').reply(201, { success: true, id: '12345' }); const response = await formProvider.postForm('/upload-form', form, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, signal: abortController.signal, }); assert.ok(scope.isDone()); assert.equal(response.status, 201); assert.deepEqual(response.body, { success: true, id: '12345' }); // Verify the listener was removed by checking that aborting after completion // doesn't cause any side effects assert.doesNotThrow(() => { abortController.abort(); }, 'Aborting after completion should not throw or cause issues'); // Verify the signal is indeed aborted assert.ok(abortController.signal.aborted, 'Signal should be aborted'); }); describe('response recording', () => { it('records JSON responses to JSONL file when env var is set', async () => { const tmpFile = `/tmp/provider-recording-test-${Date.now()}.jsonl`; process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile; try { const recordingProvider = new Provider({ prepareRequest: () => ({ url: 'https://www.myApi.com', headers: { 'Content-Type': 'application/json' }, }), rateLimiter: undefined, }); nock('https://www.myApi.com').get('/items').reply(200, { id: '123', name: 'Test', priority: 'high' }); await recordingProvider.get('/items', { credentials: { unitoCredentialId: '123' }, logger, }); const content = fs.readFileSync(tmpFile, 'utf-8'); const lines = content.trim().split('\n'); assert.equal(lines.length, 1); const entry = JSON.parse(lines[0]!); assert.ok(typeof entry.seq === 'number' && entry.seq > 0, `seq should be a positive number, got ${entry.seq}`); assert.equal(entry.url, 'https://www.myApi.com/items'); assert.equal(entry.method, 'GET'); assert.equal(entry.status, 200); assert.deepEqual(entry.body, { id: '123', name: 'Test', priority: 'high' }); } finally { delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH; try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } } }); it('does not record when env var is not set', async () => { delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH; const noRecordProvider = new Provider({ prepareRequest: () => ({ url: 'https://www.myApi.com', headers: { 'Content-Type': 'application/json' }, }), rateLimiter: undefined, }); nock('https://www.myApi.com').get('/no-record').reply(200, { id: '123' }); await noRecordProvider.get('/no-record', { credentials: { unitoCredentialId: '123' }, logger, }); // No assertion needed — just ensure no crash. }); it('does not record stream responses', async () => { const tmpFile = `/tmp/provider-recording-stream-test-${Date.now()}.jsonl`; process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile; try { const recordingProvider = new Provider({ prepareRequest: () => ({ url: 'https://www.myApi.com', headers: {}, }), rateLimiter: undefined, }); nock('https://www.myApi.com') .get('/download') .reply(200, 'binary-data', { 'Content-Type': 'application/octet-stream' }); await recordingProvider.streamingGet('/download', { credentials: { unitoCredentialId: '123' }, logger, }); assert.equal(fs.existsSync(tmpFile), false); } finally { delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH; try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } } }); it('increments seq across multiple requests', async () => { const tmpFile = `/tmp/provider-recording-seq-test-${Date.now()}.jsonl`; process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH = tmpFile; try { const recordingProvider = new Provider({ prepareRequest: () => ({ url: 'https://www.myApi.com', headers: { 'Content-Type': 'application/json' }, }), rateLimiter: undefined, }); nock('https://www.myApi.com').get('/a').reply(200, { id: '1' }).get('/b').reply(200, { id: '2' }); await recordingProvider.get('/a', { credentials: { unitoCredentialId: '123' }, logger }); await recordingProvider.get('/b', { credentials: { unitoCredentialId: '123' }, logger }); const lines = fs.readFileSync(tmpFile, 'utf-8').trim().split('\n'); assert.equal(lines.length, 2); const seq1 = JSON.parse(lines[0]!).seq; const seq2 = JSON.parse(lines[1]!).seq; assert.equal(seq2, seq1 + 1, `second seq (${seq2}) should be first seq (${seq1}) + 1`); } finally { delete process.env.UNITO_SCHEMA_SNAPSHOT_RECORD_PATH; try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } } }); }); describe('request metrics', () => { it('records api call duration in requestMetrics via fetchWrapper', async context => { const metrics = RequestMetrics.startRequest(); const response = new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); await provider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, requestMetrics: metrics, }); const result = metrics.endRequest(); assert.equal(result.apiCallCount, 1); assert.ok(result.totalApiDurationNs >= 0, 'Duration should be recorded'); }); it('records multiple api calls in the same requestMetrics', async context => { const metrics = RequestMetrics.startRequest(); context.mock.method(global, 'fetch', () => Promise.resolve( new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }), ), ); await provider.get('/endpoint1', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, requestMetrics: metrics, }); await provider.post( '/endpoint2', { key: 'value' }, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, requestMetrics: metrics, }, ); const result = metrics.endRequest(); assert.equal(result.apiCallCount, 2); assert.ok(result.totalApiDurationNs >= 0); }); it('works without requestMetrics (backward compatible)', async context => { const response = new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }); context.mock.method(global, 'fetch', () => Promise.resolve(response)); // No requestMetrics passed — should not throw const result = await provider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, }); assert.equal(result.status, 200); }); it('records api call duration from postForm', async () => { const metrics = RequestMetrics.startRequest(); const httpsProvider = new Provider({ prepareRequest: () => ({ url: 'https://www.myApi.com', headers: {}, }), rateLimiter: undefined, }); const scope = nock('https://www.myApi.com').post('/upload').reply(201, { success: true }); const FormData = (await import('form-data')).default; const form = new FormData(); form.append('file', Buffer.from('test data'), 'test.txt'); await httpsProvider.postForm('/upload', form, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, requestMetrics: metrics, }); const postFormResult = metrics.endRequest(); assert.equal(postFormResult.apiCallCount, 1); assert.ok(postFormResult.totalApiDurationNs > 0); scope.isDone(); }); it('records throttle delay when the rate limiter defers the call', async context => { const metrics = RequestMetrics.startRequest(); const throttleDelayMs = 50; const deferringProvider = new Provider({ prepareRequest: () => ({ url: 'www.myApi.com', headers: {}, }), rateLimiter: (_options, request) => new Promise(resolve => { setTimeout(() => { resolve(request()); }, throttleDelayMs); }), }); context.mock.method(global, 'fetch', () => Promise.resolve( new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }), ), ); await deferringProvider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, requestMetrics: metrics, }); const result = metrics.endRequest(); const recordedThrottleDelayMs = result.totalThrottleDelayNs / 1_000_000; assert.ok( recordedThrottleDelayMs >= throttleDelayMs * 0.8, `Expected recorded throttle delay >= ${throttleDelayMs * 0.8}ms but got ${recordedThrottleDelayMs}ms`, ); }); it('records negligible throttle delay when the rate limiter fires immediately', async context => { const metrics = RequestMetrics.startRequest(); const immediateProvider = new Provider({ prepareRequest: () => ({ url: 'www.myApi.com', headers: {}, }), rateLimiter: (_options, request) => request(), }); context.mock.method(global, 'fetch', () => Promise.resolve( new Response('{"data": "value"}', { status: 200, headers: new Headers({ 'Content-Type': 'application/json' }), }), ), ); await immediateProvider.get('/endpoint', { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, requestMetrics: metrics, }); const result = metrics.endRequest(); const recordedThrottleDelayMs = result.totalThrottleDelayNs / 1_000_000; assert.ok( recordedThrottleDelayMs < 10, `Expected negligible throttle delay but got ${recordedThrottleDelayMs}ms`, ); }); it('records api call duration from postStream', async () => { const metrics = RequestMetrics.startRequest(); const testData = 'binary stream data'; const httpsProvider = new Provider({ prepareRequest: () => ({ url: 'https://www.myApi.com', headers: {}, }), rateLimiter: undefined, }); const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true }); const stream = Readable.from(Buffer.from(testData)); await httpsProvider.postStream('/upload', stream, { credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' }, logger, requestMetrics: metrics, }); const postStreamResult = metrics.endRequest(); assert.equal(postStreamResult.apiCallCount, 1); assert.ok(postStreamResult.totalApiDurationNs > 0); scope.isDone(); }); }); });