feat(webhooks): add signature-verified GitHub webhook route
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
|
||||
import { convexTest } from 'convex-test';
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||||
|
||||
import type { Id } from '../../convex/_generated/dataModel.js';
|
||||
import schema from '../../convex/schema';
|
||||
|
||||
const modules = import.meta.glob('../../convex/**/*.*s');
|
||||
|
||||
const SECRET = 'testsecret';
|
||||
|
||||
/** Sign a body the same way the Web Crypto verifier does (HMAC-SHA256 hex). */
|
||||
const sign = (body: string): string =>
|
||||
`sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`;
|
||||
|
||||
const post = (
|
||||
t: ReturnType<typeof convexTest>,
|
||||
event: string,
|
||||
body: string,
|
||||
signature: string,
|
||||
) =>
|
||||
t.fetch('/webhooks/github', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-github-event': event,
|
||||
'x-hub-signature-256': signature,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const seedConnection = async (
|
||||
t: ReturnType<typeof convexTest>,
|
||||
installationId: string,
|
||||
) =>
|
||||
await t.run(async (ctx) => {
|
||||
const now = Date.now();
|
||||
const userId = await ctx.db.insert('users', { email: 'a@b.c', name: 'A' });
|
||||
return await ctx.db.insert('gitConnections', {
|
||||
userId,
|
||||
provider: 'github',
|
||||
displayName: `GitHub installation ${installationId}`,
|
||||
installationId,
|
||||
status: 'active',
|
||||
connectedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
const seedGithubSpoon = async (
|
||||
t: ReturnType<typeof convexTest>,
|
||||
forkOwner: string,
|
||||
forkRepo: string,
|
||||
) =>
|
||||
(await t.run(async (ctx) => {
|
||||
const now = Date.now();
|
||||
const ownerId = await ctx.db.insert('users', {
|
||||
email: 'owner@b.c',
|
||||
name: 'Owner',
|
||||
});
|
||||
return await ctx.db.insert('spoons', {
|
||||
ownerId,
|
||||
name: `${forkOwner}/${forkRepo}`,
|
||||
provider: 'github',
|
||||
upstreamOwner: 'up',
|
||||
upstreamRepo: 'x',
|
||||
upstreamDefaultBranch: 'main',
|
||||
upstreamUrl: 'https://github.com/up/x',
|
||||
forkOwner,
|
||||
forkRepo,
|
||||
forkUrl: `https://github.com/${forkOwner}/${forkRepo}`,
|
||||
visibility: 'public',
|
||||
maintenanceMode: 'watch',
|
||||
syncCadence: 'daily',
|
||||
productionRefStrategy: 'default_branch',
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
})) as Id<'spoons'>;
|
||||
|
||||
describe('POST /webhooks/github', () => {
|
||||
let previousSecret: string | undefined;
|
||||
|
||||
beforeAll(() => {
|
||||
previousSecret = process.env.GITHUB_APP_WEBHOOK_SECRET;
|
||||
process.env.GITHUB_APP_WEBHOOK_SECRET = SECRET;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (previousSecret === undefined) {
|
||||
delete process.env.GITHUB_APP_WEBHOOK_SECRET;
|
||||
} else {
|
||||
process.env.GITHUB_APP_WEBHOOK_SECRET = previousSecret;
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects a bad signature with 401 and does not process the payload', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const connectionId = await seedConnection(t, '99');
|
||||
const body = JSON.stringify({ action: 'deleted', installation: { id: 99 } });
|
||||
|
||||
const res = await post(t, 'installation', body, 'sha256=deadbeef');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
// The unverified payload must NOT have flipped the connection status.
|
||||
const connection = await t.run(
|
||||
async (ctx) => await ctx.db.get(connectionId),
|
||||
);
|
||||
expect(connection?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('returns 503 when the webhook secret is not configured', async () => {
|
||||
const saved = process.env.GITHUB_APP_WEBHOOK_SECRET;
|
||||
delete process.env.GITHUB_APP_WEBHOOK_SECRET;
|
||||
try {
|
||||
const t = convexTest(schema, modules);
|
||||
const body = JSON.stringify({ action: 'created', installation: { id: 1 } });
|
||||
const res = await post(t, 'installation', body, sign(body));
|
||||
expect(res.status).toBe(503);
|
||||
} finally {
|
||||
process.env.GITHUB_APP_WEBHOOK_SECRET = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('flips a connection to revoked on installation "deleted"', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const connectionId = await seedConnection(t, '99');
|
||||
const body = JSON.stringify({ action: 'deleted', installation: { id: 99 } });
|
||||
|
||||
const res = await post(t, 'installation', body, sign(body));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const connection = await t.run(
|
||||
async (ctx) => await ctx.db.get(connectionId),
|
||||
);
|
||||
expect(connection?.status).toBe('revoked');
|
||||
});
|
||||
|
||||
test('flips a connection to active on installation "unsuspend"', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const connectionId = await t.run(async (ctx) => {
|
||||
const now = Date.now();
|
||||
const userId = await ctx.db.insert('users', { email: 'a@b.c', name: 'A' });
|
||||
return await ctx.db.insert('gitConnections', {
|
||||
userId,
|
||||
provider: 'github',
|
||||
displayName: 'GitHub installation 77',
|
||||
installationId: '77',
|
||||
status: 'revoked',
|
||||
connectedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
});
|
||||
const body = JSON.stringify({ action: 'unsuspend', installation: { id: 77 } });
|
||||
|
||||
const res = await post(t, 'installation', body, sign(body));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const connection = await t.run(
|
||||
async (ctx) => await ctx.db.get(connectionId),
|
||||
);
|
||||
expect(connection?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('accepts a valid push and returns 200', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
// Seed a spoon whose fork repo backs the pushed repository.
|
||||
await seedGithubSpoon(t, 'me', 'x-fork');
|
||||
const body = JSON.stringify({
|
||||
repository: { full_name: 'me/x-fork' },
|
||||
});
|
||||
|
||||
// NOTE (convex-test limitation): the httpAction schedules
|
||||
// internal.githubSync.refreshSpoonById, which is a `'use node'` action and
|
||||
// therefore cannot execute in the convex-test VM. We assert the route
|
||||
// verified the signature, matched via findForRepo, and returned 200 without
|
||||
// throwing; we deliberately do NOT call finishInProgressScheduledFunctions
|
||||
// (the node action would fail to run under the test runtime).
|
||||
const res = await post(t, 'push', body, sign(body));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('returns 200 for a push to an unknown repo (no matches)', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const body = JSON.stringify({
|
||||
repository: { full_name: 'nobody/nothing' },
|
||||
});
|
||||
|
||||
const res = await post(t, 'push', body, sign(body));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('returns 200 for a verified but unhandled event type', async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
const body = JSON.stringify({ zen: 'Keep it logically awesome.' });
|
||||
|
||||
const res = await post(t, 'ping', body, sign(body));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user