From 85625bff5efeac60bb1b9933e7d6e0d945fa90a2 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sun, 21 Jul 2024 13:39:36 +0200 Subject: [PATCH 1/3] fix: export createNodeHandler --- src/index.ts | 1 + src/middleware/node/handler.ts | 115 +++++++ src/middleware/node/middleware.ts | 91 +----- test/integration/node-handler.test.ts | 441 ++++++++++++++++++++++++++ 4 files changed, 559 insertions(+), 89 deletions(-) create mode 100644 src/middleware/node/handler.ts create mode 100644 test/integration/node-handler.test.ts diff --git a/src/index.ts b/src/index.ts index b632cf7d..18a05c8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,7 @@ import type { } from "./types.js"; export { createNodeMiddleware } from "./middleware/node/index.js"; +export { createNodeHandler } from "./middleware/node/handler.js"; export { emitterEventNames } from "./generated/webhook-names.js"; // U holds the return value of `transform` function in Options diff --git a/src/middleware/node/handler.ts b/src/middleware/node/handler.ts new file mode 100644 index 00000000..79d7d852 --- /dev/null +++ b/src/middleware/node/handler.ts @@ -0,0 +1,115 @@ +// remove type imports from http for Deno compatibility +// see https://github.com/octokit/octokit.js/issues/2075#issuecomment-817361886 +// import { IncomingMessage, ServerResponse } from "http"; +type IncomingMessage = any; +type ServerResponse = any; + +import type { WebhookEventName } from "../../generated/webhook-identifiers.js"; + +import type { Webhooks } from "../../index.js"; +import type { WebhookEventHandlerError } from "../../types.js"; +import type { MiddlewareOptions } from "./types.js"; +import { getMissingHeaders } from "./get-missing-headers.js"; +import { getPayload } from "./get-payload.js"; + +type Handler = ( + request: IncomingMessage, + response: ServerResponse, +) => Promise; +export function createNodeHandler( + webhooks: Webhooks, + options?: Pick, +): Handler { + const logger = options?.log || console; + return async function handler( + request: IncomingMessage, + response: ServerResponse, + ): Promise { + // Check if the Content-Type header is `application/json` and allow for charset to be specified in it + // Otherwise, return a 415 Unsupported Media Type error + // See https://github.com/octokit/webhooks.js/issues/158 + if ( + !request.headers["content-type"] || + !request.headers["content-type"].startsWith("application/json") + ) { + response.writeHead(415, { + "content-type": "application/json", + accept: "application/json", + }); + response.end( + JSON.stringify({ + error: `Unsupported "Content-Type" header value. Must be "application/json"`, + }), + ); + return true; + } + + const missingHeaders = getMissingHeaders(request).join(", "); + + if (missingHeaders) { + response.writeHead(400, { + "content-type": "application/json", + }); + response.end( + JSON.stringify({ + error: `Required headers missing: ${missingHeaders}`, + }), + ); + + return true; + } + + const eventName = request.headers["x-github-event"] as WebhookEventName; + const signatureSHA256 = request.headers["x-hub-signature-256"] as string; + const id = request.headers["x-github-delivery"] as string; + + logger.debug(`${eventName} event received (id: ${id})`); + + // GitHub will abort the request if it does not receive a response within 10s + // See https://github.com/octokit/webhooks.js/issues/185 + let didTimeout = false; + const timeout = setTimeout(() => { + didTimeout = true; + response.statusCode = 202; + response.end("still processing\n"); + }, 9000).unref(); + + try { + const payload = await getPayload(request); + + await webhooks.verifyAndReceive({ + id: id, + name: eventName as any, + payload, + signature: signatureSHA256, + }); + clearTimeout(timeout); + + if (didTimeout) return true; + + response.end("ok\n"); + return true; + } catch (error) { + clearTimeout(timeout); + + if (didTimeout) return true; + + const err = Array.from((error as WebhookEventHandlerError).errors)[0]; + const errorMessage = err.message + ? `${err.name}: ${err.message}` + : "Error: An Unspecified error occurred"; + response.statusCode = + typeof err.status !== "undefined" ? err.status : 500; + + logger.error(error); + + response.end( + JSON.stringify({ + error: errorMessage, + }), + ); + + return true; + } + }; +} diff --git a/src/middleware/node/middleware.ts b/src/middleware/node/middleware.ts index 74991dcf..61f5e8a0 100644 --- a/src/middleware/node/middleware.ts +++ b/src/middleware/node/middleware.ts @@ -2,13 +2,10 @@ // see https://github.com/octokit/octokit.js/issues/2075#issuecomment-817361886 import type { IncomingMessage, ServerResponse } from "node:http"; -import type { WebhookEventName } from "../../generated/webhook-identifiers.js"; +import { createNodeHandler } from "./handler.js"; import type { Webhooks } from "../../index.js"; -import type { WebhookEventHandlerError } from "../../types.js"; import type { MiddlewareOptions } from "./types.js"; -import { getMissingHeaders } from "./get-missing-headers.js"; -import { getPayload } from "./get-payload.js"; import { onUnhandledRequestDefault } from "./on-unhandled-request-default.js"; export async function middleware( @@ -41,89 +38,5 @@ export async function middleware( return true; } - // Check if the Content-Type header is `application/json` and allow for charset to be specified in it - // Otherwise, return a 415 Unsupported Media Type error - // See https://github.com/octokit/webhooks.js/issues/158 - if ( - !request.headers["content-type"] || - !request.headers["content-type"].startsWith("application/json") - ) { - response.writeHead(415, { - "content-type": "application/json", - accept: "application/json", - }); - response.end( - JSON.stringify({ - error: `Unsupported "Content-Type" header value. Must be "application/json"`, - }), - ); - return true; - } - - const missingHeaders = getMissingHeaders(request).join(", "); - - if (missingHeaders) { - response.writeHead(400, { - "content-type": "application/json", - }); - response.end( - JSON.stringify({ - error: `Required headers missing: ${missingHeaders}`, - }), - ); - - return true; - } - - const eventName = request.headers["x-github-event"] as WebhookEventName; - const signatureSHA256 = request.headers["x-hub-signature-256"] as string; - const id = request.headers["x-github-delivery"] as string; - - options.log.debug(`${eventName} event received (id: ${id})`); - - // GitHub will abort the request if it does not receive a response within 10s - // See https://github.com/octokit/webhooks.js/issues/185 - let didTimeout = false; - const timeout = setTimeout(() => { - didTimeout = true; - response.statusCode = 202; - response.end("still processing\n"); - }, 9000).unref(); - - try { - const payload = await getPayload(request); - - await webhooks.verifyAndReceive({ - id: id, - name: eventName as any, - payload, - signature: signatureSHA256, - }); - clearTimeout(timeout); - - if (didTimeout) return true; - - response.end("ok\n"); - return true; - } catch (error) { - clearTimeout(timeout); - - if (didTimeout) return true; - - const err = Array.from((error as WebhookEventHandlerError).errors)[0]; - const errorMessage = err.message - ? `${err.name}: ${err.message}` - : "Error: An Unspecified error occurred"; - response.statusCode = typeof err.status !== "undefined" ? err.status : 500; - - options.log.error(error); - - response.end( - JSON.stringify({ - error: errorMessage, - }), - ); - - return true; - } + return createNodeHandler(webhooks, options)(request, response); } diff --git a/test/integration/node-handler.test.ts b/test/integration/node-handler.test.ts new file mode 100644 index 00000000..8d36b333 --- /dev/null +++ b/test/integration/node-handler.test.ts @@ -0,0 +1,441 @@ +import { createServer } from "node:http"; +import { readFileSync } from "node:fs"; +import { describe, beforeAll, afterEach, test, expect, vi } from "vitest"; +import type { AddressInfo } from "node:net"; + +import { sign } from "@octokit/webhooks-methods"; + +import { createNodeHandler, Webhooks } from "../../src/index.ts"; + +const pushEventPayload = readFileSync( + "test/fixtures/push-payload.json", + "utf-8", +); +let signatureSha256: string; + +describe("createNodeHandler(webhooks)", () => { + beforeAll(async () => { + signatureSha256 = await sign("mySecret", pushEventPayload); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("README example", async () => { + expect.assertions(3); + + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + webhooks.on("push", (event) => { + expect(event.id).toBe("123e4567-e89b-12d3-a456-426655440000"); + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: pushEventPayload, + }, + ); + + expect(response.status).toEqual(200); + await expect(response.text()).resolves.toBe("ok\n"); + + server.close(); + }); + + test("request.body already parsed (e.g. Lambda)", async () => { + expect.assertions(3); + + const webhooks = new Webhooks({ + secret: "mySecret", + }); + const dataChunks: any[] = []; + const middleware = createNodeHandler(webhooks); + + const server = createServer((req, res) => { + req.once("data", (chunk) => dataChunks.push(chunk)); + req.once("end", () => { + // @ts-expect-error - TS2339: Property 'body' does not exist on type 'IncomingMessage'. + req.body = Buffer.concat(dataChunks).toString(); + middleware(req, res); + }); + }).listen(); + + webhooks.on("push", (event) => { + expect(event.id).toBe("123e4567-e89b-12d3-a456-426655440000"); + }); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: pushEventPayload, + }, + ); + + expect(response.status).toEqual(200); + expect(await response.text()).toEqual("ok\n"); + + server.close(); + }); + + test("Handles invalid Content-Type", async () => { + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "text/plain", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: pushEventPayload, + }, + ); + + await expect(response.text()).resolves.toBe( + '{"error":"Unsupported \\"Content-Type\\" header value. Must be \\"application/json\\""}', + ); + expect(response.status).toEqual(415); + + server.close(); + }); + + test("Handles Missing Content-Type", async () => { + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: pushEventPayload, + }, + ); + + await expect(response.text()).resolves.toBe( + '{"error":"Unsupported \\"Content-Type\\" header value. Must be \\"application/json\\""}', + ); + expect(response.status).toEqual(415); + + server.close(); + }); + + test("Handles invalid JSON", async () => { + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + + const payload = '{"name":"invalid"'; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": await sign("mySecret", payload), + }, + body: payload, + }, + ); + + expect(response.status).toEqual(400); + + await expect(response.text()).resolves.toMatch(/SyntaxError: Invalid JSON/); + + server.close(); + }); + + test("Handles non POST request", async () => { + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: "invalid", + }, + ); + + expect(response.status).toEqual(400); + + await expect(response.text()).resolves.toMatch( + /{"error":"Error: \[@octokit\/webhooks\] signature does not match event payload and secret"}/, + ); + + server.close(); + }); + + test("Handles missing headers", async () => { + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + // "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: "invalid", + }, + ); + + expect(response.status).toEqual(400); + + await expect(response.text()).resolves.toMatch( + /Required headers missing: x-github-event/, + ); + + server.close(); + }); + + test("Handles non-request errors", async () => { + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + webhooks.on("push", () => { + throw new Error("boom"); + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: pushEventPayload, + }, + ); + + await expect(response.text()).resolves.toMatch(/Error: boom/); + expect(response.status).toEqual(500); + + server.close(); + }); + + test("Handles empty errors", async () => { + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + webhooks.on("push", () => { + throw new Error(); + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: pushEventPayload, + }, + ); + + await expect(response.text()).resolves.toMatch( + /Error: An Unspecified error occurred/, + ); + expect(response.status).toEqual(500); + + server.close(); + }); + + test("Handles timeout", async () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }); + + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + webhooks.on("push", async () => { + vi.advanceTimersByTime(10000); + server.close(); + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: pushEventPayload, + }, + ); + + await expect(response.text()).resolves.toMatch(/still processing/); + expect(response.status).toEqual(202); + }); + + test("Handles timeout with error", async () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }); + + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + webhooks.on("push", async () => { + vi.advanceTimersByTime(10000); + server.close(); + throw new Error("oops"); + }); + + const server = createServer(createNodeHandler(webhooks)).listen(); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "123e4567-e89b-12d3-a456-426655440000", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": signatureSha256, + }, + body: pushEventPayload, + }, + ); + + await expect(response.text()).resolves.toMatch(/still processing/); + expect(response.status).toEqual(202); + }); + + test("Handles invalid signature", async () => { + expect.assertions(3); + + const webhooks = new Webhooks({ + secret: "mySecret", + }); + + webhooks.onError((error) => { + expect(error.message).toContain( + "signature does not match event payload and secret", + ); + }); + + const log = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const middleware = createNodeHandler(webhooks, { log }); + const server = createServer(middleware).listen(); + + const { port } = server.address() as AddressInfo; + + const response = await fetch( + `http://localhost:${port}/api/github/webhooks`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-GitHub-Delivery": "1", + "X-GitHub-Event": "push", + "X-Hub-Signature-256": "", + }, + body: pushEventPayload, + }, + ); + + expect(response.status).toEqual(400); + await expect(response.text()).resolves.toBe( + '{"error":"Error: [@octokit/webhooks] signature does not match event payload and secret"}', + ); + + server.close(); + }); +}); From c9df94fc449d4544f6be3a7457c03aa04f4c9220 Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sun, 21 Jul 2024 13:58:14 +0200 Subject: [PATCH 2/3] send error with right content-type --- src/middleware/node/handler.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/middleware/node/handler.ts b/src/middleware/node/handler.ts index 79d7d852..7fd5caf8 100644 --- a/src/middleware/node/handler.ts +++ b/src/middleware/node/handler.ts @@ -98,11 +98,16 @@ export function createNodeHandler( const errorMessage = err.message ? `${err.name}: ${err.message}` : "Error: An Unspecified error occurred"; - response.statusCode = + + const statusCode = typeof err.status !== "undefined" ? err.status : 500; logger.error(error); + response.writeHead(statusCode, { + "content-type": "application/json" + }); + response.end( JSON.stringify({ error: errorMessage, From 5355c3525cf1c9160da1c45eae475d9549e8cc1a Mon Sep 17 00:00:00 2001 From: uzlopak Date: Sun, 21 Jul 2024 14:11:25 +0200 Subject: [PATCH 3/3] improve performance of headers check --- src/middleware/node/get-missing-headers.ts | 15 -------- src/middleware/node/handler.ts | 22 +++-------- src/middleware/node/validate-headers.ts | 45 ++++++++++++++++++++++ test/integration/node-handler.test.ts | 2 +- test/integration/node-middleware.test.ts | 2 +- 5 files changed, 52 insertions(+), 34 deletions(-) delete mode 100644 src/middleware/node/get-missing-headers.ts create mode 100644 src/middleware/node/validate-headers.ts diff --git a/src/middleware/node/get-missing-headers.ts b/src/middleware/node/get-missing-headers.ts deleted file mode 100644 index 8e4b0c32..00000000 --- a/src/middleware/node/get-missing-headers.ts +++ /dev/null @@ -1,15 +0,0 @@ -// remove type imports from http for Deno compatibility -// see https://github.com/octokit/octokit.js/issues/24#issuecomment-817361886 -// import type { IncomingMessage } from "node:http"; -type IncomingMessage = any; - -const WEBHOOK_HEADERS = [ - "x-github-event", - "x-hub-signature-256", - "x-github-delivery", -]; - -// https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#delivery-headers -export function getMissingHeaders(request: IncomingMessage) { - return WEBHOOK_HEADERS.filter((header) => !(header in request.headers)); -} diff --git a/src/middleware/node/handler.ts b/src/middleware/node/handler.ts index 7fd5caf8..ecc58c52 100644 --- a/src/middleware/node/handler.ts +++ b/src/middleware/node/handler.ts @@ -9,7 +9,7 @@ import type { WebhookEventName } from "../../generated/webhook-identifiers.js"; import type { Webhooks } from "../../index.js"; import type { WebhookEventHandlerError } from "../../types.js"; import type { MiddlewareOptions } from "./types.js"; -import { getMissingHeaders } from "./get-missing-headers.js"; +import { validateHeaders } from "./validate-headers.js"; import { getPayload } from "./get-payload.js"; type Handler = ( @@ -44,18 +44,7 @@ export function createNodeHandler( return true; } - const missingHeaders = getMissingHeaders(request).join(", "); - - if (missingHeaders) { - response.writeHead(400, { - "content-type": "application/json", - }); - response.end( - JSON.stringify({ - error: `Required headers missing: ${missingHeaders}`, - }), - ); - + if (validateHeaders(request, response)) { return true; } @@ -98,14 +87,13 @@ export function createNodeHandler( const errorMessage = err.message ? `${err.name}: ${err.message}` : "Error: An Unspecified error occurred"; - - const statusCode = - typeof err.status !== "undefined" ? err.status : 500; + + const statusCode = typeof err.status !== "undefined" ? err.status : 500; logger.error(error); response.writeHead(statusCode, { - "content-type": "application/json" + "content-type": "application/json", }); response.end( diff --git a/src/middleware/node/validate-headers.ts b/src/middleware/node/validate-headers.ts new file mode 100644 index 00000000..819c016c --- /dev/null +++ b/src/middleware/node/validate-headers.ts @@ -0,0 +1,45 @@ +// remove type imports from http for Deno compatibility +// see https://github.com/octokit/octokit.js/issues/24#issuecomment-817361886 +// import type { IncomingMessage } from "node:http"; +type IncomingMessage = any; +type OutgoingMessage = any; + +const webhookHeadersMissingMessage = { + "x-github-event": JSON.stringify({ + error: `Required header missing: x-github-event`, + }), + "x-hub-signature-256": JSON.stringify({ + error: `Required header missing: x-github-signature-256`, + }), + "x-github-delivery": JSON.stringify({ + error: `Required header missing: x-github-delivery`, + }), +}; + +function sendMissindHeaderResponse( + response: OutgoingMessage, + missingHeader: keyof typeof webhookHeadersMissingMessage, +) { + response.writeHead(400, { + "content-type": "application/json", + }); + response.end(webhookHeadersMissingMessage[missingHeader]); +} + +// https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#delivery-headers +export function validateHeaders( + request: IncomingMessage, + response: OutgoingMessage, +) { + if ("x-github-event" in request.headers === false) { + sendMissindHeaderResponse(response, "x-github-event"); + } else if ("x-hub-signature-256" in request.headers === false) { + sendMissindHeaderResponse(response, "x-hub-signature-256"); + } else if ("x-github-delivery" in request.headers === false) { + sendMissindHeaderResponse(response, "x-github-delivery"); + } else { + return false; + } + + return true; +} diff --git a/test/integration/node-handler.test.ts b/test/integration/node-handler.test.ts index 8d36b333..020da548 100644 --- a/test/integration/node-handler.test.ts +++ b/test/integration/node-handler.test.ts @@ -250,7 +250,7 @@ describe("createNodeHandler(webhooks)", () => { expect(response.status).toEqual(400); await expect(response.text()).resolves.toMatch( - /Required headers missing: x-github-event/, + /Required header missing: x-github-event/, ); server.close(); diff --git a/test/integration/node-middleware.test.ts b/test/integration/node-middleware.test.ts index 3014241e..8c6eb369 100644 --- a/test/integration/node-middleware.test.ts +++ b/test/integration/node-middleware.test.ts @@ -287,7 +287,7 @@ describe("createNodeMiddleware(webhooks)", () => { expect(response.status).toEqual(400); await expect(response.text()).resolves.toMatch( - /Required headers missing: x-github-event/, + /Required header missing: x-github-event/, ); server.close();