@vellumai/assistant 0.4.3 → 0.4.4

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.
Files changed (183) hide show
  1. package/.env.example +3 -0
  2. package/ARCHITECTURE.md +40 -3
  3. package/README.md +43 -35
  4. package/package.json +1 -1
  5. package/scripts/ipc/generate-swift.ts +1 -0
  6. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
  7. package/src/__tests__/actor-token-service.test.ts +1099 -0
  8. package/src/__tests__/agent-loop.test.ts +51 -0
  9. package/src/__tests__/approval-routes-http.test.ts +2 -0
  10. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
  11. package/src/__tests__/assistant-id-boundary-guard.test.ts +125 -0
  12. package/src/__tests__/call-controller.test.ts +49 -0
  13. package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
  14. package/src/__tests__/call-pointer-messages.test.ts +93 -3
  15. package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
  16. package/src/__tests__/callback-handoff-copy.test.ts +186 -0
  17. package/src/__tests__/channel-approval-routes.test.ts +133 -12
  18. package/src/__tests__/channel-guardian.test.ts +0 -87
  19. package/src/__tests__/channel-readiness-service.test.ts +10 -16
  20. package/src/__tests__/checker.test.ts +33 -12
  21. package/src/__tests__/config-schema.test.ts +4 -0
  22. package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
  23. package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
  24. package/src/__tests__/conversation-routes.test.ts +12 -3
  25. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  26. package/src/__tests__/daemon-server-session-init.test.ts +4 -0
  27. package/src/__tests__/guardian-actions-endpoint.test.ts +19 -14
  28. package/src/__tests__/guardian-dispatch.test.ts +8 -0
  29. package/src/__tests__/guardian-outbound-http.test.ts +4 -4
  30. package/src/__tests__/guardian-question-mode.test.ts +200 -0
  31. package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
  32. package/src/__tests__/guardian-routing-state.test.ts +525 -0
  33. package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
  34. package/src/__tests__/handlers-telegram-config.test.ts +0 -83
  35. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
  36. package/src/__tests__/headless-browser-navigate.test.ts +2 -0
  37. package/src/__tests__/ipc-snapshot.test.ts +18 -51
  38. package/src/__tests__/non-member-access-request.test.ts +131 -8
  39. package/src/__tests__/notification-decision-fallback.test.ts +129 -4
  40. package/src/__tests__/notification-decision-strategy.test.ts +62 -2
  41. package/src/__tests__/notification-guardian-path.test.ts +3 -0
  42. package/src/__tests__/recording-intent-handler.test.ts +1 -0
  43. package/src/__tests__/relay-server.test.ts +841 -39
  44. package/src/__tests__/send-endpoint-busy.test.ts +5 -0
  45. package/src/__tests__/session-agent-loop.test.ts +1 -0
  46. package/src/__tests__/session-confirmation-signals.test.ts +523 -0
  47. package/src/__tests__/session-init.benchmark.test.ts +0 -1
  48. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -1
  49. package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
  50. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
  51. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
  52. package/src/__tests__/tool-executor.test.ts +21 -2
  53. package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
  54. package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
  55. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
  56. package/src/__tests__/twilio-config.test.ts +2 -13
  57. package/src/agent/loop.ts +1 -1
  58. package/src/approvals/guardian-decision-primitive.ts +10 -2
  59. package/src/approvals/guardian-request-resolvers.ts +128 -9
  60. package/src/calls/call-constants.ts +21 -0
  61. package/src/calls/call-controller.ts +9 -2
  62. package/src/calls/call-domain.ts +28 -7
  63. package/src/calls/call-pointer-message-composer.ts +154 -0
  64. package/src/calls/call-pointer-messages.ts +106 -27
  65. package/src/calls/guardian-dispatch.ts +4 -2
  66. package/src/calls/relay-server.ts +424 -12
  67. package/src/calls/twilio-config.ts +4 -11
  68. package/src/calls/twilio-routes.ts +1 -1
  69. package/src/calls/types.ts +3 -1
  70. package/src/cli.ts +5 -4
  71. package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
  72. package/src/config/bundled-skills/app-builder/SKILL.md +146 -10
  73. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
  74. package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
  75. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
  76. package/src/config/bundled-skills/messaging/SKILL.md +61 -12
  77. package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
  78. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
  79. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
  80. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
  81. package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
  82. package/src/config/bundled-skills/twitter/SKILL.md +3 -3
  83. package/src/config/bundled-skills/vercel-token-setup/SKILL.md +1 -0
  84. package/src/config/calls-schema.ts +24 -0
  85. package/src/config/env.ts +22 -0
  86. package/src/config/feature-flag-registry.json +8 -0
  87. package/src/config/schema.ts +2 -2
  88. package/src/config/skills.ts +11 -0
  89. package/src/config/system-prompt.ts +11 -1
  90. package/src/config/templates/SOUL.md +2 -0
  91. package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
  92. package/src/config/vellum-skills/trusted-contacts/SKILL.md +10 -9
  93. package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
  94. package/src/daemon/call-pointer-generators.ts +59 -0
  95. package/src/daemon/computer-use-session.ts +2 -5
  96. package/src/daemon/handlers/apps.ts +76 -20
  97. package/src/daemon/handlers/config-channels.ts +5 -55
  98. package/src/daemon/handlers/config-inbox.ts +9 -3
  99. package/src/daemon/handlers/config-ingress.ts +28 -3
  100. package/src/daemon/handlers/config-telegram.ts +12 -0
  101. package/src/daemon/handlers/config.ts +2 -6
  102. package/src/daemon/handlers/pairing.ts +2 -0
  103. package/src/daemon/handlers/sessions.ts +48 -3
  104. package/src/daemon/handlers/shared.ts +17 -2
  105. package/src/daemon/ipc-contract/integrations.ts +1 -99
  106. package/src/daemon/ipc-contract/messages.ts +47 -1
  107. package/src/daemon/ipc-contract/notifications.ts +11 -0
  108. package/src/daemon/ipc-contract-inventory.json +2 -4
  109. package/src/daemon/lifecycle.ts +17 -0
  110. package/src/daemon/server.ts +14 -1
  111. package/src/daemon/session-agent-loop-handlers.ts +20 -0
  112. package/src/daemon/session-agent-loop.ts +22 -11
  113. package/src/daemon/session-lifecycle.ts +1 -1
  114. package/src/daemon/session-process.ts +11 -1
  115. package/src/daemon/session-runtime-assembly.ts +3 -0
  116. package/src/daemon/session-surfaces.ts +3 -2
  117. package/src/daemon/session.ts +88 -1
  118. package/src/daemon/tool-side-effects.ts +22 -0
  119. package/src/home-base/prebuilt/brain-graph.html +1483 -0
  120. package/src/home-base/prebuilt/index.html +40 -0
  121. package/src/inbound/platform-callback-registration.ts +157 -0
  122. package/src/memory/canonical-guardian-store.ts +1 -1
  123. package/src/memory/db-init.ts +4 -0
  124. package/src/memory/migrations/038-actor-token-records.ts +39 -0
  125. package/src/memory/migrations/index.ts +1 -0
  126. package/src/memory/schema.ts +16 -0
  127. package/src/messaging/provider-types.ts +24 -0
  128. package/src/messaging/provider.ts +7 -0
  129. package/src/messaging/providers/gmail/adapter.ts +127 -0
  130. package/src/messaging/providers/sms/adapter.ts +40 -37
  131. package/src/notifications/adapters/macos.ts +45 -2
  132. package/src/notifications/broadcaster.ts +16 -0
  133. package/src/notifications/copy-composer.ts +39 -1
  134. package/src/notifications/decision-engine.ts +22 -9
  135. package/src/notifications/destination-resolver.ts +16 -2
  136. package/src/notifications/emit-signal.ts +16 -8
  137. package/src/notifications/guardian-question-mode.ts +419 -0
  138. package/src/notifications/signal.ts +14 -3
  139. package/src/permissions/checker.ts +13 -1
  140. package/src/permissions/prompter.ts +14 -0
  141. package/src/providers/anthropic/client.ts +20 -0
  142. package/src/providers/provider-send-message.ts +15 -3
  143. package/src/runtime/access-request-helper.ts +71 -1
  144. package/src/runtime/actor-token-service.ts +234 -0
  145. package/src/runtime/actor-token-store.ts +236 -0
  146. package/src/runtime/channel-approvals.ts +5 -3
  147. package/src/runtime/channel-readiness-service.ts +23 -64
  148. package/src/runtime/channel-readiness-types.ts +3 -4
  149. package/src/runtime/channel-retry-sweep.ts +4 -1
  150. package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
  151. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  152. package/src/runtime/guardian-context-resolver.ts +82 -0
  153. package/src/runtime/guardian-outbound-actions.ts +0 -3
  154. package/src/runtime/guardian-reply-router.ts +67 -30
  155. package/src/runtime/guardian-vellum-migration.ts +57 -0
  156. package/src/runtime/http-server.ts +65 -12
  157. package/src/runtime/http-types.ts +13 -0
  158. package/src/runtime/invite-redemption-service.ts +8 -0
  159. package/src/runtime/local-actor-identity.ts +76 -0
  160. package/src/runtime/middleware/actor-token.ts +271 -0
  161. package/src/runtime/routes/approval-routes.ts +82 -7
  162. package/src/runtime/routes/brain-graph-routes.ts +222 -0
  163. package/src/runtime/routes/channel-readiness-routes.ts +71 -0
  164. package/src/runtime/routes/conversation-routes.ts +140 -52
  165. package/src/runtime/routes/events-routes.ts +20 -5
  166. package/src/runtime/routes/guardian-action-routes.ts +45 -3
  167. package/src/runtime/routes/guardian-approval-interception.ts +29 -0
  168. package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
  169. package/src/runtime/routes/inbound-message-handler.ts +143 -2
  170. package/src/runtime/routes/integration-routes.ts +7 -15
  171. package/src/runtime/routes/pairing-routes.ts +163 -0
  172. package/src/runtime/routes/twilio-routes.ts +934 -0
  173. package/src/runtime/tool-grant-request-helper.ts +3 -1
  174. package/src/security/oauth2.ts +27 -2
  175. package/src/security/token-manager.ts +46 -10
  176. package/src/tools/browser/browser-execution.ts +4 -3
  177. package/src/tools/browser/browser-handoff.ts +10 -18
  178. package/src/tools/browser/browser-manager.ts +80 -25
  179. package/src/tools/browser/browser-screencast.ts +35 -119
  180. package/src/tools/permission-checker.ts +15 -4
  181. package/src/tools/tool-approval-handler.ts +242 -18
  182. package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
  183. package/src/daemon/handlers/config-twilio.ts +0 -1082
