@memberjunction/server 6.1.4 → 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.
- package/README.md +30 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +17 -8
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
- package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
- package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
- package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
- package/dist/agentSessions/index.d.ts +1 -0
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +1 -0
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/auth/actionAuthorization.d.ts +40 -0
- package/dist/auth/actionAuthorization.d.ts.map +1 -0
- package/dist/auth/actionAuthorization.js +94 -0
- package/dist/auth/actionAuthorization.js.map +1 -0
- package/dist/auth/exampleNewUserSubClass.js +2 -1
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +55 -38
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
- package/dist/auth/magicLink/magicLinkCore.js +20 -52
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +15 -11
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +24 -34
- package/dist/context.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +3417 -3428
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +59 -19
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +95 -11
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +186 -76
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -45
- package/dist/index.js.map +1 -1
- package/dist/isolatedProvider.d.ts +20 -0
- package/dist/isolatedProvider.d.ts.map +1 -0
- package/dist/isolatedProvider.js +22 -0
- package/dist/isolatedProvider.js.map +1 -0
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +22 -2
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts +31 -5
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +75 -14
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +92 -166
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/DatasetResolver.d.ts +24 -3
- package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
- package/dist/resolvers/DatasetResolver.js +57 -8
- package/dist/resolvers/DatasetResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +11 -48
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
- package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.js +117 -38
- package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +13 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +2 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +83 -22
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +174 -72
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +2 -1
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +12 -11
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +6 -2
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -10
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +29 -17
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +21 -8
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +43 -16
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +310 -31
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
- package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SessionManager.test.ts +94 -17
- package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +131 -0
- package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
- package/src/__tests__/magicLink.test.ts +15 -76
- package/src/__tests__/magicLinkService.consume.test.ts +230 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/newUsers.test.ts +62 -5
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/agentSessions/SessionManager.ts +23 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/exampleNewUserSubClass.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +61 -39
- package/src/auth/magicLink/magicLinkCore.ts +19 -56
- package/src/auth/newUsers.ts +15 -11
- package/src/context.ts +25 -34
- package/src/generated/graphql-schemas/__mj.ts +3417 -3428
- package/src/generic/CacheInvalidationResolver.ts +74 -19
- package/src/generic/ResolverBase.ts +207 -80
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +49 -46
- package/src/isolatedProvider.ts +22 -0
- package/src/multiTenancy/index.ts +24 -2
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
- package/src/resolvers/ActionResolver.ts +84 -21
- package/src/resolvers/AdhocQueryResolver.ts +105 -187
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +11 -52
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/QuerySystemUserResolver.ts +2 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +196 -77
- package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
- package/src/resolvers/RunAIAgentResolver.ts +4 -1
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
- package/src/resolvers/SyncDataResolver.ts +6 -2
- package/src/resolvers/VectorizeEntityResolver.ts +16 -11
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskGraphActionRunner.ts +45 -16
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
hashToken,
|
|
7
7
|
evaluateInvite,
|
|
8
8
|
buildSessionClaims,
|
|
9
|
-
|
|
9
|
+
buildConsumeInvitePostgresSQL,
|
|
10
10
|
canIssueInvites,
|
|
11
11
|
isRoleGrantable,
|
|
12
12
|
unionScopes,
|
|
@@ -168,71 +168,13 @@ describe('magic-link core', () => {
|
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
170
|
|
|
171
|
-
describe('
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
expect(sql).toContain(`UPDATE ${table} `);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('increments UseCount', () => {
|
|
180
|
-
expect(sql).toContain('UseCount = UseCount + 1');
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('stamps ConsumedAt only the first time (COALESCE preserves an existing value)', () => {
|
|
184
|
-
expect(sql).toContain('ConsumedAt = COALESCE(ConsumedAt, SYSUTCDATETIME())');
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('flips Status to Consumed exactly when the last use is taken', () => {
|
|
188
|
-
expect(sql).toContain("Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('returns the affected row via OUTPUT INTO a table var so the caller can detect a win (exactly one row)', () => {
|
|
192
|
-
// Must be OUTPUT ... INTO, not a bare OUTPUT: SQL Server forbids a bare
|
|
193
|
-
// OUTPUT clause on a table with enabled triggers, and CodeGen adds an
|
|
194
|
-
// __mj_UpdatedAt trigger to every MJ table. Regression guard for that bug.
|
|
195
|
-
expect(sql).toContain('OUTPUT INSERTED.ID INTO @consumed');
|
|
196
|
-
expect(sql).not.toMatch(/OUTPUT INSERTED\.ID(?!\s+INTO)/);
|
|
197
|
-
expect(sql).toContain('SELECT ID FROM @consumed');
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it('guards atomically on Active + not-exhausted + not-expired — this IS the single-use gate', () => {
|
|
201
|
-
const where = sql.slice(sql.indexOf('WHERE'));
|
|
202
|
-
expect(where).toContain("Status = 'Active'");
|
|
203
|
-
expect(where).toContain('UseCount < MaxUses');
|
|
204
|
-
expect(where).toContain('ExpiresAt > SYSUTCDATETIME()');
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('binds the invite ID as a parameter, never interpolated (injection-safe)', () => {
|
|
208
|
-
expect(sql).toContain('ID = @p0');
|
|
209
|
-
// The id must not be string-interpolated with quotes around a value.
|
|
210
|
-
expect(sql).not.toMatch(/ID = '/);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
it('is a self-contained batch: declare table var, update-with-output, select', () => {
|
|
214
|
-
// Intentionally a 3-statement batch (the OUTPUT-INTO requirement). The ID is
|
|
215
|
-
// still parameterized, so the batch carries no interpolated user input.
|
|
216
|
-
expect(sql).toContain('DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER)');
|
|
217
|
-
expect(sql.match(/;/g)?.length).toBe(3);
|
|
218
|
-
expect(sql.trim().endsWith(';')).toBe(true);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('rejects a non-whitelisted table identifier (defense-in-depth against injection)', () => {
|
|
222
|
-
// Only bracket-quoted [schema].[table] of word chars is accepted, even though
|
|
223
|
-
// the caller derives the table from EntityInfo and never from user input.
|
|
224
|
-
expect(() => buildConsumeInviteSQL('__mj.MagicLinkInvite')).toThrow();
|
|
225
|
-
expect(() => buildConsumeInviteSQL('[__mj].[MagicLinkInvite]; DROP TABLE x;--')).toThrow();
|
|
226
|
-
expect(() => buildConsumeInviteSQL('[__mj].[Magic Link]')).toThrow();
|
|
227
|
-
expect(() => buildConsumeInviteSQL(table)).not.toThrow();
|
|
228
|
-
});
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
describe('buildConsumeInviteSQL (postgresql dialect)', () => {
|
|
232
|
-
// PG uses `schema.table` (unquoted — PostgreSQLDataProvider.ExecuteSQL auto-quotes
|
|
233
|
-
// the PascalCase identifiers). SS uses the bracket-quoted `[schema].[table]` form.
|
|
171
|
+
describe('buildConsumeInvitePostgresSQL', () => {
|
|
172
|
+
// PostgreSQL only: SQL Server consumes through spConsumeMagicLinkInvite (#4753), but PG has no
|
|
173
|
+
// such function and its runtime roles hold table DML, so PG keeps this direct guarded UPDATE.
|
|
174
|
+
// PG uses `schema.table` unquoted — PostgreSQLDataProvider.ExecuteSQL auto-quotes the
|
|
175
|
+
// PascalCase identifiers.
|
|
234
176
|
const pgTable = '__mj.MagicLinkInvite';
|
|
235
|
-
const pgSql =
|
|
177
|
+
const pgSql = buildConsumeInvitePostgresSQL(pgTable);
|
|
236
178
|
|
|
237
179
|
it('targets the supplied unquoted schema.table', () => {
|
|
238
180
|
expect(pgSql).toContain(`UPDATE ${pgTable} `);
|
|
@@ -246,35 +188,32 @@ describe('magic-link core', () => {
|
|
|
246
188
|
|
|
247
189
|
it('binds the invite ID as PG positional param $1, not T-SQL @p0 (injection-safe)', () => {
|
|
248
190
|
expect(pgSql).toContain('ID = $1');
|
|
249
|
-
expect(pgSql).not.
|
|
250
|
-
expect(pgSql).not.toMatch(/@/); // no T-SQL @-anything survives on PG
|
|
191
|
+
expect(pgSql).not.toMatch(/@/);
|
|
251
192
|
expect(pgSql).not.toMatch(/ID = '/);
|
|
252
193
|
});
|
|
253
194
|
|
|
254
195
|
it('uses PG timestamp expressions, not T-SQL SYSUTCDATETIME()', () => {
|
|
255
196
|
expect(pgSql).not.toContain('SYSUTCDATETIME');
|
|
256
|
-
expect(pgSql).toContain("(now() AT TIME ZONE 'utc')");
|
|
257
197
|
expect(pgSql).toContain("ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc'))");
|
|
258
198
|
});
|
|
259
199
|
|
|
260
|
-
it('guards atomically on Active + not-exhausted + not-expired (same predicate as
|
|
200
|
+
it('guards atomically on Active + not-exhausted + not-expired (same predicate as spConsumeMagicLinkInvite)', () => {
|
|
261
201
|
const where = pgSql.slice(pgSql.indexOf('WHERE'));
|
|
262
202
|
expect(where).toContain("Status = 'Active'");
|
|
263
203
|
expect(where).toContain('UseCount < MaxUses');
|
|
264
204
|
expect(where).toContain("ExpiresAt > (now() AT TIME ZONE 'utc')");
|
|
265
205
|
});
|
|
266
206
|
|
|
267
|
-
it('increments UseCount and flips Status on the last use (same semantics as
|
|
207
|
+
it('increments UseCount and flips Status on the last use (same semantics as spConsumeMagicLinkInvite)', () => {
|
|
268
208
|
expect(pgSql).toContain('UseCount = UseCount + 1');
|
|
269
209
|
expect(pgSql).toContain("Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END");
|
|
270
210
|
});
|
|
271
211
|
|
|
272
|
-
it('rejects a non-whitelisted
|
|
273
|
-
|
|
274
|
-
expect(() =>
|
|
275
|
-
expect(() =>
|
|
276
|
-
expect(() =>
|
|
277
|
-
expect(() => buildConsumeInviteSQL(pgTable, 'postgresql')).not.toThrow();
|
|
212
|
+
it('rejects a non-whitelisted table identifier (defense-in-depth against injection)', () => {
|
|
213
|
+
expect(() => buildConsumeInvitePostgresSQL('[__mj].[MagicLinkInvite]')).toThrow();
|
|
214
|
+
expect(() => buildConsumeInvitePostgresSQL('__mj.MagicLinkInvite; DROP TABLE x;--')).toThrow();
|
|
215
|
+
expect(() => buildConsumeInvitePostgresSQL('__mj.Magic Link')).toThrow();
|
|
216
|
+
expect(() => buildConsumeInvitePostgresSQL(pgTable)).not.toThrow();
|
|
278
217
|
});
|
|
279
218
|
});
|
|
280
219
|
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// MagicLinkService is the imperative shell — importing it pulls the full
|
|
4
|
+
// data-provider and communication stacks in at module load. Stub those out;
|
|
5
|
+
// @memberjunction/core stays real (Metadata, RunView, LogError).
|
|
6
|
+
vi.mock('@memberjunction/generic-database-provider', () => ({
|
|
7
|
+
UserCache: { Instance: { Users: [] } },
|
|
8
|
+
}));
|
|
9
|
+
vi.mock('@memberjunction/communication-engine', () => ({
|
|
10
|
+
CommunicationEngine: { Instance: {} },
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@memberjunction/communication-types', () => ({
|
|
13
|
+
Message: class {},
|
|
14
|
+
}));
|
|
15
|
+
vi.mock('../config.js', () => ({
|
|
16
|
+
configInfo: {},
|
|
17
|
+
}));
|
|
18
|
+
// Provisioning is stubbed in this suite; keep the real provider stack out of the module graph.
|
|
19
|
+
vi.mock('../isolatedProvider.js', () => ({
|
|
20
|
+
CreateIsolatedProvider: async () => {
|
|
21
|
+
throw new Error('provisioning is stubbed in this suite');
|
|
22
|
+
},
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { Metadata, RunView, type DatabaseProviderBase, type IMetadataProvider, type UserInfo } from '@memberjunction/core';
|
|
26
|
+
import type { MJMagicLinkInviteEntity } from '@memberjunction/core-entities';
|
|
27
|
+
import type { MagicLinkConfig } from '../config.js';
|
|
28
|
+
import { MagicLinkService } from '../auth/magicLink/MagicLinkService.js';
|
|
29
|
+
import { MAGIC_LINK_TOKEN_PREFIX, buildConsumeInvitePostgresSQL } from '../auth/magicLink/magicLinkCore.js';
|
|
30
|
+
|
|
31
|
+
type Platform = 'sqlserver' | 'postgresql';
|
|
32
|
+
|
|
33
|
+
/** Provider surface consumeInvite/RedeemInvite touch, with real-shaped dialect behavior. */
|
|
34
|
+
interface FakeProvider {
|
|
35
|
+
PlatformKey: Platform;
|
|
36
|
+
MJCoreSchemaName: string;
|
|
37
|
+
Roles: { ID: string; Name: string }[];
|
|
38
|
+
Applications: { ID: string; Name: string; Path: string }[];
|
|
39
|
+
Dialect: { ProcedureCallSyntax: (schema: string, name: string, params: string[]) => string };
|
|
40
|
+
BuildParameterPlaceholder: (index: number) => string;
|
|
41
|
+
ExecuteSQL: Mock;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeProvider(platform: Platform): FakeProvider {
|
|
45
|
+
const isPg = platform === 'postgresql';
|
|
46
|
+
return {
|
|
47
|
+
PlatformKey: platform,
|
|
48
|
+
MJCoreSchemaName: '__mj',
|
|
49
|
+
Roles: [{ ID: 'role-1', Name: 'Magic Link Baseline' }],
|
|
50
|
+
Applications: [],
|
|
51
|
+
Dialect: {
|
|
52
|
+
ProcedureCallSyntax: isPg
|
|
53
|
+
? (s, n, p) => `SELECT * FROM ${s}."${n}"(${p.join(', ')})`
|
|
54
|
+
: (s, n, p) => `EXEC [${s}].[${n}] ${p.join(', ')}`,
|
|
55
|
+
},
|
|
56
|
+
BuildParameterPlaceholder: isPg ? (i) => `$${i + 1}` : (i) => `@p${i}`,
|
|
57
|
+
ExecuteSQL: vi.fn(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const INVITE_ID = '11111111-2222-3333-4444-555555555555';
|
|
62
|
+
|
|
63
|
+
function makeInvite(): MJMagicLinkInviteEntity {
|
|
64
|
+
return {
|
|
65
|
+
ID: INVITE_ID,
|
|
66
|
+
Status: 'Active',
|
|
67
|
+
UseCount: 0,
|
|
68
|
+
MaxUses: 1,
|
|
69
|
+
ExpiresAt: new Date(Date.now() + 3600_000),
|
|
70
|
+
RoleID: 'role-1',
|
|
71
|
+
CreatedByUserID: 'inviter-1',
|
|
72
|
+
ApplicationID: 'app-1',
|
|
73
|
+
Email: 'g@example.com',
|
|
74
|
+
} as unknown as MJMagicLinkInviteEntity;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Private surface of MagicLinkService the tests drive or stub. */
|
|
78
|
+
interface ServicePrivates {
|
|
79
|
+
consumeInvite: (invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo) => Promise<string>;
|
|
80
|
+
resolveProvisioningContextUser: () => UserInfo | null;
|
|
81
|
+
isInviterActive: (id: string) => boolean;
|
|
82
|
+
recordRedemption: () => Promise<void>;
|
|
83
|
+
provisionUser: () => Promise<{ success: boolean; error?: string }>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe('MagicLinkService.consumeInvite (#4753)', () => {
|
|
87
|
+
let service: MagicLinkService;
|
|
88
|
+
let privates: ServicePrivates;
|
|
89
|
+
let invite: MJMagicLinkInviteEntity;
|
|
90
|
+
const contextUser = { ID: 'ctx-user' } as UserInfo;
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
service = new MagicLinkService('https://mj.example.com', {} as MagicLinkConfig);
|
|
94
|
+
privates = service as unknown as ServicePrivates;
|
|
95
|
+
invite = makeInvite();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
Metadata.Provider = null as unknown as IMetadataProvider;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
async function consume(provider: FakeProvider): Promise<string> {
|
|
103
|
+
return privates.consumeInvite(invite, provider as unknown as DatabaseProviderBase, contextUser);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
it('SQL Server: calls spConsumeMagicLinkInvite with the ID bound by name, never interpolated', async () => {
|
|
107
|
+
const provider = makeProvider('sqlserver');
|
|
108
|
+
provider.ExecuteSQL.mockResolvedValue([{ ID: INVITE_ID }]);
|
|
109
|
+
await consume(provider);
|
|
110
|
+
|
|
111
|
+
expect(provider.ExecuteSQL).toHaveBeenCalledTimes(1);
|
|
112
|
+
const [sql, params, options, user] = provider.ExecuteSQL.mock.calls[0];
|
|
113
|
+
expect(sql).toBe('EXEC [__mj].[spConsumeMagicLinkInvite] @ID=@p0');
|
|
114
|
+
expect(params).toEqual([INVITE_ID]);
|
|
115
|
+
expect(options).toEqual(expect.objectContaining({ isMutation: true }));
|
|
116
|
+
expect(user).toBe(contextUser);
|
|
117
|
+
expect(sql).not.toContain(INVITE_ID);
|
|
118
|
+
expect(String(sql).toUpperCase()).not.toContain('UPDATE');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// PG has no spConsumeMagicLinkInvite function (the release-time converter does not emit
|
|
122
|
+
// procedures) and PG's runtime roles hold table DML, so PG keeps the direct guarded UPDATE.
|
|
123
|
+
it('PostgreSQL: runs the guarded UPDATE … RETURNING directly, with the ID bound as $1', async () => {
|
|
124
|
+
const provider = makeProvider('postgresql');
|
|
125
|
+
provider.ExecuteSQL.mockResolvedValue([{ ID: INVITE_ID }]);
|
|
126
|
+
await consume(provider);
|
|
127
|
+
|
|
128
|
+
expect(provider.ExecuteSQL).toHaveBeenCalledTimes(1);
|
|
129
|
+
const [sql, params, options, user] = provider.ExecuteSQL.mock.calls[0];
|
|
130
|
+
expect(sql).toBe(buildConsumeInvitePostgresSQL('__mj.MagicLinkInvite'));
|
|
131
|
+
expect(sql).not.toContain('spConsumeMagicLinkInvite');
|
|
132
|
+
expect(params).toEqual([INVITE_ID]);
|
|
133
|
+
expect(options).toEqual(expect.objectContaining({ isMutation: true }));
|
|
134
|
+
expect(user).toBe(contextUser);
|
|
135
|
+
expect(sql).not.toContain(INVITE_ID);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("PostgreSQL: maps one row to 'won', zero rows to 'lost', and a thrown error to 'failed'", async () => {
|
|
139
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
140
|
+
const provider = makeProvider('postgresql');
|
|
141
|
+
provider.ExecuteSQL.mockResolvedValueOnce([{ ID: INVITE_ID }]).mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('connection reset'));
|
|
142
|
+
|
|
143
|
+
expect(await consume(provider)).toBe('won');
|
|
144
|
+
expect(await consume(provider)).toBe('lost');
|
|
145
|
+
expect(await consume(provider)).toBe('failed');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns 'won' when exactly one row comes back", async () => {
|
|
149
|
+
const provider = makeProvider('sqlserver');
|
|
150
|
+
provider.ExecuteSQL.mockResolvedValue([{ ID: INVITE_ID }]);
|
|
151
|
+
expect(await consume(provider)).toBe('won');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns 'lost' when zero rows come back", async () => {
|
|
155
|
+
const provider = makeProvider('sqlserver');
|
|
156
|
+
provider.ExecuteSQL.mockResolvedValue([]);
|
|
157
|
+
expect(await consume(provider)).toBe('lost');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns 'failed' and logs the invite ID when ExecuteSQL throws", async () => {
|
|
161
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
162
|
+
const provider = makeProvider('sqlserver');
|
|
163
|
+
provider.ExecuteSQL.mockRejectedValue(new Error("The EXECUTE permission was denied on the object 'spConsumeMagicLinkInvite'"));
|
|
164
|
+
|
|
165
|
+
expect(await consume(provider)).toBe('failed');
|
|
166
|
+
const logged = errorSpy.mock.calls.map((call) => call.map(String).join(' '));
|
|
167
|
+
expect(logged.some((line) => line.includes(INVITE_ID) && line.includes('spConsumeMagicLinkInvite'))).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("returns 'failed' when ExecuteSQL returns a non-array", async () => {
|
|
171
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
172
|
+
const provider = makeProvider('sqlserver');
|
|
173
|
+
provider.ExecuteSQL.mockResolvedValue(undefined);
|
|
174
|
+
|
|
175
|
+
expect(await consume(provider)).toBe('failed');
|
|
176
|
+
const logged = errorSpy.mock.calls.map((call) => call.map(String).join(' '));
|
|
177
|
+
expect(logged.some((line) => line.includes(INVITE_ID))).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('MagicLinkService.RedeemInvite consume outcome mapping (#4753)', () => {
|
|
182
|
+
let service: MagicLinkService;
|
|
183
|
+
let provider: FakeProvider;
|
|
184
|
+
let provisionUser: Mock;
|
|
185
|
+
const contextUser = { ID: 'ctx-user' } as UserInfo;
|
|
186
|
+
const rawToken = MAGIC_LINK_TOKEN_PREFIX + 'x';
|
|
187
|
+
|
|
188
|
+
beforeEach(() => {
|
|
189
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
190
|
+
provider = makeProvider('sqlserver');
|
|
191
|
+
Metadata.Provider = provider as unknown as IMetadataProvider;
|
|
192
|
+
|
|
193
|
+
service = new MagicLinkService('https://mj.example.com', {} as MagicLinkConfig);
|
|
194
|
+
const privates = service as unknown as ServicePrivates;
|
|
195
|
+
vi.spyOn(RunView.prototype, 'RunView').mockResolvedValue({ Success: true, Results: [makeInvite()] } as Awaited<ReturnType<RunView['RunView']>>);
|
|
196
|
+
vi.spyOn(privates, 'resolveProvisioningContextUser').mockReturnValue(contextUser);
|
|
197
|
+
vi.spyOn(privates, 'isInviterActive').mockReturnValue(true);
|
|
198
|
+
vi.spyOn(privates, 'recordRedemption').mockResolvedValue(undefined);
|
|
199
|
+
provisionUser = vi.fn().mockResolvedValue({ success: false, error: 'stop-here' });
|
|
200
|
+
privates.provisionUser = provisionUser;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
afterEach(() => {
|
|
204
|
+
Metadata.Provider = null as unknown as IMetadataProvider;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('RedeemInvite maps a failed consume to server_error, not consumed, and never provisions', async () => {
|
|
208
|
+
provider.ExecuteSQL.mockRejectedValue(new Error("The EXECUTE permission was denied on the object 'spConsumeMagicLinkInvite'"));
|
|
209
|
+
const result = await service.RedeemInvite(rawToken);
|
|
210
|
+
|
|
211
|
+
expect(result).toEqual(expect.objectContaining({ success: false, errorCode: 'server_error' }));
|
|
212
|
+
expect(provisionUser).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('RedeemInvite still reports consumed when the race is lost', async () => {
|
|
216
|
+
provider.ExecuteSQL.mockResolvedValue([]);
|
|
217
|
+
const result = await service.RedeemInvite(rawToken);
|
|
218
|
+
|
|
219
|
+
expect(result).toEqual(expect.objectContaining({ success: false, errorCode: 'consumed' }));
|
|
220
|
+
expect(provisionUser).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('RedeemInvite proceeds to provisioning when the consume wins', async () => {
|
|
224
|
+
provider.ExecuteSQL.mockResolvedValue([{ ID: INVITE_ID }]);
|
|
225
|
+
const result = await service.RedeemInvite(rawToken);
|
|
226
|
+
|
|
227
|
+
expect(provisionUser).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(result).toEqual(expect.objectContaining({ success: false, errorCode: 'provisioning_failed' }));
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -16,6 +16,10 @@ vi.mock('@memberjunction/communication-types', () => ({
|
|
|
16
16
|
vi.mock('../config.js', () => ({
|
|
17
17
|
configInfo: {},
|
|
18
18
|
}));
|
|
19
|
+
const isolated = vi.hoisted(() => ({ provider: null as unknown }));
|
|
20
|
+
vi.mock('../isolatedProvider.js', () => ({
|
|
21
|
+
CreateIsolatedProvider: async () => isolated.provider,
|
|
22
|
+
}));
|
|
19
23
|
|
|
20
24
|
import { Metadata, type IMetadataProvider, type RoleInfo, type UserInfo } from '@memberjunction/core';
|
|
21
25
|
import type { MJMagicLinkInviteEntity } from '@memberjunction/core-entities';
|
|
@@ -62,6 +66,7 @@ describe('MagicLinkService provisioning log policy', () => {
|
|
|
62
66
|
RollbackTransaction: async () => undefined,
|
|
63
67
|
GetEntityObject: async (entityName: string) => makeMockEntity(`mock-${entityName}`),
|
|
64
68
|
};
|
|
69
|
+
isolated.provider = provider;
|
|
65
70
|
Metadata.Provider = provider as unknown as IMetadataProvider;
|
|
66
71
|
|
|
67
72
|
service = new MagicLinkService('https://mj.example.com', {} as MagicLinkConfig);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant PreRunView hook checks the caller's ExtraFilter and builds
|
|
3
|
+
* `(<ExtraFilter>) AND (<tenant predicate>)`. On PostgreSQL the provider then rewrites the whole
|
|
4
|
+
* filter (identifier quoting, T-SQL date functions, boolean literals), and that rewrite can change
|
|
5
|
+
* the caller's part after the hook checked it. These tests run the real hook, the real PostgreSQL
|
|
6
|
+
* rewrite and the real view query builder, with only ExecuteSQL recorded, and pin that the tenant
|
|
7
|
+
* predicate stays a separate AND term whatever the rewrite does to the caller's term. The last
|
|
8
|
+
* block runs the same path with no hook and pins that the per-term rewrite keeps a SQL Server
|
|
9
|
+
* bracket name whole.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
ClearAllDataHooks,
|
|
14
|
+
Metadata,
|
|
15
|
+
RegisterDataHook,
|
|
16
|
+
UserInfo,
|
|
17
|
+
type EntityInfo,
|
|
18
|
+
type IMetadataProvider,
|
|
19
|
+
} from '@memberjunction/core';
|
|
20
|
+
import { PostgreSQLDataProvider } from '@memberjunction/postgresql-dataprovider';
|
|
21
|
+
import type { SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
22
|
+
import { IsBalancedSQLFragment, SplitTopLevelAndTerms } from '@memberjunction/sql-parser';
|
|
23
|
+
import { createTenantPreRunViewHook } from '../multiTenancy/index.js';
|
|
24
|
+
import type { MultiTenancyConfig } from '../config.js';
|
|
25
|
+
|
|
26
|
+
const TENANT_TERM = `(("OrganizationID" = 't1'))`;
|
|
27
|
+
|
|
28
|
+
/** The real PostgreSQL provider over one entity fixture, with every executed statement recorded. */
|
|
29
|
+
class RecordingPostgreSQLProvider extends PostgreSQLDataProvider {
|
|
30
|
+
public readonly ExecutedSQL: string[] = [];
|
|
31
|
+
|
|
32
|
+
constructor(private readonly testEntities: EntityInfo[]) {
|
|
33
|
+
super();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public override get Entities(): EntityInfo[] {
|
|
37
|
+
return this.testEntities;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public override async ExecuteSQL<T>(sql: string): Promise<T[]> {
|
|
41
|
+
this.ExecutedSQL.push(sql);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A tenant-scoped entity with a boolean field, which the PostgreSQL rewrite coerces. */
|
|
47
|
+
function customersEntity(): EntityInfo {
|
|
48
|
+
const field = (name: string, tsType: string) => ({ Name: name, CodeName: name, TSType: tsType, IsVirtual: false });
|
|
49
|
+
const fields = [field('ID', 'string'), field('Name', 'string'), field('OrganizationID', 'string'), field('IsActive', 'boolean')];
|
|
50
|
+
return {
|
|
51
|
+
ID: 'ENT-CUSTOMERS',
|
|
52
|
+
Name: 'Customers',
|
|
53
|
+
SchemaName: 'app',
|
|
54
|
+
BaseView: 'vwCustomers',
|
|
55
|
+
Fields: fields,
|
|
56
|
+
PrimaryKeys: [fields[0]],
|
|
57
|
+
FirstPrimaryKey: fields[0],
|
|
58
|
+
FieldByName: (n: string) => fields.find((f) => f.Name.toLowerCase() === n.trim().toLowerCase()),
|
|
59
|
+
DatetimeFields: [],
|
|
60
|
+
ExternalDataSourceID: null,
|
|
61
|
+
UserViewMaxRows: 0,
|
|
62
|
+
EnableFieldLevelSecurity: false,
|
|
63
|
+
AuditViewRuns: false,
|
|
64
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
65
|
+
GetEffectiveRowFilterWhereClause: () => '',
|
|
66
|
+
} as unknown as EntityInfo;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tenantConfig(): MultiTenancyConfig {
|
|
70
|
+
return {
|
|
71
|
+
enabled: true,
|
|
72
|
+
contextSource: 'header',
|
|
73
|
+
tenantHeader: 'x-tenant-id',
|
|
74
|
+
scopingStrategy: 'allowlist',
|
|
75
|
+
scopedEntities: ['Customers'],
|
|
76
|
+
autoExcludeCoreEntities: true,
|
|
77
|
+
defaultTenantColumn: 'OrganizationID',
|
|
78
|
+
entityColumnMappings: {},
|
|
79
|
+
adminRoles: ['Admin'],
|
|
80
|
+
writeProtection: 'strict',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The WHERE clause of the one statement the provider ran. */
|
|
85
|
+
function whereOf(provider: RecordingPostgreSQLProvider): string {
|
|
86
|
+
expect(provider.ExecutedSQL).toHaveLength(1);
|
|
87
|
+
const sql = provider.ExecutedSQL[0];
|
|
88
|
+
return sql.substring(sql.indexOf(' WHERE ') + ' WHERE '.length);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether `term` is one of the top-level AND terms inside the one pair of parentheses around
|
|
93
|
+
* `where`. Only then does it narrow every row the query returns.
|
|
94
|
+
*/
|
|
95
|
+
function isTopLevelAndTerm(where: string, term: string, dialect: SQLParserDialect): boolean {
|
|
96
|
+
const inner = where.slice(1, -1);
|
|
97
|
+
const wrapped = where.startsWith('(') && where.endsWith(')') && IsBalancedSQLFragment(inner, dialect).IsBalanced;
|
|
98
|
+
return wrapped && (SplitTopLevelAndTerms(inner, dialect) ?? []).some((t) => t.trim() === term);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('Multi-tenancy on PostgreSQL — the provider rewrite cannot reach the tenant predicate', () => {
|
|
102
|
+
let provider: RecordingPostgreSQLProvider;
|
|
103
|
+
let savedProvider: IMetadataProvider;
|
|
104
|
+
let user: UserInfo;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
provider = new RecordingPostgreSQLProvider([customersEntity()]);
|
|
108
|
+
savedProvider = Metadata.Provider;
|
|
109
|
+
Metadata.Provider = provider;
|
|
110
|
+
ClearAllDataHooks();
|
|
111
|
+
RegisterDataHook('PreRunView', createTenantPreRunViewHook(tenantConfig()));
|
|
112
|
+
user = new UserInfo(provider, { ID: 'U1', Email: 'u1@example.com' });
|
|
113
|
+
user.TenantContext = { TenantID: 't1', Source: 'header' };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
ClearAllDataHooks();
|
|
118
|
+
Metadata.Provider = savedProvider;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps the tenant predicate a separate AND term when the rewrite changes the caller term', async () => {
|
|
122
|
+
// The rewrite turns the caller's quoted text into boolean literals and changes where its
|
|
123
|
+
// parentheses close. Each term is rewritten and wrapped on its own, so that change stays
|
|
124
|
+
// inside the caller's term.
|
|
125
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) AND ("IsActive" = 1'` }, user);
|
|
126
|
+
|
|
127
|
+
expect(result.Success).toBe(true);
|
|
128
|
+
expect(whereOf(provider)).toBe(`((("IsActive" = TRUE) AND ("IsActive" = TRUE)) AND ${TENANT_TERM})`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('keeps the tenant predicate its own AND term after a quote the boolean rewrite drops, followed by a top-level OR', async () => {
|
|
132
|
+
// The rewrite drops the quote, so text the hook read inside a string literal becomes code
|
|
133
|
+
// with a top-level OR. The tenant predicate must still narrow every row.
|
|
134
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) OR (1=1) OR ("IsActive" = 1'` }, user);
|
|
135
|
+
|
|
136
|
+
expect(result.Success).toBe(true);
|
|
137
|
+
const where = whereOf(provider);
|
|
138
|
+
expect(isTopLevelAndTerm(where, TENANT_TERM, provider.Dialect), `tenant predicate is not a top-level AND term of ${where}`).toBe(true);
|
|
139
|
+
expect(where).toBe(`((("IsActive" = TRUE) OR (1=1) OR ("IsActive" = TRUE)) AND ${TENANT_TERM})`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('refuses a caller term that the rewrite leaves unbalanced, and runs no SQL', async () => {
|
|
143
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) AND (1 = 1'` }, user);
|
|
144
|
+
|
|
145
|
+
expect(provider.ExecutedSQL).toEqual([]);
|
|
146
|
+
expect(result.Success).toBe(false);
|
|
147
|
+
expect(result.ErrorMessage).toMatch(/ExtraFilter/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('leaves an ordinary filter and the tenant predicate as written, each in its own term', async () => {
|
|
151
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = 1 OR "Name" = 'a'` }, user);
|
|
152
|
+
|
|
153
|
+
expect(result.Success).toBe(true);
|
|
154
|
+
expect(whereOf(provider)).toBe(`((("IsActive" = TRUE OR "Name" = 'a')) AND ${TENANT_TERM})`);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('PostgreSQL ExtraFilter rewrite with no PreRunView hook', () => {
|
|
159
|
+
let provider: RecordingPostgreSQLProvider;
|
|
160
|
+
let savedProvider: IMetadataProvider;
|
|
161
|
+
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
provider = new RecordingPostgreSQLProvider([customersEntity()]);
|
|
164
|
+
savedProvider = Metadata.Provider;
|
|
165
|
+
Metadata.Provider = provider;
|
|
166
|
+
ClearAllDataHooks();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
Metadata.Provider = savedProvider;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('keeps a SQL Server bracket name that contains AND whole when it rewrites each AND term', async () => {
|
|
174
|
+
const user = new UserInfo(provider, { ID: 'U1', Email: 'u1@example.com' });
|
|
175
|
+
|
|
176
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: '[Profit and Loss] > 0 AND [IsActive] = 1' }, user);
|
|
177
|
+
|
|
178
|
+
expect(result.Success).toBe(true);
|
|
179
|
+
expect(whereOf(provider)).toBe('(("Profit and Loss" > 0) AND ("IsActive" = TRUE))');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -518,6 +518,57 @@ describe('Multi-Tenancy Security Edge Cases', () => {
|
|
|
518
518
|
});
|
|
519
519
|
});
|
|
520
520
|
|
|
521
|
+
// ─── Client ExtraFilter containment ───────────────────────────────────
|
|
522
|
+
|
|
523
|
+
describe('client ExtraFilter must stay inside its parentheses', () => {
|
|
524
|
+
// The hook builds `(<ExtraFilter>) AND (<tenant>)`. A filter that closes a parenthesis it did
|
|
525
|
+
// not open turns that into `(1=1) OR (1=1) AND (<tenant>)`: AND binds tighter than OR, so the
|
|
526
|
+
// tenant predicate would narrow only one side of an OR the caller controls.
|
|
527
|
+
const escapes = [
|
|
528
|
+
'1=1) OR (1=1',
|
|
529
|
+
"Name = 'a') OR (Name = 'b'",
|
|
530
|
+
'1=1)) OR ((1=1',
|
|
531
|
+
];
|
|
532
|
+
|
|
533
|
+
it.each(escapes)('refuses the ExtraFilter %j instead of ANDing the tenant predicate onto it', (extraFilter) => {
|
|
534
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
535
|
+
const params = { EntityName: 'Customers', ExtraFilter: extraFilter } as RunViewParams;
|
|
536
|
+
|
|
537
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/parenthesis/);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('refuses an ExtraFilter that ends inside an unterminated string literal', () => {
|
|
541
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
542
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a" } as RunViewParams;
|
|
543
|
+
|
|
544
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/unterminated/);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('refuses an ExtraFilter that contains a comment', () => {
|
|
548
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
549
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a' -- note\n" } as RunViewParams;
|
|
550
|
+
|
|
551
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/comment/);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('applies the PostgreSQL rules too when the provider dialect is unknown', () => {
|
|
555
|
+
// The mocked Metadata.Provider here is not a database provider, so the hook reads the filter
|
|
556
|
+
// with every supported dialect.
|
|
557
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
558
|
+
const params = { EntityName: 'Customers', ExtraFilter: 'Name = $$x$$' } as RunViewParams;
|
|
559
|
+
|
|
560
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/dollar/);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('keeps a balanced ExtraFilter and the tenant predicate as separate parenthesized terms', () => {
|
|
564
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
565
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a) OR (b' OR Name = 'c'" } as RunViewParams;
|
|
566
|
+
|
|
567
|
+
const result = hook(params, makeUser('tenant-abc')) as RunViewParams;
|
|
568
|
+
expect(result.ExtraFilter).toBe("(Name = 'a) OR (b' OR Name = 'c') AND ([OrganizationID] = 'tenant-abc')");
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
521
572
|
// ─── PreSave security edge cases ──────────────────────────────────────
|
|
522
573
|
|
|
523
574
|
describe('PreSave security', () => {
|
|
@@ -87,7 +87,7 @@ describe('Multi-Tenancy Hooks', () => {
|
|
|
87
87
|
|
|
88
88
|
const result = hook(params, user);
|
|
89
89
|
expect((result as RunViewParams).ExtraFilter).toBe(
|
|
90
|
-
"(Status = 'Active') AND [OrganizationID] = 'tenant-abc'"
|
|
90
|
+
"(Status = 'Active') AND ([OrganizationID] = 'tenant-abc')"
|
|
91
91
|
);
|
|
92
92
|
});
|
|
93
93
|
|