@memberjunction/server 6.1.5 → 6.1.6

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 (189) hide show
  1. package/dist/.tsbuildinfo +1 -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 +129 -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/exampleNewUserSubClass.js +2 -1
  19. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  20. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.js +6 -3
  22. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  23. package/dist/auth/newUsers.d.ts.map +1 -1
  24. package/dist/auth/newUsers.js +15 -11
  25. package/dist/auth/newUsers.js.map +1 -1
  26. package/dist/context.d.ts +6 -0
  27. package/dist/context.d.ts.map +1 -1
  28. package/dist/context.js +13 -11
  29. package/dist/context.js.map +1 -1
  30. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  31. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  32. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  33. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  34. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  35. package/dist/generic/CacheInvalidationResolver.js +59 -19
  36. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  37. package/dist/generic/ResolverBase.d.ts +95 -11
  38. package/dist/generic/ResolverBase.d.ts.map +1 -1
  39. package/dist/generic/ResolverBase.js +186 -76
  40. package/dist/generic/ResolverBase.js.map +1 -1
  41. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  42. package/dist/generic/RunViewResolver.js +12 -0
  43. package/dist/generic/RunViewResolver.js.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +8 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/isolatedProvider.d.ts +20 -0
  49. package/dist/isolatedProvider.d.ts.map +1 -0
  50. package/dist/isolatedProvider.js +22 -0
  51. package/dist/isolatedProvider.js.map +1 -0
  52. package/dist/multiTenancy/index.d.ts.map +1 -1
  53. package/dist/multiTenancy/index.js +22 -2
  54. package/dist/multiTenancy/index.js.map +1 -1
  55. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  56. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  57. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  58. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  59. package/dist/resolvers/ActionResolver.d.ts +31 -5
  60. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  61. package/dist/resolvers/ActionResolver.js +75 -14
  62. package/dist/resolvers/ActionResolver.js.map +1 -1
  63. package/dist/resolvers/AdhocQueryResolver.d.ts +28 -0
  64. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  65. package/dist/resolvers/AdhocQueryResolver.js +46 -0
  66. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  67. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  68. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  69. package/dist/resolvers/DatasetResolver.js +57 -8
  70. package/dist/resolvers/DatasetResolver.js.map +1 -1
  71. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  72. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  73. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  74. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  75. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  76. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  77. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  78. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  79. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  80. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  81. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  82. package/dist/resolvers/GetDataResolver.js +13 -1
  83. package/dist/resolvers/GetDataResolver.js.map +1 -1
  84. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  85. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  86. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  87. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +39 -16
  88. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  89. package/dist/resolvers/RealtimeClientSessionResolver.js +84 -38
  90. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  91. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  92. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  93. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  94. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  95. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  96. package/dist/resolvers/RunTemplateResolver.js +12 -11
  97. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  98. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  99. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  100. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  101. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  102. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  103. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  104. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  105. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  106. package/dist/resolvers/SyncDataResolver.js +6 -2
  107. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  108. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  109. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  110. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  111. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  112. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  113. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  114. package/dist/rest/ViewOperationsHandler.js +29 -17
  115. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  116. package/dist/services/TaskGraphActionRunner.d.ts +8 -0
  117. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  118. package/dist/services/TaskGraphActionRunner.js +27 -1
  119. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  120. package/package.json +92 -92
  121. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  122. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +4 -2
  123. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  124. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  125. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  126. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  127. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  128. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  129. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  130. package/src/__tests__/RealtimeClientSessionResolver.test.ts +106 -41
  131. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  132. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  133. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  134. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  135. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  136. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  137. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  138. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  139. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  140. package/src/__tests__/SessionManager.test.ts +94 -17
  141. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  142. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
  143. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  144. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  145. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  146. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  147. package/src/__tests__/actionAuthorization.test.ts +130 -0
  148. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  149. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  150. package/src/__tests__/magicLinkService.consume.test.ts +6 -0
  151. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  152. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  153. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  154. package/src/__tests__/multiTenancy.test.ts +1 -1
  155. package/src/__tests__/newUsers.test.ts +62 -5
  156. package/src/agentSessions/SessionManager.ts +23 -25
  157. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  158. package/src/agentSessions/index.ts +1 -0
  159. package/src/auth/actionAuthorization.ts +119 -0
  160. package/src/auth/exampleNewUserSubClass.ts +2 -2
  161. package/src/auth/magicLink/MagicLinkService.ts +6 -3
  162. package/src/auth/newUsers.ts +15 -11
  163. package/src/context.ts +14 -10
  164. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  165. package/src/generic/CacheInvalidationResolver.ts +74 -19
  166. package/src/generic/ResolverBase.ts +207 -80
  167. package/src/generic/RunViewResolver.ts +17 -0
  168. package/src/index.ts +9 -1
  169. package/src/isolatedProvider.ts +22 -0
  170. package/src/multiTenancy/index.ts +24 -2
  171. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  172. package/src/resolvers/ActionResolver.ts +84 -21
  173. package/src/resolvers/AdhocQueryResolver.ts +49 -1
  174. package/src/resolvers/DatasetResolver.ts +70 -10
  175. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  176. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  177. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  178. package/src/resolvers/GetDataResolver.ts +14 -1
  179. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  180. package/src/resolvers/RealtimeClientSessionResolver.ts +103 -42
  181. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  182. package/src/resolvers/RunTemplateResolver.ts +13 -12
  183. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  184. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  185. package/src/resolvers/SyncDataResolver.ts +6 -2
  186. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  187. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  188. package/src/rest/ViewOperationsHandler.ts +37 -18
  189. package/src/services/TaskGraphActionRunner.ts +29 -1
