test(voice-call): cover verification key and header helpers

This commit is contained in:
Peter Steinberger
2026-02-26 21:54:05 +01:00
parent 535ef8991c
commit 192df12d60
3 changed files with 99 additions and 2 deletions

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getHeader } from "./http-headers.js";
describe("getHeader", () => {
it("returns first value when header is an array", () => {
expect(getHeader({ "x-test": ["first", "second"] }, "x-test")).toBe("first");
});
it("matches headers case-insensitively", () => {
expect(getHeader({ "X-Twilio-Signature": "sig-1" }, "x-twilio-signature")).toBe("sig-1");
});
it("returns undefined for missing header", () => {
expect(getHeader({ host: "example.com" }, "x-missing")).toBeUndefined();
});
});

View File

@@ -203,6 +203,22 @@ describe("verifyPlivoWebhook", () => {
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
});
it("returns a stable request key when verification is skipped", () => {
const ctx = {
headers: {},
rawBody: "CallUUID=uuid&CallStatus=in-progress",
url: "https://example.com/voice/webhook",
method: "POST" as const,
};
const first = verifyPlivoWebhook(ctx, "token", { skipVerification: true });
const second = verifyPlivoWebhook(ctx, "token", { skipVerification: true });
expect(first.ok).toBe(true);
expect(first.verifiedRequestKey).toMatch(/^plivo:skip:/);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expect(second.isReplay).toBe(true);
});
});
describe("verifyTelnyxWebhook", () => {
@@ -236,6 +252,22 @@ describe("verifyTelnyxWebhook", () => {
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
});
it("returns a stable request key when verification is skipped", () => {
const ctx = {
headers: {},
rawBody: JSON.stringify({ data: { event_type: "call.initiated" } }),
url: "https://example.com/voice/webhook",
method: "POST" as const,
};
const first = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true });
const second = verifyTelnyxWebhook(ctx, undefined, { skipVerification: true });
expect(first.ok).toBe(true);
expect(first.verifiedRequestKey).toMatch(/^telnyx:skip:/);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expect(second.isReplay).toBe(true);
});
});
describe("verifyTwilioWebhook", () => {
@@ -571,4 +603,20 @@ describe("verifyTwilioWebhook", () => {
expect(result.ok).toBe(false);
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
});
it("returns a stable request key when verification is skipped", () => {
const ctx = {
headers: {},
rawBody: "CallSid=CS123&CallStatus=completed",
url: "https://example.com/voice/webhook",
method: "POST" as const,
};
const first = verifyTwilioWebhook(ctx, "token", { skipVerification: true });
const second = verifyTwilioWebhook(ctx, "token", { skipVerification: true });
expect(first.ok).toBe(true);
expect(first.verifiedRequestKey).toMatch(/^twilio:skip:/);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expect(second.isReplay).toBe(true);
});
});

View File

@@ -7,7 +7,7 @@ import { VoiceCallWebhookServer } from "./webhook.js";
const provider: VoiceCallProvider = {
name: "mock",
verifyWebhook: () => ({ ok: true }),
verifyWebhook: () => ({ ok: true, verifiedRequestKey: "mock:req:base" }),
parseWebhookEvent: () => ({ events: [] }),
initiateCall: async () => ({ providerCallId: "provider-call", status: "initiated" }),
hangupCall: async () => {},
@@ -123,7 +123,7 @@ describe("VoiceCallWebhookServer replay handling", () => {
it("acknowledges replayed webhook requests and skips event side effects", async () => {
const replayProvider: VoiceCallProvider = {
...provider,
verifyWebhook: () => ({ ok: true, isReplay: true }),
verifyWebhook: () => ({ ok: true, isReplay: true, verifiedRequestKey: "mock:req:replay" }),
parseWebhookEvent: () => ({
events: [
{
@@ -217,4 +217,37 @@ describe("VoiceCallWebhookServer replay handling", () => {
await server.stop();
}
});
it("rejects requests when verification succeeds without a request key", async () => {
const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
const badProvider: VoiceCallProvider = {
...provider,
verifyWebhook: () => ({ ok: true }),
parseWebhookEvent,
};
const { manager } = createManager([]);
const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
const server = new VoiceCallWebhookServer(config, manager, badProvider);
try {
const baseUrl = await server.start();
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (address && typeof address === "object" && "port" in address && address.port) {
requestUrl.port = String(address.port);
}
const response = await fetch(requestUrl.toString(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "CallSid=CA123&SpeechResult=hello",
});
expect(response.status).toBe(401);
expect(parseWebhookEvent).not.toHaveBeenCalled();
} finally {
await server.stop();
}
});
});