@memberjunction/server 6.1.0-edge.6 → 6.1.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.
Files changed (177) hide show
  1. package/README.md +24 -9
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +36 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +9 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
  8. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  9. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
  10. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  11. package/dist/generated/graphql-schemas/__mj.d.ts +2842 -2144
  12. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  13. package/dist/generated/graphql-schemas/__mj.js +5334 -2143
  14. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  15. package/dist/generic/ResolverBase.d.ts +119 -4
  16. package/dist/generic/ResolverBase.d.ts.map +1 -1
  17. package/dist/generic/ResolverBase.js +264 -11
  18. package/dist/generic/ResolverBase.js.map +1 -1
  19. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  20. package/dist/generic/RunViewResolver.js +26 -2
  21. package/dist/generic/RunViewResolver.js.map +1 -1
  22. package/dist/generic/refusalExtensions.d.ts +52 -0
  23. package/dist/generic/refusalExtensions.d.ts.map +1 -0
  24. package/dist/generic/refusalExtensions.js +16 -0
  25. package/dist/generic/refusalExtensions.js.map +1 -0
  26. package/dist/index.d.ts +9 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +115 -91
  29. package/dist/index.js.map +1 -1
  30. package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
  31. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  32. package/dist/resolvers/EntityRecordNameResolver.js +64 -7
  33. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  34. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
  35. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
  37. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  38. package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
  39. package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
  40. package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
  41. package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
  42. package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
  43. package/dist/serverExtensionReservedRoots.js +2 -3
  44. package/dist/serverExtensionReservedRoots.js.map +1 -1
  45. package/package.json +92 -92
  46. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
  47. package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
  48. package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
  49. package/src/__tests__/ResolverBase.filterEscaping.test.ts +63 -0
  50. package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
  51. package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
  52. package/src/__tests__/resolverBase.fls.test.ts +483 -0
  53. package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
  54. package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
  55. package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
  56. package/src/config.ts +12 -0
  57. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
  58. package/src/generated/graphql-schemas/__mj.ts +6590 -4287
  59. package/src/generic/ResolverBase.ts +313 -10
  60. package/src/generic/RunViewResolver.ts +28 -2
  61. package/src/generic/refusalExtensions.ts +59 -0
  62. package/src/index.ts +137 -100
  63. package/src/resolvers/EntityRecordNameResolver.ts +74 -8
  64. package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
  65. package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
  66. package/src/serverExtensionReservedRoots.ts +2 -3
  67. package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
  68. package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
  69. package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
  70. package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
  71. package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
  72. package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
  73. package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
  74. package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
  75. package/dist/resolvers/TelephonyResolver.d.ts +0 -26
  76. package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
  77. package/dist/resolvers/TelephonyResolver.js +0 -86
  78. package/dist/resolvers/TelephonyResolver.js.map +0 -1
  79. package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
  80. package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
  81. package/dist/resolvers/VonageTelephonyResolver.js +0 -86
  82. package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
  83. package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
  84. package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
  85. package/dist/telephony/RingCentralTelephonyService.js +0 -262
  86. package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
  87. package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
  88. package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
  89. package/dist/telephony/TeamsMeetingsRouter.js +0 -112
  90. package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
  91. package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
  92. package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
  93. package/dist/telephony/TeamsMeetingsService.js +0 -196
  94. package/dist/telephony/TeamsMeetingsService.js.map +0 -1
  95. package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
  96. package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
  97. package/dist/telephony/TwilioTelephonyRouter.js +0 -143
  98. package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
  99. package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
  100. package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
  101. package/dist/telephony/TwilioTelephonyService.js +0 -193
  102. package/dist/telephony/TwilioTelephonyService.js.map +0 -1
  103. package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
  104. package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
  105. package/dist/telephony/VonageTelephonyRouter.js +0 -201
  106. package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
  107. package/dist/telephony/VonageTelephonyService.d.ts +0 -90
  108. package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
  109. package/dist/telephony/VonageTelephonyService.js +0 -191
  110. package/dist/telephony/VonageTelephonyService.js.map +0 -1
  111. package/dist/telephony/calendar-scheduler.d.ts +0 -67
  112. package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
  113. package/dist/telephony/calendar-scheduler.js +0 -133
  114. package/dist/telephony/calendar-scheduler.js.map +0 -1
  115. package/dist/telephony/index.d.ts +0 -29
  116. package/dist/telephony/index.d.ts.map +0 -1
  117. package/dist/telephony/index.js +0 -38
  118. package/dist/telephony/index.js.map +0 -1
  119. package/dist/telephony/media-upgrade-router.d.ts +0 -47
  120. package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
  121. package/dist/telephony/media-upgrade-router.js +0 -72
  122. package/dist/telephony/media-upgrade-router.js.map +0 -1
  123. package/dist/telephony/ringcentral-runtime.d.ts +0 -14
  124. package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
  125. package/dist/telephony/ringcentral-runtime.js +0 -18
  126. package/dist/telephony/ringcentral-runtime.js.map +0 -1
  127. package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
  128. package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
  129. package/dist/telephony/teams-meetings-runtime.js +0 -20
  130. package/dist/telephony/teams-meetings-runtime.js.map +0 -1
  131. package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
  132. package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
  133. package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
  134. package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
  135. package/dist/telephony/telephony-runtime.d.ts +0 -14
  136. package/dist/telephony/telephony-runtime.d.ts.map +0 -1
  137. package/dist/telephony/telephony-runtime.js +0 -18
  138. package/dist/telephony/telephony-runtime.js.map +0 -1
  139. package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
  140. package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
  141. package/dist/telephony/twilioMediaRegistry.js +0 -106
  142. package/dist/telephony/twilioMediaRegistry.js.map +0 -1
  143. package/dist/telephony/vonage-runtime.d.ts +0 -14
  144. package/dist/telephony/vonage-runtime.d.ts.map +0 -1
  145. package/dist/telephony/vonage-runtime.js +0 -18
  146. package/dist/telephony/vonage-runtime.js.map +0 -1
  147. package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
  148. package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
  149. package/dist/telephony/vonageMediaRegistry.js +0 -158
  150. package/dist/telephony/vonageMediaRegistry.js.map +0 -1
  151. package/src/__tests__/media-upgrade-router.test.ts +0 -43
  152. package/src/__tests__/teams-meetings-service.test.ts +0 -65
  153. package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
  154. package/src/__tests__/twilio-telephony-service.test.ts +0 -23
  155. package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
  156. package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
  157. package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
  158. package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
  159. package/src/resolvers/TelephonyResolver.ts +0 -63
  160. package/src/resolvers/VonageTelephonyResolver.ts +0 -63
  161. package/src/telephony/RingCentralTelephonyService.ts +0 -342
  162. package/src/telephony/TeamsMeetingsRouter.ts +0 -143
  163. package/src/telephony/TeamsMeetingsService.ts +0 -268
  164. package/src/telephony/TwilioTelephonyRouter.ts +0 -184
  165. package/src/telephony/TwilioTelephonyService.ts +0 -261
  166. package/src/telephony/VonageTelephonyRouter.ts +0 -239
  167. package/src/telephony/VonageTelephonyService.ts +0 -259
  168. package/src/telephony/calendar-scheduler.ts +0 -176
  169. package/src/telephony/index.ts +0 -43
  170. package/src/telephony/media-upgrade-router.ts +0 -79
  171. package/src/telephony/ringcentral-runtime.ts +0 -22
  172. package/src/telephony/teams-meetings-runtime.ts +0 -24
  173. package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
  174. package/src/telephony/telephony-runtime.ts +0 -22
  175. package/src/telephony/twilioMediaRegistry.ts +0 -137
  176. package/src/telephony/vonage-runtime.ts +0 -22
  177. package/src/telephony/vonageMediaRegistry.ts +0 -200
