Skip to content

Commit 840c0d1

Browse files
authored
test(adapters): migrate Vercel Connect webhook tests to connectWebhookContract (#673)
Adopts the shared `connectWebhookContract` from `@chat-adapter/tests` in the Slack, GitHub, and Linear suites, replacing the bespoke `webhookVerifier` blocks (verifier pass → 200, throw/falsy → 401, invoked with request + raw body, precedence over a native secret). Adapter-specific Connect tests are kept (token resolvers, GitHub bot-id capture, type-level mutual exclusivity, 400-on-invalid-JSON, Linear identity/`withInstallation`). Each descriptor keeps `initialize()` network-free (GitHub `botUserId`, Slack `_botUserId` to skip `auth.test`, Linear stubs `resolveConnectIdentity`). Twilio is intentionally not included — it has a single generic `webhookVerifier` usage with no 200/401 gating suite to migrate. First of three stacked test-generalization PRs. Tests-only, no changeset. Signed-off-by: Ben Sabic <bensabic@users.noreply.github.com> Co-authored-by: Ben Sabic <bensabic@users.noreply.github.com>
1 parent 03d2742 commit 840c0d1

7 files changed

Lines changed: 71 additions & 193 deletions

File tree

packages/adapter-github/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"chat": "workspace:*"
3434
},
3535
"devDependencies": {
36+
"@chat-adapter/tests": "workspace:*",
3637
"@types/node": "^25.3.2",
3738
"tsup": "^8.3.5",
3839
"typescript": "^5.7.2",

packages/adapter-github/src/index.test.ts

Lines changed: 17 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AsyncLocalStorage } from "node:async_hooks";
22
import { createHmac } from "node:crypto";
3+
import { connectWebhookContract } from "@chat-adapter/tests";
34
import { beforeEach, describe, expect, it, vi } from "vitest";
45
import { createGitHubAdapter, GitHubAdapter } from "./index";
56
import type {
@@ -2572,6 +2573,22 @@ describe("GitHubAdapter", () => {
25722573
});
25732574
});
25742575

