@memberjunction/server 5.51.3 → 5.51.4

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 (211) hide show
  1. package/README.md +34 -1
  2. package/dist/agentSessions/SessionManager.d.ts +5 -2
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +17 -8
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.js +136 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  10. package/dist/agentSessions/index.d.ts +1 -0
  11. package/dist/agentSessions/index.d.ts.map +1 -1
  12. package/dist/agentSessions/index.js +1 -0
  13. package/dist/agentSessions/index.js.map +1 -1
  14. package/dist/auth/actionAuthorization.d.ts +40 -0
  15. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  16. package/dist/auth/actionAuthorization.js +94 -0
  17. package/dist/auth/actionAuthorization.js.map +1 -0
  18. package/dist/auth/index.d.ts +1 -0
  19. package/dist/auth/index.d.ts.map +1 -1
  20. package/dist/auth/index.js +1 -0
  21. package/dist/auth/index.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +53 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +34 -1
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts +6 -0
  31. package/dist/context.d.ts.map +1 -1
  32. package/dist/context.js +24 -34
  33. package/dist/context.js.map +1 -1
  34. package/dist/generated/generated.d.ts.map +1 -1
  35. package/dist/generated/generated.js +3262 -3273
  36. package/dist/generated/generated.js.map +1 -1
  37. package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
  38. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.js +170 -15
  40. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  41. package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
  42. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
  43. package/dist/generic/FireAndForgetHeartbeat.js +5 -4
  44. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
  45. package/dist/generic/PushStatusResolver.d.ts +58 -0
  46. package/dist/generic/PushStatusResolver.d.ts.map +1 -1
  47. package/dist/generic/PushStatusResolver.js +42 -3
  48. package/dist/generic/PushStatusResolver.js.map +1 -1
  49. package/dist/generic/ResolverBase.d.ts +148 -3
  50. package/dist/generic/ResolverBase.d.ts.map +1 -1
  51. package/dist/generic/ResolverBase.js +287 -30
  52. package/dist/generic/ResolverBase.js.map +1 -1
  53. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  54. package/dist/generic/RunViewResolver.js +12 -0
  55. package/dist/generic/RunViewResolver.js.map +1 -1
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +56 -48
  58. package/dist/index.js.map +1 -1
  59. package/dist/multiTenancy/index.d.ts.map +1 -1
  60. package/dist/multiTenancy/index.js +22 -2
  61. package/dist/multiTenancy/index.js.map +1 -1
  62. package/dist/postgresqlCompat.d.ts +8 -0
  63. package/dist/postgresqlCompat.d.ts.map +1 -0
  64. package/dist/postgresqlCompat.js +19 -0
  65. package/dist/postgresqlCompat.js.map +1 -0
  66. package/dist/postgresqlPoolSettings.d.ts +108 -0
  67. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  68. package/dist/postgresqlPoolSettings.js +134 -0
  69. package/dist/postgresqlPoolSettings.js.map +1 -0
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
  71. package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
  72. package/dist/resolvers/ActionResolver.d.ts +31 -5
  73. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  74. package/dist/resolvers/ActionResolver.js +75 -14
  75. package/dist/resolvers/ActionResolver.js.map +1 -1
  76. package/dist/resolvers/AdhocQueryResolver.d.ts +39 -17
  77. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  78. package/dist/resolvers/AdhocQueryResolver.js +97 -157
  79. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  80. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  81. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  82. package/dist/resolvers/DatasetResolver.js +57 -8
  83. package/dist/resolvers/DatasetResolver.js.map +1 -1
  84. package/dist/resolvers/EntityRecordNameResolver.d.ts +14 -4
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  86. package/dist/resolvers/EntityRecordNameResolver.js +18 -6
  87. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  88. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  89. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  90. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  91. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  92. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataResolver.js +13 -1
  94. package/dist/resolvers/GetDataResolver.js.map +1 -1
  95. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  96. package/dist/resolvers/MCPResolver.js +13 -16
  97. package/dist/resolvers/MCPResolver.js.map +1 -1
  98. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
  99. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  100. package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
  101. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  102. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  103. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  104. package/dist/resolvers/RemoteBrowserActionResolver.js +24 -27
  105. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  106. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.js +30 -40
  108. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  109. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunTemplateResolver.js +12 -11
  111. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  112. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTestResolver.js +90 -112
  114. package/dist/resolvers/RunTestResolver.js.map +1 -1
  115. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  116. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  117. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  118. package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
  119. package/dist/resolvers/VersionHistoryResolver.js +8 -12
  120. package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
  121. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  122. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  123. package/dist/resolvers/adhocTimeout.js +12 -0
  124. package/dist/resolvers/adhocTimeout.js.map +1 -0
  125. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  126. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  127. package/dist/rest/ViewOperationsHandler.js +29 -17
  128. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  129. package/dist/services/TaskOrchestrator.d.ts.map +1 -1
  130. package/dist/services/TaskOrchestrator.js +17 -15
  131. package/dist/services/TaskOrchestrator.js.map +1 -1
  132. package/package.json +90 -89
  133. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  134. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  135. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  136. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  137. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  138. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  139. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  140. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  141. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +136 -0
  142. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
  143. package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
  144. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
  145. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
  146. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
  147. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  148. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
  149. package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
  150. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  151. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
  152. package/src/__tests__/SessionManager.test.ts +88 -13
  153. package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
  154. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  155. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
  156. package/src/__tests__/actionAuthorization.test.ts +130 -0
  157. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  158. package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
  159. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
  160. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  161. package/src/__tests__/multiTenancy.test.ts +1 -1
  162. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  163. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  164. package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
  165. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  166. package/src/__tests__/resolverBase.rls.test.ts +2 -0
  167. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  168. package/src/agentSessions/SessionManager.ts +24 -25
  169. package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
  170. package/src/agentSessions/index.ts +1 -0
  171. package/src/auth/actionAuthorization.ts +119 -0
  172. package/src/auth/index.ts +1 -0
  173. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  174. package/src/config.ts +34 -1
  175. package/src/context.ts +25 -34
  176. package/src/generated/generated.ts +3262 -3273
  177. package/src/generic/CacheInvalidationResolver.ts +206 -15
  178. package/src/generic/FireAndForgetHeartbeat.ts +10 -4
  179. package/src/generic/PushStatusResolver.ts +79 -4
  180. package/src/generic/ResolverBase.ts +339 -31
  181. package/src/generic/RunViewResolver.ts +17 -0
  182. package/src/index.ts +63 -50
  183. package/src/multiTenancy/index.ts +24 -2
  184. package/src/postgresqlCompat.ts +21 -0
  185. package/src/postgresqlPoolSettings.ts +213 -0
  186. package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
  187. package/src/resolvers/ActionResolver.ts +85 -22
  188. package/src/resolvers/AdhocQueryResolver.ts +110 -177
  189. package/src/resolvers/DatasetResolver.ts +70 -10
  190. package/src/resolvers/EntityRecordNameResolver.ts +24 -7
  191. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  192. package/src/resolvers/GetDataResolver.ts +14 -1
  193. package/src/resolvers/MCPResolver.ts +13 -15
  194. package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
  195. package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
  196. package/src/resolvers/RunAIAgentResolver.ts +30 -40
  197. package/src/resolvers/RunTemplateResolver.ts +13 -12
  198. package/src/resolvers/RunTestResolver.ts +90 -112
  199. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  200. package/src/resolvers/VersionHistoryResolver.ts +10 -13
  201. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  202. package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
  203. package/src/resolvers/adhocTimeout.ts +12 -0
  204. package/src/rest/ViewOperationsHandler.ts +37 -18
  205. package/src/services/TaskOrchestrator.ts +17 -17
  206. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  207. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  208. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  209. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  210. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  211. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `ExecuteRemoteBrowserGoal` attaches a "Browser goal" step to the realtime co-agent run named by the
