@memberjunction/server 5.44.0 → 5.45.0
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/dist/agentSessions/ReturningVisitorRecap.d.ts +39 -0
- package/dist/agentSessions/ReturningVisitorRecap.d.ts.map +1 -0
- package/dist/agentSessions/ReturningVisitorRecap.js +198 -0
- package/dist/agentSessions/ReturningVisitorRecap.js.map +1 -0
- package/dist/agentSessions/SessionJanitor.d.ts +13 -0
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +65 -7
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +37 -0
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.d.ts +10 -0
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +16 -0
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/index.d.ts +1 -1
- package/dist/auth/magicLink/index.d.ts.map +1 -1
- package/dist/auth/magicLink/index.js +1 -1
- package/dist/auth/magicLink/index.js.map +1 -1
- package/dist/auth/magicLink/types.d.ts +26 -0
- package/dist/auth/magicLink/types.d.ts.map +1 -1
- package/dist/config.d.ts +1883 -144
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +160 -36
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +45 -2
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +522 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2860 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +13 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +22 -0
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +4 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +101 -3
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +6 -0
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts +26 -0
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -0
- package/dist/realtimeWidget/WidgetRouter.js +182 -0
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -0
- package/dist/realtimeWidget/WidgetSessionService.d.ts +265 -0
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -0
- package/dist/realtimeWidget/WidgetSessionService.js +477 -0
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -0
- package/dist/realtimeWidget/host-identity.d.ts +40 -0
- package/dist/realtimeWidget/host-identity.d.ts.map +1 -0
- package/dist/realtimeWidget/host-identity.js +58 -0
- package/dist/realtimeWidget/host-identity.js.map +1 -0
- package/dist/realtimeWidget/index.d.ts +10 -0
- package/dist/realtimeWidget/index.d.ts.map +1 -0
- package/dist/realtimeWidget/index.js +9 -0
- package/dist/realtimeWidget/index.js.map +1 -0
- package/dist/realtimeWidget/visitorIdentity.d.ts +76 -0
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -0
- package/dist/realtimeWidget/visitorIdentity.js +202 -0
- package/dist/realtimeWidget/visitorIdentity.js.map +1 -0
- package/dist/realtimeWidget/widgetCore.d.ts +106 -0
- package/dist/realtimeWidget/widgetCore.d.ts.map +1 -0
- package/dist/realtimeWidget/widgetCore.js +173 -0
- package/dist/realtimeWidget/widgetCore.js.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +51 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.js +79 -0
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts +7 -0
- package/dist/resolvers/ComponentRegistryResolver.d.ts.map +1 -1
- package/dist/resolvers/ComponentRegistryResolver.js +31 -8
- package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
- package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityPermissionResolver.js +1 -2
- package/dist/resolvers/EntityPermissionResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +9 -6
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +15 -13
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +47 -3
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts +27 -0
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/RingCentralTelephonyResolver.js +87 -0
- package/dist/resolvers/RingCentralTelephonyResolver.js.map +1 -0
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +14 -3
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +2 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/TeamsMeetingsResolver.d.ts +28 -0
- package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +1 -0
- package/dist/resolvers/TeamsMeetingsResolver.js +91 -0
- package/dist/resolvers/TeamsMeetingsResolver.js.map +1 -0
- package/dist/resolvers/TelephonyResolver.d.ts +26 -0
- package/dist/resolvers/TelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/TelephonyResolver.js +86 -0
- package/dist/resolvers/TelephonyResolver.js.map +1 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +2 -3
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/VonageTelephonyResolver.d.ts +26 -0
- package/dist/resolvers/VonageTelephonyResolver.d.ts.map +1 -0
- package/dist/resolvers/VonageTelephonyResolver.js +86 -0
- package/dist/resolvers/VonageTelephonyResolver.js.map +1 -0
- package/dist/rest/MediaStreamHandler.js +4 -1
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- package/dist/telephony/RingCentralTelephonyService.d.ts +115 -0
- package/dist/telephony/RingCentralTelephonyService.d.ts.map +1 -0
- package/dist/telephony/RingCentralTelephonyService.js +262 -0
- package/dist/telephony/RingCentralTelephonyService.js.map +1 -0
- package/dist/telephony/TeamsMeetingsRouter.d.ts +40 -0
- package/dist/telephony/TeamsMeetingsRouter.d.ts.map +1 -0
- package/dist/telephony/TeamsMeetingsRouter.js +96 -0
- package/dist/telephony/TeamsMeetingsRouter.js.map +1 -0
- package/dist/telephony/TeamsMeetingsService.d.ts +113 -0
- package/dist/telephony/TeamsMeetingsService.d.ts.map +1 -0
- package/dist/telephony/TeamsMeetingsService.js +196 -0
- package/dist/telephony/TeamsMeetingsService.js.map +1 -0
- package/dist/telephony/TwilioTelephonyRouter.d.ts +36 -0
- package/dist/telephony/TwilioTelephonyRouter.d.ts.map +1 -0
- package/dist/telephony/TwilioTelephonyRouter.js +143 -0
- package/dist/telephony/TwilioTelephonyRouter.js.map +1 -0
- package/dist/telephony/TwilioTelephonyService.d.ts +95 -0
- package/dist/telephony/TwilioTelephonyService.d.ts.map +1 -0
- package/dist/telephony/TwilioTelephonyService.js +193 -0
- package/dist/telephony/TwilioTelephonyService.js.map +1 -0
- package/dist/telephony/VonageTelephonyRouter.d.ts +41 -0
- package/dist/telephony/VonageTelephonyRouter.d.ts.map +1 -0
- package/dist/telephony/VonageTelephonyRouter.js +201 -0
- package/dist/telephony/VonageTelephonyRouter.js.map +1 -0
- package/dist/telephony/VonageTelephonyService.d.ts +90 -0
- package/dist/telephony/VonageTelephonyService.d.ts.map +1 -0
- package/dist/telephony/VonageTelephonyService.js +191 -0
- package/dist/telephony/VonageTelephonyService.js.map +1 -0
- package/dist/telephony/calendar-scheduler.d.ts +67 -0
- package/dist/telephony/calendar-scheduler.d.ts.map +1 -0
- package/dist/telephony/calendar-scheduler.js +133 -0
- package/dist/telephony/calendar-scheduler.js.map +1 -0
- package/dist/telephony/index.d.ts +29 -0
- package/dist/telephony/index.d.ts.map +1 -0
- package/dist/telephony/index.js +38 -0
- package/dist/telephony/index.js.map +1 -0
- package/dist/telephony/media-upgrade-router.d.ts +33 -0
- package/dist/telephony/media-upgrade-router.d.ts.map +1 -0
- package/dist/telephony/media-upgrade-router.js +56 -0
- package/dist/telephony/media-upgrade-router.js.map +1 -0
- package/dist/telephony/ringcentral-runtime.d.ts +14 -0
- package/dist/telephony/ringcentral-runtime.d.ts.map +1 -0
- package/dist/telephony/ringcentral-runtime.js +18 -0
- package/dist/telephony/ringcentral-runtime.js.map +1 -0
- package/dist/telephony/teams-meetings-runtime.d.ts +16 -0
- package/dist/telephony/teams-meetings-runtime.d.ts.map +1 -0
- package/dist/telephony/teams-meetings-runtime.js +20 -0
- package/dist/telephony/teams-meetings-runtime.js.map +1 -0
- package/dist/telephony/teamsAcsMediaRegistry.d.ts +69 -0
- package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/teamsAcsMediaRegistry.js +118 -0
- package/dist/telephony/teamsAcsMediaRegistry.js.map +1 -0
- package/dist/telephony/telephony-runtime.d.ts +14 -0
- package/dist/telephony/telephony-runtime.d.ts.map +1 -0
- package/dist/telephony/telephony-runtime.js +18 -0
- package/dist/telephony/telephony-runtime.js.map +1 -0
- package/dist/telephony/twilioMediaRegistry.d.ts +60 -0
- package/dist/telephony/twilioMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/twilioMediaRegistry.js +106 -0
- package/dist/telephony/twilioMediaRegistry.js.map +1 -0
- package/dist/telephony/vonage-runtime.d.ts +14 -0
- package/dist/telephony/vonage-runtime.d.ts.map +1 -0
- package/dist/telephony/vonage-runtime.js +18 -0
- package/dist/telephony/vonage-runtime.js.map +1 -0
- package/dist/telephony/vonageMediaRegistry.d.ts +78 -0
- package/dist/telephony/vonageMediaRegistry.d.ts.map +1 -0
- package/dist/telephony/vonageMediaRegistry.js +158 -0
- package/dist/telephony/vonageMediaRegistry.js.map +1 -0
- package/package.json +88 -83
- package/src/__tests__/search-knowledge-tags.test.ts +9 -9
- package/src/__tests__/teams-meetings-service.test.ts +65 -0
- package/src/__tests__/teamsAcsMediaRegistry.test.ts +82 -0
- package/src/__tests__/twilio-telephony-service.test.ts +23 -0
- package/src/__tests__/twilioMediaRegistry.test.ts +103 -0
- package/src/__tests__/vonageMediaRegistry.test.ts +180 -0
- package/src/__tests__/widget.test.ts +204 -0
- package/src/__tests__/widgetGuestElevation.test.ts +57 -0
- package/src/__tests__/widgetHostIdentity.test.ts +117 -0
- package/src/agentSessions/ReturningVisitorRecap.ts +242 -0
- package/src/agentSessions/SessionJanitor.ts +66 -6
- package/src/agentSessions/SessionManager.ts +38 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +17 -0
- package/src/auth/magicLink/index.ts +1 -0
- package/src/auth/magicLink/types.ts +26 -0
- package/src/config.ts +172 -38
- package/src/context.ts +50 -3
- package/src/generated/generated.ts +1991 -1
- package/src/generic/ResolverBase.ts +28 -0
- package/src/generic/RunViewResolver.ts +4 -0
- package/src/index.ts +109 -3
- package/src/integration/CustomColumnPromoter.ts +6 -0
- package/src/realtimeWidget/WidgetRouter.ts +204 -0
- package/src/realtimeWidget/WidgetSessionService.ts +714 -0
- package/src/realtimeWidget/host-identity.ts +84 -0
- package/src/realtimeWidget/index.ts +15 -0
- package/src/realtimeWidget/visitorIdentity.ts +258 -0
- package/src/realtimeWidget/widgetCore.ts +231 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +115 -0
- package/src/resolvers/ComponentRegistryResolver.ts +36 -10
- package/src/resolvers/EntityPermissionResolver.ts +1 -2
- package/src/resolvers/QuerySystemUserResolver.ts +14 -10
- package/src/resolvers/RealtimeClientSessionResolver.ts +59 -2
- package/src/resolvers/RingCentralTelephonyResolver.ts +64 -0
- package/src/resolvers/RunAIAgentResolver.ts +16 -4
- package/src/resolvers/SearchKnowledgeResolver.ts +2 -0
- package/src/resolvers/TeamsMeetingsResolver.ts +68 -0
- package/src/resolvers/TelephonyResolver.ts +63 -0
- package/src/resolvers/TestQuerySQLResolver.ts +2 -3
- package/src/resolvers/VonageTelephonyResolver.ts +63 -0
- package/src/rest/MediaStreamHandler.ts +4 -1
- package/src/telephony/RingCentralTelephonyService.ts +342 -0
- package/src/telephony/TeamsMeetingsRouter.ts +124 -0
- package/src/telephony/TeamsMeetingsService.ts +268 -0
- package/src/telephony/TwilioTelephonyRouter.ts +184 -0
- package/src/telephony/TwilioTelephonyService.ts +261 -0
- package/src/telephony/VonageTelephonyRouter.ts +239 -0
- package/src/telephony/VonageTelephonyService.ts +259 -0
- package/src/telephony/calendar-scheduler.ts +176 -0
- package/src/telephony/index.ts +43 -0
- package/src/telephony/media-upgrade-router.ts +62 -0
- package/src/telephony/ringcentral-runtime.ts +22 -0
- package/src/telephony/teams-meetings-runtime.ts +24 -0
- package/src/telephony/teamsAcsMediaRegistry.ts +152 -0
- package/src/telephony/telephony-runtime.ts +22 -0
- package/src/telephony/twilioMediaRegistry.ts +137 -0
- package/src/telephony/vonage-runtime.ts +22 -0
- package/src/telephony/vonageMediaRegistry.ts +200 -0
- package/dist/agents/skip-agent.d.ts +0 -111
- package/dist/agents/skip-agent.d.ts.map +0 -1
- package/dist/agents/skip-agent.js +0 -1487
- package/dist/agents/skip-agent.js.map +0 -1
- package/dist/agents/skip-sdk.d.ts +0 -261
- package/dist/agents/skip-sdk.d.ts.map +0 -1
- package/dist/agents/skip-sdk.js +0 -909
- package/dist/agents/skip-sdk.js.map +0 -1
- package/src/agents/skip-agent.ts +0 -1594
- package/src/agents/skip-sdk.ts +0 -1210
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { VonageCallMediaRegistry, type ITelephonyMediaSocket } from '../telephony/vonageMediaRegistry.js';
|
|
3
|
+
import type { VonageControlEvent } from '@memberjunction/ai-bridge-vonage';
|
|
4
|
+
|
|
5
|
+
/** A fake media socket capturing outbound BINARY audio + TEXT control frames + tracking close. */
|
|
6
|
+
function fakeSocket(): ITelephonyMediaSocket & { sent: Uint8Array[]; text: string[]; closed: boolean } {
|
|
7
|
+
const sent: Uint8Array[] = [];
|
|
8
|
+
const text: string[] = [];
|
|
9
|
+
return {
|
|
10
|
+
sent,
|
|
11
|
+
text,
|
|
12
|
+
closed: false,
|
|
13
|
+
sendBinary(data: Uint8Array) {
|
|
14
|
+
sent.push(data);
|
|
15
|
+
},
|
|
16
|
+
sendText(data: string) {
|
|
17
|
+
text.push(data);
|
|
18
|
+
},
|
|
19
|
+
close() {
|
|
20
|
+
(this as { closed: boolean }).closed = true;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A small PCM16 ArrayBuffer carrying one recognizable little-endian sample, for ordering/round-trip checks. */
|
|
26
|
+
const pcm = (sample: number): ArrayBuffer => {
|
|
27
|
+
const buf = new ArrayBuffer(2);
|
|
28
|
+
new DataView(buf).setInt16(0, sample, true);
|
|
29
|
+
return buf;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Vonage's outbound frame size: 20 ms @ 8 kHz / 16-bit = 320 bytes. */
|
|
33
|
+
const FRAME_BYTES = 320;
|
|
34
|
+
|
|
35
|
+
/** Builds an N-byte PCM ArrayBuffer whose first byte is `tag`, so frames are distinguishable. */
|
|
36
|
+
const audio = (bytes: number, tag = 0): ArrayBuffer => {
|
|
37
|
+
const buf = new Uint8Array(bytes);
|
|
38
|
+
if (bytes > 0) buf[0] = tag;
|
|
39
|
+
return buf.buffer;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe('VonageCallMediaRegistry', () => {
|
|
43
|
+
describe('outbound audio framing (20 ms / 320-byte slices)', () => {
|
|
44
|
+
it('buffers whole 320-byte frames before the socket connects, then flushes them on AttachSocket in order', () => {
|
|
45
|
+
const reg = new VonageCallMediaRegistry();
|
|
46
|
+
reg.RegisterCall('CALL-1');
|
|
47
|
+
reg.SendAudio('CALL-1', audio(FRAME_BYTES, 0xa1));
|
|
48
|
+
reg.SendAudio('CALL-1', audio(FRAME_BYTES, 0xb2));
|
|
49
|
+
|
|
50
|
+
const sock = fakeSocket();
|
|
51
|
+
reg.AttachSocket('CALL-1', sock);
|
|
52
|
+
|
|
53
|
+
expect(sock.sent).toHaveLength(2);
|
|
54
|
+
expect(sock.sent[0]).toHaveLength(FRAME_BYTES);
|
|
55
|
+
expect(sock.sent[0][0]).toBe(0xa1);
|
|
56
|
+
expect(sock.sent[1][0]).toBe(0xb2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('slices an oversized delta into multiple 320-byte frames and carries the sub-frame remainder', () => {
|
|
60
|
+
const reg = new VonageCallMediaRegistry();
|
|
61
|
+
const sock = fakeSocket();
|
|
62
|
+
reg.AttachSocket('CALL-1', sock);
|
|
63
|
+
|
|
64
|
+
// 800 bytes = two whole frames + 160 left over (held, not sent).
|
|
65
|
+
reg.SendAudio('CALL-1', audio(800));
|
|
66
|
+
expect(sock.sent).toHaveLength(2);
|
|
67
|
+
expect(sock.sent.every((f) => f.length === FRAME_BYTES)).toBe(true);
|
|
68
|
+
|
|
69
|
+
// Another 160 bytes completes the carried remainder into a third whole frame.
|
|
70
|
+
reg.SendAudio('CALL-1', audio(160));
|
|
71
|
+
expect(sock.sent).toHaveLength(3);
|
|
72
|
+
expect(sock.sent[2]).toHaveLength(FRAME_BYTES);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('holds a sub-frame delta until enough bytes accumulate (no silence-padded partial frame)', () => {
|
|
76
|
+
const reg = new VonageCallMediaRegistry();
|
|
77
|
+
const sock = fakeSocket();
|
|
78
|
+
reg.AttachSocket('CALL-1', sock);
|
|
79
|
+
reg.SendAudio('CALL-1', audio(100));
|
|
80
|
+
expect(sock.sent).toHaveLength(0); // < one frame — nothing sent yet
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('inbound audio dispatch', () => {
|
|
85
|
+
it('delivers inbound audio to handlers registered before the socket existed', () => {
|
|
86
|
+
const reg = new VonageCallMediaRegistry();
|
|
87
|
+
const received: number[] = [];
|
|
88
|
+
reg.OnAudio('CALL-1', (p) => received.push(new DataView(p).getInt16(0, true)));
|
|
89
|
+
|
|
90
|
+
reg.DispatchInboundAudio('CALL-1', pcm(42));
|
|
91
|
+
|
|
92
|
+
expect(received).toEqual([42]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('drops inbound audio for an unknown call (no channel) without throwing', () => {
|
|
96
|
+
const reg = new VonageCallMediaRegistry();
|
|
97
|
+
expect(() => reg.DispatchInboundAudio('UNKNOWN', pcm(1))).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('fans out audio to multiple handlers registered on one call', () => {
|
|
101
|
+
const reg = new VonageCallMediaRegistry();
|
|
102
|
+
const h1 = vi.fn();
|
|
103
|
+
const h2 = vi.fn();
|
|
104
|
+
reg.OnAudio('CALL-1', h1);
|
|
105
|
+
reg.OnAudio('CALL-1', h2);
|
|
106
|
+
reg.DispatchInboundAudio('CALL-1', pcm(7));
|
|
107
|
+
expect(h1).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(h2).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('inbound control-event dispatch', () => {
|
|
113
|
+
it('delivers parsed control events (DTMF / close) to event handlers, separate from audio', () => {
|
|
114
|
+
const reg = new VonageCallMediaRegistry();
|
|
115
|
+
const events: VonageControlEvent[] = [];
|
|
116
|
+
const audio = vi.fn();
|
|
117
|
+
reg.OnEvent('CALL-1', (e) => events.push(e));
|
|
118
|
+
reg.OnAudio('CALL-1', audio);
|
|
119
|
+
|
|
120
|
+
reg.DispatchInboundEvent('CALL-1', { event: 'websocket:dtmf', digit: '5', duration: 260 });
|
|
121
|
+
reg.DispatchInboundEvent('CALL-1', { event: 'close' });
|
|
122
|
+
|
|
123
|
+
expect(audio).not.toHaveBeenCalled();
|
|
124
|
+
expect(events).toHaveLength(2);
|
|
125
|
+
expect(events[0].digit).toBe('5');
|
|
126
|
+
expect(events[1].event).toBe('close');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('drops inbound events for an unknown call without throwing', () => {
|
|
130
|
+
const reg = new VonageCallMediaRegistry();
|
|
131
|
+
expect(() => reg.DispatchInboundEvent('UNKNOWN', { event: 'close' })).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('barge-in flush (Clear)', () => {
|
|
136
|
+
it('sends Vonage {"action":"clear"} on the live socket and drops locally-buffered audio', () => {
|
|
137
|
+
const reg = new VonageCallMediaRegistry();
|
|
138
|
+
const sock = fakeSocket();
|
|
139
|
+
reg.AttachSocket('CALL-1', sock);
|
|
140
|
+
reg.Clear('CALL-1');
|
|
141
|
+
expect(sock.text).toEqual([JSON.stringify({ action: 'clear' })]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('drops outbound frames buffered before the socket connected, sending nothing on attach', () => {
|
|
145
|
+
const reg = new VonageCallMediaRegistry();
|
|
146
|
+
reg.RegisterCall('CALL-1');
|
|
147
|
+
reg.SendAudio('CALL-1', audio(FRAME_BYTES)); // a whole frame, buffered (no socket yet)
|
|
148
|
+
reg.SendAudio('CALL-1', audio(100)); // sub-frame remainder, held in partial
|
|
149
|
+
reg.Clear('CALL-1'); // barge-in before the media socket connected
|
|
150
|
+
|
|
151
|
+
const sock = fakeSocket();
|
|
152
|
+
reg.AttachSocket('CALL-1', sock);
|
|
153
|
+
expect(sock.sent).toHaveLength(0); // both the buffered frame and the partial were cleared
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('Clear on an unknown call is a no-op', () => {
|
|
157
|
+
const reg = new VonageCallMediaRegistry();
|
|
158
|
+
expect(() => reg.Clear('NOPE')).not.toThrow();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('lifecycle', () => {
|
|
163
|
+
it('EndCall closes the socket and forgets the channel', () => {
|
|
164
|
+
const reg = new VonageCallMediaRegistry();
|
|
165
|
+
const sock = fakeSocket();
|
|
166
|
+
reg.AttachSocket('CALL-1', sock);
|
|
167
|
+
expect(reg.HasCall('CALL-1')).toBe(true);
|
|
168
|
+
|
|
169
|
+
reg.EndCall('CALL-1');
|
|
170
|
+
|
|
171
|
+
expect(sock.closed).toBe(true);
|
|
172
|
+
expect(reg.HasCall('CALL-1')).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('EndCall on an unknown call is a no-op', () => {
|
|
176
|
+
const reg = new VonageCallMediaRegistry();
|
|
177
|
+
expect(() => reg.EndCall('NOPE')).not.toThrow();
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { createPublicKey } from 'node:crypto';
|
|
4
|
+
import {
|
|
5
|
+
parseAllowedOrigins,
|
|
6
|
+
isOriginAllowed,
|
|
7
|
+
isModalityEnabled,
|
|
8
|
+
evaluateWidgetMint,
|
|
9
|
+
buildWidgetGuestClaims,
|
|
10
|
+
looksLikeBot,
|
|
11
|
+
} from '../realtimeWidget/widgetCore.js';
|
|
12
|
+
import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
|
|
13
|
+
|
|
14
|
+
describe('widget core — parseAllowedOrigins', () => {
|
|
15
|
+
it('parses a JSON array of origins', () => {
|
|
16
|
+
expect(parseAllowedOrigins('["https://a.com","https://b.com"]')).toEqual(['https://a.com', 'https://b.com']);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('tolerates a comma-separated string', () => {
|
|
20
|
+
expect(parseAllowedOrigins('https://a.com, https://b.com')).toEqual(['https://a.com', 'https://b.com']);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('normalizes case and strips trailing slashes', () => {
|
|
24
|
+
expect(parseAllowedOrigins('["HTTPS://Acme.COM/"]')).toEqual(['https://acme.com']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns empty (fail-closed) for null / blank / non-array JSON', () => {
|
|
28
|
+
expect(parseAllowedOrigins(null)).toEqual([]);
|
|
29
|
+
expect(parseAllowedOrigins(' ')).toEqual([]);
|
|
30
|
+
expect(parseAllowedOrigins('{"not":"an-array"}')).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('widget core — isOriginAllowed (fail-closed)', () => {
|
|
35
|
+
const allowed = ['https://acme.com', 'https://www.acme.com'];
|
|
36
|
+
|
|
37
|
+
it('accepts an exact (normalized) match', () => {
|
|
38
|
+
expect(isOriginAllowed('https://acme.com', allowed)).toBe(true);
|
|
39
|
+
expect(isOriginAllowed('HTTPS://ACME.COM/', allowed)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects a non-listed origin', () => {
|
|
43
|
+
expect(isOriginAllowed('https://evil.com', allowed)).toBe(false);
|
|
44
|
+
expect(isOriginAllowed('https://sub.acme.com', allowed)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects a missing request origin', () => {
|
|
48
|
+
expect(isOriginAllowed(undefined, allowed)).toBe(false);
|
|
49
|
+
expect(isOriginAllowed('', allowed)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects everything when the allowlist is empty (never "*")', () => {
|
|
53
|
+
expect(isOriginAllowed('https://acme.com', [])).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('widget core — isModalityEnabled', () => {
|
|
58
|
+
it('Both enables text and voice', () => {
|
|
59
|
+
expect(isModalityEnabled('Both', 'Text')).toBe(true);
|
|
60
|
+
expect(isModalityEnabled('Both', 'Voice')).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
it('Text enables only text', () => {
|
|
63
|
+
expect(isModalityEnabled('Text', 'Text')).toBe(true);
|
|
64
|
+
expect(isModalityEnabled('Text', 'Voice')).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
it('Voice enables only voice', () => {
|
|
67
|
+
expect(isModalityEnabled('Voice', 'Voice')).toBe(true);
|
|
68
|
+
expect(isModalityEnabled('Voice', 'Text')).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('widget core — evaluateWidgetMint', () => {
|
|
73
|
+
const base = { Status: 'Active', AllowedOrigins: '["https://acme.com"]', Modality: 'Both' };
|
|
74
|
+
|
|
75
|
+
it('passes for an active widget from an allowed origin', () => {
|
|
76
|
+
expect(evaluateWidgetMint(base, 'https://acme.com')).toEqual({ ok: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('rejects a disabled widget', () => {
|
|
80
|
+
expect(evaluateWidgetMint({ ...base, Status: 'Disabled' }, 'https://acme.com')).toEqual({
|
|
81
|
+
ok: false,
|
|
82
|
+
errorCode: 'disabled',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects a disallowed origin', () => {
|
|
87
|
+
expect(evaluateWidgetMint(base, 'https://evil.com')).toEqual({ ok: false, errorCode: 'origin_not_allowed' });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects a missing origin (fail-closed)', () => {
|
|
91
|
+
expect(evaluateWidgetMint(base, undefined)).toEqual({ ok: false, errorCode: 'origin_not_allowed' });
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('widget core — buildWidgetGuestClaims', () => {
|
|
96
|
+
const args = {
|
|
97
|
+
issuer: 'https://mj.example.com',
|
|
98
|
+
audience: 'mj-magic-link',
|
|
99
|
+
widgetId: 'WIDGET-123',
|
|
100
|
+
sessionId: 'sess-abc',
|
|
101
|
+
anonymousEmail: 'anonymous@magic-link.local',
|
|
102
|
+
applicationId: 'APP-1',
|
|
103
|
+
guestRoleName: 'Widget Guest',
|
|
104
|
+
nowSeconds: 1_000_000,
|
|
105
|
+
ttlSeconds: 900,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
it('marks the session anonymous + magic-link and binds the widget id', () => {
|
|
109
|
+
const claims = buildWidgetGuestClaims(args);
|
|
110
|
+
expect(claims.mj_anon).toBe(true);
|
|
111
|
+
expect(claims.mj_magic_link).toBe(true);
|
|
112
|
+
expect(claims.mj_widget_id).toBe('WIDGET-123');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('scopes to the application + guest role and the configured anon email', () => {
|
|
116
|
+
const claims = buildWidgetGuestClaims(args);
|
|
117
|
+
expect(claims.mj_app_id).toBe('APP-1');
|
|
118
|
+
expect(claims.mj_role).toBe('Widget Guest');
|
|
119
|
+
expect(claims.email).toBe('anonymous@magic-link.local');
|
|
120
|
+
expect(claims.sub).toBe('magic-link|sess-abc');
|
|
121
|
+
expect(claims.mj_sid).toBe('sess-abc');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('carries a single scope entry for the guest grant', () => {
|
|
125
|
+
const claims = buildWidgetGuestClaims(args);
|
|
126
|
+
expect(claims.mj_scopes).toHaveLength(1);
|
|
127
|
+
expect(claims.mj_scopes?.[0]).toMatchObject({ appId: 'APP-1', role: 'Widget Guest' });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('sets exp = iat + ttl', () => {
|
|
131
|
+
const claims = buildWidgetGuestClaims(args);
|
|
132
|
+
expect(claims.iat).toBe(1_000_000);
|
|
133
|
+
expect(claims.exp).toBe(1_000_900);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('widget core — RS256 sign + verify roundtrip (reuses MagicLinkKeyManager)', () => {
|
|
138
|
+
it('mints a widget guest token that verifies against the published JWKS with the widget claim intact', () => {
|
|
139
|
+
const km = MagicLinkKeyManager.Instance;
|
|
140
|
+
km.Initialize(); // ephemeral keypair (idempotent if already initialized in-process)
|
|
141
|
+
|
|
142
|
+
const claims = buildWidgetGuestClaims({
|
|
143
|
+
issuer: 'https://mj.example.com',
|
|
144
|
+
audience: 'mj-magic-link',
|
|
145
|
+
widgetId: 'WIDGET-XYZ',
|
|
146
|
+
sessionId: 'sess-roundtrip',
|
|
147
|
+
anonymousEmail: 'anonymous@magic-link.local',
|
|
148
|
+
applicationId: 'APP-9',
|
|
149
|
+
guestRoleName: 'Widget Guest',
|
|
150
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
151
|
+
ttlSeconds: 900,
|
|
152
|
+
});
|
|
153
|
+
const token = km.Sign(claims);
|
|
154
|
+
|
|
155
|
+
const publicKey = createPublicKey({ key: km.GetJWKS().keys[0] as object, format: 'jwk' });
|
|
156
|
+
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'], audience: 'mj-magic-link' }) as jwt.JwtPayload;
|
|
157
|
+
|
|
158
|
+
expect(decoded.mj_widget_id).toBe('WIDGET-XYZ');
|
|
159
|
+
expect(decoded.mj_anon).toBe(true);
|
|
160
|
+
expect(decoded.mj_magic_link).toBe(true);
|
|
161
|
+
expect(decoded.mj_app_id).toBe('APP-9');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('rejects a tampered widget token', () => {
|
|
165
|
+
const km = MagicLinkKeyManager.Instance;
|
|
166
|
+
km.Initialize();
|
|
167
|
+
const claims = buildWidgetGuestClaims({
|
|
168
|
+
issuer: 'https://mj.example.com',
|
|
169
|
+
audience: 'mj-magic-link',
|
|
170
|
+
widgetId: 'W',
|
|
171
|
+
sessionId: 's',
|
|
172
|
+
anonymousEmail: 'anonymous@magic-link.local',
|
|
173
|
+
applicationId: 'A',
|
|
174
|
+
guestRoleName: 'Widget Guest',
|
|
175
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
176
|
+
ttlSeconds: 900,
|
|
177
|
+
});
|
|
178
|
+
const token = km.Sign(claims);
|
|
179
|
+
const publicKey = createPublicKey({ key: km.GetJWKS().keys[0] as object, format: 'jwk' });
|
|
180
|
+
const tampered = token.slice(0, -3) + 'AAA';
|
|
181
|
+
expect(() => jwt.verify(tampered, publicKey, { algorithms: ['RS256'] })).toThrow();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('widget core — looksLikeBot (W6 mint heuristic)', () => {
|
|
186
|
+
it('flags a missing / blank user-agent as a bot', () => {
|
|
187
|
+
expect(looksLikeBot(undefined)).toBe(true);
|
|
188
|
+
expect(looksLikeBot(null)).toBe(true);
|
|
189
|
+
expect(looksLikeBot(' ')).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('flags known automation user-agents', () => {
|
|
193
|
+
expect(looksLikeBot('curl/8.4.0')).toBe(true);
|
|
194
|
+
expect(looksLikeBot('python-requests/2.31')).toBe(true);
|
|
195
|
+
expect(looksLikeBot('Googlebot/2.1 (+http://www.google.com/bot.html)')).toBe(true);
|
|
196
|
+
expect(looksLikeBot('Mozilla/5.0 (compatible; bingbot/2.0)')).toBe(true);
|
|
197
|
+
expect(looksLikeBot('HeadlessChrome/120.0.0.0')).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('allows real browser user-agents', () => {
|
|
201
|
+
expect(looksLikeBot('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36')).toBe(false);
|
|
202
|
+
expect(looksLikeBot('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1')).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { DatabaseProviderBase, UserInfo } from '@memberjunction/core';
|
|
3
|
+
import { resolveWidgetGuestRunContext, elevateUserPayload } from '../realtimeWidget/widgetGuestElevation.js';
|
|
4
|
+
import type { UserPayload } from '../types.js';
|
|
5
|
+
|
|
6
|
+
/** A provider stub — the guard paths under test return before ever touching the provider. */
|
|
7
|
+
const providerStub = {} as DatabaseProviderBase;
|
|
8
|
+
|
|
9
|
+
/** Builds a UserPayload whose synthesized userRecord carries the given guest flags. */
|
|
10
|
+
function payloadWith(userRecord: Partial<UserInfo>): UserPayload {
|
|
11
|
+
return { email: 'anonymous@magic-link.local', userRecord, sessionId: 'sess-123' };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('widgetGuestElevation — resolveWidgetGuestRunContext (guard paths, no DB)', () => {
|
|
15
|
+
it('returns null for a normal authenticated user (not anonymous)', async () => {
|
|
16
|
+
const result = await resolveWidgetGuestRunContext(payloadWith({ IsMagicLinkAnonymous: false }), providerStub);
|
|
17
|
+
expect(result).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns null for an anonymous magic-link session that is NOT a widget guest (no WidgetGuestContext)', async () => {
|
|
21
|
+
const result = await resolveWidgetGuestRunContext(
|
|
22
|
+
payloadWith({ IsMagicLinkAnonymous: true, WidgetGuestContext: undefined }),
|
|
23
|
+
providerStub,
|
|
24
|
+
);
|
|
25
|
+
expect(result).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('returns null when userRecord is absent', async () => {
|
|
29
|
+
const result = await resolveWidgetGuestRunContext({ email: '', userRecord: undefined, sessionId: 's' }, providerStub);
|
|
30
|
+
expect(result).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('widgetGuestElevation — elevateUserPayload', () => {
|
|
35
|
+
it('swaps in the elevated principal while preserving the guest sessionId for PubSub routing', () => {
|
|
36
|
+
const guestPayload = payloadWith({ IsMagicLinkAnonymous: true });
|
|
37
|
+
const systemUser = { Email: 'system@system.org' } as UserInfo;
|
|
38
|
+
|
|
39
|
+
const elevated = elevateUserPayload(guestPayload, systemUser);
|
|
40
|
+
|
|
41
|
+
expect(elevated.userRecord).toBe(systemUser);
|
|
42
|
+
expect(elevated.email).toBe('system@system.org');
|
|
43
|
+
expect(elevated.isSystemUser).toBe(true);
|
|
44
|
+
// The session id MUST be preserved so progress/streaming still routes to the guest's websocket.
|
|
45
|
+
expect(elevated.sessionId).toBe('sess-123');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('does not mutate the original guest payload', () => {
|
|
49
|
+
const guestPayload = payloadWith({ IsMagicLinkAnonymous: true });
|
|
50
|
+
const systemUser = { Email: 'system@system.org' } as UserInfo;
|
|
51
|
+
|
|
52
|
+
elevateUserPayload(guestPayload, systemUser);
|
|
53
|
+
|
|
54
|
+
expect(guestPayload.isSystemUser).toBeUndefined();
|
|
55
|
+
expect(guestPayload.email).toBe('anonymous@magic-link.local');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
4
|
+
import { verifyHostAssertion, extractHostIdentity } from '../realtimeWidget/host-identity.js';
|
|
5
|
+
import { buildWidgetGuestClaims } from '../realtimeWidget/widgetCore.js';
|
|
6
|
+
|
|
7
|
+
/** A throwaway RSA keypair for signing test host assertions. */
|
|
8
|
+
function keypair(): { privatePem: string; publicPem: string } {
|
|
9
|
+
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
|
10
|
+
return {
|
|
11
|
+
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }) as string,
|
|
12
|
+
publicPem: publicKey.export({ type: 'spki', format: 'pem' }) as string,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const WIDGET_KEY = 'pk_live_acme';
|
|
17
|
+
|
|
18
|
+
function sign(privatePem: string, claims: Record<string, unknown>, opts: jwt.SignOptions = {}): string {
|
|
19
|
+
return jwt.sign(claims, privatePem, { algorithm: 'RS256', audience: WIDGET_KEY, expiresIn: '5m', ...opts });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('verifyHostAssertion', () => {
|
|
23
|
+
it('accepts a valid host assertion and extracts the identity', () => {
|
|
24
|
+
const kp = keypair();
|
|
25
|
+
const token = sign(kp.privatePem, { email: 'jane@acme.com', given_name: 'Jane', family_name: 'Doe', sub: 'host-123' });
|
|
26
|
+
const result = verifyHostAssertion(token, kp.publicPem, WIDGET_KEY);
|
|
27
|
+
expect(result.ok).toBe(true);
|
|
28
|
+
if (result.ok) {
|
|
29
|
+
expect(result.identity).toMatchObject({ email: 'jane@acme.com', firstName: 'Jane', lastName: 'Doe', hostUserId: 'host-123' });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects a missing assertion', () => {
|
|
34
|
+
const kp = keypair();
|
|
35
|
+
expect(verifyHostAssertion(undefined, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'missing' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects when no host key is configured (fail-closed)', () => {
|
|
39
|
+
const kp = keypair();
|
|
40
|
+
const token = sign(kp.privatePem, { email: 'x@y.com' });
|
|
41
|
+
expect(verifyHostAssertion(token, undefined, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'no_key' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects a signature from the wrong key', () => {
|
|
45
|
+
const signer = keypair();
|
|
46
|
+
const other = keypair();
|
|
47
|
+
const token = sign(signer.privatePem, { email: 'x@y.com' });
|
|
48
|
+
expect(verifyHostAssertion(token, other.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'bad_signature' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects a wrong-audience assertion', () => {
|
|
52
|
+
const kp = keypair();
|
|
53
|
+
const token = sign(kp.privatePem, { email: 'x@y.com' }, { audience: 'pk_live_someone_else' });
|
|
54
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'bad_signature' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects an expired assertion', () => {
|
|
58
|
+
const kp = keypair();
|
|
59
|
+
const token = sign(kp.privatePem, { email: 'x@y.com' }, { expiresIn: -10 });
|
|
60
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'expired' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('rejects an assertion with no email', () => {
|
|
64
|
+
const kp = keypair();
|
|
65
|
+
const token = sign(kp.privatePem, { given_name: 'NoEmail' });
|
|
66
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'no_email' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects an assertion with no exp (unbounded lifetime)', () => {
|
|
70
|
+
const kp = keypair();
|
|
71
|
+
// Sign directly with no expiresIn so the token carries iat but no exp.
|
|
72
|
+
const token = jwt.sign({ email: 'x@y.com' }, kp.privatePem, { algorithm: 'RS256', audience: WIDGET_KEY });
|
|
73
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'expired' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects an assertion older than the maxAge cap even when its exp is still in the future', () => {
|
|
77
|
+
const kp = keypair();
|
|
78
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
79
|
+
// iat one hour ago (well past the 10m maxAge) with a far-future exp — the age cap must still reject it.
|
|
80
|
+
const token = jwt.sign({ email: 'x@y.com', iat: nowSec - 3600, exp: nowSec + 3600 }, kp.privatePem, {
|
|
81
|
+
algorithm: 'RS256',
|
|
82
|
+
audience: WIDGET_KEY,
|
|
83
|
+
});
|
|
84
|
+
expect(verifyHostAssertion(token, kp.publicPem, WIDGET_KEY)).toEqual({ ok: false, errorCode: 'expired' });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('buildWidgetGuestClaims with hostIdentity', () => {
|
|
89
|
+
it('carries the host identity as INFORMATIONAL claims but keeps the Anonymous principal email', () => {
|
|
90
|
+
const claims = buildWidgetGuestClaims({
|
|
91
|
+
issuer: 'iss',
|
|
92
|
+
audience: 'mj-magic-link',
|
|
93
|
+
widgetId: 'W',
|
|
94
|
+
sessionId: 's',
|
|
95
|
+
anonymousEmail: 'anonymous@magic-link.local',
|
|
96
|
+
applicationId: 'A',
|
|
97
|
+
guestRoleName: 'Widget Guest',
|
|
98
|
+
nowSeconds: 1000,
|
|
99
|
+
ttlSeconds: 900,
|
|
100
|
+
hostIdentity: { email: 'jane@acme.com', firstName: 'Jane', lastName: 'Doe' },
|
|
101
|
+
});
|
|
102
|
+
// Principal-resolving email stays anonymous (no escalation into a real account).
|
|
103
|
+
expect(claims.email).toBe('anonymous@magic-link.local');
|
|
104
|
+
expect(claims.mj_anon).toBe(true);
|
|
105
|
+
// Host identity rides as informational claims.
|
|
106
|
+
expect(claims.mj_host_email).toBe('jane@acme.com');
|
|
107
|
+
expect(claims.given_name).toBe('Jane');
|
|
108
|
+
expect(claims.name).toBe('Jane Doe');
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('extractHostIdentity', () => {
|
|
113
|
+
it('tolerates firstName/lastName fallbacks', () => {
|
|
114
|
+
const id = extractHostIdentity({ email: 'a@b.com', firstName: 'Al', lastName: 'Bo' });
|
|
115
|
+
expect(id).toMatchObject({ email: 'a@b.com', firstName: 'Al', lastName: 'Bo' });
|
|
116
|
+
});
|
|
117
|
+
});
|