mirror of https://github.com/buster-so/buster.git
send slack message if user is unauthorized
This commit is contained in:
parent
a8c6b1ad00
commit
d2ff191f17
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue