@@ -494,6 +494,50 @@ describe("listDMChannels", () => {
494494 expect ( markNeedsReauth ) . not . toHaveBeenCalled ( ) ;
495495 expect ( store . map . has ( "dm_channels" ) ) . toBe ( false ) ;
496496 } ) ;
497+
498+ it ( "flags re-auth when the token has im:history but not im:read (opted into DMs on a pre-im:read grant)" , async ( ) => {
499+ // A token granted before im:read/mpim:read joined the `dms` scope group
500+ // carries im:history but not im:read. That user OPTED IN to DM sync, so
501+ // silently no-oping strands them: the DM webhook registers (it only needs
502+ // im:history), but listDMChannels never populates `dm_channels`, so
503+ // isKnownDMChannel rejects every incoming DM event forever — while the
504+ // connection still reports itself healthy. Both scopes come from the same
505+ // group, so im:history-without-im:read can only mean a stale grant, never
506+ // a decline; prompt a reconnect to re-consent.
507+ const store = makeStore ( { } ) ;
508+ const integrationsGet = vi . fn ( ) . mockResolvedValue ( {
509+ token : "xoxp-test" ,
510+ scopes : [ "channels:history" , "im:history" , "im:write" , "mpim:history" ] , // pre-fix grant
511+ } ) ;
512+ const markNeedsReauth = vi . fn ( ) . mockResolvedValue ( undefined ) ;
513+ const create = vi . fn ( async ( ) => ( { token : "cb" } ) as never ) ;
514+ const tools = {
515+ store,
516+ integrations : { get : integrationsGet , markNeedsReauth } ,
517+ network : { createWebhook : vi . fn ( ) } ,
518+ files : { } ,
519+ callbacks : { create } ,
520+ tasks : { runTask : vi . fn ( async ( ) => "task-token" ) , scheduleRecurring : vi . fn ( async ( ) => { } ) } ,
521+ } ;
522+ const slack = new Slack ( "twist-instance-1" as never , { getTools : ( ) => tools } as never ) ;
523+
524+ const fetchMock = vi . fn ( ) ;
525+ vi . stubGlobal ( "fetch" , fetchMock ) ;
526+ try {
527+ await ( slack as unknown as {
528+ listDMChannels : ( c : string ) => Promise < void > ;
529+ } ) . listDMChannels ( "C1" ) ;
530+ } finally {
531+ vi . unstubAllGlobals ( ) ;
532+ }
533+
534+ // Still no API call: the grant genuinely lacks the enumeration scope, so
535+ // conversations.list would only return missing_scope.
536+ expect ( fetchMock ) . not . toHaveBeenCalled ( ) ;
537+ // ...but unlike a decline, surface it so the user can reconnect.
538+ expect ( markNeedsReauth ) . toHaveBeenCalledWith ( "C1" ) ;
539+ expect ( store . map . has ( "dm_channels" ) ) . toBe ( false ) ;
540+ } ) ;
497541} ) ;
498542
499543describe ( "extractSlackMessageReactions (custom emoji)" , ( ) => {
0 commit comments