diff --git a/packages/nodes-base/nodes/Microsoft/Entra/descriptions/GroupDescription.ts b/packages/nodes-base/nodes/Microsoft/Entra/descriptions/GroupDescription.ts index de8c640e2ec..1b797574275 100644 --- a/packages/nodes-base/nodes/Microsoft/Entra/descriptions/GroupDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/Entra/descriptions/GroupDescription.ts @@ -9,6 +9,7 @@ import type { } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; +import { ignoreHttpStatusErrorsConfig } from './common'; import { handleErrorPostReceive, microsoftApiRequest } from '../GenericFunctions'; export const groupOperations: INodeProperties[] = [ @@ -29,7 +30,7 @@ export const groupOperations: INodeProperties[] = [ description: 'Create a group', routing: { request: { - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, method: 'POST', url: '/groups', }, @@ -45,7 +46,7 @@ export const groupOperations: INodeProperties[] = [ description: 'Delete a group', routing: { request: { - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, method: 'DELETE', url: '=/groups/{{ $parameter["group"] }}', }, @@ -69,7 +70,7 @@ export const groupOperations: INodeProperties[] = [ description: 'Retrieve data for a specific group', routing: { request: { - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, method: 'GET', url: '=/groups/{{ $parameter["group"] }}', }, @@ -85,7 +86,7 @@ export const groupOperations: INodeProperties[] = [ description: 'Retrieve a list of groups', routing: { request: { - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, method: 'GET', url: '/groups', }, @@ -109,7 +110,7 @@ export const groupOperations: INodeProperties[] = [ description: 'Update a group', routing: { request: { - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, method: 'PATCH', url: '=/groups/{{ $parameter["group"] }}', }, diff --git a/packages/nodes-base/nodes/Microsoft/Entra/descriptions/UserDescription.ts b/packages/nodes-base/nodes/Microsoft/Entra/descriptions/UserDescription.ts index 5a901070466..9f01bd0e5ea 100644 --- a/packages/nodes-base/nodes/Microsoft/Entra/descriptions/UserDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/Entra/descriptions/UserDescription.ts @@ -10,6 +10,7 @@ import { type INodeProperties, } from 'n8n-workflow'; +import { ignoreHttpStatusErrorsConfig } from './common'; import { handleErrorPostReceive, microsoftApiRequest } from '../GenericFunctions'; export const userOperations: INodeProperties[] = [ @@ -32,7 +33,7 @@ export const userOperations: INodeProperties[] = [ request: { method: 'POST', url: '=/groups/{{ $parameter["group"] }}/members/$ref', - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, }, output: { postReceive: [ @@ -56,7 +57,7 @@ export const userOperations: INodeProperties[] = [ request: { method: 'POST', url: '/users', - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, }, output: { postReceive: [handleErrorPostReceive], @@ -72,7 +73,7 @@ export const userOperations: INodeProperties[] = [ request: { method: 'DELETE', url: '=/users/{{ $parameter["user"] }}', - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, }, output: { postReceive: [ @@ -96,7 +97,7 @@ export const userOperations: INodeProperties[] = [ request: { method: 'GET', url: '=/users/{{ $parameter["user"] }}', - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, }, output: { postReceive: [handleErrorPostReceive], @@ -112,7 +113,7 @@ export const userOperations: INodeProperties[] = [ request: { method: 'GET', url: '/users', - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, }, output: { postReceive: [ @@ -137,7 +138,7 @@ export const userOperations: INodeProperties[] = [ request: { method: 'DELETE', url: '=/groups/{{ $parameter["group"] }}/members/{{ $parameter["user"] }}/$ref', - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, }, output: { postReceive: [ @@ -161,7 +162,7 @@ export const userOperations: INodeProperties[] = [ request: { method: 'PATCH', url: '=/users/{{ $parameter["user"] }}', - ignoreHttpStatusErrors: true, + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, }, output: { postReceive: [ diff --git a/packages/nodes-base/nodes/Microsoft/Entra/descriptions/common.ts b/packages/nodes-base/nodes/Microsoft/Entra/descriptions/common.ts new file mode 100644 index 00000000000..052ad802820 --- /dev/null +++ b/packages/nodes-base/nodes/Microsoft/Entra/descriptions/common.ts @@ -0,0 +1,5 @@ +export const ignoreHttpStatusErrorsConfig = { + ignore: true as const, + // 401 responses must be passed to requestWithAuthentication so expired OAuth2 tokens can refresh. + except: [401], +}; diff --git a/packages/nodes-base/nodes/Microsoft/Entra/test/MicrosoftEntra.node.test.ts b/packages/nodes-base/nodes/Microsoft/Entra/test/MicrosoftEntra.node.test.ts index 32511d7b125..4449a6b162a 100644 --- a/packages/nodes-base/nodes/Microsoft/Entra/test/MicrosoftEntra.node.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Entra/test/MicrosoftEntra.node.test.ts @@ -1,9 +1,19 @@ +import { CredentialsHelper } from '@nodes-testing/credentials-helper'; import { NodeTestHarness } from '@nodes-testing/node-test-harness'; -import type { ILoadOptionsFunctions, WorkflowTestData } from 'n8n-workflow'; +import { convertN8nRequestToAxios } from 'n8n-core/dist/execution-engine/node-execution-context/utils/request-helper-functions'; +import type { + IExecuteSingleFunctions, + ILoadOptionsFunctions, + IN8nHttpFullResponse, + WorkflowTestData, +} from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; +import nock from 'nock'; import { microsoftEntraApiResponse, microsoftEntraNodeResponse } from './mocks'; import { MicrosoftEntra } from '../MicrosoftEntra.node'; +import { ignoreHttpStatusErrorsConfig } from '../descriptions/common'; +import { handleErrorPostReceive } from '../GenericFunctions'; describe('Microsoft Entra Node', () => { const testHarness = new NodeTestHarness(); @@ -100,6 +110,39 @@ describe('Microsoft Entra Node', () => { } }); + describe('HTTP status handling', () => { + it('handles non-authentication errors in the node error handler', async () => { + const axiosRequest = convertN8nRequestToAxios({ + method: 'DELETE', + url: 'https://graph.microsoft.com/v1.0/users/missing-user-id', + ignoreHttpStatusErrors: ignoreHttpStatusErrorsConfig, + }); + const response: IN8nHttpFullResponse = { + statusCode: 404, + headers: {}, + body: { + error: { + code: 'Request_ResourceNotFound', + message: 'Resource could not be found.', + }, + }, + }; + const context = { + getNode: jest.fn().mockReturnValue({ name: 'Microsoft Entra ID' }), + getNodeParameter: jest.fn((parameterName: string) => { + if (parameterName === 'resource') return 'user'; + if (parameterName === 'operation') return 'delete'; + return ''; + }), + } as unknown as IExecuteSingleFunctions; + + expect(axiosRequest.validateStatus?.(response.statusCode)).toBe(true); + await expect(handleErrorPostReceive.call(context, [], response)).rejects.toThrow( + "The required user doesn't match any existing one", + ); + }); + }); + describe('Load options', () => { it('should load group properties', async () => { const mockContext = { @@ -223,4 +266,170 @@ describe('Microsoft Entra Node', () => { }); }); }); + + describe('Token refresh', () => { + const tokenRefreshUrl = 'https://login.microsoftonline.com'; + const credentials = { + microsoftEntraOAuth2Api: { + grantType: 'authorizationCode', + authUrl: `${tokenRefreshUrl}/common/oauth2/v2.0/authorize`, + accessTokenUrl: `${tokenRefreshUrl}/common/oauth2/v2.0/token`, + clientId: 'CLIENT_ID', + clientSecret: 'CLIENT_SECRET', + scope: 'openid offline_access User.ReadWrite.All', + authQueryParameters: 'response_mode=query', + authentication: 'body', + graphApiBaseUrl: 'https://graph.microsoft.com', + oauthTokenData: { + token_type: 'Bearer', + expires_in: 3599, + access_token: 'ACCESSTOKEN', + refresh_token: 'REFRESHTOKEN', + }, + }, + }; + + let updateCredentialsSpy: jest.SpyInstance; + + beforeEach(() => { + jest.spyOn(CredentialsHelper.prototype, 'getParentTypes').mockReturnValue(['oAuth2Api']); + + updateCredentialsSpy = jest + .spyOn(CredentialsHelper.prototype, 'updateCredentialsOauthTokenData') + .mockResolvedValue(); + + nock('https://graph.microsoft.com') + .get('/v1.0/users') + .query(true) + .matchHeader('Authorization', 'Bearer ACCESSTOKEN') + .reply(401, { + error: { + code: 'InvalidAuthenticationToken', + message: 'Lifetime validation failed, the token is expired.', + }, + }); + + nock(tokenRefreshUrl).post('/common/oauth2/v2.0/token').reply(200, { + token_type: 'Bearer', + scope: 'openid offline_access User.ReadWrite.All', + expires_in: 3599, + access_token: 'NEWACCESSTOKEN', + refresh_token: 'NEWREFRESHTOKEN', + }); + + nock('https://graph.microsoft.com') + .get('/v1.0/users') + .query(true) + .matchHeader('Authorization', 'Bearer NEWACCESSTOKEN') + .reply(200, { + value: [ + { + id: 'user-1', + createdDateTime: '2025-04-06T13:15:34Z', + displayName: 'Test User', + userPrincipalName: 'test.user@example.com', + mail: 'test.user@example.com', + mailNickname: 'test.user', + securityIdentifier: 'S-1-1-0', + }, + ], + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + testHarness.setupTest( + { + description: 'should refresh an expired token when getting users', + input: { + workflowData: { + nodes: [ + { + parameters: {}, + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 0], + id: '1307e408-a8a5-464e-b858-494953e2f43b', + name: 'When clicking ‘Execute workflow’', + }, + { + parameters: { + resource: 'user', + operation: 'getAll', + returnAll: false, + limit: 50, + filter: '', + output: 'simple', + requestOptions: {}, + }, + type: 'n8n-nodes-base.microsoftEntra', + typeVersion: 1, + position: [220, 0], + id: '3429f7f2-dfca-4b72-8913-43a582e96e66', + name: 'Microsoft Entra ID', + credentials: { + microsoftEntraOAuth2Api: { + id: 'Hot2KwSMSoSmMVqd', + name: 'Microsoft Entra ID (Azure Active Directory) account', + }, + }, + }, + ], + connections: { + 'When clicking ‘Execute workflow’': { + main: [ + [ + { + node: 'Microsoft Entra ID', + type: NodeConnectionTypes.Main, + index: 0, + }, + ], + ], + }, + }, + }, + }, + output: { + nodeData: { + 'Microsoft Entra ID': [ + [ + { + json: { + id: 'user-1', + createdDateTime: '2025-04-06T13:15:34Z', + displayName: 'Test User', + userPrincipalName: 'test.user@example.com', + mail: 'test.user@example.com', + mailNickname: 'test.user', + securityIdentifier: 'S-1-1-0', + }, + }, + ], + ], + }, + }, + }, + { + credentials, + customAssertions: () => { + expect(updateCredentialsSpy).toHaveBeenCalledTimes(1); + expect(updateCredentialsSpy.mock.calls[0][1]).toBe('microsoftEntraOAuth2Api'); + expect(updateCredentialsSpy.mock.calls[0][2]).toMatchObject({ + oauthTokenData: expect.objectContaining({ + access_token: 'NEWACCESSTOKEN', + refresh_token: 'NEWREFRESHTOKEN', + }), + }); + expect(nock.isDone()).toBe(true); + }, + }, + ); + }); });