@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,155 @@
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 { DatabaseProviderBase, IMetadataProvider, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
6
+ import type { PubSubEngine } from 'type-graphql';
7
+ import { ResolverBase } from '../generic/ResolverBase.js';
8
+ import type { RunDynamicViewInput } from '../generic/RunViewResolver.js';
9
+ import type { UserPayload } from '../types.js';
10
+
11
+ /**
12
+ * The provider splices a client ExtraFilter as `(<ExtraFilter>) AND (<row-level security>)` and the
13
+ * tenant hook as `(<ExtraFilter>) AND (<tenant>)`. A fragment that closes a parenthesis it did not
14
+ * open, such as `1=1) OR (1=1`, turns that into `(1=1) OR (1=1) AND (<rls>)`. AND binds tighter than
15
+ * OR, so the server filter then narrows only one side of an OR the caller controls.
16
+ *
17
+ * The boundary screen parsed each fragment inside the parentheses it adds itself, which hides exactly
18
+ * that imbalance. These tests pin that the screen requires every fragment to be balanced on its own.
19
+ */
20
+
21
+ const ENTITY_NAME = 'MJ: Conversations';
22
+
23
+ /** Captures what the resolver asked `RunView` for. */
24
+ type Captured = { params: RunViewParams | null };
25
+
26
+ function fakeProvider(captured: Captured): DatabaseProviderBase {
27
+ return {
28
+ Entities: [
29
+ {
30
+ ID: 'E1',
31
+ Name: ENTITY_NAME,
32
+ SchemaName: '__mj',
33
+ BaseView: 'vwConversations',
34
+ BaseTable: 'Conversation',
35
+ Fields: [{ Name: 'Name', NeedsQuotes: true }],
36
+ GetUserPermisions: () => ({ CanRead: true }),
37
+ },
38
+ ],
39
+ RunView: async (params: RunViewParams): Promise<RunViewResult> => {
40
+ captured.params = params;
41
+ return { Success: true, Results: [], RowCount: 0, TotalRowCount: 0, ErrorMessage: '' } as RunViewResult;
42
+ },
43
+ } as unknown as DatabaseProviderBase;
44
+ }
45
+
46
+ const fakeUser = () => ({ Email: 'tester@example.com' } as UserInfo);
47
+ const fakePayload = () => ({ email: 'tester@example.com', userRecord: fakeUser() } as UserPayload);
48
+
49
+ /** Reaches the protected boundary screen and the dynamic-view entry point. */
50
+ class Probe extends ResolverBase {
51
+ public ScreenClause(clause: string, label: string, provider: DatabaseProviderBase): void {
52
+ this.assertClientClauseUsesEntityBaseViews(clause, label, provider as unknown as IMetadataProvider);
53
+ }
54
+
55
+ public RunDynamic(input: RunDynamicViewInput, provider: DatabaseProviderBase) {
56
+ return this.RunDynamicViewGeneric(input, provider, fakePayload(), undefined as unknown as PubSubEngine);
57
+ }
58
+ }
59
+
60
+ describe('ResolverBase boundary screen — a client filter must be one balanced expression', () => {
61
+ const provider = () => fakeProvider({ params: null });
62
+
63
+ // Each of these closes the parentheses the provider wraps the fragment in. All of them parse
64
+ // once the screen adds its own `(` … `)`, which is why the screen must check them standalone.
65
+ const escapes = [
66
+ '1=1) OR (1=1',
67
+ "Name = 'a') OR (Name = 'b'",
68
+ "[Name] = 'a') OR ([Name] = 'b'",
69
+ '(1=1)) OR ((1=1)',
70
+ ];
71
+
72
+ it.each(escapes)('rejects the ExtraFilter %j', (clause) => {
73
+ expect(() => new Probe().ScreenClause(clause, 'ExtraFilter', provider())).toThrow(/parenthesis/);
74
+ });
75
+
76
+ it.each(escapes)('rejects the OverrideExcludeFilter %j', (clause) => {
77
+ expect(() => new Probe().ScreenClause(clause, 'OverrideExcludeFilter', provider())).toThrow(/parenthesis/);
78
+ });
79
+
80
+ it('rejects a fragment that leaves a parenthesis open', () => {
81
+ expect(() => new Probe().ScreenClause('(1=1', 'ExtraFilter', provider())).toThrow();
82
+ });
83
+
84
+ it('allows a balanced filter, including parentheses inside string literals', () => {
85
+ const p = new Probe();
86
+ const md = provider();
87
+ expect(() => p.ScreenClause("(Name = 'a' OR Name = 'b') AND (Name <> 'c')", 'ExtraFilter', md)).not.toThrow();
88
+ expect(() => p.ScreenClause("Name = 'a) OR (b'", 'ExtraFilter', md)).not.toThrow();
89
+ expect(() => p.ScreenClause("Name LIKE '%(%'", 'OverrideExcludeFilter', md)).not.toThrow();
90
+ });
91
+
92
+ it('RunDynamicViewGeneric never reaches RunView with an ExtraFilter that closes the wrapper', async () => {
93
+ const captured: Captured = { params: null };
94
+ const input = { EntityName: ENTITY_NAME, ExtraFilter: '1=1) OR (1=1' } as RunDynamicViewInput;
95
+
96
+ await expect(new Probe().RunDynamic(input, fakeProvider(captured))).rejects.toThrow(/parenthesis/);
97
+ expect(captured.params).toBeNull();
98
+ });
99
+
100
+ it('RunDynamicViewGeneric passes a balanced ExtraFilter through unchanged', async () => {
101
+ const captured: Captured = { params: null };
102
+ const input = { EntityName: ENTITY_NAME, ExtraFilter: "Name = 'a' OR Name = 'b'" } as RunDynamicViewInput;
103
+
104
+ await new Probe().RunDynamic(input, fakeProvider(captured));
105
+
106
+ expect(captured.params?.ExtraFilter).toBe("Name = 'a' OR Name = 'b'");
107
+ });
108
+
109
+ it('rejects a comment', () => {
110
+ expect(() => new Probe().ScreenClause("Name = 'a' -- note\n", 'ExtraFilter', provider())).toThrow(/comment/);
111
+ expect(() => new Probe().ScreenClause("Name = 'a' /* note */", 'OrderBy', provider())).toThrow(/comment/);
112
+ });
113
+ });
114
+
115
+ describe('ResolverBase boundary screen — backslashes inside quotes', () => {
116
+ // The screen's parser reads a backslash before a quote inside a plain literal as an escaped
117
+ // quote; SQL Server and PostgreSQL read the quote as the end of the literal. Text after it can
118
+ // then be code the engine runs while the parser saw a string, so it escapes the screen's checks.
119
+ const provider = () => fakeProvider({ params: null });
120
+
121
+ it('rejects a filter whose hidden part the parser reads as a string constant', () => {
122
+ const hidesSelect = "Name = 'a\\' AND 1 = (SELECT 1) AND Name = ' + [b' + [c]";
123
+ expect(() => new Probe().ScreenClause(hidesSelect, 'ExtraFilter', provider())).toThrow(/backslash/);
124
+ expect(() => new Probe().ScreenClause(hidesSelect, 'OverrideExcludeFilter', provider())).toThrow(/backslash/);
125
+ });
126
+
127
+ it.each(["Path = 'C:\\temp'", '[a\\b] = 1', '"a\\b" = 1'])('rejects a backslash inside a quoted string or identifier (%#)', (clause) => {
128
+ expect(() => new Probe().ScreenClause(clause, 'ExtraFilter', provider())).toThrow(/backslash/);
129
+ });
130
+
131
+ it('allows a filter with no backslash inside quotes', () => {
132
+ expect(() => new Probe().ScreenClause("Path LIKE 'C:/temp%'", 'ExtraFilter', provider())).not.toThrow();
133
+ });
134
+ });
135
+
136
+ /** Named so the screen reads clauses with the PostgreSQL dialect, as it does for the real provider. */
137
+ class PostgreSQLDataProviderFake {
138
+ public Entities = [];
139
+ }
140
+
141
+ describe('ResolverBase boundary screen — PostgreSQL', () => {
142
+ const pgProvider = () => new PostgreSQLDataProviderFake() as unknown as DatabaseProviderBase;
143
+
144
+ it.each([
145
+ ['Name = $$x$$', /dollar/],
146
+ ['Café = 1', /outside ASCII/],
147
+ ["Name = E'x'\n'y'", /escape string/],
148
+ ])('rejects what the lexer cannot read the way PostgreSQL does (%#)', (clause, reason) => {
149
+ expect(() => new Probe().ScreenClause(clause, 'ExtraFilter', pgProvider())).toThrow(reason);
150
+ });
151
+
152
+ it('allows text outside ASCII inside a string literal', () => {
153
+ expect(() => new Probe().ScreenClause("Name = 'Café'", 'ExtraFilter', pgProvider())).not.toThrow();
154
+ });
155
+ });
@@ -0,0 +1,116 @@
1
+ // ResolverBase pulls in type-graphql decorators, which need the Reflect.metadata polyfill first.
2
+ import 'reflect-metadata';
3
+ import { describe, it, expect } from 'vitest';
4
+ import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
5
+ import type { PubSubEngine } from 'type-graphql';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
7
+ import type { RunDynamicViewInput } from '../generic/RunViewResolver.js';
8
+ import type { UserPayload } from '../types.js';
9
+
10
+ /**
11
+ * `RunDynamicView` is the GraphQL entry point for a client RunView over one entity. A subquery in
12
+ * its ExtraFilter reads an entity's base view directly, so RunView applies neither that entity's
13
+ * row-level security nor its denied-field projection to the subquery. These tests pin that the
14
+ * boundary screen refuses such a subquery when the caller's read of that entity is narrowed (the
15
+ * rule the ad-hoc SQL screen applies) and still passes it when the read is not. Inputs are harmless.
16
+ */
17
+
18
+ /** How the caller's read of an entity is narrowed: a row filter, or none (lts/5 has no field-level security). */
19
+ interface ReadScope {
20
+ RowFilter?: string;
21
+ }
22
+
23
+ /** What the resolver handed to RunView; null when the request never got there. */
24
+ type Captured = { params: RunViewParams | null };
25
+
26
+ const USER = { ID: 'U1', Email: 'reader@example.com' } as UserInfo;
27
+ const OWN_ROWS = "(OwnerID = 'U1')";
28
+ const ORDERS_SUBQUERY = `ID IN (SELECT AccountID FROM [crm].[vwOrders] WHERE [Total] > 100)`;
29
+
30
+ /** Provider double: two entities the caller can read, each under `scopes[Name]`, and a RunView that records its params. */
31
+ function fakeProvider(captured: Captured, scopes: Record<string, ReadScope> = {}): DatabaseProviderBase {
32
+ const entity = (id: string, name: string, baseView: string) => ({
33
+ ID: id,
34
+ Name: name,
35
+ SchemaName: 'crm',
36
+ BaseView: baseView,
37
+ GetUserPermisions: () => ({ CanRead: true }),
38
+ GetUserRowLevelSecurityWhereClause: () => scopes[name]?.RowFilter ?? '',
39
+ });
40
+ return {
41
+ Entities: [entity('E1', 'Accounts', 'vwAccounts'), entity('E2', 'Orders', 'vwOrders')],
42
+ RunView: async (params: RunViewParams): Promise<RunViewResult> => {
43
+ captured.params = params;
44
+ return { Success: true, Results: [], RowCount: 0, TotalRowCount: 0, ErrorMessage: '' } as unknown as RunViewResult;
45
+ },
46
+ } as unknown as DatabaseProviderBase;
47
+ }
48
+
49
+ /** Runs `RunDynamicViewGeneric` over Accounts as USER. */
50
+ class Probe extends ResolverBase {
51
+ public RunDynamic(input: Partial<RunDynamicViewInput>, provider: DatabaseProviderBase) {
52
+ const payload = { email: USER.Email, userRecord: USER } as UserPayload;
53
+ const view = { EntityName: 'Accounts', ...input } as RunDynamicViewInput;
54
+ return this.RunDynamicViewGeneric(view, provider, payload, undefined as unknown as PubSubEngine);
55
+ }
56
+ }
57
+
58
+ describe('RunDynamicViewGeneric — a client subquery reads an entity only when the caller may read it unscoped', () => {
59
+ it('refuses a subquery over an entity that is row-level-security filtered for the caller, before RunView', async () => {
60
+ const captured: Captured = { params: null };
61
+
62
+ await expect(
63
+ new Probe().RunDynamic({ ExtraFilter: ORDERS_SUBQUERY }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
64
+ ).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
65
+ expect(captured.params).toBeNull();
66
+ });
67
+
68
+ it('passes the same subquery through when the caller reads that entity unscoped', async () => {
69
+ const captured: Captured = { params: null };
70
+
71
+ await new Probe().RunDynamic({ ExtraFilter: ORDERS_SUBQUERY }, fakeProvider(captured));
72
+
73
+ expect(captured.params?.ExtraFilter).toBe(ORDERS_SUBQUERY);
74
+ });
75
+
76
+ it("refuses a subquery over the queried entity's own base view when the caller's read of it is row-filtered", async () => {
77
+ const captured: Captured = { params: null };
78
+ const ownView = `ID IN (SELECT ID FROM [crm].[vwAccounts] WHERE [Name] LIKE 'A%')`;
79
+
80
+ await expect(
81
+ new Probe().RunDynamic({ ExtraFilter: ownView }, fakeProvider(captured, { Accounts: { RowFilter: OWN_ROWS } })),
82
+ ).rejects.toThrow(/'Accounts' is row-level-security filtered for you/);
83
+ expect(captured.params).toBeNull();
84
+ });
85
+
86
+ it('refuses a CASE-WHEN subquery over a row-filtered entity, before RunView', async () => {
87
+ const captured: Captured = { params: null };
88
+ const caseWhen = `CASE WHEN (SELECT COUNT(*) FROM [crm].[vwOrders] WHERE [Total] > 100) > 0 THEN 1 ELSE 0 END = 1`;
89
+
90
+ await expect(
91
+ new Probe().RunDynamic({ ExtraFilter: caseWhen }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
92
+ ).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
93
+ expect(captured.params).toBeNull();
94
+ });
95
+
96
+ it("refuses a subquery over a row-filtered entity in a window's ORDER BY, before RunView", async () => {
97
+ const captured: Captured = { params: null };
98
+ const window = `ID IN (SELECT ID FROM (SELECT ID, ROW_NUMBER() OVER (ORDER BY (SELECT COUNT(*) FROM [crm].[vwOrders])) AS rn FROM [crm].[vwAccounts]) x)`;
99
+
100
+ await expect(
101
+ new Probe().RunDynamic({ ExtraFilter: window }, fakeProvider(captured, { Orders: { RowFilter: OWN_ROWS } })),
102
+ ).rejects.toThrow(/'Orders' is row-level-security filtered for you/);
103
+ expect(captured.params).toBeNull();
104
+ });
105
+
106
+ it('still passes an ordinary filter and sort on an entity the caller reads under a row filter', async () => {
107
+ const captured: Captured = { params: null };
108
+
109
+ await new Probe().RunDynamic(
110
+ { ExtraFilter: `[Name] = 'Acme'`, OrderBy: '[Name] DESC' },
111
+ fakeProvider(captured, { Accounts: { RowFilter: OWN_ROWS } }),
112
+ );
113
+
114
+ expect(captured.params?.ExtraFilter).toBe(`[Name] = 'Acme'`);
115
+ });
116
+ });
@@ -0,0 +1,351 @@
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 { DatabaseProviderBase, IMetadataProvider, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
6
+ import { ResolverBase } from '../generic/ResolverBase.js';
7
+ import type { UserPayload } from '../types.js';
8
+ import type { RunDynamicViewInput, RunViewByNameInput } from '../generic/RunViewResolver.js';
9
+ import type { PubSubEngine } from 'type-graphql';
10
+
11
+ /**
12
+ * The GraphQL-boundary screen for client-supplied view clauses (ExtraFilter, OrderBy,
13
+ * OverrideExcludeFilter): a subquery may only read an entity base view, and only one the
14
+ * acting user can read. UserSearchString is free text and is not screened as a clause.
15
+ *
16
+ * Also pins #4037's escaping of `ResolverBase`'s own filter builders (`findBy` and the
17
+ * `RunViewByNameGeneric` name lookup), including the fail-closed rule on the unquoted
18
+ * (numeric/boolean) slot. The RunViews structured-error test on next covers a fix that is
19
+ * not on this line, so it is not here.
20
+ */
21
+
22
+ const ENTITY_NAME = 'MJ: Users';
23
+
24
+ /** Captures what the resolver asked `RunView` for. */
25
+ type Captured = { params: RunViewParams | null };
26
+
27
+ /**
28
+ * Minimal provider: the entity/field metadata `findBy` reads, plus a `RunView` that records
29
+ * its params. `Sequence` stands in for a numeric field (`NeedsQuotes` is false only for
30
+ * Number and Boolean TS types).
31
+ */
32
+ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = [], unreadableEntities: string[] = []): DatabaseProviderBase {
33
+ const withPerms = (e: Record<string, unknown>) => ({
34
+ ...e,
35
+ // Real EntityInfo aggregates per-user permissions; the boundary screen consults CanRead,
36
+ // the row filter and the denied fields for every subquery target, so the mock answers
37
+ // them (an unscoped read).
38
+ GetUserPermisions: () => ({ CanRead: !unreadableEntities.includes(e.Name as string) }),
39
+ GetUserRowLevelSecurityWhereClause: () => '',
40
+ });
41
+ return {
42
+ Entities: [
43
+ {
44
+ ID: 'E1',
45
+ Name: ENTITY_NAME,
46
+ SchemaName: '__mj',
47
+ BaseView: 'vwUsers',
48
+ BaseTable: 'User',
49
+ Fields: [
50
+ { Name: 'Email', NeedsQuotes: true },
51
+ { Name: 'Name', NeedsQuotes: true },
52
+ { Name: 'Sequence', NeedsQuotes: false },
53
+ { Name: 'IsActive', NeedsQuotes: false },
54
+ ],
55
+ },
56
+ {
57
+ Name: 'MJ: User Views',
58
+ SchemaName: '__mj',
59
+ BaseView: 'vwUserViews',
60
+ Fields: [{ Name: 'Name', NeedsQuotes: true }],
61
+ },
62
+ {
63
+ Name: 'MJ_BizApps_Tasks: Task Assignments',
64
+ SchemaName: '__mj_BizAppsTasks',
65
+ BaseView: 'vwTaskAssignments',
66
+ BaseTable: 'TaskAssignment',
67
+ Fields: [{ Name: 'TaskID', NeedsQuotes: true }],
68
+ },
69
+ { Name: 'MJ_BizApps_Tasks: Tasks', SchemaName: '__mj_BizAppsTasks', BaseView: 'vwTasks', BaseTable: 'Task', Fields: [] },
70
+ { Name: 'Committees: Meetings', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMeetings', BaseTable: 'Meeting', Fields: [] },
71
+ { Name: 'Committees: Motions', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMotions', BaseTable: 'Motion', Fields: [] },
72
+ { Name: 'Committees: Memberships', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwMemberships', BaseTable: 'Membership', Fields: [] },
73
+ { Name: 'Committees: Terms', SchemaName: '__mj_BizAppsCommittees', BaseView: 'vwTerms', BaseTable: 'Term', Fields: [] },
74
+ ].map(withPerms),
75
+ RunView: async (params: RunViewParams): Promise<RunViewResult> => {
76
+ captured.params = params;
77
+ return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
78
+ },
79
+ } as unknown as DatabaseProviderBase;
80
+ }
81
+
82
+ const fakeUser = () => ({ Email: 'tester@example.com' } as UserInfo);
83
+ const fakePayload = () => ({ email: 'tester@example.com', userRecord: fakeUser() } as UserPayload);
84
+
85
+ /** Reaches the protected `findBy` and the client-subquery screen. */
86
+ class Probe extends ResolverBase {
87
+ public FindBy(provider: DatabaseProviderBase, entity: string, params: Record<string, unknown>) {
88
+ return this.findBy(provider, entity, params, fakeUser());
89
+ }
90
+
91
+ public ScreenClause(clause: string | undefined | null, label: string, provider: DatabaseProviderBase) {
92
+ return this.assertClientClauseUsesEntityBaseViews(clause, label, provider as unknown as IMetadataProvider);
93
+ }
94
+
95
+ public RunDynamic(input: RunDynamicViewInput, provider: DatabaseProviderBase) {
96
+ return this.RunDynamicViewGeneric(input, provider, fakePayload(), undefined as unknown as PubSubEngine);
97
+ }
98
+
99
+ /** The whole boundary screen, as the RunView* entry points call it. */
100
+ public ScreenAll(
101
+ clauses: { extraFilter?: string | null; orderBy?: string | null; userSearchString?: string | null; overrideExcludeFilter?: string | null },
102
+ provider: DatabaseProviderBase,
103
+ ) {
104
+ return this.screenClientViewClauses(clauses, provider as unknown as IMetadataProvider);
105
+ }
106
+ }
107
+
108
+ describe('ResolverBase.findBy — ExtraFilter escaping', () => {
109
+ it('doubles a single quote in a quoted value', async () => {
110
+ const captured: Captured = { params: null };
111
+ await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "o'brien@example.com" });
112
+
113
+ expect(captured.params?.ExtraFilter).toBe("Email = 'o''brien@example.com'");
114
+ });
115
+
116
+ it('renders a tautology attempt inert instead of terminating the literal', async () => {
117
+ const captured: Captured = { params: null };
118
+ // The payload closes the literal and appends its own predicate. Escaped, every quote
119
+ // it carries stays *inside* the literal, so the clause matches a (non-existent)
120
+ // address rather than every row.
121
+ await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "x' OR '1'='1" });
122
+
123
+ expect(captured.params?.ExtraFilter).toBe("Email = 'x'' OR ''1''=''1'");
124
+ // Nothing outside the literal: exactly two unescaped quotes, the delimiters.
125
+ expect(captured.params?.ExtraFilter?.replace(/''/g, '')).toBe("Email = 'x OR 1=1'");
126
+ });
127
+
128
+ it('joins multiple params with AND, escaping each', async () => {
129
+ const captured: Captured = { params: null };
130
+ await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "a'b", Name: "c'd" });
131
+
132
+ expect(captured.params?.ExtraFilter).toBe("Email = 'a''b' AND Name = 'c''d'");
133
+ });
134
+
135
+ it('leaves a genuinely numeric value unquoted', async () => {
136
+ const captured: Captured = { params: null };
137
+ await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: 42 });
138
+
139
+ expect(captured.params?.ExtraFilter).toBe('Sequence = 42');
140
+ });
141
+
142
+ it('rejects a string aimed at an unquoted (numeric) field rather than emitting it as SQL', async () => {
143
+ const captured: Captured = { params: null };
144
+ // There is no quote to escape in an unquoted slot — the value lands in the clause as
145
+ // SQL. The only safe handling is to refuse it.
146
+ await expect(
147
+ new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: '1 OR 1=1' })
148
+ ).rejects.toThrow(/Sequence/);
149
+
150
+ expect(captured.params).toBeNull(); // never reached RunView
151
+ });
152
+
153
+ it('rejects a non-finite number in an unquoted field', async () => {
154
+ const captured: Captured = { params: null };
155
+ await expect(
156
+ new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: Number.NaN })
157
+ ).rejects.toThrow(/Sequence/);
158
+ });
159
+
160
+ it('still rejects an unknown field name', async () => {
161
+ // Pre-existing behavior, pinned so the escaping rework cannot quietly drop it.
162
+ await expect(
163
+ new Probe().FindBy(fakeProvider({ params: null }), ENTITY_NAME, { NotAField: 'x' })
164
+ ).rejects.toThrow(/NotAField/);
165
+ });
166
+ });
167
+
168
+ describe('ResolverBase — GraphQL-boundary ExtraFilter AST screen', () => {
169
+ // Keyword SELECT/EXISTS ban (#4253) broke first-party IN (SELECT … FROM base view).
170
+ // The replacement parses the fragment and allows only entity BaseViews.
171
+
172
+ const provider = () => fakeProvider({ params: null });
173
+
174
+ it('rejects an EXISTS subquery against a base table', () => {
175
+ expect(() =>
176
+ new Probe().ScreenClause(`EXISTS (SELECT 1 FROM __mj.[User] WHERE Type='Owner')`, 'ExtraFilter', provider())
177
+ ).toThrow(/entity base view/);
178
+ });
179
+
180
+ it('rejects a scalar SELECT subquery against a non-view', () => {
181
+ expect(() =>
182
+ new Probe().ScreenClause('(SELECT COUNT(*) FROM __mj.APIKey)', 'OrderBy', provider())
183
+ ).toThrow(/entity base view/);
184
+ });
185
+
186
+ it('allows IN (SELECT …) against an entity BaseView', () => {
187
+ expect(() =>
188
+ new Probe().ScreenClause(
189
+ `ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments] WHERE AssigneeRecordID = 'x')`,
190
+ 'ExtraFilter',
191
+ provider(),
192
+ )
193
+ ).not.toThrow();
194
+ });
195
+
196
+ it('allows the restored Committees ExtraFilter shapes (Command Center, workspace, tracker)', () => {
197
+ const p = new Probe();
198
+ const md = provider();
199
+ const clauses = [
200
+ `TaskID IN (SELECT ID FROM [__mj_BizAppsTasks].[vwTasks] WHERE Status IN ('Open', 'InProgress'))`,
201
+ `ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments] WHERE AssigneeRecordID = 'x')`,
202
+ `MeetingID IN (SELECT ID FROM [__mj_BizAppsCommittees].[vwMeetings] WHERE CommitteeID='x')`,
203
+ `MotionID IN (SELECT ID FROM [__mj_BizAppsCommittees].[vwMotions] WHERE MeetingID = 'x')`,
204
+ `ID IN (SELECT m.PersonID FROM [__mj_BizAppsCommittees].[vwMemberships] m JOIN [__mj_BizAppsCommittees].[vwTerms] t ON m.TermID = t.ID WHERE t.CommitteeID = 'x' AND m.Status = 'Active')`,
205
+ ];
206
+ for (const c of clauses) {
207
+ expect(() => p.ScreenClause(c, 'ExtraFilter', md)).not.toThrow();
208
+ }
209
+ });
210
+
211
+ it('allows an ordinary comparison filter', () => {
212
+ expect(() =>
213
+ new Probe().ScreenClause(`Email = 'a@b.com' AND IsActive = 1`, 'ExtraFilter', provider())
214
+ ).not.toThrow();
215
+ });
216
+
217
+ it('does not false-positive on SELECT/EXISTS inside string literals', () => {
218
+ expect(() =>
219
+ new Probe().ScreenClause(`Name LIKE '%select%' OR Name = 'exists'`, 'ExtraFilter', provider())
220
+ ).not.toThrow();
221
+ });
222
+
223
+ it('allows empty/undefined clauses', () => {
224
+ expect(() => new Probe().ScreenClause('', 'ExtraFilter', provider())).not.toThrow();
225
+ expect(() => new Probe().ScreenClause(undefined, 'OrderBy', provider())).not.toThrow();
226
+ });
227
+
228
+ it('RunDynamicViewGeneric never reaches RunView when ExtraFilter hits a base table', async () => {
229
+ const captured: Captured = { params: null };
230
+ const input = {
231
+ EntityName: ENTITY_NAME,
232
+ ExtraFilter: `EXISTS (SELECT 1 FROM __mj.[User] WHERE Type='Owner')`,
233
+ } as RunDynamicViewInput;
234
+
235
+ await expect(new Probe().RunDynamic(input, fakeProvider(captured))).rejects.toThrow(
236
+ /entity base view/
237
+ );
238
+ expect(captured.params).toBeNull();
239
+ });
240
+
241
+ it('RunDynamicViewGeneric passes a BaseView subquery through to RunView', async () => {
242
+ const captured: Captured = { params: null };
243
+ const input = {
244
+ EntityName: ENTITY_NAME,
245
+ ExtraFilter: `ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`,
246
+ } as RunDynamicViewInput;
247
+
248
+ await new Probe().RunDynamic(input, fakeProvider(captured));
249
+
250
+ expect(captured.params?.ExtraFilter).toBe(`ID IN (SELECT ID FROM [__mj].[vwUsers] WHERE Email = 'a@b.com')`);
251
+ });
252
+
253
+ it('RunDynamicViewGeneric rejects a subquery into an entity the caller cannot read', async () => {
254
+ const captured: Captured = { params: null };
255
+ const input = {
256
+ EntityName: ENTITY_NAME,
257
+ ExtraFilter: `ID IN (SELECT TaskID FROM [__mj_BizAppsTasks].[vwTaskAssignments])`,
258
+ } as RunDynamicViewInput;
259
+
260
+ await expect(
261
+ new Probe().RunDynamic(input, fakeProvider(captured, [], ['MJ_BizApps_Tasks: Task Assignments']))
262
+ ).rejects.toThrow(/read permission/);
263
+ expect(captured.params).toBeNull();
264
+ });
265
+
266
+ it('RunDynamicViewGeneric passes a benign ExtraFilter through to RunView', async () => {
267
+ const captured: Captured = { params: null };
268
+ const input = {
269
+ EntityName: ENTITY_NAME,
270
+ ExtraFilter: `Email = 'a@b.com'`,
271
+ } as RunDynamicViewInput;
272
+
273
+ await new Probe().RunDynamic(input, fakeProvider(captured));
274
+
275
+ expect(captured.params?.ExtraFilter).toBe(`Email = 'a@b.com'`);
276
+ });
277
+ });
278
+
279
+ describe('ResolverBase.screenClientViewClauses — UserSearchString is free text, not a clause (#4392)', () => {
280
+ // The base-view AST screen wraps its argument as `SELECT 1 FROM x WHERE (<clause>)` and fails
281
+ // closed when that does not parse. Applied to UserSearchString it rejected everything that
282
+ // wasn't coincidentally valid SQL — which is most of what people type into a search box.
283
+ // UserSearchString never reaches SQL as a fragment: createViewUserSearchSQL builds the
284
+ // predicate and lands the text as an escaped literal, so the screen has nothing to screen.
285
+
286
+ const provider = () => fakeProvider({ params: null });
287
+
288
+ const searchTerms = [
289
+ 'Marcus Chen', // a space — parses as nothing
290
+ "O'Leary", // unterminated literal
291
+ "Marcus O'Leary Chen", // both
292
+ 'Smith, John', // punctuation
293
+ '50% off', // LIKE metacharacter
294
+ 'a_b [c]', // more LIKE metacharacters
295
+ 'select', // a keyword as a term
296
+ 'drop table users', // keywords with spaces
297
+ 'Union Pacific', // a real company name
298
+ "x' OR '1'='1", // an injection attempt — escaped as a literal downstream
299
+ ' spaced out ',
300
+ 'café ☕',
301
+ ];
302
+
303
+ it.each(searchTerms)('lets %j through the boundary screen', (term) => {
304
+ expect(() => new Probe().ScreenAll({ userSearchString: term }, provider())).not.toThrow();
305
+ });
306
+
307
+ it('still screens the three real clause fragments alongside it', () => {
308
+ const p = new Probe();
309
+ const md = provider();
310
+
311
+ expect(() =>
312
+ p.ScreenAll({ userSearchString: 'Marcus Chen', extraFilter: `EXISTS (SELECT 1 FROM __mj.[User])` }, md)
313
+ ).toThrow(/entity base view/);
314
+
315
+ expect(() =>
316
+ p.ScreenAll({ userSearchString: 'Marcus Chen', orderBy: '(SELECT COUNT(*) FROM __mj.APIKey)' }, md)
317
+ ).toThrow(/entity base view/);
318
+
319
+ expect(() =>
320
+ p.ScreenAll({ userSearchString: 'Marcus Chen', overrideExcludeFilter: `EXISTS (SELECT 1 FROM __mj.[User])` }, md)
321
+ ).toThrow(/entity base view/);
322
+ });
323
+
324
+ it('RunDynamicViewGeneric carries a multi-word search term through to RunView', async () => {
325
+ const captured: Captured = { params: null };
326
+ const input = { EntityName: ENTITY_NAME, UserSearchString: "Marcus O'Leary" } as RunDynamicViewInput;
327
+
328
+ await new Probe().RunDynamic(input, fakeProvider(captured));
329
+
330
+ expect(captured.params?.UserSearchString).toBe("Marcus O'Leary");
331
+ });
332
+ });
333
+
334
+ describe('ResolverBase.RunViewByNameGeneric — view-name escaping', () => {
335
+ it('escapes the client-supplied view name', async () => {
336
+ const captured: Captured = { params: null };
337
+ const input = { ViewName: "My View' OR '1'='1" } as RunViewByNameInput;
338
+
339
+ // Empty Results short-circuits the method (returns null) once the lookup filter is built.
340
+ const result = await new Probe().RunViewByNameGeneric(
341
+ input,
342
+ fakeProvider(captured),
343
+ fakePayload(),
344
+ undefined as unknown as PubSubEngine // unused: the empty-result path returns before it is passed on
345
+ );
346
+
347
+ expect(result).toBeNull();
348
+ expect(captured.params?.EntityName).toBe('MJ: User Views');
349
+ expect(captured.params?.ExtraFilter).toBe("Name='My View'' OR ''1''=''1'");
350
+ });
351
+ });