vellum 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/bun.lock +5 -2
- package/package.json +4 -2
- package/scripts/capture-x-graphql.ts +562 -0
- package/scripts/ipc/check-swift-decoder-drift.ts +2 -1
- package/scripts/test.sh +5 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +133 -34
- package/src/__tests__/account-registry.test.ts +2 -1
- package/src/__tests__/agent-heartbeat-service.test.ts +250 -0
- package/src/__tests__/asset-materialize-tool.test.ts +16 -15
- package/src/__tests__/asset-search-tool.test.ts +23 -22
- package/src/__tests__/attachments-store.test.ts +56 -127
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +5 -4
- package/src/__tests__/browser-skill-endstate.test.ts +4 -3
- package/src/__tests__/call-bridge.test.ts +385 -0
- package/src/__tests__/call-constants.test.ts +40 -0
- package/src/__tests__/call-orchestrator.test.ts +130 -4
- package/src/__tests__/call-recovery.test.ts +518 -0
- package/src/__tests__/call-routes-http.test.ts +459 -0
- package/src/__tests__/call-state-machine.test.ts +143 -0
- package/src/__tests__/call-store.test.ts +216 -1
- package/src/__tests__/cli-discover.test.ts +1 -1
- package/src/__tests__/commit-message-enrichment-service.test.ts +148 -7
- package/src/__tests__/compaction.benchmark.test.ts +176 -0
- package/src/__tests__/computer-use-tools.test.ts +250 -0
- package/src/__tests__/config-schema.test.ts +299 -3
- package/src/__tests__/conflict-store.test.ts +2 -1
- package/src/__tests__/contacts-tools.test.ts +331 -0
- package/src/__tests__/conversation-store.test.ts +30 -32
- package/src/__tests__/credential-security-invariants.test.ts +4 -0
- package/src/__tests__/date-context.test.ts +373 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +129 -0
- package/src/__tests__/fixtures/media-reuse-fixtures.ts +3 -3
- package/src/__tests__/followup-tools.test.ts +303 -0
- package/src/__tests__/handlers-twitter-config.test.ts +718 -0
- package/src/__tests__/intent-routing.test.ts +64 -57
- package/src/__tests__/ipc-roundtrip.benchmark.test.ts +237 -0
- package/src/__tests__/ipc-snapshot.test.ts +62 -28
- package/src/__tests__/llm-usage-store.test.ts +3 -8
- package/src/__tests__/media-generate-image.test.ts +1 -1
- package/src/__tests__/media-reuse-story.e2e.test.ts +7 -7
- package/src/__tests__/memory-retrieval.benchmark.test.ts +430 -0
- package/src/__tests__/parallel-tool.benchmark.test.ts +294 -0
- package/src/__tests__/playbook-tools.test.ts +342 -0
- package/src/__tests__/profile-compiler.test.ts +2 -1
- package/src/__tests__/provider-streaming.benchmark.test.ts +773 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +78 -0
- package/src/__tests__/recurrence-engine.test.ts +69 -0
- package/src/__tests__/recurrence-types.test.ts +71 -0
- package/src/__tests__/registry.test.ts +5 -3
- package/src/__tests__/relay-server.test.ts +633 -0
- package/src/__tests__/reminder-store.test.ts +6 -3
- package/src/__tests__/reminder.test.ts +43 -77
- package/src/__tests__/run-orchestrator-assistant-events.test.ts +8 -4
- package/src/__tests__/run-orchestrator.test.ts +4 -4
- package/src/__tests__/runtime-attachment-metadata.test.ts +7 -6
- package/src/__tests__/runtime-runs-http.test.ts +4 -4
- package/src/__tests__/runtime-runs.test.ts +4 -4
- package/src/__tests__/schedule-store.test.ts +482 -0
- package/src/__tests__/schedule-tools.test.ts +700 -0
- package/src/__tests__/scheduler-recurrence.test.ts +329 -0
- package/src/__tests__/server-history-render.test.ts +14 -13
- package/src/__tests__/session-error.test.ts +28 -0
- package/src/__tests__/session-init.benchmark.test.ts +462 -0
- package/src/__tests__/session-queue.test.ts +71 -48
- package/src/__tests__/session-runtime-assembly.test.ts +161 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +104 -0
- package/src/__tests__/signup-e2e.test.ts +2 -1
- package/src/__tests__/skill-projection.benchmark.test.ts +328 -0
- package/src/__tests__/skill-script-runner.test.ts +159 -0
- package/src/__tests__/speaker-identification.test.ts +52 -0
- package/src/__tests__/subagent-manager-notify.test.ts +42 -10
- package/src/__tests__/subagent-tools.test.ts +141 -41
- package/src/__tests__/task-compiler.test.ts +2 -1
- package/src/__tests__/task-runner.test.ts +2 -1
- package/src/__tests__/task-scheduler.test.ts +2 -1
- package/src/__tests__/task-tools.test.ts +49 -56
- package/src/__tests__/tool-audit-listener.test.ts +1 -0
- package/src/__tests__/tool-domain-event-publisher.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +500 -0
- package/src/__tests__/tool-executor.test.ts +13 -17
- package/src/__tests__/turn-commit.test.ts +218 -3
- package/src/__tests__/twilio-provider.test.ts +143 -0
- package/src/__tests__/twilio-routes.test.ts +789 -0
- package/src/__tests__/twitter-auth-handler.test.ts +581 -0
- package/src/__tests__/view-image-tool.test.ts +217 -0
- package/src/__tests__/workspace-git-service.test.ts +186 -0
- package/src/__tests__/workspace-heartbeat-service.test.ts +13 -3
- package/src/agent-heartbeat/agent-heartbeat-service.ts +155 -0
- package/src/bundler/app-bundler.ts +12 -8
- package/src/calls/call-bridge.ts +95 -0
- package/src/calls/call-constants.ts +43 -5
- package/src/calls/call-domain.ts +276 -0
- package/src/calls/call-orchestrator.ts +43 -17
- package/src/calls/call-recovery.ts +207 -0
- package/src/calls/call-state-machine.ts +68 -0
- package/src/calls/call-store.ts +192 -5
- package/src/calls/relay-server.ts +41 -4
- package/src/calls/speaker-identification.ts +213 -0
- package/src/calls/twilio-provider.ts +10 -6
- package/src/calls/twilio-routes.ts +90 -76
- package/src/calls/types.ts +1 -1
- package/src/cli/config-commands.ts +334 -0
- package/src/cli/core-commands.ts +776 -0
- package/src/cli/doordash.ts +251 -1
- package/src/cli/ipc-client.ts +82 -0
- package/src/cli/map.ts +246 -0
- package/src/cli/twitter.ts +575 -0
- package/src/cli.ts +7 -5
- package/src/commands/__tests__/cc-command-registry.test.ts +319 -0
- package/src/commands/cc-command-registry.ts +209 -0
- package/src/config/bundled-skills/contacts/SKILL.md +39 -0
- package/src/config/bundled-skills/contacts/TOOLS.json +122 -0
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +9 -0
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +9 -0
- package/src/config/bundled-skills/document/SKILL.md +18 -0
- package/src/config/bundled-skills/document/TOOLS.json +53 -0
- package/src/config/bundled-skills/document/tools/document-create.ts +9 -0
- package/src/config/bundled-skills/document/tools/document-update.ts +9 -0
- package/src/config/bundled-skills/doordash/SKILL.md +82 -23
- package/src/config/bundled-skills/followups/SKILL.md +32 -0
- package/src/config/bundled-skills/followups/TOOLS.json +100 -0
- package/src/config/bundled-skills/followups/tools/followup-create.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-list.ts +9 -0
- package/src/config/bundled-skills/followups/tools/followup-resolve.ts +9 -0
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +1 -23
- package/src/config/bundled-skills/messaging/tools/messaging-analyze-style.ts +2 -1
- package/src/config/bundled-skills/playbooks/SKILL.md +31 -0
- package/src/config/bundled-skills/playbooks/TOOLS.json +126 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +9 -0
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +9 -0
- package/src/config/bundled-skills/reminder/SKILL.md +20 -0
- package/src/config/bundled-skills/reminder/TOOLS.json +67 -0
- package/src/config/bundled-skills/reminder/tools/reminder-cancel.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-create.ts +9 -0
- package/src/config/bundled-skills/reminder/tools/reminder-list.ts +9 -0
- package/src/config/bundled-skills/schedule/SKILL.md +74 -0
- package/src/config/bundled-skills/schedule/TOOLS.json +135 -0
- package/src/config/bundled-skills/schedule/tools/schedule-create.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-delete.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-list.ts +9 -0
- package/src/config/bundled-skills/schedule/tools/schedule-update.ts +9 -0
- package/src/config/bundled-skills/subagent/SKILL.md +25 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +107 -0
- package/src/config/bundled-skills/subagent/tools/subagent-abort.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-message.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-read.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-spawn.ts +9 -0
- package/src/config/bundled-skills/subagent/tools/subagent-status.ts +9 -0
- package/src/config/bundled-skills/tasks/SKILL.md +28 -0
- package/src/config/bundled-skills/tasks/TOOLS.json +256 -0
- package/src/config/bundled-skills/tasks/tools/task-delete.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-add.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-show.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list-update.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-list.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-run.ts +9 -0
- package/src/config/bundled-skills/tasks/tools/task-save.ts +9 -0
- package/src/config/bundled-skills/twitter/SKILL.md +134 -0
- package/src/config/bundled-skills/watcher/SKILL.md +27 -0
- package/src/config/bundled-skills/watcher/TOOLS.json +147 -0
- package/src/config/bundled-skills/watcher/tools/watcher-create.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-list.ts +9 -0
- package/src/config/bundled-skills/watcher/tools/watcher-update.ts +9 -0
- package/src/config/defaults.ts +33 -0
- package/src/config/loader.ts +4 -1
- package/src/config/schema.ts +161 -1
- package/src/config/system-prompt.ts +61 -16
- package/src/config/templates/IDENTITY.md +7 -0
- package/src/config/types.ts +4 -0
- package/src/contacts/contact-store.ts +4 -4
- package/src/daemon/assistant-attachments.ts +10 -0
- package/src/daemon/classifier.ts +3 -1
- package/src/daemon/computer-use-session.ts +3 -1
- package/src/daemon/date-context.ts +136 -0
- package/src/daemon/handlers/apps.ts +16 -1
- package/src/daemon/handlers/browser.ts +54 -0
- package/src/daemon/handlers/computer-use.ts +7 -1
- package/src/daemon/handlers/config.ts +163 -5
- package/src/daemon/handlers/diagnostics.ts +5 -1
- package/src/daemon/handlers/documents.ts +18 -29
- package/src/daemon/handlers/home-base.ts +5 -1
- package/src/daemon/handlers/index.ts +40 -277
- package/src/daemon/handlers/misc.ts +9 -1
- package/src/daemon/handlers/publish.ts +6 -1
- package/src/daemon/handlers/sessions.ts +65 -12
- package/src/daemon/handlers/shared.ts +36 -1
- package/src/daemon/handlers/signing.ts +37 -0
- package/src/daemon/handlers/skills.ts +20 -6
- package/src/daemon/handlers/subagents.ts +8 -3
- package/src/daemon/handlers/twitter-auth.ts +169 -0
- package/src/daemon/handlers/work-items.ts +384 -68
- package/src/daemon/ipc-contract-inventory.json +28 -4
- package/src/daemon/ipc-contract.ts +133 -37
- package/src/daemon/ipc-protocol.ts +7 -2
- package/src/daemon/lifecycle.ts +21 -0
- package/src/daemon/main.ts +10 -4
- package/src/daemon/ride-shotgun-handler.ts +74 -10
- package/src/daemon/server.ts +143 -26
- package/src/daemon/session-agent-loop.ts +887 -0
- package/src/daemon/session-attachments.ts +28 -5
- package/src/daemon/session-error.ts +24 -3
- package/src/daemon/session-lifecycle.ts +147 -0
- package/src/daemon/session-media-retry.ts +147 -0
- package/src/daemon/session-messaging.ts +145 -0
- package/src/daemon/session-notifiers.ts +164 -0
- package/src/daemon/session-process.ts +2 -2
- package/src/daemon/session-queue-manager.ts +1 -0
- package/src/daemon/session-runtime-assembly.ts +52 -0
- package/src/daemon/session-skill-tools.ts +124 -5
- package/src/daemon/session-slash.ts +3 -0
- package/src/daemon/session-surfaces.ts +77 -2
- package/src/daemon/session-tool-setup.ts +216 -2
- package/src/daemon/session-usage.ts +0 -2
- package/src/daemon/session.ts +114 -1404
- package/src/daemon/video-thumbnail.ts +60 -0
- package/src/doordash/client.ts +121 -27
- package/src/doordash/queries.ts +1 -2
- package/src/export/formatter.ts +3 -1
- package/src/followups/followup-store.ts +4 -2
- package/src/followups/types.ts +6 -0
- package/src/hooks/templates.ts +1 -1
- package/src/index.ts +32 -1153
- package/src/memory/attachments-store.ts +28 -83
- package/src/memory/channel-delivery-store.ts +7 -21
- package/src/memory/clarification-resolver.ts +6 -5
- package/src/memory/contradiction-checker.ts +3 -2
- package/src/memory/conversation-key-store.ts +10 -29
- package/src/memory/conversation-store.ts +2 -1
- package/src/memory/db.ts +96 -2
- package/src/memory/entity-extractor.ts +6 -3
- package/src/memory/items-extractor.ts +5 -4
- package/src/memory/jobs-store.ts +3 -2
- package/src/memory/llm-usage-store.ts +1 -2
- package/src/memory/runs-store.ts +1 -2
- package/src/memory/schema.ts +23 -2
- package/src/messaging/style-analyzer.ts +3 -2
- package/src/messaging/thread-summarizer.ts +8 -12
- package/src/messaging/triage-engine.ts +4 -2
- package/src/providers/openrouter/client.ts +20 -0
- package/src/providers/registry.ts +8 -0
- package/src/runtime/http-server.ts +108 -20
- package/src/runtime/routes/attachment-routes.ts +2 -3
- package/src/runtime/routes/call-routes.ts +140 -0
- package/src/runtime/routes/channel-routes.ts +5 -10
- package/src/runtime/routes/conversation-routes.ts +5 -5
- package/src/runtime/routes/run-routes.ts +2 -2
- package/src/runtime/run-orchestrator.ts +9 -3
- package/src/schedule/recurrence-engine.ts +138 -0
- package/src/schedule/recurrence-types.ts +67 -0
- package/src/schedule/schedule-store.ts +102 -57
- package/src/schedule/scheduler.ts +9 -6
- package/src/security/oauth2.ts +29 -4
- package/src/security/secret-allowlist.ts +46 -0
- package/src/skills/clawhub.ts +1 -1
- package/src/subagent/manager.ts +40 -8
- package/src/swarm/backend-claude-code.ts +64 -9
- package/src/swarm/worker-prompts.ts +2 -1
- package/src/tasks/SPEC.md +34 -28
- package/src/tasks/ephemeral-permissions.ts +16 -7
- package/src/tasks/task-compiler.ts +5 -4
- package/src/tasks/task-runner.ts +10 -5
- package/src/tasks/task-scheduler.ts +1 -1
- package/src/tasks/tool-sanitizer.ts +36 -0
- package/src/tools/assets/search.ts +4 -4
- package/src/tools/browser/api-map.ts +220 -0
- package/src/tools/browser/auto-navigate.ts +270 -0
- package/src/tools/browser/browser-execution.ts +2 -1
- package/src/tools/browser/browser-manager.ts +2 -2
- package/src/tools/browser/network-recorder.ts +5 -4
- package/src/tools/browser/x-auto-navigate.ts +207 -0
- package/src/tools/calls/call-end.ts +17 -67
- package/src/tools/calls/call-start.ts +24 -85
- package/src/tools/calls/call-status.ts +35 -51
- package/src/tools/claude-code/claude-code.ts +77 -11
- package/src/tools/contacts/contact-merge.ts +46 -78
- package/src/tools/contacts/contact-search.ts +35 -79
- package/src/tools/contacts/contact-upsert.ts +35 -108
- package/src/tools/credentials/vault.ts +20 -4
- package/src/tools/document/document-tool.ts +71 -144
- package/src/tools/executor.ts +129 -10
- package/src/tools/followups/followup_create.ts +46 -88
- package/src/tools/followups/followup_list.ts +34 -74
- package/src/tools/followups/followup_resolve.ts +31 -66
- package/src/tools/host-terminal/cli-discover.ts +2 -1
- package/src/tools/host-terminal/host-shell.ts +10 -0
- package/src/tools/memory/handlers.ts +5 -4
- package/src/tools/network/__tests__/web-search.test.ts +427 -0
- package/src/tools/network/script-proxy/__tests__/logging.test.ts +248 -0
- package/src/tools/network/script-proxy/__tests__/policy.test.ts +234 -0
- package/src/tools/network/script-proxy/__tests__/router.test.ts +76 -0
- package/src/tools/network/web-fetch.ts +18 -6
- package/src/tools/playbooks/index.ts +4 -5
- package/src/tools/playbooks/playbook-create.ts +3 -47
- package/src/tools/playbooks/playbook-delete.ts +1 -25
- package/src/tools/playbooks/playbook-list.ts +1 -28
- package/src/tools/playbooks/playbook-update.ts +3 -51
- package/src/tools/reminder/reminder.ts +5 -78
- package/src/tools/schedule/create.ts +69 -74
- package/src/tools/schedule/delete.ts +21 -47
- package/src/tools/schedule/list.ts +55 -74
- package/src/tools/schedule/update.ts +77 -84
- package/src/tools/subagent/abort.ts +29 -58
- package/src/tools/subagent/message.ts +30 -63
- package/src/tools/subagent/read.ts +53 -84
- package/src/tools/subagent/spawn.ts +43 -82
- package/src/tools/subagent/status.ts +42 -71
- package/src/tools/swarm/delegate.ts +2 -1
- package/src/tools/tasks/index.ts +8 -8
- package/src/tools/tasks/task-delete.ts +60 -88
- package/src/tools/tasks/task-list.ts +31 -52
- package/src/tools/tasks/task-run.ts +72 -108
- package/src/tools/tasks/task-save.ts +33 -65
- package/src/tools/tasks/work-item-enqueue.ts +183 -215
- package/src/tools/tasks/work-item-list.ts +33 -63
- package/src/tools/tasks/work-item-remove.ts +45 -97
- package/src/tools/tasks/work-item-update.ts +91 -163
- package/src/tools/terminal/backends/native.ts +3 -1
- package/src/tools/tool-manifest.ts +0 -62
- package/src/tools/types.ts +6 -0
- package/src/tools/ui-surface/definitions.ts +3 -1
- package/src/tools/watch/screen-watch.ts +3 -1
- package/src/tools/watcher/create.ts +52 -98
- package/src/tools/watcher/delete.ts +20 -46
- package/src/tools/watcher/digest.ts +36 -70
- package/src/tools/watcher/list.ts +49 -79
- package/src/tools/watcher/update.ts +45 -91
- package/src/twitter/client.ts +690 -0
- package/src/twitter/session.ts +91 -0
- package/src/usage/types.ts +0 -1
- package/src/util/truncate.ts +6 -0
- package/src/watcher/providers/slack.ts +2 -1
- package/src/watcher/watcher-store.ts +3 -2
- package/src/work-items/work-item-store.ts +27 -2
- package/src/workspace/commit-message-enrichment-service.ts +31 -7
- package/src/workspace/git-service.ts +87 -22
- package/src/workspace/provider-commit-message-generator.ts +242 -0
- package/src/workspace/turn-commit.ts +62 -3
- package/src/tools/contacts/index.ts +0 -4
- package/src/tools/document/index.ts +0 -5
- package/src/tools/followups/index.ts +0 -3
- package/src/tools/subagent/index.ts +0 -5
- /package/src/__tests__/{memory-context-benchmark.test.ts → memory-context-benchmark.benchmark.test.ts} +0 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for Twilio webhook route handlers.
|
|
3
|
+
*
|
|
4
|
+
* Tests:
|
|
5
|
+
* - Signature valid/invalid/missing header
|
|
6
|
+
* - Fail-closed behavior when auth token is not configured
|
|
7
|
+
* - TWILIO_WEBHOOK_VALIDATION_DISABLED env flag bypass
|
|
8
|
+
* - Duplicate callback replay (idempotency)
|
|
9
|
+
* - Unknown status and malformed payload handling
|
|
10
|
+
* - Handler-level idempotency concurrency (concurrent duplicates, failure-retry)
|
|
11
|
+
*/
|
|
12
|
+
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from 'bun:test';
|
|
13
|
+
import { createHmac } from 'node:crypto';
|
|
14
|
+
import { mkdtempSync, rmSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'twilio-routes-test-')));
|
|
19
|
+
|
|
20
|
+
mock.module('../util/platform.js', () => ({
|
|
21
|
+
getRootDir: () => testDir,
|
|
22
|
+
getDataDir: () => testDir,
|
|
23
|
+
isMacOS: () => process.platform === 'darwin',
|
|
24
|
+
isLinux: () => process.platform === 'linux',
|
|
25
|
+
isWindows: () => process.platform === 'win32',
|
|
26
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
27
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
28
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
29
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
30
|
+
ensureDataDir: () => {},
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
mock.module('../util/logger.js', () => ({
|
|
34
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
35
|
+
get: () => () => {},
|
|
36
|
+
}),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
mock.module('../config/loader.js', () => ({
|
|
40
|
+
getConfig: () => ({
|
|
41
|
+
model: 'test',
|
|
42
|
+
provider: 'test',
|
|
43
|
+
apiKeys: {},
|
|
44
|
+
memory: { enabled: false },
|
|
45
|
+
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
46
|
+
secretDetection: { enabled: false },
|
|
47
|
+
}),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// Configurable mock auth token — tests can switch between configured/unconfigured
|
|
51
|
+
let mockAuthToken: string | undefined = 'test-auth-token-for-webhooks';
|
|
52
|
+
|
|
53
|
+
mock.module('../security/secure-keys.js', () => ({
|
|
54
|
+
getSecureKey: (account: string) => {
|
|
55
|
+
if (account === 'twilio_auth_token') return mockAuthToken;
|
|
56
|
+
return undefined;
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// Use the real TwilioConversationRelayProvider (not mocked) for signature validation
|
|
61
|
+
// but mock the instance methods that hit Twilio API
|
|
62
|
+
mock.module('../calls/twilio-provider.js', () => {
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
64
|
+
const { createHmac: createHmacNode } = require('node:crypto');
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
66
|
+
const { timingSafeEqual: timingSafeEqualNode } = require('node:crypto');
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
68
|
+
const { getSecureKey } = require('../security/secure-keys.js');
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
TwilioConversationRelayProvider: class {
|
|
72
|
+
readonly name = 'twilio';
|
|
73
|
+
|
|
74
|
+
static getAuthToken(): string | null {
|
|
75
|
+
return getSecureKey('twilio_auth_token') ?? null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static verifyWebhookSignature(
|
|
79
|
+
url: string,
|
|
80
|
+
params: Record<string, string>,
|
|
81
|
+
signature: string,
|
|
82
|
+
authToken: string,
|
|
83
|
+
): boolean {
|
|
84
|
+
const sortedKeys = Object.keys(params).sort();
|
|
85
|
+
let data = url;
|
|
86
|
+
for (const key of sortedKeys) {
|
|
87
|
+
data += key + params[key];
|
|
88
|
+
}
|
|
89
|
+
const computed = createHmacNode('sha1', authToken).update(data).digest('base64');
|
|
90
|
+
const a = Buffer.from(computed);
|
|
91
|
+
const b = Buffer.from(signature);
|
|
92
|
+
if (a.length !== b.length) return false;
|
|
93
|
+
return timingSafeEqualNode(a, b);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async initiateCall() { return { callSid: 'CA_mock_test' }; }
|
|
97
|
+
async endCall() { return; }
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Configurable mock Twilio config — tests can override wssBaseUrl
|
|
103
|
+
let mockWssBaseUrl: string = 'wss://test.example.com';
|
|
104
|
+
let mockWebhookBaseUrl: string = 'https://test.example.com';
|
|
105
|
+
|
|
106
|
+
mock.module('../calls/twilio-config.js', () => ({
|
|
107
|
+
getTwilioConfig: () => ({
|
|
108
|
+
accountSid: 'AC_test',
|
|
109
|
+
authToken: 'test-auth-token-for-webhooks',
|
|
110
|
+
phoneNumber: '+15550001111',
|
|
111
|
+
webhookBaseUrl: mockWebhookBaseUrl,
|
|
112
|
+
wssBaseUrl: mockWssBaseUrl,
|
|
113
|
+
}),
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
117
|
+
import { conversations } from '../memory/schema.js';
|
|
118
|
+
import { RuntimeHttpServer } from '../runtime/http-server.js';
|
|
119
|
+
import * as callStore from '../calls/call-store.js';
|
|
120
|
+
import {
|
|
121
|
+
createCallSession,
|
|
122
|
+
updateCallSession,
|
|
123
|
+
getCallEvents,
|
|
124
|
+
buildCallbackDedupeKey,
|
|
125
|
+
claimCallback,
|
|
126
|
+
releaseCallbackClaim,
|
|
127
|
+
} from '../calls/call-store.js';
|
|
128
|
+
import { resolveRelayUrl, handleStatusCallback } from '../calls/twilio-routes.js';
|
|
129
|
+
|
|
130
|
+
initializeDb();
|
|
131
|
+
|
|
132
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
const TEST_TOKEN = 'test-bearer-token-twilio-routes';
|
|
135
|
+
const AUTH_TOKEN = 'test-auth-token-for-webhooks';
|
|
136
|
+
|
|
137
|
+
let ensuredConvIds = new Set<string>();
|
|
138
|
+
|
|
139
|
+
function ensureConversation(id: string): void {
|
|
140
|
+
if (ensuredConvIds.has(id)) return;
|
|
141
|
+
const db = getDb();
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
db.insert(conversations).values({
|
|
144
|
+
id,
|
|
145
|
+
title: `Test conversation ${id}`,
|
|
146
|
+
createdAt: now,
|
|
147
|
+
updatedAt: now,
|
|
148
|
+
}).run();
|
|
149
|
+
ensuredConvIds.add(id);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resetTables() {
|
|
153
|
+
const db = getDb();
|
|
154
|
+
db.run('DELETE FROM processed_callbacks');
|
|
155
|
+
db.run('DELETE FROM call_pending_questions');
|
|
156
|
+
db.run('DELETE FROM call_events');
|
|
157
|
+
db.run('DELETE FROM call_sessions');
|
|
158
|
+
db.run('DELETE FROM conversations');
|
|
159
|
+
ensuredConvIds = new Set();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function computeSignature(
|
|
163
|
+
url: string,
|
|
164
|
+
params: Record<string, string>,
|
|
165
|
+
authToken: string,
|
|
166
|
+
): string {
|
|
167
|
+
const sortedKeys = Object.keys(params).sort();
|
|
168
|
+
let data = url;
|
|
169
|
+
for (const key of sortedKeys) {
|
|
170
|
+
data += key + params[key];
|
|
171
|
+
}
|
|
172
|
+
return createHmac('sha1', authToken).update(data).digest('base64');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function createTestSession(convId: string, callSid: string) {
|
|
176
|
+
ensureConversation(convId);
|
|
177
|
+
const session = createCallSession({
|
|
178
|
+
conversationId: convId,
|
|
179
|
+
provider: 'twilio',
|
|
180
|
+
fromNumber: '+15550001111',
|
|
181
|
+
toNumber: '+15559998888',
|
|
182
|
+
task: 'test task',
|
|
183
|
+
});
|
|
184
|
+
updateCallSession(session.id, { providerCallSid: callSid });
|
|
185
|
+
return session;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Tests ──────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
describe('twilio webhook routes', () => {
|
|
191
|
+
let server: RuntimeHttpServer;
|
|
192
|
+
let port: number;
|
|
193
|
+
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
resetTables();
|
|
196
|
+
mockAuthToken = AUTH_TOKEN;
|
|
197
|
+
mockWssBaseUrl = 'wss://test.example.com';
|
|
198
|
+
mockWebhookBaseUrl = 'https://test.example.com';
|
|
199
|
+
delete process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
afterAll(() => {
|
|
203
|
+
resetDb();
|
|
204
|
+
try { rmSync(testDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
async function startServer(): Promise<void> {
|
|
208
|
+
port = 20000 + Math.floor(Math.random() * 1000);
|
|
209
|
+
server = new RuntimeHttpServer({ port, bearerToken: TEST_TOKEN });
|
|
210
|
+
await server.start();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function stopServer(): Promise<void> {
|
|
214
|
+
await server?.stop();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function statusUrl(): string {
|
|
218
|
+
return `http://127.0.0.1:${port}/v1/calls/twilio/status`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildFormBody(params: Record<string, string>): string {
|
|
222
|
+
return new URLSearchParams(params).toString();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function signedRequest(
|
|
226
|
+
url: string,
|
|
227
|
+
params: Record<string, string>,
|
|
228
|
+
): { body: string; headers: Record<string, string> } {
|
|
229
|
+
const body = buildFormBody(params);
|
|
230
|
+
const sig = computeSignature(url, params, AUTH_TOKEN);
|
|
231
|
+
return {
|
|
232
|
+
body,
|
|
233
|
+
headers: {
|
|
234
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
235
|
+
'X-Twilio-Signature': sig,
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Signature validation tests ─────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
describe('signature validation', () => {
|
|
243
|
+
test('valid signature returns 200', async () => {
|
|
244
|
+
await startServer();
|
|
245
|
+
createTestSession('conv-sig-1', 'CA_sig_valid');
|
|
246
|
+
const url = statusUrl();
|
|
247
|
+
const params = { CallSid: 'CA_sig_valid', CallStatus: 'completed' };
|
|
248
|
+
const { body, headers } = signedRequest(url, params);
|
|
249
|
+
|
|
250
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
251
|
+
expect(res.status).toBe(200);
|
|
252
|
+
|
|
253
|
+
await stopServer();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('missing X-Twilio-Signature header returns 403', async () => {
|
|
257
|
+
await startServer();
|
|
258
|
+
const url = statusUrl();
|
|
259
|
+
const params = { CallSid: 'CA_no_sig', CallStatus: 'completed' };
|
|
260
|
+
|
|
261
|
+
const res = await fetch(url, {
|
|
262
|
+
method: 'POST',
|
|
263
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
264
|
+
body: buildFormBody(params),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(res.status).toBe(403);
|
|
268
|
+
const body = await res.json() as { error: string };
|
|
269
|
+
expect(body.error).toBe('Forbidden');
|
|
270
|
+
|
|
271
|
+
await stopServer();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('invalid signature returns 403', async () => {
|
|
275
|
+
await startServer();
|
|
276
|
+
const url = statusUrl();
|
|
277
|
+
const params = { CallSid: 'CA_bad_sig', CallStatus: 'completed' };
|
|
278
|
+
|
|
279
|
+
const res = await fetch(url, {
|
|
280
|
+
method: 'POST',
|
|
281
|
+
headers: {
|
|
282
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
283
|
+
'X-Twilio-Signature': 'totally-wrong-signature',
|
|
284
|
+
},
|
|
285
|
+
body: buildFormBody(params),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(res.status).toBe(403);
|
|
289
|
+
|
|
290
|
+
await stopServer();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('signature computed with wrong token returns 403', async () => {
|
|
294
|
+
await startServer();
|
|
295
|
+
const url = statusUrl();
|
|
296
|
+
const params = { CallSid: 'CA_wrong_token', CallStatus: 'completed' };
|
|
297
|
+
const wrongSig = computeSignature(url, params, 'wrong-auth-token');
|
|
298
|
+
|
|
299
|
+
const res = await fetch(url, {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
headers: {
|
|
302
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
303
|
+
'X-Twilio-Signature': wrongSig,
|
|
304
|
+
},
|
|
305
|
+
body: buildFormBody(params),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(res.status).toBe(403);
|
|
309
|
+
|
|
310
|
+
await stopServer();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ── Fail-closed behavior ──────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
describe('fail-closed when auth token missing', () => {
|
|
317
|
+
test('returns 403 when auth token is not configured', async () => {
|
|
318
|
+
mockAuthToken = undefined;
|
|
319
|
+
await startServer();
|
|
320
|
+
|
|
321
|
+
const url = statusUrl();
|
|
322
|
+
const params = { CallSid: 'CA_no_token', CallStatus: 'completed' };
|
|
323
|
+
|
|
324
|
+
const res = await fetch(url, {
|
|
325
|
+
method: 'POST',
|
|
326
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
327
|
+
body: buildFormBody(params),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(res.status).toBe(403);
|
|
331
|
+
|
|
332
|
+
await stopServer();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ── TWILIO_WEBHOOK_VALIDATION_DISABLED bypass ─────────────────────
|
|
337
|
+
|
|
338
|
+
describe('validation disabled env flag', () => {
|
|
339
|
+
test('skips validation when TWILIO_WEBHOOK_VALIDATION_DISABLED=true', async () => {
|
|
340
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
341
|
+
mockAuthToken = undefined; // Token not configured, but bypass should work
|
|
342
|
+
await startServer();
|
|
343
|
+
|
|
344
|
+
createTestSession('conv-bypass-1', 'CA_bypass');
|
|
345
|
+
const url = statusUrl();
|
|
346
|
+
const params = { CallSid: 'CA_bypass', CallStatus: 'completed' };
|
|
347
|
+
|
|
348
|
+
const res = await fetch(url, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
351
|
+
body: buildFormBody(params),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(res.status).toBe(200);
|
|
355
|
+
|
|
356
|
+
await stopServer();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('does NOT skip validation when TWILIO_WEBHOOK_VALIDATION_DISABLED is set but not "true"', async () => {
|
|
360
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = '1';
|
|
361
|
+
mockAuthToken = undefined;
|
|
362
|
+
await startServer();
|
|
363
|
+
|
|
364
|
+
const url = statusUrl();
|
|
365
|
+
const params = { CallSid: 'CA_no_bypass', CallStatus: 'completed' };
|
|
366
|
+
|
|
367
|
+
const res = await fetch(url, {
|
|
368
|
+
method: 'POST',
|
|
369
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
370
|
+
body: buildFormBody(params),
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Should fail-closed: token missing and bypass not activated
|
|
374
|
+
expect(res.status).toBe(403);
|
|
375
|
+
|
|
376
|
+
await stopServer();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test('does NOT skip validation when env var is empty string', async () => {
|
|
380
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = '';
|
|
381
|
+
mockAuthToken = undefined;
|
|
382
|
+
await startServer();
|
|
383
|
+
|
|
384
|
+
const url = statusUrl();
|
|
385
|
+
const params = { CallSid: 'CA_empty_env', CallStatus: 'completed' };
|
|
386
|
+
|
|
387
|
+
const res = await fetch(url, {
|
|
388
|
+
method: 'POST',
|
|
389
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
390
|
+
body: buildFormBody(params),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
expect(res.status).toBe(403);
|
|
394
|
+
|
|
395
|
+
await stopServer();
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ── Callback idempotency / replay tests ───────────────────────────
|
|
400
|
+
|
|
401
|
+
describe('callback idempotency', () => {
|
|
402
|
+
test('replaying the same status callback does not create duplicate events', async () => {
|
|
403
|
+
await startServer();
|
|
404
|
+
const session = createTestSession('conv-idem-1', 'CA_idem_1');
|
|
405
|
+
const url = statusUrl();
|
|
406
|
+
const params = {
|
|
407
|
+
CallSid: 'CA_idem_1',
|
|
408
|
+
CallStatus: 'in-progress',
|
|
409
|
+
Timestamp: '2025-01-15T10:00:00Z',
|
|
410
|
+
};
|
|
411
|
+
const { body, headers } = signedRequest(url, params);
|
|
412
|
+
|
|
413
|
+
// First callback — should process
|
|
414
|
+
const res1 = await fetch(url, { method: 'POST', headers, body });
|
|
415
|
+
expect(res1.status).toBe(200);
|
|
416
|
+
|
|
417
|
+
// Second callback (replay) — should return 200 but not create new events
|
|
418
|
+
const res2 = await fetch(url, { method: 'POST', headers, body });
|
|
419
|
+
expect(res2.status).toBe(200);
|
|
420
|
+
|
|
421
|
+
// Verify only one event was recorded
|
|
422
|
+
const events = getCallEvents(session.id);
|
|
423
|
+
const connectedEvents = events.filter(e => e.eventType === 'call_connected');
|
|
424
|
+
expect(connectedEvents.length).toBe(1);
|
|
425
|
+
|
|
426
|
+
await stopServer();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('different statuses for the same call create separate events', async () => {
|
|
430
|
+
await startServer();
|
|
431
|
+
const session = createTestSession('conv-idem-2', 'CA_idem_2');
|
|
432
|
+
const url = statusUrl();
|
|
433
|
+
|
|
434
|
+
// First: ringing
|
|
435
|
+
const params1 = { CallSid: 'CA_idem_2', CallStatus: 'ringing', Timestamp: 'T1' };
|
|
436
|
+
const req1 = signedRequest(url, params1);
|
|
437
|
+
await fetch(url, { method: 'POST', headers: req1.headers, body: req1.body });
|
|
438
|
+
|
|
439
|
+
// Second: in-progress (different status)
|
|
440
|
+
const params2 = { CallSid: 'CA_idem_2', CallStatus: 'in-progress', Timestamp: 'T2' };
|
|
441
|
+
const req2 = signedRequest(url, params2);
|
|
442
|
+
await fetch(url, { method: 'POST', headers: req2.headers, body: req2.body });
|
|
443
|
+
|
|
444
|
+
const events = getCallEvents(session.id);
|
|
445
|
+
expect(events.length).toBe(2);
|
|
446
|
+
|
|
447
|
+
await stopServer();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('third replay of same callback is still no-op', async () => {
|
|
451
|
+
await startServer();
|
|
452
|
+
const session = createTestSession('conv-idem-3', 'CA_idem_3');
|
|
453
|
+
const url = statusUrl();
|
|
454
|
+
const params = {
|
|
455
|
+
CallSid: 'CA_idem_3',
|
|
456
|
+
CallStatus: 'completed',
|
|
457
|
+
Timestamp: '2025-01-15T11:00:00Z',
|
|
458
|
+
};
|
|
459
|
+
const { body, headers } = signedRequest(url, params);
|
|
460
|
+
|
|
461
|
+
// Process three times
|
|
462
|
+
await fetch(url, { method: 'POST', headers, body });
|
|
463
|
+
await fetch(url, { method: 'POST', headers, body });
|
|
464
|
+
await fetch(url, { method: 'POST', headers, body });
|
|
465
|
+
|
|
466
|
+
const events = getCallEvents(session.id);
|
|
467
|
+
const endedEvents = events.filter(e => e.eventType === 'call_ended');
|
|
468
|
+
expect(endedEvents.length).toBe(1);
|
|
469
|
+
|
|
470
|
+
await stopServer();
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
// ── Unknown status + malformed payload tests ──────────────────────
|
|
475
|
+
|
|
476
|
+
describe('unknown status and malformed payloads', () => {
|
|
477
|
+
test('unknown Twilio status returns 200 but does not record event', async () => {
|
|
478
|
+
await startServer();
|
|
479
|
+
const session = createTestSession('conv-unknown-1', 'CA_unknown_1');
|
|
480
|
+
const url = statusUrl();
|
|
481
|
+
const params = {
|
|
482
|
+
CallSid: 'CA_unknown_1',
|
|
483
|
+
CallStatus: 'some-future-status',
|
|
484
|
+
Timestamp: 'T1',
|
|
485
|
+
};
|
|
486
|
+
const { body, headers } = signedRequest(url, params);
|
|
487
|
+
|
|
488
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
489
|
+
expect(res.status).toBe(200);
|
|
490
|
+
|
|
491
|
+
const events = getCallEvents(session.id);
|
|
492
|
+
expect(events.length).toBe(0);
|
|
493
|
+
|
|
494
|
+
await stopServer();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test('missing CallSid returns 200 (graceful handling)', async () => {
|
|
498
|
+
await startServer();
|
|
499
|
+
const url = statusUrl();
|
|
500
|
+
const params = { CallStatus: 'completed' };
|
|
501
|
+
const { body, headers } = signedRequest(url, params);
|
|
502
|
+
|
|
503
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
504
|
+
expect(res.status).toBe(200);
|
|
505
|
+
|
|
506
|
+
await stopServer();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('missing CallStatus returns 200 (graceful handling)', async () => {
|
|
510
|
+
await startServer();
|
|
511
|
+
const url = statusUrl();
|
|
512
|
+
const params = { CallSid: 'CA_no_status' };
|
|
513
|
+
const { body, headers } = signedRequest(url, params);
|
|
514
|
+
|
|
515
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
516
|
+
expect(res.status).toBe(200);
|
|
517
|
+
|
|
518
|
+
await stopServer();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test('CallSid not matching any session returns 200 without error', async () => {
|
|
522
|
+
await startServer();
|
|
523
|
+
const url = statusUrl();
|
|
524
|
+
const params = {
|
|
525
|
+
CallSid: 'CA_nonexistent_session',
|
|
526
|
+
CallStatus: 'completed',
|
|
527
|
+
Timestamp: 'T1',
|
|
528
|
+
};
|
|
529
|
+
const { body, headers } = signedRequest(url, params);
|
|
530
|
+
|
|
531
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
532
|
+
expect(res.status).toBe(200);
|
|
533
|
+
|
|
534
|
+
await stopServer();
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ── resolveRelayUrl unit tests ──────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
describe('resolveRelayUrl', () => {
|
|
541
|
+
test('uses wssBaseUrl when explicitly set', () => {
|
|
542
|
+
const url = resolveRelayUrl('wss://ws.example.com', 'https://web.example.com');
|
|
543
|
+
expect(url).toBe('wss://ws.example.com/v1/calls/relay');
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test('falls back to webhookBaseUrl when wssBaseUrl is empty', () => {
|
|
547
|
+
const url = resolveRelayUrl('', 'https://web.example.com');
|
|
548
|
+
expect(url).toBe('wss://web.example.com/v1/calls/relay');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('falls back to webhookBaseUrl when wssBaseUrl is whitespace-only', () => {
|
|
552
|
+
const url = resolveRelayUrl(' ', 'https://web.example.com');
|
|
553
|
+
expect(url).toBe('wss://web.example.com/v1/calls/relay');
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test('normalizes http to ws in webhookBaseUrl fallback', () => {
|
|
557
|
+
const url = resolveRelayUrl('', 'http://localhost:3000');
|
|
558
|
+
expect(url).toBe('ws://localhost:3000/v1/calls/relay');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test('normalizes https to wss in webhookBaseUrl fallback', () => {
|
|
562
|
+
const url = resolveRelayUrl('', 'https://gateway.example.com');
|
|
563
|
+
expect(url).toBe('wss://gateway.example.com/v1/calls/relay');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test('strips trailing slash from wssBaseUrl', () => {
|
|
567
|
+
const url = resolveRelayUrl('wss://ws.example.com/', 'https://web.example.com');
|
|
568
|
+
expect(url).toBe('wss://ws.example.com/v1/calls/relay');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test('strips trailing slash from webhookBaseUrl fallback', () => {
|
|
572
|
+
const url = resolveRelayUrl('', 'https://web.example.com/');
|
|
573
|
+
expect(url).toBe('wss://web.example.com/v1/calls/relay');
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test('preserves wss scheme in explicitly set wssBaseUrl', () => {
|
|
577
|
+
const url = resolveRelayUrl('wss://custom-relay.example.com', 'https://web.example.com');
|
|
578
|
+
expect(url).toBe('wss://custom-relay.example.com/v1/calls/relay');
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
// ── TwiML relay URL generation ──────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
describe('voice webhook TwiML relay URL', () => {
|
|
585
|
+
function voiceUrl(sessionId: string): string {
|
|
586
|
+
return `http://127.0.0.1:${port}/v1/calls/twilio/voice-webhook?callSessionId=${sessionId}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
test('TwiML uses explicit wssBaseUrl when set', async () => {
|
|
590
|
+
mockWssBaseUrl = 'wss://explicit-ws.example.com';
|
|
591
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
592
|
+
await startServer();
|
|
593
|
+
|
|
594
|
+
const session = createTestSession('conv-twiml-1', 'CA_twiml_1');
|
|
595
|
+
const url = voiceUrl(session.id);
|
|
596
|
+
const params = { CallSid: 'CA_twiml_1' };
|
|
597
|
+
const body = buildFormBody(params);
|
|
598
|
+
|
|
599
|
+
const res = await fetch(url, {
|
|
600
|
+
method: 'POST',
|
|
601
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
602
|
+
body,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
expect(res.status).toBe(200);
|
|
606
|
+
const twiml = await res.text();
|
|
607
|
+
expect(twiml).toContain('wss://explicit-ws.example.com/v1/calls/relay');
|
|
608
|
+
|
|
609
|
+
await stopServer();
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('TwiML falls back to webhookBaseUrl when wssBaseUrl is empty', async () => {
|
|
613
|
+
mockWssBaseUrl = '';
|
|
614
|
+
mockWebhookBaseUrl = 'https://gateway.example.com';
|
|
615
|
+
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
616
|
+
await startServer();
|
|
617
|
+
|
|
618
|
+
const session = createTestSession('conv-twiml-2', 'CA_twiml_2');
|
|
619
|
+
const url = voiceUrl(session.id);
|
|
620
|
+
const params = { CallSid: 'CA_twiml_2' };
|
|
621
|
+
const body = buildFormBody(params);
|
|
622
|
+
|
|
623
|
+
const res = await fetch(url, {
|
|
624
|
+
method: 'POST',
|
|
625
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
626
|
+
body,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
expect(res.status).toBe(200);
|
|
630
|
+
const twiml = await res.text();
|
|
631
|
+
expect(twiml).toContain('wss://gateway.example.com/v1/calls/relay');
|
|
632
|
+
|
|
633
|
+
await stopServer();
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// ── Handler-level idempotency concurrency tests ─────────────────
|
|
638
|
+
|
|
639
|
+
describe('handler-level idempotency concurrency', () => {
|
|
640
|
+
test('two concurrent identical status callbacks produce exactly one event', async () => {
|
|
641
|
+
await startServer();
|
|
642
|
+
const session = createTestSession('conv-conc-1', 'CA_conc_1');
|
|
643
|
+
const url = statusUrl();
|
|
644
|
+
const params = {
|
|
645
|
+
CallSid: 'CA_conc_1',
|
|
646
|
+
CallStatus: 'in-progress',
|
|
647
|
+
Timestamp: '2025-01-20T10:00:00Z',
|
|
648
|
+
};
|
|
649
|
+
const { body, headers } = signedRequest(url, params);
|
|
650
|
+
|
|
651
|
+
// Fire two identical callbacks concurrently
|
|
652
|
+
const [res1, res2] = await Promise.all([
|
|
653
|
+
fetch(url, { method: 'POST', headers, body }),
|
|
654
|
+
fetch(url, { method: 'POST', headers, body }),
|
|
655
|
+
]);
|
|
656
|
+
|
|
657
|
+
// Both should return 200 (one processes, one is deduplicated)
|
|
658
|
+
expect(res1.status).toBe(200);
|
|
659
|
+
expect(res2.status).toBe(200);
|
|
660
|
+
|
|
661
|
+
// Only one event should be recorded despite two concurrent requests
|
|
662
|
+
const events = getCallEvents(session.id);
|
|
663
|
+
const connectedEvents = events.filter(e => e.eventType === 'call_connected');
|
|
664
|
+
expect(connectedEvents.length).toBe(1);
|
|
665
|
+
|
|
666
|
+
await stopServer();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test('three concurrent identical status callbacks still produce exactly one event', async () => {
|
|
670
|
+
await startServer();
|
|
671
|
+
const session = createTestSession('conv-conc-2', 'CA_conc_2');
|
|
672
|
+
const url = statusUrl();
|
|
673
|
+
const params = {
|
|
674
|
+
CallSid: 'CA_conc_2',
|
|
675
|
+
CallStatus: 'completed',
|
|
676
|
+
Timestamp: '2025-01-20T11:00:00Z',
|
|
677
|
+
};
|
|
678
|
+
const { body, headers } = signedRequest(url, params);
|
|
679
|
+
|
|
680
|
+
// Fire three identical callbacks concurrently
|
|
681
|
+
const [res1, res2, res3] = await Promise.all([
|
|
682
|
+
fetch(url, { method: 'POST', headers, body }),
|
|
683
|
+
fetch(url, { method: 'POST', headers, body }),
|
|
684
|
+
fetch(url, { method: 'POST', headers, body }),
|
|
685
|
+
]);
|
|
686
|
+
|
|
687
|
+
expect(res1.status).toBe(200);
|
|
688
|
+
expect(res2.status).toBe(200);
|
|
689
|
+
expect(res3.status).toBe(200);
|
|
690
|
+
|
|
691
|
+
const events = getCallEvents(session.id);
|
|
692
|
+
const endedEvents = events.filter(e => e.eventType === 'call_ended');
|
|
693
|
+
expect(endedEvents.length).toBe(1);
|
|
694
|
+
|
|
695
|
+
await stopServer();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test('processing failure releases claim and allows successful retry', async () => {
|
|
699
|
+
await startServer();
|
|
700
|
+
const session = createTestSession('conv-conc-3', 'CA_conc_3');
|
|
701
|
+
const url = statusUrl();
|
|
702
|
+
const params = {
|
|
703
|
+
CallSid: 'CA_conc_3',
|
|
704
|
+
CallStatus: 'in-progress',
|
|
705
|
+
Timestamp: '2025-01-20T12:00:00Z',
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// Save original before spying so we can delegate on retry
|
|
709
|
+
const originalRecordCallEvent = callStore.recordCallEvent;
|
|
710
|
+
|
|
711
|
+
// Make recordCallEvent throw on the first call to exercise the handler's
|
|
712
|
+
// real catch path (twilio-routes.ts:217), which calls
|
|
713
|
+
// releaseCallbackClaim before re-throwing.
|
|
714
|
+
let shouldThrow = true;
|
|
715
|
+
const spy = spyOn(callStore, 'recordCallEvent').mockImplementation((...args: Parameters<typeof callStore.recordCallEvent>) => {
|
|
716
|
+
if (shouldThrow) {
|
|
717
|
+
shouldThrow = false;
|
|
718
|
+
throw new Error('Simulated side-effect failure');
|
|
719
|
+
}
|
|
720
|
+
spy.mockRestore();
|
|
721
|
+
return originalRecordCallEvent(...args);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// Call handleStatusCallback directly (not through Bun.serve) so we can
|
|
725
|
+
// catch the re-thrown error without Bun's HTTP server swallowing it.
|
|
726
|
+
const formBody = new URLSearchParams(params).toString();
|
|
727
|
+
const directReq = new Request(url, {
|
|
728
|
+
method: 'POST',
|
|
729
|
+
body: formBody,
|
|
730
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
// The handler should claim → throw in recordCallEvent → catch releases claim → re-throw
|
|
734
|
+
let handlerThrew = false;
|
|
735
|
+
try {
|
|
736
|
+
await handleStatusCallback(directReq);
|
|
737
|
+
} catch (err) {
|
|
738
|
+
handlerThrew = true;
|
|
739
|
+
expect((err as Error).message).toBe('Simulated side-effect failure');
|
|
740
|
+
}
|
|
741
|
+
expect(handlerThrew).toBe(true);
|
|
742
|
+
|
|
743
|
+
// No events recorded (the failed attempt rolled back via releaseCallbackClaim)
|
|
744
|
+
const eventsAfterFailure = getCallEvents(session.id);
|
|
745
|
+
expect(eventsAfterFailure.length).toBe(0);
|
|
746
|
+
|
|
747
|
+
// Retry via the real HTTP handler — should succeed because the catch block
|
|
748
|
+
// released the claim, allowing a fresh claim on retry.
|
|
749
|
+
const { body, headers } = signedRequest(url, params);
|
|
750
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
751
|
+
expect(res.status).toBe(200);
|
|
752
|
+
|
|
753
|
+
// Now exactly one event should exist from the successful retry
|
|
754
|
+
const eventsAfterRetry = getCallEvents(session.id);
|
|
755
|
+
const connectedEvents = eventsAfterRetry.filter(e => e.eventType === 'call_connected');
|
|
756
|
+
expect(connectedEvents.length).toBe(1);
|
|
757
|
+
|
|
758
|
+
await stopServer();
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test('permanently claimed callback cannot be retried', async () => {
|
|
762
|
+
await startServer();
|
|
763
|
+
const session = createTestSession('conv-conc-4', 'CA_conc_4');
|
|
764
|
+
const url = statusUrl();
|
|
765
|
+
const params = {
|
|
766
|
+
CallSid: 'CA_conc_4',
|
|
767
|
+
CallStatus: 'completed',
|
|
768
|
+
Timestamp: '2025-01-20T13:00:00Z',
|
|
769
|
+
};
|
|
770
|
+
const { body, headers } = signedRequest(url, params);
|
|
771
|
+
|
|
772
|
+
// First request processes successfully and finalizes the claim
|
|
773
|
+
const res1 = await fetch(url, { method: 'POST', headers, body });
|
|
774
|
+
expect(res1.status).toBe(200);
|
|
775
|
+
|
|
776
|
+
const events1 = getCallEvents(session.id);
|
|
777
|
+
expect(events1.filter(e => e.eventType === 'call_ended').length).toBe(1);
|
|
778
|
+
|
|
779
|
+
// Second request (retry) — should be deduplicated, no new events
|
|
780
|
+
const res2 = await fetch(url, { method: 'POST', headers, body });
|
|
781
|
+
expect(res2.status).toBe(200);
|
|
782
|
+
|
|
783
|
+
const events2 = getCallEvents(session.id);
|
|
784
|
+
expect(events2.filter(e => e.eventType === 'call_ended').length).toBe(1);
|
|
785
|
+
|
|
786
|
+
await stopServer();
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
});
|