@@ -66,13 +66,16 @@ function createInvokeContext(params: {
6666 conversationId : string ;
6767 uploadId : string ;
6868 action : "accept" | "decline" ;
69+ replyToId ?: string ;
6970} ) : {
7071 context : MSTeamsTurnContext ;
7172 sendActivity : ReturnType < typeof vi . fn > ;
7273 updateActivity : ReturnType < typeof vi . fn > ;
74+ deleteActivity : ReturnType < typeof vi . fn > ;
7375} {
7476 const sendActivity = vi . fn ( async ( ) => ( { id : "activity-id" } ) ) ;
7577 const updateActivity = vi . fn ( async ( ) => ( { id : "activity-id" } ) ) ;
78+ const deleteActivity = vi . fn ( async ( ) => { } ) ;
7679 const uploadInfo =
7780 params . action === "accept"
7881 ? {
@@ -89,6 +92,7 @@ function createInvokeContext(params: {
8992 type : "invoke" ,
9093 name : "fileConsent/invoke" ,
9194 conversation : { id : params . conversationId } ,
95+ replyToId : params . replyToId ,
9296 value : {
9397 type : "fileUpload" ,
9498 action : params . action ,
@@ -99,9 +103,11 @@ function createInvokeContext(params: {
99103 sendActivity,
100104 sendActivities : async ( ) => [ ] ,
101105 updateActivity,
106+ deleteActivity,
102107 } as unknown as MSTeamsTurnContext ,
103108 sendActivity,
104109 updateActivity,
110+ deleteActivity,
105111 } ;
106112}
107113
@@ -110,6 +116,7 @@ function createConsentInvokeHarness(params: {
110116 invokeConversationId : string ;
111117 action : "accept" | "decline" ;
112118 consentCardActivityId ?: string ;
119+ replyToId ?: string ;
113120} ) {
114121 const uploadId = storePendingUpload ( {
115122 buffer : Buffer . from ( "TOP_SECRET_VICTIM_FILE\n" ) ,
@@ -118,12 +125,13 @@ function createConsentInvokeHarness(params: {
118125 conversationId : params . pendingConversationId ?? "19:victim@thread.v2" ,
119126 consentCardActivityId : params . consentCardActivityId ,
120127 } ) ;
121- const { context, sendActivity, updateActivity } = createInvokeContext ( {
128+ const { context, sendActivity, updateActivity, deleteActivity } = createInvokeContext ( {
122129 conversationId : params . invokeConversationId ,
123130 uploadId,
124131 action : params . action ,
132+ replyToId : params . replyToId ,
125133 } ) ;
126- return { uploadId, context, sendActivity, updateActivity } ;
134+ return { uploadId, context, sendActivity, updateActivity, deleteActivity } ;
127135}
128136
129137function requirePendingUpload ( uploadId : string ) {
@@ -236,8 +244,21 @@ describe("msteams file consent invoke authz", () => {
236244 expect ( updateActivity ) . not . toHaveBeenCalled ( ) ;
237245 } ) ;
238246
247+ it ( "deletes the replied-to consent card after successful upload when no stored card id exists" , async ( ) => {
248+ const { context, deleteActivity } = createConsentInvokeHarness ( {
249+ invokeConversationId : "19:victim@thread.v2;messageid=abc123" ,
250+ action : "accept" ,
251+ replyToId : "reply-to-consent-card" ,
252+ } ) ;
253+
254+ await respondToMSTeamsFileConsentInvoke ( context , log ) ;
255+
256+ expect ( fileConsentMockState . uploadToConsentUrl ) . toHaveBeenCalledTimes ( 1 ) ;
257+ expect ( deleteActivity ) . toHaveBeenCalledWith ( "reply-to-consent-card" ) ;
258+ } ) ;
259+
239260 it ( "still completes upload if updateActivity throws" , async ( ) => {
240- const { uploadId, context, updateActivity } = createConsentInvokeHarness ( {
261+ const { uploadId, context, updateActivity, deleteActivity } = createConsentInvokeHarness ( {
241262 invokeConversationId : "19:victim@thread.v2;messageid=abc123" ,
242263 action : "accept" ,
243264 consentCardActivityId : "consent-card-activity-id-fail" ,
@@ -250,12 +271,28 @@ describe("msteams file consent invoke authz", () => {
250271 expect ( fileConsentMockState . uploadToConsentUrl ) . toHaveBeenCalledTimes ( 1 ) ;
251272 expect ( getPendingUpload ( uploadId ) ) . toBeUndefined ( ) ;
252273 expect ( updateActivity ) . toHaveBeenCalledTimes ( 1 ) ;
274+ expect ( deleteActivity ) . toHaveBeenCalledWith ( "consent-card-activity-id-fail" ) ;
275+ } ) ;
276+
277+ it ( "deletes the consent card when upload fails" , async ( ) => {
278+ fileConsentMockState . uploadToConsentUrl . mockRejectedValueOnce ( new Error ( "upload failed" ) ) ;
279+ const { context, sendActivity, deleteActivity } = createConsentInvokeHarness ( {
280+ invokeConversationId : "19:victim@thread.v2;messageid=abc123" ,
281+ action : "accept" ,
282+ consentCardActivityId : "consent-card-upload-failed" ,
283+ } ) ;
284+
285+ await respondToMSTeamsFileConsentInvoke ( context , log ) ;
286+
287+ expect ( deleteActivity ) . toHaveBeenCalledWith ( "consent-card-upload-failed" ) ;
288+ expect ( sendActivity ) . toHaveBeenCalledWith ( "File upload failed. Please try again." ) ;
253289 } ) ;
254290
255291 it ( "rejects cross-conversation accept invoke and keeps pending upload" , async ( ) => {
256- const { uploadId, context, sendActivity } = createConsentInvokeHarness ( {
292+ const { uploadId, context, sendActivity, deleteActivity } = createConsentInvokeHarness ( {
257293 invokeConversationId : "19:attacker@thread.v2" ,
258294 action : "accept" ,
295+ replyToId : "mismatched-consent-card" ,
259296 } ) ;
260297
261298 await respondToMSTeamsFileConsentInvoke ( context , log ) ;
@@ -272,6 +309,7 @@ describe("msteams file consent invoke authz", () => {
272309 ) ;
273310
274311 expect ( fileConsentMockState . uploadToConsentUrl ) . not . toHaveBeenCalled ( ) ;
312+ expect ( deleteActivity ) . toHaveBeenCalledWith ( "mismatched-consent-card" ) ;
275313 expect ( requirePendingUpload ( uploadId ) ) . toMatchObject ( {
276314 conversationId : "19:victim@thread.v2" ,
277315 filename : "secret.txt" ,
@@ -280,9 +318,10 @@ describe("msteams file consent invoke authz", () => {
280318 } ) ;
281319
282320 it ( "ignores cross-conversation decline invoke and keeps pending upload" , async ( ) => {
283- const { uploadId, context, sendActivity } = createConsentInvokeHarness ( {
321+ const { uploadId, context, sendActivity, deleteActivity } = createConsentInvokeHarness ( {
284322 invokeConversationId : "19:attacker@thread.v2" ,
285323 action : "decline" ,
324+ replyToId : "mismatched-decline-consent-card" ,
286325 } ) ;
287326
288327 await respondToMSTeamsFileConsentInvoke ( context , log ) ;
@@ -295,13 +334,57 @@ describe("msteams file consent invoke authz", () => {
295334 ) ;
296335
297336 expect ( fileConsentMockState . uploadToConsentUrl ) . not . toHaveBeenCalled ( ) ;
337+ expect ( deleteActivity ) . toHaveBeenCalledWith ( "mismatched-decline-consent-card" ) ;
298338 expect ( requirePendingUpload ( uploadId ) ) . toMatchObject ( {
299339 conversationId : "19:victim@thread.v2" ,
300340 filename : "secret.txt" ,
301341 contentType : "text/plain" ,
302342 } ) ;
303343 expect ( sendActivity ) . toHaveBeenCalledTimes ( 1 ) ;
304344 } ) ;
345+
346+ it ( "deletes the consent card when the pending upload has expired" , async ( ) => {
347+ const { context, sendActivity, deleteActivity } = createInvokeContext ( {
348+ conversationId : "19:victim@thread.v2;messageid=abc123" ,
349+ uploadId : "missing-upload-id" ,
350+ action : "accept" ,
351+ replyToId : "expired-consent-card" ,
352+ } ) ;
353+
354+ await respondToMSTeamsFileConsentInvoke ( context , log ) ;
355+
356+ expect ( deleteActivity ) . toHaveBeenCalledWith ( "expired-consent-card" ) ;
357+ expect ( sendActivity ) . toHaveBeenCalledWith (
358+ "The file upload request has expired. Please try sending the file again." ,
359+ ) ;
360+ } ) ;
361+
362+ it ( "deletes the consent card when the user declines" , async ( ) => {
363+ const { context, deleteActivity } = createConsentInvokeHarness ( {
364+ invokeConversationId : "19:victim@thread.v2;messageid=abc123" ,
365+ action : "decline" ,
366+ consentCardActivityId : "declined-consent-card" ,
367+ } ) ;
368+
369+ await respondToMSTeamsFileConsentInvoke ( context , log ) ;
370+
371+ expect ( fileConsentMockState . uploadToConsentUrl ) . not . toHaveBeenCalled ( ) ;
372+ expect ( deleteActivity ) . toHaveBeenCalledWith ( "declined-consent-card" ) ;
373+ } ) ;
374+
375+ it ( "continues when consent card deletion fails" , async ( ) => {
376+ const { uploadId, context, deleteActivity } = createConsentInvokeHarness ( {
377+ invokeConversationId : "19:victim@thread.v2;messageid=abc123" ,
378+ action : "decline" ,
379+ consentCardActivityId : "delete-fails-consent-card" ,
380+ } ) ;
381+ deleteActivity . mockRejectedValueOnce ( new Error ( "delete failed" ) ) ;
382+
383+ await respondToMSTeamsFileConsentInvoke ( context , log ) ;
384+
385+ expect ( deleteActivity ) . toHaveBeenCalledWith ( "delete-fails-consent-card" ) ;
386+ expect ( getPendingUpload ( uploadId ) ) . toBeUndefined ( ) ;
387+ } ) ;
305388} ) ;
306389
307390describe ( "msteams file consent invoke FS fallback" , ( ) => {
@@ -349,6 +432,7 @@ describe("msteams file consent invoke FS fallback", () => {
349432
350433 const sendActivity = vi . fn ( async ( ) => ( { id : "activity-id" } ) ) ;
351434 const updateActivity = vi . fn ( async ( ) => ( { id : "activity-id" } ) ) ;
435+ const deleteActivity = vi . fn ( async ( ) => { } ) ;
352436 const context = {
353437 activity : {
354438 type : "invoke" ,
@@ -370,6 +454,7 @@ describe("msteams file consent invoke FS fallback", () => {
370454 sendActivity,
371455 sendActivities : async ( ) => [ ] ,
372456 updateActivity,
457+ deleteActivity,
373458 } as unknown as MSTeamsTurnContext ;
374459
375460 await respondToMSTeamsFileConsentInvoke ( context , log ) ;
@@ -399,6 +484,7 @@ describe("msteams file consent invoke FS fallback", () => {
399484
400485 const sendActivity = vi . fn ( async ( ) => ( { id : "activity-id" } ) ) ;
401486 const updateActivity = vi . fn ( async ( ) => ( { id : "activity-id" } ) ) ;
487+ const deleteActivity = vi . fn ( async ( ) => { } ) ;
402488 const context = {
403489 activity : {
404490 type : "invoke" ,
@@ -413,6 +499,7 @@ describe("msteams file consent invoke FS fallback", () => {
413499 sendActivity,
414500 sendActivities : async ( ) => [ ] ,
415501 updateActivity,
502+ deleteActivity,
416503 } as unknown as MSTeamsTurnContext ;
417504
418505 await respondToMSTeamsFileConsentInvoke ( context , log ) ;
0 commit comments