206 lines
6.5 KiB
TypeScript
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);
|
|
});
|
|
});
|