@@ -1,1928 +0,0 @@
1
- import { mkdtempSync } from 'node:fs';
2
- import * as net from 'node:net';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
-
6
- import { beforeEach,describe, expect, mock, test } from 'bun:test';
7
-
8
- const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
9
-
10
- // Track loadRawConfig / saveRawConfig calls
11
- let rawConfigStore: Record<string, unknown> = {};
12
-
13
- mock.module('../config/loader.js', () => ({
14
- getConfig: () => ({
15
- ui: {},
16
- ...rawConfigStore }),
17
- loadConfig: () => ({ ...rawConfigStore }),
18
- loadRawConfig: () => ({ ...rawConfigStore }),
19
- saveRawConfig: (cfg: Record<string, unknown>) => {
20
- rawConfigStore = { ...cfg };
21
- },
22
- saveConfig: () => {},
23
- invalidateConfigCache: () => {},
24
- }));
25
-
26
- // Provide a thin mock of public-ingress-urls that computes real-looking
27
- // webhook URLs from the raw config store so that both getTwilioConfig()
28
- // and the syncTwilioWebhooks() helper used by ingress tests work correctly.
29
- mock.module('../inbound/public-ingress-urls.js', () => {
30
- function getBase(config: Record<string, unknown>): string {
31
- const ingress = (config?.ingress ?? {}) as Record<string, unknown>;
32
- const url = (ingress.publicBaseUrl as string) ?? '';
33
- if (!url) throw new Error('No public ingress URL configured');
34
- return url;
35
- }
36
- return {
37
- getPublicBaseUrl: (config: Record<string, unknown>) => getBase(config),
38
- getTwilioRelayUrl: (config: Record<string, unknown>) => {
39
- const base = getBase(config);
40
- return base.replace(/^http(s?)/, 'ws$1') + '/webhooks/twilio/relay';
41
- },
42
- getTwilioVoiceWebhookUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/voice',
43
- getTwilioStatusCallbackUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/status',
44
- getTwilioSmsWebhookUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/sms',
45
- };
46
- });
47
-
48
- mock.module('../util/platform.js', () => ({
49
- getRootDir: () => testDir,
50
- getDataDir: () => testDir,
51
- getIpcBlobDir: () => join(testDir, 'ipc-blobs'),
52
- isMacOS: () => process.platform === 'darwin',
53
- isLinux: () => process.platform === 'linux',
54
- isWindows: () => process.platform === 'win32',
55
- getSocketPath: () => join(testDir, 'test.sock'),
56
- getPidPath: () => join(testDir, 'test.pid'),
57
- getDbPath: () => join(testDir, 'test.db'),
58
- getLogPath: () => join(testDir, 'test.log'),
59
- ensureDataDir: () => {},
60
- readHttpToken: () => undefined,
61
- }));
62
-
63
- mock.module('../util/logger.js', () => ({
64
- getLogger: () => ({
65
- info: () => {},
66
- warn: () => {},
67
- error: () => {},
68
- debug: () => {},
69
- trace: () => {},
70
- fatal: () => {},
71
- isDebug: () => false,
72
- child: () => ({
73
- info: () => {},
74
- warn: () => {},
75
- error: () => {},
76
- debug: () => {},
77
- isDebug: () => false,
78
- }),
79
- }),
80
- }));
81
-
82
- // Mock secure key storage
83
- let secureKeyStore: Record<string, string> = {};
84
- let setSecureKeyOverride: ((account: string, value: string) => boolean) | null = null;
85
-
86
- mock.module('../security/secure-keys.js', () => ({
87
- getSecureKey: (account: string) => secureKeyStore[account] ?? undefined,
88
- setSecureKey: (account: string, value: string) => {
89
- if (setSecureKeyOverride) return setSecureKeyOverride(account, value);
90
- secureKeyStore[account] = value;
91
- return true;
92
- },
93
- deleteSecureKey: (account: string) => {
94
- if (account in secureKeyStore) {
95
- delete secureKeyStore[account];
96
- return true;
97
- }
98
- return false;
99
- },
100
- listSecureKeys: () => Object.keys(secureKeyStore),
101
- getBackendType: () => 'encrypted',
102
- isDowngradedFromKeychain: () => false,
103
- _resetBackend: () => {},
104
- _setBackend: () => {},
105
- }));
106
-
107
- // Mock credential metadata store
108
- let credentialMetadataStore: Array<{ service: string; field: string; accountInfo?: string }> = [];
109
- const deletedMetadata: Array<{ service: string; field: string }> = [];
110
-
111
- mock.module('../tools/credentials/metadata-store.js', () => ({
112
- getCredentialMetadata: (service: string, field: string) =>
113
- credentialMetadataStore.find((m) => m.service === service && m.field === field) ?? undefined,
114
- upsertCredentialMetadata: (service: string, field: string, policy?: Record<string, unknown>) => {
115
- const existing = credentialMetadataStore.find((m) => m.service === service && m.field === field);
116
- if (existing) {
117
- if (policy?.accountInfo !== undefined) existing.accountInfo = policy.accountInfo as string;
118
- return existing;
119
- }
120
- const record = { service, field, accountInfo: policy?.accountInfo as string | undefined };
121
- credentialMetadataStore.push(record);
122
- return record;
123
- },
124
- deleteCredentialMetadata: (service: string, field: string) => {
125
- deletedMetadata.push({ service, field });
126
- const idx = credentialMetadataStore.findIndex((m) => m.service === service && m.field === field);
127
- if (idx !== -1) {
128
- credentialMetadataStore.splice(idx, 1);
129
- return true;
130
- }
131
- return false;
132
- },
133
- listCredentialMetadata: () => credentialMetadataStore,
134
- assertMetadataWritable: () => {},
135
- _setMetadataPath: () => {},
136
- }));
137
-
138
- // Mock fetch for Twilio API validation
139
- const originalFetch = globalThis.fetch;
140
-
141
- import { getTwilioConfig } from '../calls/twilio-config.js';
142
- import type { HandlerContext } from '../daemon/handlers.js';
143
- import { handleIngressConfig,handleTwilioConfig } from '../daemon/handlers/config.js';
144
- import type {
145
- IngressConfigRequest,
146
- ServerMessage,
147
- TwilioConfigRequest,
148
- } from '../daemon/ipc-contract.js';
149
- import { DebouncerMap } from '../util/debounce.js';
150
-
151
- function createTestContext(): { ctx: HandlerContext; sent: ServerMessage[] } {
152
- const sent: ServerMessage[] = [];
153
- const ctx: HandlerContext = {
154
- sessions: new Map(),
155
- socketToSession: new Map(),
156
- cuSessions: new Map(),
157
- socketToCuSession: new Map(),
158
- cuObservationParseSequence: new Map(),
159
- socketSandboxOverride: new Map(),
160
- sharedRequestTimestamps: [],
161
- debounceTimers: new DebouncerMap({ defaultDelayMs: 200 }),
162
- suppressConfigReload: false,
163
- setSuppressConfigReload: () => {},
164
- updateConfigFingerprint: () => {},
165
- send: (_socket, msg) => { sent.push(msg); },
166
- broadcast: () => {},
167
- clearAllSessions: () => 0,
168
- getOrCreateSession: () => { throw new Error('not implemented'); },
169
- touchSession: () => {},
170
- };
171
- return { ctx, sent };
172
- }
173
-
174
- describe('Twilio config handler', () => {
175
- beforeEach(() => {
176
- rawConfigStore = {};
177
- secureKeyStore = {};
178
- setSecureKeyOverride = null;
179
- credentialMetadataStore = [];
180
- deletedMetadata.length = 0;
181
- globalThis.fetch = originalFetch;
182
- });
183
-
184
- // ── get ──────────────────────────────────────────────────────────────
185
-
186
- test('get action returns correct state when not configured', async () => {
187
- const msg: TwilioConfigRequest = {
188
- type: 'twilio_config',
189
- action: 'get',
190
- };
191
-
192
- const { ctx, sent } = createTestContext();
193
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
194
-
195
- expect(sent).toHaveLength(1);
196
- const res = sent[0] as { type: string; success: boolean; hasCredentials: boolean; phoneNumber?: string };
197
- expect(res.type).toBe('twilio_config_response');
198
- expect(res.success).toBe(true);
199
- expect(res.hasCredentials).toBe(false);
200
- expect(res.phoneNumber).toBeUndefined();
201
- });
202
-
203
- test('get action returns correct state when fully configured', async () => {
204
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
205
- secureKeyStore['credential:twilio:auth_token'] = 'auth_token_value';
206
- rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
207
-
208
- const msg: TwilioConfigRequest = {
209
- type: 'twilio_config',
210
- action: 'get',
211
- };
212
-
213
- const { ctx, sent } = createTestContext();
214
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
215
-
216
- expect(sent).toHaveLength(1);
217
- const res = sent[0] as { type: string; success: boolean; hasCredentials: boolean; phoneNumber?: string };
218
- expect(res.type).toBe('twilio_config_response');
219
- expect(res.success).toBe(true);
220
- expect(res.hasCredentials).toBe(true);
221
- expect(res.phoneNumber).toBe('+15551234567');
222
- });
223
-
224
- // ── set_credentials ─────────────────────────────────────────────────
225
-
226
- test('set_credentials validates and stores credentials', async () => {
227
- globalThis.fetch = (async (url: string | URL | Request) => {
228
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
229
- if (urlStr.includes('api.twilio.com') && urlStr.includes('/Accounts/')) {
230
- return new Response(JSON.stringify({
231
- sid: 'AC1234567890abcdef1234567890abcdef',
232
- friendly_name: 'Test Account',
233
- status: 'active',
234
- }), { status: 200 });
235
- }
236
- return originalFetch(url);
237
- }) as typeof fetch;
238
-
239
- const msg: TwilioConfigRequest = {
240
- type: 'twilio_config',
241
- action: 'set_credentials',
242
- accountSid: 'AC1234567890abcdef1234567890abcdef',
243
- authToken: 'test_auth_token',
244
- };
245
-
246
- const { ctx, sent } = createTestContext();
247
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
248
-
249
- expect(sent).toHaveLength(1);
250
- const res = sent[0] as { type: string; success: boolean; hasCredentials: boolean };
251
- expect(res.type).toBe('twilio_config_response');
252
- expect(res.success).toBe(true);
253
- expect(res.hasCredentials).toBe(true);
254
-
255
- // Verify credentials were stored
256
- expect(secureKeyStore['credential:twilio:account_sid']).toBe('AC1234567890abcdef1234567890abcdef');
257
- expect(secureKeyStore['credential:twilio:auth_token']).toBe('test_auth_token');
258
- // Verify metadata was stored
259
- expect(credentialMetadataStore.find((m) => m.service === 'twilio' && m.field === 'account_sid')).toBeDefined();
260
- expect(credentialMetadataStore.find((m) => m.service === 'twilio' && m.field === 'auth_token')).toBeDefined();
261
- });
262
-
263
- test('set_credentials returns error when accountSid is missing', async () => {
264
- const msg: TwilioConfigRequest = {
265
- type: 'twilio_config',
266
- action: 'set_credentials',
267
- authToken: 'test_auth_token',
268
- };
269
-
270
- const { ctx, sent } = createTestContext();
271
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
272
-
273
- expect(sent).toHaveLength(1);
274
- const res = sent[0] as { type: string; success: boolean; error?: string };
275
- expect(res.success).toBe(false);
276
- expect(res.error).toContain('accountSid and authToken are required');
277
- });
278
-
279
- test('set_credentials returns error when authToken is missing', async () => {
280
- const msg: TwilioConfigRequest = {
281
- type: 'twilio_config',
282
- action: 'set_credentials',
283
- accountSid: 'AC1234567890abcdef1234567890abcdef',
284
- };
285
-
286
- const { ctx, sent } = createTestContext();
287
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
288
-
289
- expect(sent).toHaveLength(1);
290
- const res = sent[0] as { type: string; success: boolean; error?: string };
291
- expect(res.success).toBe(false);
292
- expect(res.error).toContain('accountSid and authToken are required');
293
- });
294
-
295
- test('set_credentials returns error when Twilio API rejects credentials', async () => {
296
- globalThis.fetch = (async (url: string | URL | Request) => {
297
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
298
- if (urlStr.includes('api.twilio.com') && urlStr.includes('/Accounts/')) {
299
- return new Response(JSON.stringify({
300
- code: 20003,
301
- message: 'Authenticate',
302
- }), { status: 401 });
303
- }
304
- return originalFetch(url);
305
- }) as typeof fetch;
306
-
307
- const msg: TwilioConfigRequest = {
308
- type: 'twilio_config',
309
- action: 'set_credentials',
310
- accountSid: 'AC_invalid',
311
- authToken: 'bad_token',
312
- };
313
-
314
- const { ctx, sent } = createTestContext();
315
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
316
-
317
- expect(sent).toHaveLength(1);
318
- const res = sent[0] as { type: string; success: boolean; error?: string };
319
- expect(res.success).toBe(false);
320
- expect(res.error).toContain('Twilio API validation failed');
321
-
322
- // Verify credentials were NOT stored
323
- expect(secureKeyStore['credential:twilio:account_sid']).toBeUndefined();
324
- expect(secureKeyStore['credential:twilio:auth_token']).toBeUndefined();
325
- });
326
-
327
- test('set_credentials handles network error', async () => {
328
- globalThis.fetch = (async () => {
329
- throw new Error('Network error: ECONNREFUSED');
330
- }) as unknown as typeof fetch;
331
-
332
- const msg: TwilioConfigRequest = {
333
- type: 'twilio_config',
334
- action: 'set_credentials',
335
- accountSid: 'AC1234567890abcdef1234567890abcdef',
336
- authToken: 'test_auth_token',
337
- };
338
-
339
- const { ctx, sent } = createTestContext();
340
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
341
-
342
- expect(sent).toHaveLength(1);
343
- const res = sent[0] as { type: string; success: boolean; error?: string };
344
- expect(res.success).toBe(false);
345
- expect(res.error).toContain('Failed to validate Twilio credentials');
346
- expect(res.error).toContain('ECONNREFUSED');
347
- });
348
-
349
- test('set_credentials rolls back account_sid when auth_token storage fails', async () => {
350
- setSecureKeyOverride = (account: string, value: string) => {
351
- if (account === 'credential:twilio:auth_token') return false;
352
- secureKeyStore[account] = value;
353
- return true;
354
- };
355
-
356
- globalThis.fetch = (async (url: string | URL | Request) => {
357
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
358
- if (urlStr.includes('api.twilio.com') && urlStr.includes('/Accounts/')) {
359
- return new Response(JSON.stringify({ sid: 'AC123', status: 'active' }), { status: 200 });
360
- }
361
- return originalFetch(url);
362
- }) as typeof fetch;
363
-
364
- const msg: TwilioConfigRequest = {
365
- type: 'twilio_config',
366
- action: 'set_credentials',
367
- accountSid: 'AC1234567890abcdef1234567890abcdef',
368
- authToken: 'test_auth_token',
369
- };
370
-
371
- const { ctx, sent } = createTestContext();
372
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
373
-
374
- expect(sent).toHaveLength(1);
375
- const res = sent[0] as { type: string; success: boolean; error?: string };
376
- expect(res.success).toBe(false);
377
- expect(res.error).toContain('Failed to store Auth Token');
378
-
379
- // Account SID should have been rolled back
380
- expect(secureKeyStore['credential:twilio:account_sid']).toBeUndefined();
381
- });
382
-
383
- test('set_credentials fails when account_sid storage fails', async () => {
384
- setSecureKeyOverride = () => false;
385
-
386
- globalThis.fetch = (async (url: string | URL | Request) => {
387
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
388
- if (urlStr.includes('api.twilio.com') && urlStr.includes('/Accounts/')) {
389
- return new Response(JSON.stringify({ sid: 'AC123', status: 'active' }), { status: 200 });
390
- }
391
- return originalFetch(url);
392
- }) as typeof fetch;
393
-
394
- const msg: TwilioConfigRequest = {
395
- type: 'twilio_config',
396
- action: 'set_credentials',
397
- accountSid: 'AC1234567890abcdef1234567890abcdef',
398
- authToken: 'test_auth_token',
399
- };
400
-
401
- const { ctx, sent } = createTestContext();
402
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
403
-
404
- expect(sent).toHaveLength(1);
405
- const res = sent[0] as { type: string; success: boolean; error?: string };
406
- expect(res.success).toBe(false);
407
- expect(res.error).toContain('Failed to store Account SID');
408
- });
409
-
410
- // ── clear_credentials ───────────────────────────────────────────────
411
-
412
- test('clear_credentials removes stored credentials but preserves phone number', async () => {
413
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
414
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
415
- secureKeyStore['credential:twilio:phone_number'] = '+15551234567';
416
- rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
417
- credentialMetadataStore.push({ service: 'twilio', field: 'account_sid' });
418
- credentialMetadataStore.push({ service: 'twilio', field: 'auth_token' });
419
-
420
- const msg: TwilioConfigRequest = {
421
- type: 'twilio_config',
422
- action: 'clear_credentials',
423
- };
424
-
425
- const { ctx, sent } = createTestContext();
426
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
427
-
428
- expect(sent).toHaveLength(1);
429
- const res = sent[0] as { type: string; success: boolean; hasCredentials: boolean };
430
- expect(res.type).toBe('twilio_config_response');
431
- expect(res.success).toBe(true);
432
- expect(res.hasCredentials).toBe(false);
433
-
434
- // Verify auth credentials were cleaned up
435
- expect(secureKeyStore['credential:twilio:account_sid']).toBeUndefined();
436
- expect(secureKeyStore['credential:twilio:auth_token']).toBeUndefined();
437
- expect(deletedMetadata).toContainEqual({ service: 'twilio', field: 'account_sid' });
438
- expect(deletedMetadata).toContainEqual({ service: 'twilio', field: 'auth_token' });
439
-
440
- // Verify phone number is preserved in both stores
441
- expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15551234567');
442
- expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15551234567');
443
- });
444
-
445
- test('clear_credentials is idempotent when no credentials exist', async () => {
446
- const msg: TwilioConfigRequest = {
447
- type: 'twilio_config',
448
- action: 'clear_credentials',
449
- };
450
-
451
- const { ctx, sent } = createTestContext();
452
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
453
-
454
- expect(sent).toHaveLength(1);
455
- const res = sent[0] as { type: string; success: boolean; hasCredentials: boolean };
456
- expect(res.success).toBe(true);
457
- expect(res.hasCredentials).toBe(false);
458
- });
459
-
460
- // ── Phone number resolution order ──────────────────────────────────
461
-
462
- test('getTwilioConfig resolves phone number from config when secure key also present', () => {
463
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
464
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
465
- secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
466
- rawConfigStore = {
467
- sms: { phoneNumber: '+15551234567' },
468
- ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
469
- };
470
-
471
- // Clean env var to test config-only resolution
472
- const savedEnv = process.env.TWILIO_PHONE_NUMBER;
473
- delete process.env.TWILIO_PHONE_NUMBER;
474
-
475
- try {
476
- const config = getTwilioConfig();
477
- // Config value (+15551234567) should take priority over secure key (+15559999999)
478
- expect(config.phoneNumber).toBe('+15551234567');
479
- } finally {
480
- if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv;
481
- }
482
- });
483
-
484
- test('getTwilioConfig falls back to secure key when config has no phone number', () => {
485
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
486
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
487
- secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
488
- rawConfigStore = {
489
- ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
490
- };
491
-
492
- const savedEnv = process.env.TWILIO_PHONE_NUMBER;
493
- delete process.env.TWILIO_PHONE_NUMBER;
494
-
495
- try {
496
- const config = getTwilioConfig();
497
- // Secure key should be used as fallback
498
- expect(config.phoneNumber).toBe('+15559999999');
499
- } finally {
500
- if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv;
501
- }
502
- });
503
-
504
- test('getTwilioConfig env var overrides both config and secure key', () => {
505
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
506
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
507
- secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
508
- rawConfigStore = {
509
- sms: { phoneNumber: '+15551234567' },
510
- ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
511
- };
512
-
513
- const savedEnv = process.env.TWILIO_PHONE_NUMBER;
514
- process.env.TWILIO_PHONE_NUMBER = '+15550000000';
515
-
516
- try {
517
- const config = getTwilioConfig();
518
- // Env var should take highest priority
519
- expect(config.phoneNumber).toBe('+15550000000');
520
- } finally {
521
- if (savedEnv !== undefined) {
522
- process.env.TWILIO_PHONE_NUMBER = savedEnv;
523
- } else {
524
- delete process.env.TWILIO_PHONE_NUMBER;
525
- }
526
- }
527
- });
528
-
529
- test('getTwilioConfig with assistantId prefers assistant-scoped mapping over global phone number', () => {
530
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
531
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
532
- rawConfigStore = {
533
- sms: {
534
- phoneNumber: '+15551234567',
535
- assistantPhoneNumbers: {
536
- 'ast-alpha': '+15550000001',
537
- },
538
- },
539
- ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
540
- };
541
-
542
- const savedEnv = process.env.TWILIO_PHONE_NUMBER;
543
- delete process.env.TWILIO_PHONE_NUMBER;
544
-
545
- try {
546
- const config = getTwilioConfig('ast-alpha');
547
- expect(config.phoneNumber).toBe('+15550000001');
548
- } finally {
549
- if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv;
550
- }
551
- });
552
-
553
- test('getTwilioConfig with assistantId falls back to global number when mapping is missing', () => {
554
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
555
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
556
- rawConfigStore = {
557
- sms: {
558
- phoneNumber: '+15551234567',
559
- assistantPhoneNumbers: {
560
- 'ast-alpha': '+15550000001',
561
- },
562
- },
563
- ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
564
- };
565
-
566
- const savedEnv = process.env.TWILIO_PHONE_NUMBER;
567
- delete process.env.TWILIO_PHONE_NUMBER;
568
-
569
- try {
570
- const config = getTwilioConfig('ast-beta');
571
- expect(config.phoneNumber).toBe('+15551234567');
572
- } finally {
573
- if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv;
574
- }
575
- });
576
-
577
- // ── assign_number ───────────────────────────────────────────────────
578
-
579
- test('assign_number persists phone number to config', async () => {
580
- const msg: TwilioConfigRequest = {
581
- type: 'twilio_config',
582
- action: 'assign_number',
583
- phoneNumber: '+15551234567',
584
- };
585
-
586
- const { ctx, sent } = createTestContext();
587
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
588
-
589
- expect(sent).toHaveLength(1);
590
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
591
- expect(res.type).toBe('twilio_config_response');
592
- expect(res.success).toBe(true);
593
- expect(res.phoneNumber).toBe('+15551234567');
594
-
595
- // Verify config was persisted
596
- expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15551234567');
597
- });
598
-
599
- test('assign_number returns error when phoneNumber is missing', async () => {
600
- const msg: TwilioConfigRequest = {
601
- type: 'twilio_config',
602
- action: 'assign_number',
603
- };
604
-
605
- const { ctx, sent } = createTestContext();
606
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
607
-
608
- expect(sent).toHaveLength(1);
609
- const res = sent[0] as { type: string; success: boolean; error?: string };
610
- expect(res.success).toBe(false);
611
- expect(res.error).toContain('phoneNumber is required');
612
- });
613
-
614
- // ── list_numbers ────────────────────────────────────────────────────
615
-
616
- test('list_numbers returns available numbers', async () => {
617
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
618
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
619
-
620
- globalThis.fetch = (async (url: string | URL | Request) => {
621
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
622
- if (urlStr.includes('IncomingPhoneNumbers.json')) {
623
- return new Response(JSON.stringify({
624
- incoming_phone_numbers: [
625
- {
626
- phone_number: '+15551234567',
627
- friendly_name: 'My Number',
628
- capabilities: { voice: true, sms: true },
629
- },
630
- {
631
- phone_number: '+15559876543',
632
- friendly_name: 'Other Number',
633
- capabilities: { voice: true, sms: false },
634
- },
635
- ],
636
- }), { status: 200 });
637
- }
638
- return originalFetch(url);
639
- }) as typeof fetch;
640
-
641
- const msg: TwilioConfigRequest = {
642
- type: 'twilio_config',
643
- action: 'list_numbers',
644
- };
645
-
646
- const { ctx, sent } = createTestContext();
647
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
648
-
649
- expect(sent).toHaveLength(1);
650
- const res = sent[0] as { type: string; success: boolean; hasCredentials: boolean; numbers?: Array<{ phoneNumber: string; friendlyName: string; capabilities: { voice: boolean; sms: boolean } }> };
651
- expect(res.type).toBe('twilio_config_response');
652
- expect(res.success).toBe(true);
653
- expect(res.hasCredentials).toBe(true);
654
- expect(res.numbers).toHaveLength(2);
655
- expect(res.numbers![0].phoneNumber).toBe('+15551234567');
656
- expect(res.numbers![0].friendlyName).toBe('My Number');
657
- expect(res.numbers![0].capabilities.sms).toBe(true);
658
- expect(res.numbers![1].phoneNumber).toBe('+15559876543');
659
- expect(res.numbers![1].capabilities.sms).toBe(false);
660
- });
661
-
662
- test('list_numbers returns error when no credentials configured', async () => {
663
- const msg: TwilioConfigRequest = {
664
- type: 'twilio_config',
665
- action: 'list_numbers',
666
- };
667
-
668
- const { ctx, sent } = createTestContext();
669
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
670
-
671
- expect(sent).toHaveLength(1);
672
- const res = sent[0] as { type: string; success: boolean; error?: string };
673
- expect(res.success).toBe(false);
674
- expect(res.error).toContain('Twilio credentials not configured');
675
- });
676
-
677
- // ── provision_number ────────────────────────────────────────────────
678
-
679
- test('provision_number searches and provisions a number', async () => {
680
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
681
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
682
-
683
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
684
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
685
- // Search available numbers
686
- if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
687
- return new Response(JSON.stringify({
688
- available_phone_numbers: [
689
- {
690
- phone_number: '+15559999999',
691
- friendly_name: '(555) 999-9999',
692
- capabilities: { voice: true, sms: true },
693
- },
694
- ],
695
- }), { status: 200 });
696
- }
697
- // Purchase number
698
- if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
699
- return new Response(JSON.stringify({
700
- phone_number: '+15559999999',
701
- friendly_name: '(555) 999-9999',
702
- capabilities: { voice: true, sms: true },
703
- }), { status: 201 });
704
- }
705
- return originalFetch(url);
706
- }) as typeof fetch;
707
-
708
- const msg: TwilioConfigRequest = {
709
- type: 'twilio_config',
710
- action: 'provision_number',
711
- country: 'US',
712
- areaCode: '555',
713
- };
714
-
715
- const { ctx, sent } = createTestContext();
716
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
717
-
718
- expect(sent).toHaveLength(1);
719
- const res = sent[0] as { type: string; success: boolean; hasCredentials: boolean; phoneNumber?: string };
720
- expect(res.type).toBe('twilio_config_response');
721
- expect(res.success).toBe(true);
722
- expect(res.hasCredentials).toBe(true);
723
- expect(res.phoneNumber).toBe('+15559999999');
724
- });
725
-
726
- test('provision_number auto-assigns the purchased number to config and secure storage', async () => {
727
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
728
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
729
-
730
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
731
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
732
- if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
733
- return new Response(JSON.stringify({
734
- available_phone_numbers: [{
735
- phone_number: '+15559999999',
736
- friendly_name: '(555) 999-9999',
737
- capabilities: { voice: true, sms: true },
738
- }],
739
- }), { status: 200 });
740
- }
741
- if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
742
- return new Response(JSON.stringify({
743
- phone_number: '+15559999999',
744
- friendly_name: '(555) 999-9999',
745
- capabilities: { voice: true, sms: true },
746
- }), { status: 201 });
747
- }
748
- // Webhook lookup (no ingress configured, will fail gracefully)
749
- return new Response('{}', { status: 200 });
750
- }) as typeof fetch;
751
-
752
- const msg: TwilioConfigRequest = {
753
- type: 'twilio_config',
754
- action: 'provision_number',
755
- country: 'US',
756
- };
757
-
758
- const { ctx, sent } = createTestContext();
759
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
760
-
761
- expect(sent).toHaveLength(1);
762
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
763
- expect(res.success).toBe(true);
764
- expect(res.phoneNumber).toBe('+15559999999');
765
-
766
- // Verify the number was persisted in secure storage (same as assign_number)
767
- expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15559999999');
768
-
769
- // Verify the number was persisted in the config file (same as assign_number)
770
- expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15559999999');
771
- });
772
-
773
- test('provision_number configures Twilio webhooks when ingress URL is available', async () => {
774
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
775
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
776
- rawConfigStore = { ingress: { enabled: true, publicBaseUrl: 'https://example.ngrok.io' } };
777
-
778
- const fetchCalls: Array<{ url: string; method: string; body?: string }> = [];
779
-
780
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
781
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
782
- fetchCalls.push({ url: urlStr, method: init?.method ?? 'GET', body: init?.body?.toString() });
783
-
784
- if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
785
- return new Response(JSON.stringify({
786
- available_phone_numbers: [{
787
- phone_number: '+15559999999',
788
- friendly_name: '(555) 999-9999',
789
- capabilities: { voice: true, sms: true },
790
- }],
791
- }), { status: 200 });
792
- }
793
- if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST'
794
- && init?.body?.toString().includes('PhoneNumber')) {
795
- return new Response(JSON.stringify({
796
- phone_number: '+15559999999',
797
- friendly_name: '(555) 999-9999',
798
- capabilities: { voice: true, sms: true },
799
- }), { status: 201 });
800
- }
801
- // Webhook number lookup
802
- if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
803
- return new Response(JSON.stringify({
804
- incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+15559999999' }],
805
- }), { status: 200 });
806
- }
807
- // Webhook update
808
- if (urlStr.includes('IncomingPhoneNumbers/PN123abc.json') && init?.method === 'POST') {
809
- return new Response(JSON.stringify({ sid: 'PN123abc' }), { status: 200 });
810
- }
811
- return new Response('{}', { status: 200 });
812
- }) as typeof fetch;
813
-
814
- const msg: TwilioConfigRequest = {
815
- type: 'twilio_config',
816
- action: 'provision_number',
817
- country: 'US',
818
- };
819
-
820
- const { ctx, sent } = createTestContext();
821
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
822
-
823
- expect(sent).toHaveLength(1);
824
- const res = sent[0] as { type: string; success: boolean };
825
- expect(res.success).toBe(true);
826
-
827
- // Find the webhook update call
828
- const webhookUpdate = fetchCalls.find((c) =>
829
- c.url.includes('IncomingPhoneNumbers/PN123abc.json') && c.method === 'POST',
830
- );
831
- expect(webhookUpdate).toBeDefined();
832
-
833
- // Verify the webhook URLs contain the expected ingress base URL paths
834
- const body = webhookUpdate!.body!;
835
- expect(body).toContain('VoiceUrl=');
836
- expect(body).toContain('webhooks%2Ftwilio%2Fvoice');
837
- expect(body).toContain('StatusCallback=');
838
- expect(body).toContain('webhooks%2Ftwilio%2Fstatus');
839
- expect(body).toContain('SmsUrl=');
840
- expect(body).toContain('webhooks%2Ftwilio%2Fsms');
841
- });
842
-
843
- test('provision_number succeeds with clear warning when ingress URL is missing', async () => {
844
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
845
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
846
- // No ingress config — webhook configuration will be skipped gracefully
847
-
848
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
849
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
850
- if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
851
- return new Response(JSON.stringify({
852
- available_phone_numbers: [{
853
- phone_number: '+15559999999',
854
- friendly_name: '(555) 999-9999',
855
- capabilities: { voice: true, sms: true },
856
- }],
857
- }), { status: 200 });
858
- }
859
- if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
860
- return new Response(JSON.stringify({
861
- phone_number: '+15559999999',
862
- friendly_name: '(555) 999-9999',
863
- capabilities: { voice: true, sms: true },
864
- }), { status: 201 });
865
- }
866
- return new Response('{}', { status: 200 });
867
- }) as typeof fetch;
868
-
869
- const msg: TwilioConfigRequest = {
870
- type: 'twilio_config',
871
- action: 'provision_number',
872
- country: 'US',
873
- };
874
-
875
- const { ctx, sent } = createTestContext();
876
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
877
-
878
- // The provision should still succeed — webhook config failure is non-fatal
879
- expect(sent).toHaveLength(1);
880
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
881
- expect(res.success).toBe(true);
882
- expect(res.phoneNumber).toBe('+15559999999');
883
-
884
- // Number should still be persisted even without webhook setup
885
- expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15559999999');
886
- expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15559999999');
887
- });
888
-
889
- test('provision_number returns error when no numbers available', async () => {
890
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
891
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
892
-
893
- globalThis.fetch = (async (url: string | URL | Request) => {
894
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
895
- if (urlStr.includes('AvailablePhoneNumbers')) {
896
- return new Response(JSON.stringify({
897
- available_phone_numbers: [],
898
- }), { status: 200 });
899
- }
900
- return originalFetch(url);
901
- }) as typeof fetch;
902
-
903
- const msg: TwilioConfigRequest = {
904
- type: 'twilio_config',
905
- action: 'provision_number',
906
- };
907
-
908
- const { ctx, sent } = createTestContext();
909
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
910
-
911
- expect(sent).toHaveLength(1);
912
- const res = sent[0] as { type: string; success: boolean; error?: string };
913
- expect(res.success).toBe(false);
914
- expect(res.error).toContain('No available phone numbers found');
915
- });
916
-
917
- test('provision_number returns error when no credentials configured', async () => {
918
- const msg: TwilioConfigRequest = {
919
- type: 'twilio_config',
920
- action: 'provision_number',
921
- };
922
-
923
- const { ctx, sent } = createTestContext();
924
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
925
-
926
- expect(sent).toHaveLength(1);
927
- const res = sent[0] as { type: string; success: boolean; error?: string };
928
- expect(res.success).toBe(false);
929
- expect(res.error).toContain('Twilio credentials not configured');
930
- });
931
-
932
- // ── Unknown action ──────────────────────────────────────────────────
933
-
934
- test('unrecognized action returns error response', async () => {
935
- const msg = {
936
- type: 'twilio_config',
937
- action: 'nonexistent_action',
938
- } as unknown as TwilioConfigRequest;
939
-
940
- const { ctx, sent } = createTestContext();
941
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
942
-
943
- expect(sent).toHaveLength(1);
944
- const res = sent[0] as { type: string; success: boolean; error?: string };
945
- expect(res.type).toBe('twilio_config_response');
946
- expect(res.success).toBe(false);
947
- expect(res.error).toContain('Unknown action');
948
- expect(res.error).toContain('nonexistent_action');
949
- });
950
-
951
- // ── Ingress webhook reconciliation ──────────────────────────────────
952
-
953
- test('ingress config update triggers Twilio webhook sync when assigned number and credentials exist', async () => {
954
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
955
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
956
- rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
957
-
958
- const fetchCalls: Array<{ url: string; method: string; body?: string }> = [];
959
-
960
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
961
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
962
- fetchCalls.push({ url: urlStr, method: init?.method ?? 'GET', body: init?.body?.toString() });
963
-
964
- // Webhook number lookup
965
- if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
966
- return new Response(JSON.stringify({
967
- incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+15551234567' }],
968
- }), { status: 200 });
969
- }
970
- // Webhook update
971
- if (urlStr.includes('IncomingPhoneNumbers/PN123abc.json') && init?.method === 'POST') {
972
- return new Response(JSON.stringify({ sid: 'PN123abc' }), { status: 200 });
973
- }
974
- // Gateway reconcile (ignore)
975
- if (urlStr.includes('/internal/telegram/reconcile')) {
976
- return new Response('{}', { status: 200 });
977
- }
978
- return new Response('{}', { status: 200 });
979
- }) as typeof fetch;
980
-
981
- const msg: IngressConfigRequest = {
982
- type: 'ingress_config',
983
- action: 'set',
984
- publicBaseUrl: 'https://new-tunnel.ngrok.io',
985
- enabled: true,
986
- };
987
-
988
- const { ctx, sent } = createTestContext();
989
- await handleIngressConfig(msg, {} as net.Socket, ctx);
990
-
991
- // Ingress save should succeed
992
- expect(sent).toHaveLength(1);
993
- const res = sent[0] as { type: string; success: boolean; enabled: boolean; publicBaseUrl: string };
994
- expect(res.type).toBe('ingress_config_response');
995
- expect(res.success).toBe(true);
996
- expect(res.enabled).toBe(true);
997
- expect(res.publicBaseUrl).toBe('https://new-tunnel.ngrok.io');
998
-
999
- // Wait a tick for the fire-and-forget webhook sync to complete
1000
- await new Promise((r) => setTimeout(r, 50));
1001
-
1002
- // Verify webhook update was attempted with the new ingress URL
1003
- const webhookUpdate = fetchCalls.find((c) =>
1004
- c.url.includes('IncomingPhoneNumbers/PN123abc.json') && c.method === 'POST',
1005
- );
1006
- expect(webhookUpdate).toBeDefined();
1007
- const body = webhookUpdate!.body!;
1008
- expect(body).toContain('VoiceUrl=');
1009
- expect(body).toContain('new-tunnel.ngrok.io');
1010
- });
1011
-
1012
- test('ingress config update reconciles all unique assigned Twilio numbers (legacy + assistant mapping)', async () => {
1013
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1014
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1015
- rawConfigStore = {
1016
- sms: {
1017
- phoneNumber: '+15551234567',
1018
- assistantPhoneNumbers: {
1019
- 'ast-alpha': '+15551234567', // duplicate of legacy; should only sync once
1020
- 'ast-beta': '+15553333333',
1021
- },
1022
- },
1023
- };
1024
-
1025
- const fetchCalls: Array<{ url: string; method: string; body?: string }> = [];
1026
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1027
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1028
- fetchCalls.push({ url: urlStr, method: init?.method ?? 'GET', body: init?.body?.toString() });
1029
-
1030
- if (urlStr.includes('/internal/telegram/reconcile')) {
1031
- return new Response('{}', { status: 200 });
1032
- }
1033
- if (urlStr.includes('IncomingPhoneNumbers.json?PhoneNumber=')) {
1034
- if (urlStr.includes('%2B15551234567')) {
1035
- return new Response(JSON.stringify({
1036
- incoming_phone_numbers: [{ sid: 'PN-legacy', phone_number: '+15551234567' }],
1037
- }), { status: 200 });
1038
- }
1039
- if (urlStr.includes('%2B15553333333')) {
1040
- return new Response(JSON.stringify({
1041
- incoming_phone_numbers: [{ sid: 'PN-beta', phone_number: '+15553333333' }],
1042
- }), { status: 200 });
1043
- }
1044
- return new Response(JSON.stringify({ incoming_phone_numbers: [] }), { status: 200 });
1045
- }
1046
- if (urlStr.includes('IncomingPhoneNumbers/PN-legacy.json') && init?.method === 'POST') {
1047
- return new Response(JSON.stringify({ sid: 'PN-legacy' }), { status: 200 });
1048
- }
1049
- if (urlStr.includes('IncomingPhoneNumbers/PN-beta.json') && init?.method === 'POST') {
1050
- return new Response(JSON.stringify({ sid: 'PN-beta' }), { status: 200 });
1051
- }
1052
- return new Response('{}', { status: 200 });
1053
- }) as typeof fetch;
1054
-
1055
- const msg: IngressConfigRequest = {
1056
- type: 'ingress_config',
1057
- action: 'set',
1058
- publicBaseUrl: 'https://multi-number.ngrok.io',
1059
- enabled: true,
1060
- };
1061
-
1062
- const { ctx, sent } = createTestContext();
1063
- await handleIngressConfig(msg, {} as net.Socket, ctx);
1064
-
1065
- expect(sent).toHaveLength(1);
1066
- const res = sent[0] as { type: string; success: boolean; enabled: boolean };
1067
- expect(res.type).toBe('ingress_config_response');
1068
- expect(res.success).toBe(true);
1069
- expect(res.enabled).toBe(true);
1070
-
1071
- await new Promise((r) => setTimeout(r, 50));
1072
-
1073
- const lookupCalls = fetchCalls.filter((c) => c.url.includes('IncomingPhoneNumbers.json?PhoneNumber='));
1074
- const lookedUpNumbers = lookupCalls
1075
- .map((c) => decodeURIComponent(c.url.split('PhoneNumber=')[1] ?? ''))
1076
- .sort();
1077
- expect(lookedUpNumbers).toEqual(['+15551234567', '+15553333333']);
1078
-
1079
- const updateCalls = fetchCalls.filter((c) => c.method === 'POST' && c.url.includes('IncomingPhoneNumbers/PN-'));
1080
- const updatedSids = updateCalls.map((c) => (c.url.includes('PN-legacy') ? 'PN-legacy' : 'PN-beta')).sort();
1081
- expect(updatedSids).toEqual(['PN-beta', 'PN-legacy']);
1082
- expect(updateCalls[0]?.body ?? '').toContain('multi-number.ngrok.io');
1083
- });
1084
-
1085
- test('webhook sync failure on ingress update does not fail the ingress update', async () => {
1086
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1087
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1088
- rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
1089
-
1090
- globalThis.fetch = (async (url: string | URL | Request, _init?: RequestInit) => {
1091
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1092
- // Gateway reconcile (ignore)
1093
- if (urlStr.includes('/internal/telegram/reconcile')) {
1094
- return new Response('{}', { status: 200 });
1095
- }
1096
- // Webhook number lookup — simulate failure
1097
- if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
1098
- return new Response('Internal Server Error', { status: 500 });
1099
- }
1100
- return new Response('{}', { status: 200 });
1101
- }) as typeof fetch;
1102
-
1103
- const msg: IngressConfigRequest = {
1104
- type: 'ingress_config',
1105
- action: 'set',
1106
- publicBaseUrl: 'https://example.ngrok.io',
1107
- enabled: true,
1108
- };
1109
-
1110
- const { ctx, sent } = createTestContext();
1111
- await handleIngressConfig(msg, {} as net.Socket, ctx);
1112
-
1113
- // The ingress update must still succeed despite the webhook sync failure
1114
- expect(sent).toHaveLength(1);
1115
- const res = sent[0] as { type: string; success: boolean; enabled: boolean };
1116
- expect(res.type).toBe('ingress_config_response');
1117
- expect(res.success).toBe(true);
1118
- expect(res.enabled).toBe(true);
1119
-
1120
- // Wait a tick for the fire-and-forget promise
1121
- await new Promise((r) => setTimeout(r, 50));
1122
- });
1123
-
1124
- test('ingress config update skips webhook sync when no Twilio credentials', async () => {
1125
- rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
1126
-
1127
- const fetchCalls: Array<{ url: string }> = [];
1128
- globalThis.fetch = (async (url: string | URL | Request) => {
1129
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1130
- fetchCalls.push({ url: urlStr });
1131
- return new Response('{}', { status: 200 });
1132
- }) as typeof fetch;
1133
-
1134
- const msg: IngressConfigRequest = {
1135
- type: 'ingress_config',
1136
- action: 'set',
1137
- publicBaseUrl: 'https://example.ngrok.io',
1138
- enabled: true,
1139
- };
1140
-
1141
- const { ctx, sent } = createTestContext();
1142
- await handleIngressConfig(msg, {} as net.Socket, ctx);
1143
-
1144
- expect(sent).toHaveLength(1);
1145
- const res = sent[0] as { type: string; success: boolean };
1146
- expect(res.success).toBe(true);
1147
-
1148
- // No Twilio API calls should have been made (only gateway reconcile)
1149
- const twilioApiCalls = fetchCalls.filter((c) => c.url.includes('api.twilio.com'));
1150
- expect(twilioApiCalls).toHaveLength(0);
1151
- });
1152
-
1153
- test('ingress config update skips webhook sync when no assigned number', async () => {
1154
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1155
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1156
- // No sms.phoneNumber in config
1157
-
1158
- const fetchCalls: Array<{ url: string }> = [];
1159
- globalThis.fetch = (async (url: string | URL | Request) => {
1160
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1161
- fetchCalls.push({ url: urlStr });
1162
- return new Response('{}', { status: 200 });
1163
- }) as typeof fetch;
1164
-
1165
- const msg: IngressConfigRequest = {
1166
- type: 'ingress_config',
1167
- action: 'set',
1168
- publicBaseUrl: 'https://example.ngrok.io',
1169
- enabled: true,
1170
- };
1171
-
1172
- const { ctx, sent } = createTestContext();
1173
- await handleIngressConfig(msg, {} as net.Socket, ctx);
1174
-
1175
- expect(sent).toHaveLength(1);
1176
- const res = sent[0] as { type: string; success: boolean };
1177
- expect(res.success).toBe(true);
1178
-
1179
- // No Twilio API calls should have been made
1180
- const twilioApiCalls = fetchCalls.filter((c) => c.url.includes('api.twilio.com'));
1181
- expect(twilioApiCalls).toHaveLength(0);
1182
- });
1183
-
1184
- // ── Warning field ─────────────────────────────────────────────────
1185
-
1186
- test('provision_number surfaces webhook warning when ingress is missing', async () => {
1187
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1188
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1189
- // No ingress config — webhook configuration will produce a warning
1190
-
1191
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1192
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1193
- if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
1194
- return new Response(JSON.stringify({
1195
- available_phone_numbers: [{
1196
- phone_number: '+15559999999',
1197
- friendly_name: '(555) 999-9999',
1198
- capabilities: { voice: true, sms: true },
1199
- }],
1200
- }), { status: 200 });
1201
- }
1202
- if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
1203
- return new Response(JSON.stringify({
1204
- phone_number: '+15559999999',
1205
- friendly_name: '(555) 999-9999',
1206
- capabilities: { voice: true, sms: true },
1207
- }), { status: 201 });
1208
- }
1209
- return new Response('{}', { status: 200 });
1210
- }) as typeof fetch;
1211
-
1212
- const msg: TwilioConfigRequest = {
1213
- type: 'twilio_config',
1214
- action: 'provision_number',
1215
- country: 'US',
1216
- };
1217
-
1218
- const { ctx, sent } = createTestContext();
1219
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1220
-
1221
- expect(sent).toHaveLength(1);
1222
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string; warning?: string };
1223
- expect(res.success).toBe(true);
1224
- expect(res.phoneNumber).toBe('+15559999999');
1225
- // Warning should be present because no ingress URL is configured
1226
- expect(res.warning).toBeDefined();
1227
- expect(res.warning).toContain('Webhook configuration skipped');
1228
- });
1229
-
1230
- test('assign_number surfaces webhook warning when Twilio API fails', async () => {
1231
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1232
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1233
- rawConfigStore = { ingress: { enabled: true, publicBaseUrl: 'https://example.ngrok.io' } };
1234
-
1235
- globalThis.fetch = (async (url: string | URL | Request) => {
1236
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1237
- // Webhook number lookup — simulate Twilio API error
1238
- if (urlStr.includes('IncomingPhoneNumbers.json')) {
1239
- return new Response('Service Unavailable', { status: 503 });
1240
- }
1241
- return new Response('{}', { status: 200 });
1242
- }) as typeof fetch;
1243
-
1244
- const msg: TwilioConfigRequest = {
1245
- type: 'twilio_config',
1246
- action: 'assign_number',
1247
- phoneNumber: '+15551234567',
1248
- };
1249
-
1250
- const { ctx, sent } = createTestContext();
1251
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1252
-
1253
- expect(sent).toHaveLength(1);
1254
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string; warning?: string };
1255
- // Assignment itself succeeds
1256
- expect(res.success).toBe(true);
1257
- expect(res.phoneNumber).toBe('+15551234567');
1258
- // Warning should surface the webhook failure
1259
- expect(res.warning).toBeDefined();
1260
- expect(res.warning).toContain('Webhook configuration skipped');
1261
- });
1262
-
1263
- // ── Assistant-scoped phone number assignment ─────────────────────────
1264
-
1265
- test('get action with assistantId returns assistant-specific phone number', async () => {
1266
- rawConfigStore = {
1267
- sms: {
1268
- phoneNumber: '+15551111111',
1269
- assistantPhoneNumbers: { 'ast-alpha': '+15552222222', 'ast-beta': '+15553333333' },
1270
- },
1271
- };
1272
-
1273
- const msg: TwilioConfigRequest = {
1274
- type: 'twilio_config',
1275
- action: 'get',
1276
- assistantId: 'ast-alpha',
1277
- };
1278
-
1279
- const { ctx, sent } = createTestContext();
1280
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1281
-
1282
- expect(sent).toHaveLength(1);
1283
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1284
- expect(res.success).toBe(true);
1285
- // Should return the assistant-specific number, not the legacy one
1286
- expect(res.phoneNumber).toBe('+15552222222');
1287
- });
1288
-
1289
- test('get action with assistantId falls back to legacy phoneNumber when no mapping exists', async () => {
1290
- rawConfigStore = {
1291
- sms: { phoneNumber: '+15551111111' },
1292
- };
1293
-
1294
- const msg: TwilioConfigRequest = {
1295
- type: 'twilio_config',
1296
- action: 'get',
1297
- assistantId: 'ast-unknown',
1298
- };
1299
-
1300
- const { ctx, sent } = createTestContext();
1301
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1302
-
1303
- expect(sent).toHaveLength(1);
1304
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1305
- expect(res.success).toBe(true);
1306
- // Should fall back to the legacy phoneNumber
1307
- expect(res.phoneNumber).toBe('+15551111111');
1308
- });
1309
-
1310
- test('assign_number with assistantId persists into assistantPhoneNumbers mapping', async () => {
1311
- rawConfigStore = { sms: { phoneNumber: '+15551111111' } };
1312
-
1313
- const msg: TwilioConfigRequest = {
1314
- type: 'twilio_config',
1315
- action: 'assign_number',
1316
- phoneNumber: '+15554444444',
1317
- assistantId: 'ast-gamma',
1318
- };
1319
-
1320
- const { ctx, sent } = createTestContext();
1321
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1322
-
1323
- expect(sent).toHaveLength(1);
1324
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1325
- expect(res.success).toBe(true);
1326
- expect(res.phoneNumber).toBe('+15554444444');
1327
-
1328
- // Legacy field should NOT be overwritten when assistantId is provided
1329
- // and the field already has a value — prevents multi-assistant clobbering
1330
- const sms = rawConfigStore.sms as Record<string, unknown>;
1331
- expect(sms.phoneNumber).toBe('+15551111111');
1332
-
1333
- // Per-assistant mapping should contain the new assignment
1334
- const mapping = sms.assistantPhoneNumbers as Record<string, string>;
1335
- expect(mapping['ast-gamma']).toBe('+15554444444');
1336
- });
1337
-
1338
- test('assign_number with assistantId sets legacy phoneNumber as fallback when empty', async () => {
1339
- rawConfigStore = { sms: {} };
1340
-
1341
- const msg: TwilioConfigRequest = {
1342
- type: 'twilio_config',
1343
- action: 'assign_number',
1344
- phoneNumber: '+15554444444',
1345
- assistantId: 'ast-gamma',
1346
- };
1347
-
1348
- const { ctx, sent } = createTestContext();
1349
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1350
-
1351
- expect(sent).toHaveLength(1);
1352
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1353
- expect(res.success).toBe(true);
1354
- expect(res.phoneNumber).toBe('+15554444444');
1355
-
1356
- // When no legacy phoneNumber exists, the first assistant assignment sets it as fallback
1357
- const sms = rawConfigStore.sms as Record<string, unknown>;
1358
- expect(sms.phoneNumber).toBe('+15554444444');
1359
-
1360
- // Per-assistant mapping should contain the new assignment
1361
- const mapping = sms.assistantPhoneNumbers as Record<string, string>;
1362
- expect(mapping['ast-gamma']).toBe('+15554444444');
1363
- });
1364
-
1365
- test('assign_number with assistantId does not clobber existing global phoneNumber', async () => {
1366
- // Simulate a multi-assistant scenario: assistant alpha already has a number assigned
1367
- rawConfigStore = {
1368
- sms: {
1369
- phoneNumber: '+15551111111',
1370
- assistantPhoneNumbers: { 'ast-alpha': '+15551111111' },
1371
- },
1372
- };
1373
-
1374
- // Now assign a different number to assistant beta
1375
- const msg: TwilioConfigRequest = {
1376
- type: 'twilio_config',
1377
- action: 'assign_number',
1378
- phoneNumber: '+15552222222',
1379
- assistantId: 'ast-beta',
1380
- };
1381
-
1382
- const { ctx, sent } = createTestContext();
1383
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1384
-
1385
- expect(sent).toHaveLength(1);
1386
- const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
1387
- expect(res.success).toBe(true);
1388
- expect(res.phoneNumber).toBe('+15552222222');
1389
-
1390
- const sms = rawConfigStore.sms as Record<string, unknown>;
1391
- // The global phoneNumber should still be alpha's number, NOT beta's
1392
- expect(sms.phoneNumber).toBe('+15551111111');
1393
-
1394
- // Both assistant mappings should be intact
1395
- const mapping = sms.assistantPhoneNumbers as Record<string, string>;
1396
- expect(mapping['ast-alpha']).toBe('+15551111111');
1397
- expect(mapping['ast-beta']).toBe('+15552222222');
1398
- });
1399
-
1400
- test('assign_number without assistantId does not write assistantPhoneNumbers', async () => {
1401
- rawConfigStore = { sms: {} };
1402
-
1403
- const msg: TwilioConfigRequest = {
1404
- type: 'twilio_config',
1405
- action: 'assign_number',
1406
- phoneNumber: '+15555555555',
1407
- };
1408
-
1409
- const { ctx, sent } = createTestContext();
1410
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1411
-
1412
- expect(sent).toHaveLength(1);
1413
- const res = sent[0] as { type: string; success: boolean };
1414
- expect(res.success).toBe(true);
1415
-
1416
- const sms = rawConfigStore.sms as Record<string, unknown>;
1417
- expect(sms.phoneNumber).toBe('+15555555555');
1418
- // No assistantPhoneNumbers should have been created
1419
- expect(sms.assistantPhoneNumbers).toBeUndefined();
1420
- });
1421
-
1422
- // ── Security ────────────────────────────────────────────────────────
1423
-
1424
- test('response messages never contain raw credential values', async () => {
1425
- secureKeyStore['credential:twilio:account_sid'] = 'AC_secret_account_sid_12345';
1426
- secureKeyStore['credential:twilio:auth_token'] = 'secret_auth_token_67890';
1427
- rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
1428
-
1429
- const msg: TwilioConfigRequest = {
1430
- type: 'twilio_config',
1431
- action: 'get',
1432
- };
1433
-
1434
- const { ctx, sent } = createTestContext();
1435
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1436
-
1437
- expect(sent).toHaveLength(1);
1438
- const responseStr = JSON.stringify(sent[0]);
1439
- // No raw credential values should leak into the response
1440
- expect(responseStr).not.toContain('AC_secret_account_sid_12345');
1441
- expect(responseStr).not.toContain('secret_auth_token_67890');
1442
- });
1443
-
1444
- // ── sms_compliance_status ───────────────────────────────────────────
1445
-
1446
- test('sms_compliance_status returns structured compliance data for toll-free number', async () => {
1447
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1448
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1449
- rawConfigStore = { sms: { phoneNumber: '+18001234567' } };
1450
-
1451
- globalThis.fetch = (async (url: string | URL | Request) => {
1452
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1453
- // Phone number SID lookup
1454
- if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
1455
- return new Response(JSON.stringify({
1456
- incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+18001234567' }],
1457
- }), { status: 200 });
1458
- }
1459
- // Toll-free verification lookup
1460
- if (urlStr.includes('Tollfree/Verifications')) {
1461
- return new Response(JSON.stringify({
1462
- verifications: [{
1463
- sid: 'TF_VER_001',
1464
- status: 'TWILIO_APPROVED',
1465
- rejection_reason: null,
1466
- edit_allowed: false,
1467
- }],
1468
- }), { status: 200 });
1469
- }
1470
- return originalFetch(url);
1471
- }) as typeof fetch;
1472
-
1473
- const msg: TwilioConfigRequest = {
1474
- type: 'twilio_config',
1475
- action: 'sms_compliance_status',
1476
- };
1477
-
1478
- const { ctx, sent } = createTestContext();
1479
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1480
-
1481
- expect(sent).toHaveLength(1);
1482
- const res = sent[0] as { type: string; success: boolean; compliance?: Record<string, unknown> };
1483
- expect(res.success).toBe(true);
1484
- expect(res.compliance).toBeDefined();
1485
- expect(res.compliance!.numberType).toBe('toll_free');
1486
- expect(res.compliance!.verificationSid).toBe('TF_VER_001');
1487
- expect(res.compliance!.verificationStatus).toBe('TWILIO_APPROVED');
1488
- });
1489
-
1490
- test('sms_compliance_status returns local_10dlc type for non-toll-free number without remote check', async () => {
1491
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1492
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1493
- rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
1494
-
1495
- const msg: TwilioConfigRequest = {
1496
- type: 'twilio_config',
1497
- action: 'sms_compliance_status',
1498
- };
1499
-
1500
- const { ctx, sent } = createTestContext();
1501
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1502
-
1503
- expect(sent).toHaveLength(1);
1504
- const res = sent[0] as { type: string; success: boolean; compliance?: Record<string, unknown> };
1505
- expect(res.success).toBe(true);
1506
- expect(res.compliance).toBeDefined();
1507
- expect(res.compliance!.numberType).toBe('local_10dlc');
1508
- // No verification fields for non-toll-free
1509
- expect(res.compliance!.verificationSid).toBeUndefined();
1510
- });
1511
-
1512
- test('sms_compliance_status returns error when no credentials', async () => {
1513
- const msg: TwilioConfigRequest = {
1514
- type: 'twilio_config',
1515
- action: 'sms_compliance_status',
1516
- };
1517
-
1518
- const { ctx, sent } = createTestContext();
1519
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1520
-
1521
- expect(sent).toHaveLength(1);
1522
- const res = sent[0] as { type: string; success: boolean; error?: string };
1523
- expect(res.success).toBe(false);
1524
- expect(res.error).toContain('Twilio credentials not configured');
1525
- });
1526
-
1527
- // ── sms_submit_tollfree_verification ────────────────────────────────
1528
-
1529
- test('sms_submit_tollfree_verification validates required fields', async () => {
1530
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1531
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1532
-
1533
- const msg: TwilioConfigRequest = {
1534
- type: 'twilio_config',
1535
- action: 'sms_submit_tollfree_verification',
1536
- verificationParams: {
1537
- // Missing all required fields
1538
- businessName: 'Test Biz',
1539
- },
1540
- };
1541
-
1542
- const { ctx, sent } = createTestContext();
1543
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1544
-
1545
- expect(sent).toHaveLength(1);
1546
- const res = sent[0] as { type: string; success: boolean; error?: string };
1547
- expect(res.success).toBe(false);
1548
- expect(res.error).toContain('Missing required verification fields');
1549
- expect(res.error).toContain('tollfreePhoneNumberSid');
1550
- });
1551
-
1552
- test('sms_submit_tollfree_verification defaults businessType to SOLE_PROPRIETOR', async () => {
1553
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1554
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1555
-
1556
- let capturedBody = '';
1557
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1558
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1559
- if (urlStr.includes('Tollfree/Verifications') && init?.method === 'POST') {
1560
- capturedBody = init?.body?.toString() ?? '';
1561
- return new Response(JSON.stringify({
1562
- sid: 'TF_VER_NEW',
1563
- status: 'PENDING_REVIEW',
1564
- }), { status: 201 });
1565
- }
1566
- return originalFetch(url);
1567
- }) as typeof fetch;
1568
-
1569
- const msg: TwilioConfigRequest = {
1570
- type: 'twilio_config',
1571
- action: 'sms_submit_tollfree_verification',
1572
- verificationParams: {
1573
- tollfreePhoneNumberSid: 'PN123',
1574
- businessName: 'Test Biz',
1575
- businessWebsite: 'https://test.com',
1576
- notificationEmail: 'test@test.com',
1577
- useCaseCategories: ['CUSTOMER_CARE'],
1578
- useCaseSummary: 'Customer support messages',
1579
- productionMessageSample: 'Your order has shipped!',
1580
- optInImageUrls: ['https://test.com/optin.png'],
1581
- optInType: 'WEB_FORM',
1582
- messageVolume: '100',
1583
- // businessType NOT provided — should default to SOLE_PROPRIETOR
1584
- },
1585
- };
1586
-
1587
- const { ctx, sent } = createTestContext();
1588
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1589
-
1590
- expect(sent).toHaveLength(1);
1591
- const res = sent[0] as { type: string; success: boolean; compliance?: Record<string, unknown> };
1592
- expect(res.success).toBe(true);
1593
- expect(res.compliance).toBeDefined();
1594
- expect(res.compliance!.verificationSid).toBe('TF_VER_NEW');
1595
- expect(res.compliance!.verificationStatus).toBe('PENDING_REVIEW');
1596
-
1597
- // Verify the default businessType was sent to Twilio
1598
- expect(capturedBody).toContain('BusinessType=SOLE_PROPRIETOR');
1599
- });
1600
-
1601
- test('sms_submit_tollfree_verification accepts current Twilio enum ACCOUNT_NOTIFICATIONS', async () => {
1602
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1603
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1604
-
1605
- let capturedBody = '';
1606
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1607
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1608
- if (urlStr.includes('Tollfree/Verifications') && init?.method === 'POST') {
1609
- capturedBody = init?.body?.toString() ?? '';
1610
- return new Response(JSON.stringify({
1611
- sid: 'TF_VER_NEW',
1612
- status: 'PENDING_REVIEW',
1613
- }), { status: 201 });
1614
- }
1615
- return originalFetch(url);
1616
- }) as typeof fetch;
1617
-
1618
- const msg: TwilioConfigRequest = {
1619
- type: 'twilio_config',
1620
- action: 'sms_submit_tollfree_verification',
1621
- verificationParams: {
1622
- tollfreePhoneNumberSid: 'PN123',
1623
- businessName: 'Test Biz',
1624
- businessWebsite: 'https://test.com',
1625
- notificationEmail: 'test@test.com',
1626
- useCaseCategories: ['ACCOUNT_NOTIFICATIONS'],
1627
- useCaseSummary: 'Account alerts',
1628
- productionMessageSample: 'Your account was updated',
1629
- optInImageUrls: ['https://test.com/optin.png'],
1630
- optInType: 'WEB_FORM',
1631
- messageVolume: '100',
1632
- },
1633
- };
1634
-
1635
- const { ctx, sent } = createTestContext();
1636
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1637
-
1638
- expect(sent).toHaveLength(1);
1639
- const res = sent[0] as { success: boolean };
1640
- expect(res.success).toBe(true);
1641
- expect(capturedBody).toContain('UseCaseCategories=ACCOUNT_NOTIFICATIONS');
1642
- });
1643
-
1644
- test('sms_submit_tollfree_verification rejects invalid useCaseCategories', async () => {
1645
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1646
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1647
-
1648
- const msg: TwilioConfigRequest = {
1649
- type: 'twilio_config',
1650
- action: 'sms_submit_tollfree_verification',
1651
- verificationParams: {
1652
- tollfreePhoneNumberSid: 'PN123',
1653
- businessName: 'Test Biz',
1654
- businessWebsite: 'https://test.com',
1655
- notificationEmail: 'test@test.com',
1656
- useCaseCategories: ['INVALID_CATEGORY'],
1657
- useCaseSummary: 'Test',
1658
- productionMessageSample: 'Test message',
1659
- optInImageUrls: ['https://test.com/optin.png'],
1660
- optInType: 'WEB_FORM',
1661
- messageVolume: '100',
1662
- },
1663
- };
1664
-
1665
- const { ctx, sent } = createTestContext();
1666
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1667
-
1668
- expect(sent).toHaveLength(1);
1669
- const res = sent[0] as { type: string; success: boolean; error?: string };
1670
- expect(res.success).toBe(false);
1671
- expect(res.error).toContain('Invalid useCaseCategories');
1672
- expect(res.error).toContain('INVALID_CATEGORY');
1673
- });
1674
-
1675
- test('sms_submit_tollfree_verification returns error without verificationParams', async () => {
1676
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1677
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1678
-
1679
- const msg: TwilioConfigRequest = {
1680
- type: 'twilio_config',
1681
- action: 'sms_submit_tollfree_verification',
1682
- };
1683
-
1684
- const { ctx, sent } = createTestContext();
1685
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1686
-
1687
- expect(sent).toHaveLength(1);
1688
- const res = sent[0] as { type: string; success: boolean; error?: string };
1689
- expect(res.success).toBe(false);
1690
- expect(res.error).toContain('verificationParams is required');
1691
- });
1692
-
1693
- // ── release_number ─────────────────────────────────────────────────
1694
-
1695
- test('release_number clears config and secure keys', async () => {
1696
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1697
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1698
- secureKeyStore['credential:twilio:phone_number'] = '+15551234567';
1699
- rawConfigStore = {
1700
- sms: {
1701
- phoneNumber: '+15551234567',
1702
- assistantPhoneNumbers: { 'ast-alpha': '+15551234567' },
1703
- },
1704
- };
1705
-
1706
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1707
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1708
- // Phone number SID lookup
1709
- if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
1710
- return new Response(JSON.stringify({
1711
- incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+15551234567' }],
1712
- }), { status: 200 });
1713
- }
1714
- // Phone number deletion
1715
- if (urlStr.includes('IncomingPhoneNumbers/PN123abc.json') && init?.method === 'DELETE') {
1716
- return new Response('', { status: 204 });
1717
- }
1718
- return originalFetch(url);
1719
- }) as typeof fetch;
1720
-
1721
- const msg: TwilioConfigRequest = {
1722
- type: 'twilio_config',
1723
- action: 'release_number',
1724
- };
1725
-
1726
- const { ctx, sent } = createTestContext();
1727
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1728
-
1729
- expect(sent).toHaveLength(1);
1730
- const res = sent[0] as { type: string; success: boolean; warning?: string };
1731
- expect(res.success).toBe(true);
1732
- expect(res.warning).toContain('Phone number released');
1733
-
1734
- // Verify config was cleared
1735
- const sms = rawConfigStore.sms as Record<string, unknown>;
1736
- expect(sms.phoneNumber).toBeUndefined();
1737
- expect(sms.assistantPhoneNumbers).toBeUndefined();
1738
-
1739
- // Verify secure key was cleared
1740
- expect(secureKeyStore['credential:twilio:phone_number']).toBeUndefined();
1741
- });
1742
-
1743
- test('release_number returns error when no credentials', async () => {
1744
- rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
1745
-
1746
- const msg: TwilioConfigRequest = {
1747
- type: 'twilio_config',
1748
- action: 'release_number',
1749
- };
1750
-
1751
- const { ctx, sent } = createTestContext();
1752
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1753
-
1754
- expect(sent).toHaveLength(1);
1755
- const res = sent[0] as { type: string; success: boolean; error?: string };
1756
- expect(res.success).toBe(false);
1757
- expect(res.error).toContain('Twilio credentials not configured');
1758
- });
1759
-
1760
- test('release_number returns error when no phone number assigned', async () => {
1761
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1762
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1763
- rawConfigStore = {};
1764
-
1765
- const msg: TwilioConfigRequest = {
1766
- type: 'twilio_config',
1767
- action: 'release_number',
1768
- };
1769
-
1770
- const { ctx, sent } = createTestContext();
1771
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1772
-
1773
- expect(sent).toHaveLength(1);
1774
- const res = sent[0] as { type: string; success: boolean; error?: string };
1775
- expect(res.success).toBe(false);
1776
- expect(res.error).toContain('No phone number to release');
1777
- });
1778
-
1779
- // ── sms_delete_tollfree_verification ────────────────────────────────
1780
-
1781
- test('sms_delete_tollfree_verification requires verificationSid', async () => {
1782
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1783
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1784
-
1785
- const msg: TwilioConfigRequest = {
1786
- type: 'twilio_config',
1787
- action: 'sms_delete_tollfree_verification',
1788
- };
1789
-
1790
- const { ctx, sent } = createTestContext();
1791
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1792
-
1793
- expect(sent).toHaveLength(1);
1794
- const res = sent[0] as { type: string; success: boolean; error?: string };
1795
- expect(res.success).toBe(false);
1796
- expect(res.error).toContain('verificationSid is required');
1797
- });
1798
-
1799
- test('sms_delete_tollfree_verification includes queue warning', async () => {
1800
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1801
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1802
-
1803
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1804
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1805
- if (urlStr.includes('Tollfree/Verifications/TF_VER_001') && init?.method === 'DELETE') {
1806
- return new Response('', { status: 204 });
1807
- }
1808
- return originalFetch(url);
1809
- }) as typeof fetch;
1810
-
1811
- const msg: TwilioConfigRequest = {
1812
- type: 'twilio_config',
1813
- action: 'sms_delete_tollfree_verification',
1814
- verificationSid: 'TF_VER_001',
1815
- };
1816
-
1817
- const { ctx, sent } = createTestContext();
1818
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1819
-
1820
- expect(sent).toHaveLength(1);
1821
- const res = sent[0] as { type: string; success: boolean; warning?: string };
1822
- expect(res.success).toBe(true);
1823
- expect(res.warning).toContain('review queue');
1824
- });
1825
-
1826
- // ── sms_update_tollfree_verification ────────────────────────────────
1827
-
1828
- test('sms_update_tollfree_verification requires verificationSid', async () => {
1829
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1830
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1831
-
1832
- const msg: TwilioConfigRequest = {
1833
- type: 'twilio_config',
1834
- action: 'sms_update_tollfree_verification',
1835
- };
1836
-
1837
- const { ctx, sent } = createTestContext();
1838
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1839
-
1840
- expect(sent).toHaveLength(1);
1841
- const res = sent[0] as { type: string; success: boolean; error?: string };
1842
- expect(res.success).toBe(false);
1843
- expect(res.error).toContain('verificationSid is required');
1844
- });
1845
-
1846
- test('sms_update_tollfree_verification blocks updates when rejected verification is not editable', async () => {
1847
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1848
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1849
-
1850
- let attemptedUpdate = false;
1851
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
1852
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1853
- if (urlStr.includes('Tollfree/Verifications/TF_VER_001') && init?.method === 'GET') {
1854
- return new Response(JSON.stringify({
1855
- sid: 'TF_VER_001',
1856
- status: 'TWILIO_REJECTED',
1857
- edit_allowed: false,
1858
- edit_expiration: '2026-01-01T00:00:00Z',
1859
- }), { status: 200 });
1860
- }
1861
- if (urlStr.includes('Tollfree/Verifications/TF_VER_001') && init?.method === 'POST') {
1862
- attemptedUpdate = true;
1863
- return new Response(JSON.stringify({
1864
- sid: 'TF_VER_001',
1865
- status: 'IN_REVIEW',
1866
- }), { status: 200 });
1867
- }
1868
- return originalFetch(url);
1869
- }) as typeof fetch;
1870
-
1871
- const msg: TwilioConfigRequest = {
1872
- type: 'twilio_config',
1873
- action: 'sms_update_tollfree_verification',
1874
- verificationSid: 'TF_VER_001',
1875
- verificationParams: { useCaseSummary: 'Updated summary' },
1876
- };
1877
-
1878
- const { ctx, sent } = createTestContext();
1879
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1880
-
1881
- expect(sent).toHaveLength(1);
1882
- const res = sent[0] as { success: boolean; error?: string; compliance?: Record<string, unknown> };
1883
- expect(res.success).toBe(false);
1884
- expect(res.error).toContain('cannot be updated');
1885
- expect(res.compliance?.editAllowed).toBe(false);
1886
- expect(attemptedUpdate).toBe(false);
1887
- });
1888
-
1889
- test('sms_doctor resolves toll-free verification using phone SID lookup', async () => {
1890
- secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
1891
- secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
1892
- rawConfigStore = { sms: { phoneNumber: '+18001234567' } };
1893
-
1894
- let verificationLookupUrl = '';
1895
- globalThis.fetch = (async (url: string | URL | Request) => {
1896
- const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
1897
- if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=%2B18001234567')) {
1898
- return new Response(JSON.stringify({
1899
- incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+18001234567' }],
1900
- }), { status: 200 });
1901
- }
1902
- if (urlStr.includes('Tollfree/Verifications')) {
1903
- verificationLookupUrl = urlStr;
1904
- return new Response(JSON.stringify({
1905
- verifications: [{ sid: 'TF_VER_001', status: 'TWILIO_APPROVED' }],
1906
- }), { status: 200 });
1907
- }
1908
- return originalFetch(url);
1909
- }) as typeof fetch;
1910
-
1911
- const msg: TwilioConfigRequest = {
1912
- type: 'twilio_config',
1913
- action: 'sms_doctor',
1914
- };
1915
-
1916
- const { ctx, sent } = createTestContext();
1917
- await handleTwilioConfig(msg, {} as net.Socket, ctx);
1918
-
1919
- expect(sent).toHaveLength(1);
1920
- const res = sent[0] as {
1921
- success: boolean;
1922
- diagnostics?: { compliance?: { status?: string } };
1923
- };
1924
- expect(res.success).toBe(true);
1925
- expect(verificationLookupUrl).toContain('TollfreePhoneNumberSid=PN123abc');
1926
- expect(res.diagnostics?.compliance?.status).toBe('TWILIO_APPROVED');
1927
- });
1928
- });