mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(Microsoft SharePoint Node): Accept both v2 credentials on the trigger (no-changelog) (#39863)
This commit is contained in:
parent
b22b6f4909
commit
ff78115647
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user