Files
calendar/apps/api/v2/src/vercel-webhook.controller.e2e-spec.ts
T
MorganGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
b02470e780 feat: add webhook endpoint for Vercel deployment promoted event to promote trigger.dev (#27340)
* feat: add webhook endpoint for Vercel deployment promoted event to promote trigger.dev

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: rename TRIGGER_API_KEY to TRIGGER_SECRET_KEY

Co-Authored-By: morgan@cal.com <morgan@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-29 16:24:42 +09:00

158 lines
5.6 KiB
TypeScript

import * as crypto from "node:crypto";
import type { INestApplication } from "@nestjs/common";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import request from "supertest";
import { AppModule } from "@/app.module";
import { bootstrap } from "@/bootstrap";
import { PrismaModule } from "@/modules/prisma/prisma.module";
describe("Vercel Webhook Controller (e2e)", () => {
let app: INestApplication;
const WEBHOOK_SECRET = "test-webhook-secret";
const TRIGGER_VERSION = "test-version-123";
const TRIGGER_SECRET_KEY = "test-trigger-secret-key";
beforeAll(async () => {
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
process.env.VERCEL_PROMOTE_WEBHOOK_SECRET = WEBHOOK_SECRET;
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
process.env.TRIGGER_VERSION = TRIGGER_VERSION;
// biome-ignore lint/style/noProcessEnv: Test setup requires setting environment variables
process.env.TRIGGER_SECRET_KEY = TRIGGER_SECRET_KEY;
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule],
}).compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
delete process.env.VERCEL_PROMOTE_WEBHOOK_SECRET;
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
delete process.env.TRIGGER_VERSION;
// biome-ignore lint/style/noProcessEnv: Test cleanup requires deleting environment variables
delete process.env.TRIGGER_SECRET_KEY;
await app.close();
});
function generateSignature(payload: object): string {
// Compute signature from the JSON stringified payload
// This matches how the guard will re-stringify the parsed object
return crypto.createHmac("sha1", WEBHOOK_SECRET).update(JSON.stringify(payload)).digest("hex");
}
describe("POST /webhooks/vercel/deployment-promoted", () => {
it("should reject requests without x-vercel-signature header", async () => {
await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.send({ type: "deployment.promoted" })
.expect(401);
});
it("should reject requests with invalid signature", async () => {
await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.set("x-vercel-signature", "0000000000000000000000000000000000000000")
.send({ type: "deployment.promoted" })
.expect(401);
});
it("should ignore non-deployment.promoted events with valid signature", async () => {
const payload = { type: "deployment.created" };
const signature = generateSignature(payload);
const response = await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.set("x-vercel-signature", signature)
.send(payload)
.expect(200);
expect(response.body.status).toBe("ignored");
expect(response.body.version).toBe("");
});
it("should process deployment.promoted events with valid signature", async () => {
const payload = { type: "deployment.promoted" };
const signature = generateSignature(payload);
const originalFetch = global.fetch;
let fetchCalled = false;
let fetchUrl = "";
let fetchOptions: RequestInit | undefined;
global.fetch = jest.fn().mockImplementation((url: string, options?: RequestInit) => {
fetchCalled = true;
fetchUrl = url;
fetchOptions = options;
return Promise.resolve({
ok: true,
text: () => Promise.resolve(""),
});
});
try {
const response = await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.set("x-vercel-signature", signature)
.send(payload)
.expect(200);
expect(response.body.status).toBe("success");
expect(response.body.version).toBe(TRIGGER_VERSION);
expect(fetchCalled).toBe(true);
expect(fetchUrl).toBe(`https://api.trigger.dev/api/v1/deployments/${TRIGGER_VERSION}/promote`);
expect(fetchOptions?.method).toBe("POST");
expect(fetchOptions?.headers).toEqual(
expect.objectContaining({
Authorization: `Bearer ${TRIGGER_SECRET_KEY}`,
})
);
} finally {
global.fetch = originalFetch;
}
});
it("should retry on trigger.dev API failure", async () => {
const payload = { type: "deployment.promoted" };
const signature = generateSignature(payload);
const originalFetch = global.fetch;
let fetchCallCount = 0;
global.fetch = jest.fn().mockImplementation(() => {
fetchCallCount++;
if (fetchCallCount < 3) {
return Promise.resolve({
ok: false,
status: 500,
text: () => Promise.resolve("Internal Server Error"),
});
}
return Promise.resolve({
ok: true,
text: () => Promise.resolve(""),
});
});
try {
const response = await request(app.getHttpServer())
.post("/v2/webhooks/vercel/deployment-promoted")
.set("x-vercel-signature", signature)
.send(payload)
.expect(200);
expect(response.body.status).toBe("success");
expect(fetchCallCount).toBe(3);
} finally {
global.fetch = originalFetch;
}
}, 30000);
});
});