2576+
connectWebhookContract({
2577+
name: "github",
2578+
createAdapter: ({ webhookVerifier }) =>
2579+
new GitHubAdapter({
2580+
installationToken: "t",
2581+
webhookVerifier,
2582+
botUserId: 999,
2583+
logger: mockLogger,
2584+
}),
2585+
makeWebhookRequest: () =>
2586+
makeWebhookRequest(
2587+
JSON.stringify(makeIssueCommentPayload()),
2588+
"issue_comment"
2589+
),
2590+
});
2591+
25752592
describe("GitHubAdapter - Vercel Connect mode", () => {
25762593
beforeEach(() => {
25772594
vi.clearAllMocks();
@@ -2634,67 +2651,6 @@ describe("GitHubAdapter - Vercel Connect mode", () => {
26342651
expect(opts.headers.authorization).toBe("token ghs_static");
26352652
});
26362653

2637-
it("uses webhookVerifier instead of the signature check", async () => {
2638-
const verifier = vi.fn(() => true);
2639-
const adapter = new GitHubAdapter({
2640-
installationToken: "t",
2641-
webhookVerifier: verifier,
2642-
userName: "connect-bot",
2643-
botUserId: 999,
2644-
logger: mockLogger,
2645-
});
2646-
const mockChat = {
2647-
getLogger: vi.fn(),
2648-
getState: vi.fn(),
2649-
getUserName: vi.fn(),
2650-
handleIncomingMessage: vi.fn(),
2651-
processMessage: vi.fn(),
2652-
};
2653-
await adapter.initialize(mockChat as never);
2654-
2655-
const body = JSON.stringify(makeIssueCommentPayload());
2656-
// No x-hub-signature-256 header — verifier is the only gate.
2657-
const request = makeWebhookRequest(body, "issue_comment");
2658-
const response = await adapter.handleWebhook(request);
2659-
2660-
expect(response.status).toBe(200);
2661-
expect(verifier).toHaveBeenCalledTimes(1);
2662-
expect(mockChat.processMessage).toHaveBeenCalled();
2663-
});
2664-
2665-
it("returns 401 when the webhookVerifier throws", async () => {
2666-
const verifier = vi.fn(() => {
2667-
throw new Error("bad oidc token");
2668-
});
2669-
const adapter = new GitHubAdapter({
2670-
installationToken: "t",
2671-
webhookVerifier: verifier,
2672-
botUserId: 1,
2673-
logger: mockLogger,
2674-
});
2675-
const request = makeWebhookRequest(
2676-
JSON.stringify(makeIssueCommentPayload()),
2677-
"issue_comment"
2678-
);
2679-
const response = await adapter.handleWebhook(request);
2680-
expect(response.status).toBe(401);
2681-
});
2682-
2683-
it("returns 401 when the webhookVerifier returns falsy", async () => {
2684-
const adapter = new GitHubAdapter({
2685-
installationToken: "t",
2686-
webhookVerifier: () => false,
2687-
botUserId: 1,
2688-
logger: mockLogger,
2689-
});
2690-
const request = makeWebhookRequest(
2691-
JSON.stringify(makeIssueCommentPayload()),
2692-
"issue_comment"
2693-
);
2694-
const response = await adapter.handleWebhook(request);
2695-
expect(response.status).toBe(401);
2696-
});
2697-
26982654
it("rejects mixing Connect installationToken with App auth at the type level", () => {
26992655
const adapter = new GitHubAdapter({
27002656
installationToken: "t",

packages/adapter-linear/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"chat": "workspace:*"
3333
},
3434
"devDependencies": {
35+
"@chat-adapter/tests": "workspace:*",
3536
"@types/node": "^25.3.2",
3637
"tsup": "^8.3.5",
3738
"typescript": "^5.7.2",

packages/adapter-linear/src/index.test.ts

Lines changed: 21 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AsyncLocalStorage } from "node:async_hooks";
22
import { createHmac } from "node:crypto";
3+
import { connectWebhookContract } from "@chat-adapter/tests";
34
import type { LinearClient } from "@linear/sdk";
45
import type { ChatInstance, StateAdapter } from "chat";
56
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -1103,17 +1104,30 @@ describe("handleWebhook - invalid JSON", () => {
11031104
// Webhook - comment created handling
11041105
// =============================================================================
11051106

1106-
describe("Vercel Connect mode", () => {
1107-
it("constructs with a function accessToken + webhookVerifier (no secret)", () => {
1107+
connectWebhookContract({
1108+
name: "linear",
1109+
createAdapter: ({ webhookVerifier }) => {
11081110
const adapter = new LinearAdapter({
1109-
accessToken: () => Promise.resolve("lin_connect_token"),
1110-
webhookVerifier: () => true,
1111-
userName: "connect-bot",
1111+
accessToken: () => Promise.resolve("t"),
1112+
webhookVerifier,
1113+
userName: "test-bot",
11121114
logger: createMockLogger(),
11131115
});
1114-
expect(adapter.name).toBe("linear");
1115-
});
1116+
setBotUserId(adapter, "bot-user-id");
1117+
setDefaultOrganizationId(adapter, "org-123");
1118+
// initialize() resolves Connect identity over the network; stub it since the
1119+
// contract only exercises inbound verification.
1120+
vi.spyOn(
1121+
adapter as unknown as { resolveConnectIdentity: () => Promise<void> },
1122+
"resolveConnectIdentity"
1123+
).mockResolvedValue(undefined);
1124+
return adapter;
1125+
},
1126+
makeWebhookRequest: () =>
1127+
buildWebhookRequest(JSON.stringify(createCommentPayload())),
1128+
});
11161129

1130+
describe("Vercel Connect mode", () => {
11171131
it("verifies via webhookVerifier and dispatches comment events", async () => {
11181132
const logger = createMockLogger();
11191133
const verifier = vi.fn(() => true);
@@ -1150,32 +1164,6 @@ describe("Vercel Connect mode", () => {
11501164
);
11511165
});
11521166

1153-
it("returns 401 when the webhookVerifier throws", async () => {
1154-
const adapter = new LinearAdapter({
1155-
accessToken: () => Promise.resolve("t"),
1156-
webhookVerifier: () => {
1157-
throw new Error("bad oidc token");
1158-
},
1159-
userName: "test-bot",
1160-
logger: createMockLogger(),
1161-
});
1162-
const request = buildWebhookRequest(JSON.stringify(createCommentPayload()));
1163-
const response = await adapter.handleWebhook(request);
1164-
expect(response.status).toBe(401);
1165-
});
1166-
1167-
it("returns 401 when the webhookVerifier returns falsy", async () => {
1168-
const adapter = new LinearAdapter({
1169-
accessToken: () => Promise.resolve("t"),
1170-
webhookVerifier: () => false,
1171-
userName: "test-bot",
1172-
logger: createMockLogger(),
1173-
});
1174-
const request = buildWebhookRequest(JSON.stringify(createCommentPayload()));
1175-
const response = await adapter.handleWebhook(request);
1176-
expect(response.status).toBe(401);
1177-
});
1178-
11791167
it("returns 400 when the verified body is not valid JSON", async () => {
11801168
const adapter = new LinearAdapter({
11811169
accessToken: () => Promise.resolve("t"),

packages/adapter-slack/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"chat": "workspace:*"
5050
},
5151
"devDependencies": {
52+
"@chat-adapter/tests": "workspace:*",
5253
"@types/node": "^25.3.2",
5354
"tsup": "^8.3.5",
5455
"typescript": "^5.7.2",

packages/adapter-slack/src/index.test.ts

Lines changed: 21 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import { createHmac, randomBytes } from "node:crypto";
66
import { AuthenticationError, ValidationError } from "@chat-adapter/shared";
7+
import { connectWebhookContract } from "@chat-adapter/tests";
78
import { WebClient } from "@slack/web-api";
89
import type {
910
AdapterPostableMessage,
@@ -367,116 +368,37 @@ describe("handleWebhook - signature verification", () => {
367368
});
368369
});
369370

370-
describe("handleWebhook - webhookVerifier", () => {
371-
it("uses webhookVerifier in place of signingSecret", async () => {
372-
const adapter = createSlackAdapter({
373-
botToken: "xoxb-test-token",
374-
webhookVerifier: () => true,
375-
logger: mockLogger,
376-
});
377-
378-
const body = JSON.stringify({
379-
type: "url_verification",
380-
challenge: "verifier-challenge",
381-
});
382-
const request = new Request("https://example.com/webhook", {
383-
method: "POST",
384-
headers: { "content-type": "application/json" },
385-
body,
386-
});
387-
388-
const response = await adapter.handleWebhook(request);
389-
expect(response.status).toBe(200);
390-
const json = (await response.json()) as { challenge: string };
391-
expect(json.challenge).toBe("verifier-challenge");
392-
});
393-
394-
it("returns 401 when verifier throws", async () => {
395-
const adapter = createSlackAdapter({
396-
botToken: "xoxb-test-token",
397-
webhookVerifier: () => {
398-
throw new Error("bad signature");
399-
},
400-
logger: mockLogger,
401-
});
402-
403-
const request = new Request("https://example.com/webhook", {
404-
method: "POST",
405-
headers: { "content-type": "application/json" },
406-
body: JSON.stringify({ type: "url_verification" }),
407-
});
408-
409-
const response = await adapter.handleWebhook(request);
410-
expect(response.status).toBe(401);
411-
});
412-
413-
it("returns 401 when verifier returns a falsy value", async () => {
414-
const adapter = createSlackAdapter({
415-
botToken: "xoxb-test-token",
416-
webhookVerifier: () => false,
417-
logger: mockLogger,
418-
});
419-
420-
const request = new Request("https://example.com/webhook", {
421-
method: "POST",
422-
headers: { "content-type": "application/json" },
423-
body: JSON.stringify({ type: "url_verification" }),
424-
});
425-
426-
const response = await adapter.handleWebhook(request);
427-
expect(response.status).toBe(401);
428-
});
429-
430-
it("passes the body string to the verifier", async () => {
431-
const body = JSON.stringify({
432-
type: "url_verification",
433-
challenge: "verifier-challenge",
434-
});
435-
const verifier = vi.fn((_req: Request, _body: string) => true);
371+
connectWebhookContract({
372+
name: "slack",
373+
createAdapter: ({ webhookVerifier }) => {
436374
const adapter = createSlackAdapter({
437375
botToken: "xoxb-test-token",
438-
webhookVerifier: verifier,
376+
webhookVerifier,
439377
logger: mockLogger,
440378
});
441-
442-
const request = new Request("https://example.com/webhook", {
443-
method: "POST",
444-
headers: { "content-type": "application/json" },
445-
body,
446-
});
447-
448-
const response = await adapter.handleWebhook(request);
449-
expect(response.status).toBe(200);
450-
expect(verifier).toHaveBeenCalledTimes(1);
451-
expect(verifier.mock.calls[0]?.[1]).toBe(body);
452-
});
453-
454-
it("prefers webhookVerifier over signingSecret when both are set", async () => {
455-
const secret = "test-signing-secret";
456-
const verifier = vi.fn(() => true);
379+
// Skip initialize()'s auth.test network call for bot-id detection.
380+
(adapter as unknown as { _botUserId: string })._botUserId = "UBOT";
381+
return adapter;
382+
},
383+
createAdapterWithSecretAndVerifier: ({ webhookVerifier }) => {
457384
const adapter = createSlackAdapter({
458385
botToken: "xoxb-test-token",
459-
signingSecret: secret,
460-
webhookVerifier: verifier,
386+
signingSecret: "test-signing-secret",
387+
webhookVerifier,
461388
logger: mockLogger,
462389
});
463-
464-
const body = JSON.stringify({
465-
type: "url_verification",
466-
challenge: "test-challenge",
467-
});
468-
// No signing headers — only the verifier should run.
469-
const request = new Request("https://example.com/webhook", {
390+
(adapter as unknown as { _botUserId: string })._botUserId = "UBOT";
391+
return adapter;
392+
},
393+
makeWebhookRequest: () =>
394+
new Request("https://example.com/webhook", {
470395
method: "POST",
471396
headers: { "content-type": "application/json" },
472-
body,
473-
});
474-
475-
const response = await adapter.handleWebhook(request);
476-
expect(response.status).toBe(200);
477-
expect(verifier).toHaveBeenCalledTimes(1);
478-
});
397+
body: JSON.stringify({ type: "url_verification", challenge: "c" }),
398+
}),
399+
});
479400

401+
describe("handleWebhook - webhookVerifier", () => {
480402
it("ignores SLACK_SIGNING_SECRET env var when webhookVerifier is configured", async () => {
481403
vi.stubEnv("SLACK_SIGNING_SECRET", "env-signing-secret");
482404
try {

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)