send slack message if user is unauthorized

This commit is contained in:
Wells Bunker 2025-09-25 17:13:13 -06:00
parent a8c6b1ad00
commit d2ff191f17
No known key found for this signature in database
GPG Key ID: DB16D6F2679B78FC
2 changed files with 416 additions and 44 deletions

View File

@ -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();
});
});

View File

@ -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<string | null> {
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<Slack
// Authenticate the Slack user
const authResult = await authenticateSlackUser(event.user, payload.team_id);
// Check if authentication was successful
const userId = getUserIdFromAuthResult(authResult);
if (!userId) {
console.warn('Slack user authentication failed:', {
slackUserId: event.user,
teamId: payload.team_id,
reason: authResult.type === 'unauthorized' ? authResult.reason : 'Unknown',
});
// Throw unauthorized error
if (authResult.type === 'unauthorized') {
if (authResult.reason.toLowerCase().includes('bot')) {
return { success: true };
}
try {
const accessToken = await getSlackAccessToken(payload.team_id);
if (accessToken) {
const messagingService = new SlackMessagingService();
const threadTs = event.thread_ts || event.ts;
await messagingService.sendMessage(accessToken, event.channel, {
text: 'Sorry, you are unauthorized to chat with Buster. Please contact your Workspace Administrator for access.',
thread_ts: threadTs,
});
console.info('Sent unauthorized message to Slack user', {
channel: event.channel,
user: event.user,
threadTs,
});
}
} catch (error) {
console.warn('Failed to send unauthorized message to Slack user', {
error: error instanceof Error ? error.message : 'Unknown error',
channel: event.channel,
user: event.user,
threadTs: event.thread_ts || event.ts,
});
}
throw new Error('Unauthorized: Slack user authentication failed');
}
const organizationId = authResult.type === 'unauthorized' ? '' : authResult.organization.id;
const userId = authResult.user.id;
const organizationId = authResult.organization.id;
// Extract thread timestamp - if no thread_ts, this is a new thread so use ts
const threadTs = event.thread_ts || event.ts;
@ -232,39 +290,21 @@ export async function eventsHandler(payload: SlackWebhookPayload): Promise<Slack
// Add hourglass reaction immediately after authentication
if (organizationId) {
try {
// Fetch Slack integration to get token vault key
const slackIntegration = await db
.select({
tokenVaultKey: slackIntegrations.tokenVaultKey,
})
.from(slackIntegrations)
.where(
and(
eq(slackIntegrations.organizationId, organizationId),
eq(slackIntegrations.teamId, payload.team_id),
eq(slackIntegrations.status, 'active')
)
)
.limit(1);
const accessToken = await getSlackAccessToken(payload.team_id, organizationId);
if (slackIntegration.length > 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