From f77ce6c8e8826411dee0d507ae77fbe3a646eef0 Mon Sep 17 00:00:00 2001 From: yehorkardash Date: Fri, 13 Feb 2026 10:52:43 +0100 Subject: [PATCH] fix(Sharepoint Node): Ensure tokens are refreshed (#24978) Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> --- .../request-helper-functions.test.ts | 26 +++++ .../utils/request-helper-functions.ts | 21 +++- .../descriptions/common.descriptions.ts | 5 + .../descriptions/item/Item.resource.ts | 3 +- .../descriptions/list/List.resource.ts | 3 +- .../test/item/getTokenRefresh.test.ts | 96 +++++++++++++++++++ .../test/item/getTokenRefresh.workflow.json | 76 +++++++++++++++ packages/workflow/src/interfaces.ts | 8 +- 8 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 packages/nodes-base/nodes/Microsoft/SharePoint/test/item/getTokenRefresh.test.ts create mode 100644 packages/nodes-base/nodes/Microsoft/SharePoint/test/item/getTokenRefresh.workflow.json diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts index 57fc78a7832..be4384bfadd 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/request-helper-functions.test.ts @@ -619,6 +619,32 @@ describe('Request Helper Functions', () => { expect(axiosConfig.httpsAgent?.options.rejectUnauthorized).toBe(false); }); + + test('should ignore HTTP error except for the specified status codes', () => { + const requestOptions: IHttpRequestOptions = { + method: 'GET', + url: 'https://example.com', + ignoreHttpStatusErrors: { ignore: true, except: [401] }, + }; + + const axiosConfig = convertN8nRequestToAxios(requestOptions); + expect(axiosConfig.validateStatus).toBeDefined(); + expect(axiosConfig.validateStatus!(401)).toBe(false); + expect(axiosConfig.validateStatus!(500)).toBe(true); + }); + + test('should ignore all HTTP errors', () => { + const requestOptions: IHttpRequestOptions = { + method: 'GET', + url: 'https://example.com', + ignoreHttpStatusErrors: true, + }; + + const axiosConfig = convertN8nRequestToAxios(requestOptions); + expect(axiosConfig.validateStatus).toBeDefined(); + expect(axiosConfig.validateStatus!(401)).toBe(true); + expect(axiosConfig.validateStatus!(500)).toBe(true); + }); }); describe('applyPaginationRequestData', () => { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts index 07ac62ef897..099f9167b16 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helper-functions.ts @@ -44,6 +44,7 @@ import type { IDataObject, IExecuteData, IExecuteFunctions, + IgnoreStatusErrorConfig, IHttpRequestOptions, IN8nHttpFullResponse, IN8nHttpResponse, @@ -96,6 +97,17 @@ function validateUrl(url?: string): boolean { } } +function isIgnoreStatusErrorConfig( + ignoreHttpStatusErrors: unknown, +): ignoreHttpStatusErrors is IgnoreStatusErrorConfig { + return ( + typeof ignoreHttpStatusErrors === 'object' && + ignoreHttpStatusErrors !== null && + 'ignore' in ignoreHttpStatusErrors && + ignoreHttpStatusErrors.ignore === true + ); +} + function getUrlFromProxyConfig(proxyConfig: IHttpRequestOptions['proxy'] | string): string | null { if (typeof proxyConfig === 'string') { return validateUrl(proxyConfig) ? proxyConfig : null; @@ -843,7 +855,14 @@ export function convertN8nRequestToAxios(n8nRequest: IHttpRequestOptions): Axios } if (n8nRequest.ignoreHttpStatusErrors) { - axiosRequest.validateStatus = () => true; + const ignoreHttpStatusErrors = n8nRequest.ignoreHttpStatusErrors; + if (isIgnoreStatusErrorConfig(ignoreHttpStatusErrors)) { + axiosRequest.validateStatus = (status) => { + return !ignoreHttpStatusErrors.except.includes(status); + }; + } else { + axiosRequest.validateStatus = () => true; + } } return axiosRequest; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/common.descriptions.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/common.descriptions.ts index 4a0afe46c29..541fa453c30 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/common.descriptions.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/common.descriptions.ts @@ -154,3 +154,8 @@ export const siteRLC: INodeProperties = { required: true, type: 'resourceLocator', }; + +export const ignoreHttpStatusErrorsConfig = { + ignore: true as const, + except: [401], +}; diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/item/Item.resource.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/item/Item.resource.ts index eada60431d4..f096941dcb5 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/item/Item.resource.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/item/Item.resource.ts @@ -7,6 +7,7 @@ import * as getAll from './getAll.operation'; import * as update from './update.operation'; import * as upsert from './upsert.operation'; import { handleErrorPostReceive, simplifyItemPostReceive } from '../../helpers/utils'; +import { ignoreHttpStatusErrorsConfig } from '../common.descriptions'; export const description: INodeProperties[] = [ { @@ -79,7 +80,7 @@ export const description: INodeProperties[] = [ description: 'Retrieve an item from a list', routing: { request: { - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, method: 'GET', url: '=/sites/{{ $parameter["site"] }}/lists/{{ $parameter["list"] }}/items/{{ $parameter["item"] }}', }, diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/list/List.resource.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/list/List.resource.ts index 03eb2e68aad..b9ae7299b44 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/list/List.resource.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/descriptions/list/List.resource.ts @@ -3,6 +3,7 @@ import type { INodeProperties } from 'n8n-workflow'; import * as get from './get.operation'; import * as getAll from './getAll.operation'; import { handleErrorPostReceive, simplifyListPostReceive } from '../../helpers/utils'; +import { ignoreHttpStatusErrorsConfig } from '../common.descriptions'; export const description: INodeProperties[] = [ { @@ -22,7 +23,7 @@ export const description: INodeProperties[] = [ description: 'Retrieve details of a single list', routing: { request: { - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, method: 'GET', url: '=/sites/{{ $parameter["site"] }}/lists/{{ $parameter["list"] }}', }, diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/item/getTokenRefresh.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/item/getTokenRefresh.test.ts new file mode 100644 index 00000000000..ed53f76e754 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/item/getTokenRefresh.test.ts @@ -0,0 +1,96 @@ +import { CredentialsHelper } from '@nodes-testing/credentials-helper'; +import { NodeTestHarness } from '@nodes-testing/node-test-harness'; +import nock from 'nock'; + +import { credentials } from '../credentials'; + +describe('Microsoft SharePoint Node - Token Refresh', () => { + const { baseUrl } = credentials.microsoftSharePointOAuth2Api; + const tokenRefreshUrl = 'https://login.microsoftonline.com'; + const itemPath = + '/sites/site1/lists/list1/items/item1?%24select=id%2CcreatedDateTime%2ClastModifiedDateTime%2CwebUrl&%24expand=fields%28select%3DTitle%29'; + + let updateCredentialsSpy: jest.SpyInstance; + + beforeAll(() => { + // Mock the credential helper to return oAuth2Api as parent type + jest.spyOn(CredentialsHelper.prototype, 'getParentTypes').mockReturnValue(['oAuth2Api']); + }); + + beforeEach(() => { + // Spy on the updateCredentialsOauthTokenData to verify token refresh + updateCredentialsSpy = jest + .spyOn(CredentialsHelper.prototype, 'updateCredentialsOauthTokenData') + .mockResolvedValue(); + + // Set up mocks for token refresh scenario + // First request: Return 401 Unauthorized (expired token) + nock(baseUrl) + .get(itemPath) + .matchHeader('Authorization', 'Bearer ACCESSTOKEN') + .reply(401, { + error: { + code: 'InvalidAuthenticationToken', + message: 'Access token has expired.', + }, + }); + + // Token refresh request + nock(tokenRefreshUrl).post('/common/oauth2/v2.0/token').reply(200, { + token_type: 'Bearer', + scope: + 'https://mydomain.sharepoint.com/Sites.Manage.All https://mydomain.sharepoint.com/.default', + expires_in: 3599, + ext_expires_in: 3599, + access_token: 'NEWACCESSTOKEN', + refresh_token: 'NEWREFRESHTOKEN', + }); + + // Retry request with new token: Return success + nock(baseUrl) + .get(itemPath) + .matchHeader('Authorization', 'Bearer NEWACCESSTOKEN') + .reply(200, { + '@odata.context': + 'https://mydomain.sharepoint.com/sites/site1/_api/v2.0/$metadata#listItems/$entity', + '@odata.etag': '"07bfcdd5-450d-48ce-8dc3-04f7f59edc5f,1"', + id: 'item1', + createdDateTime: '2025-03-12T22:18:18Z', + lastModifiedDateTime: '2025-03-12T22:18:18Z', + webUrl: 'https://mydomain.sharepoint.com/sites/site1/Lists/name%20list/1_.000', + 'fields@odata.navigationLink': 'sites/site1/lists/list1/items/item1/fields', + fields: { + '@odata.etag': '"07bfcdd5-450d-48ce-8dc3-04f7f59edc5f,1"', + Title: 'Item 1', + }, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + // Use the harness's setupTests to automatically run the workflow + new NodeTestHarness().setupTests({ + credentials, + workflowFiles: ['getTokenRefresh.workflow.json'], + customAssertions: () => { + // Verify the token was refreshed and saved + expect(updateCredentialsSpy).toHaveBeenCalledTimes(1); + expect(updateCredentialsSpy.mock.calls[0][1]).toBe('microsoftSharePointOAuth2Api'); + expect(updateCredentialsSpy.mock.calls[0][2]).toMatchObject({ + oauthTokenData: expect.objectContaining({ + access_token: 'NEWACCESSTOKEN', + refresh_token: 'NEWREFRESHTOKEN', + }), + }); + + // Verify all nock interceptors were called (401 -> refresh -> retry) + expect(nock.isDone()).toBe(true); + }, + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/item/getTokenRefresh.workflow.json b/packages/nodes-base/nodes/Microsoft/SharePoint/test/item/getTokenRefresh.workflow.json new file mode 100644 index 00000000000..d67abc69afb --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/item/getTokenRefresh.workflow.json @@ -0,0 +1,76 @@ +{ + "nodes": [ + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "d8d29d0a-bb31-4094-a252-8008932f5425", + "name": "When clicking 'Test workflow'" + }, + { + "parameters": { + "resource": "item", + "operation": "get", + "site": { + "__rl": true, + "value": "site1", + "mode": "list", + "cachedResultName": "site1" + }, + "list": { + "__rl": true, + "value": "list1", + "mode": "list", + "cachedResultName": "list1" + }, + "item": { + "__rl": true, + "value": "item1", + "mode": "list", + "cachedResultName": "item1" + }, + "requestOptions": {} + }, + "type": "n8n-nodes-base.microsoftSharePoint", + "typeVersion": 1, + "position": [200, 0], + "id": "0e19be10-9d94-4654-89e9-432daa8102cb", + "name": "Microsoft SharePoint", + "credentials": { + "microsoftSharePointOAuth2Api": { + "id": "cXXnMCWyk397M5qJ", + "name": "Microsoft SharePoint account" + } + } + } + ], + "connections": { + "When clicking 'Test workflow'": { + "main": [ + [ + { + "node": "Microsoft SharePoint", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": { + "Microsoft SharePoint": [ + { + "json": { + "id": "item1", + "createdDateTime": "2025-03-12T22:18:18Z", + "lastModifiedDateTime": "2025-03-12T22:18:18Z", + "webUrl": "https://mydomain.sharepoint.com/sites/site1/Lists/name%20list/1_.000", + "fields": { + "Title": "Item 1" + } + } + } + ] + } +} diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index d745893c474..f9786b88fec 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -465,6 +465,12 @@ export interface IExecuteContextData { export type IHttpRequestMethods = 'DELETE' | 'GET' | 'HEAD' | 'PATCH' | 'POST' | 'PUT'; +export type IgnoreStatusErrorConfig = { + ignore: true; + /** Ignore HTTP status errors except for the specified status codes */ + except: number[]; +}; + /** used in helpers.httpRequest(WithAuthentication) */ export interface IHttpRequestOptions { url: string; @@ -483,7 +489,7 @@ export interface IHttpRequestOptions { encoding?: 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'; skipSslCertificateValidation?: boolean; returnFullResponse?: boolean; - ignoreHttpStatusErrors?: boolean; + ignoreHttpStatusErrors?: boolean | IgnoreStatusErrorConfig; proxy?: { host: string; port: number;