From d2ff191f17eaabbc7586366907ee02a51d5e165f Mon Sep 17 00:00:00 2001 From: Wells Bunker Date: Thu, 25 Sep 2025 17:13:13 -0600 Subject: [PATCH] send slack message if user is unauthorized --- apps/server/src/api/v2/slack/events.test.ts | 334 +++++++++++++++++++- apps/server/src/api/v2/slack/events.ts | 126 +++++--- 2 files changed, 416 insertions(+), 44 deletions(-) diff --git a/apps/server/src/api/v2/slack/events.test.ts b/apps/server/src/api/v2/slack/events.test.ts index 5ca55de6f..be4c8a854 100644 --- a/apps/server/src/api/v2/slack/events.test.ts +++ b/apps/server/src/api/v2/slack/events.test.ts @@ -1,6 +1,6 @@ import { db } from '@buster/database/connection'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { findOrCreateSlackChat } from './events'; +import { eventsHandler, findOrCreateSlackChat } from './events'; vi.mock('@buster/database/connection', () => ({ db: { @@ -14,6 +14,31 @@ vi.mock('@buster/database/schema', () => ({ slackIntegrations: {}, })); +vi.mock('@buster/database/queries', () => ({ + getSecretByName: vi.fn(), +})); + +vi.mock('@buster/slack', () => ({ + SlackMessagingService: vi.fn(() => ({ + sendMessage: vi.fn(), + })), + isEventCallback: vi.fn(), + isAppMentionEvent: vi.fn(), + isMessageImEvent: vi.fn(), + addReaction: vi.fn(), +})); + +vi.mock('@trigger.dev/sdk', () => ({ + tasks: { + trigger: vi.fn(), + }, +})); + +vi.mock('./services/slack-authentication', () => ({ + authenticateSlackUser: vi.fn(), + getUserIdFromAuthResult: vi.fn(), +})); + describe('findOrCreateSlackChat', () => { beforeEach(() => { vi.clearAllMocks(); @@ -221,3 +246,310 @@ describe('findOrCreateSlackChat', () => { }); }); }); + +describe('eventsHandler - Unauthorized Users', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return success without sending message for bot unauthorized users', async () => { + const { isEventCallback, isAppMentionEvent, isMessageImEvent } = await import('@buster/slack'); + const { authenticateSlackUser } = await import('./services/slack-authentication'); + + // Mock event callback detection + vi.mocked(isEventCallback).mockReturnValue(true); + vi.mocked(isAppMentionEvent).mockReturnValue(true); + vi.mocked(isMessageImEvent).mockReturnValue(false); + + // Mock authentication to return unauthorized bot + vi.mocked(authenticateSlackUser).mockResolvedValue({ + type: 'unauthorized', + reason: 'User is a bot account', + } as any); + + const payload = { + type: 'event_callback' as const, + token: 'xoxb-test-token', + team_id: 'T123456', + api_app_id: 'A123456', + event_id: 'E123456', + event_time: 1234567890, + event: { + type: 'app_mention' as const, + user: 'U123456', + channel: 'C123456', + text: 'Hello Buster', + ts: '1234567890.123456', + event_ts: '1234567890.123456', + }, + }; + + const result = await eventsHandler(payload); + + expect(result).toEqual({ success: true }); + expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456'); + + // Should not attempt to send message or get access token + expect(db.select).not.toHaveBeenCalled(); + }); + + it('should send unauthorized message for regular unauthorized users', async () => { + const { isEventCallback, isAppMentionEvent, isMessageImEvent, SlackMessagingService } = + await import('@buster/slack'); + const { authenticateSlackUser } = await import('./services/slack-authentication'); + const { getSecretByName } = await import('@buster/database/queries'); + + // Mock event callback detection + vi.mocked(isEventCallback).mockReturnValue(true); + vi.mocked(isAppMentionEvent).mockReturnValue(true); + vi.mocked(isMessageImEvent).mockReturnValue(false); + + // Mock authentication to return unauthorized (non-bot) + vi.mocked(authenticateSlackUser).mockResolvedValue({ + type: 'unauthorized', + reason: 'User email not found in organization domain', + } as any); + + // Mock database query for getting access token + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ tokenVaultKey: 'vault-key-123' }]), + } as any); + + // Mock vault secret + vi.mocked(getSecretByName).mockResolvedValue({ + secret: 'xoxb-test-token', + } as any); + + // Mock messaging service + const mockSendMessage = vi.fn().mockResolvedValue({ ok: true, ts: '1234567890.123456' }); + vi.mocked(SlackMessagingService).mockImplementation( + () => + ({ + sendMessage: mockSendMessage, + }) as any + ); + + const payload = { + type: 'event_callback' as const, + token: 'xoxb-test-token', + team_id: 'T123456', + api_app_id: 'A123456', + event_id: 'E123456', + event_time: 1234567890, + event: { + type: 'app_mention' as const, + user: 'U123456', + channel: 'C123456', + text: 'Hello Buster', + ts: '1234567890.123456', + event_ts: '1234567890.123456', + }, + }; + + await expect(eventsHandler(payload)).rejects.toThrow( + 'Unauthorized: Slack user authentication failed' + ); + + expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456'); + expect(getSecretByName).toHaveBeenCalledWith('vault-key-123'); + expect(mockSendMessage).toHaveBeenCalledWith('xoxb-test-token', 'C123456', { + text: 'Sorry, you are unauthorized to chat with Buster. Please contact your Workspace Administrator for access.', + thread_ts: '1234567890.123456', + }); + }); + + it('should send unauthorized message in thread for DM unauthorized users', async () => { + const { isEventCallback, isAppMentionEvent, isMessageImEvent, SlackMessagingService } = + await import('@buster/slack'); + const { authenticateSlackUser } = await import('./services/slack-authentication'); + const { getSecretByName } = await import('@buster/database/queries'); + + // Mock event callback detection - this time it's a DM + vi.mocked(isEventCallback).mockReturnValue(true); + vi.mocked(isAppMentionEvent).mockReturnValue(false); + vi.mocked(isMessageImEvent).mockReturnValue(true); + + // Mock authentication to return unauthorized + vi.mocked(authenticateSlackUser).mockResolvedValue({ + type: 'unauthorized', + reason: 'User not found in organization', + } as any); + + // Mock database query for getting access token + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ tokenVaultKey: 'vault-key-123' }]), + } as any); + + // Mock vault secret + vi.mocked(getSecretByName).mockResolvedValue({ + secret: 'xoxb-test-token', + } as any); + + // Mock messaging service + const mockSendMessage = vi.fn().mockResolvedValue({ ok: true, ts: '1234567890.123456' }); + vi.mocked(SlackMessagingService).mockImplementation( + () => + ({ + sendMessage: mockSendMessage, + }) as any + ); + + const payload = { + type: 'event_callback' as const, + token: 'xoxb-test-token', + team_id: 'T123456', + api_app_id: 'A123456', + event_id: 'E123456', + event_time: 1234567890, + event: { + type: 'message' as const, + channel_type: 'im' as const, + user: 'U123456', + channel: 'D123456', + text: 'Hello Buster', + ts: '1234567890.123456', + event_ts: '1234567890.123456', + thread_ts: '1234567890.111111', // This is a threaded message + }, + }; + + await expect(eventsHandler(payload)).rejects.toThrow( + 'Unauthorized: Slack user authentication failed' + ); + + expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456'); + expect(getSecretByName).toHaveBeenCalledWith('vault-key-123'); + expect(mockSendMessage).toHaveBeenCalledWith('xoxb-test-token', 'D123456', { + text: 'Sorry, you are unauthorized to chat with Buster. Please contact your Workspace Administrator for access.', + thread_ts: '1234567890.111111', // Should use the existing thread_ts + }); + }); + + it('should handle failure to send unauthorized message gracefully', async () => { + const { isEventCallback, isAppMentionEvent, isMessageImEvent, SlackMessagingService } = + await import('@buster/slack'); + const { authenticateSlackUser } = await import('./services/slack-authentication'); + const { getSecretByName } = await import('@buster/database/queries'); + + // Mock event callback detection + vi.mocked(isEventCallback).mockReturnValue(true); + vi.mocked(isAppMentionEvent).mockReturnValue(true); + vi.mocked(isMessageImEvent).mockReturnValue(false); + + // Mock authentication to return unauthorized + vi.mocked(authenticateSlackUser).mockResolvedValue({ + type: 'unauthorized', + reason: 'User not authorized', + } as any); + + // Mock database query for getting access token + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ tokenVaultKey: 'vault-key-123' }]), + } as any); + + // Mock vault secret + vi.mocked(getSecretByName).mockResolvedValue({ + secret: 'xoxb-test-token', + } as any); + + // Mock messaging service to throw error + const mockSendMessage = vi.fn().mockRejectedValue(new Error('Slack API error')); + vi.mocked(SlackMessagingService).mockImplementation( + () => + ({ + sendMessage: mockSendMessage, + }) as any + ); + + const payload = { + type: 'event_callback' as const, + token: 'xoxb-test-token', + team_id: 'T123456', + api_app_id: 'A123456', + event_id: 'E123456', + event_time: 1234567890, + event: { + type: 'app_mention' as const, + user: 'U123456', + channel: 'C123456', + text: 'Hello Buster', + ts: '1234567890.123456', + event_ts: '1234567890.123456', + }, + }; + + // Should still throw the unauthorized error even if message sending fails + await expect(eventsHandler(payload)).rejects.toThrow( + 'Unauthorized: Slack user authentication failed' + ); + + expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456'); + expect(mockSendMessage).toHaveBeenCalled(); + }); + + it('should handle case when no access token is available for unauthorized message', async () => { + const { isEventCallback, isAppMentionEvent, isMessageImEvent, SlackMessagingService } = + await import('@buster/slack'); + const { authenticateSlackUser } = await import('./services/slack-authentication'); + const { getSecretByName } = await import('@buster/database/queries'); + + // Mock event callback detection + vi.mocked(isEventCallback).mockReturnValue(true); + vi.mocked(isAppMentionEvent).mockReturnValue(true); + vi.mocked(isMessageImEvent).mockReturnValue(false); + + // Mock authentication to return unauthorized + vi.mocked(authenticateSlackUser).mockResolvedValue({ + type: 'unauthorized', + reason: 'User not authorized', + } as any); + + // Mock database query to return empty result (no token found) + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + } as any); + + // Mock messaging service (shouldn't be called) + const mockSendMessage = vi.fn(); + vi.mocked(SlackMessagingService).mockImplementation( + () => + ({ + sendMessage: mockSendMessage, + }) as any + ); + + const payload = { + type: 'event_callback' as const, + token: 'xoxb-test-token', + team_id: 'T123456', + api_app_id: 'A123456', + event_id: 'E123456', + event_time: 1234567890, + event: { + type: 'app_mention' as const, + user: 'U123456', + channel: 'C123456', + text: 'Hello Buster', + ts: '1234567890.123456', + event_ts: '1234567890.123456', + }, + }; + + // Should still throw the unauthorized error even when no token is available + await expect(eventsHandler(payload)).rejects.toThrow( + 'Unauthorized: Slack user authentication failed' + ); + + expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456'); + expect(getSecretByName).not.toHaveBeenCalled(); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/api/v2/slack/events.ts b/apps/server/src/api/v2/slack/events.ts index a6d30aeca..64fddeded 100644 --- a/apps/server/src/api/v2/slack/events.ts +++ b/apps/server/src/api/v2/slack/events.ts @@ -3,6 +3,7 @@ import { getSecretByName } from '@buster/database/queries'; import { chats, slackIntegrations } from '@buster/database/schema'; import type { SlackEventsResponse } from '@buster/server-shared/slack'; import { + SlackMessagingService, type SlackWebhookPayload, addReaction, isAppMentionEvent, @@ -18,6 +19,41 @@ import { getUserIdFromAuthResult, } from './services/slack-authentication'; +/** + * Helper function to get Slack access token from vault + */ +async function getSlackAccessToken( + teamId: string, + organizationId?: string +): Promise { + const filters = [eq(slackIntegrations.teamId, teamId), eq(slackIntegrations.status, 'active')]; + if (organizationId) { + filters.push(eq(slackIntegrations.organizationId, organizationId)); + } + + try { + // Fetch Slack integration to get token vault key + const slackIntegration = await db + .select({ + tokenVaultKey: slackIntegrations.tokenVaultKey, + }) + .from(slackIntegrations) + .where(and(...filters)) + .limit(1); + + if (slackIntegration.length > 0 && slackIntegration[0]?.tokenVaultKey) { + // Get the access token from vault + const vaultSecret = await getSecretByName(slackIntegration[0].tokenVaultKey); + return vaultSecret?.secret || null; + } + + return null; + } catch (error) { + console.error('Failed to get Slack access token from vault:', error); + return null; + } +} + /** * Map authentication result type to database enum value */ @@ -176,9 +212,9 @@ export async function handleSlackEventsEndpoint(c: Context) { return c.json(response); } catch (error) { - // Handle authentication errors + // Handle authentication errors with 200 status code to prevent Slack from retrying the webhook if (error instanceof Error && error.message.includes('Unauthorized')) { - return c.json({ error: 'Unauthorized' }, 401); + return c.json({ error: 'Unauthorized' }, 200); } // Re-throw other errors throw error; @@ -212,19 +248,41 @@ export async function eventsHandler(payload: SlackWebhookPayload): Promise 0 && slackIntegration[0]?.tokenVaultKey) { - // Get the access token from vault - const vaultSecret = await getSecretByName(slackIntegration[0].tokenVaultKey); + if (accessToken) { + // Add the hourglass reaction + await addReaction({ + accessToken, + channelId: event.channel, + messageTs: event.ts, + emoji: 'hourglass_flowing_sand', + }); - if (vaultSecret?.secret) { - // Add the hourglass reaction - await addReaction({ - accessToken: vaultSecret.secret, - channelId: event.channel, - messageTs: event.ts, - emoji: 'hourglass_flowing_sand', - }); - - console.info('Added hourglass reaction to app mention', { - channel: event.channel, - messageTs: event.ts, - }); - } + console.info('Added hourglass reaction to app mention', { + channel: event.channel, + messageTs: event.ts, + }); } } catch (error) { // Log but don't fail the entire process if reaction fails