Files
spoon/packages/backend/tests/unit/github-webhook-route.test.ts

206 lines
6.5 KiB
TypeScript

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