@memberjunction/server 6.2.0-edge.1 → 6.2.0-edge.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +2189 -71
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +123 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/context.d.ts.map +1 -1
  8. package/dist/context.js +11 -23
  9. package/dist/context.js.map +1 -1
  10. package/dist/generated/graphql-schemas/__mj.d.ts +2143 -205
  11. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  12. package/dist/generated/graphql-schemas/__mj.js +24441 -14433
  13. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  14. package/dist/generic/FireAndForgetHeartbeat.d.ts +15 -1
  15. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  16. package/dist/generic/FireAndForgetHeartbeat.js +16 -2
  17. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  18. package/dist/generic/OrphanedConversationDetailReconciler.d.ts +31 -0
  19. package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -0
  20. package/dist/generic/OrphanedConversationDetailReconciler.js +215 -0
  21. package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -0
  22. package/dist/generic/PushStatusResolver.d.ts +38 -0
  23. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  24. package/dist/generic/PushStatusResolver.js +84 -1
  25. package/dist/generic/PushStatusResolver.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +56 -4
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +191 -74
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/generic/RunViewResolver.d.ts +4 -0
  31. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  32. package/dist/generic/RunViewResolver.js +33 -1
  33. package/dist/generic/RunViewResolver.js.map +1 -1
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +204 -50
  37. package/dist/index.js.map +1 -1
  38. package/dist/postgresqlCompat.d.ts +8 -0
  39. package/dist/postgresqlCompat.d.ts.map +1 -0
  40. package/dist/postgresqlCompat.js +19 -0
  41. package/dist/postgresqlCompat.js.map +1 -0
  42. package/dist/postgresqlPoolSettings.d.ts +108 -0
  43. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  44. package/dist/postgresqlPoolSettings.js +134 -0
  45. package/dist/postgresqlPoolSettings.js.map +1 -0
  46. package/dist/resolvers/AdhocQueryResolver.d.ts +23 -18
  47. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/AdhocQueryResolver.js +77 -167
  49. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  50. package/dist/resolvers/AgentRunStatusPublisher.d.ts +55 -0
  51. package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -0
  52. package/dist/resolvers/AgentRunStatusPublisher.js +141 -0
  53. package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -0
  54. package/dist/resolvers/ConversationRunEventsResolver.d.ts +90 -0
  55. package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -0
  56. package/dist/resolvers/ConversationRunEventsResolver.js +283 -0
  57. package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -0
  58. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts +98 -0
  59. package/dist/resolvers/DuplicateEntryCheckResolver.d.ts.map +1 -0
  60. package/dist/resolvers/DuplicateEntryCheckResolver.js +227 -0
  61. package/dist/resolvers/DuplicateEntryCheckResolver.js.map +1 -0
  62. package/dist/resolvers/EntityRecordNameResolver.d.ts +7 -23
  63. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  64. package/dist/resolvers/EntityRecordNameResolver.js +9 -47
  65. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  66. package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
  67. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -1
  68. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  69. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  70. package/dist/resolvers/GetDataResolver.js +9 -0
  71. package/dist/resolvers/GetDataResolver.js.map +1 -1
  72. package/dist/resolvers/HumanHandoffResolver.d.ts +107 -0
  73. package/dist/resolvers/HumanHandoffResolver.d.ts.map +1 -0
  74. package/dist/resolvers/HumanHandoffResolver.js +324 -0
  75. package/dist/resolvers/HumanHandoffResolver.js.map +1 -0
  76. package/dist/resolvers/MeetingResolver.d.ts +69 -0
  77. package/dist/resolvers/MeetingResolver.d.ts.map +1 -0
  78. package/dist/resolvers/MeetingResolver.js +381 -0
  79. package/dist/resolvers/MeetingResolver.js.map +1 -0
  80. package/dist/resolvers/MergeRecordsResolver.d.ts +7 -0
  81. package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
  82. package/dist/resolvers/MergeRecordsResolver.js +16 -2
  83. package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
  84. package/dist/resolvers/RealtimeBridgeResolver.d.ts +34 -1
  85. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  86. package/dist/resolvers/RealtimeBridgeResolver.js +202 -7
  87. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +45 -89
  89. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  90. package/dist/resolvers/RealtimeClientSessionResolver.js +94 -240
  91. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  92. package/dist/resolvers/RunAIAgentResolver.d.ts +0 -16
  93. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  94. package/dist/resolvers/RunAIAgentResolver.js +23 -153
  95. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  96. package/dist/resolvers/RunDecisionResolver.d.ts +149 -0
  97. package/dist/resolvers/RunDecisionResolver.d.ts.map +1 -0
  98. package/dist/resolvers/RunDecisionResolver.js +396 -0
  99. package/dist/resolvers/RunDecisionResolver.js.map +1 -0
  100. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  101. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  102. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  103. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  104. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  105. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  106. package/dist/resolvers/adhocTimeout.js +12 -0
  107. package/dist/resolvers/adhocTimeout.js.map +1 -0
  108. package/dist/resolvers/meetings/index.d.ts +5 -0
  109. package/dist/resolvers/meetings/index.d.ts.map +1 -0
  110. package/dist/resolvers/meetings/index.js +5 -0
  111. package/dist/resolvers/meetings/index.js.map +1 -0
  112. package/dist/resolvers/meetings/meetingHelpers.d.ts +17 -0
  113. package/dist/resolvers/meetings/meetingHelpers.d.ts.map +1 -0
  114. package/dist/resolvers/meetings/meetingHelpers.js +212 -0
  115. package/dist/resolvers/meetings/meetingHelpers.js.map +1 -0
  116. package/dist/resolvers/meetings/meetingMutations.d.ts +12 -0
  117. package/dist/resolvers/meetings/meetingMutations.d.ts.map +1 -0
  118. package/dist/resolvers/meetings/meetingMutations.js +418 -0
  119. package/dist/resolvers/meetings/meetingMutations.js.map +1 -0
  120. package/dist/resolvers/meetings/meetingQueries.d.ts +7 -0
  121. package/dist/resolvers/meetings/meetingQueries.d.ts.map +1 -0
  122. package/dist/resolvers/meetings/meetingQueries.js +113 -0
  123. package/dist/resolvers/meetings/meetingQueries.js.map +1 -0
  124. package/dist/resolvers/meetings/meetingTypes.d.ts +89 -0
  125. package/dist/resolvers/meetings/meetingTypes.d.ts.map +1 -0
  126. package/dist/resolvers/meetings/meetingTypes.js +345 -0
  127. package/dist/resolvers/meetings/meetingTypes.js.map +1 -0
  128. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  129. package/dist/rest/ViewOperationsHandler.js +8 -5
  130. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  131. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  132. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  133. package/dist/services/TaskGraphActionRunner.js +27 -1
  134. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  135. package/dist/services/WorkQueueHostService.d.ts +36 -0
  136. package/dist/services/WorkQueueHostService.d.ts.map +1 -0
  137. package/dist/services/WorkQueueHostService.js +97 -0
  138. package/dist/services/WorkQueueHostService.js.map +1 -0
  139. package/dist/services/workQueueConfig.d.ts +103 -0
  140. package/dist/services/workQueueConfig.d.ts.map +1 -0
  141. package/dist/services/workQueueConfig.js +48 -0
  142. package/dist/services/workQueueConfig.js.map +1 -0
  143. package/dist/types.d.ts +5 -0
  144. package/dist/types.d.ts.map +1 -1
  145. package/dist/types.js.map +1 -1
  146. package/package.json +95 -92
  147. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  148. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  149. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  150. package/src/__tests__/AgentRunStatusPublisher.test.ts +175 -0
  151. package/src/__tests__/DuplicateEntryCheckResolver.test.ts +300 -0
  152. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  153. package/src/__tests__/HumanHandoffResolver.test.ts +233 -0
  154. package/src/__tests__/MeetingResolver.test.ts +374 -0
  155. package/src/__tests__/RealtimeBridgeResolver.test.ts +240 -2
  156. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  157. package/src/__tests__/RemoteBrowserActionRecovery.test.ts +7 -1
  158. package/src/__tests__/RemoteBrowserAudioStream.test.ts +7 -1
  159. package/src/__tests__/RemoteBrowserSnapshot.test.ts +7 -1
  160. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +96 -0
  161. package/src/__tests__/ResolverBase.clientOldValues.test.ts +52 -0
  162. package/src/__tests__/ResolverBase.filterEscaping.test.ts +21 -2
  163. package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +291 -0
  164. package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +182 -12
  165. package/src/__tests__/RunAIAgentResolver.streaming.test.ts +18 -27
  166. package/src/__tests__/RunDecisionResolver.test.ts +699 -0
  167. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +43 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/WorkQueueHostService.test.ts +133 -0
  170. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  171. package/src/__tests__/conversationRunEvents.test.ts +399 -0
  172. package/src/__tests__/orphanedConversationDetailReconciler.test.ts +283 -0
  173. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  174. package/src/__tests__/pushStatusFanOut.test.ts +206 -0
  175. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  176. package/src/auth/__tests__/backward-compatibility.test.ts +1 -0
  177. package/src/config.ts +130 -3
  178. package/src/context.ts +11 -24
  179. package/src/generated/graphql-schemas/__mj.ts +24183 -17182
  180. package/src/generic/FireAndForgetHeartbeat.ts +16 -2
  181. package/src/generic/OrphanedConversationDetailReconciler.ts +267 -0
  182. package/src/generic/PushStatusResolver.ts +97 -1
  183. package/src/generic/ResolverBase.ts +220 -76
  184. package/src/generic/RunViewResolver.ts +33 -2
  185. package/src/index.ts +228 -51
  186. package/src/postgresqlCompat.ts +21 -0
  187. package/src/postgresqlPoolSettings.ts +213 -0
  188. package/src/resolvers/AdhocQueryResolver.ts +88 -188
  189. package/src/resolvers/AgentRunStatusPublisher.ts +175 -0
  190. package/src/resolvers/ConversationRunEventsResolver.ts +308 -0
  191. package/src/resolvers/DuplicateEntryCheckResolver.ts +232 -0
  192. package/src/resolvers/EntityRecordNameResolver.ts +9 -51
  193. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -1
  194. package/src/resolvers/GetDataResolver.ts +10 -0
  195. package/src/resolvers/HumanHandoffResolver.ts +304 -0
  196. package/src/resolvers/MeetingResolver.ts +438 -0
  197. package/src/resolvers/MergeRecordsResolver.ts +14 -2
  198. package/src/resolvers/RealtimeBridgeResolver.ts +227 -13
  199. package/src/resolvers/RealtimeClientSessionResolver.ts +99 -287
  200. package/src/resolvers/RunAIAgentResolver.ts +28 -174
  201. package/src/resolvers/RunDecisionResolver.ts +435 -0
  202. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  203. package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +93 -0
  204. package/src/resolvers/adhocTimeout.ts +12 -0
  205. package/src/resolvers/meetings/index.ts +4 -0
  206. package/src/resolvers/meetings/meetingHelpers.ts +307 -0
  207. package/src/resolvers/meetings/meetingMutations.ts +535 -0
  208. package/src/resolvers/meetings/meetingQueries.ts +172 -0
  209. package/src/resolvers/meetings/meetingTypes.ts +238 -0
  210. package/src/rest/ViewOperationsHandler.ts +8 -5
  211. package/src/services/TaskGraphActionRunner.ts +29 -1
  212. package/src/services/WorkQueueHostService.ts +129 -0
  213. package/src/services/workQueueConfig.ts +52 -0
  214. package/src/types.ts +5 -0
  215. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -33
  216. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  217. package/dist/resolvers/adhoc-query-helpers.js +0 -50
  218. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  219. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  220. package/src/resolvers/adhoc-query-helpers.ts +0 -67
