Files
openclaw/extensions/sms/src/webhook.test.ts
Peter Steinberger 3513e8bfd9 feat: add Twilio SMS channel
Add a bundled SMS channel backed by Twilio inbound webhooks and outbound text delivery.

Includes signed webhook validation, pairing/allowlist access, Messaging Service sender support, chunked plain-text SMS delivery, default target support, docs, config metadata, labeler updates, and focused SMS coverage.

Verification:
- pnpm exec tsgo -p extensions/sms/tsconfig.json --noEmit
- OPENCLAW_VITEST_FS_MODULE_CACHE_PATH=/tmp/openclaw-vitest-sms-land-fix2 node scripts/run-vitest.mjs extensions/sms/src/phone.test.ts extensions/sms/src/accounts.test.ts extensions/sms/src/twilio.test.ts extensions/sms/src/inbound.test.ts extensions/sms/src/gateway.test.ts extensions/sms/src/channel.test.ts extensions/sms/src/send.test.ts extensions/sms/src/webhook.test.ts --reporter=verbose
- pnpm config:channels:check
- pnpm plugins:inventory:check
- git diff --check
- .agents/skills/autoreview/scripts/autoreview --mode local
- .agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main
2026-05-31 09:22:31 +01:00

105 lines
3.3 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SmsChannelRuntime } from "./inbound.js";
import { computeTwilioSignature, parseTwilioFormBody } from "./twilio.js";
import type { ResolvedSmsAccount } from "./types.js";
import { createSmsWebhookHandler, resetSmsWebhookReplayCacheForTest } from "./webhook.js";
const dispatchSmsInboundEvent = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("./inbound.js", () => ({
dispatchSmsInboundEvent,
}));
function createAccount(): ResolvedSmsAccount {
return {
accountId: "default",
enabled: true,
accountSid: "AC123",
authToken: "secret",
fromNumber: "+15557654321",
messagingServiceSid: "",
defaultTo: "",
webhookPath: "/webhooks/sms",
publicWebhookUrl: "https://gateway.example.com/webhooks/sms",
dangerouslyDisableSignatureValidation: false,
dmPolicy: "pairing",
allowFrom: [],
textChunkLimit: 1500,
};
}
function createRequest(body: string, signature: string): IncomingMessage {
const req = Readable.from([body]) as IncomingMessage;
req.method = "POST";
req.headers = { "x-twilio-signature": signature };
Object.defineProperty(req, "socket", {
value: { remoteAddress: "127.0.0.1" },
});
return req;
}
function createResponse(): ServerResponse & { body?: string } {
return {
statusCode: 200,
setHeader: vi.fn(),
end: vi.fn(function (this: ServerResponse & { body?: string }, body?: string) {
this.body = body;
return this;
}),
} as unknown as ServerResponse & { body?: string };
}
describe("createSmsWebhookHandler", () => {
beforeEach(() => {
dispatchSmsInboundEvent.mockClear();
resetSmsWebhookReplayCacheForTest();
});
it("dedupes replayed signed Twilio webhooks by message SID", async () => {
const body =
"AccountSid=AC123&From=%2B15551234567&To=%2B15557654321&Body=hello&MessageSid=SM123";
const signature = computeTwilioSignature({
url: "https://gateway.example.com/webhooks/sms",
authToken: "secret",
form: parseTwilioFormBody(body),
});
const handler = createSmsWebhookHandler({
cfg: {},
account: createAccount(),
channelRuntime: {} as SmsChannelRuntime,
});
const firstRes = createResponse();
await handler(createRequest(body, signature), firstRes);
const replayRes = createResponse();
await handler(createRequest(body, signature), replayRes);
expect(firstRes.statusCode).toBe(200);
expect(replayRes.statusCode).toBe(200);
expect(dispatchSmsInboundEvent).toHaveBeenCalledTimes(1);
});
it("rejects signed webhooks for a different Twilio account", async () => {
const body =
"AccountSid=AC-other&From=%2B15551234567&To=%2B15557654321&Body=hello&SmsMessageSid=SM123";
const signature = computeTwilioSignature({
url: "https://gateway.example.com/webhooks/sms",
authToken: "secret",
form: parseTwilioFormBody(body),
});
const handler = createSmsWebhookHandler({
cfg: {},
account: createAccount(),
channelRuntime: {} as SmsChannelRuntime,
});
const res = createResponse();
await handler(createRequest(body, signature), res);
expect(res.statusCode).toBe(403);
expect(dispatchSmsInboundEvent).not.toHaveBeenCalled();
});
});