/** * Unit tests for the Twilio voice webhook gateway handler. * * Validates that: * - Inbound calls (no callSessionId) resolve the assistant by "To" phone number * for gateway routing decisions (reject/default/forward). * - When phone-number lookup misses, fallback routing (defaultAssistantId / * unmapped policy) is applied instead of silently forwarding with no assistant. * - Outbound calls (callSessionId present) do not resolve or forward an assistantId. * - Validation failures are propagated as responses. * - Assistant IDs are NOT forwarded to the daemon (daemon uses internal scope). * - The `no_one` admission kill switch rejects inbound voice with TwiML. * * Kill-switch testing note: `phone` is an enforced admission channel. The * tests mock `isAdmissionPolicyExemptChannel` and the admission-policy cache * directly (rather than standing up a real store) so each test can flip the * resolved floor / flag in isolation. */ import { describe, test, expect, mock, beforeEach } from "bun:test"; import { resolvePublicBaseWssUrl } from "../../runtime/client.js"; // ── Mocks ────────────────────────────────────────────────────────────── let lastForwardedParams: Record | undefined; let _lastForwardedOriginalUrl: string | undefined; let forwardCalled = false; mock.module("../../runtime/client.js", () => ({ forwardTwilioVoiceWebhook: async ( _config: unknown, params: Record, originalUrl: string, ) => { lastForwardedParams = params; _lastForwardedOriginalUrl = originalUrl; forwardCalled = true; return { status: 200, body: "", headers: { "Content-Type": "text/xml" }, }; }, })); mock.module("../../twilio/validate-webhook.js", () => ({ validateTwilioWebhookRequest: async (req: Request) => { const rawBody = await req.text(); const formData = new URLSearchParams(rawBody); const params: Record = {}; for (const [key, value] of formData.entries()) { params[key] = value; } return { rawBody, params }; }, })); mock.module("../../logger.js", () => ({ getLogger: () => new Proxy({} as Record, { get: () => () => {}, }), })); mock.module("../../voice/verification.js", () => ({ findPendingPhoneSession: async () => null, gatherVerificationTwiml: () => "", })); // ── Admission-policy mocks (kill switch) ─────────────────────────────── // Mutable knobs so individual tests can flip the flag / exempt state / // resolved policy without re-mocking. Defaults keep the kill switch a no-op // (flag off) so the pre-existing routing tests are unaffected. let flagEnabled = false; let phoneExempt = false; let phonePolicy = "everyone"; mock.module("../../feature-flag-resolver.js", () => ({ isFeatureFlagEnabled: (flag: string) => flag === "channel-trust-floors" ? flagEnabled : false, })); mock.module("@vellumai/gateway-client", () => ({ isAdmissionPolicyExemptChannel: (channel: string) => channel === "phone" ? phoneExempt : false, })); mock.module("../../risk/admission-policy-cache.js", () => ({ getAdmissionPolicyCache: () => ({ get: () => phonePolicy }), })); import { createTwilioVoiceWebhookHandler } from "./twilio-voice-webhook.js"; import type { GatewayConfig } from "../../config.js"; import type { ConfigFileCache } from "../../config-file-cache.js"; // ── Test config ──────────────────────────────────────────────────────── const baseConfig: GatewayConfig = { assistantRuntimeBaseUrl: "http://127.0.0.1:7821", defaultAssistantId: undefined, gatewayInternalBaseUrl: "http://127.0.0.1:7830", logFile: { dir: undefined, retentionDays: 30 }, maxAttachmentBytes: { telegram: 50 * 1024 * 1024, slack: 100 * 1024 * 1024, whatsapp: 16 * 1024 * 1024, default: 50 * 1024 * 1024, }, maxAttachmentConcurrency: 3, maxWebhookPayloadBytes: 1024 * 1024, port: 7830, routingEntries: [], runtimeInitialBackoffMs: 500, runtimeMaxRetries: 2, runtimeProxyRequireAuth: true, runtimeTimeoutMs: 30000, shutdownDrainMs: 5000, unmappedPolicy: "reject", trustProxy: false, }; function makeVoiceRequest( params: Record, queryString = "", ): Request { const body = new URLSearchParams(params).toString(); return new Request( `http://127.0.0.1:7830/webhooks/twilio/voice${queryString}`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }, ); } /** Create a mock ConfigFileCache with phone number mappings. */ function makeCachesWithPhoneNumbers(mapping?: Record) { const phoneNumbers = mapping ?? { "assistant-abc": "+15550001111", "assistant-xyz": "+15550002222", }; const configFile = { getString: () => undefined, getRecord: (section: string, key: string) => { if (section === "twilio" && key === "assistantPhoneNumbers") return phoneNumbers; return undefined; }, refreshNow: () => {}, } as unknown as ConfigFileCache; return { configFile }; } // ── Tests ────────────────────────────────────────────────────────────── describe("twilio voice webhook handler", () => { beforeEach(() => { lastForwardedParams = undefined; _lastForwardedOriginalUrl = undefined; forwardCalled = false; flagEnabled = false; phoneExempt = false; phonePolicy = "everyone"; }); test("inbound call resolves assistant by To number and forwards to daemon", async () => { const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_inbound_1", From: "+14155551234", To: "+15550001111", }); const res = await handler(req); expect(res.status).toBe(200); // Gateway resolves assistant for routing but does NOT forward assistantId to daemon expect(forwardCalled).toBe(true); expect(lastForwardedParams?.CallSid).toBe("CA_inbound_1"); }); test("inbound call with unknown To number is rejected when unmappedPolicy is reject", async () => { const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_inbound_2", From: "+14155551234", To: "+19999999999", }); const res = await handler(req); expect(res.status).toBe(200); const body = await res.text(); expect(body).toContain(" { const configWithDefault: GatewayConfig = { ...baseConfig, unmappedPolicy: "default", defaultAssistantId: "fallback-assistant", }; const handler = createTwilioVoiceWebhookHandler( configWithDefault, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_inbound_fallback", From: "+14155551234", To: "+19999999999", }); const res = await handler(req); expect(res.status).toBe(200); // Gateway resolves fallback for routing, forwards call to daemon without assistantId expect(forwardCalled).toBe(true); }); test("anonymous inbound call (no From) still routes to the default assistant under default policy", async () => { // Caller-ID-withheld call: no `From` to route on. resolveAssistant // fail-closes on the missing identity, but a voice line is intentionally // open, so the handler answers via the default assistant. const configWithDefault: GatewayConfig = { ...baseConfig, unmappedPolicy: "default", defaultAssistantId: "fallback-assistant", }; const handler = createTwilioVoiceWebhookHandler( configWithDefault, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_inbound_anon", To: "+12015550199", }); const res = await handler(req); expect(res.status).toBe(200); expect(forwardCalled).toBe(true); }); test("anonymous inbound call (no From) is rejected under reject policy", async () => { const handler = createTwilioVoiceWebhookHandler( baseConfig, // unmappedPolicy: "reject" makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_inbound_anon_reject", To: "+12015550199", }); const res = await handler(req); expect(res.status).toBe(200); const body = await res.text(); expect(body).toContain(" { const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest( { CallSid: "CA_outbound_1", From: "+15550001111", To: "+14155559999", }, "?callSessionId=existing-session-id", ); const res = await handler(req); expect(res.status).toBe(200); expect(forwardCalled).toBe(true); }); test("empty callSessionId is treated as inbound (resolves assistant by To number)", async () => { const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest( { CallSid: "CA_empty_session", From: "+14155551234", To: "+15550001111", }, "?callSessionId=", ); const res = await handler(req); expect(res.status).toBe(200); expect(forwardCalled).toBe(true); expect(lastForwardedParams?.CallSid).toBe("CA_empty_session"); }); test("inbound call resolves second assistant by To number", async () => { const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_inbound_3", From: "+14155551234", To: "+15550002222", }); const res = await handler(req); expect(res.status).toBe(200); expect(forwardCalled).toBe(true); }); test("inbound call without assistantPhoneNumbers config is rejected when unmappedPolicy is reject", async () => { // No configFile cache means no phone number mapping — falls through to unmapped policy. const handler = createTwilioVoiceWebhookHandler(baseConfig); const req = makeVoiceRequest({ CallSid: "CA_inbound_4", From: "+14155551234", To: "+15550001111", }); const res = await handler(req); expect(res.status).toBe(200); const body = await res.text(); expect(body).toContain(" { const configNoMappingWithDefault: GatewayConfig = { ...baseConfig, unmappedPolicy: "default", defaultAssistantId: "fallback-assistant", }; const handler = createTwilioVoiceWebhookHandler(configNoMappingWithDefault); const req = makeVoiceRequest({ CallSid: "CA_inbound_5", From: "+14155551234", To: "+15550001111", }); const res = await handler(req); expect(res.status).toBe(200); expect(forwardCalled).toBe(true); }); test("inbound call is rejected when phone admission policy is no_one (flag on, enforced)", async () => { flagEnabled = true; phoneExempt = false; // phone is enforced phonePolicy = "no_one"; const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_no_one", From: "+14155551234", To: "+15550001111", }); const res = await handler(req); expect(res.status).toBe(200); const body = await res.text(); expect(body).toContain(" { flagEnabled = true; phoneExempt = false; phonePolicy = "everyone"; const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_everyone", From: "+14155551234", To: "+15550001111", }); const res = await handler(req); expect(res.status).toBe(200); expect(forwardCalled).toBe(true); }); test("kill switch is skipped if a channel is exempt even with policy no_one", async () => { flagEnabled = true; phoneExempt = true; // exercise the defensive exempt branch phonePolicy = "no_one"; const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest({ CallSid: "CA_exempt", From: "+14155551234", To: "+15550001111", }); const res = await handler(req); expect(res.status).toBe(200); expect(forwardCalled).toBe(true); }); test("outbound call (callSessionId present) is never kill-switched", async () => { flagEnabled = true; phoneExempt = false; phonePolicy = "no_one"; const handler = createTwilioVoiceWebhookHandler( baseConfig, makeCachesWithPhoneNumbers(), ); const req = makeVoiceRequest( { CallSid: "CA_outbound_no_one", From: "+15550001111", To: "+14155559999", }, "?callSessionId=existing-session-id", ); const res = await handler(req); expect(res.status).toBe(200); expect(forwardCalled).toBe(true); }); }); // ── resolvePublicBaseWssUrl unit tests ───────────────────────────────── describe("resolvePublicBaseWssUrl", () => { const baseConfig = { assistantRuntimeBaseUrl: "http://localhost:3000", } as Parameters[0]; test("returns undefined when neither velayBaseUrl nor configFile publicBaseUrl is set", () => { expect(resolvePublicBaseWssUrl(baseConfig)).toBeUndefined(); }); test("uses velayBaseUrl + platformAssistantId when both are present", () => { const config = { ...baseConfig, velayBaseUrl: "https://velay-dev.vellum.ai", }; const result = resolvePublicBaseWssUrl( config, undefined, "abc12345-0000-0000-0000-000000000000", ); expect(result).toBe( "wss://velay-dev.vellum.ai/abc12345-0000-0000-0000-000000000000", ); }); test("uses configFile publicBaseUrl before velayBaseUrl fallback", () => { const config = { ...baseConfig, velayBaseUrl: "http://host.docker.internal:8501", }; const mockConfigFile = { getString: (section: string, key: string) => section === "ingress" && key === "publicBaseUrl" ? "https://velay-public.example.test/abc12345-0000-0000-0000-000000000000" : undefined, } as Parameters[1]; const result = resolvePublicBaseWssUrl( config, mockConfigFile, "abc12345-0000-0000-0000-000000000000", ); expect(result).toBe( "wss://velay-public.example.test/abc12345-0000-0000-0000-000000000000", ); }); test("falls back to configFile publicBaseUrl when platformAssistantId is missing", () => { const config = { ...baseConfig, velayBaseUrl: "https://velay-dev.vellum.ai", }; const mockConfigFile = { getString: (section: string, key: string) => section === "ingress" && key === "publicBaseUrl" ? "https://velay-dev.vellum.ai/abc12345-0000-0000-0000-000000000000" : undefined, } as Parameters[1]; const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined); expect(result).toBe( "wss://velay-dev.vellum.ai/abc12345-0000-0000-0000-000000000000", ); }); test("uses assistant ID from validated platform callback URL with Velay", () => { const config = { ...baseConfig, velayBaseUrl: "https://velay-staging.vellum.ai", }; const result = resolvePublicBaseWssUrl( config, undefined, undefined, "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice/?callSessionId=37b47ade-2eaf-469a-bede-6f2454875e6e", ); expect(result).toBe( "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e", ); }); test("prefers validated platform callback URL over stale configFile publicBaseUrl", () => { const config = { ...baseConfig, velayBaseUrl: "https://velay-staging.vellum.ai", }; const mockConfigFile = { getString: (section: string, key: string) => section === "ingress" && key === "publicBaseUrl" ? "https://stale-tunnel.example.test" : undefined, } as Parameters[1]; const result = resolvePublicBaseWssUrl( config, mockConfigFile, undefined, "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice?callSessionId=sess-1", ); expect(result).toBe( "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e", ); }); test("does not use a platform callback URL as the websocket base", () => { const config = { ...baseConfig, velayBaseUrl: "https://velay-staging.vellum.ai", }; const mockConfigFile = { getString: (section: string, key: string) => section === "ingress" && key === "publicBaseUrl" ? "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e" : undefined, } as Parameters[1]; const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined); expect(result).toBe( "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e", ); }); test("strips trailing slash from velayBaseUrl before joining assistant ID", () => { const config = { ...baseConfig, velayBaseUrl: "https://velay-dev.vellum.ai/", }; const result = resolvePublicBaseWssUrl( config, undefined, "abc12345-0000-0000-0000-000000000000", ); expect(result).toBe( "wss://velay-dev.vellum.ai/abc12345-0000-0000-0000-000000000000", ); }); });