@@ -3,7 +3,7 @@
3
3
  // bring it in automatically — this line MUST precede any import that pulls in the resolver file.
4
4
  import 'reflect-metadata';
5
5
 
6
- import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
7
 
8
8
  // --- Mock CanRun authorization (AIAgentPermissionHelper.HasPermission) plus the
9
9
  // provider-scoped AIEngineBase whose cached metadata serves the resolver's pairing-row
@@ -210,9 +210,23 @@ interface FakeSession {
210
210
  NewRecord: () => void;
211
211
  Save: () => Promise<boolean>;
212
212
  Load: (id: string) => Promise<boolean>;
213
- LatestResult?: { CompleteMessage?: string };
213
+ LatestResult?: { CompleteMessage?: string; Success?: boolean };
214
+ /** Only set on the fakes exercising `describeSaveFailure`'s "no failure detail recorded" branches. */
215
+ ResultHistory?: unknown[];
214
216
  }
215
217
 
218
+ /**
219
+ * A `console.error` spy shared by the save-failure-log tests (#4791) below. Assigned with
220
+ * `vi.spyOn(console, 'error').mockImplementation(() => undefined)` inside each test that needs it
221
+ * — restored here so no test leaks a mocked `console.error` into the ones after it.
222
+ */
223
+ let errSpy: ReturnType<typeof vi.spyOn> | undefined;
224
+
225
+ afterEach(() => {
226
+ errSpy?.mockRestore();
227
+ errSpy = undefined;
228
+ });
229
+
216
230
  function makeSessionEntity(overrides: Partial<FakeSession> = {}): FakeSession {
217
231
  return {
218
232
  ID: 'session-1',
@@ -848,6 +862,28 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
848
862
  resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'x', '{}', makeCtx(), makePubSub()),
849
863
  ).rejects.toThrow(/no target agent configured/i);