@@ -0,0 +1,227 @@
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 applies neither the
17
+ * row-level filter nor the denied-field projection RunView does.
18
+ */
19
+
20
+ type EntityShape = {
21
+ Name: string;
22
+ SchemaName: string;
23
+ BaseView: string;
24
+ canRead?: boolean;
25
+ rowFilter?: string;
26
+ deniedFields?: 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
+ GetEffectiveRowFilterWhereClause: () => e.rowFilter ?? '',
38
+ GetDeniedReadFields: () => new Set(e.deniedFields ?? []),
39
+ })),
40
+ } as unknown as IMetadataProvider;
41
+ }
42
+
43
+ const USER = { ID: 'U1', Email: 'u@example.com' } as unknown as UserInfo;
44
+
45
+ class Probe extends ResolverBase {
46
+ public Screen(sql: string, provider: IMetadataProvider, user: UserInfo | undefined = USER) {
47
+ return this.assertFullQueryUsesReadableEntityViews(sql, provider, user, 'ad-hoc SQL');
48
+ }
49
+
50
+ /** Renders the SQL the way `ExecuteAdhocQuery` does, then screens the rendered text. */
51
+ public ScreenRendered(sql: string, platform: DatabasePlatform, provider: IMetadataProvider) {
52
+ const rendered = RenderPipeline.Run(sql, { Platform: platform, ContextUser: USER, RequireReadStatement: true });
53
+ return this.Screen(rendered.Trace.AfterTemplates, provider);
54
+ }
55
+ }
56
+
57
+ const PUBLIC_FOO: EntityShape = { Name: 'Public Foo', SchemaName: 'public', BaseView: 'vwFoo' };
58
+
59
+ describe('assertFullQueryUsesReadableEntityViews', () => {
60
+ it('admits an unscoped readable base view', () => {
61
+ expect(() => new Probe().Screen('SELECT ID FROM [public].[vwFoo]', fakeProvider([PUBLIC_FOO]))).not.toThrow();
62
+ });
63
+
64
+ it('does not resolve a schema-qualified reference through another schema', () => {
65
+ expect(() => new Probe().Screen('SELECT ID FROM secret.vwFoo', fakeProvider([PUBLIC_FOO]))).toThrow(
66
+ /entity base view, not 'secret.vwFoo'/,
67
+ );
68
+ });
69
+
70
+ it('refuses an unqualified view name that exists in more than one schema', () => {
71
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Other Foo', SchemaName: 'other', BaseView: 'vwFoo' }]);
72
+ expect(() => new Probe().Screen('SELECT ID FROM vwFoo', provider)).toThrow(/more than one schema/);
73
+ expect(() => new Probe().Screen('SELECT ID FROM other.vwFoo', provider)).not.toThrow();
74
+ });
75
+
76
+ it('refuses an entity the caller cannot read', () => {
77
+ expect(() => new Probe().Screen('SELECT ID FROM public.vwFoo', fakeProvider([{ ...PUBLIC_FOO, canRead: false }]))).toThrow(
78
+ /read permission/,
79
+ );
80
+ });
81
+
82
+ it('refuses an entity that is row-level-security filtered for the caller', () => {
83
+ const provider = fakeProvider([{ ...PUBLIC_FOO, rowFilter: "(UserID = 'U1')" }]);
84
+ expect(() => new Probe().Screen('SELECT ID, UserID FROM public.vwFoo', provider)).toThrow(/row-level-security/);
85
+ });
86
+
87
+ it('refuses an entity with a field the caller is denied', () => {
88
+ const provider = fakeProvider([{ ...PUBLIC_FOO, deniedFields: ['secret'] }]);
89
+ expect(() => new Probe().Screen('SELECT ID FROM public.vwFoo', provider)).toThrow(/not permitted to read/);
90
+ });
91
+
92
+ it('applies the scope check to every reference, including joins and subqueries', () => {
93
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
94
+ expect(() =>
95
+ new Probe().Screen('SELECT f.ID FROM public.vwFoo f WHERE f.ID IN (SELECT FooID FROM public.vwBar)', provider),
96
+ ).toThrow(/'Bar' is row-level-security filtered/);
97
+ });
98
+
99
+ it('applies the scope check to a CASE subquery in the select list', () => {
100
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
101
+ expect(() =>
102
+ new Probe().Screen('SELECT CASE WHEN (SELECT COUNT(*) FROM public.vwBar) > 0 THEN 1 ELSE 0 END AS x FROM public.vwFoo', provider),
103
+ ).toThrow(/'Bar' is row-level-security filtered/);
104
+ });
105
+
106
+ it('applies the scope check to every FROM item when an alias repeats', () => {
107
+ const provider = fakeProvider([PUBLIC_FOO, { Name: 'Bar', SchemaName: 'public', BaseView: 'vwBar', rowFilter: '(1=0)' }]);
108
+ expect(() =>
109
+ new Probe().Screen('SELECT a.ID FROM public.vwBar a WHERE a.ID IN (SELECT a.ID FROM public.vwFoo a)', provider),
110
+ ).toThrow(/'Bar' is row-level-security filtered/);
111
+ });
112
+
113
+ it('exempts CTE names the statement defines', () => {
114
+ expect(() =>
115
+ new Probe().Screen('WITH x AS (SELECT ID FROM public.vwFoo) SELECT ID FROM x', fakeProvider([PUBLIC_FOO])),
116
+ ).not.toThrow();
117
+ });
118
+ });
119
+
120
+ describe('assertFullQueryUsesReadableEntityViews — CTE names', () => {
121
+ // The parser renames bracket identifiers it cannot read and maps the new names back. The CTE
122
+ // exemption must only cover names the statement declares, so a name of the renamed form,
123
+ // quoted or bare, is refused. Orders is row-filtered for the caller; Accounts is not.
124
+ const ACCOUNTS: EntityShape = { Name: 'Accounts', SchemaName: 'dbo', BaseView: 'vwAccounts' };
125
+ const ORDERS: EntityShape = { Name: 'Orders', SchemaName: 'dbo', BaseView: 'vwOrders', rowFilter: "(OwnerID = 'U1')" };
126
+ const DIALECTS: Array<{ Name: string; Platform: DatabasePlatform; Quote: (name: string) => string }> = [
127
+ { Name: 'SQL Server', Platform: 'sqlserver', Quote: (name) => `[${name}]` },
128
+ { Name: 'PostgreSQL', Platform: 'postgresql', Quote: (name) => `"${name}"` },
129
+ ];
130
+
131
+ for (const { Name, Platform, Quote } of DIALECTS) {
132
+ const provider = fakeProvider([ACCOUNTS, ORDERS], Platform);
133
+
134
+ it(`refuses a CTE name reserved for the SQL parser, before reading any view (${Name})`, () => {
135
+ const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x), _mjid_1 AS (SELECT 1 AS y) SELECT * FROM ${Quote('vwOrders')}`;
136
+
137
+ expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/reserved for the SQL parser/);
138
+ });
139
+
140
+ it(`refuses a reserved name in any case or quoting (${Name})`, () => {
141
+ for (const name of ['_MJID_1', Quote('_mjid_1'), '_mjid2_0']) {
142
+ const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x), ${name} AS (SELECT 1 AS y) SELECT * FROM ${Quote('vwAccounts')}`;
143
+ expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/reserved for the SQL parser/);
144
+ }
145
+ });
146
+
147
+ it(`still exempts an ordinary quoted CTE name and screens its body (${Name})`, () => {
148
+ const cte = Quote('Recent Accounts');
149
+ const reads = (view: string) => `WITH ${cte} AS (SELECT ID FROM ${Quote(view)}) SELECT ID FROM ${cte}`;
150
+
151
+ expect(() => new Probe().ScreenRendered(reads('vwAccounts'), Platform, provider)).not.toThrow();
152
+ expect(() => new Probe().ScreenRendered(reads('vwOrders'), Platform, provider)).toThrow(/'Orders' is row-level-security filtered/);
153
+ });
154
+
155
+ it(`still refuses a row-filtered view read next to an ordinary quoted CTE (${Name})`, () => {
156
+ const sql = `WITH ${Quote('a b')} AS (SELECT 1 AS x) SELECT * FROM ${Quote('vwOrders')}`;
157
+
158
+ expect(() => new Probe().ScreenRendered(sql, Platform, provider)).toThrow(/'Orders' is row-level-security filtered/);
159
+ });
160
+ }
161
+ });
162
+
163
+ describe('assertFullQueryUsesReadableEntityViews — the CTE exemption covers only a CTE in scope', () => {
164
+ // Orders is row-filtered for the caller; Accounts is not. Each refused statement reads the real
165
+ // Orders view at a point where no CTE of that name is in scope, so the read must be checked.
166
+ const ACCOUNTS: EntityShape = { Name: 'Accounts', SchemaName: 'dbo', BaseView: 'vwAccounts' };
167
+ const ORDERS: EntityShape = { Name: 'Orders', SchemaName: 'dbo', BaseView: 'vwOrders', rowFilter: "(OwnerID = 'U1')" };
168
+ const ROW_FILTERED = /'Orders' is row-level-security filtered/;
169
+ const providers: Record<DatabasePlatform, IMetadataProvider> = {
170
+ sqlserver: fakeProvider([ACCOUNTS, ORDERS], 'sqlserver'),
171
+ postgresql: fakeProvider([ACCOUNTS, ORDERS], 'postgresql'),
172
+ };
173
+ const screen = (sql: string, platform: DatabasePlatform) => () => new Probe().ScreenRendered(sql, platform, providers[platform]);
174
+
175
+ it('refuses a schema-qualified read of a view a CTE is named after (SQL Server dbo, PostgreSQL)', () => {
176
+ expect(screen('WITH vwOrders AS (SELECT 1 AS y) SELECT * FROM dbo.vwOrders', 'sqlserver')).toThrow(ROW_FILTERED);
177
+ expect(screen('WITH "vwOrders" AS (SELECT 1 AS y) SELECT * FROM dbo."vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
178
+ });
179
+
180
+ it('refuses a read outside the subquery or branch whose WITH declares the name (PostgreSQL)', () => {
181
+ const statements = [
182
+ 'SELECT o.* FROM (WITH "vwOrders" AS (SELECT 1 AS a) SELECT * FROM "vwOrders") s CROSS JOIN "vwOrders" o',
183
+ 'SELECT "ID" FROM "vwAccounts" WHERE EXISTS (WITH "vwOrders" AS (SELECT 1 AS a) SELECT 1 FROM "vwOrders") AND "ID" IN (SELECT "ID" FROM "vwOrders")',
184
+ 'WITH a AS (WITH "vwOrders" AS (SELECT 1 AS x) SELECT * FROM "vwOrders") SELECT * FROM a, "vwOrders"',
185
+ 'SELECT * FROM ((WITH "vwOrders" AS (SELECT 1 AS "ID") SELECT "ID" FROM "vwOrders") UNION SELECT "ID" FROM "vwOrders") u',
186
+ ];
187
+ for (const sql of statements) {
188
+ expect(screen(sql, 'postgresql')).toThrow(ROW_FILTERED);
189
+ }
190
+ });
191
+
192
+ it('refuses a read in a CTE body that does not see that CTE yet (PostgreSQL without RECURSIVE, SQL Server)', () => {
193
+ expect(screen('WITH "vwOrders" AS (SELECT * FROM "vwOrders") SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
194
+ expect(screen('WITH a AS (SELECT * FROM "vwOrders"), "vwOrders" AS (SELECT 1 AS y) SELECT * FROM a', 'postgresql')).toThrow(ROW_FILTERED);
195
+ expect(screen('WITH a AS (SELECT * FROM vwOrders), vwOrders AS (SELECT 1 AS y) SELECT * FROM a', 'sqlserver')).toThrow(ROW_FILTERED);
196
+ });
197
+
198
+ it('refuses a quoted read whose name differs from the CTE name once PostgreSQL case rules apply', () => {
199
+ expect(screen('WITH vworders AS (SELECT 1 AS y) SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
200
+ expect(screen('WITH "VWORDERS" AS (SELECT 1 AS y) SELECT * FROM "vwOrders"', 'postgresql')).toThrow(ROW_FILTERED);
201
+ });
202
+
203
+ it('still exempts ordinary CTE references (SQL Server)', () => {
204
+ const statements = [
205
+ 'WITH Recent AS (SELECT ID FROM vwAccounts) SELECT ID FROM recent',
206
+ 'WITH r AS (SELECT ID FROM vwAccounts UNION ALL SELECT ID FROM r WHERE 1 = 0) SELECT ID FROM r',
207
+ '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)',
208
+ ];
209
+ for (const sql of statements) {
210
+ expect(screen(sql, 'sqlserver')).not.toThrow();
211
+ }
212
+ });
213
+
214
+ it('still exempts ordinary CTE references (PostgreSQL)', () => {
215
+ const statements = [
216
+ 'WITH recent AS (SELECT "ID" FROM "vwAccounts") SELECT "ID" FROM Recent',
217
+ 'WITH "Recent" AS (SELECT "ID" FROM "vwAccounts") SELECT "ID" FROM "Recent"',
218
+ 'WITH RECURSIVE r AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM r WHERE n < 3) SELECT n FROM r',
219
+ 'WITH RECURSIVE a AS (SELECT * FROM b), b AS (SELECT "ID" FROM "vwAccounts") SELECT * FROM a',
220
+ 'SELECT * FROM (WITH x AS (SELECT "ID" FROM "vwAccounts") SELECT * FROM x) s',
221
+ '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)',
222
+ ];
223
+ for (const sql of statements) {
224
+ expect(screen(sql, 'postgresql')).not.toThrow();
225
+ }
226
+ });
227
+ });
@@ -0,0 +1,174 @@
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
+ it('RunViews returns a failure result, and never reaches the provider, for a stacked aggregate', async () => {
141
+ const captured: Captured = { params: null };
142
+ const provider = fakeProvider(captured);
143
+ const input = [{ EntityName: ENTITY_NAME, Aggregates: [{ expression: STACKED_AGGREGATE }] }] as Parameters<RunViewResolver['RunViews']>[0];
144
+
145
+ const results = await new RunViewResolver().RunViews(
146
+ input,
147
+ { providers: [{ type: 'Read-Only', provider }] as unknown as AppContext['providers'], userPayload: fakePayload() } as AppContext,
148
+ undefined as unknown as PubSubEngine,
149
+ );
150
+
151
+ expect(results).toHaveLength(1);
152
+ expect(results[0].Success).toBe(false);
153
+ expect(results[0].ErrorMessage).toMatch(/Invalid Aggregate: multiple statements/);
154
+ expect(captured.params).toBeNull();
155
+ });
156
+
157
+ it('RunViews does not fail the batch because one aggregate cannot be parsed', async () => {
158
+ const captured: Captured = { params: null };
159
+ const provider = fakeProvider(captured);
160
+ const input = [
161
+ { EntityName: ENTITY_NAME, Aggregates: [{ expression: 'COUNT_BIG(*)' }] },
162
+ { EntityName: ENTITY_NAME },
163
+ ] as Parameters<RunViewResolver['RunViews']>[0];
164
+
165
+ const results = await new RunViewResolver().RunViews(
166
+ input,
167
+ { providers: [{ type: 'Read-Only', provider }] as unknown as AppContext['providers'], userPayload: fakePayload() } as AppContext,
168
+ undefined as unknown as PubSubEngine,
169
+ );
170
+
171
+ expect(results.map((r) => r.Success)).toEqual([true, true]);
172
+ expect(captured.params?.Aggregates).toEqual([{ expression: 'COUNT_BIG(*)' }]);
173
+ });
174
+ });
@@ -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
+ });