refactor(Microsoft Teams Node): Centralize the Service Principal guard and share the chat member binding (no-changelog) (#38928)

This commit is contained in:
Csaba Tuncsik 2026-09-23 10:38:21 +00:00 committed by GitHub
parent 0254343de3
commit 4eddcc43d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 124 additions and 223 deletions

View File

@ -149,6 +149,29 @@ describe('Microsoft Teams V2 - chatMember:add request body', () => {
expect(apiRequest).not.toHaveBeenCalled();
});
// Teams deep links carry the UPN percent-encoded. The validator decodes it, so the bind
// carries the plain UPN in the slash form the add-member docs show.
it('decodes a URL-copied UPN, so the bind carries the plain UPN', async () => {
setParams(addParams({ userId: 'jacob%40contoso.com', options: {} }));
await node.execute.call(ctx);
expect(apiRequest).toHaveBeenCalledWith('POST', '/v1.0/chats/19:abc@thread.v2/members', {
'@odata.type': '#microsoft.graph.aadUserConversationMember',
'user@odata.bind': 'https://graph.microsoft.com/v1.0/users/jacob@contoso.com',
roles: ['owner'],
});
});
// A lone surrogate is never a valid Graph id; the validator rejects it with the node's own
// error instead of letting it reach the request.
it('rejects a lone surrogate in the user id before any request', async () => {
setParams(addParams({ userId: `${userId}\uD800`, options: {} }));
await expect(node.execute.call(ctx)).rejects.toThrow('The ID is not valid');
expect(apiRequest).not.toHaveBeenCalled();
});
it('uses the credential graphApiBaseUrl in user@odata.bind (sovereign cloud)', async () => {
ctx.getCredentials.mockResolvedValue({ graphApiBaseUrl: 'https://graph.microsoft.us' });
setParams(addParams({ options: {} }));

View File

@ -26,7 +26,7 @@ vi.mock('../../../v2/transport', async () => {
};
});
describe('Microsoft Teams V2 — Service Principal runtime guards', () => {
describe('Microsoft Teams V2, Service Principal runtime guards', () => {
let node: MicrosoftTeamsV2;
let ctx: MockProxy<IExecuteFunctions>;
@ -58,71 +58,31 @@ describe('Microsoft Teams V2 — Service Principal runtime guards', () => {
);
};
// One case per resource: the router throws before it reads `operation`, so every operation
// of a hidden resource takes the same path. The `description` is pinned too, so a swapped
// entry in the router's guard map fails here.
it.each([
['create', 'create'],
['get', 'get'],
['getAll', 'getAll'],
])('chatMessage:%s throws a static error and issues no request under SP', async (_label, op) => {
selectSp({
resource: 'chatMessage',
operation: op,
chatId: 'chatID',
returnAll: true,
// a mention row, so moving the guard below resolveMentions fires a GET /v1.0/users/...
'mentions.mention': [
{ userId: { __rl: true, mode: 'id', value: '714c1202-cbac-40ff-9160-53ab5c4df9b8' } },
],
});
await expect(node.execute.call(ctx)).rejects.toThrow(
[
'chat',
'Chats are not available with the Service Principal credential',
'App-only Microsoft Graph has no signed-in user to read or create chats for. Use an OAuth2 credential for chat actions.',
],
[
'chatMember',
'Chat members are not available with the Service Principal credential',
'The chat picker cannot list chats app-only. Use an OAuth2 credential for chat actions.',
],
[
'chatMessage',
'Chat messages are not available with the Service Principal credential',
);
expect(transport.microsoftApiRequest).not.toHaveBeenCalled();
expect(transport.microsoftApiRequestAllItems).not.toHaveBeenCalled();
});
'App-only Microsoft Graph has no signed-in user. Use an OAuth2 credential for chat actions.',
],
])(
'%s throws a static error and issues no request under SP',
async (resource, message, description) => {
selectSp({ resource, operation: 'create' });
it.each(['create', 'get', 'getAll'])(
'chat:%s throws a static error and issues no request under SP',
async (operation) => {
selectSp({
resource: 'chat',
operation,
chatId: 'chatID',
returnAll: true,
chatType: 'oneOnOne',
// a participant row, so moving the guard below the row loop fires a GET /v1.0/users/...
'members.member': [
{ userId: { __rl: true, mode: 'id', value: '714c1202-cbac-40ff-9160-53ab5c4df9b8' } },
],
'members.member[0].userId': '714c1202-cbac-40ff-9160-53ab5c4df9b8',
});
await expect(node.execute.call(ctx)).rejects.toThrow(
'Chats are not available with the Service Principal credential',
);
expect(transport.microsoftApiRequest).not.toHaveBeenCalled();
expect(transport.microsoftApiRequestAllItems).not.toHaveBeenCalled();
// the guard reads only the `authentication` parameter, never the credential itself
expect(ctx.getCredentials).not.toHaveBeenCalled();
},
);
it.each(['add', 'getAll', 'remove'])(
'chatMember:%s throws a static error and issues no request under SP',
async (operation) => {
selectSp({
resource: 'chatMember',
operation,
chatId: 'chatID',
userId: 'e76f456f-5c3f-4f1e-9d5e-4d8f0f6ab111',
membershipId: 'MCMjMiMj',
returnAll: true,
options: {},
});
await expect(node.execute.call(ctx)).rejects.toThrow(
'Chat members are not available with the Service Principal credential',
);
await expect(node.execute.call(ctx)).rejects.toMatchObject({ message, description });
expect(transport.microsoftApiRequest).not.toHaveBeenCalled();
expect(transport.microsoftApiRequestAllItems).not.toHaveBeenCalled();
// the guard reads only the `authentication` parameter, never the credential itself
@ -436,25 +396,6 @@ describe('Microsoft Teams V2 — Service Principal runtime guards', () => {
});
});
describe('chatMessage delete actions under SP', () => {
it.each(['softDeleteMessage', 'undoSoftDeleteMessage'])(
'chatMessage:%s throws a static error and issues no request under SP',
async (operation) => {
selectSp({
resource: 'chatMessage',
operation,
chatId: 'chatID',
messageId: '1698378560692',
});
await expect(node.execute.call(ctx)).rejects.toThrow(
'Chat messages are not available with the Service Principal credential',
);
expect(transport.microsoftApiRequest).not.toHaveBeenCalled();
},
);
});
describe('channelMessage delete actions under SP', () => {
it.each(['softDeleteMessage', 'undoSoftDeleteMessage'])(
'channelMessage:%s throws a static error and issues no request under SP',

View File

@ -20,7 +20,7 @@ vi.mock('../../../v2/transport', async () => {
};
});
const SP_UNSUPPORTED = /is not available with the Service Principal credential|are not available/;
const SP_UNSUPPORTED = /(is|are) not available with the Service Principal credential/;
/**
* `Workflow` drops a hidden parameter before execution, so a parameter behind `SP_HIDE` is

View File

@ -8,10 +8,13 @@ import {
import { escapeODataValue } from '@utils/query-escaping';
import { updateDisplayOptions } from '@utils/utilities';
import { throwIfChatUnsupported } from './sharedGuard';
import { stampItemIndexOnError, validateUserTargetId } from '../../../../GenericFunctions';
import { userRLC } from '../../descriptions';
import { resolveUserTarget, userTargetMessages } from '../../helpers/utils';
import {
aadUserConversationMember,
resolveUserTarget,
userTargetMessages,
} from '../../helpers/utils';
import { getGraphBaseUrl, microsoftApiRequest, SP_HIDE } from '../../transport';
const properties: INodeProperties[] = [
@ -116,9 +119,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://learn.microsoft.com/en-us/graph/api/chat-post?view=graph-rest-1.0
// App-only Graph has no signed-in user to create a chat for; fail before any request.
throwIfChatUnsupported.call(this);
const node = this.getNode();
const chatType = this.getNodeParameter('chatType', i) as 'oneOnOne' | 'group';
// The `''` fallback is required: `Workflow` strips hidden parameters before execution,
@ -239,11 +239,12 @@ export async function execute(this: IExecuteFunctions, i: number) {
const baseUrl = await getGraphBaseUrl.call(this);
const toMember = (member: { id: string; role: string; tenantId?: string }) => ({
'@odata.type': '#microsoft.graph.aadUserConversationMember',
roles: [member.role],
// Two escaping layers hold this bind: percent-encode the id for the URL (a B2B guest UPN
// truncates at its `#` otherwise), then double any quote for the OData literal.
'user@odata.bind': `${baseUrl}/v1.0/users('${escapeODataValue(encodeURIComponent(member.id))}')`,
...aadUserConversationMember(
`${baseUrl}/v1.0/users('${escapeODataValue(encodeURIComponent(member.id))}')`,
member.role,
),
...(member.tenantId ? { tenantId: member.tenantId } : {}),
});

View File

@ -2,7 +2,6 @@ import type { INodeProperties, IExecuteFunctions } from 'n8n-workflow';
import { updateDisplayOptions } from '@utils/utilities';
import { throwIfChatUnsupported } from './sharedGuard';
import { chatRLC } from '../../descriptions';
import { buildTeamsPath, microsoftApiRequest, rewriteNotFound, SP_HIDE } from '../../transport';
@ -23,9 +22,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://learn.microsoft.com/en-us/graph/api/chat-get?view=graph-rest-1.0
// App-only Graph cannot read chats; fail before any request.
throwIfChatUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
// Kept outside the try for clarity: it is a validation step, not a request.
const endpoint = buildTeamsPath.call(this, ['/v1.0/chats/', { id: chatId }]);

View File

@ -3,7 +3,6 @@ import type { INodeProperties, IExecuteFunctions } from 'n8n-workflow';
import { returnAllOrLimit } from '@utils/descriptions';
import { updateDisplayOptions } from '@utils/utilities';
import { throwIfChatUnsupported } from './sharedGuard';
import { microsoftApiRequestAllItems, SP_HIDE } from '../../transport';
/** Graph caps `$top` on `GET /chats` at 50 and 400s above it. */
@ -26,9 +25,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://learn.microsoft.com/en-us/graph/api/chat-list?view=graph-rest-1.0
// App-only Graph cannot list chats; fail before any request.
throwIfChatUnsupported.call(this);
const returnAll = this.getNodeParameter('returnAll', i);
// Clamp low on `limit`, not on `$top`: a 0 limit is falsy for the early return in
// `microsoftApiRequestAllItems`, so `$top: 1` alone would still walk every page.

View File

@ -1,24 +0,0 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { getTeamsCredentialType, SERVICE_PRINCIPAL_AUTH } from '../../transport';
/**
* Throws a static `NodeOperationError` when the node is configured with the
* app-only (Service Principal) credential. `GET /chats` and `POST /chats` are
* delegated-only (app-only must go through `GET /users/{id}/chats` and has no
* signed-in user to add to a new chat), so every chat operation guards on this
* BEFORE any request - covering hand-edited workflows that bypass the hidden UI.
*/
export function throwIfChatUnsupported(this: IExecuteFunctions): void {
if (getTeamsCredentialType.call(this) === SERVICE_PRINCIPAL_AUTH) {
throw new NodeOperationError(
this.getNode(),
'Chats are not available with the Service Principal credential',
{
description:
'App-only Microsoft Graph has no signed-in user to read or create chats for. Use an OAuth2 credential for chat actions.',
},
);
}
}

View File

@ -9,6 +9,7 @@ import {
import { updateDisplayOptions } from '@utils/utilities';
import { chatRLC, userRLC } from '../../descriptions';
import { aadUserConversationMember } from '../../helpers/utils';
import {
buildTeamsPath,
getGraphBaseUrl,
@ -16,7 +17,6 @@ import {
SP_HIDE,
validateTeamsId,
} from '../../transport';
import { throwIfChatMemberUnsupported } from './sharedGuard';
const properties: INodeProperties[] = [
{
@ -104,9 +104,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://learn.microsoft.com/en-us/graph/api/chat-post-members?view=graph-rest-1.0
// The chat picker cannot list chats app-only; fail before any request.
throwIfChatMemberUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
// Direct validator call rather than buildTeamsPath: this id is interpolated into
// the body, and RLC `validation` is UI-only, so an expression can still supply
@ -117,11 +114,12 @@ export async function execute(this: IExecuteFunctions, i: number) {
);
const options = this.getNodeParameter('options', i, {});
const body: IDataObject = {
'@odata.type': '#microsoft.graph.aadUserConversationMember',
'user@odata.bind': `${await getGraphBaseUrl.call(this)}/v1.0/users/${userId}`,
roles: [options.role === 'guest' ? 'guest' : 'owner'],
};
// Slash key form, as the add-member docs show. Raw is safe: the validator above rejects
// `/ \ ? # %`, so the id cannot break out of the path.
const body: IDataObject = aadUserConversationMember(
`${await getGraphBaseUrl.call(this)}/v1.0/users/${userId}`,
options.role === 'guest' ? 'guest' : 'owner',
);
// Not inverted: omitting the field shares NO history, and the 0001-01-01 sentinel
// shares the WHOLE history.

View File

@ -5,7 +5,6 @@ import { updateDisplayOptions } from '@utils/utilities';
import { chatRLC } from '../../descriptions';
import { buildTeamsPath, microsoftApiRequestAllItems, SP_HIDE } from '../../transport';
import { throwIfChatMemberUnsupported } from './sharedGuard';
const properties: INodeProperties[] = [chatRLC, ...returnAllOrLimit];
@ -24,9 +23,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://learn.microsoft.com/en-us/graph/api/chat-list-members?view=graph-rest-1.0
// The chat picker cannot list chats app-only; fail before any request.
throwIfChatMemberUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
const endpoint = buildTeamsPath.call(this, ['/v1.0/chats/', { id: chatId }, '/members']);

View File

@ -7,7 +7,6 @@ import {
import { updateDisplayOptions } from '@utils/utilities';
import { throwIfChatMemberUnsupported } from './sharedGuard';
import { chatMemberRLC, chatRLC } from '../../descriptions';
import { buildTeamsPath, microsoftApiRequest, SP_HIDE } from '../../transport';
@ -28,9 +27,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://learn.microsoft.com/en-us/graph/api/chat-delete-members?view=graph-rest-1.0
// The chat picker cannot list chats app-only; fail before any request.
throwIfChatMemberUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
const membershipId = this.getNodeParameter('membershipId', i, '', {
extractValue: true,

View File

@ -1,25 +0,0 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { getTeamsCredentialType, SERVICE_PRINCIPAL_AUTH } from '../../transport';
/**
* Throws a static `NodeOperationError` when the node is configured with the
* app-only (Service Principal) credential. The endpoints themselves have
* application permissions, but the chat picker does not: `GET /chats` is
* delegated-only (app-only must go through `GET /users/{id}/chats`), so every
* chatMember operation guards on this BEFORE any request - covering
* hand-edited workflows that bypass the hidden UI.
*/
export function throwIfChatMemberUnsupported(this: IExecuteFunctions): void {
if (getTeamsCredentialType.call(this) === SERVICE_PRINCIPAL_AUTH) {
throw new NodeOperationError(
this.getNode(),
'Chat members are not available with the Service Principal credential',
{
description:
'The chat picker cannot list chats app-only. Use an OAuth2 credential for chat actions.',
},
);
}
}

View File

@ -10,7 +10,6 @@ import {
} from '../../descriptions';
import { prepareMessage, resolveMentions } from '../../helpers/utils';
import { buildTeamsPath, microsoftApiRequest, SP_HIDE } from '../../transport';
import { throwIfChatMessageUnsupported } from './sharedGuard';
const properties: INodeProperties[] = [
chatRLC,
@ -70,9 +69,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number, instanceId: string) {
// https://docs.microsoft.com/en-us/graph/api/channel-post-messages?view=graph-rest-1.0&tabs=http
// App-only Graph cannot post chat messages; fail before any request.
throwIfChatMessageUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
const contentType = this.getNodeParameter('contentType', i) as string;
const message = this.getNodeParameter('message', i) as string;

View File

@ -4,7 +4,6 @@ import { updateDisplayOptions } from '@utils/utilities';
import { chatRLC } from '../../descriptions';
import { buildTeamsPath, microsoftApiRequest, SP_HIDE } from '../../transport';
import { throwIfChatMessageUnsupported } from './sharedGuard';
const properties: INodeProperties[] = [
chatRLC,
@ -34,10 +33,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://docs.microsoft.com/en-us/graph/api/chat-list-messages?view=graph-rest-1.0&tabs=http
// App-only Graph cannot read chats; fail before the request (and before the
// catch below, so the static SP message is surfaced, not the generic one).
throwIfChatMessageUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
const messageId = this.getNodeParameter('messageId', i) as string;
// Built outside the try so an invalid id surfaces as its own validation

View File

@ -5,7 +5,6 @@ import { updateDisplayOptions } from '@utils/utilities';
import { chatRLC } from '../../descriptions';
import { buildTeamsPath, microsoftApiRequestAllItems, SP_HIDE } from '../../transport';
import { throwIfChatMessageUnsupported } from './sharedGuard';
const properties: INodeProperties[] = [chatRLC, ...returnAllOrLimit];
@ -24,9 +23,6 @@ export const description = updateDisplayOptions(displayOptions, properties);
export async function execute(this: IExecuteFunctions, i: number) {
// https://docs.microsoft.com/en-us/graph/api/chat-list-messages?view=graph-rest-1.0&tabs=http
// App-only Graph cannot read chats; fail before any request.
throwIfChatMessageUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
const returnAll = this.getNodeParameter('returnAll', i);

View File

@ -1,7 +1,6 @@
import type { IExecuteFunctions, INodeProperties } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { throwIfChatMessageUnsupported } from './sharedGuard';
import { chatRLC } from '../../descriptions';
import { readTextParameter } from '../../helpers/parameters';
import { buildTeamsPath, microsoftApiRequest, SP_HIDE } from '../../transport';
@ -78,8 +77,6 @@ export async function runMessageAction(
i: number,
action: ChatMessageAction,
) {
throwIfChatMessageUnsupported.call(this);
const chatId = readTextParameter.call(this, 'chatId', i, true);
const messageId = readTextParameter.call(this, 'messageId', i);
const messagePath = buildTeamsPath.call(this, [

View File

@ -7,7 +7,6 @@ import {
import { createUtmCampaignLink } from '../../../../../../utils/utilities';
import { chatRLC } from '../../descriptions';
import { buildTeamsPath, microsoftApiRequest, SP_HIDE } from '../../transport';
import { throwIfChatMessageUnsupported } from './sharedGuard';
export const description: INodeProperties[] = getSendAndWaitProperties(
[chatRLC],
@ -32,10 +31,6 @@ export const description: INodeProperties[] = getSendAndWaitProperties(
}));
export async function execute(this: IExecuteFunctions, i: number, instanceId: string) {
// App-only Graph cannot post chat messages. Dispatched from the router before the
// item loop, so this guard fires before any putExecutionToWait.
throwIfChatMessageUnsupported.call(this);
const chatId = this.getNodeParameter('chatId', i, '', { extractValue: true }) as string;
const config = getSendAndWaitConfig(this);

View File

@ -1,24 +0,0 @@
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { getTeamsCredentialType, SERVICE_PRINCIPAL_AUTH } from '../../transport';
/**
* Throws a static `NodeOperationError` when the node is configured with the
* app-only (Service Principal) credential. Chat messages have no usable app-only
* form (app-only Graph has no signed-in user), so every chatMessage operation
* guards on this BEFORE any request — covering hand-edited workflows that bypass
* the hidden UI. For `sendAndWait` it fires before any `putExecutionToWait`.
*/
export function throwIfChatMessageUnsupported(this: IExecuteFunctions): void {
if (getTeamsCredentialType.call(this) === SERVICE_PRINCIPAL_AUTH) {
throw new NodeOperationError(
this.getNode(),
'Chat messages are not available with the Service Principal credential',
{
description:
'App-only Microsoft Graph has no signed-in user. Use an OAuth2 credential for chat actions.',
},
);
}
}

View File

@ -11,33 +11,51 @@ import * as activityNotification from './activityNotification';
import * as channel from './channel';
import * as channelMessage from './channelMessage';
import * as chat from './chat';
import { throwIfChatUnsupported } from './chat/sharedGuard';
import * as chatMember from './chatMember';
import { throwIfChatMemberUnsupported } from './chatMember/sharedGuard';
import * as chatMessage from './chatMessage';
import { throwIfChatMessageUnsupported } from './chatMessage/sharedGuard';
import type { MicrosoftTeamsType } from './node.type';
import * as onlineMeeting from './onlineMeeting';
import * as task from './task';
import { configureWaitTillDate } from '../../../../../utils/sendAndWait/configureWaitTillDate.util';
import { stampItemIndexOnError } from '../../../GenericFunctions';
import { getTeamsCredentialType, SERVICE_PRINCIPAL_AUTH } from '../transport';
/**
* The resources whose operation selector is hidden under the Service Principal credential.
*
* `Workflow` drops a hidden parameter before execution, so `operation` is absent for these
* and the read below fails with `Could not get parameter "operation"` before any operation
* can run its own guard. Guard on the resource first, so the user gets the real reason.
* Each entry is the operation-level guard itself, so the message has one source.
* and the read below fails with `Could not get parameter "operation"`. Guard on the resource
* first, so the user gets the real reason. Each entry holds the error for one resource.
*
* A `Map`, not an object: `resource` is a stored parameter, and a plain object walks
* `Object.prototype`, so a resource of `__proto__` returns a non-nullish value and fails
* with a raw `TypeError` instead of the node's own unsupported-operation error.
* `Object.prototype`, so a resource of `__proto__` would match the guard, and the error
* constructor would fail with a raw `TypeError` on the `undefined` message.
*/
const SERVICE_PRINCIPAL_RESOURCE_GUARDS = new Map<string, (this: IExecuteFunctions) => void>([
['chat', throwIfChatUnsupported],
['chatMember', throwIfChatMemberUnsupported],
['chatMessage', throwIfChatMessageUnsupported],
const SERVICE_PRINCIPAL_RESOURCE_GUARDS = new Map([
[
'chat',
{
message: 'Chats are not available with the Service Principal credential',
description:
'App-only Microsoft Graph has no signed-in user to read or create chats for. Use an OAuth2 credential for chat actions.',
},
],
[
'chatMember',
{
message: 'Chat members are not available with the Service Principal credential',
description:
'The chat picker cannot list chats app-only. Use an OAuth2 credential for chat actions.',
},
],
[
'chatMessage',
{
message: 'Chat messages are not available with the Service Principal credential',
description:
'App-only Microsoft Graph has no signed-in user. Use an OAuth2 credential for chat actions.',
},
],
]);
export async function router(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
@ -46,7 +64,12 @@ export async function router(this: IExecuteFunctions): Promise<INodeExecutionDat
let responseData;
const resource = this.getNodeParameter<MicrosoftTeamsType>('resource', 0);
SERVICE_PRINCIPAL_RESOURCE_GUARDS.get(String(resource))?.call(this);
const unsupported = SERVICE_PRINCIPAL_RESOURCE_GUARDS.get(resource);
if (unsupported && getTeamsCredentialType.call(this) === SERVICE_PRINCIPAL_AUTH) {
throw new NodeOperationError(this.getNode(), unsupported.message, {
description: unsupported.description,
});
}
const operation = this.getNodeParameter('operation', 0);
const nodeVersion = this.getNode().typeVersion;

View File

@ -272,6 +272,16 @@ export async function resolveUserTarget(
}
}
/**
* Builds one `aadUserConversationMember` entry. `userRef` is the full `user@odata.bind` URL:
* the two endpoints document different key forms, so each call site composes its own.
*/
export const aadUserConversationMember = (userRef: string, role: string) => ({
'@odata.type': '#microsoft.graph.aadUserConversationMember',
roles: [role],
'user@odata.bind': userRef,
});
/**
* Resolves every mention row to a Graph user or team tag. Graph stores `mentions[].mentioned`
* verbatim and resolves nothing: a UPN, a well-formed but nonexistent GUID or a bogus tag ID is

View File

@ -535,6 +535,14 @@ describe('Microsoft Graph transport kernel', () => {
expect(() => validateMicrosoftGraphId(id, mockNode)).toThrow();
});
it.each(['\uD800', 'e76f456f\uDC00'])('rejects an id with a lone surrogate (%j)', (id) => {
expect(() => validateMicrosoftGraphId(id, mockNode)).toThrow('The ID is not valid');
});
it('accepts a well-formed astral character (a surrogate pair is not a lone surrogate)', () => {
expect(validateMicrosoftGraphId('team-\u{1F600}', mockNode)).toBe('team-\u{1F600}');
});
it.each([
'a/b',
'a\\b',

View File

@ -96,6 +96,13 @@ export function validateMicrosoftGraphId(id: string, node: INode): string {
description: 'The ID contains a malformed percent-encoding. Copy it again and try again.',
});
}
// A lone surrogate is not valid text. `decodeURIComponent` passes it through, and the
// `encodeURIComponent` on a body value (`@odata.bind`) then throws a raw `URIError`.
if (/\p{Surrogate}/u.test(value)) {
throw new NodeOperationError(node, 'The ID is not valid', {
description: 'The ID contains an invalid character. Copy it again and try again.',
});
}
if (/^\.+$/.test(value)) {
throw new NodeOperationError(node, 'The ID is not valid', {
description: 'An ID cannot consist only of dots.',