850
864
  });
865
+
866
+ it('logs the session id and "no failure detail recorded" when updatePendingFeedbackRunID\'s save is refused silently (#4791)', async () => {
867
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
868
+ const session = makeSessionEntity({
869
+ Config_: JSON.stringify({ targetAgentID: 'target-1' }),
870
+ Save: vi.fn(async () => false),
871
+ LatestResult: undefined,
872
+ ResultHistory: [],
873
+ });
874
+ currentProvider = makeProvider(() => session);
875
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"q":1}', Success: true, PausedRunID: 'paused-1' });
876
+ const resolver = makeResolver();
877
+
878
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
879
+
880
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
881
+ const failureLine = loggedLines.find((line) => line.includes('updatePendingFeedbackRunID'));
882
+ expect(failureLine).toBeDefined();
883
+ expect(failureLine).toContain('session-1');
884
+ expect(failureLine).toContain('no failure detail recorded');
885
+ expect(failureLine).not.toContain('unknown error');
886
+ });
851
887
  });
852
888
 
853
889
  describe('RealtimeClientSessionResolver.RelayRealtimeTranscript', () => {
@@ -1690,6 +1726,7 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
1690
1726
  });
1691
1727
 
1692
1728
  it('returns a structured failure when the artifact header save fails (no version attempted)', async () => {
1729
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
1693
1730
  const failingArtifact = makeSessionEntity({
1694
1731
  ID: 'artifact-fail',
1695
1732
  Save: vi.fn(async () => false),
@@ -1706,10 +1743,14 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
1706
1743
 
1707
1744
  expect(result.Success).toBe(false);
1708
1745
  expect(result.ErrorMessage).toMatch(/artifact save failed/i);
1746
+ expect(result.ErrorMessage).toContain('session session-1');
1747
+ expect(result.ErrorMessage).toContain('db down');
1748
+ expect(errSpy.mock.calls.map((call) => String(call[0]))).toContain(result.ErrorMessage);
1709
1749
  expect(version.Save).not.toHaveBeenCalled();
1710
1750
  });
1711
1751
 
1712
1752
  it('returns a structured failure (carrying the orphaned ArtifactID) when the version save fails', async () => {
1753
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
1713
1754
  const failingVersion = makeSessionEntity({
1714
1755
  ID: 'version-fail',
1715
1756
  Save: vi.fn(async () => false),
@@ -1728,6 +1769,10 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
1728
1769
  expect(result.Success).toBe(false);
1729
1770
  expect(result.ArtifactID).toBe('artifact-1');
1730
1771
  expect(result.ArtifactVersionID).toBeUndefined();
1772
+ expect(result.ErrorMessage).toMatch(/artifact version save failed/i);
1773
+ expect(result.ErrorMessage).toContain('session session-1');
1774
+ expect(result.ErrorMessage).toContain('too big');
1775
+ expect(errSpy.mock.calls.map((call) => String(call[0]))).toContain(result.ErrorMessage);
1731
1776
  expect(junction.Save).not.toHaveBeenCalled();
1732
1777
  });
1733
1778
 
@@ -2880,6 +2925,44 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
2880
2925
  expect(executeRelayedToolMock.mock.calls[0][1]).toBe(ANON_USER);
2881
2926
  });
2882
2927
 
2928
+ it('writes the hidden direct-action tool turn (ExecuteRealtimeSessionTool) as the CALLER, not the system user (#4791)', async () => {
2929
+ const detail = makeSessionEntity({ ID: 'detail-1' });
2930
+ currentProvider = {
2931
+ GetEntityObject: vi.fn(async (name: string) =>
2932
+ name === 'MJ: Conversation Details' ? detail : makeSessionEntity({ UserID: 'anon-1' }),
2933
+ ),
2934
+ };
2935
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"success":true}', Success: true });
2936
+ const resolver = makeAnonResolver();
2937
+
2938
+ await resolver.ExecuteRealtimeSessionTool(
2939
+ 'session-1', 'call-1', 'Some Direct Action', '{}', makeCtx(), makePubSub(),
2940
+ );
2941
+
2942
+ // The dispatch itself still runs elevated (SYSTEM_USER)…
2943
+ expect(executeRelayedToolMock.mock.calls[0][1]).toBe(SYSTEM_USER);
2944
+ // …but the hidden Conversation Detail turn is written as the CALLER, never the system user.
2945
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
2946
+ expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
2947
+ expect(detail.Save).toHaveBeenCalled();
2948
+ expect(detail.UserID).toBe('anon-1');
2949
+ });
2950
+
2951
+ it('writes the direct-action tool turn as the caller unchanged for a normal authenticated user', async () => {
2952
+ const detail = makeSessionEntity({ ID: 'detail-2' });
2953
+ currentProvider = {
2954
+ GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: Conversation Details' ? detail : makeSessionEntity())),
2955
+ };
2956
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"success":true}', Success: true });
2957
+ const resolver = makeResolver();
2958
+
2959
+ await resolver.ExecuteRealtimeSessionTool(
2960
+ 'session-1', 'call-1', 'Some Direct Action', '{}', makeCtx(), makePubSub(),
2961
+ );
2962
+
2963
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', USER);
2964
+ });
2965
+
2883
2966
  it('accumulates relayed usage onto the prompt run under the SYSTEM user', async () => {
2884
2967
  const session = makeSessionEntity({
2885
2968
  UserID: 'anon-1',
@@ -2942,6 +3025,32 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
2942
3025
  expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
2943
3026
  });
2944
3027
 
3028
+ it('writes the hidden co-agent tool turn (RelayRealtimeToolTurn) as the CALLER (#4791)', async () => {
3029
+ const session = makeSessionEntity({
3030
+ UserID: 'anon-1',
3031
+ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
3032
+ });
3033
+ const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
3034
+ const detail = makeSessionEntity({ ID: 'detail-3' });
3035
+ currentProvider = {
3036
+ GetEntityObject: vi.fn(async (name: string) => {
3037
+ if (name === 'MJ: AI Prompt Runs') return promptRun;
3038
+ if (name === 'MJ: Conversation Details') return detail;
3039
+ return session;
3040
+ }),
3041
+ };
3042
+ const resolver = makeAnonResolver();
3043
+
3044
+ const ok = await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{"url":"x"}');
3045
+
3046
+ expect(ok).toBe(true);
3047
+ // The co-agent run mirror stays SYSTEM…
3048
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
3049
+ // …but the hidden Conversation Detail tool-turn is written as the CALLER, never SYSTEM.
3050
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
3051
+ expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
3052
+ });
3053
+
2945
3054
  it('prepares the session with observability under the SYSTEM user while UserID stays the visitor', async () => {
2946
3055
  hasPermissionMock.mockResolvedValue(true);
2947
3056
  currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'anon-1' }));
@@ -3003,6 +3112,10 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
3003
3112
  // The junction write (an entity the anon role does NOT hold) runs as the SYSTEM user.
3004
3113
  expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Detail Artifacts', SYSTEM_USER);
3005
3114
  expect(junctions).toHaveLength(1);
3115
+ // The hidden anchor `Conversation Detail` is written as the CALLER (#4791) — like every
3116
+ // other `MJ: Conversation Details` write, the System user is refused as not the owner.
3117
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
3118
+ expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
3006
3119
  // The hidden anchor is attributed to the SESSION owner, not the elevated principal.
3007
3120
  expect(anchors).toHaveLength(1);
3008
3121
  expect(anchors[0].UserID).toBe('anon-1');
@@ -3102,6 +3215,107 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
3102
3215
  expect((storeRecordingMock.mock.calls[0][0] as { ContextUser: unknown }).ContextUser).toBe(USER);
3103
3216
  expect(getSystemUserMock).not.toHaveBeenCalled();
3104
3217
  });
