@@ -13,6 +13,11 @@ const runtimeMock = vi.hoisted(() => ({
1313 sendReaction : vi . fn ( ) ,
1414 sendRichMessage : vi . fn ( ) ,
1515 sendAttachment : vi . fn ( ) ,
16+ renameGroup : vi . fn ( ) ,
17+ setGroupIcon : vi . fn ( ) ,
18+ addParticipant : vi . fn ( ) ,
19+ removeParticipant : vi . fn ( ) ,
20+ leaveGroup : vi . fn ( ) ,
1621} ) ) ;
1722
1823const rememberIMessageReplyCacheMock = vi . hoisted ( ( ) => vi . fn ( ) ) ;
@@ -90,6 +95,11 @@ describe("imessage message actions", () => {
9095 runtimeMock . sendReaction . mockReset ( ) ;
9196 runtimeMock . sendRichMessage . mockReset ( ) ;
9297 runtimeMock . sendAttachment . mockReset ( ) ;
98+ runtimeMock . renameGroup . mockReset ( ) ;
99+ runtimeMock . setGroupIcon . mockReset ( ) ;
100+ runtimeMock . addParticipant . mockReset ( ) ;
101+ runtimeMock . removeParticipant . mockReset ( ) ;
102+ runtimeMock . leaveGroup . mockReset ( ) ;
93103 rememberIMessageReplyCacheMock . mockReset ( ) ;
94104 probeMock . getCachedIMessagePrivateApiStatus . mockReset ( ) ;
95105 probeMock . probeIMessagePrivateApi . mockReset ( ) ;
@@ -183,6 +193,100 @@ describe("imessage message actions", () => {
183193 expect ( described ?. actions ) . toContain ( "edit" ) ;
184194 } ) ;
185195
196+ it ( "requires a trusted requester for group management from iMessage turns" , ( ) => {
197+ for ( const action of [
198+ "renameGroup" ,
199+ "setGroupIcon" ,
200+ "addParticipant" ,
201+ "removeParticipant" ,
202+ "leaveGroup" ,
203+ ] as const ) {
204+ expect (
205+ imessageMessageActions . requiresTrustedRequesterSender ?.( {
206+ action,
207+ toolContext : { currentChannelProvider : "imessage" } ,
208+ } ) ,
209+ ) . toBe ( true ) ;
210+ }
211+ expect (
212+ imessageMessageActions . requiresTrustedRequesterSender ?.( {
213+ action : "renameGroup" ,
214+ toolContext : { currentChannelProvider : "discord" } ,
215+ } ) ,
216+ ) . toBe ( false ) ;
217+ expect (
218+ imessageMessageActions . requiresTrustedRequesterSender ?.( {
219+ action : "react" ,
220+ toolContext : { currentChannelProvider : "imessage" } ,
221+ } ) ,
222+ ) . toBe ( false ) ;
223+ } ) ;
224+
225+ it . each ( [
226+ [ "renameGroup" , { name : "Unauthorized rename" } , runtimeMock . renameGroup ] ,
227+ [
228+ "setGroupIcon" ,
229+ { buffer : Buffer . from ( "unauthorized icon" ) . toString ( "base64" ) , filename : "icon.png" } ,
230+ runtimeMock . setGroupIcon ,
231+ ] ,
232+ [ "addParticipant" , { address : "+15551230001" } , runtimeMock . addParticipant ] ,
233+ [ "removeParticipant" , { address : "+15551230002" } , runtimeMock . removeParticipant ] ,
234+ [ "leaveGroup" , { } , runtimeMock . leaveGroup ] ,
235+ ] as const ) (
236+ "rejects %s from non-owner non-admin callers before native mutation" ,
237+ async ( action , params , runtimeAction ) => {
238+ probeMock . getCachedIMessagePrivateApiStatus . mockReturnValue ( {
239+ available : true ,
240+ v2Ready : true ,
241+ selectors : { } ,
242+ } ) ;
243+ await expect (
244+ imessageMessageActions . handleAction ?.( {
245+ action,
246+ cfg : cfg ( ) ,
247+ params : { chatGuid : "iMessage;+;chat0000" , ...params } ,
248+ senderIsOwner : false ,
249+ gatewayClientScopes : [ "operator.write" ] ,
250+ } as never ) ,
251+ ) . rejects . toThrow ( "iMessage group management requires an owner or operator.admin requester." ) ;
252+ expect ( runtimeAction ) . not . toHaveBeenCalled ( ) ;
253+ } ,
254+ ) ;
255+
256+ it ( "allows owner and operator.admin group management" , async ( ) => {
257+ probeMock . getCachedIMessagePrivateApiStatus . mockReturnValue ( {
258+ available : true ,
259+ v2Ready : true ,
260+ selectors : { } ,
261+ } ) ;
262+ runtimeMock . renameGroup . mockResolvedValue ( undefined ) ;
263+ runtimeMock . leaveGroup . mockResolvedValue ( undefined ) ;
264+
265+ await imessageMessageActions . handleAction ?.( {
266+ action : "renameGroup" ,
267+ cfg : cfg ( ) ,
268+ params : { chatGuid : "iMessage;+;chat0000" , name : "Renamed group" } ,
269+ senderIsOwner : true ,
270+ } as never ) ;
271+ await imessageMessageActions . handleAction ?.( {
272+ action : "leaveGroup" ,
273+ cfg : cfg ( ) ,
274+ params : { chatGuid : "iMessage;+;chat0000" } ,
275+ senderIsOwner : false ,
276+ gatewayClientScopes : [ "operator.admin" ] ,
277+ } as never ) ;
278+
279+ expect ( runtimeMock . renameGroup ) . toHaveBeenCalledWith ( {
280+ chatGuid : "iMessage;+;chat0000" ,
281+ displayName : "Renamed group" ,
282+ options : imsgOptions ( "iMessage;+;chat0000" ) ,
283+ } ) ;
284+ expect ( runtimeMock . leaveGroup ) . toHaveBeenCalledWith ( {
285+ chatGuid : "iMessage;+;chat0000" ,
286+ options : imsgOptions ( "iMessage;+;chat0000" ) ,
287+ } ) ;
288+ } ) ;
289+
186290 it ( "emits a channels/imessage WARN when the private API bridge is unavailable" , async ( ) => {
187291 probeMock . getCachedIMessagePrivateApiStatus . mockReturnValue ( undefined ) ;
188292 probeMock . probeIMessagePrivateApi . mockResolvedValue ( {
0 commit comments