diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0c852880..6b14028c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -* @asein-sinch @JPPortier @krogers0607 @Dovchik +* @asein-sinch @JPPortier @matsk-sinch diff --git a/.github/workflows/run-ci.yaml b/.github/workflows/run-ci.yaml index acf12067..50dcee71 100644 --- a/.github/workflows/run-ci.yaml +++ b/.github/workflows/run-ci.yaml @@ -34,6 +34,7 @@ jobs: token: ${{ secrets.PAT_CI }} fetch-depth: 0 path: sinch-sdk-mockserver + ref: 213a7ab2cbb607467d2d912e74877b97159bb467 - name: Install Docker Compose run: | diff --git a/packages/sdk-client/src/client/api-client-helpers.ts b/packages/sdk-client/src/client/api-client-helpers.ts index 4807191a..8a697f1c 100644 --- a/packages/sdk-client/src/client/api-client-helpers.ts +++ b/packages/sdk-client/src/client/api-client-helpers.ts @@ -11,8 +11,7 @@ export const manageExpiredToken = async ( errorContext: ErrorContext, requestPlugins: RequestPlugin[] | undefined, requestOptions: RequestOptions, - callback: (props: any) => Promise, -) => { +): Promise => { // Use the circuitBreaker variable to try to regenerate a valid JWT only 3 times if (!apiCallParameters.circuitBreaker) { apiCallParameters.circuitBreaker = 1; @@ -26,12 +25,7 @@ export const manageExpiredToken = async ( ); } } - const optionsWithNewJwt = await invalidateAndRegenerateJwt(requestPlugins, requestOptions, errorContext); - const newApiCallParameters = { - ...apiCallParameters, - requestOptions: optionsWithNewJwt, - }; - return callback(newApiCallParameters); + return await invalidateAndRegenerateJwt(requestPlugins, requestOptions, errorContext); }; export function buildErrorContext( diff --git a/packages/sdk-client/src/client/api-fetch-client.ts b/packages/sdk-client/src/client/api-fetch-client.ts index d6f38bb1..8867a9c5 100644 --- a/packages/sdk-client/src/client/api-fetch-client.ts +++ b/packages/sdk-client/src/client/api-fetch-client.ts @@ -62,8 +62,18 @@ export class ApiFetchClient extends ApiClient { // Execute call try { - // Send the request with the refresh token mechanism - response = await this.sinchFetch(props, errorContext); + response = await fetch(props.url, props.requestOptions); + if ( + response.status === 401 + && response.headers.get('www-authenticate')?.includes('expired') + ) { + const requestOptions = await manageExpiredToken( + props, + errorContext, + this.apiClientOptions.requestPlugins, + props.requestOptions); + response = await fetch(props.url, requestOptions); + } body = await response.text(); } catch (error: any) { this.buildFetchError(error, errorContext); @@ -103,53 +113,26 @@ export class ApiFetchClient extends ApiClient { return reviveDates(transformedResponse); } - private async sinchFetch( - apiCallParameters: ApiCallParameters, - errorContext: ErrorContext, - ): Promise { - const response = await fetch(apiCallParameters.url, apiCallParameters.requestOptions); - if ( - response.status === 401 - && response.headers.get('www-authenticate')?.includes('expired') - ) { - return manageExpiredToken( - apiCallParameters, - errorContext, - this.apiClientOptions.requestPlugins, - apiCallParameters.requestOptions, - this.processCall); - } - return response; - } - - public processCallWithPagination( - props: ApiCallParametersWithPagination, + public async processCallWithPagination( + apiCallParameters: ApiCallParametersWithPagination, ): Promise> { // Read the "Origin" header if existing, for logging purposes - const origin = (props.requestOptions.headers as Headers).get('Origin'); - const errorContext: ErrorContext = buildErrorContext(props, origin); + const origin = (apiCallParameters.requestOptions.headers as Headers).get('Origin'); + const errorContext: ErrorContext = buildErrorContext(apiCallParameters, origin); + let exception: Error | undefined; // Execute call - return this.sinchFetchWithPagination(props, errorContext, origin); - }; - - private async sinchFetchWithPagination( - apiCallParameters: ApiCallParametersWithPagination, - errorContext: ErrorContext, - origin: string | null, - ): Promise> { - let exception: Error | undefined; - const response = await fetch(apiCallParameters.url, apiCallParameters.requestOptions); + let response = await fetch(apiCallParameters.url, apiCallParameters.requestOptions); if ( response.status === 401 && response.headers.get('www-authenticate')?.includes('expired') ) { - return manageExpiredToken( + const requestOptions = await manageExpiredToken( apiCallParameters, errorContext, this.apiClientOptions.requestPlugins, - apiCallParameters.requestOptions, - this.processCallWithPagination); + apiCallParameters.requestOptions); + response = await fetch(apiCallParameters.url, requestOptions); } // When handling pagination, we won't return the raw response but a PageResult const body = await response.text(); @@ -197,7 +180,7 @@ export class ApiFetchClient extends ApiClient { nextPage: () => createNextPageMethod( this, buildPaginationContext(apiCallParameters), apiCallParameters.requestOptions, nextPage), }; - } + }; private buildFetchError(error: any, errorContext: ErrorContext): Error { if (error instanceof GenericError) { @@ -249,7 +232,18 @@ export class ApiFetchClient extends ApiClient { // Execute call try { // Send the request with the refresh token mechanism - response = await this.sinchFetch(props, errorContext); + response = await fetch(props.url, props.requestOptions); + if ( + response.status === 401 + && response.headers.get('www-authenticate')?.includes('expired') + ) { + const requestOptions = await manageExpiredToken( + props, + errorContext, + this.apiClientOptions.requestPlugins, + props.requestOptions); + response = await fetch(props.url, requestOptions); + } body = await response.buffer(); fileName = this.extractFileName(response.headers); } catch (error: any) { diff --git a/packages/sdk-client/tests/client/api-fetch-client.test.ts b/packages/sdk-client/tests/client/api-fetch-client.test.ts new file mode 100644 index 00000000..9645204d --- /dev/null +++ b/packages/sdk-client/tests/client/api-fetch-client.test.ts @@ -0,0 +1,181 @@ +jest.mock('node-fetch', () => { + const actual = jest.requireActual('node-fetch'); + return { + __esModule: true, + default: jest.fn(), + Headers: actual.Headers, + Response: actual.Response, + }; +}); + +import { RequestPluginEnum } from '../../src/plugins/core/request-plugin'; +import { ApiFetchClient, PaginationEnum } from '../../src'; +import fetch, { Headers, Response } from 'node-fetch'; + +const mockedFetch = fetch as unknown as jest.Mock; + +describe('manageExpiredToken', () => { + + let token: string | undefined; + let apiClient: ApiFetchClient; + let oauth2Plugin: any; + + beforeEach(() => { + jest.clearAllMocks(); + + oauth2Plugin = { + getName: () => RequestPluginEnum.OAUTH2_TOKEN_REQUEST, + invalidateToken: jest.fn(() => { + token = undefined; + }), + load: () => ({ + transform: async (options: any) => { + if (!token) { + token = 'new-token'; + } + const headers = new Headers(options.headers); + headers.set('Authorization', `Bearer ${token}`); + return { ...options, headers }; + }, + }), + }; + + apiClient = new ApiFetchClient({ + requestPlugins: [oauth2Plugin], + }); + + token = 'expired-token'; + }); + + it('should regenerate JWT and retry the call after token expiration', async () => { + + // Fake fetch simulation (first call 401, second call 200) + mockedFetch.mockResolvedValueOnce( + new Response('', { + status: 401, + headers: { 'www-authenticate': 'token expired' }, + }), + ); + // Second call — success + mockedFetch.mockResolvedValue( + new Response(JSON.stringify({ data: 'success' }), { + status: 200, + headers: {}, + }), + ); + + const headers = new Headers({ Authorization: `Bearer ${token}` }); + const requestOptions = { + method: 'GET', + headers, + hostname: 'https://api.example.com', + path: '/endpoint', + }; + + const result = await apiClient.processCall<{ data: string }>({ + url: 'https://api.example.com/endpoint', + requestOptions, + apiName: 'TestAPI', + operationId: 'testOperation', + }); + + expect(result).toEqual({ data: 'success' }); + expect(oauth2Plugin.invalidateToken).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(2); + expect(mockedFetch.mock.calls[0][1].headers.get('Authorization')).toBe('Bearer expired-token'); + expect(mockedFetch.mock.calls[1][1].headers.get('Authorization')).toBe('Bearer new-token'); + }); + + it('should regenerate JWT and retry the call after token expiration for paginated endpoint', async () => { + + // Fake fetch simulation (first call 401, second call 200) + mockedFetch.mockResolvedValueOnce( + new Response('', { + status: 401, + headers: { 'www-authenticate': 'token expired' }, + }), + ); + // Second call — success + mockedFetch.mockResolvedValue( + new Response(JSON.stringify({ data: ['success'], count: 1, page: 1, page_size: 2 }), { + status: 200, + headers: {}, + }), + ); + + const headers = new Headers({ Authorization: `Bearer ${token}` }); + const requestOptions = { + method: 'GET', + headers, + hostname: 'https://api.example.com', + path: '/endpoint', + }; + + const result = await apiClient.processCallWithPagination<{ data: string }>({ + url: 'https://api.example.com/endpoint', + requestOptions, + apiName: 'TestAPI', + operationId: 'testOperation', + pagination: PaginationEnum.PAGE, + dataKey: 'data', + }); + + const expectedResponse = { + data: ['success'], + hasNextPage: false, + nextPageValue: '"2"', + }; + + expect(result).toMatchObject(expectedResponse); + expect(typeof result.nextPage).toBe('function'); + expect(oauth2Plugin.invalidateToken).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(2); + expect(mockedFetch.mock.calls[0][1].headers.get('Authorization')).toBe('Bearer expired-token'); + expect(mockedFetch.mock.calls[1][1].headers.get('Authorization')).toBe('Bearer new-token'); + }); + + it('should regenerate JWT and retry the call after token expiration for file call', async () => { + + // Fake fetch simulation (first call 401, second call 200) + mockedFetch.mockResolvedValueOnce( + new Response('', { + status: 401, + headers: { 'www-authenticate': 'token expired' }, + }), + ); + // Second call — success + mockedFetch.mockResolvedValue( + new Response('responseBuffer', { + status: 200, + headers: {}, + }), + ); + + const headers = new Headers({ Authorization: `Bearer ${token}` }); + const requestOptions = { + method: 'GET', + headers, + hostname: 'https://api.example.com', + path: '/endpoint', + }; + + const result = await apiClient.processFileCall({ + url: 'https://api.example.com/endpoint', + requestOptions, + apiName: 'TestAPI', + operationId: 'testOperation', + }); + + const expectedResponse = { + buffer: Buffer.from('responseBuffer', 'utf-8'), + fileName: 'default-name.pdf', + }; + + expect(result).toMatchObject(expectedResponse); + expect(oauth2Plugin.invalidateToken).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(2); + expect(mockedFetch.mock.calls[0][1].headers.get('Authorization')).toBe('Bearer expired-token'); + expect(mockedFetch.mock.calls[1][1].headers.get('Authorization')).toBe('Bearer new-token'); + }); + +});