From ff781156478b480ba9936c8573b829c4a75a715a Mon Sep 17 00:00:00 2001 From: Stephen Wright Date: Fri, 2 Oct 2026 10:49:37 +0000 Subject: [PATCH] feat(Microsoft SharePoint Node): Accept both v2 credentials on the trigger (no-changelog) (#39863) --- .../MicrosoftSharePointTrigger.node.ts | 49 +++++++- .../MicrosoftSharePointTrigger.node.test.ts | 113 +++++++++++++++++- .../Microsoft/SharePoint/transport/index.ts | 10 +- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts index defdf3e6bd5..2a2d084325e 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/MicrosoftSharePointTrigger.node.ts @@ -6,6 +6,8 @@ import { NodeConnectionTypes, } from 'n8n-workflow'; +import { SERVICE_PRINCIPAL_AUTH } from './transport'; + export class MicrosoftSharePointTrigger implements INodeType { description: INodeTypeDescription = { displayName: 'Microsoft SharePoint Trigger', @@ -21,13 +23,58 @@ export class MicrosoftSharePointTrigger implements INodeType { defaults: { name: 'Microsoft SharePoint Trigger', }, + // The v1 credential (microsoftSharePointOAuth2Api) is not offered: its + // tokens target the legacy {subdomain}.sharepoint.com/_api host and fail + // against Graph. + credentials: [ + { + name: 'microsoftOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['microsoftOAuth2Api'], + }, + }, + }, + { + name: SERVICE_PRINCIPAL_AUTH, + required: true, + displayOptions: { + show: { + authentication: [SERVICE_PRINCIPAL_AUTH], + }, + }, + }, + ], // Stays out of the node picker until the whole trigger is built. Note this // also labels the node "Deprecated" in the NDV until it is removed. hidden: true, polling: true, inputs: [], outputs: [NodeConnectionTypes.Main], - properties: [], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Microsoft OAuth2 (Graph)', + value: 'microsoftOAuth2Api', + description: + 'Generic Microsoft Graph credential. Enable the scopes this trigger needs (e.g. Sites.Read.All) on the credential.', + }, + { + name: 'Microsoft Entra Service Principal (App-Only)', + value: SERVICE_PRINCIPAL_AUTH, + description: + 'App-only access via a Microsoft Entra app registration. Polling continues when no user is signed in.', + }, + ], + default: 'microsoftOAuth2Api', + }, + ], }; async poll(this: IPollFunctions): Promise { diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts index f992d84c90c..cc58841913f 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/test/MicrosoftSharePointTrigger.node.test.ts @@ -1,9 +1,10 @@ import { readFileSync } from 'fs'; -import type { IPollFunctions } from 'n8n-workflow'; +import type { INode, IPollFunctions } from 'n8n-workflow'; import { join } from 'path'; import { mock } from 'vitest-mock-extended'; import { MicrosoftSharePointTrigger } from '../MicrosoftSharePointTrigger.node'; +import { microsoftApiRequest, SERVICE_PRINCIPAL_AUTH } from '../transport'; describe('Microsoft SharePoint Trigger', () => { const { description } = new MicrosoftSharePointTrigger(); @@ -28,9 +29,40 @@ describe('Microsoft SharePoint Trigger', () => { expect(description.hidden).toBe(true); }); - it('carries no credential or parameter yet', () => { - expect(description.credentials).toBeUndefined(); - expect(description.properties).toEqual([]); + it('declares its own credential pair, independent of the action node', () => { + expect(description.credentials?.map((c) => c.name)).toEqual([ + 'microsoftOAuth2Api', + SERVICE_PRINCIPAL_AUTH, + ]); + }); + + it('leads with an authentication selector defaulting to the delegated credential', () => { + const auth = description.properties[0]; + + expect(auth.name).toBe('authentication'); + expect(auth.type).toBe('options'); + expect(auth.noDataExpression).toBe(true); + expect(auth.default).toBe('microsoftOAuth2Api'); + expect((auth.options ?? []).map((o) => ('value' in o ? o.value : undefined))).toEqual([ + 'microsoftOAuth2Api', + SERVICE_PRINCIPAL_AUTH, + ]); + }); + + it.each(['microsoftOAuth2Api', SERVICE_PRINCIPAL_AUTH])( + 'gates the %s credential behind its own authentication value', + (name) => { + const credential = description.credentials?.find((c) => c.name === name); + + expect(credential?.required).toBe(true); + expect(credential?.displayOptions?.show?.authentication).toEqual([name]); + }, + ); + + it('does not offer the legacy SharePoint credential, whose tokens cannot reach Graph', () => { + expect(description.credentials?.map((c) => c.name)).not.toContain( + 'microsoftSharePointOAuth2Api', + ); }); it('emits nothing until the poll is implemented', async () => { @@ -47,4 +79,77 @@ describe('Microsoft SharePoint Trigger', () => { expect(codex.node).toBe('n8n-nodes-base.microsoftSharePointTrigger'); }); + + describe('authenticating a poll', () => { + const pollContext = (authentication?: string) => { + const ctx = mock(); + ctx.getNode.mockReturnValue(mock()); + ctx.getNodeParameter.mockImplementation( + (name: string, fallback?: unknown) => + (name === 'authentication' ? (authentication ?? fallback) : fallback) as never, + ); + ctx.getCredentials.mockResolvedValue({ graphApiBaseUrl: 'https://graph.microsoft.com' }); + return ctx; + }; + + it.each([ + ['microsoftOAuth2Api', 'requestOAuth2'], + [SERVICE_PRINCIPAL_AUTH, 'requestWithAuthentication'], + ] as const)('sends a %s poll through %s', async (authentication, helper) => { + const ctx = pollContext(authentication); + const send = vi.fn().mockResolvedValue({ value: [] }); + ctx.helpers[helper] = send; + + await microsoftApiRequest.call(ctx, 'GET', '/v1.0/sites/root'); + + expect(send).toHaveBeenCalledTimes(1); + expect(ctx.getCredentials).toHaveBeenCalledWith(authentication); + }); + + it.each([ + [ + 'microsoftOAuth2Api', + 'requestOAuth2', + 'the signed-in account may not have access to this resource', + ], + [ + SERVICE_PRINCIPAL_AUTH, + 'requestWithAuthentication', + 'missing a consented application permission', + ], + ] as const)('explains a 403 in %s terms', async (authentication, helper, wording) => { + const ctx = pollContext(authentication); + ctx.helpers[helper] = vi + .fn() + .mockRejectedValue(Object.assign(new Error('Forbidden'), { statusCode: 403 })); + + await expect(microsoftApiRequest.call(ctx, 'GET', '/v1.0/sites/root')).rejects.toMatchObject({ + httpCode: '403', + message: expect.stringContaining(wording), + }); + }); + + it('falls back to the delegated credential when reading the parameter throws', async () => { + const ctx = pollContext(); + ctx.getNodeParameter.mockImplementation(() => { + throw new Error('Could not get parameter'); + }); + const send = vi.fn().mockResolvedValue({ value: [] }); + ctx.helpers.requestOAuth2 = send; + + await microsoftApiRequest.call(ctx, 'GET', '/v1.0/sites/root'); + + expect(ctx.getCredentials).toHaveBeenCalledWith('microsoftOAuth2Api'); + }); + + it('falls back to the delegated credential when no authentication is set', async () => { + const ctx = pollContext(); + const send = vi.fn().mockResolvedValue({ value: [] }); + ctx.helpers.requestOAuth2 = send; + + await microsoftApiRequest.call(ctx, 'GET', '/v1.0/sites/root'); + + expect(ctx.getCredentials).toHaveBeenCalledWith('microsoftOAuth2Api'); + }); + }); }); diff --git a/packages/nodes-base/nodes/Microsoft/SharePoint/transport/index.ts b/packages/nodes-base/nodes/Microsoft/SharePoint/transport/index.ts index 698df07bcf9..705a06ddbca 100644 --- a/packages/nodes-base/nodes/Microsoft/SharePoint/transport/index.ts +++ b/packages/nodes-base/nodes/Microsoft/SharePoint/transport/index.ts @@ -81,9 +81,13 @@ export const REQUIRED_PERMISSIONS: Readonly< }); export function getSharePointCredentialType(this: SharePointContext): SharePointCredentialType { - // In load-options contexts the 2nd arg is the fallback, not an item index — keep the 2-arg form - const selected = this.getNodeParameter('authentication', 0); - return selected === SERVICE_PRINCIPAL_AUTH ? SERVICE_PRINCIPAL_AUTH : 'microsoftOAuth2Api'; + try { + // In load-options contexts the 2nd arg is the fallback, not an item index — keep the 2-arg form + const selected = this.getNodeParameter('authentication', 0); + return selected === SERVICE_PRINCIPAL_AUTH ? SERVICE_PRINCIPAL_AUTH : 'microsoftOAuth2Api'; + } catch { + return 'microsoftOAuth2Api'; + } } /** Best-effort lookup; load-options contexts may not expose resource/operation. */