mirror of https://github.com/buster-so/buster.git
Adding github integration and webhooks
This commit is contained in:
parent
4f93c53c48
commit
d7e3eb5351
|
@ -37,6 +37,7 @@
|
|||
"@buster/vitest-config": "workspace:*",
|
||||
"@electric-sql/client": "catalog:",
|
||||
"@hono/zod-validator": "^0.7.3",
|
||||
"@octokit/webhooks": "^14.1.3",
|
||||
"@supabase/supabase-js": "catalog:",
|
||||
"@trigger.dev/sdk": "4.0.2",
|
||||
"ai": "catalog:",
|
||||
|
@ -45,6 +46,7 @@
|
|||
"hono-pino": "^0.10.2",
|
||||
"js-yaml": "catalog:",
|
||||
"lodash-es": "catalog:",
|
||||
"octokit": "^5.0.3",
|
||||
"pino": "^9.10.0",
|
||||
"pino-pretty": "^13.1.1",
|
||||
"tsup": "catalog:",
|
||||
|
|
|
@ -3,7 +3,8 @@ import {
|
|||
getActiveGithubIntegration,
|
||||
getUserOrganizationId,
|
||||
} from '@buster/database/queries';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { Hono } from 'hono';
|
||||
import { requireAuth } from '../../../../middleware/auth';
|
||||
|
||||
interface GetIntegrationResponse {
|
||||
connected: boolean;
|
||||
|
@ -13,6 +14,14 @@ interface GetIntegrationResponse {
|
|||
status?: string;
|
||||
}
|
||||
|
||||
const app = new Hono().get('/', requireAuth, async (c) => {
|
||||
const user = c.get('busterUser');
|
||||
const response = await getIntegrationHandler(user);
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
/**
|
||||
* Get the current GitHub integration status for the user's organization
|
||||
* Returns non-sensitive information about the integration
|
|
@ -0,0 +1,8 @@
|
|||
import { Hono } from 'hono';
|
||||
import GET from './GET';
|
||||
import INSTALL from './install';
|
||||
import WEBHOOKS from './webhooks';
|
||||
|
||||
const app = new Hono().route('/', GET).route('/webhooks', WEBHOOKS).route('/install', INSTALL);
|
||||
|
||||
export default app;
|
|
@ -1,15 +1,22 @@
|
|||
import { randomBytes } from 'node:crypto';
|
||||
import { type User, getUserOrganizationId } from '@buster/database/queries';
|
||||
import { getUserOrganizationId } from '@buster/database/queries';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { storeInstallationState } from '../services/installation-state';
|
||||
import { requireAuth } from '../../../../../middleware/auth';
|
||||
import { storeInstallationState } from '../../services/installation-state';
|
||||
|
||||
/**
|
||||
* Initiates the GitHub App installation flow
|
||||
* Redirects the user to GitHub to install the app with a state parameter
|
||||
*/
|
||||
export async function authInitHandler(user: User): Promise<{ redirectUrl: string }> {
|
||||
const app = new Hono().get('/', requireAuth, async (c) => {
|
||||
const user = c.get('busterUser');
|
||||
console.info('Github app/install received');
|
||||
const response = await appInstallHandler(user.id);
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
export async function appInstallHandler(userId: string): Promise<{ redirectUrl: string }> {
|
||||
// Get user's organization
|
||||
const userOrg = await getUserOrganizationId(user.id);
|
||||
const userOrg = await getUserOrganizationId(userId);
|
||||
if (!userOrg) {
|
||||
throw new HTTPException(400, {
|
||||
message: 'User is not associated with an organization',
|
||||
|
@ -21,7 +28,7 @@ export async function authInitHandler(user: User): Promise<{ redirectUrl: string
|
|||
|
||||
// Store the state with user/org context (expires in 10 minutes)
|
||||
await storeInstallationState(state, {
|
||||
userId: user.id,
|
||||
userId: userId,
|
||||
organizationId: userOrg.organizationId,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
@ -46,7 +53,7 @@ export async function authInitHandler(user: User): Promise<{ redirectUrl: string
|
|||
// We pass the state as a parameter that GitHub will preserve
|
||||
const redirectUrl = `https://github.com/apps/${appName}/installations/new?state=${state}`;
|
||||
|
||||
console.info(`Initiating GitHub installation for user ${user.id}, org ${userOrg.organizationId}`);
|
||||
console.info(`Initiating GitHub installation for user ${userId}, org ${userOrg.organizationId}`);
|
||||
|
||||
return { redirectUrl };
|
||||
}
|
|
@ -1,6 +1,37 @@
|
|||
import { getUserOrganizationId } from '@buster/database/queries';
|
||||
import { handleInstallationCallback } from '../services/handle-installation-callback';
|
||||
import { retrieveInstallationState } from '../services/installation-state';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { handleInstallationCallback } from '../../../services/handle-installation-callback';
|
||||
import { retrieveInstallationState } from '../../../services/installation-state';
|
||||
|
||||
// Define request schemas
|
||||
const GithubInstallationCallbackSchema = z.object({
|
||||
state: z.string().optional(),
|
||||
installation_id: z.string().optional(),
|
||||
setup_action: z.enum(['install', 'update']).optional(),
|
||||
error: z.string().optional(), // GitHub sends this when user cancels
|
||||
error_description: z.string().optional(),
|
||||
});
|
||||
|
||||
const app = new Hono().get(
|
||||
'/',
|
||||
zValidator('query', GithubInstallationCallbackSchema),
|
||||
async (c) => {
|
||||
const query = c.req.valid('query');
|
||||
console.info('GitHub auth callback received', { query });
|
||||
const result = await githubInstallationCallbackHandler({
|
||||
state: query.state,
|
||||
installation_id: query.installation_id,
|
||||
setup_action: query.setup_action,
|
||||
error: query.error,
|
||||
error_description: query.error_description,
|
||||
});
|
||||
return c.redirect(result.redirectUrl);
|
||||
}
|
||||
);
|
||||
|
||||
export default app;
|
||||
|
||||
interface CompleteInstallationRequest {
|
||||
state?: string | undefined;
|
||||
|
@ -19,7 +50,7 @@ interface AuthCallbackResult {
|
|||
* This is called after the user installs the app on GitHub
|
||||
* Returns a redirect URL to send the user to the appropriate page
|
||||
*/
|
||||
export async function authCallbackHandler(
|
||||
export async function githubInstallationCallbackHandler(
|
||||
request: CompleteInstallationRequest
|
||||
): Promise<AuthCallbackResult> {
|
||||
// Get base URL from environment
|
||||
|
@ -76,7 +107,6 @@ export async function authCallbackHandler(
|
|||
}
|
||||
|
||||
// Create the installation callback payload
|
||||
// The webhook will arrive shortly with full details, but we can create the record now
|
||||
const callbackPayload = {
|
||||
action: 'created' as const,
|
||||
installation: {
|
|
@ -0,0 +1,7 @@
|
|||
import { Hono } from 'hono';
|
||||
import GET from './GET';
|
||||
import CALLBACK_GET from './callback/GET';
|
||||
|
||||
const app = new Hono().route('/', GET).route('/callback', CALLBACK_GET);
|
||||
|
||||
export default app;
|
|
@ -0,0 +1,36 @@
|
|||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { githubWebhookMiddleware } from '../../../../../middleware/github-webhook-middleware';
|
||||
|
||||
const app = new Hono().post('/', githubWebhookMiddleware(), async (c) => {
|
||||
console.info('GitHub webhook POST');
|
||||
const githubApp = c.get('githubApp');
|
||||
if (!githubApp) {
|
||||
throw new HTTPException(400, {
|
||||
message: 'GitHub app not found',
|
||||
});
|
||||
}
|
||||
|
||||
githubApp.webhooks.on('pull_request.opened', ({ octokit, payload }) => {
|
||||
const owner = payload.repository.owner.login;
|
||||
const repo = payload.repository.name;
|
||||
const issue_number = payload.pull_request.number;
|
||||
const username = payload.pull_request.user.login;
|
||||
console.info(`Pull request opened by ${username} in ${owner}/${repo}#${issue_number}`);
|
||||
const body = 'Thank you for your pull request!';
|
||||
return octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
});
|
||||
|
||||
githubApp.webhooks.on('installation', ({ payload }) => {
|
||||
console.info(`Installation event received: ${payload.action}`);
|
||||
console.info('Installation event payload:', payload);
|
||||
return c.text('Installation event received and processed', 201);
|
||||
});
|
||||
});
|
||||
|
||||
export default app;
|
|
@ -0,0 +1,6 @@
|
|||
import { Hono } from 'hono';
|
||||
import POST from './POST';
|
||||
|
||||
const app = new Hono().route('/', POST);
|
||||
|
||||
export default app;
|
|
@ -1,368 +1,368 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '@buster/database/connection';
|
||||
import { createGithubIntegration, getSecretByName } from '@buster/database/queries';
|
||||
import { createSecret, deleteSecret } from '@buster/database/queries';
|
||||
import {
|
||||
githubIntegrations,
|
||||
organizations,
|
||||
users,
|
||||
usersToOrganizations,
|
||||
} from '@buster/database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { generateTestId, skipIfNoGitHubCredentials } from '../test-helpers/github-test-setup';
|
||||
import { authCallbackHandler } from './auth-callback';
|
||||
// import { randomUUID } from 'node:crypto';
|
||||
// import { db } from '@buster/database/connection';
|
||||
// import { createGithubIntegration, getSecretByName } from '@buster/database/queries';
|
||||
// import { createSecret, deleteSecret } from '@buster/database/queries';
|
||||
// import {
|
||||
// githubIntegrations,
|
||||
// organizations,
|
||||
// users,
|
||||
// usersToOrganizations,
|
||||
// } from '@buster/database/schema';
|
||||
// import { eq } from 'drizzle-orm';
|
||||
// import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
// import { generateTestId, skipIfNoGitHubCredentials } from '../test-helpers/github-test-setup';
|
||||
// import { authCallbackHandler } from './auth-callback';
|
||||
|
||||
describe('Auth Callback Handler Integration Tests', () => {
|
||||
const testIds: string[] = [];
|
||||
const secretIds: string[] = [];
|
||||
let testOrgId: string;
|
||||
let testUserId: string;
|
||||
// describe('Auth Callback Handler Integration Tests', () => {
|
||||
// const testIds: string[] = [];
|
||||
// const secretIds: string[] = [];
|
||||
// let testOrgId: string;
|
||||
// let testUserId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// beforeEach(async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// Set BUSTER_URL for redirect testing
|
||||
process.env.BUSTER_URL = 'http://localhost:3000';
|
||||
// // Set BUSTER_URL for redirect testing
|
||||
// process.env.BUSTER_URL = 'http://localhost:3000';
|
||||
|
||||
// Create test organization
|
||||
const [org] = await db
|
||||
.insert(organizations)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
name: generateTestId('test-org'),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
if (!org) throw new Error('Failed to create test organization');
|
||||
testOrgId = org.id;
|
||||
testIds.push(org.id);
|
||||
// // Create test organization
|
||||
// const [org] = await db
|
||||
// .insert(organizations)
|
||||
// .values({
|
||||
// id: randomUUID(),
|
||||
// name: generateTestId('test-org'),
|
||||
// createdAt: new Date().toISOString(),
|
||||
// updatedAt: new Date().toISOString(),
|
||||
// })
|
||||
// .returning();
|
||||
// if (!org) throw new Error('Failed to create test organization');
|
||||
// testOrgId = org.id;
|
||||
// testIds.push(org.id);
|
||||
|
||||
// Create test user
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
email: `${generateTestId('test')}@example.com`,
|
||||
name: 'Test User',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
if (!user) throw new Error('Failed to create test user');
|
||||
testUserId = user.id;
|
||||
testIds.push(user.id);
|
||||
// // Create test user
|
||||
// const [user] = await db
|
||||
// .insert(users)
|
||||
// .values({
|
||||
// id: randomUUID(),
|
||||
// email: `${generateTestId('test')}@example.com`,
|
||||
// name: 'Test User',
|
||||
// createdAt: new Date().toISOString(),
|
||||
// updatedAt: new Date().toISOString(),
|
||||
// })
|
||||
// .returning();
|
||||
// if (!user) throw new Error('Failed to create test user');
|
||||
// testUserId = user.id;
|
||||
// testIds.push(user.id);
|
||||
|
||||
// Create user organization grant
|
||||
const [grant] = await db
|
||||
.insert(usersToOrganizations)
|
||||
.values({
|
||||
userId: testUserId,
|
||||
organizationId: testOrgId,
|
||||
role: 'workspace_admin',
|
||||
createdBy: testUserId,
|
||||
updatedBy: testUserId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
// // Create user organization grant
|
||||
// const [grant] = await db
|
||||
// .insert(usersToOrganizations)
|
||||
// .values({
|
||||
// userId: testUserId,
|
||||
// organizationId: testOrgId,
|
||||
// role: 'workspace_admin',
|
||||
// createdBy: testUserId,
|
||||
// updatedBy: testUserId,
|
||||
// createdAt: new Date().toISOString(),
|
||||
// updatedAt: new Date().toISOString(),
|
||||
// })
|
||||
// .returning();
|
||||
// });
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up secrets
|
||||
for (const secretId of secretIds) {
|
||||
try {
|
||||
await deleteSecret(secretId);
|
||||
} catch (error) {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
secretIds.length = 0;
|
||||
// afterEach(async () => {
|
||||
// // Clean up secrets
|
||||
// for (const secretId of secretIds) {
|
||||
// try {
|
||||
// await deleteSecret(secretId);
|
||||
// } catch (error) {
|
||||
// // Ignore deletion errors
|
||||
// }
|
||||
// }
|
||||
// secretIds.length = 0;
|
||||
|
||||
// Clean up test integrations
|
||||
await db.delete(githubIntegrations).where(eq(githubIntegrations.organizationId, testOrgId));
|
||||
// // Clean up test integrations
|
||||
// await db.delete(githubIntegrations).where(eq(githubIntegrations.organizationId, testOrgId));
|
||||
|
||||
// Clean up test data
|
||||
for (const id of testIds) {
|
||||
try {
|
||||
await db.delete(organizations).where(eq(organizations.id, id));
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
} catch (error) {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
testIds.length = 0;
|
||||
});
|
||||
// // Clean up test data
|
||||
// for (const id of testIds) {
|
||||
// try {
|
||||
// await db.delete(organizations).where(eq(organizations.id, id));
|
||||
// await db.delete(users).where(eq(users.id, id));
|
||||
// } catch (error) {
|
||||
// // Ignore deletion errors
|
||||
// }
|
||||
// }
|
||||
// testIds.length = 0;
|
||||
// });
|
||||
|
||||
describe('OAuth Callback Handling', () => {
|
||||
it('should handle user cancellation', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// describe('OAuth Callback Handling', () => {
|
||||
// it('should handle user cancellation', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
error: 'access_denied',
|
||||
error_description: 'User cancelled the authorization',
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// error: 'access_denied',
|
||||
// error_description: 'User cancelled the authorization',
|
||||
// });
|
||||
|
||||
expect(result.redirectUrl).toBe(
|
||||
'http://localhost:3000/app/settings/integrations?status=cancelled'
|
||||
);
|
||||
});
|
||||
// expect(result.redirectUrl).toBe(
|
||||
// 'http://localhost:3000/app/settings/integrations?status=cancelled'
|
||||
// );
|
||||
// });
|
||||
|
||||
it('should handle GitHub errors', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should handle GitHub errors', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
error: 'server_error',
|
||||
error_description: 'GitHub had an internal error',
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// error: 'server_error',
|
||||
// error_description: 'GitHub had an internal error',
|
||||
// });
|
||||
|
||||
expect(result.redirectUrl).toContain('status=error');
|
||||
expect(result.redirectUrl).toContain('error=GitHub%20had%20an%20internal%20error');
|
||||
});
|
||||
// expect(result.redirectUrl).toContain('status=error');
|
||||
// expect(result.redirectUrl).toContain('error=GitHub%20had%20an%20internal%20error');
|
||||
// });
|
||||
|
||||
it('should handle missing installation_id', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should handle missing installation_id', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
state: 'some-state',
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// state: 'some-state',
|
||||
// });
|
||||
|
||||
expect(result.redirectUrl).toBe(
|
||||
'http://localhost:3000/app/settings/integrations?status=error&error=missing_installation_id'
|
||||
);
|
||||
});
|
||||
// expect(result.redirectUrl).toBe(
|
||||
// 'http://localhost:3000/app/settings/integrations?status=error&error=missing_installation_id'
|
||||
// );
|
||||
// });
|
||||
|
||||
it('should handle missing state', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should handle missing state', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
installation_id: '12345',
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// installation_id: '12345',
|
||||
// });
|
||||
|
||||
expect(result.redirectUrl).toBe(
|
||||
'http://localhost:3000/app/settings/integrations?status=error&error=missing_state'
|
||||
);
|
||||
});
|
||||
// expect(result.redirectUrl).toBe(
|
||||
// 'http://localhost:3000/app/settings/integrations?status=error&error=missing_state'
|
||||
// );
|
||||
// });
|
||||
|
||||
it('should handle invalid state', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should handle invalid state', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
installation_id: '12345',
|
||||
state: 'invalid-state-that-does-not-exist',
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// installation_id: '12345',
|
||||
// state: 'invalid-state-that-does-not-exist',
|
||||
// });
|
||||
|
||||
expect(result.redirectUrl).toBe(
|
||||
'http://localhost:3000/app/settings/integrations?status=error&error=invalid_state'
|
||||
);
|
||||
});
|
||||
// expect(result.redirectUrl).toBe(
|
||||
// 'http://localhost:3000/app/settings/integrations?status=error&error=invalid_state'
|
||||
// );
|
||||
// });
|
||||
|
||||
it('should handle expired state', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should handle expired state', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const stateId = generateTestId('state');
|
||||
const stateKey = `github_oauth_state_${stateId}`;
|
||||
// const stateId = generateTestId('state');
|
||||
// const stateKey = `github_oauth_state_${stateId}`;
|
||||
|
||||
// Create an expired state (expired 10 minutes ago)
|
||||
const expiredMetadata = {
|
||||
userId: testUserId,
|
||||
organizationId: testOrgId,
|
||||
expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||||
};
|
||||
// // Create an expired state (expired 10 minutes ago)
|
||||
// const expiredMetadata = {
|
||||
// userId: testUserId,
|
||||
// organizationId: testOrgId,
|
||||
// expiresAt: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
||||
// };
|
||||
|
||||
const secretId = await createSecret({
|
||||
name: stateKey,
|
||||
secret: 'test-secret',
|
||||
description: JSON.stringify(expiredMetadata),
|
||||
});
|
||||
secretIds.push(secretId);
|
||||
// const secretId = await createSecret({
|
||||
// name: stateKey,
|
||||
// secret: 'test-secret',
|
||||
// description: JSON.stringify(expiredMetadata),
|
||||
// });
|
||||
// secretIds.push(secretId);
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
installation_id: '12345',
|
||||
state: stateId,
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// installation_id: '12345',
|
||||
// state: stateId,
|
||||
// });
|
||||
|
||||
expect(result.redirectUrl).toBe(
|
||||
'http://localhost:3000/app/settings/integrations?status=error&error=invalid_state'
|
||||
);
|
||||
});
|
||||
// expect(result.redirectUrl).toBe(
|
||||
// 'http://localhost:3000/app/settings/integrations?status=error&error=invalid_state'
|
||||
// );
|
||||
// });
|
||||
|
||||
it('should handle valid state but user no longer has org access', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should handle valid state but user no longer has org access', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const stateId = generateTestId('state');
|
||||
const stateKey = `github_oauth_state_${stateId}`;
|
||||
// const stateId = generateTestId('state');
|
||||
// const stateKey = `github_oauth_state_${stateId}`;
|
||||
|
||||
// Create a different org that user doesn't have access to
|
||||
const [differentOrg] = await db
|
||||
.insert(organizations)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
name: generateTestId('different-org'),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
if (!differentOrg) throw new Error('Failed to create different org');
|
||||
testIds.push(differentOrg.id);
|
||||
// // Create a different org that user doesn't have access to
|
||||
// const [differentOrg] = await db
|
||||
// .insert(organizations)
|
||||
// .values({
|
||||
// id: randomUUID(),
|
||||
// name: generateTestId('different-org'),
|
||||
// createdAt: new Date().toISOString(),
|
||||
// updatedAt: new Date().toISOString(),
|
||||
// })
|
||||
// .returning();
|
||||
// if (!differentOrg) throw new Error('Failed to create different org');
|
||||
// testIds.push(differentOrg.id);
|
||||
|
||||
// Create state with different org
|
||||
const stateMetadata = {
|
||||
userId: testUserId,
|
||||
organizationId: differentOrg.id, // User doesn't have access to this org
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
};
|
||||
// // Create state with different org
|
||||
// const stateMetadata = {
|
||||
// userId: testUserId,
|
||||
// organizationId: differentOrg.id, // User doesn't have access to this org
|
||||
// expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
// };
|
||||
|
||||
const secretId = await createSecret({
|
||||
name: stateKey,
|
||||
secret: 'test-secret',
|
||||
description: JSON.stringify(stateMetadata),
|
||||
});
|
||||
secretIds.push(secretId);
|
||||
// const secretId = await createSecret({
|
||||
// name: stateKey,
|
||||
// secret: 'test-secret',
|
||||
// description: JSON.stringify(stateMetadata),
|
||||
// });
|
||||
// secretIds.push(secretId);
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
installation_id: '12345',
|
||||
state: stateId,
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// installation_id: '12345',
|
||||
// state: stateId,
|
||||
// });
|
||||
|
||||
expect(result.redirectUrl).toBe(
|
||||
'http://localhost:3000/app/settings/integrations?status=error&error=unauthorized'
|
||||
);
|
||||
});
|
||||
// expect(result.redirectUrl).toBe(
|
||||
// 'http://localhost:3000/app/settings/integrations?status=error&error=unauthorized'
|
||||
// );
|
||||
// });
|
||||
|
||||
it('should handle successful callback and create integration', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should handle successful callback and create integration', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const stateId = generateTestId('state');
|
||||
const stateKey = `github_oauth_state_${stateId}`;
|
||||
const installationId = generateTestId('install');
|
||||
// const stateId = generateTestId('state');
|
||||
// const stateKey = `github_oauth_state_${stateId}`;
|
||||
// const installationId = generateTestId('install');
|
||||
|
||||
// Create valid state
|
||||
const stateMetadata = {
|
||||
userId: testUserId,
|
||||
organizationId: testOrgId,
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
};
|
||||
// // Create valid state
|
||||
// const stateMetadata = {
|
||||
// userId: testUserId,
|
||||
// organizationId: testOrgId,
|
||||
// expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
// };
|
||||
|
||||
const secretId = await createSecret({
|
||||
name: stateKey,
|
||||
secret: 'test-secret',
|
||||
description: JSON.stringify(stateMetadata),
|
||||
});
|
||||
secretIds.push(secretId);
|
||||
// const secretId = await createSecret({
|
||||
// name: stateKey,
|
||||
// secret: 'test-secret',
|
||||
// description: JSON.stringify(stateMetadata),
|
||||
// });
|
||||
// secretIds.push(secretId);
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
installation_id: installationId,
|
||||
state: stateId,
|
||||
setup_action: 'install',
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// installation_id: installationId,
|
||||
// state: stateId,
|
||||
// setup_action: 'install',
|
||||
// });
|
||||
|
||||
// Should redirect to success
|
||||
expect(result.redirectUrl).toContain('status=success');
|
||||
// // Should redirect to success
|
||||
// expect(result.redirectUrl).toContain('status=success');
|
||||
|
||||
// Verify integration was created
|
||||
const [integration] = await db
|
||||
.select()
|
||||
.from(githubIntegrations)
|
||||
.where(eq(githubIntegrations.installationId, installationId));
|
||||
// // Verify integration was created
|
||||
// const [integration] = await db
|
||||
// .select()
|
||||
// .from(githubIntegrations)
|
||||
// .where(eq(githubIntegrations.installationId, installationId));
|
||||
|
||||
expect(integration).toBeTruthy();
|
||||
expect(integration?.userId).toBe(testUserId);
|
||||
expect(integration?.status).toBe('pending'); // Initial status before webhook
|
||||
});
|
||||
// expect(integration).toBeTruthy();
|
||||
// expect(integration?.userId).toBe(testUserId);
|
||||
// expect(integration?.status).toBe('pending'); // Initial status before webhook
|
||||
// });
|
||||
|
||||
it('should handle update action for existing installation', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should handle update action for existing installation', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const stateId = generateTestId('state');
|
||||
const stateKey = `github_oauth_state_${stateId}`;
|
||||
const installationId = generateTestId('install');
|
||||
// const stateId = generateTestId('state');
|
||||
// const stateKey = `github_oauth_state_${stateId}`;
|
||||
// const installationId = generateTestId('install');
|
||||
|
||||
// Create existing integration
|
||||
await createGithubIntegration({
|
||||
organizationId: testOrgId,
|
||||
userId: testUserId,
|
||||
installationId: installationId,
|
||||
githubOrgId: generateTestId('github-org'),
|
||||
status: 'active',
|
||||
});
|
||||
// // Create existing integration
|
||||
// await createGithubIntegration({
|
||||
// organizationId: testOrgId,
|
||||
// userId: testUserId,
|
||||
// installationId: installationId,
|
||||
// githubOrgId: generateTestId('github-org'),
|
||||
// status: 'active',
|
||||
// });
|
||||
|
||||
// Create valid state
|
||||
const stateMetadata = {
|
||||
userId: testUserId,
|
||||
organizationId: testOrgId,
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
};
|
||||
// // Create valid state
|
||||
// const stateMetadata = {
|
||||
// userId: testUserId,
|
||||
// organizationId: testOrgId,
|
||||
// expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
// };
|
||||
|
||||
const secretId = await createSecret({
|
||||
name: stateKey,
|
||||
secret: 'test-secret',
|
||||
description: JSON.stringify(stateMetadata),
|
||||
});
|
||||
secretIds.push(secretId);
|
||||
// const secretId = await createSecret({
|
||||
// name: stateKey,
|
||||
// secret: 'test-secret',
|
||||
// description: JSON.stringify(stateMetadata),
|
||||
// });
|
||||
// secretIds.push(secretId);
|
||||
|
||||
const result = await authCallbackHandler({
|
||||
installation_id: installationId,
|
||||
state: stateId,
|
||||
setup_action: 'update',
|
||||
});
|
||||
// const result = await authCallbackHandler({
|
||||
// installation_id: installationId,
|
||||
// state: stateId,
|
||||
// setup_action: 'update',
|
||||
// });
|
||||
|
||||
// Should redirect to success
|
||||
expect(result.redirectUrl).toContain('status=success');
|
||||
});
|
||||
// // Should redirect to success
|
||||
// expect(result.redirectUrl).toContain('status=success');
|
||||
// });
|
||||
|
||||
it('should clean up state after successful callback', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
// it('should clean up state after successful callback', async () => {
|
||||
// if (skipIfNoGitHubCredentials()) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
const stateId = generateTestId('state');
|
||||
const stateKey = `github_oauth_state_${stateId}`;
|
||||
const installationId = generateTestId('install');
|
||||
// const stateId = generateTestId('state');
|
||||
// const stateKey = `github_oauth_state_${stateId}`;
|
||||
// const installationId = generateTestId('install');
|
||||
|
||||
// Create valid state
|
||||
const stateMetadata = {
|
||||
userId: testUserId,
|
||||
organizationId: testOrgId,
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
};
|
||||
// // Create valid state
|
||||
// const stateMetadata = {
|
||||
// userId: testUserId,
|
||||
// organizationId: testOrgId,
|
||||
// expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
// };
|
||||
|
||||
const secretId = await createSecret({
|
||||
name: stateKey,
|
||||
secret: 'test-secret',
|
||||
description: JSON.stringify(stateMetadata),
|
||||
});
|
||||
// Don't track for cleanup since it should be auto-deleted
|
||||
// const secretId = await createSecret({
|
||||
// name: stateKey,
|
||||
// secret: 'test-secret',
|
||||
// description: JSON.stringify(stateMetadata),
|
||||
// });
|
||||
// // Don't track for cleanup since it should be auto-deleted
|
||||
|
||||
await authCallbackHandler({
|
||||
installation_id: installationId,
|
||||
state: stateId,
|
||||
});
|
||||
// await authCallbackHandler({
|
||||
// installation_id: installationId,
|
||||
// state: stateId,
|
||||
// });
|
||||
|
||||
// Verify state was deleted
|
||||
const deletedState = await getSecretByName(stateKey);
|
||||
expect(deletedState).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
// // Verify state was deleted
|
||||
// const deletedState = await getSecretByName(stateKey);
|
||||
// expect(deletedState).toBeNull();
|
||||
// });
|
||||
// });
|
||||
// });
|
||||
|
|
|
@ -1,288 +0,0 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '@buster/database/connection';
|
||||
import { createGithubIntegration, getSecretByName } from '@buster/database/queries';
|
||||
import type { User } from '@buster/database/queries';
|
||||
import { deleteSecret } from '@buster/database/queries';
|
||||
import {
|
||||
githubIntegrations,
|
||||
organizations,
|
||||
users,
|
||||
usersToOrganizations,
|
||||
} from '@buster/database/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { generateTestId, skipIfNoGitHubCredentials } from '../test-helpers/github-test-setup';
|
||||
import { authInitHandler } from './auth-init';
|
||||
|
||||
describe('Auth Init Handler Integration Tests', () => {
|
||||
const testIds: string[] = [];
|
||||
const secretNames: string[] = [];
|
||||
let testOrgId: string;
|
||||
let testUser: User;
|
||||
|
||||
beforeEach(async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create test organization
|
||||
const [org] = await db
|
||||
.insert(organizations)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
name: generateTestId('test-org'),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
if (!org) throw new Error('Failed to create test organization');
|
||||
testOrgId = org.id;
|
||||
testIds.push(org.id);
|
||||
|
||||
// Create test user
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
email: `${generateTestId('test')}@example.com`,
|
||||
name: 'Test User',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
if (!user) throw new Error('Failed to create test user');
|
||||
testUser = user;
|
||||
testIds.push(user.id);
|
||||
|
||||
// Create user organization grant
|
||||
const [grant] = await db
|
||||
.insert(usersToOrganizations)
|
||||
.values({
|
||||
userId: testUser.id,
|
||||
organizationId: testOrgId,
|
||||
role: 'workspace_admin',
|
||||
createdBy: testUser.id,
|
||||
updatedBy: testUser.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up secrets
|
||||
for (const secretName of secretNames) {
|
||||
try {
|
||||
const secret = await getSecretByName(secretName);
|
||||
if (secret) {
|
||||
// Use deleteSecret function instead of direct db access
|
||||
await deleteSecret(secret.id);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
secretNames.length = 0;
|
||||
|
||||
// Clean up test integrations
|
||||
await db.delete(githubIntegrations).where(eq(githubIntegrations.organizationId, testOrgId));
|
||||
|
||||
// Clean up test data
|
||||
for (const id of testIds) {
|
||||
try {
|
||||
await db.delete(organizations).where(eq(organizations.id, id));
|
||||
await db.delete(users).where(eq(users.id, id));
|
||||
} catch (error) {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
testIds.length = 0;
|
||||
});
|
||||
|
||||
describe('OAuth Initiation', () => {
|
||||
it('should initiate OAuth flow and create state', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await authInitHandler(testUser);
|
||||
|
||||
// Should return installation URL with state
|
||||
expect(result.redirectUrl).toContain('https://github.com/apps/');
|
||||
expect(result.redirectUrl).toContain('/installations/new');
|
||||
expect(result.redirectUrl).toContain('state=');
|
||||
|
||||
// Extract state from URL
|
||||
const url = new URL(result.redirectUrl);
|
||||
const state = url.searchParams.get('state');
|
||||
expect(state).toBeTruthy();
|
||||
|
||||
// Track for cleanup
|
||||
if (state) {
|
||||
secretNames.push(`github_oauth_state_${state}`);
|
||||
}
|
||||
|
||||
// Verify state was stored in vault
|
||||
const storedState = await getSecretByName(`github_oauth_state_${state}`);
|
||||
expect(storedState).toBeTruthy();
|
||||
expect(storedState?.description).toContain(testOrgId);
|
||||
expect(storedState?.description).toContain(testUser.id);
|
||||
});
|
||||
|
||||
it('should use GITHUB_APP_NAME from environment', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await authInitHandler(testUser);
|
||||
|
||||
const appName = process.env.GITHUB_APP_NAME;
|
||||
if (appName) {
|
||||
expect(result.redirectUrl).toContain(`/apps/${appName}/installations/new`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should prevent duplicate active integrations', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an existing active integration
|
||||
await createGithubIntegration({
|
||||
organizationId: testOrgId,
|
||||
userId: testUser.id,
|
||||
installationId: generateTestId('existing'),
|
||||
githubOrgId: generateTestId('github-org'),
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// Should throw error when trying to initiate another
|
||||
await expect(authInitHandler(testUser)).rejects.toThrow(
|
||||
/already has an active GitHub integration/
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow initiation when existing integration is revoked', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a revoked integration
|
||||
await createGithubIntegration({
|
||||
organizationId: testOrgId,
|
||||
userId: testUser.id,
|
||||
installationId: generateTestId('revoked'),
|
||||
githubOrgId: generateTestId('github-org'),
|
||||
status: 'revoked',
|
||||
});
|
||||
|
||||
// Should allow new initiation
|
||||
const result = await authInitHandler(testUser);
|
||||
expect(result.redirectUrl).toContain('https://github.com/apps/');
|
||||
|
||||
// Extract and track state for cleanup
|
||||
const url = new URL(result.redirectUrl);
|
||||
const state = url.searchParams.get('state');
|
||||
if (state) {
|
||||
secretNames.push(`github_oauth_state_${state}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate unique states for each initiation', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create another test user and org
|
||||
const [org2] = await db
|
||||
.insert(organizations)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
name: generateTestId('test-org2'),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
if (!org2) throw new Error('Failed to create test org2');
|
||||
testIds.push(org2.id);
|
||||
|
||||
const [user2] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
email: `${generateTestId('test2')}@example.com`,
|
||||
name: 'Test User 2',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
if (!user2) throw new Error('Failed to create test user2');
|
||||
testIds.push(user2.id);
|
||||
|
||||
// Create grant for user2
|
||||
const [grant2] = await db
|
||||
.insert(usersToOrganizations)
|
||||
.values({
|
||||
userId: user2.id,
|
||||
organizationId: org2.id,
|
||||
role: 'workspace_admin',
|
||||
createdBy: user2.id,
|
||||
updatedBy: user2.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Initiate for both users
|
||||
const result1 = await authInitHandler(testUser);
|
||||
const result2 = await authInitHandler(user2);
|
||||
|
||||
// Extract states
|
||||
const url1 = new URL(result1.redirectUrl);
|
||||
const url2 = new URL(result2.redirectUrl);
|
||||
const state1 = url1.searchParams.get('state');
|
||||
const state2 = url2.searchParams.get('state');
|
||||
|
||||
// States should be different
|
||||
expect(state1).not.toBe(state2);
|
||||
|
||||
// Track for cleanup
|
||||
if (state1) secretNames.push(`github_oauth_state_${state1}`);
|
||||
if (state2) secretNames.push(`github_oauth_state_${state2}`);
|
||||
});
|
||||
|
||||
it('should include proper state expiration in metadata', async () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await authInitHandler(testUser);
|
||||
|
||||
// Extract state
|
||||
const url = new URL(result.redirectUrl);
|
||||
const state = url.searchParams.get('state');
|
||||
expect(state).toBeTruthy();
|
||||
|
||||
// Track for cleanup
|
||||
if (state) {
|
||||
secretNames.push(`github_oauth_state_${state}`);
|
||||
}
|
||||
|
||||
// Check stored state metadata
|
||||
const storedState = await getSecretByName(`github_oauth_state_${state}`);
|
||||
expect(storedState).toBeTruthy();
|
||||
|
||||
if (storedState?.description) {
|
||||
const metadata = JSON.parse(storedState.description);
|
||||
expect(metadata.expiresAt).toBeTruthy();
|
||||
|
||||
// Should expire in 10 minutes
|
||||
const expiresAt = new Date(metadata.expiresAt);
|
||||
const now = new Date();
|
||||
const diffMinutes = (expiresAt.getTime() - now.getTime()) / (1000 * 60);
|
||||
expect(diffMinutes).toBeGreaterThan(9);
|
||||
expect(diffMinutes).toBeLessThan(11);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,74 +1,6 @@
|
|||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { z } from 'zod';
|
||||
import { requireAuth } from '../../../middleware/auth';
|
||||
import { githubWebhookValidator } from '../../../middleware/github-webhook-validator';
|
||||
import '../../../types/hono.types';
|
||||
import { authCallbackHandler } from './handlers/auth-callback';
|
||||
import { authInitHandler } from './handlers/auth-init';
|
||||
import { getIntegrationHandler } from './handlers/get-integration';
|
||||
import { webhookHandler } from './handlers/webhook';
|
||||
import APP from './app';
|
||||
|
||||
// Define request schemas
|
||||
const AuthCallbackSchema = z.object({
|
||||
state: z.string().optional(),
|
||||
installation_id: z.string().optional(),
|
||||
setup_action: z.enum(['install', 'update']).optional(),
|
||||
error: z.string().optional(), // GitHub sends this when user cancels
|
||||
error_description: z.string().optional(),
|
||||
});
|
||||
|
||||
const app = new Hono()
|
||||
// Get integration info - returns non-sensitive data
|
||||
.get('/', requireAuth, async (c) => {
|
||||
const user = c.get('busterUser');
|
||||
const response = await getIntegrationHandler(user);
|
||||
return c.json(response);
|
||||
})
|
||||
|
||||
// OAuth flow endpoints
|
||||
.get('/auth/init', requireAuth, async (c) => {
|
||||
const user = c.get('busterUser');
|
||||
const response = await authInitHandler(user);
|
||||
return c.json(response);
|
||||
})
|
||||
|
||||
// OAuth callback - no auth needed since GitHub redirects here
|
||||
.get('/auth/callback', zValidator('query', AuthCallbackSchema), async (c) => {
|
||||
const query = c.req.valid('query');
|
||||
const result = await authCallbackHandler({
|
||||
state: query.state,
|
||||
installation_id: query.installation_id,
|
||||
setup_action: query.setup_action,
|
||||
error: query.error,
|
||||
error_description: query.error_description,
|
||||
});
|
||||
return c.redirect(result.redirectUrl);
|
||||
})
|
||||
|
||||
// Webhook endpoint - no auth required, verified by signature
|
||||
.post('/webhook', githubWebhookValidator(), async (c) => {
|
||||
const payload = c.get('githubPayload');
|
||||
|
||||
if (!payload) {
|
||||
throw new HTTPException(400, {
|
||||
message: 'Invalid webhook payload',
|
||||
});
|
||||
}
|
||||
|
||||
const response = await webhookHandler(payload);
|
||||
return c.json(response, 200);
|
||||
})
|
||||
|
||||
// Error handling
|
||||
.onError((e, c) => {
|
||||
if (e instanceof HTTPException) {
|
||||
return e.getResponse();
|
||||
}
|
||||
|
||||
console.error('Unhandled error in GitHub routes:', e);
|
||||
return c.json({ error: 'Internal server error' }, 500);
|
||||
});
|
||||
const app = new Hono().route('/app', APP);
|
||||
|
||||
export default app;
|
||||
|
|
|
@ -9,9 +9,7 @@ import type { githubIntegrations } from '@buster/database/schema';
|
|||
import {
|
||||
GitHubErrorCode,
|
||||
type InstallationCallbackRequest,
|
||||
createGitHubApp,
|
||||
deleteInstallationToken,
|
||||
storeInstallationToken,
|
||||
} from '@buster/github';
|
||||
import type { InferSelectModel } from 'drizzle-orm';
|
||||
|
||||
|
@ -98,15 +96,7 @@ async function handleInstallationCreated(params: {
|
|||
);
|
||||
}
|
||||
|
||||
// Generate and store new token
|
||||
const tokenVaultKey = await generateAndStoreToken(installation.id.toString());
|
||||
|
||||
// Update the integration with the new vault key
|
||||
const fullyUpdated = await updateGithubIntegration(existing.id, {
|
||||
tokenVaultKey,
|
||||
});
|
||||
|
||||
return fullyUpdated || updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Create new integration
|
||||
|
@ -116,7 +106,7 @@ async function handleInstallationCreated(params: {
|
|||
githubOrgName: installation.account.login,
|
||||
organizationId,
|
||||
userId,
|
||||
status: 'pending', // Will be updated to 'active' after token generation
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
|
@ -126,25 +116,9 @@ async function handleInstallationCreated(params: {
|
|||
);
|
||||
}
|
||||
|
||||
// Generate and store installation token
|
||||
const tokenVaultKey = await generateAndStoreToken(installation.id.toString());
|
||||
|
||||
// Update integration with token vault key and active status
|
||||
const updatedIntegration = await updateGithubIntegration(integration.id, {
|
||||
tokenVaultKey,
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
if (!updatedIntegration) {
|
||||
throw createGitHubError(
|
||||
GitHubErrorCode.DATABASE_ERROR,
|
||||
`Failed to update integration for installation ${installation.id}`
|
||||
);
|
||||
}
|
||||
|
||||
console.info(`Created GitHub integration for installation ${installation.id}`);
|
||||
|
||||
return updatedIntegration;
|
||||
return integration;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -222,13 +196,9 @@ async function handleInstallationUnsuspended(installationId: string): Promise<Gi
|
|||
);
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
const tokenVaultKey = await generateAndStoreToken(installationId);
|
||||
|
||||
// Update status to active
|
||||
const unsuspended = await updateGithubIntegration(integration.id, {
|
||||
status: 'active',
|
||||
tokenVaultKey,
|
||||
});
|
||||
|
||||
if (!unsuspended) {
|
||||
|
@ -246,35 +216,36 @@ async function handleInstallationUnsuspended(installationId: string): Promise<Gi
|
|||
/**
|
||||
* Generate and store an installation token
|
||||
*/
|
||||
async function generateAndStoreToken(installationId: string): Promise<string> {
|
||||
try {
|
||||
const app = createGitHubApp();
|
||||
// Commenting out for now since I don't believe we should store tokens. If we want to then we can add it back easily.
|
||||
// async function generateAndStoreToken(installationId: string): Promise<string> {
|
||||
// try {
|
||||
// const app = createGitHubApp();
|
||||
|
||||
// Generate installation access token
|
||||
const { data } = await app.octokit.rest.apps.createInstallationAccessToken({
|
||||
installation_id: Number.parseInt(installationId, 10),
|
||||
});
|
||||
// // Generate installation access token
|
||||
// const { data } = await app.octokit.rest.apps.createInstallationAccessToken({
|
||||
// installation_id: Number.parseInt(installationId, 10),
|
||||
// });
|
||||
|
||||
// Store token in vault
|
||||
const vaultKey = await storeInstallationToken(
|
||||
installationId,
|
||||
data.token,
|
||||
data.expires_at,
|
||||
data.permissions,
|
||||
data.repository_selection
|
||||
);
|
||||
// // Store token in vault
|
||||
// const vaultKey = await storeInstallationToken(
|
||||
// installationId,
|
||||
// data.token,
|
||||
// data.expires_at,
|
||||
// data.permissions,
|
||||
// data.repository_selection
|
||||
// );
|
||||
|
||||
console.info(`Generated and stored token for installation ${installationId}`);
|
||||
// console.info(`Generated and stored token for installation ${installationId}`);
|
||||
|
||||
return vaultKey;
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate token for installation ${installationId}:`, error);
|
||||
throw createGitHubError(
|
||||
GitHubErrorCode.TOKEN_GENERATION_FAILED,
|
||||
`Failed to generate token: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// return vaultKey;
|
||||
// } catch (error) {
|
||||
// console.error(`Failed to generate token for installation ${installationId}:`, error);
|
||||
// throw createGitHubError(
|
||||
// GitHubErrorCode.TOKEN_GENERATION_FAILED,
|
||||
// `Failed to generate token: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Create a GitHub operation error
|
||||
|
|
|
@ -23,10 +23,3 @@ export {
|
|||
retrieveInstallationToken,
|
||||
storeInstallationToken,
|
||||
} from './token-storage';
|
||||
|
||||
// Webhook signature verification
|
||||
export {
|
||||
extractGitHubWebhookSignature,
|
||||
verifyGitHubWebhook,
|
||||
verifyGitHubWebhookSignature,
|
||||
} from './verify-webhook-signature';
|
||||
|
|
|
@ -11,7 +11,7 @@ interface InstallationState {
|
|||
* Generate a vault key for OAuth state
|
||||
*/
|
||||
function generateStateVaultKey(state: string): string {
|
||||
return `github_oauth_state_${state}`;
|
||||
return `github_app_state_${state}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -24,7 +24,7 @@ export async function storeInstallationState(
|
|||
): Promise<void> {
|
||||
const key = generateStateVaultKey(state);
|
||||
const expirationTime = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
||||
const description = `GitHub OAuth state expires at ${expirationTime}`;
|
||||
const description = `GitHub App state expires at ${expirationTime}`;
|
||||
|
||||
try {
|
||||
// Check if state already exists (shouldn't happen with random generation)
|
||||
|
@ -45,9 +45,9 @@ export async function storeInstallationState(
|
|||
});
|
||||
}
|
||||
|
||||
console.info(`Stored OAuth state for user ${data.userId}, org ${data.organizationId}`);
|
||||
console.info(`Stored App state for user ${data.userId}, org ${data.organizationId}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to store OAuth state:', error);
|
||||
console.error('Failed to store App state:', error);
|
||||
throw new Error('Failed to store installation state');
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ export async function retrieveInstallationState(state: string): Promise<Installa
|
|||
const secret = await getSecretByName(key);
|
||||
|
||||
if (!secret) {
|
||||
console.warn(`OAuth state not found: ${state}`);
|
||||
console.warn(`State not found: ${state}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -72,7 +72,7 @@ export async function retrieveInstallationState(state: string): Promise<Installa
|
|||
try {
|
||||
data = JSON.parse(secret.secret) as InstallationState;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse OAuth state data:', error);
|
||||
console.error('Failed to parse App state data:', error);
|
||||
await deleteSecret(secret.id);
|
||||
return null;
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ export async function retrieveInstallationState(state: string): Promise<Installa
|
|||
const tenMinutes = 10 * 60 * 1000;
|
||||
|
||||
if (now.getTime() - createdAt.getTime() > tenMinutes) {
|
||||
console.warn(`OAuth state expired: ${state}`);
|
||||
console.warn(`App state expired: ${state}`);
|
||||
// Clean up expired state
|
||||
await deleteSecret(secret.id);
|
||||
return null;
|
||||
|
@ -90,16 +90,16 @@ export async function retrieveInstallationState(state: string): Promise<Installa
|
|||
// Delete state after retrieval (one-time use)
|
||||
await deleteSecret(secret.id);
|
||||
|
||||
console.info(`Retrieved OAuth state for user ${data.userId}, org ${data.organizationId}`);
|
||||
console.info(`Retrieved App state for user ${data.userId}, org ${data.organizationId}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve OAuth state:', error);
|
||||
console.error('Failed to retrieve App state:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired OAuth states
|
||||
* Clean up expired App states
|
||||
* This should be called periodically to clean up old states
|
||||
*/
|
||||
export async function cleanupExpiredStates(): Promise<void> {
|
||||
|
|
|
@ -1,213 +0,0 @@
|
|||
import { createHmac } from 'node:crypto';
|
||||
import { GitHubErrorCode } from '@buster/server-shared/github';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
extractGitHubWebhookSignature,
|
||||
verifyGitHubWebhook,
|
||||
verifyGitHubWebhookSignature,
|
||||
} from './verify-webhook-signature';
|
||||
|
||||
describe('verify-webhook-signature', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up test environment
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
GITHUB_APP_ID: '123456',
|
||||
GITHUB_APP_PRIVATE_KEY_BASE64: Buffer.from(
|
||||
'-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----'
|
||||
).toString('base64'),
|
||||
GITHUB_WEBHOOK_SECRET: 'test-webhook-secret',
|
||||
};
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('verifyGitHubWebhookSignature', () => {
|
||||
it('should return true for valid signature', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
const signature = `sha256=${createHmac('sha256', 'test-webhook-secret').update(payload).digest('hex')}`;
|
||||
|
||||
// Act
|
||||
const result = verifyGitHubWebhookSignature(payload, signature);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid signature', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
const signature = 'sha256=invalid-signature';
|
||||
|
||||
// Act
|
||||
const result = verifyGitHubWebhookSignature(payload, signature);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when signature is missing', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
|
||||
// Act
|
||||
const result = verifyGitHubWebhookSignature(payload, undefined);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when signature format is invalid', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
const signature = 'invalid-format-signature';
|
||||
|
||||
// Act
|
||||
const result = verifyGitHubWebhookSignature(payload, signature);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when signature has wrong algorithm', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
const signature = `sha1=${createHmac('sha1', 'test-webhook-secret').update(payload).digest('hex')}`;
|
||||
|
||||
// Act
|
||||
const result = verifyGitHubWebhookSignature(payload, signature);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle different payload types', () => {
|
||||
// Arrange
|
||||
const payload = 'plain-text-payload';
|
||||
const signature = `sha256=${createHmac('sha256', 'test-webhook-secret').update(payload).digest('hex')}`;
|
||||
|
||||
// Act
|
||||
const result = verifyGitHubWebhookSignature(payload, signature);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractGitHubWebhookSignature', () => {
|
||||
it('should extract signature from headers', () => {
|
||||
// Arrange
|
||||
const headers = {
|
||||
'x-hub-signature-256': 'sha256=test-signature',
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
// Act
|
||||
const signature = extractGitHubWebhookSignature(headers);
|
||||
|
||||
// Assert
|
||||
expect(signature).toBe('sha256=test-signature');
|
||||
});
|
||||
|
||||
it('should handle array header values', () => {
|
||||
// Arrange
|
||||
const headers = {
|
||||
'x-hub-signature-256': ['sha256=first-signature', 'sha256=second-signature'],
|
||||
};
|
||||
|
||||
// Act
|
||||
const signature = extractGitHubWebhookSignature(headers);
|
||||
|
||||
// Assert
|
||||
expect(signature).toBe('sha256=first-signature');
|
||||
});
|
||||
|
||||
it('should return undefined when signature header is missing', () => {
|
||||
// Arrange
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
// Act
|
||||
const signature = extractGitHubWebhookSignature(headers);
|
||||
|
||||
// Assert
|
||||
expect(signature).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle undefined header value', () => {
|
||||
// Arrange
|
||||
const headers = {
|
||||
'x-hub-signature-256': undefined,
|
||||
};
|
||||
|
||||
// Act
|
||||
const signature = extractGitHubWebhookSignature(headers);
|
||||
|
||||
// Assert
|
||||
expect(signature).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyGitHubWebhook', () => {
|
||||
it('should not throw for valid webhook', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
const signature = `sha256=${createHmac('sha256', 'test-webhook-secret').update(payload).digest('hex')}`;
|
||||
const headers = {
|
||||
'x-hub-signature-256': signature,
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
expect(() => verifyGitHubWebhook(payload, headers)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when signature is missing', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
const headers = {};
|
||||
|
||||
// Act & Assert
|
||||
expect(() => verifyGitHubWebhook(payload, headers)).toThrow(
|
||||
'Missing X-Hub-Signature-256 header'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when signature is invalid', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
const headers = {
|
||||
'x-hub-signature-256': 'sha256=invalid-signature',
|
||||
};
|
||||
|
||||
// Act & Assert
|
||||
expect(() => verifyGitHubWebhook(payload, headers)).toThrow('Invalid webhook signature');
|
||||
});
|
||||
|
||||
it('should include error code in thrown error', () => {
|
||||
// Arrange
|
||||
const payload = JSON.stringify({ test: 'data' });
|
||||
const headers = {
|
||||
'x-hub-signature-256': 'sha256=invalid-signature',
|
||||
};
|
||||
|
||||
// Act
|
||||
let error: any;
|
||||
try {
|
||||
verifyGitHubWebhook(payload, headers);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
// Assert
|
||||
expect(error).toBeDefined();
|
||||
expect(error.code).toBe(GitHubErrorCode.WEBHOOK_VERIFICATION_FAILED);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,109 +0,0 @@
|
|||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { getGitHubAppCredentials } from '@buster/github';
|
||||
import { GitHubErrorCode } from '@buster/server-shared/github';
|
||||
|
||||
/**
|
||||
* Verify a GitHub webhook signature
|
||||
*
|
||||
* @param payload The raw request body as a string
|
||||
* @param signature The signature from the X-Hub-Signature-256 header
|
||||
* @returns true if the signature is valid, false otherwise
|
||||
*/
|
||||
export function verifyGitHubWebhookSignature(
|
||||
payload: string,
|
||||
signature: string | undefined
|
||||
): boolean {
|
||||
if (!signature) {
|
||||
console.error('Missing GitHub webhook signature header');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const { webhookSecret } = getGitHubAppCredentials();
|
||||
|
||||
// GitHub sends the signature in the format "sha256=<signature>"
|
||||
if (!signature.startsWith('sha256=')) {
|
||||
console.error('Invalid GitHub webhook signature format');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract the actual signature hash
|
||||
const signatureHash = signature.slice('sha256='.length);
|
||||
|
||||
// Compute the expected signature
|
||||
const expectedSignature = createHmac('sha256', webhookSecret).update(payload).digest('hex');
|
||||
|
||||
// Use constant-time comparison to prevent timing attacks
|
||||
const signatureBuffer = Buffer.from(signatureHash, 'hex');
|
||||
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
|
||||
|
||||
// Both buffers must be the same length for timingSafeEqual
|
||||
if (signatureBuffer.length !== expectedBuffer.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return timingSafeEqual(signatureBuffer, expectedBuffer);
|
||||
} catch (error) {
|
||||
console.error('Error verifying GitHub webhook signature:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the signature from GitHub webhook headers
|
||||
*
|
||||
* @param headers The request headers
|
||||
* @returns The signature string or undefined if not found
|
||||
*/
|
||||
export function extractGitHubWebhookSignature(
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
): string | undefined {
|
||||
// GitHub sends the signature in the X-Hub-Signature-256 header
|
||||
const signature = headers['x-hub-signature-256'];
|
||||
|
||||
if (Array.isArray(signature)) {
|
||||
return signature[0];
|
||||
}
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a GitHub webhook request
|
||||
* Combines signature extraction and verification
|
||||
*
|
||||
* @param payload The raw request body as a string
|
||||
* @param headers The request headers
|
||||
* @throws Error if the signature is invalid
|
||||
*/
|
||||
export function verifyGitHubWebhook(
|
||||
payload: string,
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
): void {
|
||||
const signature = extractGitHubWebhookSignature(headers);
|
||||
|
||||
if (!signature) {
|
||||
throw createGitHubError(
|
||||
GitHubErrorCode.WEBHOOK_VERIFICATION_FAILED,
|
||||
'Missing X-Hub-Signature-256 header'
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = verifyGitHubWebhookSignature(payload, signature);
|
||||
|
||||
if (!isValid) {
|
||||
throw createGitHubError(
|
||||
GitHubErrorCode.WEBHOOK_VERIFICATION_FAILED,
|
||||
'Invalid webhook signature'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub operation error
|
||||
*/
|
||||
function createGitHubError(code: GitHubErrorCode, message: string): Error {
|
||||
const error = new Error(message) as Error & { code: GitHubErrorCode };
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
import {
|
||||
InstallationCallbackSchema,
|
||||
createGitHubApp,
|
||||
verifyGitHubWebhookSignature,
|
||||
} from '@buster/github';
|
||||
import type { WebhookEventName } from '@octokit/webhooks/types';
|
||||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { App } from 'octokit';
|
||||
|
||||
let githubApp: App | undefined;
|
||||
|
||||
function getOrSetApp() {
|
||||
if (!githubApp) {
|
||||
if (!process.env.GITHUB_WEBHOOK_SECRET) {
|
||||
throw new Error('GITHUB_WEBHOOK_SECRET is not set');
|
||||
}
|
||||
githubApp = createGitHubApp();
|
||||
}
|
||||
return githubApp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate GitHub webhook requests
|
||||
* Verifies signature and parses payload
|
||||
*/
|
||||
export function githubWebhookMiddleware(): MiddlewareHandler {
|
||||
return async (c: Context, next) => {
|
||||
console.info('GitHub webhook middleware');
|
||||
try {
|
||||
const githubApp = getOrSetApp();
|
||||
c.set('githubApp', githubApp);
|
||||
console.info('GitHubapp set');
|
||||
|
||||
const id = c.req.header('x-github-delivery');
|
||||
const signature = c.req.header('x-hub-signature-256');
|
||||
const name = c.req.header('x-github-event') as WebhookEventName;
|
||||
const payload = await c.req.text();
|
||||
|
||||
if (!id || !signature || !name) {
|
||||
throw new HTTPException(403, {
|
||||
message: 'Invalid webhook request',
|
||||
});
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
const result = await githubApp.webhooks.verifyAndReceive({
|
||||
id,
|
||||
name,
|
||||
payload,
|
||||
signature,
|
||||
});
|
||||
console.info('GitHub webhook result:', result);
|
||||
|
||||
return c.text('Webhook received & verified', 201);
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Failed to validate GitHub webhook:', error);
|
||||
throw new HTTPException(400, {
|
||||
message: 'Invalid webhook payload',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,169 +0,0 @@
|
|||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { githubWebhookValidator } from './github-webhook-validator';
|
||||
|
||||
// Mock the verify webhook signature from github package
|
||||
vi.mock('@buster/github', () => ({
|
||||
verifyGitHubWebhookSignature: vi.fn(),
|
||||
InstallationCallbackSchema: {
|
||||
parse: vi.fn((value) => value), // Pass through for testing
|
||||
},
|
||||
}));
|
||||
|
||||
import { InstallationCallbackSchema, verifyGitHubWebhookSignature } from '@buster/github';
|
||||
|
||||
describe('githubWebhookValidator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const mockPayload = {
|
||||
action: 'created' as const,
|
||||
installation: {
|
||||
id: 123456,
|
||||
account: {
|
||||
id: 789,
|
||||
login: 'test-org',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createMockContext = (signature?: string, body = mockPayload) => {
|
||||
const app = new Hono();
|
||||
app.use('*', githubWebhookValidator());
|
||||
app.post('/', (c) => {
|
||||
const payload = c.get('githubPayload');
|
||||
return c.json({ payload });
|
||||
});
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (signature) {
|
||||
headers['X-Hub-Signature-256'] = signature;
|
||||
}
|
||||
|
||||
return {
|
||||
app,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
};
|
||||
|
||||
it('should validate a valid webhook request', async () => {
|
||||
const { app, headers, body } = createMockContext('sha256=test-signature');
|
||||
|
||||
// Mock environment variable
|
||||
process.env.GITHUB_WEBHOOK_SECRET = 'test-secret';
|
||||
|
||||
// Mock signature verification to return true
|
||||
vi.mocked(verifyGitHubWebhookSignature).mockReturnValue(true);
|
||||
|
||||
// Mock InstallationCallbackSchema to return the parsed payload
|
||||
vi.mocked(InstallationCallbackSchema.parse).mockReturnValue(mockPayload);
|
||||
|
||||
const res = await app.request('/', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { payload: typeof mockPayload };
|
||||
expect(data.payload).toEqual(mockPayload);
|
||||
expect(verifyGitHubWebhookSignature).toHaveBeenCalledWith(body, 'sha256=test-signature');
|
||||
});
|
||||
|
||||
it('should reject request without signature header', async () => {
|
||||
const { app, body } = createMockContext(); // No signature
|
||||
|
||||
process.env.GITHUB_WEBHOOK_SECRET = 'test-secret';
|
||||
|
||||
const res = await app.request('/', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const text = await res.text();
|
||||
expect(text).toBe('Missing X-Hub-Signature-256 header');
|
||||
});
|
||||
|
||||
it('should reject request with invalid signature', async () => {
|
||||
const { app, headers, body } = createMockContext('sha256=invalid-signature');
|
||||
|
||||
process.env.GITHUB_WEBHOOK_SECRET = 'test-secret';
|
||||
|
||||
// Mock signature verification to return false
|
||||
vi.mocked(verifyGitHubWebhookSignature).mockReturnValue(false);
|
||||
|
||||
const res = await app.request('/', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const text = await res.text();
|
||||
expect(text).toBe('Invalid webhook signature');
|
||||
});
|
||||
|
||||
it('should reject request when webhook secret is not configured', async () => {
|
||||
const { app, headers, body } = createMockContext('sha256=test-signature');
|
||||
|
||||
// Remove environment variable
|
||||
delete process.env.GITHUB_WEBHOOK_SECRET;
|
||||
|
||||
const res = await app.request('/', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const text = await res.text();
|
||||
expect(text).toBe('GITHUB_WEBHOOK_SECRET not configured');
|
||||
});
|
||||
|
||||
it('should reject request with invalid JSON payload', async () => {
|
||||
const { app, headers } = createMockContext('sha256=test-signature');
|
||||
|
||||
process.env.GITHUB_WEBHOOK_SECRET = 'test-secret';
|
||||
vi.mocked(verifyGitHubWebhookSignature).mockReturnValue(true);
|
||||
|
||||
const res = await app.request('/', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: 'invalid json',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const text = await res.text();
|
||||
expect(text).toBe('Invalid webhook payload');
|
||||
});
|
||||
|
||||
it('should reject request with invalid payload schema', async () => {
|
||||
const invalidPayload = {
|
||||
action: 'created',
|
||||
// Missing required installation field - type assertion needed for test
|
||||
} as any;
|
||||
|
||||
const { app, headers, body } = createMockContext('sha256=test-signature', invalidPayload);
|
||||
|
||||
process.env.GITHUB_WEBHOOK_SECRET = 'test-secret';
|
||||
vi.mocked(verifyGitHubWebhookSignature).mockReturnValue(true);
|
||||
|
||||
// Mock schema to throw an error for invalid payload
|
||||
vi.mocked(InstallationCallbackSchema.parse).mockImplementation(() => {
|
||||
throw new Error('Invalid payload');
|
||||
});
|
||||
|
||||
const res = await app.request('/', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const text = await res.text();
|
||||
expect(text).toBe('Invalid webhook payload');
|
||||
});
|
||||
});
|
|
@ -1,65 +0,0 @@
|
|||
import { InstallationCallbackSchema, verifyGitHubWebhookSignature } from '@buster/github';
|
||||
import type { Context, MiddlewareHandler } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
|
||||
/**
|
||||
* Middleware to validate GitHub webhook requests
|
||||
* Verifies signature and parses payload
|
||||
*/
|
||||
export function githubWebhookValidator(): MiddlewareHandler {
|
||||
return async (c: Context, next) => {
|
||||
console.info('GitHub webhook received');
|
||||
|
||||
try {
|
||||
// Get the raw body for signature verification
|
||||
const rawBody = await c.req.text();
|
||||
console.info('Raw body length:', rawBody.length);
|
||||
|
||||
// Get signature header
|
||||
const signature = c.req.header('X-Hub-Signature-256');
|
||||
if (!signature) {
|
||||
throw new HTTPException(401, {
|
||||
message: 'Missing X-Hub-Signature-256 header',
|
||||
});
|
||||
}
|
||||
|
||||
// Get webhook secret from environment
|
||||
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
throw new HTTPException(500, {
|
||||
message: 'GITHUB_WEBHOOK_SECRET not configured',
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
const isValid = verifyGitHubWebhookSignature(rawBody, signature);
|
||||
if (!isValid) {
|
||||
throw new HTTPException(401, {
|
||||
message: 'Invalid webhook signature',
|
||||
});
|
||||
}
|
||||
|
||||
// Parse and validate the payload
|
||||
const parsedBody = JSON.parse(rawBody);
|
||||
const validatedPayload = InstallationCallbackSchema.parse(parsedBody);
|
||||
|
||||
// Set the validated payload in context
|
||||
c.set('githubPayload', validatedPayload);
|
||||
|
||||
console.info(
|
||||
`GitHub webhook validated: action=${validatedPayload.action}, installationId=${validatedPayload.installation.id}`
|
||||
);
|
||||
|
||||
return next();
|
||||
} catch (error) {
|
||||
if (error instanceof HTTPException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error('Failed to validate GitHub webhook:', error);
|
||||
throw new HTTPException(400, {
|
||||
message: 'Invalid webhook payload',
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
|
@ -3,6 +3,7 @@ import type { ApiKeyContext } from '@buster/server-shared';
|
|||
import type { InstallationCallbackRequest } from '@buster/server-shared/github';
|
||||
import type { UserOrganizationRole } from '@buster/server-shared/organization';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import type { App } from 'octokit';
|
||||
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
|
@ -26,7 +27,7 @@ declare module 'hono' {
|
|||
/**
|
||||
* GitHub webhook payload. Set by the githubWebhookValidator middleware.
|
||||
*/
|
||||
readonly githubPayload?: InstallationCallbackRequest;
|
||||
readonly githubApp?: App;
|
||||
/**
|
||||
* API key context for public API endpoints. Set by the createApiKeyAuthMiddleware.
|
||||
*/
|
||||
|
|
|
@ -122,6 +122,9 @@ describe('github-app', () => {
|
|||
expect(App).toHaveBeenCalledWith({
|
||||
appId: 123456,
|
||||
privateKey: '-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----',
|
||||
webhooks: {
|
||||
secret: 'webhook-secret',
|
||||
},
|
||||
});
|
||||
expect(app).toBe(mockApp);
|
||||
});
|
||||
|
|
|
@ -70,12 +70,15 @@ export function getGitHubAppCredentials(): {
|
|||
* Create a configured GitHub App instance
|
||||
*/
|
||||
export function createGitHubApp(): App {
|
||||
const { appId, privateKey } = getGitHubAppCredentials();
|
||||
const { appId, privateKey, webhookSecret } = getGitHubAppCredentials();
|
||||
|
||||
try {
|
||||
return new App({
|
||||
appId,
|
||||
privateKey,
|
||||
webhooks: {
|
||||
secret: webhookSecret,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw createGitHubError(
|
||||
|
|
|
@ -1,248 +0,0 @@
|
|||
import { createHmac } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { skipIfNoGitHubCredentials } from '../../../../apps/server/src/api/v2/github/test-helpers/github-test-setup';
|
||||
import { verifyGitHubWebhookSignature } from './webhook';
|
||||
|
||||
describe('GitHub Webhook Service Integration Tests', () => {
|
||||
describe('Webhook Signature Verification', () => {
|
||||
it('should verify valid webhook signature', () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET!;
|
||||
|
||||
// Sample webhook payload
|
||||
const payload = {
|
||||
action: 'created',
|
||||
installation: {
|
||||
id: 12345,
|
||||
account: {
|
||||
id: 67890,
|
||||
login: 'test-org',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const payloadString = JSON.stringify(payload);
|
||||
|
||||
// Generate valid signature
|
||||
const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`;
|
||||
|
||||
// Should verify successfully
|
||||
const isValid = verifyGitHubWebhookSignature(payloadString, signature);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid webhook signature', () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET!;
|
||||
|
||||
const payload = {
|
||||
action: 'created',
|
||||
installation: {
|
||||
id: 12345,
|
||||
},
|
||||
};
|
||||
|
||||
const payloadString = JSON.stringify(payload);
|
||||
|
||||
// Generate signature with wrong secret
|
||||
const wrongSignature = `sha256=${createHmac('sha256', 'wrong-secret').update(payloadString).digest('hex')}`;
|
||||
|
||||
// Should fail verification
|
||||
const isValid = verifyWebhookSignature(payloadString, wrongSignature, webhookSecret);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject signature with wrong format', () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET!;
|
||||
|
||||
const payload = { test: 'data' };
|
||||
const payloadString = JSON.stringify(payload);
|
||||
|
||||
// Wrong format signatures
|
||||
const wrongFormats = [
|
||||
'invalid-signature',
|
||||
'sha1=12345', // Wrong algorithm
|
||||
'', // Empty
|
||||
'sha256=', // Missing hash
|
||||
];
|
||||
|
||||
for (const signature of wrongFormats) {
|
||||
const isValid = verifyGitHubWebhookSignature(payloadString, signature);
|
||||
expect(isValid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle different payload types', () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET!;
|
||||
|
||||
// Test different GitHub webhook event types
|
||||
const payloads = [
|
||||
{
|
||||
action: 'deleted',
|
||||
installation: { id: 123 },
|
||||
},
|
||||
{
|
||||
action: 'suspend',
|
||||
installation: { id: 456 },
|
||||
},
|
||||
{
|
||||
action: 'unsuspend',
|
||||
installation: { id: 789 },
|
||||
},
|
||||
{
|
||||
action: 'added',
|
||||
installation: { id: 111 },
|
||||
repositories_added: [{ id: 222, name: 'test-repo' }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const payload of payloads) {
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`;
|
||||
|
||||
const isValid = verifyGitHubWebhookSignature(payloadString, signature);
|
||||
expect(isValid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should be consistent with repeated verifications', () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET!;
|
||||
|
||||
const payload = { test: 'consistency' };
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`;
|
||||
|
||||
// Verify multiple times - should always return same result
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const isValid = verifyGitHubWebhookSignature(payloadString, signature);
|
||||
expect(isValid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle large payloads', () => {
|
||||
if (skipIfNoGitHubCredentials()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSecret = process.env.GITHUB_WEBHOOK_SECRET!;
|
||||
|
||||
// Create a large payload similar to real GitHub webhooks
|
||||
const largePayload = {
|
||||
action: 'created',
|
||||
installation: {
|
||||
id: 12345,
|
||||
account: {
|
||||
id: 67890,
|
||||
login: 'test-org',
|
||||
type: 'Organization',
|
||||
site_admin: false,
|
||||
html_url: 'https://github.com/test-org',
|
||||
},
|
||||
repository_selection: 'all',
|
||||
access_tokens_url: 'https://api.github.com/app/installations/12345/access_tokens',
|
||||
repositories_url: 'https://api.github.com/installation/repositories',
|
||||
html_url: 'https://github.com/settings/installations/12345',
|
||||
app_id: 123,
|
||||
target_id: 67890,
|
||||
target_type: 'Organization',
|
||||
permissions: {
|
||||
actions: 'write',
|
||||
administration: 'write',
|
||||
checks: 'write',
|
||||
contents: 'write',
|
||||
deployments: 'write',
|
||||
environments: 'write',
|
||||
issues: 'write',
|
||||
metadata: 'read',
|
||||
packages: 'write',
|
||||
pages: 'write',
|
||||
pull_requests: 'write',
|
||||
repository_hooks: 'write',
|
||||
repository_projects: 'write',
|
||||
security_events: 'write',
|
||||
statuses: 'write',
|
||||
vulnerability_alerts: 'write',
|
||||
},
|
||||
events: [
|
||||
'branch_protection_rule',
|
||||
'check_run',
|
||||
'check_suite',
|
||||
'code_scanning_alert',
|
||||
'commit_comment',
|
||||
'content_reference',
|
||||
'create',
|
||||
'delete',
|
||||
'deployment',
|
||||
'deployment_review',
|
||||
'deployment_status',
|
||||
'deploy_key',
|
||||
'discussion',
|
||||
'discussion_comment',
|
||||
'fork',
|
||||
'gollum',
|
||||
'issues',
|
||||
'issue_comment',
|
||||
'label',
|
||||
'member',
|
||||
'membership',
|
||||
'milestone',
|
||||
'organization',
|
||||
'org_block',
|
||||
'page_build',
|
||||
'project',
|
||||
'project_card',
|
||||
'project_column',
|
||||
'public',
|
||||
'pull_request',
|
||||
'pull_request_review',
|
||||
'pull_request_review_comment',
|
||||
'push',
|
||||
'registry_package',
|
||||
'release',
|
||||
'repository',
|
||||
'repository_dispatch',
|
||||
'secret_scanning_alert',
|
||||
'star',
|
||||
'status',
|
||||
'team',
|
||||
'team_add',
|
||||
'watch',
|
||||
'workflow_dispatch',
|
||||
'workflow_run',
|
||||
],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
sender: {
|
||||
login: 'test-user',
|
||||
id: 11111,
|
||||
type: 'User',
|
||||
},
|
||||
};
|
||||
|
||||
const payloadString = JSON.stringify(largePayload);
|
||||
const signature = `sha256=${createHmac('sha256', webhookSecret).update(payloadString).digest('hex')}`;
|
||||
|
||||
const isValid = verifyGitHubWebhookSignature(payloadString, signature);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -285,6 +285,9 @@ importers:
|
|||
'@hono/zod-validator':
|
||||
specifier: ^0.7.3
|
||||
version: 0.7.3(hono@4.9.7)(zod@3.25.76)
|
||||
'@octokit/webhooks':
|
||||
specifier: ^14.1.3
|
||||
version: 14.1.3
|
||||
'@supabase/supabase-js':
|
||||
specifier: 'catalog:'
|
||||
version: 2.57.4
|
||||
|
@ -309,6 +312,9 @@ importers:
|
|||
lodash-es:
|
||||
specifier: 'catalog:'
|
||||
version: 4.17.21
|
||||
octokit:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3
|
||||
pino:
|
||||
specifier: ^9.10.0
|
||||
version: 9.10.0
|
||||
|
@ -19557,7 +19563,7 @@ snapshots:
|
|||
magic-string: 0.30.17
|
||||
sirv: 3.0.1
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||
ws: 8.18.3
|
||||
optionalDependencies:
|
||||
playwright: 1.55.1
|
||||
|
@ -19582,7 +19588,7 @@ snapshots:
|
|||
std-env: 3.9.0
|
||||
test-exclude: 7.0.1
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||
optionalDependencies:
|
||||
'@vitest/browser': 3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.4(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)
|
||||
transitivePeerDependencies:
|
||||
|
|
Loading…
Reference in New Issue