@vellumai/assistant 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -13
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
- package/src/__tests__/app-git-history.test.ts +22 -27
- package/src/__tests__/app-git-service.test.ts +44 -78
- package/src/__tests__/channel-approval-routes.test.ts +930 -14
- package/src/__tests__/channel-approval.test.ts +2 -0
- package/src/__tests__/channel-delivery-store.test.ts +104 -1
- package/src/__tests__/channel-guardian.test.ts +184 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +5 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
- package/src/__tests__/handlers-telegram-config.test.ts +82 -0
- package/src/__tests__/handlers-twilio-config.test.ts +665 -5
- package/src/__tests__/ingress-url-consistency.test.ts +64 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/run-orchestrator.test.ts +1 -1
- package/src/__tests__/session-process-bridge.test.ts +2 -0
- package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
- package/src/calls/twilio-config.ts +10 -1
- package/src/calls/twilio-rest.ts +70 -0
- package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/schema.ts +3 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
- package/src/daemon/handlers/config.ts +168 -15
- package/src/daemon/handlers/sessions.ts +5 -3
- package/src/daemon/handlers/skills.ts +61 -17
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +10 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +20 -3
- package/src/daemon/session-slash.ts +50 -2
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/inbound/public-ingress-urls.ts +20 -3
- package/src/index.ts +1 -23
- package/src/memory/app-git-service.ts +24 -0
- package/src/memory/app-store.ts +0 -21
- package/src/memory/channel-delivery-store.ts +74 -3
- package/src/memory/channel-guardian-store.ts +54 -26
- package/src/memory/conversation-key-store.ts +20 -0
- package/src/memory/conversation-store.ts +14 -2
- package/src/memory/db.ts +12 -0
- package/src/memory/schema.ts +5 -0
- package/src/runtime/http-server.ts +13 -5
- package/src/runtime/routes/channel-routes.ts +134 -43
- package/src/skills/clawhub.ts +6 -2
- package/src/subagent/manager.ts +4 -1
- package/src/subagent/types.ts +2 -0
- package/src/tools/skills/vellum-catalog.ts +45 -2
- package/src/tools/subagent/spawn.ts +2 -0
|
@@ -10,8 +10,8 @@ const testDir = mkdtempSync(join(tmpdir(), 'handlers-twilio-cfg-test-'));
|
|
|
10
10
|
let rawConfigStore: Record<string, unknown> = {};
|
|
11
11
|
|
|
12
12
|
mock.module('../config/loader.js', () => ({
|
|
13
|
-
getConfig: () => ({}),
|
|
14
|
-
loadConfig: () => ({}),
|
|
13
|
+
getConfig: () => ({ ...rawConfigStore }),
|
|
14
|
+
loadConfig: () => ({ ...rawConfigStore }),
|
|
15
15
|
loadRawConfig: () => ({ ...rawConfigStore }),
|
|
16
16
|
saveRawConfig: (cfg: Record<string, unknown>) => {
|
|
17
17
|
rawConfigStore = { ...cfg };
|
|
@@ -20,6 +20,28 @@ mock.module('../config/loader.js', () => ({
|
|
|
20
20
|
invalidateConfigCache: () => {},
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
|
+
// Provide a thin mock of public-ingress-urls that computes real-looking
|
|
24
|
+
// webhook URLs from the raw config store so that both getTwilioConfig()
|
|
25
|
+
// and the syncTwilioWebhooks() helper used by ingress tests work correctly.
|
|
26
|
+
mock.module('../inbound/public-ingress-urls.js', () => {
|
|
27
|
+
function getBase(config: Record<string, unknown>): string {
|
|
28
|
+
const ingress = (config?.ingress ?? {}) as Record<string, unknown>;
|
|
29
|
+
const url = (ingress.publicBaseUrl as string) ?? '';
|
|
30
|
+
if (!url) throw new Error('No public ingress URL configured');
|
|
31
|
+
return url;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
getPublicBaseUrl: (config: Record<string, unknown>) => getBase(config),
|
|
35
|
+
getTwilioRelayUrl: (config: Record<string, unknown>) => {
|
|
36
|
+
const base = getBase(config);
|
|
37
|
+
return base.replace(/^http(s?)/, 'ws$1') + '/webhooks/twilio/relay';
|
|
38
|
+
},
|
|
39
|
+
getTwilioVoiceWebhookUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/voice',
|
|
40
|
+
getTwilioStatusCallbackUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/status',
|
|
41
|
+
getTwilioSmsWebhookUrl: (config: Record<string, unknown>) => getBase(config) + '/webhooks/twilio/sms',
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
23
45
|
mock.module('../util/platform.js', () => ({
|
|
24
46
|
getRootDir: () => testDir,
|
|
25
47
|
getDataDir: () => testDir,
|
|
@@ -113,10 +135,12 @@ mock.module('../tools/credentials/metadata-store.js', () => ({
|
|
|
113
135
|
// Mock fetch for Twilio API validation
|
|
114
136
|
const originalFetch = globalThis.fetch;
|
|
115
137
|
|
|
116
|
-
import { handleTwilioConfig } from '../daemon/handlers/config.js';
|
|
138
|
+
import { handleTwilioConfig, handleIngressConfig } from '../daemon/handlers/config.js';
|
|
139
|
+
import { getTwilioConfig } from '../calls/twilio-config.js';
|
|
117
140
|
import type { HandlerContext } from '../daemon/handlers.js';
|
|
118
141
|
import type {
|
|
119
142
|
TwilioConfigRequest,
|
|
143
|
+
IngressConfigRequest,
|
|
120
144
|
ServerMessage,
|
|
121
145
|
} from '../daemon/ipc-contract.js';
|
|
122
146
|
import { DebouncerMap } from '../util/debounce.js';
|
|
@@ -382,9 +406,11 @@ describe('Twilio config handler', () => {
|
|
|
382
406
|
|
|
383
407
|
// ── clear_credentials ───────────────────────────────────────────────
|
|
384
408
|
|
|
385
|
-
test('clear_credentials removes stored credentials', async () => {
|
|
409
|
+
test('clear_credentials removes stored credentials but preserves phone number', async () => {
|
|
386
410
|
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
387
411
|
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
412
|
+
secureKeyStore['credential:twilio:phone_number'] = '+15551234567';
|
|
413
|
+
rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
|
|
388
414
|
credentialMetadataStore.push({ service: 'twilio', field: 'account_sid' });
|
|
389
415
|
credentialMetadataStore.push({ service: 'twilio', field: 'auth_token' });
|
|
390
416
|
|
|
@@ -402,11 +428,15 @@ describe('Twilio config handler', () => {
|
|
|
402
428
|
expect(res.success).toBe(true);
|
|
403
429
|
expect(res.hasCredentials).toBe(false);
|
|
404
430
|
|
|
405
|
-
// Verify
|
|
431
|
+
// Verify auth credentials were cleaned up
|
|
406
432
|
expect(secureKeyStore['credential:twilio:account_sid']).toBeUndefined();
|
|
407
433
|
expect(secureKeyStore['credential:twilio:auth_token']).toBeUndefined();
|
|
408
434
|
expect(deletedMetadata).toContainEqual({ service: 'twilio', field: 'account_sid' });
|
|
409
435
|
expect(deletedMetadata).toContainEqual({ service: 'twilio', field: 'auth_token' });
|
|
436
|
+
|
|
437
|
+
// Verify phone number is preserved in both stores
|
|
438
|
+
expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15551234567');
|
|
439
|
+
expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15551234567');
|
|
410
440
|
});
|
|
411
441
|
|
|
412
442
|
test('clear_credentials is idempotent when no credentials exist', async () => {
|
|
@@ -424,6 +454,75 @@ describe('Twilio config handler', () => {
|
|
|
424
454
|
expect(res.hasCredentials).toBe(false);
|
|
425
455
|
});
|
|
426
456
|
|
|
457
|
+
// ── Phone number resolution order ──────────────────────────────────
|
|
458
|
+
|
|
459
|
+
test('getTwilioConfig resolves phone number from config when secure key also present', () => {
|
|
460
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
461
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
462
|
+
secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
|
|
463
|
+
rawConfigStore = {
|
|
464
|
+
sms: { phoneNumber: '+15551234567' },
|
|
465
|
+
ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Clean env var to test config-only resolution
|
|
469
|
+
const savedEnv = process.env.TWILIO_PHONE_NUMBER;
|
|
470
|
+
delete process.env.TWILIO_PHONE_NUMBER;
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
const config = getTwilioConfig();
|
|
474
|
+
// Config value (+15551234567) should take priority over secure key (+15559999999)
|
|
475
|
+
expect(config.phoneNumber).toBe('+15551234567');
|
|
476
|
+
} finally {
|
|
477
|
+
if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv;
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('getTwilioConfig falls back to secure key when config has no phone number', () => {
|
|
482
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
483
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
484
|
+
secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
|
|
485
|
+
rawConfigStore = {
|
|
486
|
+
ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const savedEnv = process.env.TWILIO_PHONE_NUMBER;
|
|
490
|
+
delete process.env.TWILIO_PHONE_NUMBER;
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
const config = getTwilioConfig();
|
|
494
|
+
// Secure key should be used as fallback
|
|
495
|
+
expect(config.phoneNumber).toBe('+15559999999');
|
|
496
|
+
} finally {
|
|
497
|
+
if (savedEnv !== undefined) process.env.TWILIO_PHONE_NUMBER = savedEnv;
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('getTwilioConfig env var overrides both config and secure key', () => {
|
|
502
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
503
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
504
|
+
secureKeyStore['credential:twilio:phone_number'] = '+15559999999';
|
|
505
|
+
rawConfigStore = {
|
|
506
|
+
sms: { phoneNumber: '+15551234567' },
|
|
507
|
+
ingress: { enabled: true, publicBaseUrl: 'https://test.ngrok.io' },
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const savedEnv = process.env.TWILIO_PHONE_NUMBER;
|
|
511
|
+
process.env.TWILIO_PHONE_NUMBER = '+15550000000';
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
const config = getTwilioConfig();
|
|
515
|
+
// Env var should take highest priority
|
|
516
|
+
expect(config.phoneNumber).toBe('+15550000000');
|
|
517
|
+
} finally {
|
|
518
|
+
if (savedEnv !== undefined) {
|
|
519
|
+
process.env.TWILIO_PHONE_NUMBER = savedEnv;
|
|
520
|
+
} else {
|
|
521
|
+
delete process.env.TWILIO_PHONE_NUMBER;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
427
526
|
// ── assign_number ───────────────────────────────────────────────────
|
|
428
527
|
|
|
429
528
|
test('assign_number persists phone number to config', async () => {
|
|
@@ -573,6 +672,169 @@ describe('Twilio config handler', () => {
|
|
|
573
672
|
expect(res.phoneNumber).toBe('+15559999999');
|
|
574
673
|
});
|
|
575
674
|
|
|
675
|
+
test('provision_number auto-assigns the purchased number to config and secure storage', async () => {
|
|
676
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
677
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
678
|
+
|
|
679
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
680
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
681
|
+
if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
|
|
682
|
+
return new Response(JSON.stringify({
|
|
683
|
+
available_phone_numbers: [{
|
|
684
|
+
phone_number: '+15559999999',
|
|
685
|
+
friendly_name: '(555) 999-9999',
|
|
686
|
+
capabilities: { voice: true, sms: true },
|
|
687
|
+
}],
|
|
688
|
+
}), { status: 200 });
|
|
689
|
+
}
|
|
690
|
+
if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
|
|
691
|
+
return new Response(JSON.stringify({
|
|
692
|
+
phone_number: '+15559999999',
|
|
693
|
+
friendly_name: '(555) 999-9999',
|
|
694
|
+
capabilities: { voice: true, sms: true },
|
|
695
|
+
}), { status: 201 });
|
|
696
|
+
}
|
|
697
|
+
// Webhook lookup (no ingress configured, will fail gracefully)
|
|
698
|
+
return new Response('{}', { status: 200 });
|
|
699
|
+
}) as typeof fetch;
|
|
700
|
+
|
|
701
|
+
const msg: TwilioConfigRequest = {
|
|
702
|
+
type: 'twilio_config',
|
|
703
|
+
action: 'provision_number',
|
|
704
|
+
country: 'US',
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const { ctx, sent } = createTestContext();
|
|
708
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
709
|
+
|
|
710
|
+
expect(sent).toHaveLength(1);
|
|
711
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
|
|
712
|
+
expect(res.success).toBe(true);
|
|
713
|
+
expect(res.phoneNumber).toBe('+15559999999');
|
|
714
|
+
|
|
715
|
+
// Verify the number was persisted in secure storage (same as assign_number)
|
|
716
|
+
expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15559999999');
|
|
717
|
+
|
|
718
|
+
// Verify the number was persisted in the config file (same as assign_number)
|
|
719
|
+
expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15559999999');
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test('provision_number configures Twilio webhooks when ingress URL is available', async () => {
|
|
723
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
724
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
725
|
+
rawConfigStore = { ingress: { enabled: true, publicBaseUrl: 'https://example.ngrok.io' } };
|
|
726
|
+
|
|
727
|
+
const fetchCalls: Array<{ url: string; method: string; body?: string }> = [];
|
|
728
|
+
|
|
729
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
730
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
731
|
+
fetchCalls.push({ url: urlStr, method: init?.method ?? 'GET', body: init?.body?.toString() });
|
|
732
|
+
|
|
733
|
+
if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
|
|
734
|
+
return new Response(JSON.stringify({
|
|
735
|
+
available_phone_numbers: [{
|
|
736
|
+
phone_number: '+15559999999',
|
|
737
|
+
friendly_name: '(555) 999-9999',
|
|
738
|
+
capabilities: { voice: true, sms: true },
|
|
739
|
+
}],
|
|
740
|
+
}), { status: 200 });
|
|
741
|
+
}
|
|
742
|
+
if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST'
|
|
743
|
+
&& init?.body?.toString().includes('PhoneNumber')) {
|
|
744
|
+
return new Response(JSON.stringify({
|
|
745
|
+
phone_number: '+15559999999',
|
|
746
|
+
friendly_name: '(555) 999-9999',
|
|
747
|
+
capabilities: { voice: true, sms: true },
|
|
748
|
+
}), { status: 201 });
|
|
749
|
+
}
|
|
750
|
+
// Webhook number lookup
|
|
751
|
+
if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
|
|
752
|
+
return new Response(JSON.stringify({
|
|
753
|
+
incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+15559999999' }],
|
|
754
|
+
}), { status: 200 });
|
|
755
|
+
}
|
|
756
|
+
// Webhook update
|
|
757
|
+
if (urlStr.includes('IncomingPhoneNumbers/PN123abc.json') && init?.method === 'POST') {
|
|
758
|
+
return new Response(JSON.stringify({ sid: 'PN123abc' }), { status: 200 });
|
|
759
|
+
}
|
|
760
|
+
return new Response('{}', { status: 200 });
|
|
761
|
+
}) as typeof fetch;
|
|
762
|
+
|
|
763
|
+
const msg: TwilioConfigRequest = {
|
|
764
|
+
type: 'twilio_config',
|
|
765
|
+
action: 'provision_number',
|
|
766
|
+
country: 'US',
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const { ctx, sent } = createTestContext();
|
|
770
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
771
|
+
|
|
772
|
+
expect(sent).toHaveLength(1);
|
|
773
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
774
|
+
expect(res.success).toBe(true);
|
|
775
|
+
|
|
776
|
+
// Find the webhook update call
|
|
777
|
+
const webhookUpdate = fetchCalls.find((c) =>
|
|
778
|
+
c.url.includes('IncomingPhoneNumbers/PN123abc.json') && c.method === 'POST',
|
|
779
|
+
);
|
|
780
|
+
expect(webhookUpdate).toBeDefined();
|
|
781
|
+
|
|
782
|
+
// Verify the webhook URLs contain the expected ingress base URL paths
|
|
783
|
+
const body = webhookUpdate!.body!;
|
|
784
|
+
expect(body).toContain('VoiceUrl=');
|
|
785
|
+
expect(body).toContain('webhooks%2Ftwilio%2Fvoice');
|
|
786
|
+
expect(body).toContain('StatusCallback=');
|
|
787
|
+
expect(body).toContain('webhooks%2Ftwilio%2Fstatus');
|
|
788
|
+
expect(body).toContain('SmsUrl=');
|
|
789
|
+
expect(body).toContain('webhooks%2Ftwilio%2Fsms');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('provision_number succeeds with clear warning when ingress URL is missing', async () => {
|
|
793
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
794
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
795
|
+
// No ingress config — webhook configuration will be skipped gracefully
|
|
796
|
+
|
|
797
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
798
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
799
|
+
if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
|
|
800
|
+
return new Response(JSON.stringify({
|
|
801
|
+
available_phone_numbers: [{
|
|
802
|
+
phone_number: '+15559999999',
|
|
803
|
+
friendly_name: '(555) 999-9999',
|
|
804
|
+
capabilities: { voice: true, sms: true },
|
|
805
|
+
}],
|
|
806
|
+
}), { status: 200 });
|
|
807
|
+
}
|
|
808
|
+
if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
|
|
809
|
+
return new Response(JSON.stringify({
|
|
810
|
+
phone_number: '+15559999999',
|
|
811
|
+
friendly_name: '(555) 999-9999',
|
|
812
|
+
capabilities: { voice: true, sms: true },
|
|
813
|
+
}), { status: 201 });
|
|
814
|
+
}
|
|
815
|
+
return new Response('{}', { status: 200 });
|
|
816
|
+
}) as typeof fetch;
|
|
817
|
+
|
|
818
|
+
const msg: TwilioConfigRequest = {
|
|
819
|
+
type: 'twilio_config',
|
|
820
|
+
action: 'provision_number',
|
|
821
|
+
country: 'US',
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
const { ctx, sent } = createTestContext();
|
|
825
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
826
|
+
|
|
827
|
+
// The provision should still succeed — webhook config failure is non-fatal
|
|
828
|
+
expect(sent).toHaveLength(1);
|
|
829
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
|
|
830
|
+
expect(res.success).toBe(true);
|
|
831
|
+
expect(res.phoneNumber).toBe('+15559999999');
|
|
832
|
+
|
|
833
|
+
// Number should still be persisted even without webhook setup
|
|
834
|
+
expect(secureKeyStore['credential:twilio:phone_number']).toBe('+15559999999');
|
|
835
|
+
expect((rawConfigStore.sms as Record<string, unknown>)?.phoneNumber).toBe('+15559999999');
|
|
836
|
+
});
|
|
837
|
+
|
|
576
838
|
test('provision_number returns error when no numbers available', async () => {
|
|
577
839
|
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
578
840
|
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
@@ -635,6 +897,404 @@ describe('Twilio config handler', () => {
|
|
|
635
897
|
expect(res.error).toContain('nonexistent_action');
|
|
636
898
|
});
|
|
637
899
|
|
|
900
|
+
// ── Ingress webhook reconciliation ──────────────────────────────────
|
|
901
|
+
|
|
902
|
+
test('ingress config update triggers Twilio webhook sync when assigned number and credentials exist', async () => {
|
|
903
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
904
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
905
|
+
rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
|
|
906
|
+
|
|
907
|
+
const fetchCalls: Array<{ url: string; method: string; body?: string }> = [];
|
|
908
|
+
|
|
909
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
910
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
911
|
+
fetchCalls.push({ url: urlStr, method: init?.method ?? 'GET', body: init?.body?.toString() });
|
|
912
|
+
|
|
913
|
+
// Webhook number lookup
|
|
914
|
+
if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
|
|
915
|
+
return new Response(JSON.stringify({
|
|
916
|
+
incoming_phone_numbers: [{ sid: 'PN123abc', phone_number: '+15551234567' }],
|
|
917
|
+
}), { status: 200 });
|
|
918
|
+
}
|
|
919
|
+
// Webhook update
|
|
920
|
+
if (urlStr.includes('IncomingPhoneNumbers/PN123abc.json') && init?.method === 'POST') {
|
|
921
|
+
return new Response(JSON.stringify({ sid: 'PN123abc' }), { status: 200 });
|
|
922
|
+
}
|
|
923
|
+
// Gateway reconcile (ignore)
|
|
924
|
+
if (urlStr.includes('/internal/telegram/reconcile')) {
|
|
925
|
+
return new Response('{}', { status: 200 });
|
|
926
|
+
}
|
|
927
|
+
return new Response('{}', { status: 200 });
|
|
928
|
+
}) as typeof fetch;
|
|
929
|
+
|
|
930
|
+
const msg: IngressConfigRequest = {
|
|
931
|
+
type: 'ingress_config',
|
|
932
|
+
action: 'set',
|
|
933
|
+
publicBaseUrl: 'https://new-tunnel.ngrok.io',
|
|
934
|
+
enabled: true,
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
const { ctx, sent } = createTestContext();
|
|
938
|
+
await handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
939
|
+
|
|
940
|
+
// Ingress save should succeed
|
|
941
|
+
expect(sent).toHaveLength(1);
|
|
942
|
+
const res = sent[0] as { type: string; success: boolean; enabled: boolean; publicBaseUrl: string };
|
|
943
|
+
expect(res.type).toBe('ingress_config_response');
|
|
944
|
+
expect(res.success).toBe(true);
|
|
945
|
+
expect(res.enabled).toBe(true);
|
|
946
|
+
expect(res.publicBaseUrl).toBe('https://new-tunnel.ngrok.io');
|
|
947
|
+
|
|
948
|
+
// Wait a tick for the fire-and-forget webhook sync to complete
|
|
949
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
950
|
+
|
|
951
|
+
// Verify webhook update was attempted with the new ingress URL
|
|
952
|
+
const webhookUpdate = fetchCalls.find((c) =>
|
|
953
|
+
c.url.includes('IncomingPhoneNumbers/PN123abc.json') && c.method === 'POST',
|
|
954
|
+
);
|
|
955
|
+
expect(webhookUpdate).toBeDefined();
|
|
956
|
+
const body = webhookUpdate!.body!;
|
|
957
|
+
expect(body).toContain('VoiceUrl=');
|
|
958
|
+
expect(body).toContain('new-tunnel.ngrok.io');
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
test('webhook sync failure on ingress update does not fail the ingress update', async () => {
|
|
962
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
963
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
964
|
+
rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
|
|
965
|
+
|
|
966
|
+
globalThis.fetch = (async (url: string | URL | Request, _init?: RequestInit) => {
|
|
967
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
968
|
+
// Gateway reconcile (ignore)
|
|
969
|
+
if (urlStr.includes('/internal/telegram/reconcile')) {
|
|
970
|
+
return new Response('{}', { status: 200 });
|
|
971
|
+
}
|
|
972
|
+
// Webhook number lookup — simulate failure
|
|
973
|
+
if (urlStr.includes('IncomingPhoneNumbers.json') && urlStr.includes('PhoneNumber=')) {
|
|
974
|
+
return new Response('Internal Server Error', { status: 500 });
|
|
975
|
+
}
|
|
976
|
+
return new Response('{}', { status: 200 });
|
|
977
|
+
}) as typeof fetch;
|
|
978
|
+
|
|
979
|
+
const msg: IngressConfigRequest = {
|
|
980
|
+
type: 'ingress_config',
|
|
981
|
+
action: 'set',
|
|
982
|
+
publicBaseUrl: 'https://example.ngrok.io',
|
|
983
|
+
enabled: true,
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
const { ctx, sent } = createTestContext();
|
|
987
|
+
await handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
988
|
+
|
|
989
|
+
// The ingress update must still succeed despite the webhook sync failure
|
|
990
|
+
expect(sent).toHaveLength(1);
|
|
991
|
+
const res = sent[0] as { type: string; success: boolean; enabled: boolean };
|
|
992
|
+
expect(res.type).toBe('ingress_config_response');
|
|
993
|
+
expect(res.success).toBe(true);
|
|
994
|
+
expect(res.enabled).toBe(true);
|
|
995
|
+
|
|
996
|
+
// Wait a tick for the fire-and-forget promise
|
|
997
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test('ingress config update skips webhook sync when no Twilio credentials', async () => {
|
|
1001
|
+
rawConfigStore = { sms: { phoneNumber: '+15551234567' } };
|
|
1002
|
+
|
|
1003
|
+
const fetchCalls: Array<{ url: string }> = [];
|
|
1004
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
1005
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
1006
|
+
fetchCalls.push({ url: urlStr });
|
|
1007
|
+
return new Response('{}', { status: 200 });
|
|
1008
|
+
}) as typeof fetch;
|
|
1009
|
+
|
|
1010
|
+
const msg: IngressConfigRequest = {
|
|
1011
|
+
type: 'ingress_config',
|
|
1012
|
+
action: 'set',
|
|
1013
|
+
publicBaseUrl: 'https://example.ngrok.io',
|
|
1014
|
+
enabled: true,
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
const { ctx, sent } = createTestContext();
|
|
1018
|
+
await handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
1019
|
+
|
|
1020
|
+
expect(sent).toHaveLength(1);
|
|
1021
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
1022
|
+
expect(res.success).toBe(true);
|
|
1023
|
+
|
|
1024
|
+
// No Twilio API calls should have been made (only gateway reconcile)
|
|
1025
|
+
const twilioApiCalls = fetchCalls.filter((c) => c.url.includes('api.twilio.com'));
|
|
1026
|
+
expect(twilioApiCalls).toHaveLength(0);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test('ingress config update skips webhook sync when no assigned number', async () => {
|
|
1030
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
1031
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
1032
|
+
// No sms.phoneNumber in config
|
|
1033
|
+
|
|
1034
|
+
const fetchCalls: Array<{ url: string }> = [];
|
|
1035
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
1036
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
1037
|
+
fetchCalls.push({ url: urlStr });
|
|
1038
|
+
return new Response('{}', { status: 200 });
|
|
1039
|
+
}) as typeof fetch;
|
|
1040
|
+
|
|
1041
|
+
const msg: IngressConfigRequest = {
|
|
1042
|
+
type: 'ingress_config',
|
|
1043
|
+
action: 'set',
|
|
1044
|
+
publicBaseUrl: 'https://example.ngrok.io',
|
|
1045
|
+
enabled: true,
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
const { ctx, sent } = createTestContext();
|
|
1049
|
+
await handleIngressConfig(msg, {} as net.Socket, ctx);
|
|
1050
|
+
|
|
1051
|
+
expect(sent).toHaveLength(1);
|
|
1052
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
1053
|
+
expect(res.success).toBe(true);
|
|
1054
|
+
|
|
1055
|
+
// No Twilio API calls should have been made
|
|
1056
|
+
const twilioApiCalls = fetchCalls.filter((c) => c.url.includes('api.twilio.com'));
|
|
1057
|
+
expect(twilioApiCalls).toHaveLength(0);
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
// ── Warning field ─────────────────────────────────────────────────
|
|
1061
|
+
|
|
1062
|
+
test('provision_number surfaces webhook warning when ingress is missing', async () => {
|
|
1063
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
1064
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
1065
|
+
// No ingress config — webhook configuration will produce a warning
|
|
1066
|
+
|
|
1067
|
+
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
|
1068
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
1069
|
+
if (urlStr.includes('AvailablePhoneNumbers') && urlStr.includes('Local.json')) {
|
|
1070
|
+
return new Response(JSON.stringify({
|
|
1071
|
+
available_phone_numbers: [{
|
|
1072
|
+
phone_number: '+15559999999',
|
|
1073
|
+
friendly_name: '(555) 999-9999',
|
|
1074
|
+
capabilities: { voice: true, sms: true },
|
|
1075
|
+
}],
|
|
1076
|
+
}), { status: 200 });
|
|
1077
|
+
}
|
|
1078
|
+
if (urlStr.includes('IncomingPhoneNumbers.json') && init?.method === 'POST') {
|
|
1079
|
+
return new Response(JSON.stringify({
|
|
1080
|
+
phone_number: '+15559999999',
|
|
1081
|
+
friendly_name: '(555) 999-9999',
|
|
1082
|
+
capabilities: { voice: true, sms: true },
|
|
1083
|
+
}), { status: 201 });
|
|
1084
|
+
}
|
|
1085
|
+
return new Response('{}', { status: 200 });
|
|
1086
|
+
}) as typeof fetch;
|
|
1087
|
+
|
|
1088
|
+
const msg: TwilioConfigRequest = {
|
|
1089
|
+
type: 'twilio_config',
|
|
1090
|
+
action: 'provision_number',
|
|
1091
|
+
country: 'US',
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
const { ctx, sent } = createTestContext();
|
|
1095
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
1096
|
+
|
|
1097
|
+
expect(sent).toHaveLength(1);
|
|
1098
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string; warning?: string };
|
|
1099
|
+
expect(res.success).toBe(true);
|
|
1100
|
+
expect(res.phoneNumber).toBe('+15559999999');
|
|
1101
|
+
// Warning should be present because no ingress URL is configured
|
|
1102
|
+
expect(res.warning).toBeDefined();
|
|
1103
|
+
expect(res.warning).toContain('Webhook configuration skipped');
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
test('assign_number surfaces webhook warning when Twilio API fails', async () => {
|
|
1107
|
+
secureKeyStore['credential:twilio:account_sid'] = 'AC1234567890abcdef1234567890abcdef';
|
|
1108
|
+
secureKeyStore['credential:twilio:auth_token'] = 'test_auth_token';
|
|
1109
|
+
rawConfigStore = { ingress: { enabled: true, publicBaseUrl: 'https://example.ngrok.io' } };
|
|
1110
|
+
|
|
1111
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
1112
|
+
const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
|
1113
|
+
// Webhook number lookup — simulate Twilio API error
|
|
1114
|
+
if (urlStr.includes('IncomingPhoneNumbers.json')) {
|
|
1115
|
+
return new Response('Service Unavailable', { status: 503 });
|
|
1116
|
+
}
|
|
1117
|
+
return new Response('{}', { status: 200 });
|
|
1118
|
+
}) as typeof fetch;
|
|
1119
|
+
|
|
1120
|
+
const msg: TwilioConfigRequest = {
|
|
1121
|
+
type: 'twilio_config',
|
|
1122
|
+
action: 'assign_number',
|
|
1123
|
+
phoneNumber: '+15551234567',
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const { ctx, sent } = createTestContext();
|
|
1127
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
1128
|
+
|
|
1129
|
+
expect(sent).toHaveLength(1);
|
|
1130
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string; warning?: string };
|
|
1131
|
+
// Assignment itself succeeds
|
|
1132
|
+
expect(res.success).toBe(true);
|
|
1133
|
+
expect(res.phoneNumber).toBe('+15551234567');
|
|
1134
|
+
// Warning should surface the webhook failure
|
|
1135
|
+
expect(res.warning).toBeDefined();
|
|
1136
|
+
expect(res.warning).toContain('Webhook configuration skipped');
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
// ── Assistant-scoped phone number assignment ─────────────────────────
|
|
1140
|
+
|
|
1141
|
+
test('get action with assistantId returns assistant-specific phone number', async () => {
|
|
1142
|
+
rawConfigStore = {
|
|
1143
|
+
sms: {
|
|
1144
|
+
phoneNumber: '+15551111111',
|
|
1145
|
+
assistantPhoneNumbers: { 'ast-alpha': '+15552222222', 'ast-beta': '+15553333333' },
|
|
1146
|
+
},
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
const msg: TwilioConfigRequest = {
|
|
1150
|
+
type: 'twilio_config',
|
|
1151
|
+
action: 'get',
|
|
1152
|
+
assistantId: 'ast-alpha',
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
const { ctx, sent } = createTestContext();
|
|
1156
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
1157
|
+
|
|
1158
|
+
expect(sent).toHaveLength(1);
|
|
1159
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
|
|
1160
|
+
expect(res.success).toBe(true);
|
|
1161
|
+
// Should return the assistant-specific number, not the legacy one
|
|
1162
|
+
expect(res.phoneNumber).toBe('+15552222222');
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
test('get action with assistantId falls back to legacy phoneNumber when no mapping exists', async () => {
|
|
1166
|
+
rawConfigStore = {
|
|
1167
|
+
sms: { phoneNumber: '+15551111111' },
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
const msg: TwilioConfigRequest = {
|
|
1171
|
+
type: 'twilio_config',
|
|
1172
|
+
action: 'get',
|
|
1173
|
+
assistantId: 'ast-unknown',
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
const { ctx, sent } = createTestContext();
|
|
1177
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
1178
|
+
|
|
1179
|
+
expect(sent).toHaveLength(1);
|
|
1180
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
|
|
1181
|
+
expect(res.success).toBe(true);
|
|
1182
|
+
// Should fall back to the legacy phoneNumber
|
|
1183
|
+
expect(res.phoneNumber).toBe('+15551111111');
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
test('assign_number with assistantId persists into assistantPhoneNumbers mapping', async () => {
|
|
1187
|
+
rawConfigStore = { sms: { phoneNumber: '+15551111111' } };
|
|
1188
|
+
|
|
1189
|
+
const msg: TwilioConfigRequest = {
|
|
1190
|
+
type: 'twilio_config',
|
|
1191
|
+
action: 'assign_number',
|
|
1192
|
+
phoneNumber: '+15554444444',
|
|
1193
|
+
assistantId: 'ast-gamma',
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
const { ctx, sent } = createTestContext();
|
|
1197
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
1198
|
+
|
|
1199
|
+
expect(sent).toHaveLength(1);
|
|
1200
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
|
|
1201
|
+
expect(res.success).toBe(true);
|
|
1202
|
+
expect(res.phoneNumber).toBe('+15554444444');
|
|
1203
|
+
|
|
1204
|
+
// Legacy field should NOT be overwritten when assistantId is provided
|
|
1205
|
+
// and the field already has a value — prevents multi-assistant clobbering
|
|
1206
|
+
const sms = rawConfigStore.sms as Record<string, unknown>;
|
|
1207
|
+
expect(sms.phoneNumber).toBe('+15551111111');
|
|
1208
|
+
|
|
1209
|
+
// Per-assistant mapping should contain the new assignment
|
|
1210
|
+
const mapping = sms.assistantPhoneNumbers as Record<string, string>;
|
|
1211
|
+
expect(mapping['ast-gamma']).toBe('+15554444444');
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
test('assign_number with assistantId sets legacy phoneNumber as fallback when empty', async () => {
|
|
1215
|
+
rawConfigStore = { sms: {} };
|
|
1216
|
+
|
|
1217
|
+
const msg: TwilioConfigRequest = {
|
|
1218
|
+
type: 'twilio_config',
|
|
1219
|
+
action: 'assign_number',
|
|
1220
|
+
phoneNumber: '+15554444444',
|
|
1221
|
+
assistantId: 'ast-gamma',
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
const { ctx, sent } = createTestContext();
|
|
1225
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
1226
|
+
|
|
1227
|
+
expect(sent).toHaveLength(1);
|
|
1228
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
|
|
1229
|
+
expect(res.success).toBe(true);
|
|
1230
|
+
expect(res.phoneNumber).toBe('+15554444444');
|
|
1231
|
+
|
|
1232
|
+
// When no legacy phoneNumber exists, the first assistant assignment sets it as fallback
|
|
1233
|
+
const sms = rawConfigStore.sms as Record<string, unknown>;
|
|
1234
|
+
expect(sms.phoneNumber).toBe('+15554444444');
|
|
1235
|
+
|
|
1236
|
+
// Per-assistant mapping should contain the new assignment
|
|
1237
|
+
const mapping = sms.assistantPhoneNumbers as Record<string, string>;
|
|
1238
|
+
expect(mapping['ast-gamma']).toBe('+15554444444');
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
test('assign_number with assistantId does not clobber existing global phoneNumber', async () => {
|
|
1242
|
+
// Simulate a multi-assistant scenario: assistant alpha already has a number assigned
|
|
1243
|
+
rawConfigStore = {
|
|
1244
|
+
sms: {
|
|
1245
|
+
phoneNumber: '+15551111111',
|
|
1246
|
+
assistantPhoneNumbers: { 'ast-alpha': '+15551111111' },
|
|
1247
|
+
},
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
// Now assign a different number to assistant beta
|
|
1251
|
+
const msg: TwilioConfigRequest = {
|
|
1252
|
+
type: 'twilio_config',
|
|
1253
|
+
action: 'assign_number',
|
|
1254
|
+
phoneNumber: '+15552222222',
|
|
1255
|
+
assistantId: 'ast-beta',
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
const { ctx, sent } = createTestContext();
|
|
1259
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
1260
|
+
|
|
1261
|
+
expect(sent).toHaveLength(1);
|
|
1262
|
+
const res = sent[0] as { type: string; success: boolean; phoneNumber?: string };
|
|
1263
|
+
expect(res.success).toBe(true);
|
|
1264
|
+
expect(res.phoneNumber).toBe('+15552222222');
|
|
1265
|
+
|
|
1266
|
+
const sms = rawConfigStore.sms as Record<string, unknown>;
|
|
1267
|
+
// The global phoneNumber should still be alpha's number, NOT beta's
|
|
1268
|
+
expect(sms.phoneNumber).toBe('+15551111111');
|
|
1269
|
+
|
|
1270
|
+
// Both assistant mappings should be intact
|
|
1271
|
+
const mapping = sms.assistantPhoneNumbers as Record<string, string>;
|
|
1272
|
+
expect(mapping['ast-alpha']).toBe('+15551111111');
|
|
1273
|
+
expect(mapping['ast-beta']).toBe('+15552222222');
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
test('assign_number without assistantId does not write assistantPhoneNumbers', async () => {
|
|
1277
|
+
rawConfigStore = { sms: {} };
|
|
1278
|
+
|
|
1279
|
+
const msg: TwilioConfigRequest = {
|
|
1280
|
+
type: 'twilio_config',
|
|
1281
|
+
action: 'assign_number',
|
|
1282
|
+
phoneNumber: '+15555555555',
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
const { ctx, sent } = createTestContext();
|
|
1286
|
+
await handleTwilioConfig(msg, {} as net.Socket, ctx);
|
|
1287
|
+
|
|
1288
|
+
expect(sent).toHaveLength(1);
|
|
1289
|
+
const res = sent[0] as { type: string; success: boolean };
|
|
1290
|
+
expect(res.success).toBe(true);
|
|
1291
|
+
|
|
1292
|
+
const sms = rawConfigStore.sms as Record<string, unknown>;
|
|
1293
|
+
expect(sms.phoneNumber).toBe('+15555555555');
|
|
1294
|
+
// No assistantPhoneNumbers should have been created
|
|
1295
|
+
expect(sms.assistantPhoneNumbers).toBeUndefined();
|
|
1296
|
+
});
|
|
1297
|
+
|
|
638
1298
|
// ── Security ────────────────────────────────────────────────────────
|
|
639
1299
|
|
|
640
1300
|
test('response messages never contain raw credential values', async () => {
|