fix(Sharepoint Node): Ensure tokens are refreshed (#24978)

Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
This commit is contained in:
yehorkardash
2026-02-13 09:52:43 +00:00
committed by GitHub
co-authored by Michael Kret
parent 3798c417db
commit f77ce6c8e8
8 changed files with 234 additions and 4 deletions
@@ -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', () => {
@@ -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;
@@ -154,3 +154,8 @@ export const siteRLC: INodeProperties = {
required: true,
type: 'resourceLocator',
};
export const ignoreHttpStatusErrorsConfig = {
ignore: true as const,
except: [401],
};
@@ -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"] }}',
},
@@ -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"] }}',
},
@@ -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);
},
});
});
@@ -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"
}
}
}
]
}
}
+7 -1
View File
@@ -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;