@memberjunction/server 6.1.0-edge.5 → 6.1.0-edge.7
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 +30 -11
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.js +2 -2
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
- package/dist/auth/exampleNewUserSubClass.js +9 -4
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +2 -8
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +4 -12
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/auth/principals.d.ts +92 -0
- package/dist/auth/principals.d.ts.map +1 -0
- package/dist/auth/principals.js +193 -0
- package/dist/auth/principals.js.map +1 -0
- package/dist/config.d.ts +106 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -4
- package/dist/config.js.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +7607 -6853
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +25141 -21645
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +152 -2
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +389 -10
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +26 -2
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/generic/refusalExtensions.d.ts +52 -0
- package/dist/generic/refusalExtensions.d.ts.map +1 -0
- package/dist/generic/refusalExtensions.js +16 -0
- package/dist/generic/refusalExtensions.js.map +1 -0
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +131 -93
- package/dist/index.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +2 -12
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.js +20 -5
- package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +64 -7
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +1 -2
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +8 -4
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/rest/EntityCRUDHandler.d.ts +2 -1
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +3 -18
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/RESTEndpointHandler.d.ts +2 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +3 -16
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
- package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
- package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +2 -3
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/package.json +92 -91
- package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
- package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +190 -3
- package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
- package/src/__tests__/WidgetSessionService.test.ts +4 -1
- package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/newUsers.test.ts +36 -14
- package/src/__tests__/principals.callSites.test.ts +112 -0
- package/src/__tests__/principals.logging.test.ts +160 -0
- package/src/__tests__/principals.test.ts +305 -0
- package/src/__tests__/resolverBase.fls.test.ts +483 -0
- package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
- package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
- package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
- package/src/agentSessions/SessionJanitor.ts +2 -2
- package/src/auth/exampleNewUserSubClass.ts +9 -6
- package/src/auth/magicLink/MagicLinkService.ts +2 -8
- package/src/auth/newUsers.ts +7 -15
- package/src/auth/principals.ts +257 -0
- package/src/config.ts +44 -4
- package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
- package/src/generated/graphql-schemas/__mj.ts +18966 -16452
- package/src/generic/ResolverBase.ts +467 -9
- package/src/generic/RunViewResolver.ts +28 -2
- package/src/generic/refusalExtensions.ts +59 -0
- package/src/index.ts +154 -102
- package/src/realtimeWidget/WidgetSessionService.ts +2 -14
- package/src/realtimeWidget/visitorIdentity.ts +21 -6
- package/src/resolvers/EntityRecordNameResolver.ts +74 -8
- package/src/resolvers/FileCategoryResolver.ts +2 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
- package/src/resolvers/QuerySystemUserResolver.ts +1 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
- package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/UserViewResolver.ts +8 -4
- package/src/rest/EntityCRUDHandler.ts +3 -19
- package/src/rest/RESTEndpointHandler.ts +3 -17
- package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
- package/src/serverExtensionReservedRoots.ts +2 -3
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
- package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
- package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
- package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
- package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
- package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
- package/dist/resolvers/TelephonyResolver.d.ts +0 -26
- package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/TelephonyResolver.js +0 -86
- package/dist/resolvers/TelephonyResolver.js.map +0 -1
- package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
- package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/VonageTelephonyResolver.js +0 -86
- package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
- package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
- package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
- package/dist/telephony/RingCentralTelephonyService.js +0 -262
- package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
- package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
- package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
- package/dist/telephony/TeamsMeetingsRouter.js +0 -96
- package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
- package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
- package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
- package/dist/telephony/TeamsMeetingsService.js +0 -196
- package/dist/telephony/TeamsMeetingsService.js.map +0 -1
- package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
- package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
- package/dist/telephony/TwilioTelephonyRouter.js +0 -143
- package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
- package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
- package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
- package/dist/telephony/TwilioTelephonyService.js +0 -193
- package/dist/telephony/TwilioTelephonyService.js.map +0 -1
- package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
- package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
- package/dist/telephony/VonageTelephonyRouter.js +0 -201
- package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
- package/dist/telephony/VonageTelephonyService.d.ts +0 -90
- package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
- package/dist/telephony/VonageTelephonyService.js +0 -191
- package/dist/telephony/VonageTelephonyService.js.map +0 -1
- package/dist/telephony/calendar-scheduler.d.ts +0 -67
- package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
- package/dist/telephony/calendar-scheduler.js +0 -133
- package/dist/telephony/calendar-scheduler.js.map +0 -1
- package/dist/telephony/index.d.ts +0 -29
- package/dist/telephony/index.d.ts.map +0 -1
- package/dist/telephony/index.js +0 -38
- package/dist/telephony/index.js.map +0 -1
- package/dist/telephony/media-upgrade-router.d.ts +0 -47
- package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
- package/dist/telephony/media-upgrade-router.js +0 -72
- package/dist/telephony/media-upgrade-router.js.map +0 -1
- package/dist/telephony/ringcentral-runtime.d.ts +0 -14
- package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
- package/dist/telephony/ringcentral-runtime.js +0 -18
- package/dist/telephony/ringcentral-runtime.js.map +0 -1
- package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
- package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
- package/dist/telephony/teams-meetings-runtime.js +0 -20
- package/dist/telephony/teams-meetings-runtime.js.map +0 -1
- package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
- package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
- package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
- package/dist/telephony/telephony-runtime.d.ts +0 -14
- package/dist/telephony/telephony-runtime.d.ts.map +0 -1
- package/dist/telephony/telephony-runtime.js +0 -18
- package/dist/telephony/telephony-runtime.js.map +0 -1
- package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
- package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/twilioMediaRegistry.js +0 -106
- package/dist/telephony/twilioMediaRegistry.js.map +0 -1
- package/dist/telephony/vonage-runtime.d.ts +0 -14
- package/dist/telephony/vonage-runtime.d.ts.map +0 -1
- package/dist/telephony/vonage-runtime.js +0 -18
- package/dist/telephony/vonage-runtime.js.map +0 -1
- package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
- package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/vonageMediaRegistry.js +0 -158
- package/dist/telephony/vonageMediaRegistry.js.map +0 -1
- package/src/__tests__/media-upgrade-router.test.ts +0 -43
- package/src/__tests__/teams-meetings-service.test.ts +0 -65
- package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
- package/src/__tests__/twilio-telephony-service.test.ts +0 -23
- package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
- package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
- package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
- package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
- package/src/resolvers/TelephonyResolver.ts +0 -63
- package/src/resolvers/VonageTelephonyResolver.ts +0 -63
- package/src/telephony/RingCentralTelephonyService.ts +0 -342
- package/src/telephony/TeamsMeetingsRouter.ts +0 -124
- package/src/telephony/TeamsMeetingsService.ts +0 -268
- package/src/telephony/TwilioTelephonyRouter.ts +0 -184
- package/src/telephony/TwilioTelephonyService.ts +0 -261
- package/src/telephony/VonageTelephonyRouter.ts +0 -239
- package/src/telephony/VonageTelephonyService.ts +0 -259
- package/src/telephony/calendar-scheduler.ts +0 -176
- package/src/telephony/index.ts +0 -43
- package/src/telephony/media-upgrade-router.ts +0 -79
- package/src/telephony/ringcentral-runtime.ts +0 -22
- package/src/telephony/teams-meetings-runtime.ts +0 -24
- package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
- package/src/telephony/telephony-runtime.ts +0 -22
- package/src/telephony/twilioMediaRegistry.ts +0 -137
- package/src/telephony/vonage-runtime.ts +0 -22
- package/src/telephony/vonageMediaRegistry.ts +0 -200
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// ResolverBase transitively pulls in type-graphql decorators, which need the
|
|
2
|
+
// Reflect.metadata polyfill at import time.
|
|
3
|
+
import 'reflect-metadata';
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
5
|
+
import { CompositeKey } from '@memberjunction/core';
|
|
6
|
+
import type { DatabaseProviderBase, EntityInfo, UserInfo } from '@memberjunction/core';
|
|
7
|
+
import { EntityRecordNameResolver } from '../resolvers/EntityRecordNameResolver.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `GetEntityRecordName` must not hand back a name the caller cannot READ.
|
|
11
|
+
*
|
|
12
|
+
* This resolver predates field-level security and took no user context at all, which made it an
|
|
13
|
+
* open side channel: the name never travelled through `MapFieldNamesToCodeNames`, the boundary
|
|
14
|
+
* that strips denied fields, so a caller denied READ on an entity's name field could still obtain
|
|
15
|
+
* that name from here.
|
|
16
|
+
*
|
|
17
|
+
* FLS turned the latent gap into a live bypass, and the shape of it is worth remembering: the
|
|
18
|
+
* form-field FK control falls through to this query PRECISELY WHEN the joined display column is
|
|
19
|
+
* denied. The fallback that exists to handle a denial was the thing that defeated it. Found in
|
|
20
|
+
* manual Phase 5 testing — a Contract form showed its Client's name while the grid, correctly,
|
|
21
|
+
* did not.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DENIED_USER = { ID: 'u-1', Email: 'restricted@test' } as unknown as UserInfo;
|
|
25
|
+
|
|
26
|
+
/** An entity whose name field is denied or allowed, per the flag. */
|
|
27
|
+
function entityInfo(opts: { fls: boolean; deniedNames?: string[]; nameField?: string }): EntityInfo {
|
|
28
|
+
const nameFieldName = opts.nameField ?? 'Name';
|
|
29
|
+
return {
|
|
30
|
+
Name: 'Clients',
|
|
31
|
+
EnableFieldLevelSecurity: opts.fls,
|
|
32
|
+
Fields: [
|
|
33
|
+
{ Name: 'ID', IsNameField: false },
|
|
34
|
+
{ Name: nameFieldName, IsNameField: true },
|
|
35
|
+
{ Name: 'Industry', IsNameField: false },
|
|
36
|
+
],
|
|
37
|
+
GetDeniedReadFields: () => new Set(opts.deniedNames ?? []),
|
|
38
|
+
} as unknown as EntityInfo;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A provider that would happily return the name — the resolver must decide not to ask. */
|
|
42
|
+
function providerWith(entity: EntityInfo, getName = vi.fn().mockResolvedValue('Acme Manufacturing')) {
|
|
43
|
+
return {
|
|
44
|
+
Entities: [entity],
|
|
45
|
+
GetEntityRecordName: getName,
|
|
46
|
+
} as unknown as DatabaseProviderBase & { GetEntityRecordName: typeof getName };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Exposes the inner method so the test does not need a GraphQL context. */
|
|
50
|
+
class Probe extends EntityRecordNameResolver {
|
|
51
|
+
public Inner(md: DatabaseProviderBase, entityName: string, pk: CompositeKey, user?: UserInfo) {
|
|
52
|
+
// The resolver resolves the acting user from the payload; supply it directly.
|
|
53
|
+
vi.spyOn(this as never, 'GetUserFromPayload').mockReturnValue(user as never);
|
|
54
|
+
return this.InnerGetEntityRecordName(md, entityName, pk, { email: 'restricted@test' } as never);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const KEY = new CompositeKey([{ FieldName: 'ID', Value: 'c-1' }]);
|
|
59
|
+
|
|
60
|
+
describe('GetEntityRecordName — field-security gate', () => {
|
|
61
|
+
it('withholds the name when the name field is read-denied', async () => {
|
|
62
|
+
const getName = vi.fn().mockResolvedValue('Acme Manufacturing');
|
|
63
|
+
const md = providerWith(entityInfo({ fls: true, deniedNames: ['name'] }), getName);
|
|
64
|
+
|
|
65
|
+
const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
|
|
66
|
+
|
|
67
|
+
expect(result.Success).toBe(false);
|
|
68
|
+
expect(result.RecordName).toBeUndefined();
|
|
69
|
+
// Not merely filtered after the fact — the lookup must not run at all. The provider's
|
|
70
|
+
// record-name cache is keyed by entity + primary key and NOT by user, so a name fetched
|
|
71
|
+
// here would be visible to any later caller.
|
|
72
|
+
expect(getName).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns the name when the user may read the name field', async () => {
|
|
76
|
+
const md = providerWith(entityInfo({ fls: true, deniedNames: ['industry'] }));
|
|
77
|
+
|
|
78
|
+
const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
|
|
79
|
+
|
|
80
|
+
expect(result.Success).toBe(true);
|
|
81
|
+
expect(result.RecordName).toBe('Acme Manufacturing');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('is inert when the entity has field security switched off', async () => {
|
|
85
|
+
// Nearly every entity. A denial set that would otherwise match must not apply.
|
|
86
|
+
const md = providerWith(entityInfo({ fls: false, deniedNames: ['name'] }));
|
|
87
|
+
|
|
88
|
+
const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
|
|
89
|
+
|
|
90
|
+
expect(result.Success).toBe(true);
|
|
91
|
+
expect(result.RecordName).toBe('Acme Manufacturing');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('honours a designated name field that is not literally called "Name"', async () => {
|
|
95
|
+
const md = providerWith(entityInfo({ fls: true, nameField: 'Title', deniedNames: ['title'] }));
|
|
96
|
+
|
|
97
|
+
const result = await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
|
|
98
|
+
|
|
99
|
+
expect(result.Success).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('passes the acting user to the provider so row-level security has an identity', async () => {
|
|
103
|
+
const getName = vi.fn().mockResolvedValue('Acme Manufacturing');
|
|
104
|
+
const md = providerWith(entityInfo({ fls: true }), getName);
|
|
105
|
+
|
|
106
|
+
await new Probe().Inner(md, 'Clients', KEY, DENIED_USER);
|
|
107
|
+
|
|
108
|
+
expect(getName).toHaveBeenCalledWith('Clients', expect.anything(), DENIED_USER);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('does not disclose which of "missing" or "restricted" applies', async () => {
|
|
112
|
+
// The denial status must read the same as a genuine not-found, so the query cannot be
|
|
113
|
+
// used to probe for which entities carry restricted name fields.
|
|
114
|
+
const denied = await new Probe().Inner(
|
|
115
|
+
providerWith(entityInfo({ fls: true, deniedNames: ['name'] })), 'Clients', KEY, DENIED_USER);
|
|
116
|
+
expect(denied.Status).not.toMatch(/denied|permission|restricted|field.security/i);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -2769,6 +2769,26 @@ describe('RealtimeClientSessionResolver — app awareness (applicationId / appCo
|
|
|
2769
2769
|
const relayArg = executeRelayedToolMock.mock.calls[0][0] as { AllowedAgents?: Array<{ agentId: string }> };
|
|
2770
2770
|
expect(relayArg.AllowedAgents?.map(a => a.agentId)).toEqual(['skip-1']);
|
|
2771
2771
|
});
|
|
2772
|
+
|
|
2773
|
+
it('passes the persisted directActions from the session config into ExecuteRelayedTool', async () => {
|
|
2774
|
+
currentProvider = makeProvider(() =>
|
|
2775
|
+
makeSessionEntity({
|
|
2776
|
+
Config_: JSON.stringify({
|
|
2777
|
+
targetAgentID: 'lead-1',
|
|
2778
|
+
directActions: { enabled: true, actionNames: ['SendEmail'] },
|
|
2779
|
+
}),
|
|
2780
|
+
}),
|
|
2781
|
+
);
|
|
2782
|
+
executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"ok":true}', Success: true });
|
|
2783
|
+
const resolver = makeResolver();
|
|
2784
|
+
|
|
2785
|
+
await resolver.ExecuteRealtimeSessionTool(
|
|
2786
|
+
'session-1', 'call-1', 'SendEmail', '{"To":"user@test.com"}', makeCtx(), makePubSub(),
|
|
2787
|
+
);
|
|
2788
|
+
|
|
2789
|
+
const relayArg = executeRelayedToolMock.mock.calls[0][0] as { DirectActions?: { enabled: boolean; actionNames: string[] } };
|
|
2790
|
+
expect(relayArg.DirectActions).toEqual({ enabled: true, actionNames: ['SendEmail'] });
|
|
2791
|
+
});
|
|
2772
2792
|
});
|
|
2773
2793
|
|
|
2774
2794
|
describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3371)', () => {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
3
|
+
import { MJGlobal, RegisterClass } from '@memberjunction/global';
|
|
4
|
+
import { BaseRealtimeModel, RealtimeProxyRegistry, IRealtimeSession, RealtimeSessionParams } from '@memberjunction/ai';
|
|
5
|
+
import { createRealtimeSdpBrokerRouter, createOpenAILiveBrokerRouter, requireTicket } from '../rest/RealtimeSdpBrokerHandler.js';
|
|
6
|
+
|
|
7
|
+
@RegisterClass(BaseRealtimeModel, 'MockOpenAILiveForBrokerTest', 10)
|
|
8
|
+
class MockOpenAILiveForBrokerTest extends BaseRealtimeModel {
|
|
9
|
+
public static LastSessionConfig: Record<string, unknown> | undefined;
|
|
10
|
+
|
|
11
|
+
public override async StartSession(_params: RealtimeSessionParams): Promise<IRealtimeSession> {
|
|
12
|
+
throw new Error('Not needed in broker test');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public override async ExchangeWebRtcSdp(
|
|
16
|
+
offerSdp: string,
|
|
17
|
+
sessionConfig?: Record<string, unknown>
|
|
18
|
+
): Promise<{ answerSdp: string; sessionId: string; prebillSeconds: number }> {
|
|
19
|
+
MockOpenAILiveForBrokerTest.LastSessionConfig = sessionConfig;
|
|
20
|
+
if (offerSdp === 'trigger_error') {
|
|
21
|
+
throw new Error('Upstream OpenAI internal error details');
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
answerSdp: `mock_answer_for_${offerSdp}`,
|
|
25
|
+
sessionId: 'sess_mock_123',
|
|
26
|
+
prebillSeconds: 15,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeMockReqRes(query: Record<string, string>, body: unknown): {
|
|
32
|
+
req: Request;
|
|
33
|
+
res: Response;
|
|
34
|
+
getStatus: () => number;
|
|
35
|
+
getJson: () => Record<string, unknown>;
|
|
36
|
+
} {
|
|
37
|
+
let statusCode = 200;
|
|
38
|
+
let jsonBody: Record<string, unknown> = {};
|
|
39
|
+
|
|
40
|
+
const res = {
|
|
41
|
+
locals: {} as Record<string, unknown>,
|
|
42
|
+
status(code: number): Response {
|
|
43
|
+
statusCode = code;
|
|
44
|
+
return res as unknown as Response;
|
|
45
|
+
},
|
|
46
|
+
json(data: Record<string, unknown>): Response {
|
|
47
|
+
jsonBody = data;
|
|
48
|
+
return res as unknown as Response;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const req = {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
url: '/',
|
|
55
|
+
query,
|
|
56
|
+
body,
|
|
57
|
+
headers: {},
|
|
58
|
+
_body: true,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
req: req as unknown as Request,
|
|
63
|
+
res: res as unknown as Response,
|
|
64
|
+
getStatus: () => statusCode,
|
|
65
|
+
getJson: () => jsonBody,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('RealtimeSdpBrokerHandler', () => {
|
|
70
|
+
let router: ReturnType<typeof createRealtimeSdpBrokerRouter>;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
router = createRealtimeSdpBrokerRouter();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('createOpenAILiveBrokerRouter is backwards-compatible alias', () => {
|
|
77
|
+
expect(createOpenAILiveBrokerRouter).toBe(createRealtimeSdpBrokerRouter);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('requireTicket middleware rejects missing ticket without calling next()', () => {
|
|
81
|
+
const { req, res, getStatus, getJson } = makeMockReqRes({}, {});
|
|
82
|
+
const next = vi.fn();
|
|
83
|
+
requireTicket(req, res, next as NextFunction);
|
|
84
|
+
expect(getStatus()).toBe(401);
|
|
85
|
+
expect(getJson().error).toContain('missing ticket');
|
|
86
|
+
expect(next).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('requireTicket middleware rejects invalid ticket without calling next()', () => {
|
|
90
|
+
const { req, res, getStatus, getJson } = makeMockReqRes({ ticket: 'non-existent-ticket' }, {});
|
|
91
|
+
const next = vi.fn();
|
|
92
|
+
requireTicket(req, res, next as NextFunction);
|
|
93
|
+
expect(getStatus()).toBe(401);
|
|
94
|
+
expect(getJson().error).toContain('invalid or expired ticket');
|
|
95
|
+
expect(next).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects missing ticket with 401', async () => {
|
|
99
|
+
const { req, res, getStatus, getJson } = makeMockReqRes({}, { sdp: 'v=0...' });
|
|
100
|
+
// @ts-expect-error reaching the route handler directly
|
|
101
|
+
await router.handle(req, res, () => {});
|
|
102
|
+
expect(getStatus()).toBe(401);
|
|
103
|
+
expect(getJson().error).toContain('missing ticket');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('rejects invalid or expired ticket with 401', async () => {
|
|
107
|
+
const { req, res, getStatus, getJson } = makeMockReqRes({ ticket: 'non-existent-ticket' }, { sdp: 'v=0...' });
|
|
108
|
+
// @ts-expect-error reaching the route handler directly
|
|
109
|
+
await router.handle(req, res, () => {});
|
|
110
|
+
expect(getStatus()).toBe(401);
|
|
111
|
+
expect(getJson().error).toContain('invalid or expired ticket');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects missing sdp in request body with 400', async () => {
|
|
115
|
+
const ticket = RealtimeProxyRegistry.Instance.Issue({
|
|
116
|
+
UpstreamUrl: 'https://api.openai.com/v1/live/sessions',
|
|
117
|
+
UpstreamAuthHeader: 'test-key',
|
|
118
|
+
DriverClass: 'MockOpenAILiveForBrokerTest',
|
|
119
|
+
TTLSeconds: 60,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const { req, res, getStatus, getJson } = makeMockReqRes({ ticket: ticket.ID }, {});
|
|
123
|
+
// @ts-expect-error reaching the route handler directly
|
|
124
|
+
await router.handle(req, res, () => {});
|
|
125
|
+
expect(getStatus()).toBe(400);
|
|
126
|
+
expect(getJson().error).toContain('Missing or invalid sdp');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('exchanges SDP offer for answer with a valid ticket and consumes the ticket', async () => {
|
|
130
|
+
const serverConfig = { model: 'gpt-live-1', authoritative: true };
|
|
131
|
+
const ticket = RealtimeProxyRegistry.Instance.Issue({
|
|
132
|
+
UpstreamUrl: 'https://api.openai.com/v1/live/sessions',
|
|
133
|
+
UpstreamAuthHeader: 'test-key',
|
|
134
|
+
DriverClass: 'MockOpenAILiveForBrokerTest',
|
|
135
|
+
SessionConfig: serverConfig,
|
|
136
|
+
TTLSeconds: 60,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Client attempts to pass its own session config
|
|
140
|
+
const clientAttemptedSession = { model: 'tampered-model' };
|
|
141
|
+
const { req, res, getStatus, getJson } = makeMockReqRes(
|
|
142
|
+
{ ticket: ticket.ID },
|
|
143
|
+
{ sdp: 'offer_123', session: clientAttemptedSession }
|
|
144
|
+
);
|
|
145
|
+
// @ts-expect-error reaching the route handler directly
|
|
146
|
+
await router.handle(req, res, () => {});
|
|
147
|
+
|
|
148
|
+
expect(getStatus()).toBe(200);
|
|
149
|
+
const json = getJson();
|
|
150
|
+
expect(json.answerSdp).toBe('mock_answer_for_offer_123');
|
|
151
|
+
expect(json.sessionId).toBe('sess_mock_123');
|
|
152
|
+
expect(json.prebillSeconds).toBe(15);
|
|
153
|
+
// Verify server's authoritative session config was passed, not the client's
|
|
154
|
+
expect(MockOpenAILiveForBrokerTest.LastSessionConfig).toEqual(serverConfig);
|
|
155
|
+
|
|
156
|
+
// Ticket is single-use: consuming again should fail
|
|
157
|
+
const reused = makeMockReqRes({ ticket: ticket.ID }, { sdp: 'offer_123' });
|
|
158
|
+
// @ts-expect-error reaching the route handler directly
|
|
159
|
+
await router.handle(reused.req, reused.res, () => {});
|
|
160
|
+
expect(reused.getStatus()).toBe(401);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects ticket without upstream auth header with 500', async () => {
|
|
164
|
+
const ticket = RealtimeProxyRegistry.Instance.Issue({
|
|
165
|
+
UpstreamUrl: 'https://api.openai.com/v1/live/sessions',
|
|
166
|
+
DriverClass: 'MockOpenAILiveForBrokerTest',
|
|
167
|
+
TTLSeconds: 60,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const { req, res, getStatus, getJson } = makeMockReqRes({ ticket: ticket.ID }, { sdp: 'offer_123' });
|
|
171
|
+
// @ts-expect-error reaching the route handler directly
|
|
172
|
+
await router.handle(req, res, () => {});
|
|
173
|
+
expect(getStatus()).toBe(500);
|
|
174
|
+
expect(getJson().error).toContain('missing upstream credentials');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('sanitizes 502 error when upstream exchange throws without leaking internal error', async () => {
|
|
178
|
+
const ticket = RealtimeProxyRegistry.Instance.Issue({
|
|
179
|
+
UpstreamUrl: 'https://api.openai.com/v1/live/sessions',
|
|
180
|
+
UpstreamAuthHeader: 'test-key',
|
|
181
|
+
DriverClass: 'MockOpenAILiveForBrokerTest',
|
|
182
|
+
TTLSeconds: 60,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const { req, res, getStatus, getJson } = makeMockReqRes({ ticket: ticket.ID }, { sdp: 'trigger_error' });
|
|
186
|
+
// @ts-expect-error reaching the route handler directly
|
|
187
|
+
await router.handle(req, res, () => {});
|
|
188
|
+
expect(getStatus()).toBe(502);
|
|
189
|
+
expect(getJson().error).toBe('Realtime WebRTC handshake failed');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// polyfill is loaded first. MUST precede any import that pulls in the resolver file.
|
|
3
3
|
import 'reflect-metadata';
|
|
4
4
|
|
|
5
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
6
6
|
import { RemoteBrowserCapabilityNotSupportedError } from '@memberjunction/remote-browser-base';
|
|
7
7
|
|
|
8
8
|
// type-graphql's `@Field()`/`@ObjectType()`/`@Mutation()` decorators need `emitDecoratorMetadata`
|
|
@@ -32,14 +32,21 @@ vi.mock('type-graphql', () => {
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
// --- Mock the live-session engine the resolver drives. A single mutable session object lets each
|
|
35
|
-
// test script what StartAudioStream/StopAudioStream
|
|
35
|
+
// test script what StartAudioStream/StopAudioStream (and, for the TTL-sweep tests below,
|
|
36
|
+
// StartScreencast/StopScreencast) do (succeed / throw a capability error). ---
|
|
36
37
|
interface FakeLiveSession {
|
|
37
38
|
StartAudioStream: ReturnType<typeof vi.fn>;
|
|
38
39
|
StopAudioStream: ReturnType<typeof vi.fn>;
|
|
40
|
+
StartScreencast: ReturnType<typeof vi.fn>;
|
|
41
|
+
StopScreencast: ReturnType<typeof vi.fn>;
|
|
42
|
+
GetCurrentUrl: ReturnType<typeof vi.fn>;
|
|
39
43
|
}
|
|
40
44
|
const liveSession: FakeLiveSession = {
|
|
41
45
|
StartAudioStream: vi.fn(async () => undefined),
|
|
42
46
|
StopAudioStream: vi.fn(async () => undefined),
|
|
47
|
+
StartScreencast: vi.fn(async () => undefined),
|
|
48
|
+
StopScreencast: vi.fn(async () => undefined),
|
|
49
|
+
GetCurrentUrl: vi.fn(() => 'https://example.com'),
|
|
43
50
|
};
|
|
44
51
|
const startSessionMock = vi.fn(async () => liveSession);
|
|
45
52
|
const getSessionMock = vi.fn(() => liveSession as FakeLiveSession | undefined);
|
|
@@ -116,6 +123,8 @@ describe('RemoteBrowserActionResolver — audio stream', () => {
|
|
|
116
123
|
resolver = new TestableResolver();
|
|
117
124
|
liveSession.StartAudioStream.mockReset().mockResolvedValue(undefined);
|
|
118
125
|
liveSession.StopAudioStream.mockReset().mockResolvedValue(undefined);
|
|
126
|
+
liveSession.StartScreencast.mockReset().mockResolvedValue(undefined);
|
|
127
|
+
liveSession.StopScreencast.mockReset().mockResolvedValue(undefined);
|
|
119
128
|
startSessionMock.mockReset().mockResolvedValue(liveSession);
|
|
120
129
|
getSessionMock.mockReset().mockReturnValue(liveSession);
|
|
121
130
|
});
|
|
@@ -194,3 +203,73 @@ describe('RemoteBrowserActionResolver — audio stream', () => {
|
|
|
194
203
|
await expect(resolver.StopRemoteBrowserAudioStream('sess-1', ctx)).resolves.toBe(true);
|
|
195
204
|
});
|
|
196
205
|
});
|
|
206
|
+
|
|
207
|
+
describe('RemoteBrowserActionResolver — stream idempotency-map TTL sweep', () => {
|
|
208
|
+
// Regression coverage for the Round 13 memory-leak-audit finding: `startedScreencasts` /
|
|
209
|
+
// `startedAudioStreams` are process-lifetime maps (the resolver is instantiated once per process by
|
|
210
|
+
// type-graphql's default container) that only ever shrink via an explicit Stop call. A session that
|
|
211
|
+
// crashes/disconnects/times out before calling Stop — a normal occurrence, not an edge case — used to
|
|
212
|
+
// leave its entry behind forever. These tests drive the maps past their internal TTL (see
|
|
213
|
+
// MAX_STREAM_ENTRY_AGE_MS in RemoteBrowserActionResolver.ts) WITHOUT ever calling Stop, and assert the
|
|
214
|
+
// swept entry no longer blocks a fresh Start — proving the leak is now bounded rather than permanent.
|
|
215
|
+
let resolver: TestableResolver;
|
|
216
|
+
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
resolver = new TestableResolver();
|
|
219
|
+
liveSession.StartAudioStream.mockReset().mockResolvedValue(undefined);
|
|
220
|
+
liveSession.StopAudioStream.mockReset().mockResolvedValue(undefined);
|
|
221
|
+
liveSession.StartScreencast.mockReset().mockResolvedValue(undefined);
|
|
222
|
+
liveSession.StopScreencast.mockReset().mockResolvedValue(undefined);
|
|
223
|
+
startSessionMock.mockReset().mockResolvedValue(liveSession);
|
|
224
|
+
getSessionMock.mockReset().mockReturnValue(liveSession);
|
|
225
|
+
vi.useFakeTimers();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
afterEach(() => {
|
|
229
|
+
vi.useRealTimers();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('audio stream: a crashed session (no Stop call) is forgotten after the TTL, so a later Start re-invokes StartAudioStream', async () => {
|
|
233
|
+
const pubSub = makePubSub();
|
|
234
|
+
await resolver.StartRemoteBrowserAudioStream('sess-crash-1', ctx, pubSub as never);
|
|
235
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(1);
|
|
236
|
+
|
|
237
|
+
// Well under the TTL: still idempotent, exactly like a legitimately long-lived active stream.
|
|
238
|
+
vi.advanceTimersByTime(60 * 60 * 1000); // +1h
|
|
239
|
+
await resolver.StartRemoteBrowserAudioStream('sess-crash-1', ctx, pubSub as never);
|
|
240
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(1);
|
|
241
|
+
|
|
242
|
+
// Past the TTL, with Stop NEVER called (simulating a crash/disconnect): the stale entry is swept,
|
|
243
|
+
// and a fresh Start is treated as a real (re)start rather than trusting the dead flag forever.
|
|
244
|
+
vi.advanceTimersByTime(5 * 60 * 60 * 1000); // +5h more (6h total, past the 4h TTL)
|
|
245
|
+
await resolver.StartRemoteBrowserAudioStream('sess-crash-1', ctx, pubSub as never);
|
|
246
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(2);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('screencast: a crashed session (no Stop call) is forgotten after the TTL, so a later Start re-invokes StartScreencast', async () => {
|
|
250
|
+
const pubSub = makePubSub();
|
|
251
|
+
await resolver.StartRemoteBrowserScreencast('sess-crash-2', ctx, pubSub as never);
|
|
252
|
+
expect(liveSession.StartScreencast).toHaveBeenCalledTimes(1);
|
|
253
|
+
|
|
254
|
+
vi.advanceTimersByTime(6 * 60 * 60 * 1000); // 6h, past the 4h TTL
|
|
255
|
+
await resolver.StartRemoteBrowserScreencast('sess-crash-2', ctx, pubSub as never);
|
|
256
|
+
expect(liveSession.StartScreencast).toHaveBeenCalledTimes(2);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('sweeping one stale surface does not evict an unrelated surface still inside the TTL', async () => {
|
|
260
|
+
const pubSub = makePubSub();
|
|
261
|
+
await resolver.StartRemoteBrowserAudioStream('sess-old', ctx, pubSub as never);
|
|
262
|
+
|
|
263
|
+
vi.advanceTimersByTime(3 * 60 * 60 * 1000); // +3h — sess-old is now 3h old, sess-new is fresh
|
|
264
|
+
await resolver.StartRemoteBrowserAudioStream('sess-new', ctx, pubSub as never);
|
|
265
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(2);
|
|
266
|
+
|
|
267
|
+
vi.advanceTimersByTime(2 * 60 * 60 * 1000); // +2h more — sess-old is 5h old (past TTL), sess-new is 2h old (within TTL)
|
|
268
|
+
// Triggering the sweep via a third session's Start must not evict sess-new's still-valid entry.
|
|
269
|
+
await resolver.StartRemoteBrowserAudioStream('sess-trigger', ctx, pubSub as never);
|
|
270
|
+
await resolver.StartRemoteBrowserAudioStream('sess-new', ctx, pubSub as never);
|
|
271
|
+
// sess-old, sess-new(first call), sess-trigger all real starts = 3 so far; the second sess-new
|
|
272
|
+
// call must still be idempotent (no 4th real start).
|
|
273
|
+
expect(liveSession.StartAudioStream).toHaveBeenCalledTimes(3);
|
|
274
|
+
});
|
|
275
|
+
});
|