3
+ * session's `coAgentRunID`. The session owner can edit `Config`, so the run must be a record of the
4
+ * session before a step is attached to it.
5
+ */
6
+ // type-graphql decorators on the resolver need the reflect-metadata polyfill loaded first.
7
+ import 'reflect-metadata';
8
+
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
10
+
11
+ // As in RemoteBrowserActionRecovery.test.ts: the decorators become no-ops so the resolver's plain logic runs.
12
+ vi.mock('type-graphql', () => {
13
+ const noopDecorator = () => () => undefined;
14
+ return {
15
+ Resolver: noopDecorator,
16
+ Mutation: noopDecorator,
17
+ Query: noopDecorator,
18
+ Subscription: noopDecorator,
19
+ ObjectType: noopDecorator,
20
+ InputType: noopDecorator,
21
+ Field: noopDecorator,
22
+ Arg: noopDecorator,
23
+ Args: noopDecorator,
24
+ Ctx: noopDecorator,
25
+ PubSub: noopDecorator,
26
+ Root: noopDecorator,
27
+ Float: class {},
28
+ Int: class {},
29
+ ID: class {},
30
+ };
31
+ });
32
+
33
+ const achieveGoalMock = vi.fn(async (_sessionID: string, _goal: string, _options: Record<string, unknown>) => ({ Success: true, Status: 'Done' }));
34
+ vi.mock('@memberjunction/remote-browser-server', () => ({
35
+ NormalizeInstanceKey: (key?: string) => key,
36
+ RemoteBrowserEngine: {
37
+ Instance: {
38
+ AchieveGoal: (sessionID: string, goal: string, options: Record<string, unknown>) => achieveGoalMock(sessionID, goal, options),
39
+ },
40
+ },
41
+ }));
42
+
43
+ const beginStepMock = vi.fn(async (_provider: unknown, _user: unknown, _coAgentRunID: string | undefined, _goal: string) => null);
44
+ vi.mock('../agentSessions/remoteBrowserGoalEngine.js', async (importOriginal) => ({
45
+ ...(await importOriginal<typeof import('../agentSessions/remoteBrowserGoalEngine.js')>()),
46
+ beginBrowserGoalStep: (provider: unknown, user: unknown, coAgentRunID: string | undefined, goal: string) =>
47
+ beginStepMock(provider, user, coAgentRunID, goal),
48
+ finalizeBrowserGoalStep: vi.fn(async () => undefined),
49
+ }));
50
+
51
+ // lts/5's UserCache throws from GetSystemUser while unloaded; no system user is cached here.
52
+ vi.mock('@memberjunction/sqlserver-dataprovider', async (importOriginal) => ({
53
+ ...(await importOriginal<Record<string, unknown>>()),
54
+ UserCache: { get Instance() { return { GetSystemUser: () => undefined }; } },
55
+ }));
56
+ vi.mock('@memberjunction/aiengine', () => ({ AIEngine: { Instance: { Config: vi.fn(), Prompts: [] } } }));
57
+ vi.mock('@memberjunction/ai-prompts', async (importOriginal) => ({
58
+ ...(await importOriginal<typeof import('@memberjunction/ai-prompts')>()),
59
+ AIPromptRunner: class {},
60
+ }));
61
+ vi.mock('@memberjunction/ai-core-plus', async (importOriginal) => ({
62
+ ...(await importOriginal<typeof import('@memberjunction/ai-core-plus')>()),
63
+ AIPromptParams: class {},
64
+ }));
65
+
66
+ import { RemoteBrowserActionResolver } from '../resolvers/RemoteBrowserActionResolver.js';
67
+ import type { AppContext } from '../types.js';
68
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
69
+ import type { MJAIAgentSessionEntity } from '@memberjunction/core-entities';
70
+
71
+ const SESSION_ID = 'c3c3c3c3-2000-4000-8000-000000000001';
72
+ const CO_RUN_ID = 'c3c3c3c3-2000-4000-8000-000000000002';
73
+ /** A run that exists, but belongs to someone else's session. */
74
+ const FOREIGN_RUN_ID = 'c3c3c3c3-2000-4000-8000-0000000000f1';
75
+
76
+ /** The runs the fake lookup reports as records of the session. */
77
+ let sessionRunIDs: string[] = [];
78
+ const runViewsMock = vi.fn(async (paramsList: Array<{ EntityName?: string }>) =>
79
+ paramsList.map((params) => ({
80
+ Success: true,
81
+ Results: params.EntityName === 'MJ: AI Agent Runs' ? sessionRunIDs.map((ID) => ({ ID })) : [],
82
+ })),
83
+ );
84
+
85
+ /** Bypasses the ownership/provider plumbing (covered elsewhere); the session carries `ConfigJson`. */
86
+ class TestableResolver extends RemoteBrowserActionResolver {
87
+ public ConfigJson = '';
88
+ protected override requireUserAndProvider(): { contextUser: UserInfo; provider: IMetadataProvider } {
89
+ return {
90
+ contextUser: { ID: 'c3c3c3c3-2000-4000-8000-0000000000aa' } as unknown as UserInfo,
91
+ provider: { RunViews: runViewsMock } as unknown as IMetadataProvider,
92
+ };
93
+ }
94
+ protected override async loadOwnedSession(): Promise<MJAIAgentSessionEntity> {
95
+ return { ID: SESSION_ID, AgentID: 'agent-1', Config_: this.ConfigJson } as unknown as MJAIAgentSessionEntity;
96
+ }
97
+ protected override async resolveProviderName(): Promise<string | undefined> {
98
+ return 'Self-Hosted Chrome';
99
+ }
100
+ }
101
+
102
+ const ctx = { userPayload: { sessionId: 'push-sess-1' }, providers: [] } as unknown as AppContext;
103
+
104
+ async function startGoal(config: Record<string, unknown>): Promise<void> {
105
+ const resolver = new TestableResolver();
106
+ resolver.ConfigJson = JSON.stringify(config);
107
+ await resolver.ExecuteRemoteBrowserGoal(SESSION_ID, 'open the docs', ctx);
108
+ }
109
+
110
+ beforeEach(() => {
111
+ sessionRunIDs = [];
112
+ runViewsMock.mockClear();
113
+ beginStepMock.mockClear();
114
+ achieveGoalMock.mockClear();
115
+ });
116
+
117
+ describe('RemoteBrowserActionResolver.ExecuteRemoteBrowserGoal — co-agent run from Config', () => {
118
+ it('does not attach the goal to a run that is not a record of the session', async () => {
119
+ await startGoal({ targetAgentID: 'target-1', coAgentRunID: FOREIGN_RUN_ID });
120
+
121
+ expect(beginStepMock).toHaveBeenCalledTimes(1);
122
+ expect(beginStepMock.mock.calls[0][2]).toBeUndefined();
123
+ expect(achieveGoalMock.mock.calls[0][2].AgentRunID).toBeUndefined();
124
+ });
125
+
126
+ it('attaches the goal to the session\'s co-agent run', async () => {
127
+ sessionRunIDs = [CO_RUN_ID];
128
+
129
+ await startGoal({ targetAgentID: 'target-1', coAgentRunID: CO_RUN_ID });
130
+
131
+ expect(beginStepMock.mock.calls[0][2]).toBe(CO_RUN_ID);
132
+ expect(achieveGoalMock.mock.calls[0][2].AgentRunID).toBe(CO_RUN_ID);
133
+ });
134
+ });
@@ -0,0 +1,221 @@
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 { DatabasePlatform, IMetadataProvider, UserInfo } from '@memberjunction/core';
6
+ import { RenderPipeline } from '@memberjunction/generic-database-provider';
7
+ import { ResolverBase } from '../generic/ResolverBase.js';
8
+
9
+ /**
10
+ * The ad-hoc SQL screen (`assertFullQueryUsesReadableEntityViews`) admits a table reference
11
+ * only when it resolves to an entity base view the caller can read UNSCOPED. These tests pin
12
+ * the three ways that resolution was looser than it looked (PR #4782 review):
13
+ *
14
+ * - a schema-qualified reference fell back to a bare-name match in another schema;
15
+ * - an unqualified name present in two schemas resolved to whichever loaded last;
16
+ * - entity CanRead was treated as unscoped read authority, though raw SQL does not apply the
17
+ * row-level filter RunView does. (lts/5 has no field-level security, so there is no
18
+ * denied-field case on this line.)
19
+ */
20
+
21
+ type EntityShape = {
22
+ Name: string;
23
+ SchemaName: string;
24
+ BaseView: string;
25
+ canRead?: boolean;
26
+ rowFilter?: string;
27
+ };
28
+
29
+ function fakeProvider(entities: EntityShape[], platform?: DatabasePlatform): IMetadataProvider {
30
+ return {
31
+ PlatformKey: platform,
32
+ Entities: entities.map((e) => ({
33
+ Name: e.Name,
34
+ SchemaName: e.SchemaName,
35
+ BaseView: e.BaseView,
36
+ GetUserPermisions: () => ({ CanRead: e.canRead ?? true }),
37
+ GetUserRowLevelSecurityWhereClause: () => e.rowFilter ?? '',
38
+ })),
39
+ } as unknown as IMetadataProvider;
40
+ }
41
+
42
+ const USER = { ID: 'U1', Email: 'u@example.com' } as unknown as UserInfo;
43
+
44
+ class Probe extends ResolverBase {
45
+ public Screen(sql: string, provider: IMetadataProvider, user: UserInfo | undefined = USER) {
46
+ return this.assertFullQueryUsesReadableEntityViews(sql, provider, user, 'ad-hoc SQL');
47
+ }
48
+
49
+ /** Renders the SQL the way `ExecuteAdhocQuery` does, then screens the rendered text. */
50
+ public ScreenRendered(sql: string, platform: DatabasePlatform, provider: IMetadataProvider) {
51
+ const rendered = RenderPipeline.Run(sql, { Platform: platform, ContextUser: USER, RequireReadStatement: true });
52
+ return this.Screen(rendered.Trace.AfterTemplates, provider);
53
+ }
54
+ }
55
+
56
+ const PUBLIC_FOO: EntityShape = { Name: 'Public Foo', SchemaName: 'public', BaseView: 'vwFoo' };
57
+
58
+ describe('assertFullQueryUsesReadableEntityViews', () => {
59
+ it('admits an unscoped readable base view', () => {
60
+ expect(() => new Probe().Screen('SELECT ID FROM [public].[vwFoo]', fakeProvider([PUBLIC_FOO]))).not.toThrow();
61
+ });
62
+
63
+ it('does not resolve a schema-qualified reference through another schema', () => {
64
+ expect(() => new Probe().Screen('SELECT ID FROM secret.vwFoo', fakeProvider([PUBLIC_FOO]))).toThrow(
65
+ /entity base view, not 'secret.vwFoo'/,
66
+ );
67
+ });
68
+
69
+ it('refuses an unqualified view name that exists in more than one schema', () => {
70
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Other Foo', SchemaName: 'other', BaseView: 'vwFoo' }]);
71
+ expect(() => new Probe().Screen('SELECT ID FROM vwFoo', provider)).toThrow(/more than one schema/);
72
+ expect(() => new Probe().Screen('SELECT ID FROM other.vwFoo', provider)).not.toThrow();
73
+ });
74
+
75
+ it('refuses an entity the caller cannot read', () => {
76
+ expect(() => new Probe().Screen('SELECT ID FROM public.vwFoo', fakeProvider([{ ...PUBLIC_FOO, canRead: false }]))).toThrow(
77
+ /read permission/,
78
+ );
79
+ });
80
+
81
+ it('refuses an entity that is row-level-security filtered for the caller', () => {
82
+ const provider = fakeProvider([{ ...PUBLIC_FOO, rowFilter: "(UserID = 'U1')" }]);
83
+ expect(() => new Probe().Screen('SELECT ID, UserID FROM public.vwFoo', provider)).toThrow(/row-level-security/);
84
+ });
85
+
86
+ it('applies the scope check to every reference, including joins and subqueries', () => {
87
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
88
+ expect(() =>
89
+ new Probe().Screen('SELECT f.ID FROM public.vwFoo f WHERE f.ID IN (SELECT FooID FROM public.vwBar)', provider),
90
+ ).toThrow(/'Bar' is row-level-security filtered/);
91
+ });
92
+
93
+ it('applies the scope check to a CASE subquery in the select list', () => {
94
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
95
+ expect(() =>
96
+ new Probe().Screen('SELECT CASE WHEN (SELECT COUNT(*) FROM public.vwBar) > 0 THEN 1 ELSE 0 END AS x FROM public.vwFoo', provider),
97
+ ).toThrow(/'Bar' is row-level-security filtered/);
98
+ });
99
+
100
+ it('applies the scope check to every FROM item when an alias repeats', () => {
101
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
102
+ expect(() =>
103
+ new Probe().Screen('SELECT a.ID FROM public.vwBar a WHERE a.ID IN (SELECT a.ID FROM public.vwFoo a)', provider),
104
+ ).toThrow(/'Bar' is row-level-security filtered/);
105
+ });
106
+
107
+ it('exempts CTE names the statement defines', () => {
108
+ expect(() =>
109
+ new Probe().Screen('WITH x AS (SELECT ID FROM public.vwFoo) SELECT ID FROM x', fakeProvider([PUBLIC_FOO])),
110
+ ).not.toThrow();
111
+ });
112
+ });
113
+
114
+ describe('assertFullQueryUsesReadableEntityViews — CTE names', () => {
115
+ // The parser renames bracket identifiers it cannot read and maps the new names back. The CTE
116
+ // exemption must only cover names the statement declares, so a name of the renamed form,
117
+ // quoted or bare, is refused. Orders is row-filtered for the caller; Accounts is not.
118
+ const ACCOUNTS: EntityShape = { Name: 'Accounts', SchemaName: 'dbo', BaseView: 'vwAccounts' };
119
+ const ORDERS: EntityShape = { Name: 'Orders', SchemaName: 'dbo', BaseView: 'vwOrders', rowFilter: "(OwnerID = 'U1')" };
120
+ const DIALECTS: Array<{ Name: string; Platform: DatabasePlatform; Quote: (name: string) => string }> = [
121
+ { Name: 'SQL Server', Platform: 'sqlserver', Quote: (name) => `[${name}]` },
122
+ { Name: 'PostgreSQL', Platform: 'postgresql', Quote: (name) => `"${name}"` },
123
+ ];
124
+
125
+ for (const { Name, Platform, Quote } of DIALECTS) {
126
+ const provider = fakeProvider([ACCOUNTS, ORDERS], Platform);
127
+
128
+ it(`refuses a CTE name reserved for the SQL parser, before reading any view (${Name})`, () => {
129
+ const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x), _mjid_1 AS (SELECT 1 AS y) SELECT * FROM ${Quote('vwOrders')}`;
130
+
131
+ expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/reserved for the SQL parser/);
132
+ });
133
+
134
+ it(`refuses a reserved name in any case or quoting (${Name})`, () => {
135
+ for (const name of ['_MJID_1', Quote('_mjid_1'), '_mjid2_0']) {
136
+ const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x), ${name} AS (SELECT 1 AS y) SELECT * FROM ${Quote('vwAccounts')}`;
137
+ expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/reserved for the SQL parser/);
138
+ }
139
+ });
140
+
141
+ it(`still exempts an ordinary quoted CTE name and screens its body (${Name})`, () => {
142
+ const cte = Quote('Recent Accounts');
143
+ const reads = (view: string) => `WITH ${cte} AS (SELECT ID FROM ${Quote(view)}) SELECT ID FROM ${cte}`;
144
+
145
+ expect(() => new Probe().ScreenRendered(reads('vwAccounts'), Platform, provider)).not.toThrow();
146
+ expect(() => new Probe().ScreenRendered(reads('vwOrders'), Platform, provider)).toThrow(/'Orders' is row-level-security filtered/);
147
+ });
148
+
149
+ it(`still refuses a row-filtered view read next to an ordinary quoted CTE (${Name})`, () => {
150
+ const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x) SELECT * FROM ${Quote('vwOrders')}`;
151
+
152
+ expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/'Orders' is row-level-security filtered/);
153
+ });
154
+ }
155
+ });
156
+
157
+ describe('assertFullQueryUsesReadableEntityViews — the CTE exemption covers only a CTE in scope', () => {
158
+ // Orders is row-filtered for the caller; Accounts is not. Each refused statement reads the real
159
+ // Orders view at a point where no CTE of that name is in scope, so the read must be checked.
160
+ const ACCOUNTS: EntityShape = { Name: 'Accounts', SchemaName: 'dbo', BaseView: 'vwAccounts' };
161
+ const ORDERS: EntityShape = { Name: 'Orders', SchemaName: 'dbo', BaseView: 'vwOrders', rowFilter: "(OwnerID = 'U1')" };
162
+ const ROW_FILTERED = /'Orders' is row-level-security filtered/;
163
+ const providers: Record<DatabasePlatform, IMetadataProvider> = {
164
+ sqlserver: fakeProvider([ACCOUNTS, ORDERS], 'sqlserver'),
165
+ postgresql: fakeProvider([ACCOUNTS, ORDERS], 'postgresql'),
166
+ };
167
+ const screen = (sql: string, platform: DatabasePlatform) => () => new Probe().ScreenRendered(sql, platform, providers[platform]);
168
+
169
+ it('refuses a schema-qualified read of a view a CTE is named after (SQL Server dbo, PostgreSQL)', () => {
170
+ expect(screen('WITH vwOrders AS (SELECT 1 AS y) SELECT * FROM dbo.vwOrders', 'sqlserver')).toThrow(ROW_FILTERED);
171
+ expect(screen('WITH "vwOrders" AS (SELECT 1 AS y) SELECT * FROM dbo."vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
172
+ });
173
+
174
+ it('refuses a read outside the subquery or branch whose WITH declares the name (PostgreSQL)', () => {
175
+ const statements = [
176
+ 'SELECT o.* FROM (WITH "vwOrders" AS (SELECT 1 AS a) SELECT * FROM "vwOrders") s CROSS JOIN "vwOrders" o',
177
+ 'SELECT "ID" FROM "vwAccounts" WHERE EXISTS (WITH "vwOrders" AS (SELECT 1 AS a) SELECT 1 FROM "vwOrders") AND "ID" IN (SELECT "ID" FROM "vwOrders")',
178
+ 'WITH a AS (WITH "vwOrders" AS (SELECT 1 AS x) SELECT * FROM "vwOrders") SELECT * FROM a, "vwOrders"',
179
+ 'SELECT * FROM ((WITH "vwOrders" AS (SELECT 1 AS "ID") SELECT "ID" FROM "vwOrders") UNION SELECT "ID" FROM "vwOrders") u',
180
+ ];
181
+ for (const sql of statements) {
182
+ expect(screen(sql, 'postgresql')).toThrow(ROW_FILTERED);
183
+ }
184
+ });
185
+
186
+ it('refuses a read in a CTE body that does not see that CTE yet (PostgreSQL without RECURSIVE, SQL Server)', () => {
187
+ expect(screen('WITH "vwOrders" AS (SELECT * FROM "vwOrders") SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
188
+ expect(screen('WITH a AS (SELECT * FROM "vwOrders"), "vwOrders" AS (SELECT 1 AS y) SELECT * FROM a', 'postgresql')).toThrow(ROW_FILTERED);
189
+ expect(screen('WITH a AS (SELECT * FROM vwOrders), vwOrders AS (SELECT 1 AS y) SELECT * FROM a', 'sqlserver')).toThrow(ROW_FILTERED);
190
+ });
191
+
192
+ it('refuses a quoted read whose name differs from the CTE name once PostgreSQL case rules apply', () => {
193
+ expect(screen('WITH vworders AS (SELECT 1 AS y) SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
194
+ expect(screen('WITH "VWORDERS" AS (SELECT 1 AS y) SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
195
+ });
196
+
197
+ it('still exempts ordinary CTE references (SQL Server)', () => {
198
+ const statements = [
199
+ 'WITH Recent AS (SELECT ID FROM vwAccounts) SELECT ID FROM recent',
200
+ 'WITH r AS (SELECT ID FROM vwAccounts UNION ALL SELECT ID FROM r WHERE 1 = 0) SELECT ID FROM r',
201
+ 'WITH a AS (SELECT ID FROM vwAccounts), b AS (SELECT ID FROM a) SELECT ID FROM b WHERE ID IN (SELECT ID FROM a UNION SELECT ID FROM a)',
202
+ ];
203
+ for (const sql of statements) {
204
+ expect(screen(sql, 'sqlserver')).not.toThrow();
205
+ }
206
+ });
207
+
208
+ it('still exempts ordinary CTE references (PostgreSQL)', () => {
209
+ const statements = [
210
+ 'WITH recent AS (SELECT "ID" FROM "vwAccounts") SELECT "ID" FROM Recent',
211
+ 'WITH "Recent" AS (SELECT "ID" FROM "vwAccounts") SELECT "ID" FROM "Recent"',
212
+ 'WITH RECURSIVE r AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM r WHERE n < 3) SELECT n FROM r',
213
+ 'WITH RECURSIVE a AS (SELECT * FROM b), b AS (SELECT "ID" FROM "vwAccounts") SELECT * FROM a',
214
+ 'SELECT * FROM (WITH x AS (SELECT "ID" FROM "vwAccounts") SELECT * FROM x) s',
215
+ 'WITH a AS (SELECT "ID" FROM "vwAccounts") SELECT "ID" FROM (SELECT "ID" FROM a) s WHERE "ID" IN (SELECT "ID" FROM a UNION SELECT "ID" FROM a)',
216
+ ];
217
+ for (const sql of statements) {
218
+ expect(screen(sql, 'postgresql')).not.toThrow();
219
+ }
220
+ });
221
+ });
@@ -0,0 +1,173 @@
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 { AggregateExpression, DatabaseProviderBase, IMetadataProvider, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
6
+ import type { PubSubEngine } from 'type-graphql';
7
+ import { ResolverBase } from '../generic/ResolverBase.js';
8
+ import { RunViewResolver, type RunDynamicViewInput } from '../generic/RunViewResolver.js';
9
+ import type { AppContext, UserPayload } from '../types.js';
10
+
11
+ /**
12
+ * `Aggregates[].expression` is spliced into `SELECT <expression> AS [Agg_0] FROM <view> WHERE …`.
13
+ * The GraphQL-boundary screen must treat it like the other client SQL fragments (ExtraFilter,
14
+ * OrderBy, OverrideExcludeFilter): one statement, no writes, and subqueries only against entity
15
+ * base views the caller can read.
16
+ *
17
+ * The payloads below are harmless (`SELECT 1`).
18
+ */
19
+
20
+ const ENTITY_NAME = 'MJ: Users';
21
+
22
+ /** An aggregate that carries a second statement. */
23
+ const STACKED_AGGREGATE = "COUNT(*) AS [a'] ; SELECT 1 AS [b'], COUNT(*)";
24
+
25
+ /** A subquery against a base table rather than an entity base view. */
26
+ const BASE_TABLE_AGGREGATE = 'COUNT(*) + (SELECT COUNT(*) FROM __mj.[User])';
27
+
28
+ type Captured = { params: RunViewParams | null };
29
+
30
+ /** Entity metadata the screen reads, plus a `RunView` that records what reached it. */
31
+ function fakeProvider(captured: Captured): DatabaseProviderBase {
32
+ return {
33
+ Entities: [
34
+ {
35
+ ID: 'E1',
36
+ Name: ENTITY_NAME,
37
+ SchemaName: '__mj',
38
+ BaseView: 'vwUsers',
39
+ BaseTable: 'User',
40
+ Fields: [
41
+ { Name: 'ID', NeedsQuotes: true },
42
+ { Name: 'Name', NeedsQuotes: true },
43
+ { Name: 'Sequence', NeedsQuotes: false },
44
+ ],
45
+ PrimaryKeys: [{ Name: 'ID' }],
46
+ GetUserPermisions: () => ({ CanRead: true }),
47
+ },
48
+ ],
49
+ EntityByName: (name: string) => (name === ENTITY_NAME ? { ID: 'E1', Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] } : undefined),
50
+ RunView: async (params: RunViewParams): Promise<RunViewResult> => {
51
+ captured.params = params;
52
+ return { Success: true, Results: [], RowCount: 0, TotalRowCount: 0, ErrorMessage: '' } as RunViewResult;
53
+ },
54
+ RunViews: async (params: RunViewParams[]): Promise<RunViewResult[]> => {
55
+ captured.params = params[0] ?? null;
56
+ return params.map(() => ({ Success: true, Results: [], RowCount: 0, TotalRowCount: 0, ErrorMessage: '' } as RunViewResult));
57
+ },
58
+ } as unknown as DatabaseProviderBase;
59
+ }
60
+
61
+ const fakeUser = () => ({ Email: 'tester@example.com' } as UserInfo);
62
+ const fakePayload = () => ({ email: 'tester@example.com', userRecord: fakeUser() } as UserPayload);
63
+
64
+ /** Reaches the boundary screen and the dynamic-view entry point. */
65
+ class Probe extends ResolverBase {
66
+ public ScreenAll(
67
+ clauses: { extraFilter?: string | null; aggregates?: AggregateExpression[] | null },
68
+ provider: DatabaseProviderBase,
69
+ ) {
70
+ return this.screenClientViewClauses(clauses, provider as unknown as IMetadataProvider);
71
+ }
72
+
73
+ public RunDynamic(input: RunDynamicViewInput, provider: DatabaseProviderBase) {
74
+ return this.RunDynamicViewGeneric(input, provider, fakePayload(), undefined as unknown as PubSubEngine);
75
+ }
76
+ }
77
+
78
+ describe('ResolverBase.screenClientViewClauses — aggregate expressions', () => {
79
+ it('rejects an aggregate that carries a second statement', () => {
80
+ expect(() =>
81
+ new Probe().ScreenAll({ aggregates: [{ expression: STACKED_AGGREGATE }] }, fakeProvider({ params: null })),
82
+ ).toThrow(/Invalid Aggregate: multiple statements/);
83
+ });
84
+
85
+ it('rejects an aggregate whose subquery reads a base table', () => {
86
+ expect(() =>
87
+ new Probe().ScreenAll({ aggregates: [{ expression: BASE_TABLE_AGGREGATE }] }, fakeProvider({ params: null })),
88
+ ).toThrow(/Invalid Aggregate: subquery must use an entity base view/);
89
+ });
90
+
91
+ it('screens every aggregate in the list, not only the first', () => {
92
+ expect(() =>
93
+ new Probe().ScreenAll(
94
+ { aggregates: [{ expression: 'COUNT(*)' }, { expression: STACKED_AGGREGATE }] },
95
+ fakeProvider({ params: null }),
96
+ ),
97
+ ).toThrow(/Invalid Aggregate: multiple statements/);
98
+ });
99
+
100
+ it('leaves an aggregate the parser cannot read to the provider, which refuses it per aggregate', () => {
101
+ expect(() =>
102
+ new Probe().ScreenAll({ aggregates: [{ expression: 'COUNT_BIG(*)' }] }, fakeProvider({ params: null })),
103
+ ).not.toThrow();
104
+ });
105
+
106
+ it('lets ordinary aggregate expressions through', () => {
107
+ const ordinary = [
108
+ 'COUNT(*)',
109
+ 'SUM(Sequence)',
110
+ 'AVG(Sequence * 2)',
111
+ 'COUNT(DISTINCT Name)',
112
+ "COUNT(CASE WHEN Name = 'x' THEN 1 END)",
113
+ 'MIN([Name])',
114
+ ];
115
+ expect(() =>
116
+ new Probe().ScreenAll({ aggregates: ordinary.map((expression) => ({ expression })) }, fakeProvider({ params: null })),
117
+ ).not.toThrow();
118
+ });
119
+ });
120
+
121
+ describe('ResolverBase RunView entry points — aggregate expressions', () => {
122
+ it('RunDynamicViewGeneric never reaches RunView with a stacked aggregate', async () => {
123
+ const captured: Captured = { params: null };
124
+ const input = { EntityName: ENTITY_NAME, Aggregates: [{ expression: STACKED_AGGREGATE }] } as RunDynamicViewInput;
125
+
126
+ await expect(new Probe().RunDynamic(input, fakeProvider(captured))).rejects.toThrow(/Invalid Aggregate: multiple statements/);
127
+ expect(captured.params).toBeNull();
128
+ });
129
+
130
+ it('RunDynamicViewGeneric passes ordinary aggregates through to RunView', async () => {
131
+ const captured: Captured = { params: null };
132
+ const aggregates = [{ expression: 'COUNT(*)', alias: 'Total' }, { expression: 'SUM(Sequence)' }];
133
+ const input = { EntityName: ENTITY_NAME, Aggregates: aggregates } as RunDynamicViewInput;
134
+
135
+ await new Probe().RunDynamic(input, fakeProvider(captured));
136
+
137
+ expect(captured.params?.Aggregates).toEqual(aggregates);
138
+ });
139
+
140
+ // lts/5: a refused clause fails the whole RunViews batch (the resolver returns null) rather than one result.
141
+ it('RunViews fails the batch, and never reaches the provider, for a stacked aggregate', async () => {
142
+ const captured: Captured = { params: null };
143
+ const provider = fakeProvider(captured);
144
+ const input = [{ EntityName: ENTITY_NAME, Aggregates: [{ expression: STACKED_AGGREGATE }] }] as Parameters<RunViewResolver['RunViews']>[0];
145
+
146
+ const results = await new RunViewResolver().RunViews(
147
+ input,
148
+ { providers: [{ type: 'Read-Only', provider }] as unknown as AppContext['providers'], userPayload: fakePayload() } as AppContext,
149
+ undefined as unknown as PubSubEngine,
150
+ );
151
+
152
+ expect(results).toBeNull();
153
+ expect(captured.params).toBeNull();
154
+ });
155
+
156
+ it('RunViews does not fail the batch because one aggregate cannot be parsed', async () => {
157
+ const captured: Captured = { params: null };
158
+ const provider = fakeProvider(captured);
159
+ const input = [
160
+ { EntityName: ENTITY_NAME, Aggregates: [{ expression: 'COUNT_BIG(*)' }] },
161
+ { EntityName: ENTITY_NAME },
162
+ ] as Parameters<RunViewResolver['RunViews']>[0];
163
+
164
+ const results = await new RunViewResolver().RunViews(
165
+ input,
166
+ { providers: [{ type: 'Read-Only', provider }] as unknown as AppContext['providers'], userPayload: fakePayload() } as AppContext,
167
+ undefined as unknown as PubSubEngine,
168
+ );
169
+
170
+ expect(results.map((r) => r.Success)).toEqual([true, true]);
171
+ expect(captured.params?.Aggregates).toEqual([{ expression: 'COUNT_BIG(*)' }]);
172
+ });
173
+ });