feat(Microsoft SharePoint Node): Accept both v2 credentials on the trigger (no-changelog) (#39863)

This commit is contained in:
Stephen Wright 2026-10-02 10:49:37 +00:00 committed by GitHub
parent b22b6f4909
commit ff78115647
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 164 additions and 8 deletions

View File

@ -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<INodeExecutionData[][] | null> {

View File

@ -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<IPollFunctions>();
ctx.getNode.mockReturnValue(mock<INode>());
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');
});
});
});

View File

@ -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. */