diff --git a/.changeset/fix-build-source-user-authorizations.md b/.changeset/fix-build-source-user-authorizations.md new file mode 100644 index 000000000..ff65e677d --- /dev/null +++ b/.changeset/fix-build-source-user-authorizations.md @@ -0,0 +1,5 @@ +--- +"@slack/bolt": patch +--- + +Fix `context.userId` being `undefined` for events whose payload does not carry a user field directly, by sourcing the user ID from the request's `authorizations` array in `buildSource`, matching how `teamId` and `enterpriseId` are already resolved. diff --git a/src/App.ts b/src/App.ts index fd5dc488f..42a7a1482 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1547,7 +1547,21 @@ function buildSource( const userId: string | undefined = (() => { if (type === IncomingEventType.Event) { // NOTE: no type system backed exhaustiveness check within this incoming event type - const { event } = body as SlackEventMiddlewareArgs['body']; + const bodyAsEvent = body as SlackEventMiddlewareArgs['body']; + // The authorizations array names the installing user this event was + // delivered for. Event payload fields like `event.user` reference the + // user that *triggered* the event, who may never have installed the + // app (e.g. the invitee in `member_joined_channel`), which sends + // `authorize`/`fetchInstallation` looking up the wrong installation. + // Mirrors the teamId/enterpriseId extraction above. See #2271. + if ( + Array.isArray(bodyAsEvent.authorizations) && + bodyAsEvent.authorizations[0] !== undefined && + bodyAsEvent.authorizations[0].user_id !== null + ) { + return bodyAsEvent.authorizations[0].user_id; + } + const { event } = bodyAsEvent; if ('user' in event) { if (typeof event.user === 'string') { return event.user; diff --git a/test/unit/App/build-source.spec.ts b/test/unit/App/build-source.spec.ts new file mode 100644 index 000000000..66516ef08 --- /dev/null +++ b/test/unit/App/build-source.spec.ts @@ -0,0 +1,95 @@ +import assert from 'node:assert'; +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + createDummyAppMentionEventMiddlewareArgs, + createFakeLogger, + FakeReceiver, + importApp, + mergeOverrides, + noopMiddleware, + type Override, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App authorize source (buildSource)', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + let fakeAck: SinonSpy; + let fakeAuthorize: SinonSpy; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + fakeAck = sinon.fake(); + fakeAuthorize = sinon.fake.resolves({ botToken: '', botId: '' }); + MockApp = importApp(buildOverrides([])); + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: fakeAuthorize, + }); + }); + + it('should prefer the authorizations array user over the event user for events', async () => { + // The event `user` is whoever triggered the event (e.g. the invitee in + // `member_joined_channel`) and may never have installed the app; the + // authorizations array names the installing user the event was + // delivered for. authorize/fetchInstallation must be keyed on the + // latter. See #2271. + app.event('app_mention', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyAppMentionEventMiddlewareArgs(undefined, { + authorizations: [ + { + enterprise_id: null, + team_id: 'T1234', + user_id: 'U-installer', + is_bot: false, + is_enterprise_install: false, + }, + ], + }), + ack: fakeAck, + }); + sinon.assert.calledOnce(fakeAuthorize); + const source = fakeAuthorize.getCall(0).args[0]; + assert.strictEqual(source.userId, 'U-installer'); + }); + + it('should fall back to the event user when no authorizations array is present', async () => { + app.event('app_mention', fakeHandler); + const args = createDummyAppMentionEventMiddlewareArgs({ + event: { + type: 'app_mention', + text: 'hi', + user: 'U-event-user', + channel: 'C1234', + ts: '1234.56', + event_ts: '1234.56', + }, + }); + await fakeReceiver.sendEvent({ + ...args, + ack: fakeAck, + }); + sinon.assert.calledOnce(fakeAuthorize); + const source = fakeAuthorize.getCall(0).args[0]; + assert.strictEqual(source.userId, 'U-event-user'); + }); +});