3218
+
3219
+ /** Provider routing a hidden-tool-turn RelayRealtimeToolTurn call: session, prompt run, detail. */
3220
+ function makeToolTurnProvider(detail: FakeSession): unknown {
3221
+ const session = makeSessionEntity({
3222
+ UserID: 'anon-1',
3223
+ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
3224
+ });
3225
+ const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
3226
+ return {
3227
+ GetEntityObject: vi.fn(async (name: string) => {
3228
+ if (name === 'MJ: AI Prompt Runs') return promptRun;
3229
+ if (name === 'MJ: Conversation Details') return detail;
3230
+ return session;
3231
+ }),
3232
+ };
3233
+ }
3234
+
3235
+ it('logs the session id, write user id and "no failure detail recorded" when a hidden tool turn save is refused without a LatestResult (#4791)', async () => {
3236
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
3237
+ const detail = makeSessionEntity({
3238
+ ID: 'detail-1',
3239
+ Save: vi.fn(async () => false),
3240
+ LatestResult: undefined,
3241
+ ResultHistory: [],
3242
+ });
3243
+ currentProvider = makeToolTurnProvider(detail);
3244
+ const resolver = makeAnonResolver();
3245
+
3246
+ await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
3247
+
3248
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
3249
+ const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
3250
+ expect(failureLine).toBeDefined();
3251
+ // Exact substrings: 'anon-1' alone is the caller, session.UserID AND detail.UserID at once,
3252
+ // so only the labelled form proves the WRITE user is what got logged.
3253
+ expect(failureLine).toContain('session session-1');
3254
+ expect(failureLine).toContain('write user anon-1');
3255
+ expect(failureLine).toContain('no failure detail recorded (LatestResult null; ResultHistory length 0)');
3256
+ expect(failureLine).toContain('the write was refused before reaching the provider');
3257
+ expect(failureLine).not.toContain('unknown error');
3258
+ });
3259
+
3260
+ it('does not claim WHERE the write was refused when a result was registered with an empty message (#4791)', async () => {
3261
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
3262
+ const detail = makeSessionEntity({
3263
+ ID: 'detail-1',
3264
+ Save: vi.fn(async () => false),
3265
+ LatestResult: { Success: false, CompleteMessage: ' ' },
3266
+ ResultHistory: [{}],
3267
+ });
3268
+ currentProvider = makeToolTurnProvider(detail);
3269
+ const resolver = makeAnonResolver();
3270
+
3271
+ await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
3272
+
3273
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
3274
+ const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
3275
+ expect(failureLine).toBeDefined();
3276
+ expect(failureLine).toContain('no failure detail recorded (LatestResult empty; ResultHistory length 1)');
3277
+ expect(failureLine).not.toContain('refused before reaching the provider');
3278
+ });
3279
+
3280
+ it('never logs a PRIOR SUCCESS entry\'s text as the failure reason when the refusal registered nothing (#4791)', async () => {
3281
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
3282
+ const detail = makeSessionEntity({
3283
+ ID: 'detail-1',
3284
+ Save: vi.fn(async () => false),
3285
+ LatestResult: { Success: true, CompleteMessage: 'previous save ok' },
3286
+ ResultHistory: [{}, {}],
3287
+ });
3288
+ currentProvider = makeToolTurnProvider(detail);
3289
+ const resolver = makeAnonResolver();
3290
+
3291
+ await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
3292
+
3293
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
3294
+ const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
3295
+ expect(failureLine).toBeDefined();
3296
+ expect(failureLine).toContain(
3297
+ 'no failure detail recorded (latest result entry is a prior success; ResultHistory length 2)',
3298
+ );
3299
+ expect(failureLine).not.toContain('previous save ok');
3300
+ });
3301
+
3302
+ it('logs the real CompleteMessage when present (#4791)', async () => {
3303
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
3304
+ const detail = makeSessionEntity({
3305
+ ID: 'detail-1',
3306
+ Save: vi.fn(async () => false),
3307
+ LatestResult: { CompleteMessage: 'You do not have access to this conversation.' },
3308
+ });
3309
+ currentProvider = makeToolTurnProvider(detail);
3310
+ const resolver = makeAnonResolver();
3311
+
3312
+ await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
3313
+
3314
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
3315
+ const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
3316
+ expect(failureLine).toBeDefined();
3317
+ expect(failureLine).toContain('You do not have access to this conversation.');
3318
+ });
3105
3319
  });
