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, 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, 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, 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); }); });