mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
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:
co-authored by
Michael Kret
parent
3798c417db
commit
f77ce6c8e8
+26
@@ -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', () => {
|
||||
|
||||
+20
-1
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
+76
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user