vellum 0.2.10 → 0.2.12
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/bun.lock +6 -2
- package/package.json +2 -2
- package/src/__tests__/gateway-only-enforcement.test.ts +9 -35
- package/src/__tests__/oauth2-gateway-transport.test.ts +14 -33
- package/src/__tests__/skills.test.ts +2 -2
- package/src/__tests__/twilio-routes.test.ts +78 -153
- package/src/__tests__/twitter-auth-handler.test.ts +1 -1
- package/src/cli/main-screen.tsx +15 -117
- package/src/config/bundled-skills/macos-automation/SKILL.md +66 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +334 -0
- package/src/config/system-prompt.ts +9 -59
- package/src/daemon/lifecycle.ts +36 -7
- package/src/home-base/prebuilt/seed.ts +1 -1
- package/src/memory/db.ts +36 -0
- package/src/security/oauth2.ts +8 -8
- package/src/util/logger.ts +4 -4
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* Integration tests for Twilio webhook route handlers.
|
|
3
3
|
*
|
|
4
4
|
* Tests:
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
* - TWILIO_WEBHOOK_VALIDATION_DISABLED env flag bypass
|
|
5
|
+
* - Gateway-only blocking of direct webhook routes (signature validation
|
|
6
|
+
* is now handled at the gateway, not the runtime)
|
|
8
7
|
* - Duplicate callback replay (idempotency)
|
|
9
8
|
* - Unknown status and malformed payload handling
|
|
10
9
|
* - Handler-level idempotency concurrency (concurrent duplicates, failure-retry)
|
|
@@ -122,11 +121,8 @@ import {
|
|
|
122
121
|
getCallSession,
|
|
123
122
|
updateCallSession,
|
|
124
123
|
getCallEvents,
|
|
125
|
-
buildCallbackDedupeKey,
|
|
126
|
-
claimCallback,
|
|
127
|
-
releaseCallbackClaim,
|
|
128
124
|
} from '../calls/call-store.js';
|
|
129
|
-
import { resolveRelayUrl, handleStatusCallback } from '../calls/twilio-routes.js';
|
|
125
|
+
import { resolveRelayUrl, handleStatusCallback, handleVoiceWebhook } from '../calls/twilio-routes.js';
|
|
130
126
|
import { registerCallCompletionNotifier, unregisterCallCompletionNotifier } from '../calls/call-state.js';
|
|
131
127
|
|
|
132
128
|
initializeDb();
|
|
@@ -187,6 +183,22 @@ function createTestSession(convId: string, callSid: string) {
|
|
|
187
183
|
return session;
|
|
188
184
|
}
|
|
189
185
|
|
|
186
|
+
function makeStatusRequest(params: Record<string, string>): Request {
|
|
187
|
+
return new Request('http://127.0.0.1/v1/calls/twilio/status', {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
190
|
+
body: new URLSearchParams(params).toString(),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function makeVoiceRequest(sessionId: string, params: Record<string, string>): Request {
|
|
195
|
+
return new Request(`http://127.0.0.1/v1/calls/twilio/voice-webhook?callSessionId=${sessionId}`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
198
|
+
body: new URLSearchParams(params).toString(),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
190
202
|
// ── Tests ──────────────────────────────────────────────────────────────
|
|
191
203
|
|
|
192
204
|
describe('twilio webhook routes', () => {
|
|
@@ -239,23 +251,24 @@ describe('twilio webhook routes', () => {
|
|
|
239
251
|
};
|
|
240
252
|
}
|
|
241
253
|
|
|
242
|
-
// ──
|
|
254
|
+
// ── Gateway-only blocking tests ───────────────────────────────────
|
|
255
|
+
// Direct Twilio webhook routes are blocked in gateway-only mode.
|
|
256
|
+
// Signature validation is now handled at the gateway level, not the runtime.
|
|
243
257
|
|
|
244
|
-
describe('
|
|
245
|
-
test('
|
|
258
|
+
describe('gateway-only blocking of direct webhook routes', () => {
|
|
259
|
+
test('direct status callback returns 410', async () => {
|
|
246
260
|
await startServer();
|
|
247
|
-
createTestSession('conv-sig-1', 'CA_sig_valid');
|
|
248
261
|
const url = statusUrl();
|
|
249
262
|
const params = { CallSid: 'CA_sig_valid', CallStatus: 'completed' };
|
|
250
263
|
const { body, headers } = signedRequest(url, params);
|
|
251
264
|
|
|
252
265
|
const res = await fetch(url, { method: 'POST', headers, body });
|
|
253
|
-
expect(res.status).toBe(
|
|
266
|
+
expect(res.status).toBe(410);
|
|
254
267
|
|
|
255
268
|
await stopServer();
|
|
256
269
|
});
|
|
257
270
|
|
|
258
|
-
test('
|
|
271
|
+
test('direct status callback without signature returns 410', async () => {
|
|
259
272
|
await startServer();
|
|
260
273
|
const url = statusUrl();
|
|
261
274
|
const params = { CallSid: 'CA_no_sig', CallStatus: 'completed' };
|
|
@@ -266,14 +279,12 @@ describe('twilio webhook routes', () => {
|
|
|
266
279
|
body: buildFormBody(params),
|
|
267
280
|
});
|
|
268
281
|
|
|
269
|
-
expect(res.status).toBe(
|
|
270
|
-
const body = await res.json() as { error: string };
|
|
271
|
-
expect(body.error).toBe('Forbidden');
|
|
282
|
+
expect(res.status).toBe(410);
|
|
272
283
|
|
|
273
284
|
await stopServer();
|
|
274
285
|
});
|
|
275
286
|
|
|
276
|
-
test('invalid signature returns
|
|
287
|
+
test('direct status callback with invalid signature returns 410', async () => {
|
|
277
288
|
await startServer();
|
|
278
289
|
const url = statusUrl();
|
|
279
290
|
const params = { CallSid: 'CA_bad_sig', CallStatus: 'completed' };
|
|
@@ -287,27 +298,27 @@ describe('twilio webhook routes', () => {
|
|
|
287
298
|
body: buildFormBody(params),
|
|
288
299
|
});
|
|
289
300
|
|
|
290
|
-
expect(res.status).toBe(
|
|
301
|
+
expect(res.status).toBe(410);
|
|
291
302
|
|
|
292
303
|
await stopServer();
|
|
293
304
|
});
|
|
294
305
|
|
|
295
|
-
test('
|
|
306
|
+
test('direct status callback with wrong token signature returns 410', async () => {
|
|
296
307
|
await startServer();
|
|
297
308
|
const url = statusUrl();
|
|
298
309
|
const params = { CallSid: 'CA_wrong_token', CallStatus: 'completed' };
|
|
299
|
-
|
|
310
|
+
computeSignature(url, params, 'wrong-auth-token');
|
|
300
311
|
|
|
301
312
|
const res = await fetch(url, {
|
|
302
313
|
method: 'POST',
|
|
303
314
|
headers: {
|
|
304
315
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
305
|
-
'X-Twilio-Signature':
|
|
316
|
+
'X-Twilio-Signature': computeSignature(url, params, 'wrong-auth-token'),
|
|
306
317
|
},
|
|
307
318
|
body: buildFormBody(params),
|
|
308
319
|
});
|
|
309
320
|
|
|
310
|
-
expect(res.status).toBe(
|
|
321
|
+
expect(res.status).toBe(410);
|
|
311
322
|
|
|
312
323
|
await stopServer();
|
|
313
324
|
});
|
|
@@ -316,7 +327,7 @@ describe('twilio webhook routes', () => {
|
|
|
316
327
|
// ── Fail-closed behavior ──────────────────────────────────────────
|
|
317
328
|
|
|
318
329
|
describe('fail-closed when auth token missing', () => {
|
|
319
|
-
test('returns
|
|
330
|
+
test('direct route returns 410 regardless of auth token config', async () => {
|
|
320
331
|
mockAuthToken = undefined;
|
|
321
332
|
await startServer();
|
|
322
333
|
|
|
@@ -329,7 +340,7 @@ describe('twilio webhook routes', () => {
|
|
|
329
340
|
body: buildFormBody(params),
|
|
330
341
|
});
|
|
331
342
|
|
|
332
|
-
expect(res.status).toBe(
|
|
343
|
+
expect(res.status).toBe(410);
|
|
333
344
|
|
|
334
345
|
await stopServer();
|
|
335
346
|
});
|
|
@@ -338,12 +349,11 @@ describe('twilio webhook routes', () => {
|
|
|
338
349
|
// ── TWILIO_WEBHOOK_VALIDATION_DISABLED bypass ─────────────────────
|
|
339
350
|
|
|
340
351
|
describe('validation disabled env flag', () => {
|
|
341
|
-
test('
|
|
352
|
+
test('direct route returns 410 even when validation disabled', async () => {
|
|
342
353
|
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
343
|
-
mockAuthToken = undefined;
|
|
354
|
+
mockAuthToken = undefined;
|
|
344
355
|
await startServer();
|
|
345
356
|
|
|
346
|
-
createTestSession('conv-bypass-1', 'CA_bypass');
|
|
347
357
|
const url = statusUrl();
|
|
348
358
|
const params = { CallSid: 'CA_bypass', CallStatus: 'completed' };
|
|
349
359
|
|
|
@@ -353,12 +363,12 @@ describe('twilio webhook routes', () => {
|
|
|
353
363
|
body: buildFormBody(params),
|
|
354
364
|
});
|
|
355
365
|
|
|
356
|
-
expect(res.status).toBe(
|
|
366
|
+
expect(res.status).toBe(410);
|
|
357
367
|
|
|
358
368
|
await stopServer();
|
|
359
369
|
});
|
|
360
370
|
|
|
361
|
-
test('
|
|
371
|
+
test('direct route returns 410 when env var is non-true value', async () => {
|
|
362
372
|
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = '1';
|
|
363
373
|
mockAuthToken = undefined;
|
|
364
374
|
await startServer();
|
|
@@ -372,13 +382,12 @@ describe('twilio webhook routes', () => {
|
|
|
372
382
|
body: buildFormBody(params),
|
|
373
383
|
});
|
|
374
384
|
|
|
375
|
-
|
|
376
|
-
expect(res.status).toBe(403);
|
|
385
|
+
expect(res.status).toBe(410);
|
|
377
386
|
|
|
378
387
|
await stopServer();
|
|
379
388
|
});
|
|
380
389
|
|
|
381
|
-
test('
|
|
390
|
+
test('direct route returns 410 when env var is empty string', async () => {
|
|
382
391
|
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = '';
|
|
383
392
|
mockAuthToken = undefined;
|
|
384
393
|
await startServer();
|
|
@@ -392,148 +401,113 @@ describe('twilio webhook routes', () => {
|
|
|
392
401
|
body: buildFormBody(params),
|
|
393
402
|
});
|
|
394
403
|
|
|
395
|
-
expect(res.status).toBe(
|
|
404
|
+
expect(res.status).toBe(410);
|
|
396
405
|
|
|
397
406
|
await stopServer();
|
|
398
407
|
});
|
|
399
408
|
});
|
|
400
409
|
|
|
401
410
|
// ── Callback idempotency / replay tests ───────────────────────────
|
|
411
|
+
// These call handleStatusCallback directly (bypassing the HTTP server)
|
|
412
|
+
// since direct routes are blocked by gateway-only mode.
|
|
402
413
|
|
|
403
414
|
describe('callback idempotency', () => {
|
|
404
415
|
test('replaying the same status callback does not create duplicate events', async () => {
|
|
405
|
-
await startServer();
|
|
406
416
|
const session = createTestSession('conv-idem-1', 'CA_idem_1');
|
|
407
|
-
const url = statusUrl();
|
|
408
417
|
const params = {
|
|
409
418
|
CallSid: 'CA_idem_1',
|
|
410
419
|
CallStatus: 'in-progress',
|
|
411
420
|
Timestamp: '2025-01-15T10:00:00Z',
|
|
412
421
|
};
|
|
413
|
-
const { body, headers } = signedRequest(url, params);
|
|
414
422
|
|
|
415
423
|
// First callback — should process
|
|
416
|
-
const res1 = await
|
|
424
|
+
const res1 = await handleStatusCallback(makeStatusRequest(params));
|
|
417
425
|
expect(res1.status).toBe(200);
|
|
418
426
|
|
|
419
427
|
// Second callback (replay) — should return 200 but not create new events
|
|
420
|
-
const res2 = await
|
|
428
|
+
const res2 = await handleStatusCallback(makeStatusRequest(params));
|
|
421
429
|
expect(res2.status).toBe(200);
|
|
422
430
|
|
|
423
431
|
// Verify only one event was recorded
|
|
424
432
|
const events = getCallEvents(session.id);
|
|
425
433
|
const connectedEvents = events.filter(e => e.eventType === 'call_connected');
|
|
426
434
|
expect(connectedEvents.length).toBe(1);
|
|
427
|
-
|
|
428
|
-
await stopServer();
|
|
429
435
|
});
|
|
430
436
|
|
|
431
437
|
test('different statuses for the same call create separate events', async () => {
|
|
432
|
-
await startServer();
|
|
433
438
|
const session = createTestSession('conv-idem-2', 'CA_idem_2');
|
|
434
|
-
const url = statusUrl();
|
|
435
439
|
|
|
436
440
|
// First: ringing
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
441
|
+
await handleStatusCallback(makeStatusRequest({
|
|
442
|
+
CallSid: 'CA_idem_2', CallStatus: 'ringing', Timestamp: 'T1',
|
|
443
|
+
}));
|
|
440
444
|
|
|
441
445
|
// Second: in-progress (different status)
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
446
|
+
await handleStatusCallback(makeStatusRequest({
|
|
447
|
+
CallSid: 'CA_idem_2', CallStatus: 'in-progress', Timestamp: 'T2',
|
|
448
|
+
}));
|
|
445
449
|
|
|
446
450
|
const events = getCallEvents(session.id);
|
|
447
451
|
expect(events.length).toBe(2);
|
|
448
|
-
|
|
449
|
-
await stopServer();
|
|
450
452
|
});
|
|
451
453
|
|
|
452
454
|
test('third replay of same callback is still no-op', async () => {
|
|
453
|
-
await startServer();
|
|
454
455
|
const session = createTestSession('conv-idem-3', 'CA_idem_3');
|
|
455
|
-
const url = statusUrl();
|
|
456
456
|
const params = {
|
|
457
457
|
CallSid: 'CA_idem_3',
|
|
458
458
|
CallStatus: 'completed',
|
|
459
459
|
Timestamp: '2025-01-15T11:00:00Z',
|
|
460
460
|
};
|
|
461
|
-
const { body, headers } = signedRequest(url, params);
|
|
462
461
|
|
|
463
462
|
// Process three times
|
|
464
|
-
await
|
|
465
|
-
await
|
|
466
|
-
await
|
|
463
|
+
await handleStatusCallback(makeStatusRequest(params));
|
|
464
|
+
await handleStatusCallback(makeStatusRequest(params));
|
|
465
|
+
await handleStatusCallback(makeStatusRequest(params));
|
|
467
466
|
|
|
468
467
|
const events = getCallEvents(session.id);
|
|
469
468
|
const endedEvents = events.filter(e => e.eventType === 'call_ended');
|
|
470
469
|
expect(endedEvents.length).toBe(1);
|
|
471
|
-
|
|
472
|
-
await stopServer();
|
|
473
470
|
});
|
|
474
471
|
});
|
|
475
472
|
|
|
476
473
|
// ── Unknown status + malformed payload tests ──────────────────────
|
|
474
|
+
// Call handleStatusCallback directly since direct routes are blocked.
|
|
477
475
|
|
|
478
476
|
describe('unknown status and malformed payloads', () => {
|
|
479
477
|
test('unknown Twilio status returns 200 but does not record event', async () => {
|
|
480
|
-
await startServer();
|
|
481
478
|
const session = createTestSession('conv-unknown-1', 'CA_unknown_1');
|
|
482
|
-
const url = statusUrl();
|
|
483
479
|
const params = {
|
|
484
480
|
CallSid: 'CA_unknown_1',
|
|
485
481
|
CallStatus: 'some-future-status',
|
|
486
482
|
Timestamp: 'T1',
|
|
487
483
|
};
|
|
488
|
-
const { body, headers } = signedRequest(url, params);
|
|
489
484
|
|
|
490
|
-
const res = await
|
|
485
|
+
const res = await handleStatusCallback(makeStatusRequest(params));
|
|
491
486
|
expect(res.status).toBe(200);
|
|
492
487
|
|
|
493
488
|
const events = getCallEvents(session.id);
|
|
494
489
|
expect(events.length).toBe(0);
|
|
495
|
-
|
|
496
|
-
await stopServer();
|
|
497
490
|
});
|
|
498
491
|
|
|
499
492
|
test('missing CallSid returns 200 (graceful handling)', async () => {
|
|
500
|
-
await
|
|
501
|
-
const url = statusUrl();
|
|
502
|
-
const params = { CallStatus: 'completed' };
|
|
503
|
-
const { body, headers } = signedRequest(url, params);
|
|
504
|
-
|
|
505
|
-
const res = await fetch(url, { method: 'POST', headers, body });
|
|
493
|
+
const res = await handleStatusCallback(makeStatusRequest({ CallStatus: 'completed' }));
|
|
506
494
|
expect(res.status).toBe(200);
|
|
507
|
-
|
|
508
|
-
await stopServer();
|
|
509
495
|
});
|
|
510
496
|
|
|
511
497
|
test('missing CallStatus returns 200 (graceful handling)', async () => {
|
|
512
|
-
await
|
|
513
|
-
const url = statusUrl();
|
|
514
|
-
const params = { CallSid: 'CA_no_status' };
|
|
515
|
-
const { body, headers } = signedRequest(url, params);
|
|
516
|
-
|
|
517
|
-
const res = await fetch(url, { method: 'POST', headers, body });
|
|
498
|
+
const res = await handleStatusCallback(makeStatusRequest({ CallSid: 'CA_no_status' }));
|
|
518
499
|
expect(res.status).toBe(200);
|
|
519
|
-
|
|
520
|
-
await stopServer();
|
|
521
500
|
});
|
|
522
501
|
|
|
523
502
|
test('CallSid not matching any session returns 200 without error', async () => {
|
|
524
|
-
await startServer();
|
|
525
|
-
const url = statusUrl();
|
|
526
503
|
const params = {
|
|
527
504
|
CallSid: 'CA_nonexistent_session',
|
|
528
505
|
CallStatus: 'completed',
|
|
529
506
|
Timestamp: 'T1',
|
|
530
507
|
};
|
|
531
|
-
const { body, headers } = signedRequest(url, params);
|
|
532
508
|
|
|
533
|
-
const res = await
|
|
509
|
+
const res = await handleStatusCallback(makeStatusRequest(params));
|
|
534
510
|
expect(res.status).toBe(200);
|
|
535
|
-
|
|
536
|
-
await stopServer();
|
|
537
511
|
});
|
|
538
512
|
});
|
|
539
513
|
|
|
@@ -688,78 +662,53 @@ describe('twilio webhook routes', () => {
|
|
|
688
662
|
});
|
|
689
663
|
|
|
690
664
|
// ── TwiML relay URL generation ──────────────────────────────────────
|
|
665
|
+
// Call handleVoiceWebhook directly since direct routes are blocked.
|
|
691
666
|
|
|
692
667
|
describe('voice webhook TwiML relay URL', () => {
|
|
693
|
-
function voiceUrl(sessionId: string): string {
|
|
694
|
-
return `http://127.0.0.1:${port}/v1/calls/twilio/voice-webhook?callSessionId=${sessionId}`;
|
|
695
|
-
}
|
|
696
|
-
|
|
697
668
|
test('TwiML uses explicit wssBaseUrl when set', async () => {
|
|
698
669
|
mockWssBaseUrl = 'wss://explicit-ws.example.com';
|
|
699
|
-
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
700
|
-
await startServer();
|
|
701
670
|
|
|
702
671
|
const session = createTestSession('conv-twiml-1', 'CA_twiml_1');
|
|
703
|
-
const
|
|
704
|
-
const params = { CallSid: 'CA_twiml_1' };
|
|
705
|
-
const body = buildFormBody(params);
|
|
672
|
+
const req = makeVoiceRequest(session.id, { CallSid: 'CA_twiml_1' });
|
|
706
673
|
|
|
707
|
-
const res = await
|
|
708
|
-
method: 'POST',
|
|
709
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
710
|
-
body,
|
|
711
|
-
});
|
|
674
|
+
const res = await handleVoiceWebhook(req);
|
|
712
675
|
|
|
713
676
|
expect(res.status).toBe(200);
|
|
714
677
|
const twiml = await res.text();
|
|
715
678
|
expect(twiml).toContain('wss://explicit-ws.example.com/v1/calls/relay');
|
|
716
|
-
|
|
717
|
-
await stopServer();
|
|
718
679
|
});
|
|
719
680
|
|
|
720
681
|
test('TwiML falls back to webhookBaseUrl when wssBaseUrl is empty', async () => {
|
|
721
682
|
mockWssBaseUrl = '';
|
|
722
683
|
mockWebhookBaseUrl = 'https://gateway.example.com';
|
|
723
|
-
process.env.TWILIO_WEBHOOK_VALIDATION_DISABLED = 'true';
|
|
724
|
-
await startServer();
|
|
725
684
|
|
|
726
685
|
const session = createTestSession('conv-twiml-2', 'CA_twiml_2');
|
|
727
|
-
const
|
|
728
|
-
const params = { CallSid: 'CA_twiml_2' };
|
|
729
|
-
const body = buildFormBody(params);
|
|
686
|
+
const req = makeVoiceRequest(session.id, { CallSid: 'CA_twiml_2' });
|
|
730
687
|
|
|
731
|
-
const res = await
|
|
732
|
-
method: 'POST',
|
|
733
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
734
|
-
body,
|
|
735
|
-
});
|
|
688
|
+
const res = await handleVoiceWebhook(req);
|
|
736
689
|
|
|
737
690
|
expect(res.status).toBe(200);
|
|
738
691
|
const twiml = await res.text();
|
|
739
692
|
expect(twiml).toContain('wss://gateway.example.com/v1/calls/relay');
|
|
740
|
-
|
|
741
|
-
await stopServer();
|
|
742
693
|
});
|
|
743
694
|
});
|
|
744
695
|
|
|
745
696
|
// ── Handler-level idempotency concurrency tests ─────────────────
|
|
697
|
+
// Call handleStatusCallback directly since direct routes are blocked.
|
|
746
698
|
|
|
747
699
|
describe('handler-level idempotency concurrency', () => {
|
|
748
700
|
test('two concurrent identical status callbacks produce exactly one event', async () => {
|
|
749
|
-
await startServer();
|
|
750
701
|
const session = createTestSession('conv-conc-1', 'CA_conc_1');
|
|
751
|
-
const url = statusUrl();
|
|
752
702
|
const params = {
|
|
753
703
|
CallSid: 'CA_conc_1',
|
|
754
704
|
CallStatus: 'in-progress',
|
|
755
705
|
Timestamp: '2025-01-20T10:00:00Z',
|
|
756
706
|
};
|
|
757
|
-
const { body, headers } = signedRequest(url, params);
|
|
758
707
|
|
|
759
708
|
// Fire two identical callbacks concurrently
|
|
760
709
|
const [res1, res2] = await Promise.all([
|
|
761
|
-
|
|
762
|
-
|
|
710
|
+
handleStatusCallback(makeStatusRequest(params)),
|
|
711
|
+
handleStatusCallback(makeStatusRequest(params)),
|
|
763
712
|
]);
|
|
764
713
|
|
|
765
714
|
// Both should return 200 (one processes, one is deduplicated)
|
|
@@ -770,26 +719,21 @@ describe('twilio webhook routes', () => {
|
|
|
770
719
|
const events = getCallEvents(session.id);
|
|
771
720
|
const connectedEvents = events.filter(e => e.eventType === 'call_connected');
|
|
772
721
|
expect(connectedEvents.length).toBe(1);
|
|
773
|
-
|
|
774
|
-
await stopServer();
|
|
775
722
|
});
|
|
776
723
|
|
|
777
724
|
test('three concurrent identical status callbacks still produce exactly one event', async () => {
|
|
778
|
-
await startServer();
|
|
779
725
|
const session = createTestSession('conv-conc-2', 'CA_conc_2');
|
|
780
|
-
const url = statusUrl();
|
|
781
726
|
const params = {
|
|
782
727
|
CallSid: 'CA_conc_2',
|
|
783
728
|
CallStatus: 'completed',
|
|
784
729
|
Timestamp: '2025-01-20T11:00:00Z',
|
|
785
730
|
};
|
|
786
|
-
const { body, headers } = signedRequest(url, params);
|
|
787
731
|
|
|
788
732
|
// Fire three identical callbacks concurrently
|
|
789
733
|
const [res1, res2, res3] = await Promise.all([
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
734
|
+
handleStatusCallback(makeStatusRequest(params)),
|
|
735
|
+
handleStatusCallback(makeStatusRequest(params)),
|
|
736
|
+
handleStatusCallback(makeStatusRequest(params)),
|
|
793
737
|
]);
|
|
794
738
|
|
|
795
739
|
expect(res1.status).toBe(200);
|
|
@@ -799,14 +743,10 @@ describe('twilio webhook routes', () => {
|
|
|
799
743
|
const events = getCallEvents(session.id);
|
|
800
744
|
const endedEvents = events.filter(e => e.eventType === 'call_ended');
|
|
801
745
|
expect(endedEvents.length).toBe(1);
|
|
802
|
-
|
|
803
|
-
await stopServer();
|
|
804
746
|
});
|
|
805
747
|
|
|
806
748
|
test('processing failure releases claim and allows successful retry', async () => {
|
|
807
|
-
await startServer();
|
|
808
749
|
const session = createTestSession('conv-conc-3', 'CA_conc_3');
|
|
809
|
-
const url = statusUrl();
|
|
810
750
|
const params = {
|
|
811
751
|
CallSid: 'CA_conc_3',
|
|
812
752
|
CallStatus: 'in-progress',
|
|
@@ -829,14 +769,8 @@ describe('twilio webhook routes', () => {
|
|
|
829
769
|
return originalRecordCallEvent(...args);
|
|
830
770
|
});
|
|
831
771
|
|
|
832
|
-
// Call handleStatusCallback directly
|
|
833
|
-
|
|
834
|
-
const formBody = new URLSearchParams(params).toString();
|
|
835
|
-
const directReq = new Request(url, {
|
|
836
|
-
method: 'POST',
|
|
837
|
-
body: formBody,
|
|
838
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
839
|
-
});
|
|
772
|
+
// Call handleStatusCallback directly so we can catch the re-thrown error
|
|
773
|
+
const directReq = makeStatusRequest(params);
|
|
840
774
|
|
|
841
775
|
// The handler should claim → throw in recordCallEvent → catch releases claim → re-throw
|
|
842
776
|
let handlerThrew = false;
|
|
@@ -852,46 +786,37 @@ describe('twilio webhook routes', () => {
|
|
|
852
786
|
const eventsAfterFailure = getCallEvents(session.id);
|
|
853
787
|
expect(eventsAfterFailure.length).toBe(0);
|
|
854
788
|
|
|
855
|
-
// Retry
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
const res = await fetch(url, { method: 'POST', headers, body });
|
|
859
|
-
expect(res.status).toBe(200);
|
|
789
|
+
// Retry — should succeed because the catch block released the claim
|
|
790
|
+
const retryRes = await handleStatusCallback(makeStatusRequest(params));
|
|
791
|
+
expect(retryRes.status).toBe(200);
|
|
860
792
|
|
|
861
793
|
// Now exactly one event should exist from the successful retry
|
|
862
794
|
const eventsAfterRetry = getCallEvents(session.id);
|
|
863
795
|
const connectedEvents = eventsAfterRetry.filter(e => e.eventType === 'call_connected');
|
|
864
796
|
expect(connectedEvents.length).toBe(1);
|
|
865
|
-
|
|
866
|
-
await stopServer();
|
|
867
797
|
});
|
|
868
798
|
|
|
869
799
|
test('permanently claimed callback cannot be retried', async () => {
|
|
870
|
-
await startServer();
|
|
871
800
|
const session = createTestSession('conv-conc-4', 'CA_conc_4');
|
|
872
|
-
const url = statusUrl();
|
|
873
801
|
const params = {
|
|
874
802
|
CallSid: 'CA_conc_4',
|
|
875
803
|
CallStatus: 'completed',
|
|
876
804
|
Timestamp: '2025-01-20T13:00:00Z',
|
|
877
805
|
};
|
|
878
|
-
const { body, headers } = signedRequest(url, params);
|
|
879
806
|
|
|
880
807
|
// First request processes successfully and finalizes the claim
|
|
881
|
-
const res1 = await
|
|
808
|
+
const res1 = await handleStatusCallback(makeStatusRequest(params));
|
|
882
809
|
expect(res1.status).toBe(200);
|
|
883
810
|
|
|
884
811
|
const events1 = getCallEvents(session.id);
|
|
885
812
|
expect(events1.filter(e => e.eventType === 'call_ended').length).toBe(1);
|
|
886
813
|
|
|
887
814
|
// Second request (retry) — should be deduplicated, no new events
|
|
888
|
-
const res2 = await
|
|
815
|
+
const res2 = await handleStatusCallback(makeStatusRequest(params));
|
|
889
816
|
expect(res2.status).toBe(200);
|
|
890
817
|
|
|
891
818
|
const events2 = getCallEvents(session.id);
|
|
892
819
|
expect(events2.filter(e => e.eventType === 'call_ended').length).toBe(1);
|
|
893
|
-
|
|
894
|
-
await stopServer();
|
|
895
820
|
});
|
|
896
821
|
});
|
|
897
822
|
});
|
|
@@ -306,7 +306,7 @@ describe('Twitter auth handler', () => {
|
|
|
306
306
|
};
|
|
307
307
|
|
|
308
308
|
const msg: TwitterAuthStartRequest = { type: 'twitter_auth_start' };
|
|
309
|
-
const { ctx
|
|
309
|
+
const { ctx } = createTestContext();
|
|
310
310
|
await handleMessage(msg, {} as net.Socket, ctx);
|
|
311
311
|
|
|
312
312
|
await new Promise((r) => setTimeout(r, 50));
|