mirror of https://github.com/buster-so/buster.git
Merge branch 'staging' into cursor/add-migration-and-update-endpoint-1563
This commit is contained in:
commit
8a85d42016
|
@ -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<void>) => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -4,6 +4,8 @@ import { HTTPException } from 'hono/http-exception';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { getActiveIntegration, updateDefaultChannel } from './services/slack-helpers';
|
import { getActiveIntegration, updateDefaultChannel } from './services/slack-helpers';
|
||||||
import { type SlackOAuthService, createSlackOAuthService } from './services/slack-oauth-service';
|
import { type SlackOAuthService, createSlackOAuthService } from './services/slack-oauth-service';
|
||||||
|
import { SlackChannelService } from '@buster/slack';
|
||||||
|
import * as slackHelpers from './services/slack-helpers';
|
||||||
|
|
||||||
// Request schemas
|
// Request schemas
|
||||||
const InitiateOAuthSchema = z.object({
|
const InitiateOAuthSchema = z.object({
|
||||||
|
@ -34,7 +36,7 @@ const UpdateIntegrationSchema = z.object({
|
||||||
export class SlackError extends Error {
|
export class SlackError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
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
|
public code?: string
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
|
@ -395,6 +397,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
|
// Export singleton instance
|
||||||
|
|
|
@ -10,6 +10,7 @@ const app = new Hono()
|
||||||
// Protected endpoints
|
// Protected endpoints
|
||||||
.get('/integration', requireAuth, (c) => slackHandler.getIntegration(c))
|
.get('/integration', requireAuth, (c) => slackHandler.getIntegration(c))
|
||||||
.put('/integration', requireAuth, (c) => slackHandler.updateIntegration(c))
|
.put('/integration', requireAuth, (c) => slackHandler.updateIntegration(c))
|
||||||
|
.get('/channels', requireAuth, (c) => slackHandler.getChannels(c))
|
||||||
.delete('/integration', requireAuth, (c) => slackHandler.removeIntegration(c))
|
.delete('/integration', requireAuth, (c) => slackHandler.removeIntegration(c))
|
||||||
// Error handling
|
// Error handling
|
||||||
.onError((e, c) => {
|
.onError((e, c) => {
|
||||||
|
|
Loading…
Reference in New Issue