@@ -0,0 +1,106 @@
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 } from 'vitest';
5
+ import { ReadableFieldsTransportKey } from '@memberjunction/core';
6
+ import type { EntityInfo, IMetadataProvider, UserInfo } from '@memberjunction/core';
7
+ import { ResolverBase } from '../generic/ResolverBase.js';
8
+
9
+ /**
10
+ * The server states IN-BAND which fields the caller may read.
11
+ *
12
+ * Deleting a denied key from the response object is not sufficient on its own: GraphQL emits
13
+ * every field the client SELECTED, so a denied field the client asked for arrives as an explicit
14
+ * `null` that is indistinguishable from a genuine one. The client must not settle that from its
15
+ * own metadata — that copy is wrong in the window right after a permission change, and will be
16
+ * wrong permanently once metadata is filtered for restricted users (issue #3485).
17
+ *
18
+ * The list names READABLE fields rather than denied ones, deliberately: naming denied fields
19
+ * would hand back precisely what metadata filtering exists to withhold.
20
+ */
21
+ class TransportProbe extends ResolverBase {
22
+ public Build(entityInfo: EntityInfo, denied: Set<string> | null | undefined): string[] | null {
23
+ return this.BuildReadableFieldsTransportValue(entityInfo, denied);
24
+ }
25
+ public MapOne(entityName: string, row: unknown, provider: IMetadataProvider, user?: UserInfo, denied?: Set<string>) {
26
+ return this.MapFieldNamesToCodeNames(entityName, row, user, provider, denied);
27
+ }
28
+ }
29
+
30
+ const ENTITY_NAME = 'Employees';
31
+
32
+ const FIELDS = [
33
+ { Name: 'ID', CodeName: 'ID' },
34
+ { Name: 'Name', CodeName: 'Name' },
35
+ { Name: 'Salary', CodeName: 'Salary' },
36
+ { Name: '__mj_CreatedAt', CodeName: '__mj_CreatedAt' },
37
+ ];
38
+
39
+ function fakeEntityInfo(): EntityInfo {
40
+ return { Name: ENTITY_NAME, Fields: FIELDS, EncryptedFields: [] } as unknown as EntityInfo;
41
+ }
42
+
43
+ function fakeProvider(): IMetadataProvider {
44
+ return {
45
+ EntityByName: (name: string) => (name === ENTITY_NAME ? fakeEntityInfo() : undefined),
46
+ } as unknown as IMetadataProvider;
47
+ }
48
+
49
+ function row(): Record<string, unknown> {
50
+ return { ID: 'e-1', Name: 'Ada', Salary: 120000, __mj_CreatedAt: 'T0' };
51
+ }
52
+
53
+ describe('BuildReadableFieldsTransportValue', () => {
54
+ it('returns null when the caller is denied nothing — nothing to state', () => {
55
+ const probe = new TransportProbe();
56
+ expect(probe.Build(fakeEntityInfo(), null)).toBeNull();
57
+ expect(probe.Build(fakeEntityInfo(), undefined)).toBeNull();
58
+ expect(probe.Build(fakeEntityInfo(), new Set())).toBeNull();
59
+ });
60
+
61
+ it('lists the readable fields and never the denied one', () => {
62
+ const readable = new TransportProbe().Build(fakeEntityInfo(), new Set(['salary']));
63
+
64
+ expect(readable).toEqual(['ID', 'Name', '__mj_CreatedAt']);
65
+ // The whole point of the readable-list direction: a denied field's NAME is not disclosed.
66
+ expect(readable).not.toContain('Salary');
67
+ });
68
+
69
+ it('carries entity field NAMES, not the _mj__ transport shape', () => {
70
+ // The client consumes this AFTER reversing the transport rename and matches against
71
+ // EntityFieldInfo.Name — the same key the denied set is built from. Sending the shape
72
+ // both sides already agree on avoids a second mapping that could drift.
73
+ const readable = new TransportProbe().Build(fakeEntityInfo(), new Set(['salary']));
74
+ expect(readable).toContain('__mj_CreatedAt');
75
+ expect(readable).not.toContain('_mj__CreatedAt');
76
+ });
77
+ });
78
+
79
+ describe('MapFieldNamesToCodeNames attaches the transport key', () => {
80
+ it('strips the denied value AND says what remained readable', async () => {
81
+ const mapped = (await new TransportProbe().MapOne(
82
+ ENTITY_NAME, row(), fakeProvider(), undefined, new Set(['salary']),
83
+ )) as Record<string, unknown>;
84
+
85
+ expect(mapped.Salary).toBeUndefined();
86
+ expect(mapped[ReadableFieldsTransportKey]).toEqual(['ID', 'Name', '__mj_CreatedAt']);
87
+ });
88
+
89
+ it('omits the key entirely for an unrestricted caller', async () => {
90
+ // Nearly every request. A null here keeps the addition free where it is not needed.
91
+ const mapped = (await new TransportProbe().MapOne(
92
+ ENTITY_NAME, row(), fakeProvider(), undefined, new Set(),
93
+ )) as Record<string, unknown>;
94
+
95
+ expect(mapped[ReadableFieldsTransportKey]).toBeUndefined();
96
+ expect(mapped.Salary).toBe(120000);
97
+ });
98
+
99
+ it('does not write the key onto the caller\'s row', async () => {
100
+ const source = row();
101
+ await new TransportProbe().MapOne(ENTITY_NAME, source, fakeProvider(), undefined, new Set(['salary']));
102
+
103
+ expect(source[ReadableFieldsTransportKey]).toBeUndefined();
104
+ expect(source.Salary).toBe(120000);
105
+ });
106
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync, existsSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import ts from 'typescript';
5
+
6
+ describe('Generated GraphQL Schema Structural Rules', () => {
7
+ it('requires every entity ObjectType declaring _mj__CreatedAt to also declare ReadableFields___', () => {
8
+ const schemaPath = fileURLToPath(new URL('../generated/graphql-schemas/__mj.ts', import.meta.url));
9
+ expect(existsSync(schemaPath), `Schema file must exist at ${schemaPath}`).toBe(true);
10
+
11
+ const sourceCode = readFileSync(schemaPath, 'utf8');
12
+ const sourceFile = ts.createSourceFile('__mj.ts', sourceCode, ts.ScriptTarget.Latest, true);
13
+
14
+ const violations: string[] = [];
15
+ let checkedEntitiesCount = 0;
16
+
17
+ ts.forEachChild(sourceFile, (node) => {
18
+ if (ts.isClassDeclaration(node) && node.name) {
19
+ const className = node.name.text;
20
+ const memberNames = new Set<string>();
21
+
22
+ for (const member of node.members) {
23
+ if (member.name) {
24
+ if (ts.isIdentifier(member.name)) {
25
+ memberNames.add(member.name.text);
26
+ } else {
27
+ memberNames.add(member.name.getText(sourceFile));
28
+ }
29
+ }
30
+ }
31
+
32
+ if (memberNames.has('_mj__CreatedAt')) {
33
+ checkedEntitiesCount++;
34
+ if (!memberNames.has('ReadableFields___')) {
35
+ violations.push(className);
36
+ }
37
+ }
38
+ }
39
+ });
40
+
41
+ // Sanity guard: make sure we actually inspected entity classes in the file
42
+ expect(checkedEntitiesCount).toBeGreaterThan(0);
43
+ // Per-class rule: no entity ObjectType declaring _mj__CreatedAt may omit ReadableFields___
44
+ expect(violations).toEqual([]);
45
+ });
46
+ });
@@ -0,0 +1,483 @@
1
+ /**
2
+ * Tests for ResolverBase.StripDeniedReadFieldsFromClientInput — the field-level-security
3
+ * guard on the update path.
4
+ *
5
+ * The hazard it closes: a read-denied field is stripped from every payload the client
6
+ * receives, so the client's Save fabricates a value for it (default / 0 / '' / null) and sends
7
+ * it back. Without the guard, UpdateRecord's SetMany applies the fabrication — silent data
8
+ * loss when the user holds update access, and a spurious save failure on UNRELATED edits when
9
+ * they don't. The guard strips denied-read keys from the client's new values and OldValues
10
+ * before anything applies them, and its `true` return forces the caller onto the
11
+ * load-truth-from-DB branch so denied fields hold real values a stripped SetMany never
12
+ * touches.
13
+ */
14
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
15
+
16
+ // ─── Hoisted mocks ────────────────────────────────────────────────────────
17
+ const { mockUserCacheUsers } = vi.hoisted(() => ({
18
+ mockUserCacheUsers: [] as Array<{ Email: string; ID: string }>,
19
+ }));
20
+
21
+ // Stub external deps before imports
22
+ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
23
+ SQLServerDataProvider: class {},
24
+ }));
25
+
26
+ // UserCache moved to the shared provider package, so the mock has to follow it — mocking it on
27
+ // sqlserver-dataprovider silently no-ops and the real (empty) cache answers instead.
28
+ vi.mock('@memberjunction/generic-database-provider', () => ({
29
+ UserCache: {
30
+ get Users() { return mockUserCacheUsers; },
31
+ },
32
+ }));
33
+
34
+ vi.mock('cloudevents', () => ({
35
+ CloudEvent: class {},
36
+ httpTransport: () => () => undefined,
37
+ emitterFor: () => () => undefined,
38
+ }));
39
+
40
+ vi.mock('type-graphql', () => ({
41
+ Resolver: () => () => undefined,
42
+ Mutation: () => () => undefined,
43
+ Query: () => () => undefined,
44
+ Subscription: () => () => undefined,
45
+ Ctx: () => () => undefined,
46
+ Arg: () => () => undefined,
47
+ PubSub: () => () => undefined,
48
+ Root: () => () => undefined,
49
+ ObjectType: () => () => undefined,
50
+ InputType: () => () => undefined,
51
+ Field: () => () => undefined,
52
+ FieldResolver: () => () => undefined,
53
+ Int: () => undefined,
54
+ Float: () => undefined,
55
+ registerEnumType: () => undefined,
56
+ }));
57
+
58
+ vi.mock('graphql', () => ({
59
+ GraphQLError: class extends Error {
60
+ constructor(msg: string) { super(msg); }
61
+ },
62
+ }));
63
+
64
+ vi.mock('mssql', () => ({}));
65
+
66
+ vi.mock('@memberjunction/api-keys', () => ({
67
+ GetAPIKeyEngine: vi.fn(),
68
+ }));
69
+
70
+ vi.mock('@memberjunction/encryption', () => ({
71
+ EncryptionEngine: { Instance: {} },
72
+ }));
73
+
74
+ vi.mock('@memberjunction/graphql-dataprovider', () => ({
75
+ FieldMapper: class { static Instance = { MapFieldsFromCodeNamesToDBNames: vi.fn() }; },
76
+ }));
77
+
78
+ vi.mock('../generic/PubSubManager.js', () => ({
79
+ PubSubManager: class { static Instance = { publish: vi.fn() }; },
80
+ }));
81
+
82
+ vi.mock('../generic/PushStatusResolver.js', () => ({
83
+ PUSH_STATUS_UPDATES_TOPIC: 'test-push-topic',
84
+ PushStatusNotification: class {},
85
+ PushStatusResolver: class {},
86
+ }));
87
+
88
+ vi.mock('../generic/CacheInvalidationResolver.js', () => ({
89
+ CACHE_INVALIDATION_TOPIC: 'test-cache-topic',
90
+ }));
91
+
92
+ vi.mock('../generic/RunViewResolver.js', () => ({
93
+ RunViewByIDInput: class {},
94
+ RunViewByNameInput: class {},
95
+ RunDynamicViewInput: class {},
96
+ }));
97
+
98
+ vi.mock('../generic/DeleteOptionsInput.js', () => ({
99
+ DeleteOptionsInput: class {},
100
+ }));
101
+
102
+ vi.mock('../types.js', () => ({
103
+ RunViewGenericParams: class {},
104
+ }));
105
+
106
+ vi.mock('@memberjunction/core', async () => {
107
+ const actual = await vi.importActual<typeof import('@memberjunction/core')>('@memberjunction/core');
108
+ return {
109
+ ...actual,
110
+ LogError: vi.fn(),
111
+ LogStatus: vi.fn(),
112
+ };
113
+ });
114
+
115
+ vi.mock('@memberjunction/core-entities', () => ({}));
116
+
117
+ // ─── Import after mocks ──────────────────────────────────────────────────
118
+ import { ResolverBase } from '../generic/ResolverBase';
119
+ import { EntityInfo, UserInfo, UserRoleInfo } from '@memberjunction/core';
120
+
121
+ // ─── Metadata builders (mirrors MJCore's fieldSecurity.enforcement tests) ──
122
+
123
+ const HR_ROLE_ID = 'A0000000-0000-0000-0000-000000000001';
124
+ const INTERN_ROLE_ID = 'A0000000-0000-0000-0000-000000000003';
125
+ const ENTITY_ID = 'entity-employees';
126
+
127
+ /**
128
+ * An `Employees` entity: `Salary` and `Base Salary` are readable/updatable only by HR.
129
+ * `Base Salary` has a space, so its CodeName (`Base_Salary`) differs from its Name —
130
+ * exercising the Name→CodeName bridge (input keys are CodeNames).
131
+ */
132
+ /**
133
+ * Full access to `fieldId` for every listed role. Snapshot initialization writes rows like these
134
+ * for every (field, role) that should have one — which is why the UNRESTRICTED fields below need
135
+ * them explicitly. On an FLS-enabled entity a field with no rows is denied, not open.
136
+ */
137
+ function openTo(fieldId: string, roles: string[] = [HR_ROLE_ID, INTERN_ROLE_ID]): Record<string, unknown>[] {
138
+ return roles.map((roleId, i) => ({
139
+ ID: `${fieldId}-open-${i}`,
140
+ EntityFieldID: fieldId,
141
+ RoleID: roleId,
142
+ ReadAccess: 'Allow',
143
+ UpdateAccess: 'Allow',
144
+ CreateAccess: 'Allow',
145
+ }));
146
+ }
147
+
148
+ function employeeEntityInit(withFls: boolean): Record<string, unknown> {
149
+ return {
150
+ ID: ENTITY_ID,
151
+ Name: 'Employees',
152
+ SchemaName: 'dbo',
153
+ BaseTable: 'Employee',
154
+ BaseView: 'vwEmployees',
155
+ IncludeInAPI: true,
156
+ AllowCreateAPI: true,
157
+ AllowUpdateAPI: true,
158
+ AllowDeleteAPI: true,
159
+ // The flag is the enforcement gate — `withFls: false` switches field security off
160
+ // entirely rather than merely removing rows.
161
+ EnableFieldLevelSecurity: withFls,
162
+ Permissions: [
163
+ { EntityID: ENTITY_ID, RoleID: HR_ROLE_ID, CanCreate: true, CanRead: true, CanUpdate: true, CanDelete: true },
164
+ { EntityID: ENTITY_ID, RoleID: INTERN_ROLE_ID, CanCreate: true, CanRead: true, CanUpdate: true, CanDelete: true },
165
+ ],
166
+ Fields: [
167
+ { ID: 'f-id', EntityID: ENTITY_ID, Sequence: 1, Name: 'ID', Entity: 'Employees', Type: 'uniqueidentifier', IsPrimaryKey: true },
168
+ { ID: 'f-name', EntityID: ENTITY_ID, Sequence: 2, Name: 'Name', Entity: 'Employees', Type: 'nvarchar', EntityFieldPermissions: openTo('f-name') },
169
+ { ID: 'f-salary', EntityID: ENTITY_ID, Sequence: 3, Name: 'Salary', Entity: 'Employees', Type: 'money', EntityFieldPermissions: openTo('f-salary', [HR_ROLE_ID]) },
170
+ { ID: 'f-base-salary', EntityID: ENTITY_ID, Sequence: 4, Name: 'Base Salary', Entity: 'Employees', Type: 'money', EntityFieldPermissions: openTo('f-base-salary', [HR_ROLE_ID]) },
171
+ { ID: 'f-notes', EntityID: ENTITY_ID, Sequence: 5, Name: 'Notes', Entity: 'Employees', Type: 'nvarchar', EntityFieldPermissions: openTo('f-notes') },
172
+ ],
173
+ };
174
+ }
175
+
176
+ function buildUser(roleIds: string[], id = 'user-1'): UserInfo {
177
+ const u = new UserInfo();
178
+ u.ID = id;
179
+ u.Name = 'Test User';
180
+ u.Email = `${id}@test.com`;
181
+ u.IsActive = true;
182
+ (u as unknown as Record<string, unknown>)['_UserRoles'] =
183
+ roleIds.map(rid => new UserRoleInfo({ UserID: id, RoleID: rid, Role: `Role-${rid}` }));
184
+ return u;
185
+ }
186
+
187
+ type ClientInput = { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>;
188
+
189
+ /** Subclass that exposes the protected guards for testing. */
190
+ class TestResolver extends ResolverBase {
191
+ public TestStrip(entityInfo: EntityInfo, userInfo: UserInfo, input: ClientInput, clientNewValues: Record<string, unknown>): boolean {
192
+ return this.StripDeniedReadFieldsFromClientInput(entityInfo, userInfo, input, clientNewValues);
193
+ }
194
+
195
+ public TestMustLoadTruth(entityInfo: EntityInfo, input: ClientInput, hasDeniedReadFields: boolean, hasNarrowedAuditPayload: boolean): boolean {
196
+ return this.MustLoadTruthFromDatabase(entityInfo, input, hasDeniedReadFields, hasNarrowedAuditPayload);
197
+ }
198
+ }
199
+
200
+ /** Builds the (input, clientNewValues) pair the way UpdateRecord does. */
201
+ function makeClientPayload(fields: Record<string, unknown>, oldValues?: Array<{ Key: string; Value: unknown }>): { input: ClientInput; clientNewValues: Record<string, unknown> } {
202
+ const input: ClientInput = { ...fields };
203
+ if (oldValues) {
204
+ input.OldValues___ = oldValues;
205
+ }
206
+ const clientNewValues: Record<string, unknown> = { ...fields };
207
+ return { input, clientNewValues };
208
+ }
209
+
210
+ // ─── Tests ────────────────────────────────────────────────────────────────
211
+
212
+ describe('ResolverBase.StripDeniedReadFieldsFromClientInput', () => {
213
+ let resolver: TestResolver;
214
+ let flsEntity: EntityInfo;
215
+ let noFlsEntity: EntityInfo;
216
+
217
+ beforeEach(() => {
218
+ resolver = new TestResolver();
219
+ flsEntity = new EntityInfo(employeeEntityInit(true));
220
+ noFlsEntity = new EntityInfo(employeeEntityInit(false));
221
+ });
222
+
223
+ it('strips a denied-read field from clientNewValues AND input, and returns true', () => {
224
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Notes: 'edited', Salary: 0 });
225
+ const result = resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
226
+
227
+ expect(result).toBe(true);
228
+ expect(clientNewValues).not.toHaveProperty('Salary');
229
+ expect(input).not.toHaveProperty('Salary');
230
+ expect(clientNewValues).toEqual({ ID: '1', Notes: 'edited' });
231
+ });
232
+
233
+ it('removes denied-field entries from OldValues___ (fabricated old values must not drive conflict detection)', () => {
234
+ const { input, clientNewValues } = makeClientPayload(
235
+ { ID: '1', Notes: 'edited', Salary: 0 },
236
+ [{ Key: 'ID', Value: '1' }, { Key: 'Notes', Value: 'orig' }, { Key: 'Salary', Value: '0' }]
237
+ );
238
+ resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
239
+
240
+ expect(input.OldValues___).toEqual([{ Key: 'ID', Value: '1' }, { Key: 'Notes', Value: 'orig' }]);
241
+ });
242
+
243
+ it('bridges Name → CodeName: a denied field with a space is stripped by its CodeName key', () => {
244
+ // 'Base Salary' arrives from the wire as its CodeName 'Base_Salary'
245
+ const { input, clientNewValues } = makeClientPayload(
246
+ { ID: '1', Notes: 'edited', Base_Salary: 100 },
247
+ [{ Key: 'Base_Salary', Value: '100' }]
248
+ );
249
+ const result = resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
250
+
251
+ expect(result).toBe(true);
252
+ expect(clientNewValues).not.toHaveProperty('Base_Salary');
253
+ expect(input.OldValues___).toEqual([]);
254
+ });
255
+
256
+ it('leaves permitted fields and metadata blobs untouched', () => {
257
+ const restore = { marker: true };
258
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Name: 'Ada', Notes: 'n', Salary: 5 });
259
+ (input as Record<string, unknown>)['RestoreContext___'] = restore;
260
+ resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
261
+
262
+ expect(clientNewValues).toEqual({ ID: '1', Name: 'Ada', Notes: 'n' });
263
+ expect(input['RestoreContext___']).toBe(restore);
264
+ expect(input['Name']).toBe('Ada');
265
+ });
266
+
267
+ it('returns true even when the client sent nothing for the denied field — the truth-load must still be forced', () => {
268
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Notes: 'edited' });
269
+ const result = resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
270
+
271
+ expect(result).toBe(true);
272
+ expect(clientNewValues).toEqual({ ID: '1', Notes: 'edited' });
273
+ });
274
+
275
+ it('is a no-op returning false when the entity has no field security', () => {
276
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Salary: 12345 });
277
+ const result = resolver.TestStrip(noFlsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
278
+
279
+ expect(result).toBe(false);
280
+ expect(clientNewValues['Salary']).toBe(12345);
281
+ expect(input['Salary']).toBe(12345);
282
+ });
283
+
284
+ it('is a no-op returning false for a user who HOLDS the granting role', () => {
285
+ const { input, clientNewValues } = makeClientPayload(
286
+ { ID: '1', Salary: 99000 },
287
+ [{ Key: 'Salary', Value: '85000' }]
288
+ );
289
+ const result = resolver.TestStrip(flsEntity, buildUser([HR_ROLE_ID]), input, clientNewValues);
290
+
291
+ expect(result).toBe(false);
292
+ expect(clientNewValues['Salary']).toBe(99000);
293
+ expect(input.OldValues___).toHaveLength(1);
294
+ });
295
+
296
+ it('is a no-op returning false without a user', () => {
297
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Salary: 1 });
298
+ const result = resolver.TestStrip(flsEntity, undefined as unknown as UserInfo, input, clientNewValues);
299
+
300
+ expect(result).toBe(false);
301
+ expect(clientNewValues['Salary']).toBe(1);
302
+ });
303
+
304
+ it('matches input keys case-insensitively (wire casing must not defeat the strip)', () => {
305
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', SALARY: 0 });
306
+ const result = resolver.TestStrip(flsEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
307
+
308
+ expect(result).toBe(true);
309
+ expect(clientNewValues).not.toHaveProperty('SALARY');
310
+ });
311
+ });
312
+
313
+ // ─── A write-only field is a FORBIDDEN configuration ──────────────────────
314
+ //
315
+ // Read is required for Update, enforced by a row-level CHECK constraint AND by the clamp in
316
+ // EntityFieldInfo.GetUserFieldPermissions that catches the across-roles case the constraint
317
+ // cannot see. These tests write the forbidden rows DIRECTLY into metadata, bypassing the
318
+ // constraint, and assert the runtime still refuses to produce a write-only outcome.
319
+
320
+ describe('write-only fields are unreachable, so every denied-read field is stripped', () => {
321
+ const resolver = new TestResolver();
322
+
323
+ /**
324
+ * Salary with the forbidden shape written straight into metadata: the intern is denied READ
325
+ * but granted UPDATE. A CHECK constraint would refuse this row; reaching it here means it
326
+ * was written outside the entity path.
327
+ */
328
+ function forbiddenWriteOnlyEntity(): EntityInfo {
329
+ const init = employeeEntityInit(true);
330
+ const fields = init['Fields'] as Array<Record<string, unknown>>;
331
+ const salary = fields.find(f => f['Name'] === 'Salary')!;
332
+ salary['EntityFieldPermissions'] = [
333
+ ...openTo('f-salary', [HR_ROLE_ID]),
334
+ { ID: 'p2', EntityFieldID: 'f-salary', RoleID: INTERN_ROLE_ID, ReadAccess: 'No Access', UpdateAccess: 'Allow', CreateAccess: 'No Access' },
335
+ ];
336
+ return new EntityInfo(init);
337
+ }
338
+
339
+ it('the aggregation clamps the forbidden row — update does not survive a denied read', () => {
340
+ const entity = forbiddenWriteOnlyEntity();
341
+ const intern = buildUser([INTERN_ROLE_ID]);
342
+
343
+ expect(entity.GetDeniedReadFields(intern).has('salary')).toBe(true);
344
+ expect(entity.GetDeniedUpdateFields(intern).has('salary')).toBe(true);
345
+ });
346
+
347
+ it('strips the value anyway — a client cannot have a legitimate value for a field it never saw', () => {
348
+ const entity = forbiddenWriteOnlyEntity();
349
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Salary: 123456 });
350
+ const result = resolver.TestStrip(entity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
351
+
352
+ expect(result).toBe(true); // truth-load still forced — the denied-read set is non-empty
353
+ expect(clientNewValues).not.toHaveProperty('Salary');
354
+ expect(input).not.toHaveProperty('Salary');
355
+ });
356
+
357
+ it('removes the OldValues entry too (the client cannot know a true old value)', () => {
358
+ const entity = forbiddenWriteOnlyEntity();
359
+ const { input, clientNewValues } = makeClientPayload(
360
+ { ID: '1', Salary: 123456 },
361
+ [{ Key: 'ID', Value: '1' }, { Key: 'Salary', Value: '99' }]
362
+ );
363
+ resolver.TestStrip(entity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
364
+
365
+ expect(input.OldValues___).toEqual([{ Key: 'ID', Value: '1' }]);
366
+ expect(clientNewValues).not.toHaveProperty('Salary');
367
+ });
368
+
369
+ it('strips EVERY denied-read field, with no update-permission split', () => {
370
+ // Base Salary is denied read AND update; Salary carries the forbidden write-only row.
371
+ // Both are stripped, because denied-read ∩ denied-update is now just denied-read.
372
+ const entity = forbiddenWriteOnlyEntity();
373
+ const { input, clientNewValues } = makeClientPayload({ ID: '1', Salary: 123456, Base_Salary: 7 });
374
+ resolver.TestStrip(entity, buildUser([INTERN_ROLE_ID]), input, clientNewValues);
375
+
376
+ expect(clientNewValues).not.toHaveProperty('Salary');
377
+ expect(clientNewValues).not.toHaveProperty('Base_Salary');
378
+ });
379
+ });
380
+
381
+ /**
382
+ * Tests for ResolverBase.MustLoadTruthFromDatabase — the hydration-source decision.
383
+ *
384
+ * `UpdateRecord` may hydrate the entity from the client's `OldValues___` instead of loading the
385
+ * row, as an optimization for the case where nothing needs true prior state. This predicate decides
386
+ * when that shortcut is unsafe.
387
+ *
388
+ * The security case, and the reason `EnableFieldLevelSecurity` is in the condition at all: the
389
+ * canonical FLS configuration is Read Allow + Update Deny, which leaves `hasDeniedReadFields`
390
+ * FALSE. Before this predicate consulted the entity flag, such a caller took the shortcut and was
391
+ * hydrated from its own `OldValues___`. A value supplied there arrives via `LoadFromData`, which
392
+ * the EntityField setter records as the field's initial value — so it is not dirty,
393
+ * `CheckFieldLevelUpdatePermissions` (which rejects only `field.Dirty && denied`) never sees it,
394
+ * and `GenerateSaveSQL` sends it anyway because it filters on `NotLoaded`, never on `Dirty`.
395
+ * Pinning a value in `OldValues___` was a write to a field the caller may not write.
396
+ */
397
+ describe('ResolverBase.MustLoadTruthFromDatabase', () => {
398
+ let resolver: TestResolver;
399
+
400
+ beforeEach(() => {
401
+ resolver = new TestResolver();
402
+ });
403
+
404
+ /**
405
+ * `Employees` where the Intern role may READ every field — including Salary — but may not
406
+ * UPDATE Salary. "You can see the salaries, you just cannot change them."
407
+ *
408
+ * Every field must be readable, not just Salary. `StripDeniedReadFieldsFromClientInput`
409
+ * returns `true` whenever the caller's denied-READ set is non-empty AT ALL, regardless of what
410
+ * the payload contained, so a single unrelated read denial anywhere on the entity would force
411
+ * the truth-load by itself and mask the gap. A caller with no read denials is the one whose
412
+ * hydration source was decided purely by the terms this test is about.
413
+ */
414
+ function readAllowUpdateDenyEntity(withFls: boolean): EntityInfo {
415
+ const init = employeeEntityInit(withFls);
416
+ init['TrackRecordChanges'] = false; // the shortcut is only reachable with tracking off
417
+ const fields = init['Fields'] as Array<Record<string, unknown>>;
418
+ for (const f of fields) {
419
+ if (f['ID'] === 'f-id') continue; // primary key — never restrictable
420
+ f['EntityFieldPermissions'] = openTo(f['ID'] as string);
421
+ }
422
+ const salary = fields.find((f) => f['ID'] === 'f-salary')!;
423
+ salary['EntityFieldPermissions'] = [
424
+ ...openTo('f-salary', [HR_ROLE_ID]),
425
+ { ID: 'f-salary-intern', EntityFieldID: 'f-salary', RoleID: INTERN_ROLE_ID, ReadAccess: 'Allow', UpdateAccess: 'Deny', CreateAccess: 'Deny' },
426
+ ];
427
+ return new EntityInfo(init);
428
+ }
429
+
430
+ const withOldValues = (): ClientInput => ({
431
+ ID: '1',
432
+ Notes: 'edited',
433
+ OldValues___: [
434
+ { Key: 'ID', Value: '1' },
435
+ { Key: 'Notes', Value: 'original' },
436
+ { Key: 'Salary', Value: '999999' }, // the pinned value — never in the mutation input
437
+ ],
438
+ });
439
+
440
+ it('the fixture really is Read Allow + Update Deny, with an EMPTY denied-READ set', () => {
441
+ const entity = readAllowUpdateDenyEntity(true);
442
+ const intern = buildUser([INTERN_ROLE_ID]);
443
+
444
+ // This is what makes the bypass reachable: nothing to strip, so hasDeniedReadFields is false.
445
+ expect(entity.GetDeniedReadFields(intern).size).toBe(0);
446
+ expect(entity.GetDeniedUpdateFields(intern).has('salary')).toBe(true);
447
+ });
448
+
449
+ it('REGRESSION: forces the truth-load on an FLS entity even when no field is denied READ', () => {
450
+ const entity = readAllowUpdateDenyEntity(true);
451
+ const intern = buildUser([INTERN_ROLE_ID]);
452
+ const input = withOldValues();
453
+
454
+ // Exactly what UpdateRecord computes: the strip finds nothing to remove, so it returns false.
455
+ const hasDeniedReadFields = resolver.TestStrip(entity, intern, input, { ID: '1', Notes: 'edited' });
456
+ expect(hasDeniedReadFields).toBe(false);
457
+ expect(input.OldValues___).toContainEqual({ Key: 'Salary', Value: '999999' }); // survives the strip
458
+
459
+ // Before the fix every other term was false here and the client's OldValues won.
460
+ expect(resolver.TestMustLoadTruth(entity, input, hasDeniedReadFields, false)).toBe(true);
461
+ });
462
+
463
+ it('keeps the OldValues shortcut when field-level security is OFF (no cost to the ~99%)', () => {
464
+ const entity = readAllowUpdateDenyEntity(false);
465
+ expect(resolver.TestMustLoadTruth(entity, withOldValues(), false, false)).toBe(false);
466
+ });
467
+
468
+ it('still forces the truth-load for each pre-existing reason, independently', () => {
469
+ const flsOff = readAllowUpdateDenyEntity(false);
470
+
471
+ // No OldValues to hydrate from.
472
+ expect(resolver.TestMustLoadTruth(flsOff, { ID: '1' }, false, false)).toBe(true);
473
+ // A denied READ field was stripped.
474
+ expect(resolver.TestMustLoadTruth(flsOff, withOldValues(), true, false)).toBe(true);
475
+ // The audit payload was narrowed.
476
+ expect(resolver.TestMustLoadTruth(flsOff, withOldValues(), false, true)).toBe(true);
477
+
478
+ // The entity tracks record changes.
479
+ const tracked = employeeEntityInit(false);
480
+ tracked['TrackRecordChanges'] = true;
481
+ expect(resolver.TestMustLoadTruth(new EntityInfo(tracked), withOldValues(), false, false)).toBe(true);
482
+ });
483
+ });