From 45b52e7fe561c2ced8967831ec75795685263ce1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Jul 2025 21:06:41 +0000 Subject: [PATCH] Add Slack channels endpoint with integration and error handling Co-authored-by: dallin --- .../src/api/v2/slack/channels.int.test.ts | 278 ++++++++++++++++++ apps/server/src/api/v2/slack/handler.ts | 104 ++++++- apps/server/src/api/v2/slack/index.ts | 1 + 3 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/api/v2/slack/channels.int.test.ts diff --git a/apps/server/src/api/v2/slack/channels.int.test.ts b/apps/server/src/api/v2/slack/channels.int.test.ts new file mode 100644 index 000000000..6bbcfaa6b --- /dev/null +++ b/apps/server/src/api/v2/slack/channels.int.test.ts @@ -0,0 +1,278 @@ +import { db, slackIntegrations } from '@buster/database'; +import { eq } from 'drizzle-orm'; +import { Hono } from 'hono'; +import type { Context } from 'hono'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import slackRoutes from './index'; +import { cleanupTestOrgAndUser, createTestOrgAndUser } from './test-helpers'; + +// Skip tests if required environment variables are not set +const skipIfNoEnv = + !process.env.DATABASE_URL || + !process.env.SLACK_CLIENT_ID || + !process.env.SLACK_CLIENT_SECRET || + !process.env.SLACK_REDIRECT_URI || + !process.env.SUPABASE_URL || + !process.env.SUPABASE_SERVICE_ROLE_KEY || + !process.env.SLACK_TEST_ACCESS_TOKEN; // Additional env var for testing channels + +// Mock the requireAuth middleware +vi.mock('../../../middleware/auth', () => ({ + requireAuth: (c: Context, next: () => Promise) => { + return next(); + }, +})); + +// Mock SlackChannelService if needed +const mockChannels = [ + { id: 'C1234567890', name: 'general', is_private: false, is_archived: false, is_member: true }, + { id: 'C0987654321', name: 'random', is_private: false, is_archived: false, is_member: true }, + { id: 'C1111111111', name: 'engineering', is_private: false, is_archived: false, is_member: false }, +]; + +// Conditionally mock SlackChannelService based on environment +if (!process.env.SLACK_TEST_ACCESS_TOKEN) { + vi.mock('@buster/slack', () => ({ + SlackChannelService: vi.fn().mockImplementation(() => ({ + getAvailableChannels: vi.fn().mockResolvedValue(mockChannels), + })), + SlackAuthService: vi.fn(), + })); +} + +describe.skipIf(skipIfNoEnv)('Slack Channels Integration Tests', () => { + let app: Hono; + let testOrganizationId: string; + let testUserId: string; + const createdIntegrationIds: string[] = []; + const testRunId = Date.now().toString(); + + beforeAll(async () => { + if (skipIfNoEnv) { + console.log('Skipping Slack channels integration tests - required environment variables not set'); + return; + } + + // Create unique test organization and user + const { organizationId, userId } = await createTestOrgAndUser(); + testOrganizationId = organizationId; + testUserId = userId; + + if (process.env.SLACK_TEST_ACCESS_TOKEN) { + console.log('Running with real Slack API access token'); + } else { + console.log('Running with mocked Slack responses'); + } + }); + + beforeEach(async () => { + if (!skipIfNoEnv) { + // Clean up any existing active integrations + try { + await db + .delete(slackIntegrations) + .where(eq(slackIntegrations.organizationId, testOrganizationId)); + } catch (error) { + console.error('Error cleaning up integrations:', error); + } + + // Create a new Hono app for each test + app = new Hono(); + + // Add middleware to set auth context + app.use('*', async (c, next) => { + const path = c.req.path; + if (path.includes('/channels')) { + (c as Context).set('busterUser', { id: testUserId }); + (c as Context).set('organizationId', testOrganizationId); + } + await next(); + }); + + // Mount the Slack routes + app.route('/api/v2/slack', slackRoutes); + } + }); + + afterAll(async () => { + // Clean up all test data + if (!skipIfNoEnv && testOrganizationId && testUserId) { + await cleanupTestOrgAndUser(testOrganizationId, testUserId); + } + }); + + describe('GET /api/v2/slack/channels', () => { + it('should return 404 when no integration exists', async () => { + const response = await app.request('/api/v2/slack/channels', { + method: 'GET', + }); + + expect(response.status).toBe(404); + const data = await response.json(); + expect(data.error).toBe('No active Slack integration found'); + expect(data.code).toBe('INTEGRATION_NOT_FOUND'); + }); + + it('should return channels for active integration', async () => { + // Create an active integration with a token + const tokenVaultKey = `test-token-${testRunId}-${Date.now()}`; + + // If we have a real token, store it in the vault + if (process.env.SLACK_TEST_ACCESS_TOKEN) { + const { createSecret } = await import('@buster/database'); + await createSecret({ + secret: process.env.SLACK_TEST_ACCESS_TOKEN, + name: tokenVaultKey, + description: 'Test Slack OAuth token', + }); + } + + const [integration] = await db + .insert(slackIntegrations) + .values({ + organizationId: testOrganizationId, + userId: testUserId, + status: 'active', + teamId: `T${testRunId}-channels`, + teamName: 'Test Workspace', + teamDomain: 'test-workspace', + botUserId: `U${testRunId}-bot`, + scope: 'channels:read', + tokenVaultKey, + installedAt: new Date().toISOString(), + }) + .returning(); + + createdIntegrationIds.push(integration.id); + + const response = await app.request('/api/v2/slack/channels', { + method: 'GET', + }); + + expect(response.status).toBe(200); + const data = await response.json(); + expect(data.channels).toBeDefined(); + expect(Array.isArray(data.channels)).toBe(true); + + // Each channel should have id and name + if (data.channels.length > 0) { + expect(data.channels[0]).toHaveProperty('id'); + expect(data.channels[0]).toHaveProperty('name'); + // Should not have other properties (only id and name as requested) + expect(Object.keys(data.channels[0])).toEqual(['id', 'name']); + } + + // Clean up the secret if we created one + if (process.env.SLACK_TEST_ACCESS_TOKEN) { + const { deleteSecret, getSecretByName } = await import('@buster/database'); + const secret = await getSecretByName(tokenVaultKey); + if (secret) { + await deleteSecret(secret.id); + } + } + }); + + it('should update last used timestamp when fetching channels', async () => { + // Create an active integration + const tokenVaultKey = `test-token-lastused-${testRunId}-${Date.now()}`; + + const [integration] = await db + .insert(slackIntegrations) + .values({ + organizationId: testOrganizationId, + userId: testUserId, + status: 'active', + teamId: `T${testRunId}-lastused`, + teamName: 'Test Workspace', + teamDomain: 'test-workspace', + botUserId: `U${testRunId}-bot`, + scope: 'channels:read', + tokenVaultKey, + installedAt: new Date().toISOString(), + lastUsedAt: null, // Initially null + }) + .returning(); + + createdIntegrationIds.push(integration.id); + + await app.request('/api/v2/slack/channels', { + method: 'GET', + }); + + // Check that lastUsedAt was updated + const [updated] = await db + .select() + .from(slackIntegrations) + .where(eq(slackIntegrations.id, integration.id)); + + expect(updated.lastUsedAt).toBeTruthy(); + expect(new Date(updated.lastUsedAt!).getTime()).toBeGreaterThan( + new Date(integration.createdAt).getTime() + ); + }); + + it('should return 503 when integration is disabled', async () => { + // Temporarily disable the integration + const originalEnabled = process.env.SLACK_INTEGRATION_ENABLED; + process.env.SLACK_INTEGRATION_ENABLED = 'false'; + + // Need to clear the module cache and re-import + vi.resetModules(); + const { default: freshRoutes } = await import('./index'); + + // Create a fresh app instance + const testApp = new Hono(); + testApp.use('*', async (c, next) => { + if (c.req.path.includes('/channels')) { + (c as Context).set('busterUser', { id: testUserId }); + (c as Context).set('organizationId', testOrganizationId); + } + await next(); + }); + testApp.route('/api/v2/slack', freshRoutes); + + const response = await testApp.request('/api/v2/slack/channels', { + method: 'GET', + }); + + expect(response.status).toBe(503); + const data = await response.json(); + expect(data.error).toBe('Slack integration is not configured'); + expect(data.code).toBe('INTEGRATION_NOT_CONFIGURED'); + + // Restore original value + process.env.SLACK_INTEGRATION_ENABLED = originalEnabled; + vi.resetModules(); + }); + + it('should handle token retrieval errors', async () => { + // Create an integration with a non-existent token key + const [integration] = await db + .insert(slackIntegrations) + .values({ + organizationId: testOrganizationId, + userId: testUserId, + status: 'active', + teamId: `T${testRunId}-notoken`, + teamName: 'Test Workspace', + teamDomain: 'test-workspace', + botUserId: `U${testRunId}-bot`, + scope: 'channels:read', + tokenVaultKey: 'non-existent-token-key', + installedAt: new Date().toISOString(), + }) + .returning(); + + createdIntegrationIds.push(integration.id); + + const response = await app.request('/api/v2/slack/channels', { + method: 'GET', + }); + + expect(response.status).toBe(500); + const data = await response.json(); + expect(data.error).toBe('Failed to retrieve authentication token'); + expect(data.code).toBe('TOKEN_RETRIEVAL_ERROR'); + }); + }); +}); \ No newline at end of file diff --git a/apps/server/src/api/v2/slack/handler.ts b/apps/server/src/api/v2/slack/handler.ts index 2dc5133e6..3b6263c87 100644 --- a/apps/server/src/api/v2/slack/handler.ts +++ b/apps/server/src/api/v2/slack/handler.ts @@ -3,6 +3,8 @@ import type { Context } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { z } from 'zod'; import { type SlackOAuthService, createSlackOAuthService } from './services/slack-oauth-service'; +import { SlackChannelService } from '@buster/slack'; +import * as slackHelpers from './services/slack-helpers'; // Request schemas const InitiateOAuthSchema = z.object({ @@ -24,7 +26,7 @@ const OAuthCallbackSchema = z.object({ export class SlackError extends Error { constructor( message: string, - public statusCode: 500 | 400 | 401 | 403 | 404 | 409 | 503 = 500, + public statusCode: 500 | 400 | 401 | 403 | 404 | 409 | 429 | 503 = 500, public code?: string ) { super(message); @@ -310,6 +312,106 @@ export class SlackHandler { ); } } + + /** + * GET /api/v2/slack/channels + * Get public channels for the current integration + */ + async getChannels(c: Context) { + try { + // Get service instance (lazy initialization) + const slackOAuthService = this.getSlackOAuthService(); + + // Check if service is available + if (!slackOAuthService) { + return c.json( + { + error: 'Slack integration is not configured', + code: 'INTEGRATION_NOT_CONFIGURED', + }, + 503 + ); + } + + const busterUser = c.get('busterUser'); + + if (!busterUser) { + throw new HTTPException(401, { message: 'Authentication required' }); + } + + const organizationGrant = await getUserOrganizationId(busterUser.id); + + if (!organizationGrant) { + throw new HTTPException(400, { message: 'Organization not found' }); + } + + // Get active integration + const integration = await slackHelpers.getActiveIntegration( + organizationGrant.organizationId + ); + + if (!integration) { + return c.json( + { + error: 'No active Slack integration found', + code: 'INTEGRATION_NOT_FOUND', + }, + 404 + ); + } + + // Get token from vault + const token = await slackOAuthService.getTokenFromVault(integration.id); + + if (!token) { + throw new SlackError( + 'Failed to retrieve authentication token', + 500, + 'TOKEN_RETRIEVAL_ERROR' + ); + } + + // Fetch channels using the SlackChannelService + const channelService = new SlackChannelService(); + const channels = await channelService.getAvailableChannels(token, false); + + // Update last used timestamp + await slackHelpers.updateLastUsedAt(integration.id); + + // Return only id and name as requested + return c.json({ + channels: channels.map((channel) => ({ + id: channel.id, + name: channel.name, + })), + }); + } catch (error) { + console.error('Failed to get channels:', error); + + if (error instanceof HTTPException) { + throw error; + } + + // Handle Slack-specific errors + if (error instanceof Error && error.message.includes('Invalid or expired access token')) { + throw new SlackError('Invalid or expired access token', 401, 'INVALID_TOKEN'); + } + + if (error instanceof Error && error.message.includes('Rate limit exceeded')) { + throw new SlackError( + 'Rate limit exceeded. Please try again later.', + 429, + 'RATE_LIMITED' + ); + } + + throw new SlackError( + error instanceof Error ? error.message : 'Failed to get channels', + 500, + 'GET_CHANNELS_ERROR' + ); + } + } } // Export singleton instance diff --git a/apps/server/src/api/v2/slack/index.ts b/apps/server/src/api/v2/slack/index.ts index 2ecbe46b4..6d2cad729 100644 --- a/apps/server/src/api/v2/slack/index.ts +++ b/apps/server/src/api/v2/slack/index.ts @@ -9,6 +9,7 @@ const app = new Hono() .get('/auth/callback', (c) => slackHandler.handleOAuthCallback(c)) // Protected endpoints .get('/integration', requireAuth, (c) => slackHandler.getIntegration(c)) + .get('/channels', requireAuth, (c) => slackHandler.getChannels(c)) .delete('/integration', requireAuth, (c) => slackHandler.removeIntegration(c)) // Error handling .onError((e, c) => {