3106
3320
 
3107
3321
  describe('RealtimeClientSessionResolver — colleague authorization (allowedAgents)', () => {
@@ -59,7 +59,13 @@ vi.mock('@memberjunction/remote-browser-server', () => ({
59
59
  }));
60
60
 
61
61
  vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
62
- vi.mock('@memberjunction/ai-prompts', () => ({ AIPromptRunner: class {} }));
62
+ // Spread the real module, as for ai-core-plus below, so transitively-loaded modules resolve: runners such as
63
+ // AIRerankerRunner (which extends BaseModelRunner), and RunDecisionResolver's AIDecisionRunner. Only
64
+ // AIPromptRunner is overridden for the test.
65
+ vi.mock('@memberjunction/ai-prompts', async (importOriginal) => ({
66
+ ...(await importOriginal<typeof import('@memberjunction/ai-prompts')>()),
67
+ AIPromptRunner: class {},
68
+ }));
63
69
  vi.mock('@memberjunction/ai-core-plus', async (importOriginal) => ({
64
70
  ...(await importOriginal<typeof import('@memberjunction/ai-core-plus')>()),
65
71
  AIPromptParams: class {},
@@ -72,7 +72,13 @@ vi.mock('@memberjunction/remote-browser-server', () => ({
72
72
 
73
73
  // Keep the AI imports (visual interpreter) inert — they aren't exercised by the audio path.
74
74
  vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
75
- vi.mock('@memberjunction/ai-prompts', () => ({ AIPromptRunner: class {} }));
75
+ // Spread the real module, as for ai-core-plus below, so transitively-loaded modules resolve: runners such as
76
+ // AIRerankerRunner (which extends BaseModelRunner), and RunDecisionResolver's AIDecisionRunner. Only
77
+ // AIPromptRunner is overridden for the test.
78
+ vi.mock('@memberjunction/ai-prompts', async (importOriginal) => ({
79
+ ...(await importOriginal<typeof import('@memberjunction/ai-prompts')>()),
80
+ AIPromptRunner: class {},
81
+ }));
76
82
  // Spread the real module so transitively-loaded code (e.g. ArtifactToolManager → DataSnapshotToolLibrary,
77
83
  // which extends BaseArtifactToolLibrary) still resolves with its REAL base classes; only AIPromptParams is
78
84
  // overridden for the test. (Subsumes the narrower stub-two-exports approach — any other transitive export
@@ -62,7 +62,13 @@ vi.mock('@memberjunction/remote-browser-server', () => ({
62
62
 
63
63
  // Keep the AI imports (visual interpreter) inert — they aren't exercised by the snapshot path.
64
64
  vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
65
- vi.mock('@memberjunction/ai-prompts', () => ({ AIPromptRunner: class {} }));
65
+ // Spread the real module, as for ai-core-plus below, so transitively-loaded modules resolve: runners such as
66
+ // AIRerankerRunner (which extends BaseModelRunner), and RunDecisionResolver's AIDecisionRunner. Only
67
+ // AIPromptRunner is overridden for the test.
68
+ vi.mock('@memberjunction/ai-prompts', async (importOriginal) => ({
69
+ ...(await importOriginal<typeof import('@memberjunction/ai-prompts')>()),
70
+ AIPromptRunner: class {},
71
+ }));
66
72
  // Spread the real module so transitively-loaded code still resolves with its REAL base classes; only
67
73
  // AIPromptParams is overridden for the test.
68
74
  vi.mock('@memberjunction/ai-core-plus', async (importOriginal) => ({
@@ -0,0 +1,96 @@
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 type { IMetadataProvider, UserInfo } from '@memberjunction/core';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
7
+
8
+ /**
9
+ * The ad-hoc SQL screen (`assertFullQueryUsesReadableEntityViews`) admits a table reference
10
+ * only when it resolves to an entity base view the caller can read UNSCOPED. These tests pin
11
+ * the three ways that resolution was looser than it looked (PR #4782 review):
12
+ *
13
+ * - a schema-qualified reference fell back to a bare-name match in another schema;
14
+ * - an unqualified name present in two schemas resolved to whichever loaded last;
15
+ * - entity CanRead was treated as unscoped read authority, though raw SQL applies neither the
16
+ * row-level filter nor the denied-field projection RunView does.
17
+ */
18
+
19
+ type EntityShape = {
20
+ Name: string;
21
+ SchemaName: string;
22
+ BaseView: string;
23
+ canRead?: boolean;
24
+ rowFilter?: string;
25
+ deniedFields?: string[];
26
+ };
27
+
28
+ function fakeProvider(entities: EntityShape[]): IMetadataProvider {
29
+ return {
30
+ Entities: entities.map((e) => ({
31
+ Name: e.Name,
32
+ SchemaName: e.SchemaName,
33
+ BaseView: e.BaseView,
34
+ GetUserPermisions: () => ({ CanRead: e.canRead ?? true }),
35
+ GetEffectiveRowFilterWhereClause: () => e.rowFilter ?? '',
36
+ GetDeniedReadFields: () => new Set(e.deniedFields ?? []),
37
+ })),
38
+ } as unknown as IMetadataProvider;
39
+ }
40
+
41
+ const USER = { ID: 'U1', Email: 'u@example.com' } as unknown as UserInfo;
42
+
43
+ class Probe extends ResolverBase {
44
+ public Screen(sql: string, provider: IMetadataProvider, user: UserInfo | undefined = USER) {
45
+ return this.assertFullQueryUsesReadableEntityViews(sql, provider, user, 'ad-hoc SQL');
46
+ }
47
+ }
48
+
49
+ const PUBLIC_FOO: EntityShape = { Name: 'Public Foo', SchemaName: 'public', BaseView: 'vwFoo' };
50
+
51
+ describe('assertFullQueryUsesReadableEntityViews', () => {
52
+ it('admits an unscoped readable base view', () => {
53
+ expect(() => new Probe().Screen('SELECT ID FROM [public].[vwFoo]', fakeProvider([PUBLIC_FOO]))).not.toThrow();
54
+ });
55
+
56
+ it('does not resolve a schema-qualified reference through another schema', () => {
57
+ expect(() => new Probe().Screen('SELECT ID FROM secret.vwFoo', fakeProvider([PUBLIC_FOO]))).toThrow(
58
+ /entity base view, not 'secret.vwFoo'/,
59
+ );
60
+ });
61
+
62
+ it('refuses an unqualified view name that exists in more than one schema', () => {
63
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Other Foo', SchemaName: 'other', BaseView: 'vwFoo' }]);
64
+ expect(() => new Probe().Screen('SELECT ID FROM vwFoo', provider)).toThrow(/more than one schema/);
65
+ expect(() => new Probe().Screen('SELECT ID FROM other.vwFoo', provider)).not.toThrow();
66
+ });
67
+
68
+ it('refuses an entity the caller cannot read', () => {
69
+ expect(() => new Probe().Screen('SELECT ID FROM public.vwFoo', fakeProvider([{ ...PUBLIC_FOO, canRead: false }]))).toThrow(
70
+ /read permission/,
71
+ );
72
+ });
73
+
74
+ it('refuses an entity that is row-level-security filtered for the caller', () => {
75
+ const provider = fakeProvider([{ ...PUBLIC_FOO, rowFilter: "(UserID = 'U1')" }]);
76
+ expect(() => new Probe().Screen('SELECT ID, UserID FROM public.vwFoo', provider)).toThrow(/row-level-security/);
77
+ });
78
+
79
+ it('refuses an entity with a field the caller is denied', () => {
80
+ const provider = fakeProvider([{ ...PUBLIC_FOO, deniedFields: ['secret'] }]);
81
+ expect(() => new Probe().Screen('SELECT ID FROM public.vwFoo', provider)).toThrow(/not permitted to read/);
82
+ });
83
+
84
+ it('applies the scope check to every reference, including joins and subqueries', () => {
85
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
86
+ expect(() =>
87
+ new Probe().Screen('SELECT f.ID FROM public.vwFoo f WHERE f.ID IN (SELECT FooID FROM public.vwBar)', provider),
88
+ ).toThrow(/'Bar' is row-level-security filtered/);
89
+ });
90
+
91
+ it('exempts CTE names the statement defines', () => {
92
+ expect(() =>
93
+ new Probe().Screen('WITH x AS (SELECT ID FROM public.vwFoo) SELECT ID FROM x', fakeProvider([PUBLIC_FOO])),
94
+ ).not.toThrow();
95
+ });
96
+ });
@@ -0,0 +1,52 @@
1
+ // Reflect.metadata polyfill at import time.
2
+ import 'reflect-metadata';
3
+ import { describe, it, expect } from 'vitest';
4
+ import { EntityFieldTSType, type EntityFieldInfo, type EntityInfo } from '@memberjunction/core';
5
+ import { ResolverBase } from '../generic/ResolverBase.js';
6
+
7
+ /**
8
+ * Client old values arrive as strings, with dates as epoch milliseconds (the GraphQL Timestamp form).
9
+ * Loading them as-is made an unchanged date field an Invalid Date, so it read as edited: a
10
+ * Comments-only update to an MJ: Record Changes row was refused for "changing" ChangedAt.
11
+ */
12
+ class Probe extends ResolverBase {
13
+ public Convert(field: Partial<EntityFieldInfo>, raw: unknown) {
14
+ return this.ClientOldValueToFieldValue(field as EntityFieldInfo, raw);
15
+ }
16
+ public MustLoad(entity: Partial<EntityInfo>, oldValues: boolean) {
17
+ return this.MustLoadTruthFromDatabase(entity as EntityInfo, { OldValues___: oldValues ? [{ Key: 'Comments', Value: null }] : undefined }, false, false);
18
+ }
19
+ }
20
+ const probe = new Probe();
21
+ const dateField = { Name: 'ChangedAt', TSType: EntityFieldTSType.Date, Type: 'datetimeoffset', AllowsNull: false, DefaultValue: 'getutcdate()' };
22
+
23
+ describe('ResolverBase.ClientOldValueToFieldValue', () => {
24
+ it('reads an epoch-milliseconds string as the same instant', () => {
25
+ const value = probe.Convert(dateField, '1790365634563') as Date;
26
+ expect(value).toBeInstanceOf(Date);
27
+ expect(value.getTime()).toBe(1790365634563);
28
+ });
29
+
30
+ it('reads an ISO date string too, and keeps null as null for a nullable date', () => {
31
+ expect((probe.Convert(dateField, '2026-09-25T19:47:14.563Z') as Date).toISOString()).toBe('2026-09-25T19:47:14.563Z');
32
+ expect(probe.Convert({ ...dateField, AllowsNull: true, DefaultValue: null }, null)).toBeNull();
33
+ });
34
+
35
+ it('types numbers and booleans like the field', () => {
36
+ expect(probe.Convert({ Name: 'N', TSType: EntityFieldTSType.Number, Type: 'int' }, '42')).toBe(42);
37
+ expect(probe.Convert({ Name: 'B', TSType: EntityFieldTSType.Boolean, Type: 'bit' }, 'false')).toBe(false);
38
+ expect(probe.Convert({ Name: 'B', TSType: EntityFieldTSType.Boolean, Type: 'bit', AllowsNull: true }, null)).toBe(false);
39
+ expect(probe.Convert({ Name: 'B', TSType: EntityFieldTSType.Boolean, Type: 'bit' }, '1')).toBe(true);
40
+ expect(probe.Convert({ Name: 'M', TSType: EntityFieldTSType.Number, Type: 'money' }, '1.5')).toBe(1.5);
41
+ });
42
+ });
43
+
44
+ describe('ResolverBase.MustLoadTruthFromDatabase', () => {
45
+ it('always loads a Record Change from the database, so client OldValues cannot pin forged audit columns', () => {
46
+ expect(probe.MustLoad({ Name: 'MJ: Record Changes', TrackRecordChanges: false }, true)).toBe(true);
47
+ });
48
+
49
+ it('still trusts OldValues on other untracked entities without field security', () => {
50
+ expect(probe.MustLoad({ Name: 'MJ: Tags', TrackRecordChanges: false, EnableFieldLevelSecurity: false }, true)).toBe(false);
51
+ });
52
+ });
@@ -33,7 +33,13 @@ type Captured = { params: RunViewParams | null };
33
33
  * its params. `Sequence` stands in for a numeric field (`NeedsQuotes` is false only for
34
34
  * Number and Boolean TS types).
35
35
  */
36
- function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []): DatabaseProviderBase {
36
+ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = [], unreadableEntities: string[] = []): DatabaseProviderBase {
37
+ const withPerms = (e: Record<string, unknown>) => ({
38
+ ...e,
39
+ // Real EntityInfo aggregates per-user permissions; the boundary screen now consults
40
+ // CanRead for every subquery target, so the mock must answer it.
41
+ GetUserPermisions: () => ({ CanRead: !unreadableEntities.includes(e.Name as string) }),
42
+ });
37
43
  return {
38
44
  Entities: [
39
45
  {
@@ -67,7 +73,7 @@ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []):
67
73
  { Name: 'Committees: Motions', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMotions', BaseTable: 'Motion', Fields: [] },
68
74
  { Name: 'Committees: Memberships', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMemberships', BaseTable: 'Membership', Fields: [] },
69
75
  { Name: 'Committees: Terms', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwTerms', BaseTable: 'Term', Fields: [] },
70
- ],
76
+ ].map(withPerms),
71
77
  RunView: async (params: RunViewParams): Promise<RunViewResult> => {
72
78
  captured.params = params;
73
79
  return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
@@ -246,6 +252,19 @@ describe('ResolverBase — GraphQL-boundary ExtraFilter AST screen', () => {
246
252
  expect(captured.params?.ExtraFilter).toBe(`ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`);
247
253
  });
248
254
 
255
+ it('RunDynamicViewGeneric rejects a subquery into an entity the caller cannot read', async () => {
256
+ const captured: Captured = { params: null };
257
+ const input = {
258
+ EntityName: ENTITY_NAME,
259
+ ExtraFilter: `ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments])`,
260
+ } as RunDynamicViewInput;
261
+
262
+ await expect(
263
+ new Probe().RunDynamic(input, fakeProvider(captured, [], ['MJ_BizApps_Tasks: Task Assignments']))
264
+ ).rejects.toThrow(/read permission/);
265
+ expect(captured.params).toBeNull();
266
+ });
267
+
249
268
  it('RunDynamicViewGeneric passes a benign ExtraFilter through to RunView', async () => {
250
269
  const captured: Captured = { params: null };
251
270
  const input = {