@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
44import { msteamsPlugin } from "./channel.js" ;
55
66const {
7+ addParticipantMSTeamsMock,
78 editMessageMSTeamsMock,
89 deleteMessageMSTeamsMock,
910 getChannelInfoMSTeamsMock,
@@ -13,11 +14,14 @@ const {
1314 listReactionsMSTeamsMock,
1415 pinMessageMSTeamsMock,
1516 reactMessageMSTeamsMock,
17+ removeParticipantMSTeamsMock,
18+ renameGroupMSTeamsMock,
1619 searchMessagesMSTeamsMock,
1720 sendAdaptiveCardMSTeamsMock,
1821 sendMessageMSTeamsMock,
1922 unpinMessageMSTeamsMock,
2023} = vi . hoisted ( ( ) => ( {
24+ addParticipantMSTeamsMock : vi . fn ( ) ,
2125 editMessageMSTeamsMock : vi . fn ( ) ,
2226 deleteMessageMSTeamsMock : vi . fn ( ) ,
2327 getChannelInfoMSTeamsMock : vi . fn ( ) ,
@@ -27,6 +31,8 @@ const {
2731 listReactionsMSTeamsMock : vi . fn ( ) ,
2832 pinMessageMSTeamsMock : vi . fn ( ) ,
2933 reactMessageMSTeamsMock : vi . fn ( ) ,
34+ removeParticipantMSTeamsMock : vi . fn ( ) ,
35+ renameGroupMSTeamsMock : vi . fn ( ) ,
3036 searchMessagesMSTeamsMock : vi . fn ( ) ,
3137 sendAdaptiveCardMSTeamsMock : vi . fn ( ) ,
3238 sendMessageMSTeamsMock : vi . fn ( ) ,
@@ -35,6 +41,7 @@ const {
3541
3642vi . mock ( "./channel.runtime.js" , ( ) => ( {
3743 msTeamsChannelRuntime : {
44+ addParticipantMSTeams : addParticipantMSTeamsMock ,
3845 editMessageMSTeams : editMessageMSTeamsMock ,
3946 deleteMessageMSTeams : deleteMessageMSTeamsMock ,
4047 getChannelInfoMSTeams : getChannelInfoMSTeamsMock ,
@@ -44,6 +51,8 @@ vi.mock("./channel.runtime.js", () => ({
4451 listReactionsMSTeams : listReactionsMSTeamsMock ,
4552 pinMessageMSTeams : pinMessageMSTeamsMock ,
4653 reactMessageMSTeams : reactMessageMSTeamsMock ,
54+ removeParticipantMSTeams : removeParticipantMSTeamsMock ,
55+ renameGroupMSTeams : renameGroupMSTeamsMock ,
4756 searchMessagesMSTeams : searchMessagesMSTeamsMock ,
4857 sendAdaptiveCardMSTeams : sendAdaptiveCardMSTeamsMock ,
4958 sendMessageMSTeams : sendMessageMSTeamsMock ,
@@ -52,6 +61,7 @@ vi.mock("./channel.runtime.js", () => ({
5261} ) ) ;
5362
5463const actionMocks = [
64+ addParticipantMSTeamsMock ,
5565 editMessageMSTeamsMock ,
5666 deleteMessageMSTeamsMock ,
5767 getChannelInfoMSTeamsMock ,
@@ -61,6 +71,8 @@ const actionMocks = [
6171 listReactionsMSTeamsMock ,
6272 pinMessageMSTeamsMock ,
6373 reactMessageMSTeamsMock ,
74+ removeParticipantMSTeamsMock ,
75+ renameGroupMSTeamsMock ,
6476 searchMessagesMSTeamsMock ,
6577 sendAdaptiveCardMSTeamsMock ,
6678 sendMessageMSTeamsMock ,
@@ -82,6 +94,8 @@ const reactMissingEmojiError =
8294 "React requires an emoji (reaction type). Valid types: like, heart, laugh, surprised, sad, angry." ;
8395const reactMissingEmojiDetail = "React requires an emoji (reaction type)." ;
8496const searchMissingQueryError = "Search requires a target (to) and query." ;
97+ const groupManagementAuthError =
98+ "Microsoft Teams group management requires an owner or operator.admin requester." ;
8599
86100function padded ( value : string ) {
87101 return ` ${ value } ` ;
@@ -114,6 +128,9 @@ async function runAction(params: {
114128 toolContext ?: Record < string , unknown > ;
115129 mediaLocalRoots ?: readonly string [ ] ;
116130 mediaReadFile ?: ( filePath : string ) => Promise < Buffer > ;
131+ requesterSenderId ?: string | null ;
132+ senderIsOwner ?: boolean ;
133+ gatewayClientScopes ?: readonly string [ ] ;
117134} ) {
118135 const handleAction = requireMSTeamsHandleAction ( ) ;
119136 return await handleAction ( {
@@ -124,6 +141,9 @@ async function runAction(params: {
124141 mediaLocalRoots : params . mediaLocalRoots ,
125142 mediaReadFile : params . mediaReadFile ,
126143 toolContext : params . toolContext ,
144+ requesterSenderId : params . requesterSenderId ,
145+ senderIsOwner : params . senderIsOwner ,
146+ gatewayClientScopes : params . gatewayClientScopes ,
127147 } as Parameters < ReturnType < typeof requireMSTeamsHandleAction > > [ 0 ] ) ;
128148}
129149
@@ -182,6 +202,9 @@ async function expectSuccessfulAction(params: {
182202 toolContext ?: Parameters < typeof runAction > [ 0 ] [ "toolContext" ] ;
183203 mediaLocalRoots ?: Parameters < typeof runAction > [ 0 ] [ "mediaLocalRoots" ] ;
184204 mediaReadFile ?: Parameters < typeof runAction > [ 0 ] [ "mediaReadFile" ] ;
205+ requesterSenderId ?: Parameters < typeof runAction > [ 0 ] [ "requesterSenderId" ] ;
206+ senderIsOwner ?: Parameters < typeof runAction > [ 0 ] [ "senderIsOwner" ] ;
207+ gatewayClientScopes ?: Parameters < typeof runAction > [ 0 ] [ "gatewayClientScopes" ] ;
185208 runtimeParams : Record < string , unknown > ;
186209 details : Record < string , unknown > ;
187210 contentDetails ?: Record < string , unknown > ;
@@ -193,6 +216,9 @@ async function expectSuccessfulAction(params: {
193216 mediaLocalRoots : params . mediaLocalRoots ,
194217 mediaReadFile : params . mediaReadFile ,
195218 toolContext : params . toolContext ,
219+ requesterSenderId : params . requesterSenderId ,
220+ senderIsOwner : params . senderIsOwner ,
221+ gatewayClientScopes : params . gatewayClientScopes ,
196222 } ) ;
197223 expectActionRuntimeCall ( params . mockFn , params . runtimeParams ) ;
198224 expectActionSuccess ( result , params . details , params . contentDetails ) ;
@@ -351,6 +377,147 @@ describe("msteamsPlugin message actions", () => {
351377 } ) ;
352378 } ) ;
353379
380+ it ( "requires trusted requester sender for Teams group-management actions from Teams turns" , ( ) => {
381+ const requiresTrustedRequesterSender = msteamsPlugin . actions ?. requiresTrustedRequesterSender ;
382+ if ( ! requiresTrustedRequesterSender ) {
383+ throw new Error ( "msteams actions.requiresTrustedRequesterSender unavailable" ) ;
384+ }
385+
386+ for ( const action of [ "addParticipant" , "removeParticipant" , "renameGroup" ] as const ) {
387+ expect (
388+ requiresTrustedRequesterSender ( {
389+ action,
390+ toolContext : { currentChannelProvider : "msteams" } ,
391+ } ) ,
392+ ) . toBe ( true ) ;
393+ }
394+ expect (
395+ requiresTrustedRequesterSender ( {
396+ action : "addParticipant" ,
397+ toolContext : { currentChannelProvider : "discord" } ,
398+ } ) ,
399+ ) . toBe ( false ) ;
400+ expect (
401+ requiresTrustedRequesterSender ( {
402+ action : "read" ,
403+ toolContext : { currentChannelProvider : "msteams" } ,
404+ } ) ,
405+ ) . toBe ( false ) ;
406+ } ) ;
407+
408+ it ( "rejects group-management actions from non-owner non-admin callers" , async ( ) => {
409+ const cases = [
410+ {
411+ action : "addParticipant" ,
412+ mockFn : addParticipantMSTeamsMock ,
413+ params : { target : targetChannelId , userId : "user-1" } ,
414+ } ,
415+ {
416+ action : "removeParticipant" ,
417+ mockFn : removeParticipantMSTeamsMock ,
418+ params : { target : targetChannelId , userId : "user-1" } ,
419+ } ,
420+ {
421+ action : "renameGroup" ,
422+ mockFn : renameGroupMSTeamsMock ,
423+ params : { target : targetChannelId , name : "Renamed group" } ,
424+ } ,
425+ ] as const ;
426+
427+ for ( const testCase of cases ) {
428+ await expectActionError (
429+ {
430+ action : testCase . action ,
431+ params : testCase . params ,
432+ senderIsOwner : false ,
433+ gatewayClientScopes : [ "operator.write" ] ,
434+ } ,
435+ groupManagementAuthError ,
436+ ) ;
437+ expect ( testCase . mockFn ) . not . toHaveBeenCalled ( ) ;
438+ }
439+ } ) ;
440+
441+ it ( "allows owner-authorized group-management actions" , async ( ) => {
442+ await expectSuccessfulAction ( {
443+ mockFn : addParticipantMSTeamsMock ,
444+ mockResult : { added : { userId : "user-1" , chatId : targetChannelId } } ,
445+ action : "addParticipant" ,
446+ actionParams : {
447+ target : targetChannelId ,
448+ userId : " user-1 " ,
449+ role : " owner " ,
450+ } ,
451+ senderIsOwner : true ,
452+ runtimeParams : {
453+ to : targetChannelId ,
454+ userId : "user-1" ,
455+ role : "owner" ,
456+ } ,
457+ details : okMSTeamsActionDetails ( "addParticipant" , {
458+ added : { userId : "user-1" , chatId : targetChannelId } ,
459+ } ) ,
460+ contentDetails : {
461+ ok : true ,
462+ channel : "msteams" ,
463+ action : "addParticipant" ,
464+ added : { userId : "user-1" , chatId : targetChannelId } ,
465+ } ,
466+ } ) ;
467+ } ) ;
468+
469+ it ( "allows operator.admin group-management actions without owner sender status" , async ( ) => {
470+ await expectSuccessfulAction ( {
471+ mockFn : removeParticipantMSTeamsMock ,
472+ mockResult : { removed : { userId : "user-1" , chatId : targetChannelId } } ,
473+ action : "removeParticipant" ,
474+ actionParams : {
475+ target : targetChannelId ,
476+ userId : " user-1 " ,
477+ } ,
478+ senderIsOwner : false ,
479+ gatewayClientScopes : [ "operator.admin" ] ,
480+ runtimeParams : {
481+ to : targetChannelId ,
482+ userId : "user-1" ,
483+ } ,
484+ details : okMSTeamsActionDetails ( "removeParticipant" , {
485+ removed : { userId : "user-1" , chatId : targetChannelId } ,
486+ } ) ,
487+ contentDetails : {
488+ ok : true ,
489+ channel : "msteams" ,
490+ action : "removeParticipant" ,
491+ removed : { userId : "user-1" , chatId : targetChannelId } ,
492+ } ,
493+ } ) ;
494+
495+ await expectSuccessfulAction ( {
496+ mockFn : renameGroupMSTeamsMock ,
497+ mockResult : { renamed : { chatId : targetChannelId , newName : "Renamed group" } } ,
498+ action : "renameGroup" ,
499+ actionParams : {
500+ target : targetChannelId ,
501+ name : " Renamed group " ,
502+ } ,
503+ senderIsOwner : false ,
504+ gatewayClientScopes : [ "operator.admin" ] ,
505+ runtimeParams : {
506+ to : targetChannelId ,
507+ name : "Renamed group" ,
508+ } ,
509+ details : okMSTeamsActionDetails ( "renameGroup" , {
510+ renamed : { chatId : targetChannelId , newName : "Renamed group" } ,
511+ } ) ,
512+ contentDetails : {
513+ ok : true ,
514+ channel : "msteams" ,
515+ action : "renameGroup" ,
516+ renamed : { chatId : targetChannelId , newName : "Renamed group" } ,
517+ } ,
518+ } ) ;
519+ } ) ;
520+
354521 it ( "accepts target as an alias for pin actions" , async ( ) => {
355522 await expectSuccessfulAction ( {
356523 mockFn : pinMessageMSTeamsMock ,
0 commit comments